vue的Virtual Dom实现snabbdom解密


Posted in Javascript onMay 03, 2017

vue在官方文档中提到与react的渲染性能对比中,因为其使用了snabbdom而有更优异的性能。

JavaScript 开销直接与求算必要 DOM 操作的机制相关。尽管 Vue 和 React 都使用了 Virtual Dom 实现这一点,但 Vue 的 Virtual Dom 实现(复刻自 snabbdom)是更加轻量化的,因此也就比 React 的实现更高效。

看到火到不行的国产前端框架vue也在用别人的 Virtual Dom开源方案,是不是很好奇snabbdom有何强大之处呢?不过正式解密snabbdom之前,先简单介绍下Virtual Dom。

什么是Virtual Dom

Virtual Dom可以看做一棵模拟了DOM树的JavaScript树,其主要是通过vnode,实现一个无状态的组件,当组件状态发生更新时,然后触发Virtual Dom数据的变化,然后通过Virtual Dom和真实DOM的比对,再对真实DOM更新。可以简单认为Virtual Dom是真实DOM的缓存。

为什么用Virtual Dom

我们知道,当我们希望实现一个具有复杂状态的界面时,如果我们在每个可能发生变化的组件上都绑定事件,绑定字段数据,那么很快由于状态太多,我们需要维护的事件和字段将会越来越多,代码也会越来越复杂,于是,我们想我们可不可以将视图和状态分开来,只要视图发生变化,对应状态也发生变化,然后状态变化,我们再重绘整个视图就好了。

这样的想法虽好,但是代价太高了,于是我们又想,能不能只更新状态发生变化的视图?于是Virtual Dom应运而生,状态变化先反馈到Virtual Dom上,Virtual Dom在找到最小更新视图,最后批量更新到真实DOM上,从而达到性能的提升。

除此之外,从移植性上看,Virtual Dom还对真实dom做了一次抽象,这意味着Virtual Dom对应的可以不是浏览器的DOM,而是不同设备的组件,极大的方便了多平台的使用。如果是要实现前后端同构直出方案,使用Virtual Dom的框架实现起来是比较简单的,因为在服务端的Virtual Dom跟浏览器DOM接口并没有绑定关系。

基于Virtual DOM 的数据更新与UI同步机制:

vue的Virtual Dom实现snabbdom解密

初始渲染时,首先将数据渲染为 Virtual DOM,然后由 Virtual DOM 生成 DOM。

vue的Virtual Dom实现snabbdom解密

数据更新时,渲染得到新的 Virtual DOM,与上一次得到的 Virtual DOM 进行 diff,得到所有需要在 DOM 上进行的变更,然后在 patch 过程中应用到 DOM 上实现UI的同步更新。

Virtual DOM 作为数据结构,需要能准确地转换为真实 DOM,并且方便进行对比。

介绍完Virtual DOM,我们应该对snabbdom的功用有个认识了,下面具体解剖下snabbdom这只“小麻雀”。

snabbdom

vnode

DOM 通常被视为一棵树,元素则是这棵树上的节点(node),而 Virtual DOM 的基础,就是 Virtual Node 了。

Snabbdom 的 Virtual Node 则是纯数据对象,通过 vnode 模块来创建,对象属性包括:

sel
data
children
text
elm
key

可以看到 Virtual Node 用于创建真实节点的数据包括:

元素类型
元素属性
元素的子节点

源码:

//VNode函数,用于将输入转化成VNode
 /**
 *
 * @param sel 选择器
 * @param data 绑定的数据
 * @param children 子节点数组
 * @param text 当前text节点内容
 * @param elm 对真实dom element的引用
 * @returns {{sel: *, data: *, children: *, text: *, elm: *, key: undefined}}
 */
function vnode(sel, data, children, text, elm) {

 var key = data === undefined ? undefined : data.key;
 return { sel: sel, data: data, children: children,
 text: text, elm: elm, key: key };
}

snabbdom并没有直接暴露vnode对象给我们用,而是使用h包装器,h的主要功能是处理参数:

h(sel,[data],[children],[text]) => vnode

从snabbdom的typescript的源码可以看出,其实就是这几种函数重载:

