2018-01-31
Virtual-DOM
构造 VNode 对象
接续前篇的 《虚拟 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 联系起来,在我的实现里包含:
  1. VNode 和 Text 类型来抽象表达 DOM 节点,构造虚拟 DOM
  2. 用 vid 区分不同的节点 (diff 和 patch 操作的时候需要用到)
  3. 从真实的 DOM 中生成虚拟 DOM
  4. 从虚拟 DOM 中生成真实的 DOM
  5. $ 字段表示前一次的 render 结果

下一篇先介绍 diff 算法,最后一篇来介绍虚拟 DOM 的 diffpatch 操作的实现。




回到顶部