虚拟 dom 的探索与学习

写在前面

本文参考的虚拟 dom 库为 https://github.com/Matt-Esch/virtual-dom

本文可以告诉下列东西,篇幅较长:

  1. virtual dom 做了哪些事
  2. virtual dom 需要提供哪些接口哪些方法
  3. 得到实现这些方法的大致思路
  4. 得到一份完整注释的参考库源码

转载请保留出处

从官方 demo 开始

// 1: Create a function that declares what the DOM should look like
function render(count)  {
    return h('div', {
        style: {
            textAlign: 'center',
            lineHeight: (100 + count) + 'px',
            border: '1px solid red',
            width: (100 + count) + 'px',
            height: (100 + count) + 'px'
        }
    }, [String(count)]);
}

// 2: Initialise the document
var count = 0;      // We need some app data. Here we just store a count.

var tree = render(count);               // We need an initial tree
var rootNode = createElement(tree);     // Create an initial root DOM node ...
document.body.appendChild(rootNode);    // ... and it should be in the document

// 3: Wire up the update logic
setInterval(function () {
    count++;

    var newTree = render(count);
    var patches = diff(tree, newTree);
    rootNode = patch(rootNode, patches);
    tree = newTree;
}, 1000);

我们可以看到,这个使用过程为:

  • 声明 render 函数,函数体内会依据 count 参数,调用 h 函数生成不同的虚拟 dom 对象
  • 调用 render 函数,依据 count 的初始值,生成初次的虚拟 dom 对象
  • 调用 createElement 函数,将虚拟 dom 对象生成真实 dom
  • 真实 dom 插入 html 文档
  • 设置定时器,定时器内部:
    • 每隔1s更新一次 count 值, 依据新的 count 值生成新的虚拟 dom 对象
    • diff 新的虚拟 dom 对象与原有的虚拟 dom 对象
    • diff 的结果 patch 到真实 dom 中

试着考虑下整个过程,我们不难发现,整个虚拟 dom 概念,需要提供的就是:

  1. 虚拟 dom 生成函数
  2. 虚拟 dom 向真实 dom 的转化函数
  3. 新旧虚拟 dom 片段的 diff 函数
  4. 将 diff 结果应用到真实 dom 上的 patch 函数

h 函数

我们首先来看 h 函数, 这个函数作用为生成虚拟 dom 的 js 对象

先考虑下我们的 html 节点,比如一个 <p> 标签:

<p class="anning" id="amnhh">这是一个P标签</p>

我们把它拆分来看:

可以得到虚拟 dom 的几个关键字:

  • tagName => 标签名
  • properties => 属性集合
  • children => 子元素集合

我们简单来看下参考库中的 h 函数的定义

function h(tagName, properties, children) {
    var childNodes = [];
    var tag, props, key, namespace;

    // 兼容 h(tagName, children) 的调用方式
    if (!children && isChildren(properties)) {
        children = properties;
        props = {};
    }

    props = props || properties || {};
    // 解析 tagName
    tag = parseTag(tagName, props);

    // support keys
    if (props.hasOwnProperty('key')) {
        // 给 key 赋值,并且将 props 对象中的 key 置空
        // 因为我们设置 key 的本意,并不是为 dom 元素设置一个常规属性 key
        // 而是为了给 dom 元素增加索引,所以 key 属性并不应该出现在 props 这个对象中
        key = props.key;
        props.key = undefined;
    }

    // support namespace
    if (props.hasOwnProperty('namespace')) {
        namespace = props.namespace;
        props.namespace = undefined;
    }

    // 主要用来处理事件绑定
    transformProperties(props);
    // 处理 children
    if (children !== undefined && children !== null) {
        addChild(children, childNodes, tag, props);
    }
    // 返回一个 VNode 对象
    return new VNode(tag, props, childNodes, key, namespace);
}

我们可以看到,h 函数其实就只是对很多参数做了处理之后,返回一个以这些参数生成的 VNode 实例:

我们刚刚可以看到,h 函数最后返回的是 VNode 的实例,这里把 VNode 叫成是一个类可能更好一些。

这个类的定义具体细节就不展开说了,只说下它做的事情:

  1. 缓存 tagName, properties, children, key, namespace 到实例下
  2. 统一将事件缓存到 hooks 属性下
  3. 计算后代所有 children 的个数,缓存到 count 属性下
  4. 一些其他的属性的缓存

具体代码可以移步有完整注释的这里 :

https://github.com/amnhh/virtual-dom/blob/master/vnode/vnode.js#L12

createElement 函数

我们有了拥有着 tagName, properties, children 等属性的 js 对象后,需要将这些遵循 html 的规范,生成真实 DOM 结构

这个 createElement 函数就是用来将虚拟 dom 对象转化为真实 DOM 结构的函数

假设我们现有的是这样一个树对象:

我们先要考虑这个节点,是一个类似 <p> 的标签,还是只是一个文本节点

创建标签我们一般的手段为 document.createElement, 创建文本节点为 document.createTextNode 方法,根据不同的情况选用不同的方法