export function h(sel: string): VNode; 
export function h(sel: string, data: VNodeData): VNode; 
export function h(sel: string, text: string): VNode; 
export function h(sel: string, children: Array<VNode | undefined | null>): VNode; 
export function h(sel: string, data: VNodeData, text: string): VNode; 
export function h(sel: string, data: VNodeData, children: Array<VNode | undefined | null>): VNode;

patch

创建vnode后,接下来就是调用patch方法将Virtual Dom渲染成真实DOM了。patch是snabbdom的init函数返回的。
snabbdom.init传入modules数组,module用来扩展snabbdom创建复杂dom的能力。

不多说了直接上patch的源码:

return function patch(oldVnode, vnode) {
 var i, elm, parent;
 //记录被插入的vnode队列,用于批触发insert
 var insertedVnodeQueue = [];
 //调用全局pre钩子
 for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
 //如果oldvnode是dom节点,转化为oldvnode
 if (isUndef(oldVnode.sel)) {
 oldVnode = emptyNodeAt(oldVnode);
 }
 //如果oldvnode与vnode相似,进行更新
 if (sameVnode(oldVnode, vnode)) {
 patchVnode(oldVnode, vnode, insertedVnodeQueue);
 } else {
 //否则,将vnode插入,并将oldvnode从其父节点上直接删除
 elm = oldVnode.elm;
 parent = api.parentNode(elm);

 createElm(vnode, insertedVnodeQueue);

 if (parent !== null) {
 api.insertBefore(parent, vnode.elm, api.nextSibling(elm));
 removeVnodes(parent, [oldVnode], 0, 0);
 }
 }
 //插入完后,调用被插入的vnode的insert钩子
 for (i = 0; i < insertedVnodeQueue.length; ++i) {
 insertedVnodeQueue[i].data.hook.insert(insertedVnodeQueue[i]);
 }
 //然后调用全局下的post钩子
 for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
 //返回vnode用作下次patch的oldvnode
 return vnode;
 };

先判断新旧虚拟dom是否是相同层级vnode,是才执行patchVnode,否则创建新dom删除旧dom,判断是否相同vnode比较简单:

function sameVnode(vnode1, vnode2) {
 //判断key值和选择器
 return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
}

patch方法里面实现了snabbdom 作为一个高效virtual dom库的法宝—高效的diff算法,可以用一张图示意:

vue的Virtual Dom实现snabbdom解密

diff算法的核心是比较只会在同层级进行, 不会跨层级比较。而不是逐层逐层搜索遍历的方式,时间复杂度将会达到 O(n^3)的级别,代价非常高,而只比较同层级的方式时间复杂度可以降低到O(n)。

patchVnode函数的主要作用是以打补丁的方式去更新dom树。

function patchVnode(oldVnode, vnode, insertedVnodeQueue) {
 var i, hook;
 //在patch之前,先调用vnode.data的prepatch钩子
 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的引用相同,说明没发生任何变化直接返回,避免性能浪费
 if (oldVnode === vnode) return;
 //如果oldvnode和vnode不同,说明vnode有更新
 //如果vnode和oldvnode不相似则直接用vnode引用的DOM节点去替代oldvnode引用的旧节点
 if (!sameVnode(oldVnode, vnode)) {
 var parentElm = api.parentNode(oldVnode.elm);
 elm = createElm(vnode, insertedVnodeQueue);
 api.insertBefore(parentElm, elm, oldVnode.elm);
 removeVnodes(parentElm, [oldVnode], 0, 0);
 return;
 }
 //如果vnode和oldvnode相似,那么我们要对oldvnode本身进行更新
 if (isDef(vnode.data)) {
 //首先调用全局的update钩子,对vnode.elm本身属性进行更新
 for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
 //然后调用vnode.data里面的update钩子,再次对vnode.elm更新
 i = vnode.data.hook;
 if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode);
 }
 //如果vnode不是text节点
 if (isUndef(vnode.text)) {
 //如果vnode和oldVnode都有子节点
 if (isDef(oldCh) && isDef(ch)) {
 //当Vnode和oldvnode的子节点不同时,调用updatechilren函数,diff子节点
 if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
 }
 //如果vnode有子节点,oldvnode没子节点
 else if (isDef(ch)) {
 //oldvnode是text节点,则将elm的text清除
 if (isDef(oldVnode.text)) api.setTextContent(elm, '');
 //并添加vnode的children
 addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
 }
 //如果oldvnode有children,而vnode没children,则移除elm的children
 else if (isDef(oldCh)) {
 removeVnodes(elm, oldCh, 0, oldCh.length - 1);
 }
 //如果vnode和oldvnode都没chidlren,且vnode没text,则删除oldvnode的text
 else if (isDef(oldVnode.text)) {
 api.setTextContent(elm, '');
 }
 }

 //如果oldvnode的text和vnode的text不同,则更新为vnode的text
 else if (oldVnode.text !== vnode.text) {
 api.setTextContent(elm, vnode.text);
 }
 //patch完,触发postpatch钩子
 if (isDef(hook) && isDef(i = hook.postpatch)) {
 i(oldVnode, vnode);
 }
 }

