为什么使用Virtual DOM
- 手动操作DOM比较麻烦。还需要考虑浏览器兼容性问题,虽然有JQuery等库简化DOM操作,但是随着项目的复杂DOM操作复杂提升。
- 为了简化DOM的复杂操作于是出现了各种MVVM框架,MVVM框架解决了视图和状态的同步问题
- 为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题,于是Virtual DOM出现了
- Virtual DOM的好处是当状态改变时不需要立即更新DOM,只需要创建一个虚拟树来描述DOM,Virtual DOM内部将弄清楚如何有效的更新DOM(利用Diff算法实现)。
Virtual DOM的特性
- Virtual DOM可以维护程序的状态,跟踪上一次的状态。
- 通过比较前后两次的状态差异更新真实DOM。
实现一个基础的Virtual DOM库
我们可以仿照snabbdom库https://github.com/snabbdom/snabbdom.git
自己动手实现一款迷你版Virtual DOM库。
首先,我们创建一个index.html文件,写一下我们需要展示的内容,内容如下:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>vdom</title> <style> .main { color: #00008b; } .main1{ font-weight: bold; } </style> </head> <body> <div id="app"></div> <script src="./vdom.js"></script> <script> function render() { return h('div', { style: useObjStr({ 'color': '#ccc', 'font-size': '20px' }) }, [ h('div', {}, [h('span', { onClick: () => { alert('1'); } }, '文本'), h('a', { href: 'https://www.baidu.com', class: 'main main1' }, '点击') ]), ]) } // 页面改变 function render1() { return h('div', { style: useStyleStr({ 'color': '#ccc', 'font-size': '20px' }) }, [ h('div', {}, [h('span', { onClick: () => { alert('1'); } }, '文本改变了') ]), ]) } // 首次加载 mountNode(render, '#app'); // 状态改变 setTimeout(()=>{ mountNode(render1, '#app'); },3000) </script> </body> </html>
我们在body
标签内创建了一个id是app
的DOM元素,用于被挂载节点。接着我们引入了一个vdom.js文件,这个文件就是我们将要实现的迷你版Virtual DOM库。最后,我们在script
标签内定义了一个render
方法,返回为一个h
方法。调用mountNode
方法挂载到id是app
的DOM元素上。h
方法中数据结构我们是借鉴snabbdom库,第一个参数是标签名,第二个参数是属性,最后一个参数是子节点。还有,你可能会注意到在h
方法中我们使用了useStyleStr
方法,这个方法主要作用是将style样式转化成页面能识别的结构,实现代码我会在最后给出。
思路理清楚了,展示页面的代码也写完了。下面我们将重点看下vdom.js
,如何一步一步地实现它。
第一步
我们看到index.html文件中首先需要调用mountNode
方法,所以,我们先在vdom.js文件中定义一个mountNode
方法。
// Mount node function mountNode(render, selector) { }
接着,我们会看到mountNode
方法第一个参数是render
方法,render
方法返回了h方法
,并且看到第一个参数是标签,第二个参数是属性,第三个参数是子节点。
那么,我们接着在vdom.js文件中再定义一个h方法。
function h(tag, props, children) { return { tag, props, children }; }
还没有结束,我们需要根据传入的三个参数tag
、props
、children
来挂载到页面上。
我们需要这样操作。我们在mountNode
方法内封装一个mount
方法,将传给mountNode
方法的参数经过处理传给mount
方法。
// Mount node function mountNode(render, selector) { mount(render(), document.querySelector(selector)) }
接着,我们定义一个mount
方法。
function mount(vnode, container) { const el = document.createElement(vnode.tag); vnode.el = el; // props if (vnode.props) { for (const key in vnode.props) { if (key.startsWith('on')) { el.addEventListener(key.slice(2).toLowerCase(), vnode.props[key],{ passive:true }) } else { el.setAttribute(key, vnode.props[key]); } } } if (vnode.children) { if (typeof vnode.children === "string") { el.textContent = vnode.children; } else { vnode.children.forEach(child => { mount(child, el); }); } } container.appendChild(el); }
第一个参数是调用传进来的render
方法,它返回的是h
方法,而h
方返回一个同名参数的对象{ tag, props, children }
,那么我们就可以通过vnode.tag
、vnode.props
、vnode.children
取到它们。
我们看到先是判断属性,如果属性字段开头含有,on
标识就是代表事件,那么就从属性字段第三位截取,利用addEventListener
API创建一个监听事件。否则,直接利用setAttribute
API设置属性。
接着,再判断子节点,如果是字符串,我们直接将字符串赋给文本节点。否则就是节点,我们就递归调用mount
方法。
最后,我们将使用appendChild
API把节点内容挂载到真实DOM中。
页面正常显示。
第二步
我们知道Virtual DOM有以下两个特性:
- Virtual DOM可以维护程序的状态,跟踪上一次的状态。
- 通过比较前后两次的状态差异更新真实DOM。
这就利用到了我们之前提到的diff算法。
我们首先定义一个patch方法。因为要对比前后状态的差异,所以第一个参数是旧节点,第二个参数是新节点。
function patch(n1, n2) { }
下面,我们还需要做一件事,那就是完善mountNode
方法,为什么这样操作呢?是因为当状态改变时,只更新状态改变的DOM,也就是我们所说的差异更新。这时就需要配合patch
方法做diff算法。
相比之前,我们加上了对是否挂载节点进行了判断。如果没有挂载的话,就直接调用mount
方法挂载节点。否则,调用patch
方法进行差异更新。
let isMounted = false; let oldTree; // Mount node function mountNode(render, selector) { if (!isMounted) { mount(oldTree = render(), document.querySelector(selector)); isMounted = true; } else { const newTree = render(); patch(oldTree, newTree); oldTree = newTree; } }
那么下面我们将主动看下patch
方法,这也是在这个库中最复杂的方法。
function patch(n1, n2) { // Implement this // 1. check if n1 and n2 are of the same type if (n1.tag !== n2.tag) { // 2. if not, replace const parent = n1.el.parentNode; const anchor = n1.el.nextSibling; parent.removeChild(n1.el); mount(n2, parent, anchor); return } const el = n2.el = n1.el; // 3. if yes // 3.1 diff props const oldProps = n1.props || {}; const newProps = n2.props || {}; for (const key in newProps) { const newValue = newProps[key]; const oldValue = oldProps[key]; if (newValue !== oldValue) { if (newValue != null) { el.setAttribute(key, newValue); } else { el.removeAttribute(key); } } } for (const key in oldProps) { if (!(key in newProps)) { el.removeAttribute(key); } } // 3.2 diff children const oc = n1.children; const nc = n2.children; if (typeof nc === 'string') { if (nc !== oc) { el.textContent = nc; } } else if (Array.isArray(nc)) { if (Array.isArray(oc)) { // array diff const commonLength = Math.min(oc.length, nc.length); for (let i = 0; i < commonLength; i++) { patch(oc[i], nc[i]); } if (nc.length > oc.length) { nc.slice(oc.length).forEach(c => mount(c, el)); } else if (oc.length > nc.length) { oc.slice(nc.length).forEach(c => { el.removeChild(c.el); }) } } else { el.innerHTML = ''; nc.forEach(c => mount(c, el)); } } }
我们从patch
方法入参开始,两个参数分别是在mountNode
方法中传进来的旧节点oldTree
和新节点newTree
,首先我们进行对新旧节点的标签进行对比。
如果新旧节点的标签不相等,就移除旧节点。另外,利用nextSibling
API取指定节点之后紧跟的节点(在相同的树层级中)。然后,传给mount
方法第三个参数。这时你可能会有疑问,mount
方法不是有两个参数吗?对,但是这里我们需要传进去第三个参数,主要是为了对同级节点进行处理。
if (n1.tag !== n2.tag) { // 2. if not, replace const parent = n1.el.parentNode; const anchor = n1.el.nextSibling; parent.removeChild(n1.el); mount(n2, parent, anchor); return }
所以,我们重新修改下mount
方法。我们看到我们只是加上了对anchor
参数是否为空的判断。
如果anchor
参数不为空,我们使用insertBefore
API,在参考节点之前插入一个拥有指定父节点的子节点。insertBefore
API第一个参数是用于插入的节点,第二个参数将要插在这个节点之前,如果这个参数为 null
则用于插入的节点将被插入到子节点的末尾。
如果anchor
参数为空,直接在父节点下的子节点列表末尾添加子节点。
function mount(vnode, container, anchor) { const el = document.createElement(vnode.tag); vnode.el = el; // props if (vnode.props) { for (const key in vnode.props) { if (key.startsWith('on')) { el.addEventListener(key.slice(2).toLowerCase(), vnode.props[key],{ passive:true }) } else { el.setAttribute(key, vnode.props[key]); } } } if (vnode.children) { if (typeof vnode.children === "string") { el.textContent = vnode.children; } else { vnode.children.forEach(child => { mount(child, el); }); } } if (anchor) { container.insertBefore(el, anchor); } else { container.appendChild(el); } }
下面,我们再回到patch
方法。如果新旧节点的标签相等,我们首先要遍历新旧节点的属性。我们先遍历新节点的属性,判断新旧节点的属性值是否相同,如果不相同,再进行进一步处理。判断新节点的属性值是否为null
,否则直接移除属性。然后,遍历旧节点的属性,如果属性名不在新节点属性表中,则直接移除属性。
分析完了对新旧节点属性的对比,接下来,我们来分析第三个参数子节点。
首先,我们分别定义两个变量oc
、nc
,分别赋予旧节点的children
属性和新节点的children
属性。如果新节点的children
属性是字符串,并且新旧节点的内容不相同,那么就直接将新节点的文本内容赋予即可。
接下来,我们看到利用Array.isArray()
方法判断新节点的children
属性是否是数组,如果是数组的话,就执行下面这些代码。
else if (Array.isArray(nc)) { if (Array.isArray(oc)) { // array diff const commonLength = Math.min(oc.length, nc.length); for (let i = 0; i < commonLength; i++) { patch(oc[i], nc[i]); } if (nc.length > oc.length) { nc.slice(oc.length).forEach(c => mount(c, el)); } else if (oc.length > nc.length) { oc.slice(nc.length).forEach(c => { el.removeChild(c.el); }) } } else { el.innerHTML = ''; nc.forEach(c => mount(c, el)); } }
我们看到里面又判断旧节点的children
属性是否是数组。
如果是,我们取新旧子节点数组的长度两者的最小值。然后,我们将其循环递归patch
方法。为什么取最小值呢?是因为如果取的是他们共有的长度。然后,每次遍历递归时,判断nc.length
和oc.length
的大小,循环执行对应的方法。
如果不是,直接将节点内容清空,重新循环执行mount
方法。
这样,我们搭建的迷你版Virtual DOM库就这样完成了。
页面如下所示。
源码
index.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>vdom</title> <style> .main { color: #00008b; } .main1{ font-weight: bold; } </style> </head> <body> <div id="app"></div> <script src="./vdom.js"></script> <script> function render() { return h('div', { style: useObjStr({ 'color': '#ccc', 'font-size': '20px' }) }, [ h('div', {}, [h('span', { onClick: () => { alert('1'); } }, '文本'), h('a', { href: 'https://www.baidu.com', class: 'main main1' }, '点击') ]), ]) } // 页面改变 function render1() { return h('div', { style: useStyleStr({ 'color': '#ccc', 'font-size': '20px' }) }, [ h('div', {}, [h('span', { onClick: () => { alert('1'); } }, '文本改变了') ]), ]) } // 首次加载 mountNode(render, '#app'); // 状态改变 setTimeout(()=>{ mountNode(render1, '#app'); },3000) </script> </body> </html>
vdom.js
// vdom --- function h(tag, props, children) { return { tag, props, children }; } function mount(vnode, container, anchor) { const el = document.createElement(vnode.tag); vnode.el = el; // props if (vnode.props) { for (const key in vnode.props) { if (key.startsWith('on')) { el.addEventListener(key.slice(2).toLowerCase(), vnode.props[key],{ passive:true }) } else { el.setAttribute(key, vnode.props[key]); } } } if (vnode.children) { if (typeof vnode.children === "string") { el.textContent = vnode.children; } else { vnode.children.forEach(child => { mount(child, el); }); } } if (anchor) { container.insertBefore(el, anchor); } else { container.appendChild(el); } } // processing strings function useStyleStr(obj) { const reg = /^{|}/g; const reg1 = new RegExp('"',"g"); const str = JSON.stringify(obj); const ustr = str.replace(reg, '').replace(',', ';').replace(reg1,''); return ustr; } function patch(n1, n2) { // Implement this // 1. check if n1 and n2 are of the same type if (n1.tag !== n2.tag) { // 2. if not, replace const parent = n1.el.parentNode; const anchor = n1.el.nextSibling; parent.removeChild(n1.el); mount(n2, parent, anchor); return } const el = n2.el = n1.el; // 3. if yes // 3.1 diff props const oldProps = n1.props || {}; const newProps = n2.props || {}; for (const key in newProps) { const newValue = newProps[key]; const oldValue = oldProps[key]; if (newValue !== oldValue) { if (newValue != null) { el.setAttribute(key, newValue); } else { el.removeAttribute(key); } } } for (const key in oldProps) { if (!(key in newProps)) { el.removeAttribute(key); } } // 3.2 diff children const oc = n1.children; const nc = n2.children; if (typeof nc === 'string') { if (nc !== oc) { el.textContent = nc; } } else if (Array.isArray(nc)) { if (Array.isArray(oc)) { // array diff const commonLength = Math.min(oc.length, nc.length); for (let i = 0; i < commonLength; i++) { patch(oc[i], nc[i]); } if (nc.length > oc.length) { nc.slice(oc.length).forEach(c => mount(c, el)); } else if (oc.length > nc.length) { oc.slice(nc.length).forEach(c => { el.removeChild(c.el); }) } } else { el.innerHTML = ''; nc.forEach(c => mount(c, el)); } } } let isMounted = false; let oldTree; // Mount node function mountNode(render, selector) { if (!isMounted) { mount(oldTree = render(), document.querySelector(selector)); isMounted = true; } else { const newTree = render(); patch(oldTree, newTree); oldTree = newTree; } }