所以这里我们会执行:

var node = document.createElement('div')

此时得到的元素为一个 <div></div>

接下来需要将 properties 应用到这个 div 上,这里我们需要考虑到,我们在 properties 不光会存储类似 id, class 这样特殊的属性,这些属性可以直接以 property 的形式赋值,表现为 nodeattribute,类似 srcidchecked

还有类似:ev-click(ev-*vdom 参考库的事件前缀,可能为函数)、style(样式)、attributes(标签属性)这些可能为对象的形式

在代码中需要对这些进行兼容处理,如 style 会对 node.style[stylePropName] 进行赋值,attributes 会使用 setAttributeremoveAttribute 进行赋值等

function applyProperties(node, props, previous) {
    // for in 遍历所有 vnode.properties
    for (var propName in props) {
        var propValue = props[propName]

        if (propValue === undefined) {
            // 不允许空...
        } else if (isHook(propValue)) {
            // 是事件 hook 的情况....
        } else {
            // 是对象的情况,此时会对 attribute 和 style 做单独处理
            if (isObject(propValue)) {
                patchObject(node, props, previous, propName, propValue);
            } else {
                // 否则就是一个 property
                // 直接可以考虑为一个 node 对象的属性
                node[propName] = propValue
            }
        }
    }
}

function patchObject(node, props, previous, propName, propValue) {
    var previousValue = previous ? previous[propName] : undefined
    // 是 attribute 则使用 setAttribute 和 removeAttribute 
    if (propName === "attributes") {
        for (var attrName in propValue) {
            var attrValue = propValue[attrName]
            if (attrValue === undefined) {
                node.removeAttribute(attrName)
            } else {
                node.setAttribute(attrName, attrValue)
            }
        }
        return
    }
    
    // other code...

    // 处理 style
    var replacer = propName === "style" ? "" : undefined
    // 赋值为类似 node.style.color = 'red' 的形式
    for (var k in propValue) {
        var value = propValue[k]
        node[propName][k] = (value === undefined) ? replacer : value
    }
}

处理 properties 之后,紧接着会去处理 children, 显而易见通过递归创建 child, 然后使用 node.appendChild:

var children = vnode.children

// 生成并插入
for (var i = 0; i < children.length; i++) {
    var childNode = createElement(children[i], opts)
    if (childNode) {
        node.appendChild(childNode)
    }
}

// 最后将整个生成处理好的 node 返回
return node

总结起来经历了这些步骤:

有完善注释的代码可以查阅

https://github.com/amnhh/virtual-dom/blob/master/vdom/create-element.js#L18

diff 函数

diff 函数可以说是我们认知里虚拟 dom 最复杂的部分了,不要方,我们一层一层拨开来看

首先 diff 的主体是两个 vnode 节点,这里先称作新旧节点,简单来说有以下的情况:

其中可复用节点的 diff 过于复杂,可以后期新开一篇来讲,这里就不展开来说了

我们可以看到在每种情况的末尾,有不同的 VPatch.VTEXT, VPatch.VNODE, VPatch.REMOVE 等,其实这东西是一种标记,标记着新旧节点之间的差异

这里贴出有详尽注释的代码供大家查阅:

function walk(a, b, patch, index) {
    // 如果 a 和 b 两个引用都完全相同
    // 直接啥都不做
    // 对 patch 也啥都不做,直接终结
    if (a === b) {
        return
    }

    var apply = patch[index]
    var applyClear = false

    // 由于我们入参是 tree 和 newTree
    // 都是 h 函数直接返回的 VNode
    // 所以直接跳过 isThunk 还有 b == null 的环节
    if (isThunk(a) || isThunk(b)) {
        thunks(a, b, patch, index)
    } else if (b == null) {
        // If a is a widget we will add a remove patch for it
        // Otherwise any child widgets/hooks must be destroyed.
        // This prevents adding two remove patches for a widget.
        if (!isWidget(a)) {
            clearState(a, patch, index)
            apply = patch[index]
        }

        apply = appendPatch(apply, new VPatch(VPatch.REMOVE, a, b))
        // 如果说 b 是一个 VNode
    } else if (isVNode(b)) {
        // 直接进入到 a, b 都是 VNode 的环节
        if (isVNode(a)) {
            // 如果是相同标签,相同 namespace,相同 key
            // 这特么也太严格了...
            // 感觉上应该是一种提升性能的体现
            // 直接调用 appendPatch 应该也是 ok 的
            // 但是如果说都满足的话,可以大量的节省时间
            // emmm... 这暂时只是个猜想
            if (
                a.tagName === b.tagName
                && a.namespace === b.namespace
                && a.key === b.key
            ) {
                // tagName,namespace,key都相同的话,说明可以认为是一个元素
                // 则我们不会去增减这个元素,只需要 diff props 看看是否需要更改就完事了
                // 对 properties 这个对象进行 diff
                // 以 test example1 为例的话
                // 这里就会返回: { style : { lineHeight : '101px', width: '101px', height: '101px' } }
                var propsPatch = diffProps(a.properties, b.properties)
                if (propsPatch) {
                    // 这里我们只修改了 props
                    // 所以 apply 被赋值为了一个 VPatch 的实例
                    // 这个 VPatch实例中,
                    // type 参数传入的是 VPatch.PROPS, 也就是只是修改了属性 props
                    // vNode 参数传入的是 a, 也就是我们老的那个 DOM 对象,即要发生改变的那个 DOM
                    // patch 参数传入的是 propsDiff 的结果,这里就是 { style : { lineHeight : '101px', width: '101px', height: '101px' } }
                    // 此时 apply 其实就是我们 appendPatch 里说的,patch1
                    // 这时候 apply 就被赋值成了 {type : 4, vNode : a, patch : {style : { lineHeight : '101px', width: '101px', height: '101px' }}}
                    apply = appendPatch(apply,
                        new VPatch(VPatch.PROPS, a, propsPatch))
                }
                // 接下来就进入到 children 的 diff
                // 我们的两个 div 同时拥有着一个 VText 节点
                apply = diffChildren(a, b, patch, apply, index)
            } else {
                apply = appendPatch(apply, new VPatch(VPatch.VNODE, a, b))
                applyClear = true
            }
        } else {
            // b 是 vnode 而 a 不是 vnode 的话
            // 则向 patch 里 append VNODE 类型的 patch
            // VNODE 类型的 patch,最终反映到真实 dom 上是会直接调用 replaceNode 的
            apply = appendPatch(apply, new VPatch(VPatch.VNODE, a, b))
            applyClear = true
        }
    } else if (isVText(b)) {
        // 如果说,b 是一个 text 节点而 a 不是一个 text 节点
        if (!isVText(a)) {
            // 这里只是多于一个 applyClear 的样子...
            apply = appendPatch(apply, new VPatch(VPatch.VTEXT, a, b))
            applyClear = true
        } else if (a.text !== b.text) {
            // 如果两者都是 VTEXT
            // 也是需要一次 patch 的
            apply = appendPatch(apply, new VPatch(VPatch.VTEXT, a, b))
        }
    } else if (isWidget(b)) {
        if (!isWidget(a)) {
            applyClear = true
        }

        apply = appendPatch(apply, new VPatch(VPatch.WIDGET, a, b))
    }

    if (apply) {
        patch[index] = apply
    }

    if (applyClear) {
        clearState(a, patch, index)
    }
}

patch

patch 用来将新旧节点的差异,更新到真实 dom 中

看到这里可能大家已经想到了各个 VPatch.* 的作用,其实 VPatch.* 是一种纽带,一边标示着新旧节点差异的类型,一边对应着不同的差异类型该执行的更新函数

VPatch 类型各种各样,在这个参考库中列出了以下类型:

VirtualPatch.NONE = 0
VirtualPatch.VTEXT = 1
VirtualPatch.VNODE = 2
VirtualPatch.WIDGET = 3
VirtualPatch.PROPS = 4
VirtualPatch.ORDER = 5
VirtualPatch.INSERT = 6
VirtualPatch.REMOVE = 7
VirtualPatch.THUNK = 8

每一种 VPatch 类型都对应着相应的处理函数:

function applyPatch(vpatch, domNode, renderOptions) {
    var type = vpatch.type
    var vNode = vpatch.vNode
    var patch = vpatch.patch

    switch (type) {
        case VPatch.REMOVE:
            return removeNode(domNode, vNode)
        case VPatch.INSERT:
            return insertNode(domNode, patch, renderOptions)
        case VPatch.VTEXT:
            return stringPatch(domNode, vNode, patch, renderOptions)
        case VPatch.WIDGET:
            return widgetPatch(domNode, vNode, patch, renderOptions)
        case VPatch.VNODE:
            return vNodePatch(domNode, vNode, patch, renderOptions)
        case VPatch.ORDER:
            reorderChildren(domNode, patch)
            return domNode
        case VPatch.PROPS:
            applyProperties(domNode, patch, vNode.properties)
            return domNode
        case VPatch.THUNK:
            return replaceRoot(domNode,
                renderOptions.patch(domNode, patch, renderOptions))
        default:
            return domNode
    }
}

会根据每种不同的 VPatch type 来调用不同的处理函数去处理旧节点,比如 REMOVE 就是移除节点,则会使用 parent.removeChild, INSERT 就是新增节点,使用 parent.appendChildVTEXTVNODE 就是会执行文本、节点的替换,使用 parent.replaceChild 来替换,等等

总结来看,patch 就是一个将 diff 的结果转换到真实 dom 中的过程:

总结

是不是总体看下来,对虚拟 dom 有了个大致的了解?完整注释可以参考这里

下面放出完整的思维导图,供大家理解:

谢谢阅读 ^ v ^

猜你喜欢

转载自www.cnblogs.com/amnhhh/p/12358913.html
今日推荐