patchVnode将新旧虚拟DOM分为几种情况,执行替换textContent还是updateChildren。

updateChildren是实现diff算法的主要地方:

function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue) {
 var oldStartIdx = 0, newStartIdx = 0;
 var oldEndIdx = oldCh.length - 1;
 var oldStartVnode = oldCh[0];
 var oldEndVnode = oldCh[oldEndIdx];
 var newEndIdx = newCh.length - 1;
 var newStartVnode = newCh[0];
 var newEndVnode = newCh[newEndIdx];
 var oldKeyToIdx;
 var idxInOld;
 var elmToMove;
 var before;
 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
 if (oldStartVnode == null) {
 oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
 }
 else if (oldEndVnode == null) {
 oldEndVnode = oldCh[--oldEndIdx];
 }
 else if (newStartVnode == null) {
 newStartVnode = newCh[++newStartIdx];
 }
 else if (newEndVnode == null) {
 newEndVnode = newCh[--newEndIdx];
 }
 else if (sameVnode(oldStartVnode, newStartVnode)) {
 patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
 oldStartVnode = oldCh[++oldStartIdx];
 newStartVnode = newCh[++newStartIdx];
 }
 else if (sameVnode(oldEndVnode, newEndVnode)) {
 patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
 oldEndVnode = oldCh[--oldEndIdx];
 newEndVnode = newCh[--newEndIdx];
 }
 else if (sameVnode(oldStartVnode, newEndVnode)) {
 patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
 api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm));
 oldStartVnode = oldCh[++oldStartIdx];
 newEndVnode = newCh[--newEndIdx];
 }
 else if (sameVnode(oldEndVnode, newStartVnode)) {
 patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
 api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
 oldEndVnode = oldCh[--oldEndIdx];
 newStartVnode = newCh[++newStartIdx];
 }
 else {
 if (oldKeyToIdx === undefined) {
  oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
 }
 idxInOld = oldKeyToIdx[newStartVnode.key];
 if (isUndef(idxInOld)) {
  api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm);
  newStartVnode = newCh[++newStartIdx];
 }
 else {
  elmToMove = oldCh[idxInOld];
  if (elmToMove.sel !== newStartVnode.sel) {
  api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm);
  }
  else {
  patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
  oldCh[idxInOld] = undefined;
  api.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm);
  }
  newStartVnode = newCh[++newStartIdx];
 }
 }
 }
 if (oldStartIdx > oldEndIdx) {
 before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
 addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
 }
 else if (newStartIdx > newEndIdx) {
 removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
 }
 }

updateChildren的代码比较有难度,借助几张图比较好理解些:

vue的Virtual Dom实现snabbdom解密

过程可以概括为:oldCh和newCh各有两个头尾的变量StartIdx和EndIdx,它们的2个变量相互比较,一共有4种比较方式。如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx表明oldCh和newCh至少有一个已经遍历完了,就会结束比较。

具体的diff分析:
对于与sameVnode(oldStartVnode, newStartVnode)和sameVnode(oldEndVnode,newEndVnode)为true的情况,不需要对dom进行移动。

有3种需要dom操作的情况:

1.当oldStartVnode,newEndVnode相同层级时,说明oldStartVnode.el跑到oldEndVnode.el的后边了。

vue的Virtual Dom实现snabbdom解密

