Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vue3 三大核心模块之【渲染器】 #16

Open
hexiaokang opened this issue Sep 26, 2022 · 0 comments
Open

Vue3 三大核心模块之【渲染器】 #16

hexiaokang opened this issue Sep 26, 2022 · 0 comments

Comments

@hexiaokang
Copy link
Owner

hexiaokang commented Sep 26, 2022

从编译器原理中我们知道,我们所编写的template内容在经过编译器处理后最终输出render函数,调用render函数则会输出VDOM(虚拟DOM)。所谓虚拟DOM,本质就是用JS对象形式来描述DOM结构。如下:

const vNode = {
    type: 'div',
    props: {},
    children: ['Hi']
}

渲染器的作用就是将VDOM渲染为真实的DOM。不过Vue3的渲染器在功能上有相应的拓展,所以它也可以渲染在其他支持JS的平台上,此处仅讲解针对浏览器平台的渲染。

整体设计

首先来看设计一个渲染器需要编写的核心代码:

function createRenderer(options = {}) {
    const { createElement, insert, setElementText, patchProps } = options
    // 渲染入口
    function render(vnode, container) {
        if(vnode) {
            // 挂载 或 更新
            patch(container._vnode, vnode, container)
        } else {
            // 卸载
            unmount(container._vnode)
        }
        // 绑定vnode
        container._vnode = vnode
    }
    // 比较新旧node, n1为旧node,n2为新node
    function patch(n1, n2, container) {
        if(n1 && n1.type !== n2.type) {
            // 优先比较节点类型是否一致,不同则卸载旧node
            unmount(n1)
            n1 = null
        }
        if(!n1) {
            // 挂载
            mountedElement(n2, container)
        } else {
            // 更新
            patchElement(n1, n2)
        }
    }
    // 挂载节点
    function mountedElement(vnode, container) {
        const el = vnode.el = createElement(vnode.type)
        if(vnode.props) {
            for(let key in vnode.props) {
                // 设置属性
                patchProps(el, key, null, vnode.props[key])
            }
        }
        if(typeof vnode.children === 'string') {
            setElementText(el, vnode.children)
        } else if (Array.isArray(vnode.children)) {
            vnode.children.forEach(c => patch(null, c, container))
        } else {
            // 其他节点情况
        }
        insert(el, container)
    }
    return {
        render
    }
}

其中有几个重点务必理解清楚:
1、createRenderer用来创建一个渲染器,具体的渲染函数render之所以采用内部创建再返回这种方式,就是因为考虑到渲染器的通用性。实际上createRenderer不仅返回render,还会返回hydrate方法,hydrate用于SSR。
2、渲染器中将依赖平台特定API的方法都抽离至渲染器外面,比如createElement、insert,作为参数传入,这样就能实现渲染器函数在不同平台运行,便于拓展。
3、patch函数是渲染器的核心,接收旧节点n1和新节点n2,当旧节点n1为空时,说明执行挂载,当新节点n2为空时,说明要执行卸载操作。
4、patch函数优先判断两个节点的节点类型是否一致,如果不同则直接卸载旧节点。

更新子节点

一个标签的子节点有三种情况:无子节点、文本子节点、子节点list。因此比较两个标签的子节点时共有九种情况,如下代码实现:

function patchChildren(n1, n2, container) {
    const { children } = n2
    if(typeof children === 'string') {
        if(Array.isArray(n1.children)) {
            n1.children.forEach(ch => unmount(ch))
        }
        setElementText(container, children)
    } else if (Array.isArray(children)) {
        if(Array.isArray(n1.children)) {
            n1.children.forEach(ch => unmount(ch))
            // 新旧子节点都是list,DIff算法优化
        } else {
            if(typeof n1.children === 'string') {
                setElementText(container, '')           
            }
            n2.children.forEach(ch => patch(null, ch, container))
        }
    } else {
        if(Array.isArray(n1.children)) {
            n1.children.forEach(ch => unmount(ch))
        } else if(typeof n1.children === 'string') {
            setElementText(container, '')
        }
    }
}

