众所周知,对前端而言,直接操做 DOM 是一件及其耗费性能的事情,以 React 和 Vue 为表明的众多框架广泛采用 Virtual DOM 来解决现在愈发复杂 Web 应用中状态频繁发生变化致使的频繁更新 DOM 的性能问题。本文为笔者经过实际操做,实现了一个很是简单的 Virtual DOM ,加深对现今主流前端框架中 Virtual DOM 的理解。javascript
关于 Virtual DOM ,社区已经有许多优秀的文章,而本文是笔者采用本身的方式,并有所借鉴前辈们的实现,以浅显易懂的方式,对 Virtual DOM 进行简单实现,但不包含snabbdom的源码分析,在笔者的最终实现里,参考了snabbdom的原理,将本文的Virtual DOM实现进行了改进,感兴趣的读者能够阅读上面几篇文章,并参考笔者本文的最终代码进行阅读。html
本文阅读时间约15~20分钟。前端
本文分为如下几个方面来说述极简版本的 Virtual DOM 核心实现:java
将 Virtual DOM 转换为真实 DOMnode
处理变化react
要理解 Virtual DOM 的含义,首先须要理解 DOM ,DOM 是针对 HTML 文档和 XML 文档的一个 API , DOM 描绘了一个层次化的节点树,经过调用 DOM API,开发人员能够任意添加,移除和修改页面的某一部分。而 Virtual DOM 则是用 JavaScript 对象来对 Virtual DOM 进行抽象化的描述。Virtual DOM 的本质是JavaScript对象,经过 Render函数,能够将 Virtual DOM 树 映射为 真实 DOM 树。webpack
一旦 Virtual DOM 发生改变,会生成新的 Virtual DOM ,相关算法会对比新旧两颗 Virtual DOM 树,并找到他们之间的不一样,尽量地经过最少的 DOM 操做来更新真实 DOM 树。git
咱们能够这么表示 Virtual DOM 与 DOM 的关系:DOM = Render(Virtual DOM)
。github
Virtual DOM 是用 JavaScript 对象表示,并存储在内存中的。主流的框架均支持使用 JSX 的写法, JSX 最终会被 babel 编译为JavaScript 对象,用于来表示Virtual DOM,思考下列的 JSX:web
<div> <span className="item">item</span> <input disabled={true} /> </div>
最终会被babel编译为以下的 JavaScript对象:
{ type: 'div', props: null, children: [{ type: 'span', props: { class: 'item', }, children: ['item'], }, { type: 'input', props: { disabled: true, }, children: [], }], }
咱们能够注意到如下两点:
{ type: '...', props: { ... }, children: { ... }, on: { ... } }
那么 JSX 又是如何转化为 JavaScript 对象的呢。幸运的是,社区有许许多多优秀的工具帮助咱们完成了这件事,因为篇幅有限,本文对这个问题暂时不作探讨。为了方便你们更快速地理解 Virtual DOM ,对于这一个步骤,笔者使用了开源工具来完成。著名的 babel 插件babel-plugin-transform-react-jsx帮助咱们完成这项工做。
为了更好地使用babel-plugin-transform-react-jsx,咱们须要搭建一下webpack开发环境。具体过程这里不作阐述,有兴趣本身实现的同窗能够到simple-virtual-dom查看代码。
对于不使用 JSX 语法的同窗,能够不配置babel-plugin-transform-react-jsx,经过咱们的vdom
函数建立 Virtual DOM:
function vdom(type, props, ...children) { return { type, props, children, }; }
而后咱们能够经过以下代码建立咱们的 Virtual DOM 树:
const vNode = vdom('div', null, vdom('span', { class: 'item' }, 'item'), vdom('input', { disabled: true }) );
在控制台输入上述代码,能够看到,已经建立好了用 JavaScript对象表示的 Virtual DOM 树:
如今咱们知道了如何用 JavaScript对象 来表明咱们的真实 DOM 树,那么, Virtual DOM 又是怎么转换为真实 DOM 给咱们呈现的呢?
在这以前,咱们要先知道几项注意事项:
$
开头的变量来表示真实 DOM 对象;toRealDom
函数接受一个 Virtual DOM 对象为参数,将返回一个真实 DOM 对象;mount
函数接受两个参数:将挂载 Virtual DOM 对象的父节点,这是一个真实 DOM 对象,命名为$parent
;以及被挂载的 Virtual DOM 对象vNode
;下面是toRealDom
的函数原型:
function toRealDom(vNode) { let $dom; // do something with vNode return $dom; }
经过toRealDom
方法,咱们能够将一个vNode
对象转化为一个真实 DOM 对象,而mount
函数经过appendChild
,将真实 DOM 挂载:
function mount($parent, vNode) { return $parent.appendChild(toRealDom(vNode)); }
下面,让咱们来分别处理vNode
的type
、props
和children
。
首先,由于咱们同时具备字符类型的文本节点和对象类型的element
节点,须要对type
作单独的处理:
if (typeof vNode === 'string') { $dom = document.createTextNode(vNode); } else { $dom = document.createElement(vNode.type); }
在这样一个简单的toRealDom
函数中,对type
的处理就完成了,接下来让咱们看看对props
的处理。
咱们知道,若是节点有props
,那么props
是一个对象。经过遍历props
,调用setProp
方法,对每一类props
单独处理。
if (vNode.props) { Object.keys(vNode.props).forEach(key => { setProp($dom, key, vNode.props[key]); }); }
setProp
接受三个参数:
$target
,这是一个真实 DOM 对象,setProp
将对这个节点进行 DOM 操做;name
,表示属性名;value
,表示属性的值;读到这里,相信你已经大概清楚setProp
须要作什么了,通常状况下,对于普通的props
,咱们会经过setAttribute
给 DOM 对象附加属性。
function setProp($target, name, value) { return $target.setAttribute(name, value); }
但这远远不够,思考下列的 JSX 结构:
<div> <span className="item" data-node="item" onClick={() => console.log('item')}>item</span> <input disabled={true} /> </div>
从上面的 JSX 结构中,咱们发现如下几点:
class
是 JavaScript 的保留字, JSX 通常使用className
来表示 DOM 节点所属的class
;on
开头的属性来表示事件;disabled
,当该值为true
时,则添加这一属性;因此,setProp
也一样须要考虑上述状况:
function isEventProp(name) { return /^on/.test(name); } function extractEventName(name) { return name.slice(2).toLowerCase(); } function setProp($target, name, value) { if (name === 'className') { // 由于class是保留字,JSX使用className来表示节点的class return $target.setAttribute('class', value); } else if (isEventProp(name)) { // 针对 on 开头的属性,为事件 return $target.addEventListener(extractEventName(name), value); } else if (typeof value === 'boolean') { // 兼容属性为布尔值的状况 if (value) { $target.setAttribute(name, value); } return $target[name] = value; } else { return $target.setAttribute(name, value); } }
最后,还有一类属性是咱们的自定义属性,例如主流框架中的组件间的状态传递,即经过props
来进行传递的,咱们并不但愿这一类属性显示在 DOM 中,所以须要编写一个函数isCustomProp
来检查这个属性是不是自定义属性,由于本文只是为了实现 Virtual DOM 的核心思想,为了方便,在本文中,这个函数直接返回false
。
function isCustomProp(name) { return false; }
最终的setProp
函数:
function setProp($target, name, value) { if (isCustomProp(name)) { return; } else if (name === 'className') { // fix react className return $target.setAttribute('class', value); } else if (isEventProp(name)) { return $target.addEventListener(extractEventName(name), value); } else if (typeof value === 'boolean') { if (value) { $target.setAttribute(name, value); } return $target[name] = value; } else { return $target.setAttribute(name, value); } }
对于children
里的每一项,都是一个vNode
对象,在进行 Virtual DOM 转化为真实 DOM 时,子节点也须要被递归转化,能够想到,针对有子节点的状况,须要对子节点以此递归调用toRealDom
,以下代码所示:
if (vNode.children && vNode.children.length) { vNode.children.forEach(childVdom => { const realChildDom = toRealDom(childVdom); $dom.appendChild(realChildDom); }); }
最终完成的toRealDom
以下:
function toRealDom(vNode) { let $dom; if (typeof vNode === 'string') { $dom = document.createTextNode(vNode); } else { $dom = document.createElement(vNode.type); } if (vNode.props) { Object.keys(vNode.props).forEach(key => { setProp($dom, key, vNode.props[key]); }); } if (vNode.children && vNode.children.length) { vNode.children.forEach(childVdom => { const realChildDom = toRealDom(childVdom); $dom.appendChild(realChildDom); }); } return $dom; }
Virtual DOM 之因此被创造出来,最根本的缘由是性能提高,经过 Virtual DOM ,开发者能够减小许多没必要要的 DOM 操做,以达到最优性能,那么下面咱们来看看 Virtual DOM 算法 是如何经过对比更新前的 Virtual DOM 树和更新后的 Virtual DOM 树来实现性能优化的。
注:本文是笔者的最简单实现,目前社区广泛通用的算法是 snabbdom,如 Vue 则是借鉴该算法实现的 Virtual DOM ,有兴趣的读者能够查看这个库的源代码,基于本文的 Virtual DOM 的小示例,笔者最终也参考了该算法实现, 本文demo传送门,因为篇幅有限,感兴趣的读者能够自行研究。
为了处理变化,首先声明一个updateDom
函数,这个函数接受如下四个参数:
$parent
,表示将被挂载的父节点;oldVNode
,旧的VNode
对象;newVNode
,新的VNode
对象;index
,在更新子节点时使用,表示当前更新第几个子节点,默认为0;函数原型以下:
function updateDom($parent, oldVNode, newVNode, index = 0) { }
首先咱们来看新增一个节点的状况,对于本来没有该节点,须要添加新的一个节点到 DOM 树中,咱们须要经过appendChild
来实现:
转化为代码表述为:
// 没有旧的节点,添加新的节点 if (!oldVNode) { return $parent.appendChild(toRealDom(newVNode)); }
同理,对于删除一个旧节点的状况,咱们经过removeChild
来实现,在这里,咱们应该从真实 DOM 中将旧的节点删掉,但问题是在这个函数中是直接取不到这一个节点的,咱们须要知道这个节点在父节点中的位置,事实上,能够经过$parent.childNodes[index]
来取到,这即是上面提到的为什么须要传入index
,它表示当前更新的节点在父节点中的索引:
转化为代码表述为:
const $currentDom = $parent.childNodes[index]; // 没有新的节点,删除旧的节点 if (!newVNode) { return $parent.removeChild($currentDom); }
Virtual DOM 的核心在于如何高效更新节点,下面咱们来看看更新节点的状况。
首先,针对文本节点,咱们能够简单处理,对于文本节点是否发生改变,只须要经过比较其新旧字符串是否相等便可,若是是相同的文本节点,是不须要咱们更新 DOM 的,在updateDom
函数中,直接return
便可:
// 都是文本节点,都没有发生变化 if (typeof oldVNode === 'string' && typeof newVNode === 'string' && oldVNode === newVNode) { return; }
接下来,考虑节点是否真的须要更新,如图所示,一个节点的类型从span
换成了div
,显而易见,这是必定须要咱们去更新DOM
的:
咱们须要编写一个函数isNodeChanged
来帮助咱们判断旧节点和新节点是否真的一致,若是不一致,须要咱们把节点进行替换:
function isNodeChanged(oldVNode, newVNode) { // 一个是textNode,一个是element,必定改变 if (typeof oldVNode !== typeof newVNode) { return true; } // 都是textNode,比较文本是否改变 if (typeof oldVNode === 'string' && typeof newVNode === 'string') { return oldVNode !== newVNode; } // 都是element节点,比较节点类型是否改变 if (typeof oldVNode === 'object' && typeof newVNode === 'object') { return oldVNode.type !== newVNode.type; } }
在updateDom
中,发现节点类型发生变化,则将该节点直接替换,以下代码所示,经过调用replaceChild
,将旧的 DOM 节点移除,并将新的 DOM 节点加入:
if (isNodeChanged(oldVNode, newVNode)) { return $parent.replaceChild(toRealDom(newVNode), $currentDom); }
但这远远尚未结束,考虑下面这种状况:
<!-- old --> <div class="item" data-item="old-item"></div>
<!-- new --> <div id="item" data-item="new-item"></div>
对比上面的新旧两个节点,发现节点类型并无发生改变,即VNode.type
都是'div'
,可是节点的属性却发生了改变,除了针对节点类型的变化更新 DOM 外,针对节点的属性的改变,也须要对应把 DOM 更新。
与上述方法相似,咱们编写一个isPropsChanged
函数,来判断新旧两个节点的属性是否有发生变化:
function isPropsChanged(oldProps, newProps) { // 类型都不一致,props确定发生变化了 if (typeof oldProps !== typeof newProps) { return true; } // props为对象 if (typeof oldProps === 'object' && typeof newProps === 'object') { const oldKeys = Object.keys(oldProps); const newkeys = Object.keys(newProps); // props的个数都不同,必定发生了变化 if (oldKeys.length !== newkeys.length) { return true; } // props的个数相同的状况,遍历props,看是否有不一致的props for (let i = 0; i < oldKeys.length; i++) { const key = oldKeys[i] if (oldProps[key] !== newProps[key]) { return true; } } // 默认未改变 return false; } return false; }
由于当节点没有任何属性时,props
为null
,isPropsChanged
首先判断新旧两个节点的props
是不是同一类型,便是否存在旧节点的props
为null
,新节点有新的属性,或者反之:新节点的props
为null
,旧节点的属性被删除了。若是类型不一致,那么属性必定是被更新的。
接下来,考虑到节点在更新先后都有props
的状况,咱们须要判断更新先后的props
是否一致,即两个对象是否全等,遍历便可。若是有不相等的属性,则认为props
发生改变,须要处理props
的变化。
如今,让咱们回到咱们的updateDom
函数,看看是把Virtual DOM 节点props
的更新应用到真实 DOM 上的。
// 虚拟DOM的type未改变,对比节点的props是否改变 const oldProps = oldVNode.props || {}; const newProps = newVNode.props || {}; if (isPropsChanged(oldProps, newProps)) { const oldPropsKeys = Object.keys(oldProps); const newPropsKeys = Object.keys(newProps); // 若是新节点没有属性,把旧的节点的属性清除掉 if (newPropsKeys.length === 0) { oldPropsKeys.forEach(propKey => { removeProp($currentDom, propKey, oldProps[propKey]); }); } else { // 拿到全部的props,以此遍历,增长/删除/修改对应属性 const allPropsKeys = new Set([...oldPropsKeys, ... newPropsKeys]); allPropsKeys.forEach(propKey => { // 属性被去除了 if (!newProps[propKey]) { return removeProp($currentDom, propKey, oldProps[propKey]); } // 属性改变了/增长了 if (newProps[propKey] !== oldProps[propKey]) { return setProp($currentDom, propKey, newProps[propKey]); } }); } }
上面的代码也很是好理解,若是发现props
改变了,那么对旧的props
的每项去作遍历。把不存在的属性清除,再把新增长的属性加入到更新后的 DOM 树中:
removeProp
删除。removeProp
与setProp
相对应,因为本文篇幅有限,笔者在这里就不作过多阐述;function removeProp($target, name, value) { if (isCustomProp(name)) { return; } else if (name === 'className') { // fix react className return $target.removeAttribute('class'); } else if (isEventProp(name)) { return $target.removeEventListener(extractEventName(name), value); } else if (typeof value === 'boolean') { $target.removeAttribute(name); $target[name] = false; } else { $target.removeAttribute(name); } }
setProp
给真实 DOM 节点添加新的属性。在最后,与toRealDom
相似的是,在updateDom
中,咱们也应当处理全部子节点,对子节点进行递归调用updateDom
,一个一个对比全部子节点的VNode
是否有更新,一旦VNode
有更新,则真实 DOM 也须要从新渲染:
// 根节点相同,但子节点不一样,要递归对比子节点 if ( (oldNode.children && oldNode.children.length) || (newNode.children && newNode.children.length) ) { for (let i = 0; i < oldNode.children.length || i < newNode.children.length; i++) { updateDom($currentDom, oldNode.children[i], newNode.children[i], i); } }
以上是笔者实现的最简单的 Virtual DOM 代码,但这与社区咱们所用到 Virtual DOM 算法是有天壤之别的,笔者在这里举个最简单的例子:
<!-- old --> <ul> <li>1</li> <li>2</li> <li>3</li> <li>4</li> <li>5</li> </ul>
<!-- new --> <ul> <li>5</li> <li>1</li> <li>2</li> <li>3</li> <li>4</li> </ul>
对于上述代码中实现的updateDom
函数而言,更新先后的 DOM 结构如上所示,则会触发五个li
节点所有从新渲染,这显然是一种性能的浪费。而snabbdom则经过移动节点的方式较好地解决了上述问题,因为本文篇幅有限,而且社区也有许多对该 Virtual DOM 算法的分析文章,笔者就不在本文作过多阐述了,有兴趣的读者能够到自行研究。笔者也基于本文实例,参考snabbdom算法实现了最终的版本,有兴趣的读者能够查看本文示例最终版