2.当oldEndVnode,newStartVnode相同层级时,说明oldEndVnode.el跑到了newStartVnode.el的前边。

vue的Virtual Dom实现snabbdom解密

3.newCh中的节点oldCh里没有,将新节点插入到oldStartVnode.el的前边。

vue的Virtual Dom实现snabbdom解密

在结束时,分为两种情况:

1.oldStartIdx > oldEndIdx,可以认为oldCh先遍历完。当然也有可能newCh此时也正好完成了遍历,统一都归为此类。此时newStartIdx和newEndIdx之间的vnode是新增的,调用addVnodes,把他们全部插进before的后边,before很多时候是为null的。addVnodes调用的是insertBefore操作dom节点,我们看看insertBefore的文档:parentElement.insertBefore(newElement, referenceElement)如果referenceElement为null则newElement将被插入到子节点的末尾。如果newElement已经在DOM树中,newElement首先会从DOM树中移除。所以before为null,newElement将被插入到子节点的末尾。

vue的Virtual Dom实现snabbdom解密

2.newStartIdx > newEndIdx,可以认为newCh先遍历完。此时oldStartIdx和oldEndIdx之间的vnode在新的子节点里已经不存在了,调用removeVnodes将它们从dom里删除。

vue的Virtual Dom实现snabbdom解密

hook

shabbdom主要流程的代码在上面就介绍完毕了,在上面的代码中可能看不出来如果要创建比较复杂的dom,比如有attribute、props、eventlistener的dom怎么办?奥秘就在与shabbdom在各个主要的环节提供了钩子。钩子方法中可以执行扩展模块,attribute、props、eventlistener等可以通过扩展模块实现。

在源码中可以看到hook是在snabbdom初始化的时候注册的。

var hooks = ['create', 'update', 'remove', 'destroy', 'pre', 'post'];
var h_1 = require("./h");
exports.h = h_1.h;
var thunk_1 = require("./thunk");
exports.thunk = thunk_1.thunk;
function init(modules, domApi) {
 var i, j, cbs = {};
 var api = domApi !== undefined ? domApi : htmldomapi_1.default;
 for (i = 0; i < hooks.length; ++i) {
 cbs[hooks[i]] = [];
 for (j = 0; j < modules.length; ++j) {
 var hook = modules[j][hooks[i]];
 if (hook !== undefined) {
 cbs[hooks[i]].push(hook);
 }
 }
 }

snabbdom在全局下有6种类型的钩子,触发这些钩子时,会调用对应的函数对节点的状态进行更改首先我们来看看有哪些钩子以及它们触发的时间:

vue的Virtual Dom实现snabbdom解密

比如在patch的代码中可以看到调用了pre钩子

return function patch(oldVnode, vnode) {
 var i, elm, parent;
 var insertedVnodeQueue = [];
 for (i = 0; i < cbs.pre.length; ++i)
 cbs.pre[i]();
 if (!isVnode(oldVnode)) {
 oldVnode = emptyNodeAt(oldVnode);
 }

我们找一个比较简单的class模块来看下其源码:

function updateClass(oldVnode, vnode) {
 var cur, name, elm = vnode.elm, oldClass = oldVnode.data.class, klass = vnode.data.class;
 if (!oldClass && !klass)
 return;
 if (oldClass === klass)
 return;
 oldClass = oldClass || {};
 klass = klass || {};
 for (name in oldClass) {
 if (!klass[name]) {
 elm.classList.remove(name);
 }
 }
 for (name in klass) {
 cur = klass[name];
 if (cur !== oldClass[name]) {
 elm.classList[cur ? 'add' : 'remove'](name);
 }
 }
}
exports.classModule = { create: updateClass, update: updateClass };
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = exports.classModule;

},{}]},{},[1])(1)
});

可以看出create和update钩子方法调用的时候,可以执行class模块的updateClass:从elm中删除vnode中不存在的或者值为false的类。

将vnode中新的class添加到elm上去。

总结snabbdom

  • vnode是基础数据结构
  • patch创建或更新DOM树
  • diff算法只比较同层级
  • 通过钩子和扩展模块创建有attribute、props、eventlistener的复杂dom

参考:

snabbdom

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
解决JS浮点数运算出现Bug的方法
Mar 12 Javascript
js实现DOM走马灯特效的方法
Jan 21 Javascript
js获取内联样式的方法
Jan 27 Javascript
JavaScript中reduce()方法的使用详解
Jun 09 Javascript
莱鸟介绍window.print()方法
Jan 06 Javascript
jQuery 操作input中radio的技巧
Jul 18 Javascript
picLazyLoad 实现图片延时加载(包含背景图片)
Jul 21 Javascript
JavaScript中transform实现数字翻页效果
Mar 08 Javascript
jquery拼接ajax 的json和字符串拼接的方法
Mar 11 Javascript
vue.js 使用v-if v-else发现没有执行解决办法
May 15 Javascript
vue动态路由实现多级嵌套面包屑的思路与方法
Aug 16 Javascript
Vue项目中配置pug解析支持
May 10 Javascript
JavaScript中undefined和null的区别
May 03 #Javascript
Node.js对MongoDB数据库实现模糊查询的方法
May 03 #Javascript
xmlplus组件设计系列之文本框(TextBox)(3)
May 03 #Javascript
利用Mongoose让JSON数据直接插入或更新到MongoDB
May 03 #Javascript
xmlplus组件设计系列之选项卡(Tabbar)(5)
May 03 #Javascript
javascript过滤数组重复元素的实现方法
May 03 #Javascript
JS实现图片点击后出现模态框效果
May 03 #Javascript
You might like
我的群发邮件程序
2006/10/09 PHP
PHP n个不重复的随机数生成代码
2009/06/23 PHP
php中is_null,empty,isset,unset 的区别详细介绍
2013/04/28 PHP
PHP面向对象程序设计实例分析
2016/01/26 PHP
PHP将URL转换成短网址的算法分享
2016/09/13 PHP
PHP框架Laravel中实现supervisor执行异步进程的方法
2017/06/07 PHP
第二次聊一聊JS require.js模块化工具的基础知识
2016/04/17 Javascript
Bootstrap进度条组件知识详解
2016/05/01 Javascript
Node.js环境下JavaScript实现单链表与双链表结构
2016/06/12 Javascript
js与jquery分别实现tab标签页功能的方法
2016/11/18 Javascript
通过BootStrap-select插件 js jQuery控制select属性变化
2017/01/03 Javascript
bootstrap下拉菜单使用方法解析
2017/01/13 Javascript
详谈Angular路由与Nodejs路由的区别
2017/03/05 NodeJs
JS实现静态页面搜索并高亮显示功能完整示例
2017/09/19 Javascript
jQuery插件Validation表单验证详解
2018/05/26 jQuery
Vue中使用方法、计算属性或观察者的方法实例详解
2018/10/31 Javascript
jQuery zTree树插件的使用教程
2019/08/16 jQuery
分享一个常用的Python模拟登陆类
2015/03/29 Python
Python下使用Psyco模块优化运行速度
2015/04/05 Python
pandas将numpy数组写入到csv的实例
2018/07/04 Python
python2 与 python3 实现共存的方法
2018/07/12 Python
Python实现九宫格式的朋友圈功能内附“马云”朋友圈
2019/05/07 Python
Python学习笔记之函数的定义和作用域实例详解
2019/08/13 Python
Python OpenCV视频截取并保存实现代码
2019/11/30 Python
python 实现多维数组转向量
2019/11/30 Python
numpy ndarray 取出满足特定条件的某些行实例
2019/12/05 Python
PyTorch笔记之scatter()函数的使用
2020/02/12 Python
一款利用html5和css3实现的3D滚动特效的教程
2015/01/04 HTML / CSS
英国文具、办公用品和科技商店:Ryman
2018/09/27 全球购物
编程实现当输入某产品代码则打印出该产品记录的功能
2014/05/03 面试题
企划主管岗位职责
2013/12/12 职场文书
大学生如何写自荐信
2014/01/08 职场文书
表决心的诗句大全
2014/03/11 职场文书
2015年度工程师评职称工作总结
2015/10/14 职场文书
2019暑假学生安全口号
2019/06/27 职场文书
springboot使用Redis作缓存使用入门教程
2021/07/25 Redis