从比较函数中可发现:
1、当新节点的子节点为文本时,需要判断旧节点的子节点是否为list,如果是则逐个卸载即可,最后再设置文本。
2、当新节点的子节点为list时,判断旧节点的子节点是否也为list,如果是则先卸载原list,再挂载新list。但是如此处理会耗费性能,所以后续有Diff算法专门优化此环节。如果旧节点的子节点原来为文本,需要先设置文本为空。
3、当新节点的子节点为空时,说明需要卸载全部子节点。

渲染属性

为节点设置属性,我们很自然会想到用setAttribute API,但实际上并没有那么简单。首先我们有必要了解下元素属性有两种 - HTML attributes 和 DOM properties。直接在html元素上设置或者调用setAttribute就是设置的HTML attributes,采用el.xx这种方式设置就是DOM properties。有些属性不能用setAttribute设置,比如textContent,用el.setAttribute('textContent', 'test value')设置文本值时无法生效,还有disabled属性调用setAttribute设置时也一样会出现异常。另一种情况,有些属性值无法用el.xx设置,比如input元素的form属性,只能用setAttribute方式设置。因此有必要在patchProps中兼容这些常见的情形:

function patchProps(el, key, prevValue, nextValue) {
    function shouldSetAsProps(el, key, value) {
        if(key === 'form' && el.tagName === 'INPUT') return false
        return key in el
    }
    if(shouldSetAsProps(el, key, nextValue)) {
        const type = typeof el[key]
        if(key === 'class') {
            el.className = nextValue || ''
        } else if(type === 'boolean' && nextValue === '') {
            el[key] === true
        } else {
            el[key] = nextValue
        }
    } else {
        el.setAttribute(key, nextValue)
    }
}

从函数中,可以得出几个重点信息:
1、如果当前dom有对应属性的情况下,优先采用dom properties的方式设置属性值,但是当属性值为boolean值,且传入的新值为‘’空字符串时,需要手动设置true,这才是用户的本意。
2、设置class属性时需要特殊处理,因为设置class有多种方式,但是设置className这种方式性能是最佳的。

事件处理

事件无非是用addEventListener和removeEventListener来处理,但是考虑到性能以及绑定的事件会很多,且同个事件绑定的函数还可能多个等情况,需要设计一个合理的绑定机制,如下为核心代码:

function patchProps(el, key, prevValue, nextValue) {
    // 省略上面已讲解过的代码
    if(/^on/.test(key)) {
        const invokers = el._vei || (el._vei = {})
        let invoker = invokers[key]
        const name = key.slice(2).toLowerCase()
        if(nextValue) {
            if(!invoker) {
                invoker = el._vei[key] = (e) => {
                    // 判断当前函数执行时间必须大于绑定时间点
                    // 以防父元素事件在子元素事件冒泡阶段绑定而触发的异常情况
                    if(e.timeStamp < invoker.attached) return
                    // 一个事件可绑定多个函数,props中传入数组即可
                    if(Array.isArray(invoker.value)) {
                        invoker.value.forEach(fn => fn(e))
                    } else {
                        invoker.value(e)
                    }
                }
                invoker.value = nextValue
                invoker.attached = performance.now()
                el.addEventListener(name, invoker)
            } else {
                invoker.value = nextValue
            }
        } else {
            el.removeEventListener(name, invoker)
        }
    }
}

从上面逻辑中可以发现:
为el元素新增invokers对象,invokers中存储了为每一个事件创建的虚拟函数invoker,再为invoker函数设置value属性,value存储真实的绑定函数。最后将invoker绑定元素上。这样做的好处在于:为不同事件绑定不同函数,避免覆盖。同时,也是有利于性能提升,因为在事件更新时不用多次调用removeEventListener解绑函数,而是直接修改Invoker.value值。

至此,我们已经实现了渲染器的核心功能,可对节点、属性、事件执行常规的挂载、卸载、更新操作。但是还有一个重要的问题没有解决 - 子节点更新时的性能优化。在更新子节点时,上面采用的方式是暴力卸载+挂载,这种方式无疑是特别耗费性能,因为会多出成倍的DOM操作。因此Vue设计了DIff算法对其进行优化。

