写完《HTMLStringParser:自己撸一个Virtual DOM之前》之后,我第一时间整理代码,把文章转载到掘金上,满足作为宅逼程序猿的虚荣感。但写完HTMLStringPareser之后,我并不满足,既然都已经实现了把html转换为对应的js对象结构了,而且连createElement都写了,为何不更进一步,把整个Virtual DOM也给实现了呢?于是开始手撸。这一下,把自己给摔进坑里,在实现diff的时候,几乎陷入了绝境。最后,在无法实现的前提下,做了妥协,做了不严谨处理,最后才终于撸完了整个Virtual DOM,代码在这里,你可以自己慢慢拍砖。
在文章发表在掘金之后,我就开始去写代码了,但是过了两天回去看掘金的时候,有几条评论,还挺兴奋,一看,一位带着“@阿里巴巴”尾巴的资深x师竟然嘲讽到说“从HTML转vdom这个实践不行,自己写写玩玩吧”,我就怒了,这口气,太拽了吧,也不知道是不是随便说的。我写HTMLStringParser就是要解决自己抓取网页,获取某个节点数据的实际问题,写完了就解决了,怎么是玩玩呢?再说了,vue用的不也是html字符串模板么?所以,我打算写一篇文章,把自己完整撸完Virtual DOM的这个过程写下来,实现的肯定不如React,但是从整个功能而已,是完整的,说明上面这位资深的说法不对,不仅可行,而且说不定以后我还会用到自己的开发中,不是玩玩。
整体思路
我已经写关于Virtual DOM的文章好几篇了,虽然不一定对所有细节都非常专业,但是自认为整个逻辑是搞清楚了的。实现一个Virtual DOM不是把真实DOM结构转换为一个js对象就完事了,一个Virtual DOM还要包括把这个js对象渲染回真实DOM,接收数据变化,数据变化时做diff,知道哪些节点会发生变化,做patch去更新真实DOM。所以,我的整体思路,就是写一个VirtualDOM的Class,这个Class包含:
- createVritualDOM:从html字符串模板抽象出js对象即Virtual DOM
- createDOM:用Virtual DOM渲染出真正的DOM
- render:将createDOMD得到的节点挂载到真实的文档中,使文档发生变化
- update:传入新的数据,新数据会引起界面变化
- diff:传入数据之后使用createVirtualDOM得到一个新的Virtual DOM,和老的进行比较,得到哪些节点是变化了的
- patch:利用diff的结果,更新真实的DOM,使界面发生变化
- destroy:销毁对应的DOM节点,界面发生变化
就这么几个方就够了,这就是一个Virtual DOM的全部内容了。我们又不是在写component,所以React的那套生命周期我没考虑进来。因此,我们要实现的,就是上面这些内容,一个一个实现就好了。
HTML字符串模板转Virtual DOM
在写HTMLStringParser时,利用htmlparser2去解析html的原理已经说过了,如果不懂,可以在文章开头的链接中找到答案。但是,我们还要解决三个问题:1. 插值;2. 事件绑定;3. for和if。
插值
插值其实比较好解决,无非是字符串匹配替换。我采用了双大括号作为插值标记,在字符串模板的任何位置都可以使用,但是应该注意几个点:
- 插值代表变量,因此最好只用在属性值(除id和key两个属性外)和文本中
- 插值只能是字符串或数字,不能是array或object
- 插值结果是在html字符串转换为VNode之前就已经完成了,VNode中不存在插值
我们来看一段实例:
<div id="my-test" class="{{myClass}}">{{myText}}</div>
上面的{{myClass}}
和{{myText}}
在转换完的VNode结果中,是真实的值,而不是插值字符串。
插值从哪里来呢?所以,我们要在实例化Class的时候,把插值对应的真实值都传进来,所以,我规定了一个data选项,实例化的时候应该:
new VirtualDOM({ template: `<div id="my-test" class="{{myClass}}">{{myText}}</div>`, data: { myClass: 'class1 class2', myText: 'this is a text', }, })
这是初始化信息,传进来之后,后面还可以通过update方法去改数据来修改界面。而这个效果,跟Vue的感觉有点像,但是Vue是把整个component实现了,我这里只实现Virtual DOM。
那么从代码层面怎么实现插值呢?下面来看代码。
先定义一个函数,用来替换插值:
let interpose = (str, key, value) => { if (typeof str !== 'string') { return str } if (str.indexOf('{{') > -1 && str.indexOf('}}')) { let reg = new RegExp('\{\{' + key + '\}\}', 'g') str = str.replace(reg, value) } return str }
这个函数的使用方法非常简单,str就是字符串模板,key就是插值字符串,value就是插值字符串对应的值。有了这个函数之后,我们只需要遍历我们所有的插值预设项,也就是data,如果data中有对应的key,那么key就会被替换为value。
// data是我们前面提到的,传入的data数据对象,template就是字符串模板 let dataKeys = Object.keys(data) if (dataKeys.length) { dataKeys.forEach(key => { let value = data[key] template = interpose(template, key, value) }) }
这个处理在使用htmlparser2进行解析前就可以先完成。
事件绑定
插值是最好解决的,字符串替换就可以了,但是事件绑定比较难,因为事件的回调函数数据类型是函数,不能和字符串直接连接,必须采用绕一点的方案来解决这个问题。
首先是如何实现事件机制,请再翻看一下这里,把事件绑定回dom元素的时候,我们从VNode的events属性中取出事件的回调函数。现在的问题是,我们希望在html字符串模板中把事件记录进去,怎么办?我的解决方案是,使用特殊的{{:eventHandler}}
插值表达式。用一个冒号区分和普通插值的不同,有冒号的,表示事件插值,对应的是传入的events的property名:
new VirtualDOM({ template: `<a href="javascript:" onclick="{{:clickHandler}}">click</a>`, events: { clickHandler(e) { e.preventDefault() }, }, })
当这样的规则建立之后,在createVirtualDOM阶段,这个VNode的events中就会把click事件的值设置为clickHandler,而clickHandler是一个函数,可以在createElement中用来作为被绑定的回调函数。
代码层面,怎么解决{{:
的识别呢?其实很简单,我不是通过{{:
进行识别的,而是通过属性名前缀是否为on
来识别。如果是on前缀的属性,说明这是一个绑定,所以代码就如下操作:
let attrs = obj.attrs // obj实际上就是vnode let attrKeys = Object.keys(attrs) attrKeys.forEach(key => { let value = attrs[key] if (key.indexOf('on') === 0 && value.substring(0, 3) == '{{:' && value.substring(value.length - 2) == '}}') { let eventName = key.substring(2).toLowerCase() let eventCallbackName = value.substring(3, value.length - 2) obj.events[eventName] = this.events[eventCallbackName].bind(this) delete attrs[key] } }) obj.attrs = attrs
这段代码是在创建VNode的函数createVNode中的,这样的函数的结果,会正好把事件相关的回调函数都保存起来,也就是我们想象中的VNode结构。
@foreach循环
在很多模板引擎中,都有for循环,但是我觉得,作为和DOM结合紧密的Virtual DOM,更多的是从数据中去循环,也就是根据数据不同,渲染不同结果。所以,我没有实现for,而是实现了foreach。foreach的目标是object,array也是一种特殊的object,所以是通用的。foreach的实现模型如下:
foreach(object, key, value) { // ... }
传入一个object,它会把每一个key=>value在遍历中给出来。
但是要在html模板中实现也有些让人苦恼,vue的实现方式是在标签的属性里面加了一个for,但我是直接增加了一个<@foreach>标签,标签内部的东西会被循环加载到结果中。
<@foreach target="items" key="i" value="v"> <li>{{i}} {{v}}</li> </@foreach>
target对应的是data的某个属性的property name,用来获取数据,比如上面的items,实际上对应的是data.items。
代码层面怎么去实现呢?这个确实有点复杂,它涉及到克隆的问题,也就是一个节点要反复多次使用。而且,有一个难点,是htmlparser2解析的结果,会把<@foreach>作为一个正常的节点,那么我们期望的父子关系就会被这个节点打乱,为了解决这个问题,我们还要处理还原为正常的节点关系,这个也比较难。
最终我的方案,是在全部节点已经处理完之后,再重新遍历一遍所有节点,这个时候再来处理@foreach的问题:
elements.forEach(vnode => { if (vnode.name === '@foreach') { let attrs = vnode.attrs let items = data[attrs.target] let key = attrs.key let value = attrs.value let children = vnode.children let childNodes = [] if (items) { foreach(items, (i, item) => { children.forEach(child => { let node = {} foreach(child, (prop, value) => { node[prop] = value }) node.text = interpose(node.text, key, i) node.text = interpose(node.text, value, item) foreach(node.attrs, (k, v) => { node.attrs[k] = interpose(v, key, i) node.attrs[k] = interpose(v, value, item) }) node.id = node.attrs.id node.class = node.attrs.class ? node.attrs.class.split(' ') : [] childNodes.push(node) }) }) } if (childNodes.length) { let parentChildren = vnode.parent ? vnode.parent.children : elements let i = parentChildren.indexOf(vnode) parentChildren.splice(i, 1, ...childNodes) } } })
读过之前文章的同学知道,elements是包含所有html节点,扁平的数组,因此遍历它,就可以实现全部节点的处理了。
通过修改parent属性,实现了节点提升,这样就不用担心@foreach打乱原本设想的节点父子关系了。
上面有一个foreach函数,其实实现起来也超级简单,用法跟jquery的$.each一样。
@if的实现
和foreach一样,@if也是一个特殊标签,标签内部的内容根据标签condition属性值的运算结果来确定是否加入到VNode中。当然,也存在节点父子关系的问题,但是实现起来就简单很多,因为不用考虑循环克隆的问题。
else if (vnode.name === '@if') {
let attrs = vnode.attrs
let condition = attrs.condition
let children = vnode.children
let parentChildren = vnode.parent ? vnode.parent.children : elements
let i = parentChildren.indexOf(vnode)
if (eval(condition)) {
parentChildren.splice(i, 1, ...children)
}
else {
parentChildren.splice(i, 1)
}
}
红色的eval是不太安全的一种操作,但是目前因为没有仔细思考如何自己实现一个运算器,所以暂且这样做,后期可以自己实现一个condition运算器来替代这里。
小结
HTMLStringParser的实现帮助我实现了基本的Virtual DOM的创建问题,上面这四个小节则在前文的基础上,更加深入的实现了模板引擎的新功能。作为开发者,你想要完全搞定这块,读源码吧,所有的源码都在createVirtualDOM方法中。
真实DOM元素的构建和渲染
这个知识在这里已经讲过了,也是非常容易实现的一个环节。无非是document.createElement和appendChild之类的操作。最后得到一个真实的DOM元素,而且这个DOM元素还被绑定了事件(如果有的话)。
因为有了VNode这一层,从HTML模板到真实DOM之间就有了一层抽象,真实DOM依赖的是VNode的name、attrs、events这几个属性,所以完全脱离原始信息。
除了我在createElement原来的内容基础上,我还增加了一个工作,就是把一个VNode对应的element挂载到VNode的$element属性上,这样当你想操作一个VNode对应的DOM元素时,就非常简单了,因为直接通过这个$element属性,就可以找到它对应的真实DOM元素。当然,这也增加了风险,就是当你删除一个DOM元素时,应该把$element的引用也删除,不然会造成内存泄露问题。
总之,还原真实DOM的这部分内容相对来说还是比较简单的。具体的源码可以在这里阅读。而渲染到文档界面中就更简单,直接使用DOM元素的操作方法即可实现。但是在渲染之前,应该用innerHTML = ''
将内部文档置空。
用数据更新界面
这可是Virtual DOM的重头戏,在大部分文献中只会讲diff和patch,不会告诉你数据进入Virtual DOM到界面输出的过程是怎么做到的。diff和patch我会单独拿出来讲,因此,这里主要是梳理数据进入到界面输出的逻辑过程。
其实这个逻辑过程也超级简单:
- 传入新数据
- 重新获得一份新的Virtual DOM,而新的Virtual DOM是根据传入的新数据和老数据merge之后,利用插值机制得到的,因此插值不同,得到的Virtual DOM可能就不同
- 既然可能不同,那么就要找出不同的地方,利用一个diff算法,把所有不同的地方记录到一个patches的数组中
- 遍历这个patches数组,每一个元素就是我要进行的真实DOM的操作,操作完DOM之后,界面就发生了改变
在这整个逻辑过程中,一定要注意js这门语言的一些特性,特别是object是引用型数据类型的这一特性,如果不处理好,就会存在很多bug。
你可以看我写的udpate方法,超级简单的几行代码。
diff算法
重头戏中的重头戏,diff算法消耗了我整个写作最多的时间,我查阅了网上很多资料,还看了github上几个知名的Virtual DOM库的源代码,发现他们都是将diff和patch紧密的融合在一起,我希望写一个独立的diff算法的函数,这个函数得到patches,但不执行patch的任何操作。最终的代码在这里。
关于React标准的diff算法,这里就不去深究了,毕竟React的diff算法还要考虑component内嵌component的情况,而我们现在不用考虑这种情况,因为我们是html字符串模板,所以,我们只需要考虑React标准diff中三种情况中的一种即可,Text组件和Component组件我们都不考虑(虽然我们会考虑如果一个元素只包含文本的情况)。
diff原则
我们只会涉及到React标准diff中有关元素对比的部分。即主要遵循的规则如下:
- 逐层对比
- 同层的元素间对比
什么意思呢?就是在老的VNode和新的VNode中,先比较顶层的元素,顶层的元素相同,再对比下层,如果顶层的不同,直接不用比下层了。而在同一层中,会存在多个元素,怎么确定每一个位置的元素是否是相同的,或者位置发生了变化,这是一个极度困难的问题。
确定元素是否是同一个元素
在老的VNode和新的VNode中,有这么一层存在一些节点,如图:
现在我们假定要比较第二层的元素,那么我们能否确定A1和B1代表的是同一个元素呢?我们来举一个实际代码的例子:
A:<div class="class1">...</div><div class="class2">...</div><div class="class3">...</div> B:<div class="class1">...</div><div class="class2">...</div><div class="class3">...</div>
我们能确定A1和B1是同一个元素吗?其实是不能的。但是下面这种情况我们就能确定:
A:<div id="my-test" class="class1">...</div><div class="class2">...</div><div class="class3">...</div> B:<div id="my-test" class="class1">...</div><div class="class2">...</div><div class="class3">...</div>
因为在我们的文档中,我们一般会保证一个id只存在一个元素,所以两个VNode中,如果存在id相等的情况,我们基本可以确定他们是同一个元素。当然,如果把id属性用插值来表示,那就坑了。
因此,我们根本无法做到完全判断节点是否是同一个,我们只能通过比较一些基本特征来确定。当然,我们学习React中的做法,声明一个key属性,如果key属性相等,那么这两个节点肯定是指同一个元素。
在这种模糊状态下,我们采用比较理想化的解决方法:当一个VNode和另外一个VNode的name、attrs的属性名都相等时,我们认为它们是指同一个元素。
虽然从理论上讲,这是非常不严谨的,但是从实践的角度看,这样做把问题简单化。因为如果一个html的属性列表比较复杂的话,这种方法可以非常容易区分不同的元素。而如果最坏的情况出现,也就是标签没有任何属性,那么我也认为它们是同一个元素,只不过他们的children可能变了而已。
还要考虑到,如果一个元素直接包含文本,那么它和另外一个包含子节点的元素肯定是不同的元素。
因此,我写了一个函数来决定这个元素的身份:
function identify(vnode) { if (vnode.attrs.key) { return vnode.name + ':' + vnode.attrs.key } return vnode.name + ':' + Object.keys(vnode.attrs).join(',') + '|' + !!vnode.text }
当来自新老Virtual DOM的两个VNode经过计算得到相同的identity之后,就认为他们是相同的元素。
同级元素的diff
接下来,我们就要开始写同级元素的diff算法。在我们思考的过程中,应该由简入难,先把简单的情况处理掉,然后再处理复杂的情况。因此,我的做法如下:
先把新老节点列表的identity算出来:
let oldIdentifies = oldNodes.map(vnode => identify(vnode)) let newIdentifies = newNodes.map(vnode => identify(vnode))
这样可以知道,在新的DOM中,哪些老节点被删除了:
oldIdentifies.forEach((id, i) => { let oldNode = oldNodes[i] if (newIdentifies.indexOf(id) === -1) { patches.push(makePatch('remove', oldNode)) } else { ... } })
被删除的节点立即记录到了patches这个变量里面。
接下来,我们去遍历新节点列表,每遍历到一个,就用它去和老节点中(被删除后剩余的节点列表)这个位置上的节点是不是同一个节点,如果是,那么就保持不动,进入children层级的比较:
if (id === targetIndentity) { patches = patches.concat(diffSameNodes(targetNode, newNode)) }
diffSameNodes函数就是去对比相同节点,这个函数内会去对比文本、attributes属性、以及children,当进入到children之后,其实又是同一层的对比,所以是一个递归。后文会讲文本和attrs的对比。
接下来,我们要考虑一种情况,那就是遍历到的新节点的identity在老节点列表里找到了,但是不在当前对比的这个位置上,那么说明这个节点位置发生了改变。
let foundPosition = findIndentityIndex(id, finalIdentities, cursor) ... else if (foundPosition !== -1) { let oldNode = finalNodes[foundPosition] let oldIndentity = finalIdentities[foundPosition] patches.push(makePatch('move', targetNode, oldNode)) ... }
findIndentityIndex是一个用来找到老节点所在位置的函数,它内部要考虑一种情况,就是排除以前已经对比过的节点,所以第三个参数cursor就是用来记录要从哪个位置开始往后找。
而当新节点不存在与老节点列表中时,有两种情况,一种是在中间插入,一种是插入到最后:
// not exists, insert else if (i < finalIdentities.length) { patches.push(makePatch('insert', targetNode, newNode)) ... } // not exists, append else { patches.push(makePatch('append', parentNode, newNode)) ... }
这样就解决了几乎所有情况。剩下的就是实现diffSameNodes。
两个相同的元素,也可能存在变化,一种是如果它只包含文本的话,那么文本可能变,另一种是它的attrs可能变,还有一种是它的子节点可能变。所以,都要处理。文本变很容易:
if (oldNode.text !== newNode.text) { patches.push(makePatch('changeText', oldNode, newNode.text)) }
attrs变的话,要相对复杂一点,要做一个遍历:
function diffAttributes(oldNode, newNode) { let patches = [] let oldAttrs = oldNode.attrs let newAttrs = newNode.attrs let keys = Object.keys(newAttrs) if (keys.length) { keys.forEach(key => { let oldValue = oldAttrs[key] let newVaule = newAttrs[key] if (oldValue !== newVaule) { patches.push({ key, value: newVaule, }) } }) } return patches } let attrsPatches = diffAttributes(oldNode, newNode) if (attrsPatches.length) { patches = patches.push(makePatch('changeAttribute', oldNode, attrsPatches)) }
最后是children,实际上,就是进入到下一层的diff算法。直接使用diff函数去比较就可以了,就是一个递归罢了。
小结
整体的逻辑就这些,和React标准diff存在差异,性能上存在不足,在一些特殊情况下,可能一次性更新大片的DOM。但是,自己手撸能够做出来,我已经很欣慰了。当然,这里的只是思路,具体的全部代码,还是要去看GitHub,看着点列出来的代码完全没意义。
patch算法
说算法根本谈不上,patch操作其实就是从diff中拿到patches变量,然后根据变量中的元素进行遍历,对dom进行操作,仅此而已。代码在这里,你可以自己看下,真的很少,就是一个switch。
但是有一个点需要注意,就是在remove的时候,一定要将$element和$vnode的引用清除,否则会造成内存问题。
总结
整个Virtual DOM的手撸过程大致就是这样,可能有很多细节你还是不懂,你可以在本文下方留言,或者在GitHub上提issue。实际上,讲这么多,重要的还是在html字符串转VNode和diff这两部分,而且目前来看,都有可以改进,真的是短板在哪里哪里被谈的最多。
另外需要强调的是,这真的是一个Virtual DOM的独立库,它不包含生命周期函数,但是已经具备了应有的功能。作为开发者,可以用它去进行封装,实现类似React Component的部分功能,但是如何实现在template中加入component,还需要再深入思考。无论如何,自己手撸一个总能学到东西,我是实践派,不轻易在没有自己撸过的情况下说“这个实践不行”这样的话。
2017-09-17 5753 React, Virtual DOM