VDom 的含义和实现 原文地址 原文是一篇讲得很明白的文章,本文基本上是照着写了一遍 不但学到了 VDOM 相关的一些东西,原生 JS 的方法也熟悉了一遍,收获颇丰
一文说清「VirtualDOM」的含义与实现
如何理解 VDom 前端常做的事情就是根据数据状态的更新,来更新页面视图。然而频繁的更新 DOM 会造成回流或者重绘,引发性能下降,页面卡顿 因此我们需要方法避免频繁更新 DOM 树 思路就是对比 DOM 差距,只更新需要更新的节点,而不是整棵树 实现这个算法的基础,需要遍历 DOM 树的结点,来进行比较更新 为了更快地处理,不使用 DOM 对象,而改用 JS 对象 他就像是 JS 与 DOM 之间的一层缓存
如何表示 VDom 借助 ES6 的 class,表示 VDom 的语义化更强。一个基础的 VDom 需要有标签名,标签属性以及子节点
1 2 3 4 5 6 7 class Element { constructor (tagName, props, children ) { this .tagName = tagName; this .props = props; this .children = children; } }
为了更方便调用(不用每次都 new),将其封装返回实例的函数
1 2 3 function el (tagName, props, children ) { return new Element (tagName, props, children); }
用上面的方法表达 DOM 结构:
1 2 3 <div class ="test" > <span > span1</span > </div >
用 VDom 表示 ↓
1 2 const span = el ("span" , {}, ["span1" ]);const div = el ("div" , { class : "test" }, [span]);
之后再对比和更新两棵 vdom 树的时候,将会涉及到将 VDom 渲染成真正的 Dom 节点。因此给class Element
增加render
方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class Element { constructor (tagName, props, children ) { this .tagName = tagName; this .props = props; this .children = children; } render ( ) { const dom = document .createElement (this .tagName ); Reflect .ownKeys (this .props ).forEach ((name ) => dom.setAttribute (name, this .props [name]) ); this .children .forEach ((child ) => { const childDom = child instanceof Element ? child.render () : document .createTextNode (child); dom.appendChild (childDom); }); return dom; } }
如何比较 dom 树,并且进行高效更新 前面已经说明了 VDom 的用法与含义, 多个 VDom 就会组成一棵虚拟的 VDom 树 剩下要做的就是:根据不同情况,来进行树上结点的增删改操作 这个过程分为diff
和path
diff: 递归对比两棵 dom 树对应位置的差异
patch: 根据差异,进行节点的更新
现在有两种思路,一种是先 diff 一遍,记录所有差异,再统一进行 patch 另一种是 diff 同时进行 patch 相比较,第二种方法少了一次递归查询,以及不需要构造过多对象,下面采用第二种思路
变量的含义 将 diff 和 patch 的过程放入updateEl
方法中
1 2 3 4 5 6 7 8 function updateEl ($parent, newNode, oldNode, index = 0 ) {}
所有以$开头的变量,代表真实DOM 参数index表示oldNode再$parent 的所有子节点构成的数组的下标
1.新增节点 如果 oldNode 为 undefined,说明 newNode 是一个新增的 DOM 节点。将其直接追加到 DOM 中即可
1 2 3 4 5 function updateEl ($parent, newNode, oldNode, index = 0 ) { if (!oldNode) { $parent.appendChild (newNode.render ()); } }
2.删除节点 如果 newNode 为 undefined,说明新的 VDom 中,当前位置没有节点,因此需要将其从实际的 DOM 中删除 删除就调用$parent.removeChild(), 通过 index 参数,可以拿到被删除元素的引用
1 2 3 4 5 6 7 function updateEl ($parent, newNode, oldNode, index = 0 ) { if (!oldNode) { $parent.appendChild (newNode.render ()); } else if (!newNode) { $parent.removeChild ($parent.childNodes [index]); } }
3.变化节点 对比 oldNode 和 newNode,有三种情况,均可视为改变
节点类型发生变化,文本变成 vdom,vdom 变成文本
新旧节点都是文本,内容改变
节点属性发生变化
首先,借助 Symbol 更好地语义化声明三种变化
1 2 3 const CHANGE_TYPE_TEXT = Symbol ("text" );const CHANGE_TYPE_PROP = Symbol ("props" );const CHANGE_TYPE_REPLACE = Symbol ("replace" );
针对节点属性发生改变,没有现成 API 供我们批量更新,所以封装replaceAttribute
方法,将新的 vdom 属性值直接映射到 dom 结构上
1 2 3 4 5 6 7 8 9 function replaceAttribute ($node, removedAttrs, newAttrs ) { if (!$node) { return ; } Reflect .ownKeys (removedAttrs).forEach ((attr ) => $node.removeAttribute (attr)); Reflect .ownKeys (newAttrs).forEach ((attr ) => $node.setAttribute (attr, newAttrs[attr]) ); }
编写 checkChangeType 函数判断变化的类型,如果没有变化,则返回空
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 function checkChangeType (newNode, oldNode ) { if ( typeof newNode !== typeof oldNode || newNode.tagName !== oldNode.tagName ) { return CHANGE_TYPE_REPLACE ; } if (typeof newNode === "string" ) { if (newNode !== oldNode) { return CHANGE_TYPE_TEXT ; } return ; } const propsChanged = Reflect .ownKeys (newNode.props ).reduce ( (prev, name ) => prev || oldNode.props [name] !== newNode.props [name], false ); if (propsChanged) { return CHANGE_TYPE_PROP ; } return ; }
在 updateEl 中,根据 checkChangeType 返回的变化类型,做出对应处理 如果类型为空,则不进行处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 function updateEl ($parent, newNode, oldNode, index = 0 ) { let changeType = null ; if (!oldNode) { $parent.appendChild (newNode.render ()); } else if (!newNode) { $parent.removeChild ($parent.childNodes [index]); } else if ((changeType = checkChangeType (newNode, oldNode))) { if (changeType === CHANGE_TYPE_TEXT ) { $parent.replaceChild ( document .createTextNode (newNode), $parent.childNodes [index] ); } else if (changeType === CHANGE_TYPE_REPLACE ) { $parent.replaceChild (newNode.render (), $parent.childNodes [index]); } else if (changeType === CHANGE_TYPE_PROP ) { replaceAttribute ($parent.childNodes [index], oldNode.props , newNode.props ); } } }
4.递归对子节点进行 diff 如果情况 1,2,3 都没有命重,那说明当前的新旧节点自身并没有变化 需要遍历他们的 children 数组,递归进行处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 function updateEL ($parent, newNode, oldNode, index = 0 ) { let changeType = null ; if (!oldNode) { $parent.appendChild (newNode.render ()); } else if (!newNode) { $parent.removeChild ($parent.childNodes [index]); } else if ((changeType = checkChangeType (newNode, oldNode))) { if (changeType === CHANGE_TYPE_TEXT ) { $parent.replaceChild ( document .createTextNode (newNode), $parent.childNodes [index] ); } else if (changeType === CHANGE_TYPE_REPLACE ) { $parent.replaceChild (newNode.render (), $parent.childNodes [index]); } else if (changeType === CHANGE_TYPE_PROP ) { replaceAttribute ($parent.childNodes [index], oldNode.props , newNode.props ); } } else if (newNode.tagName ) { const newLength = newNode.children .length ; const oldLength = oldNode.children .length ; for (let i = 0 ; i < newLength || i < oldLength; ++i) { updateEl ( $parent.childNodes [index], newNode.children [i], oldNode.children [i] ); } } }