Diff算法

Diff算法只考虑新旧子节点都是list的情况,目的就是为了在更新子节点list时尽可能的减少性能开销。从Vue诞生初期到现在,diff算法经历多版优化,最初是采用简单diff算法,Vue2中是采用双端diff,最新的Vue3中是采用快速diff算法。
tips: 以下核心逻辑中简化了type值的判断

简单diff

更新子节点list时可能存在如下几种情况:

  • 新list中某个节点与旧list中某个节点key值相同,文本不同,这种节点则更新文本即可
  • 新list与旧list中,存在key和文本相同的节点,但是顺序不一致,这种节点移动位置即可
  • 新list中存在旧list中没有的节点,这种节点需要挂载
  • 旧list中存在新list中没有的节点,这种节点则需要被卸载

这里有必要重点讲下key机制
key值好比是VNode的“身份证号”,判断节点type和key值如果一致则可复用DOM节点,减少DOM操作的性能损耗。如果子节点list可能存在更新,避免使用index索引或random()随机数做key值。

下面来看diff的核心逻辑:

function patchChildren(n1, n2, container) {
    if(typeof n2.children === 'string') {
        // 处理文本节点
    } else if(Array.isArray(n2.children)) {
        const oldChildren = n1.children
        const newChildren = n2.children
       // 记录最大索引值
       // 原理: 遍历新旧列表时,如果节点在旧列表中对应的索引值是呈递增趋势,则位置不变,反之则需要移动位置来保证跟新列表顺序一致
        let lastIndex = 0
        for(let i = 0; i < newChildren.length; i++) {
            const newVNode = newChildren[i]
            let findSameKey = false
            for(let j = 0; j < oldChildren.length; j++) {
                const oldVNode = oldChildren[j]
                // 通过key判断是否有可复用的DOM节点,移动即可
                if(newVNode.key === oldVNode.key) {
                    findSameKey = true
                    patch(oldVNode, newVNode, container)
                    if(j < lastIndex) {
                        const prevVNode = newChildren[i - 1]
                        if(prevVNode) {
                            const anchor = prevVNode.el.nextSibling
                            // 移动节点
                            insert(newVNode.el, container, anchor)
                        }
                    } else {
                        lastIndex = j
                    }
                    break
                }
            }
            if(!findSameKey) {
                // 说明未找到可复用节点
                const prevVNode = newChildren[i - 1]
                let anchor = null
                if(prevVNode) {
                    anchor = prevVNode.el.nextSibling
                } else {
                    anchor = container.firstChild
                }
                // 挂载新节点
                patch(null, newVNode, container, anchor)
            }
        }
        for(let j = 0; j < oldChildren.length; j++) {
            const has = newChildren.find(n => n.key === oldChildren[j].key)
            // 判断旧节点list中是否有未处理的节点,执行卸载
            if(!has) {
                unmount(oldChildren[j])
            }
        }
    } else {
        // 其他情况
    }
}

image

从函数实现以及简单diff流程图可以清晰理解比较过程:
1、判断新节点中P2时,发现旧list中有相同节点,则复用P2节点,更新最大索引值为1
2、判断新节点中P0时,发现旧list中没有,则需要执行挂载,挂载在P2节点后面
3、判断P1节点时,发现旧list中有P1,但此时P1在旧list中的索引为0,比原最大值1要小,所以要进行位置移动,即插入至已创建的真实DOM最末尾处
4、最后新节点全部处理完毕,需要判断旧list中是否有未处理的节点,有则执行卸载,如P3

双端Diff

简单diff算法比暴力卸载+挂载有明显的性能提升。但是在有些场景下依然有优化空间,因为它的比较模式太过机械固定。因此Vue2中采用了双端diff算法对其进行优化。双端diff的核心在于它会同时比较新旧两组节点的端点是否相等。具体逻辑如下:

