转自:https://segmentfault.com/a/1190000014572815
什么是 VDOM
首先我们都知道什么是DOM(Document Object Model),简单说就是将一个HTML文档抽象表达为树结构。VDOM则是将DOM再抽象一层生成的简化版js对象,这个对象也拥有DOM上的一些属性,比如id, class等,但它是完全脱离于浏览器而存在的。
为什么要用 VDOM
随着前端技术的发展,现在的网页应用变得越来越庞大,DOM树也随之变得复杂。当我们要修改DOM的某个元素时,我们需要先遍历找个这个元素,然后才修改能修改。而且如果我们大量地去修改DOM,每次DOM修改浏览器就得重绘(repaint)页面,有的时候甚至还得重排(reflow)页面,浏览器的重排重绘是很损耗性能的。
React是怎么用VDOM解决这个问题的呢?
- 首先,在React当我们要去修改数据的时候,我们会调用React提供的setState方法来修改数据;
React根据新的数据生成一个新的VDOM,因为VDOM本质上只是一个普通的js对象,所以这个过程是很快的; - 然后React拿着新生成VDOM和之前的VDOM进行对比(diff算法),找出不同的地方(新增,删除,修改),生成一个个的补丁(patches);
- 最后React把这些补丁一次性打到DOM上,完成视图的修改。
原理其实还是很直观的,但React到底是怎么用代码实现的呢?其中最关键的一步是React是怎么diff的?如果搞清楚了内部的实现原理,对于我们使用React来写出性能更高的代码至关重要。所以今天我要手把手教大家怎么从零开始实现VDOM。
我们的设计蓝图
我们将采用跟React类似的方式
- 使用JSX来编写组件;
- 用Babel将JSX转化为纯js(类似hyperscript);
- 将hyperscript转化成我们的VDOM;
- 将VDOM渲染到页面,形成真实的DOM;
- 手动更新数据并手动触发更新视图操作(这部分是react做的,跟VDOM的实现无关,所以我们手动模拟一下);
- 重复步骤二和步骤三,得到新的VDOM;
- diff新VDOM和旧VDOM,得到需要修改真实DOM的patches;
- 把patches一次性打到DOM上,只更新DOM上需要更改的地方。
项目结构
- index.html
- index.js(所有的逻辑都写在这个文件里)
- .babelrc(babel的配置页)
- package.json
- compiled.js(这个文件是babel打包自己生成的,不需要自己写)
详情见 Github
实现首次渲染
index.js
首先,我们用 JSX 来编写“模板”:
1 | function view() { |
接下来,我们要将 JSX 编译成 js, 也就是 hyperscript。我们先用 Babel 编译一下,看这段 JSX 转成 js 会是什么样子,打开命令行,输入 npm run compile
,得到的 compile.js:
1 | function view() { |
可以看出 h
函数接收的参数,第一个参数是 node 的类型,比如 ul,li,第二个参数是 node 的属性,之后的参数是 node 的 children,假如 child 又是一个 node 的话,就会继续调用 h
函数。
清楚了 Babel 会将我们的JSX编译成什么样子后,接下来我们就可以继续在 index.js 中来写 h
函数了。
1 | function flatten(arr) { |
我们的 h
函数主要的工作就是返回我们真正需要的 hyperscript 对象,只有三个参数,第一个参数是节点类型,第二个参数是属性对象,第三个是子节点的数组。
flatten(children)
这个操作是因为 children 这个数组里的元素有可能也是个数组,那样就成了一个二维数组,所以我们需要将数组拍平成一维数组。
我们现在可以先来看一下 h
函数最终返回的对象长什么样子。
1 | function render() { |
我们在render函数中打印出执行完 view() 的结果,再 npm run compile
后,用浏览器打开我们的 index.html,看控制台输出的结果。
可以,很完美!这个对象就是我们的 VDOM 了!
下面我们就可以根据VDOM, 来渲染真实 DOM 了。先改写 render 函数:
1 | function render(el) { |
createElement 函数生成 DOM,然后再插入到我们在 index.html 中写的根节点 app。注意 render 函数式在 index.html 中被调用的。
1 | function createElement(node) { |
我们来仔细看下 createElement 函数。假如说 node,即 VDOM 的类型是文本,我们直接返回一个创建好的文本节点。否则的话,我们取出 node 中类型,属性和子节点, 先根据类型创建相应的目标节点,然后再调用 setProps 函数依次设置好目标节点的属性,最后遍历子节点,递归调用 createElement 方法,将返回的子节点插入到刚刚创建的目标节点里。最后返回这个目标节点。
还需要注意的一点是,jsx 中 class 的写成了 className,所以我需要特殊处理一下。
大功告成,complie 后浏览器打开 index.html 看看结果吧。
实现更新渲染
改写 view()
1 | function view(count) { |
我们的 view 函数接收一个参数 count,变量 r 表示从 0 到 count-1 的一个数组。假如 count=3, r=[0, 1, 2]。ul 的 className 的值有三种可能:list-0, list-1, list-2。li 的数量取决于count。
改写 render()
1 | function render(el) { |
render 函数有两个修改,首先调用 view() 的时候传入 count=0。其次,写了一个定时器,1 秒后执行tick 函数。tick 函数接收两个参数,el 代表节点元素,count 是当前计数值。
tick 函数依次做了这几件事:
- 调用diff函数,对比新旧两个 VDOM,根据两者的不同得到需要修改的补丁
- 将补丁 patch 到真实 DOM 上
- 当计数器小于等于 5 的时候,将 count 加 1,再继续下一次 tick
- 当计数器大于 5 的时候,结束
下面我们来实现 diff 函数和 patch 函数。
常量
我们先列出来新旧两个 VDOM 对比,会有哪些不同。在 index.js 文件的最前面声明一下几个常量。
1 | const CREATE = 'CREATE' //新增一个节点 |
diff()
1 | function diff(newNode, oldNode) { |
- 假如旧节点不存在,我们返回的 patches 对象, 类型为新增节点;
- 假如新节点不存在,表示是删除节点;
- 假如两者都存在的话,调用 changed 函数判断他们是不是有变动;
- 假如两者都存在,且 changed() 返回 false 的话,判断新节点是否是 VDOM(根据 type 是否存在来判断的,因为 type 不存在的话,newNode 要么是空节点,要么是字符串)。假如新节点是 VDOM,则返回一个 patches 对象,类型是 UPDATE,同时对 props 和 children 分别进行 diffProps 和diffChildren 操作。
下面我们一次看一下 changed, diffProps, diffChildren 函数。
changed()
1 | function changed(node1, node2) { |
检查新旧 VDOM 是否有变动的方法很简单,
- 首先假如数据类型都不一样,那肯定是变动了;
- 其次假如两者的类型都是纯文本,则直接比较两者是否相等;
- 最后比较两者的类型是否相等。
diffProps()
1 | function diffProps(newNode, oldNode) { |
比较新旧 VDOM 的属性的变化,并返回相应的 patches。
- 首先我们采用最大可能性原则,将新旧 VDOM 的所有属性都合并赋值给一个新的变量 props
- 遍历 props 变量的所有 Keys,依次比较新旧 VDOM 对于这个KEY的值
- 假如新值不存在,表示这个属性被删除了
- 假如旧值不存在,或者新旧值不同,则表示我们需要重新设置这个属性
diffChildren()
1 | function diffChildren(newNode, oldNode) { |
同样采用最大可能性原则,取新旧 VDOM 的 children 的最长值作为遍历 children 的长度。然后依次比较新旧 VDOM 的在相同 INDEX 下的每一个 child。
这里需要强烈注意一下
为了简化,我们没有引入 key 的概念,直接比较的是相同 index 下的 child。所以假如说一个列表 ul 有5 项,分别是 li1, li2, li3, li4, li5; 如果我们删掉了第一项,新的变成了 li2, li3, li4, li5。那么 diffchildren 的时候,我们会拿 li1 和 li2 比较,依次类推。这样一来,本来只是删除了li1, 而 li2, li3, li4, li5 没有任何变化,我们得出的 diff 结论却是 [li替换,li2替换, li3替换, li4替换, li5删除]。所以react让大家渲染列表的时候,必须添加 Key。
截止到现在,我们已经得到了我们需要的补丁。下面我们要将补丁 Patch 到 DOM 里。
patch()
1 | function patch(parent, patches, index = 0) { |
- 首先当 patches 不存在时,直接 return,不进行任何操作
- 利用 childNodes 和 Index 取出当前正在处理的这个节点,赋值为 el
- 开始判断补丁的类型
- 当类型是 CREATE 时,生成一个新节点,并 append 到根节点
- 当类型是 REMOVE 时,直接删除当前节点 el
- 当类型是 REPLACE 时,生成新节点,同时替换掉原节点
- 当类型是 UPDATE 时,需要我们特殊处理
- 调用 patchProps 将我们之前 diffProps 得到的补丁渲染到节点上
- 遍历之前 diffChildren 得到的补丁列表,再依次递归调用 patch
最后我们再补充一下 patchProps 函数
patchProps()
1 | function patchProps(parent, patches) { |
这个就不用我解释了,代码很直观,setProp 函数在上一集我们已经定义过了。这样一来,我们就完成了整个数据更新导致 DOM 更新的完整过程。
npm run compile 后打开浏览器查看效果,你应该看到是一个背景颜色在不同变化,同时列表项在逐渐增加的列表。