接续前篇的 《虚拟 DOM 原理表述》,本篇继续探讨虚拟 DOM 的原理以及一个可行的实现。
上篇的主题:
虚拟 DOM 技术是指用 JavaScript 来表达 DOM 的数据结构,并由一定的算法来找出虚拟 DOM 变化前后的差异,最后将差异应用到真实的 DOM 上,从而达到性能优化的技术目的。
现在来谈谈虚拟 DOM 的数据结构,即要怎样用 JavaScript 表达 DOM,若读者无意于实现,本篇可以跳过不看了(本篇基本上是 Code)
# VNode & Text ↵
一个 DOM 节点有很多类型,在此着重注意两种类型,一种是 nodeType 为 1 的标签,另外一种是 nodeType 为 3 的文本节点。
这样来看似乎可以表达成这样子:
00let domHTML = ` 01 <div id="app" class="abc"> 02 <p>hello</p> 03 </div> 04`; 05 06let vdom = { 07 tag: 'div', 08 vid: 0, 09 nodeType: 1, 10 props: { 11 id: 'app', 12 class: 'abc' 13 }, 14 children: [ 15 { 16 tag: 'p', 17 vid: 1, 18 nodeType: 1, 19 props: {}, 20 children: [ 21 { 22 vid: 2, 23 nodeType: 3, 24 text: 'hello' 25 } 26 ] 27 } 28 ] 29}
当然,为了标识出不同的节点,最好给节点加上 id,由此可以写出如下构造函数:
00// VNode.js 01module.exports = VNode; 02 03/** 04 * @description 产生不重复的 id 05 * @returns { Number } 06 */ 07function uuid(){ 08 let id = vid.id || 1000; 09 10 vid.id = id + 1; 11 12 return id; 13} 14 15/** 16 * @description VNode 构造标签节点 17 * @param { String } tag 18 * @param { Object } props 19 * @param { Array<VNode> } children 20 */ 21function VNode(tag = 'div', props = {}, children = []){ 22 this.tag = tag; 23 this.props = props; 24 this.nodeType = 1; 25 26 // 这里的意思是说,如果 props 里显式的钦点了 vid 那么就不生成 vid 27 if (props.vid){ 28 this.vid = props.vid; 29 } else { 30 this.vid = uuid(); 31 } 32 33 this.children = children; 34} 35 36/** 37 * @description VNode 为了方便使用取的别名 Alias 38 * @param { String } $1 tag 39 * @param { Object } $2 props 40 * @param { Array<VNode> } $3 children 41 * @returns { VNode } 42 */ 43VNode.h = ($1, $2, $3) => new VNode($1, $2, $3); 44 45/** 46 * @description 构造文本节点 47 * @param { String } str 48 */ 49function Text(str){ 50 this.nodeType = 3; 51 this.vid = uuid(); 52 this.text = str; 53} 54 55/** 56 * @description 为了方便使用取的别名 Alias 57 * @param { String } $ 文本 58 * @returns { Text } 59 */ 60Text.t = $ => new Text($); 61VNode.t = Text.t; 62 63// 挂到 VNode 上 64VNode.Text = Text;
那么构造上面的那个vdom的时候就很好写了:
00let { h, t } = require('./path/to/VNode'); 01 02let domHTML = ` 03 <div id="app" class="abc"> 04 <p>hello</p> 05 </div> 06`; 07 08let vdom = h('div', { id: 'app', class: 'abc' }, [ 09 h('p', {}, [ 10 t('hello') 11 ]) 12]);
这样就完成了 VNode 的抽象表达。
# 从真实的 DOM 里生成 VNode 树 ↵
# from$Node
为了调试的方便,我希望能参考真实的 DOM 并复制一份 VNode 树出来,即从 DOM 里生成 VNode 虚拟 DOM 树,其递归实现如下:
00// from$Node 01const VNode = require('./VNode'); 02 03let { h } = VNode; 04 05module.exports = from$Node; 06 07/** 08 * @description 根据 DOM 节点构造 VNode 对象 09 * @param { Node } 节点 DOM 10 * @returns { VNode } 虚拟 DOM 11 */ 12function from$Node($node){ 13 let { nodeType } = $node; 14 if (nodeType === 3){ 15 // 过滤掉一些没用的文本节点 (全是空格和回车的无意义节点) 16 if (!$node.nodeValue.trim()) return null; 17 else return new VNode.Text($node.nodeValue); 18 } else if (nodeType === 1) { 19 let tag = $node.tagName.toLowerCase(); 20 21 let props = Array.from($node.attributes).reduce((props, attr) => { 22 let { name, value } = attr; 23 24 props[name] = value; 25 26 return props; 27 }, {}); 28 29 let $children = Array.from($node.childNodes); 30 31 let children = $children.map($child => { 32 if ($child.nodeType === 3 || $child.nodeType === 1){ 33 // 子节点上继续本函数 34 return from$Node($child); 35 } 36 }).filter(e => e); 37 38 return h(tag, props, children); 39 } 40}
# from$html
在上面的基础上,完成从 html 生成 VNode 树:
00// from$Html 01const VNode = require('./VNode') 02 , from$Node = require('./from$Node') 03 04module.exports = from$Html; 05 06/** 07 * @description 根据 DOM 节点构造 VNode 对象 08 * @param { String } html 09 * @returns { VNode } 虚拟 DOM 10 */ 11function from$Html(html){ 12 html = html.trim(); 13 14 let div = document.createElement('div'); 15 div.innerHTML = html; 16 div = div.children[0]; 17 18 return from$Node(div); 19}
# 根据 VNode 生成真实的 DOM ↵
还有一种情况就是,根据当前的 VNode 生成真实的 DOM 节点,其实现如下:
00/** 01 * @description 渲染为 DOM 树 02 * @return { Node } dom 树 03 */ 04VNode.prototype.render = function(){ 05 let { tag, props, vid, children, text } = this; 06 07 let $node = document.createElement(tag); 08 09 // Selector Bind 10 this.$ = $node; 11 12 Object.keys(props).forEach(key => { 13 let val = props[key]; 14 15 try { 16 $node.setAttribute(key, val); 17 } catch (err) {} 18 }); 19 20 $node.setAttribute('vid', vid); 21 22 children.forEach(child => { 23 if (child.nodeType === 3){ 24 if (!child.text.trim()) return null; 25 } 26 27 // Text 类型也有 render 方法因此不会报错 28 let $child = child.render(); 29 $node.appendChild($child); 30 }); 31 32 return $node; 33} 34 35/** 36 * @description 获得先前的 render 结果,如果没有则调用 this.render 37 * @returns { Node } dom 节点 38 */ 39VNode.prototype.$$ = function(){ 40 if (this.$) return this.$; 41 else return this.render(); 42} 43 44/** 45 * @description Text 节点渲染 46 * @returns { Element } 返回文本节点 47 */ 48Text.prototype.render = function(){ 49 let { text } = this; 50 51 let $node = document.createTextNode(text); 52 53 this.$ = $node; 54 55 return $node; 56} 57 58/** 59 * @description 获得先前的 render 结果,如果没有则调用 this.render 60 * @returns { Element } 返回节点 61 */ 62Text.prototype.$$ = function(){ 63 if (this.$) return this.$; 64 else return this.render(); 65}
# 将 VNode 挂到真实的 DOM 上 ↵
00/** 01 * @description 挂在某个节点上,渲染 VNode 并替换掉传入的 DOM 02 * @param { Element } $el 03 */ 04VNode.prototype.$mount = function($el){ 05 let $tree = this.render(); 06 07 $el.replaceWith(tree); 08 // 等价于: 09 // $el.parentElement.replaceChild($tree, $el); 10 // replaceChild 是比较新的 DOM API 11}
# TL;DR ↵
总之,需要实现一种抽象,将真实的 DOM 和虚拟 DOM 联系起来,在我的实现里包含:
- VNode 和 Text 类型来抽象表达 DOM 节点,构造虚拟 DOM
- 用 vid 区分不同的节点 (diff 和 patch 操作的时候需要用到)
- 从真实的 DOM 中生成虚拟 DOM
- 从虚拟 DOM 中生成真实的 DOM
- $ 字段表示前一次的 render 结果
下一篇先介绍 diff 算法,最后一篇来介绍虚拟 DOM 的
diff
和 patch
操作的实现。