function updateChildren(n1, n2, container) {
    const newChildren = n2.children
    const oldChildren = n1.children
    let oldStartIndex = 0
    let oldEndIndex = oldChildren.length - 1
    let newStartIndex = 0
    let newEndIndex = newChildren.length - 1
    let oldStartVNode = oldChildren[0]
    let oldEndVNode = oldChildren[oldEndIndex]
    let newStartVNode = newChildren[0]
    let newEndVNode = newChildren[newEndIndex]
    while(newStartIndex <= newEndIndex && oldStartIndex <= oldEndIndex) {
        if(!oldStartVNode) {
            // 说明oldStartVNode对应的节点已被处理
            oldStartVNode = oldChildren[++oldStartIndex]
        } else if (!oldEndVNode) {
            // 说明oldEndVNode对应的节点已被处理
            oldEndVNode = oldChildren[--oldEndIndex]
        } else if(oldStartVNode.key === newStartVNode.key) {
            // 比较新旧子节点列表的第一个节点
            patch(oldStartVNode, newStartVNode, container)
            oldStartVNode = oldChildren[++oldStartIndex]
            newStartVNode = newChildren[++newStartIndex]
        } else if (oldEndVNode.key === newEndVNode.key) {
            // 比较新旧子节点列表的最后一个节点
            patch(oldEndVNode, newEndVNode, container)
            oldEndVNode = oldChildren[--oldEndIndex]
            newEndVNode = newChildren[--newEndIndex]
        } else if (oldStartVNode.key === newEndVNode.key) {
            // 比较旧节点列表的第一个与新节点列表的最后一个
            patch(oldStartVNode, newEndVNode, container)
            insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)
            oldStartVNode = oldChildren[++oldStartIndex]
            newEndVNode = newChildren[--newEndIndex]
        } else if (oldEndVNode.key === newStartVNode.key) {
            // 比较旧节点列表的最后一个与新节点列表的第一个
            patch(oldEndVNode, newStartVNode, container)
            insert(oldEndVNode.el, container, oldStartVNode.el)
            oldEndVNode = oldChildren[--oldEndIndex]
            newStartVNode = newChildren[++newStartIndex]
        } else {
            // 四个端点都未找到相同key值的节点
            // 则判断当前新列表的第一个节点是否在旧列表中出现过
            const indexInOld = oldChildren.find(
                node => node.key === newStartVNode.key
            )
            if(indexInOld => -1) {
                // 出现过则移动此节点
                const vNodeToMove = oldChildren[indexInOld]
                patch(vNodeToMove, newStartVNode, container)
                insert(vNodeToMove.el, container, oldStartVNode.el)
                // 在旧列表中原位置设置节点为空,后面循环时可直接跳过
                oldChildren[indexInOld] = undefined
            } else {
                // 未出现则新增此节点
                patch(null, newStartVNode, container, oldStartVNode.el)
            }
            newStartVNode = newChildren[++newStartIndex]
        }
    }
    // 双端diff处理完之后判断新旧列表是否有未处理的节点
    if(oldEndIndex < oldStartIndex && newStartIndex <= newEndIndex) {
        // 新列表中存在未处理的节点,需要挂载
        for(let i = newStartIndex; i <= newEndIndex; i++) {
            const anchor = newChildren[newEndIndex + 1] ? newChildren[newEndIndex + 1] : null
            patch(null, newChildren[i], container, anchor)
        }
    } else if (newEndIndex < newStartIndex && oldStartIndex <= oldEndIndex) {
        // 旧列表中存在未处理的节点,则需要卸载
        for(let i = oldStartIndex; i <= oldEndIndex; i++) {
            if(!oldChildren[i]) continue
            unmount(oldChildren[i])
        }
    }
}

