若无法打开文中引用链接,那么可能是你上网的姿势不对
virtual dom中心思想
如果没有理解virtual dom的构建思想,那么你可以参考这篇精致文章Boiling React Down to a Few Lines in jQuery
virtual dom优化开发的方式是:通过vnode,来实现无状态组件,结合单向数据流(undirectional data flow),进行UI更新,整体代码结构是:
var newVnode = render(vnode, state)
var oldVnode = patch(oldVnode, newVnode)
state.dispatch('change')
var newVnode = render(vnode, state)
var oldVnode = patch(oldVnode, newVnode)
...
virtual dom库选择
在众多virtual dom库中,我们选择snabbdom库,原因有很多:
- snabbdom性能排名靠前,虽然这个benchmark的参考性不高
- snabbdom示例丰富
- snabbdom具有一定的生态圈,如motorcycle.js,cycle-snabbdom,cerebral
- snabbdom实现的十分优雅,使用的是recursive方式调用patch,对比infernojs优化痕迹明显的代码,snabbdom更易读。
- 在阅读过程中发现,snabbdom的模块化,插件支持做得极佳
snabbdom的工作方式
如果不理解view层的工作原理,那么可以参考这篇文章React-less Virtual DOM with Snabbdom。接下来,我们来查看snabbdom基本使用方式。
// snabbdom在./snabbdom.js
var snabbdom = require('snabbdom')
// 初始化snabbdom,得到patch。随后,我们可以看到snabbdom设计的精妙之处
var patch = snabbdom.init([
require('snabbdom/modules/class'),
require('snabbdom/modules/props'),
require('snabbdom/modules/style'),
require('snabbdom/modules/eventlisteners')
])
// h是一个生成vnode的包装函数,factory模式?对生成vnode更精细的包装就是使用jsx
// 在工程里,我们通常使用webpack或者browserify对jsx编译
var h = require('snabbdom/h')
// 构造一个virtual dom,在实际中,我们通常希望一个无状态的vnode
// 并且我们通过state来创造vnode
// react使用具有render方法的对象来作为组件,这个组件可以接受props和state
// 在snabbdom里面,我们同样可以实现类似效果
// function component(state){return h(...)}
var vnode =
h(
'div#container.two.classes',
{on: {click: someFn}},
[
h('span', {style: {fontWeight: 'bold'}}, 'This is bold'),
' and this is just normal text',
h('a', {props: {href: '/foo'}},
'I\'ll take you places!')
]
)
// 得到初始的容器,注意container是一个dom element
var container = document.getElementById('container')
// 将vnode patch到container中
// patch函数会对第一个参数做处理,如果第一个参数不是vnode,那么就把它包装成vnode
// patch过后,vnode发生变化,代表了现在virtual dom的状态
patch(container, vnode)
// 创建一个新的vnode
var newVnode =
h(
'div#container.two.classes',
{on: {click: anotherEventHandler}},
[
h('span', {style: {fontWeight: 'normal', fontStyle: 'italics'}},
'This is now italics'),
' and this is still just normal text',
h('a', {props: {href: '/bar'}}, 'I\'ll take you places!')
]
)
// 将新的vnode patch到vnode上,现在newVnode代表vdom的状态
patch(vnode, newVnode)
阅读源代码
vnode的定义
阅读vdom实现,首先弄清楚vnode的定义
vnode的定义在./vnode.js中
vnode具备的属性
- tagName 可以是custom tag,可以是'div','span',etc,代表这个virtual dom的tag name
- data, virtual dom数据,它们与dom element的prop、attr的语义类似。但是virtual dom包含的数据可以更灵活。
比如利用./modules/class.js插件,我们在data里面轻松toggle一个类名
h('p', {class: {'hide': hideIntro}})
- children, 对应element的children,但是这是vdom的children。vdom的实现重点就在对children的patch上
- text, 对应element.textContent,在children里定义一个string,那么我们会为这个string创建一个textNode
- elm, 对dom element的引用
- key,用于提示children patch过程,随后将详细说明
h参数
随后是h函数的包装
h的实现在./h.js
包装函数一共注意三点
- 对svg的包装,创建svg需要namespace
- 将vdom.text统一转化为string类型
- 将vdom.children中的string element转化为textNode
与dom api的对接
采用adapter模式,对dom api进行包装,然后将htmldomapi作为默认的浏览器接口
这种设计很机智。在扩展snabbdom的兼容性的时候,只需要改变snabbdom.init使用的浏览器接口,而不用改变patch等方法的实现
snabbdom的patch解析
snabbdom的核心内容实现在./snabbdom.js。snabbdom的核心实现不到三百行(233 sloc),非常简短。
在snabbdom里面实现了snabbdom的virtual dom diff算法与virtual dom lifecycle hook支持。
virtual dom diff
vdom diff是virtual dom的核心算法,snabbdom的实现原理与react官方文档Reconciliation一致
总结起来有:
- 对两个树结构进行完整的diff和patch,复杂度增长为O(n^3),几乎不可用
- 对两个数结构进行启发式diff,将大大节省开销
一篇阅读量颇丰的文章React’s diff algorithm也说明的就是启发过程,可惜,没有实际的代码参照。现在,我们根据snabbdom代码来看启发规则的运用,结束后,你会明白virtual dom的实现有多简单。
首先来到snabbdom.js中init函数的return语句
return function(oldVnode, vnode) {
var i, elm, parent;
// insertedVnodeQueue存在于整个patch过程
// 用于收集patch中新插入的vnode
var insertedVnodeQueue = [];
// 在进行patch之前,我们需要运行prepatch hook
// cbs是init函数变量,即,这个return语句中函数的闭包
// 这里,我们不理会lifecycle hook,而只关注vdom diff算法
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
// 如果oldVnode不是vnode(在第一次调用时,oldVnode是dom element)
// 那么用emptyNodeAt函数来将其包装为vnode
if (isUndef(oldVnode.sel)) {
oldVnode = emptyNodeAt(oldVnode);
}
// sameVnode是上述“值不值得patch”的核心
// sameVnode实现很简单,查看两个vnode的key与sel是否分别相同
// ()=>{vnode1.key === vnode2.key && vnode1.sel === vnode2.
// 比较语义不同的结构没有意义,比如diff一个'div'和'span'
// 而应该移除div,根据span vnode插入新的span
// diff两个key不相同的vnode同样没有意义
// 指定key就是为了区分element
// 对于不同key的element,不应该去根据newVnode来改变oldVnode的数据
// 而应该移除不再oldVnode,添加newVnode
if (sameVnode(oldVnode, vnode)) {
// oldVnode与vnode的sel和key分别相同,那么这两个vnode值得去比较
//patchVnode根据vnode来更新oldVnode
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
//不值得去patch的,我们就暴力点
// 移除oldVnode,根据newVnode创建elm,并添加至parent中
elm = oldVnode.elm;
parent = api.parentNode(elm);
// createElm根据vnode创建element
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
// 将新创建的element添加到parent中
api.insertBefore(parent, vnode.elm, api.nextSibling(elm));
// 同时移除oldVnode
removeVnodes(parent, [oldVnode], 0, 0);
}
}
// 结束以后,调用插入vnode的insert hook
for (i = 0; i < insertedVnodeQueue.length; ++i) {
insertedVnodeQueue[i].data.hook.insert(insertedVnodeQueue[i]);
}
// 整个patch结束,调用cbs中的post hook
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
return vnode;
};
然后我们阅读patch的过程
function patchVnode(oldVnode, vnode, insertedVnodeQueue) {
var i, hook;
// 如前,在patch之前,调用prepatch hook,但是这个是vnode在data里定义的prepatch hook,而不是全局定义的prepatch hook
if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) {
i(oldVnode, vnode);
}
var elm = vnode.elm = oldVnode.elm, oldCh = oldVnode.children, ch = vnode.children;
// 如果oldVnode和vnode引用相同,则没必要比较。在良好设计的vdom里,大部分时间我们都在执行这个返回语句。
if (oldVnode === vnode) return;
// 如果两次引用不同,那说明新的vnode创建了
// 与之前一样,我们先看这两个vnode值不值得去patch
if (!sameVnode(oldVnode, vnode)) {
// 这四条语句是否与init返回函数里那四条相同?
var parentElm = api.parentNode(oldVnode.elm);
elm = createElm(vnode, insertedVnodeQueue);
api.insertBefore(parentElm, elm, oldVnode.elm);
removeVnodes(parentElm, [oldVnode], 0, 0);
return;
}
// 这两个vnode值得去patch
// 我们先patch vnode,patch的方法就是先调用全局的update hook
// 然后调用vnode.data定义的update hook
if (isDef(vnode.data)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
i = vnode.data.hook;
if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode);
}
// patch两个vnode的text和children
// 查看vnode.text定义
// vdom中规定,具有text属性的vnode不应该具备children
// 对于<p>foo:<b>123</b></p>的良好写法是
// h('p', [ 'foo:', h('b', '123')]), 而非
// h('p', 'foo:', [h('b', '123')])
if (isUndef(vnode.text)) {
// vnode不是text node,我们再查看他们是否有children
if (isDef(oldCh) && isDef(ch)) {
// 两个vnode都有children,那么就调用updateChildren
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
} else if (isDef(ch)) {
// 只有新的vnode有children,那么添加vnode的children
if (isDef(oldVnode.text)) api.setTextContent(elm, '');
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
// 只有旧vnode有children,那么移除oldCh
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
} else if (isDef(oldVnode.text)) {
// 两者都没有children,并且oldVnode.text不为空,vnode.text未定义,则清空elm.textContent
api.setTextContent(elm, '');
}
} else if (oldVnode.text !== vnode.text) {
// vnode是一个text node,我们改变对应的elm.textContent
// 在这里我们使用api.setText api
api.setTextContent(elm, vnode.text);
}
if (isDef(hook) && isDef(i = hook.postpatch)) {
i(oldVnode, vnode);
}
}
patch的实现是否简单明了?甚至有觉得“啊?这就patch完了”的感觉。当然,我们还差最后一个,这个是重头戏——updateChildren。
最后阅读updateChildren*
updateChildren的代码较长且密集,但是算法十分简单
oldCh是一个包含oldVnode的children数组,newCh同理
我们先遍历两个数组(while语句),维护四个变量
- 遍历oldCh的头索引 - oldStartIdx
- 遍历oldCh的尾索引 - oldEndIdx
- 遍历newCh的头索引 - newStartIdx
- 遍历newCh的尾索引 - newEndIdx
当oldStartIdx > oldEndIdx或者newStartIdx > newOldStartIdx的时候停止遍历。
遍历过程中有五种比较
前四种比较
- oldStartVnode和newStartVnode,两者elm相对位置不变,若值得(sameVnode)比较,这patch这两个vnode
- oldEndVnode和newEndVnode,同上,elm相对位置不变,做相同patch检测
- oldStartVnode和newEndVnode,如果oldStartVnode和newEndVnode值得比较,说明oldCh中的这个oldStartVnode.elm向右移动了。那么执行
api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm))
调整它的位置 - oldEndVnode和newStartVnode,同上,但这是oldVnode.elm向左移,需要调整它的位置
最后一种比较
- 利用vnode.key,在
ul>li*n
的结构里,我们很有可能使用key来标志li
的唯一性,那么我们就会来到最后一种情况。这个时候,我们先产生一个index-key表(createKeyToOldIdx),然后根据这个表来进行更改。
更改规则
- 如果newVnode.key不在表中,那么这个newVnode就是新的vnode,将其插入
- 如果newVnode.key在表中,那么对应的oldVnode存在,我们需要patch这两个vnode,并在patch之后,将这个oldVnode置为undefined(
oldCh[idxInOld] = undefined
),同时将oldVnode.elm位置变换到当前oldStartIdx之前,以免影响接下来的遍历
遍历结束后,检查四个变量,对移除剩余的oldCh或添加剩余的newCh
patch总结
阅读完init函数return语句,patch,updateChildren,我们可以理解整个diff和patch的过程
有些函数createElm,removeVnodes并不重要
lifecycle hook
阅读完virtual dom diff算法实现后,我们可能会奇怪,关于style、class、attr的patch在哪里?这些实现都在modules,并通过lifecycle发挥作用
snabbdom的生命周期钩子函数定义在core doc - hook中。
再查看modules里的class会发现,class module通过两个hook钩子来对elm的class进行patch。这两个钩子是create和update。
回到init函数,这两个钩子在函数体开头注册
for (i = 0; i < hooks.length; ++i) {
cbs[hooks[i]] = [];
for (j = 0; j < modules.length; ++j) {
if (modules[j][hooks[i]] !== undefined)
cbs[hooks[i]].push(modules[j][hooks[i]]);
}
}
- create hook在createElm中调用。createElm是唯一添加vnode的方法,所以
insertedVnodeQueue.push
只发生在createElm中。
- update hook在patch中调用
寻找每个lifecycle hook的调用位置,你会更清楚lifecycle hook对snabbdom扩展性的好处。
lifecycle hook的强大之处
snabbdom有一个示例是animated list
animated list示例可以表现一个框架对动画的支持性,对比其他框架对animated list的实现:
react - flip-flop
vue - animatd list
我们发现,react提供成熟的lifecycle hook,轻松实现animated list。vue也可以轻松实现,但实现的意义却不及snabbdom和react,因为vue对list渲染方法进行了monkey patch,这并不属于vue的api。
写在最后
随着react社区逐渐庞大,关于virtual dom的讨论也越加深入。很多概念,像pure function, immutable data, flux, rx,都在孕育新的框架。特别是,react
类的开发体验无论在prototype阶段,还是在测试阶段,都优于data-bind
类(MVC, MVVM)开发,相信越来越多人的会偏向react社区。
最后推荐一些资料和有趣的线索
- snabbdom作者另一个github repo——functional-frontend-architecture,里面充满了作为业界标榜的创造力!
- 更短,但是不完整的vdom,它甚至看起来不像vdom—— frzr。frzr的实现很像snabbdom,如作者所说,他听取了很多来自snabbdom作者的建议,对于frzr的阐述,作者本人有一篇很好的文章
- 函数编程对前端的影响
- 比较react dom和其他框架的好文章virtual dom vs incremental dom vs glimmer,另外除了incremental dom,还有一个有趣的库——morphdom
- 一个对前端框架的优秀benchmark website,