从核心逻辑中可得出总结:
1、双端diff设置四个索引值(newStartIndex、newEndIndex、oldStartIndex、oldEndIndex)分别指向新旧节点列表的开始节点、结束节点。
2、满足循环条件下,每轮比较会判断新旧列表的开始节点、结束节点是否存在key值相等的情况,如果有则进行复用,节点相同但是位置不同则需要移动节点,同时,索引值也需要相应移动。比如当newStartIndex对应的节点与oldEndIndex对应节点相同时,则需要将oldEndIndex对应的节点进行移动。还需要修改索引值:newStartIndex递增,oldEndIndex递减。
3、如果新旧列表的开始节点、结束节点不存在相等的情况,则判断新列表当前的开始节点是否在旧列表中存在。不存在说明是新增节点,需要挂载;存在则将此节点对应的真实DOM进行移动。移动之后有一个重点细节 - 将旧列表此节点对应的位置设置为undefined,再次遍历取到此值时依据空值直接跳过。
4、最后,当双端diff循环处理结束后,需要通过四个索引值的关系判断新旧列表中是否还存在未处理的节点,如果新列表中有则需要依次将其挂载,如果旧列表中有则需要依次将其卸载。
5、双端diff算法相比简单diff算法的核心优势在于某些场景下,双端diff可以实现更少的DOM操作。比如:旧列表为p1、p2、p3,新列表更改为p3、p1、p2,如果用简单diff则需要两次DOM操作,分别移动p1、p2;如果用双端diff则只需要移动一次p3节点即可,节点越多,优化性能越明显。

image

从流程图中可以看出:

  1. 首先比较四个端点处节点是否一致,发现都不相等。
  2. 接着取出新列表的头部节点p5,判断旧列表中是否出现此节点,未出现,则挂载p5至真实DOM列表的第一个位置,然后递增newStartIndex为1。
  3. 此时newStartIndex为1,继续判断新旧列表的开始和结束节点是否存在相等的情况,还是未发现相等的情况,接着取新列表的开始节点p3判断是否在旧列表中出现过,发现存在p3,则需要将p3移动位置,移动至oldStartIndex对应位置的前面节点,刚好也是p5之后。同时,处理完p3节点之后,切记将旧列表中p3对应的位置设置为undefined。将newStartIndex继续递增为2。
  4. 此时newStartIndex为2,继续按以上模式比较,发现新列表的开始节点p4与旧列表的结束节点p4刚好一致,因此可复用,接着移动p4节点。之后递增newStartIndex为3,递减oldEndIndex为2。
  5. 此时新旧列表的节点是p1、p6和p1、p2、p3,继续比较,发现新旧列表的开始节点一致,因为位置相同,所以不用移动,patch比较即可。之后递增newStartIndex为4,递增oldStartIndex为1。
  6. 此时新旧列表的节点是p6和p2、p3,继续比较,未发现端点相等的情况,再取出新列表第一个节点p6也不在旧列表中,因此需要挂载。继续递增newStartIndex为5。
  7. 此时newStartIndex大于newEndIndex,不满足循环条件,退出循环。
  8. while循环结束之后,判断新旧列表中是否有未处理的元素,发现新列表中没有,旧列表中有两个,则需要执行卸载。注意:旧列表中如果有节点值为undefined,说明已经处理过,直接跳过即可。

快速DIff

Vue3采用的是快速diff,相比简单diff和双端diff,快速diff借鉴了文本预处理的思路,拥有更快的算法效率。何为文本预处理模式?通俗理解就是,先去掉两个比较对象相同的前置部分和后置部分,从而缩小比较范围。首先来看核心逻辑:

function patchKeyedChildren(n1, n2, container) {
    const newChildren = n2.children
    const oldChildren = n1.children
    let j = 0
    let newVNode = newChildren[j]
    let oldVNode = oldChildren[j]
    // 处理相同的前置节点
    while(oldVNode.key === newVNode.key) {
        patch(oldVNode, newVNode, container)
        j++
        newVNode = newChildren[j]
        oldVNode = oldChildren[j]
    }
    let oldEndIndex = oldChildren.length - 1
    let newEndIndex = newChildren.length - 1
    newVNode = newChildren[newEndIndex]
    oldVNode = oldChildren[oldEndIndex]
    // 处理相同的后置节点
    while(oldVNode.key === newVNode.key) {
        patch(oldVNode, newVNode, container)
        newVNode = newChildren[--newEndIndex]
        oldVNode = oldChildren[--oldEndIndex]
    }
    if(j > oldEndIndex && j <= newEndIndex) {
        // 新节点列表中存在未处理的节点
        const anchorIndex = newEndIndex + 1
        const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null
        while(j <= newEndIndex) {
            // 新节点挂载
            patch(null, newChildren[j++], container, anchor)
        }
    } else if (j > newEndIndex && j <= oldEndIndex) {
        // 旧节点列表中存在未处理的节点
        while(j <= oldEndIndex) {
            // 旧节点卸载
            unmount(oldChildren[j++])
        }
    } else {
        // 创建source数组,存储新节点在旧列表中所处索引位置,旧列表中无则存储-1
        const count = newEndIndex - j + 1
        const source = new Array(count)
        source.fill(-1)
        const newStartIndex = j
        const oldStartIndex = j
        const keyIndex = {}
        // 记录节点是否需要移动
        let moved = false
        // 最大索引值
        let maxNewIndexSoFar = 0
        let patched = 0
        for(let i = newStartIndex; i <= newEndIndex; i++) {
            keyIndex[newChildren[i].key] = i
        }
        for(let i = oldStartIndex; i <= oldEndIndex; i++) {
            oldVNode = oldChildren[i]
            // 已更新节点数量不可超过source总长度
            if (patched <= count) {
                const k = keyIndex[oldVNode.key]
                if(typeof k !== 'undefined') {
                    // 此节点在旧列表中存在
                    newVNode = newChildren[k]
                    patch(oldVNode, newVNode, container)
                    patched++
                    source[k - newStartIndex] = i
                    if(k < maxNewIndexSoFar) {
                        moved = true
                    } else {
                        maxNewIndexSoFar = k
                    }
                } else {
                    unmount(oldVNode)
                }
            } else {
                unmount(oldVNode)
            }
        }
        // 有节点需要移动,构建最长递增子序列
        if(moved) {
            // getSequence工具函数获取一个数组的最长递增子序列
            // [2,1,8,3,5] => [1,3,5]、[2,3,5]都是最长递增子序列
            // 注意: getSequence函数返回的是索引值,比如上面例子[1,3,5]对应的是[1,3,4]
            const seq = getSequence(source)
            // s指向最长递增子序列的末尾索引值
            let s = seq.length - 1
            // i指向新节点列表的末尾索引值
            let i = count - 1
            for(i; i >= 0; i--) {
                if(source[i] === -1) {
                    // 挂载新节点
                    const pos = i + newStartIndex
                    const newVNode = newChildren[pos]
                    const nextPos = pos + 1
                    const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null
                    patch(null, newVNode, container, anchor)
                } else if(seq[s] !== i) {
                    // 最长递增子序列末尾索引值与source数组末尾索引值不一致代表需移动节点
                    const pos = i + newStartIndex
                    const newVNode = newChildren[pos]
                    const nextPos = pos + 1
                    const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null
                    insert(newVNode.el, container, anchor)
                } else {
                    // 一致则代表无需移动节点,将s递减即可
                    s--
                }
            }
        }
    }
}

从函数中可以总结快速diff的核心:

  1. 首先利用文本预处理的思路,先比较新旧两组节点的前置节点和后置节点,判断是否有相同的节点。
  2. 接着判断新旧列表是否存在其中一组已处理完而另一组未处理完这种情况,如果有则需要将另一组进行挂载或者卸载即可。
  3. 前面两种方式最终未处理完新旧列表时,需进一步处理,以新节点列表剩余节点为基础,构建source数组,存储剩余新节点在旧列表中对应的索引值,在旧列表中无则存储-1。
  4. 创建source的核心流程为:先基于新节点列表创建索引表,接着遍历旧节点列表,判断当前旧节点的key值是否在索引表中,在则可复用节点。同时,需要记录遍历过程中节点在新列表中对应的最大索引值,如果当前节点的索引值小于最大索引值,则需要移动节点。
  5. 移动节点时,基于source数组获取其中的最长递增子序列。需要注意的是最长递增子序列返回的是索引值。从尾部开始遍历source数组,如果source尾部索引与最长递增子序列的尾部索引值一致,则说明位置一致,不用移动节点,反之则需要移动节点。
@hexiaokang hexiaokang changed the title Vue3 三大核心模块之渲染器 Vue3 三大核心模块之【渲染器】 Sep 26, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant