【vue3源码笔记2】- 渲染

Vue | 2021-03-09 01:54:09 1823次 3次

前言

在之前的源码分析文章中总是贴出大量的代码,也一直找不到一种好的方式来怎么写源码分析(尽管这是一件平庸的事),这个过程中更多的了为了让自己后续回顾能快速的浮现出代码组织。毕竟,【自嗨】也是一件快乐的事!

在写 react 源码分析的过程中更多了为了学习它的思想,至于代码组织只是思想的一种实现;

在写 jq 源码分析中,更多的为了学习它的代码组织和功能上的设计,一些公共方法的抽象分离;

在写 webpack 源码分析中,仅仅为了跑通流程,学习一种插件结构的设计;

在写 vue 源码中,精简了很多内容,更多的为了跑通一个流程和思想上的复现以及代码组织设计;


言归正传,本篇的渲染引擎的分析开写。


Vnode

vue3 中做了一些优化,静态节点提升、block 块等,帮助运行时的效率提升。

其中 shapeFlag 代表了节点类型,patchFlag 在编译时生成,可以用来做 patch 时优化处理,区分动态和静态。

编译时会创建动态节点 block tree,结合 patchFlag 实现细粒度的更新,将生成的动态节点放入一个数组中,diff 时直接取出用,避免了递归树检查。

编译器会生成 openBlock、createVnode、createBlock 等方法,如下:

    // 一种树的遍历,先按照层级顺序执行openBlock,然后再是树的完成过程(叶子节点开始)
    // openBlock1 --> openBlock2 --> Frag --> div
    (openBlock(1),
      createBlock('div', null, [
        ((openBlock(2),
            createBlock("Frag", null, [
              222,
              333
            ])
         ))
    ]))

最后创建完成的 currentBlock 会被挂载到根 vnode 的 dynamicChildren 上。


Mount

组件的初始加载会进行 render 的挂载,挂载 render 的时候会判断当前组件返回值:

     如果是一个函数,则认为当前组件中采用 jsx 的写法或者说 createVnode 方法,不需要走 vue 的 compiler 系统。

     如果是一个对象,则认为当前组件中有模板写法,则需要进行编译,同时将 setUp 中返回的对象传给 render,建立起编                 译后的模板和数据的关联。

同时,将数据和响应式系统通过 effect 关联上,达到响应式的组件级别的更新:

        instance.update = effect(() => {
            if(!instance.isMounted){
                let { bm, m } = instance;
                // Life onBeforeMount
                bm && invokeArrayFns(bm);

                // 初始加载 
                const subTree = (instance.subTree = renderComponentRoot(instance))
                patch(null, subTree, container);

                // Life onMounted
                m && queuePostRenderEffect(m);

                instance.isMounted = true;
            }else{
                // 更新
            }
        },{
            scheduler: job => {
                queueJob(job);
            }
        })
export function renderComponentRoot(
    instance: ComponentInstance
): VNode{
    const {
        render,
        setupState
    } = instance;
    // 触发render方法,这里面用到了响应式中数据的话,则会对 effect 响应式处理
    return normalizeVNode(render(setupState));
}

对于普通节点的初始加载,则是递归 vnode 树即可,从子节点开始层层 parent.insertBefore 操作,最终挂载到根节点上,同时注意属性也要挂载到真实节点。


Update

组件的更新,当数据变化时,则会触发 effect,初始挂载后将信息记录在 subTree 上,更新时重新执行 render 计算出新的虚拟dom 树,再次进行 patch 比较:

        instance.update = effect(() => {
            if(!instance.isMounted){
                ...
            }else{
                const { bu, u } = instance;
                // Life onBeforeUpdate
                bu && invokeArrayFns(bu);

                // 数据更新 前后树对比 继续patch
                const nextTree = renderComponentRoot(instance);
                const prevTree = instance.subTree;
                // 更换
                instance.subTree = nextTree;
                patch(prevTree, nextTree, hostParentNode(prevTree.el!)!);

                // Life onUpdated
                u && queuePostRenderEffect(u);
            }
        },{
            scheduler: job => {
                queueJob(job);
            }
        })

对于节点的更新,利用到编译优化的点,patchFlag 大于 0 的时候是动态属性,比如动态 class、动态 style、其他动态属性仅对比当前 dom 的制定属性,不会全量对比 props,除非命中 PatchFlags.FULL_PROPS 或者不优化(并且dynamicChildren为空)时再进行全量的属性对比。
        接着如果命中 block 优化的条件,则进行 patchBlockChildren,根据新旧根节点上的 dynamicChildren 直接遍历进行 patch。

无法进行优化处理的再进入大名鼎鼎的 diff 阶段。


Diff

首先进入 patchChildren 进行节点类型的对比:

        1 newtext     oldarray

        2 newtext     oldtext | null

        3 newarray   oldarray +++++++++

        4 newnull     oldarray

        5 newnull     oldtext | null

        6 newarray   oldtext | null

        if(shapeFlag & ShapeFlags.TEXT_CHILDREN){
            if(prevShapeFlag & ShapeFlags.ARRAY_CHILDREN){
                // 卸载组件
                container.innerHTML = '';
            }
            if(c2 !== c1){
                hostSetElementText(container, c2 as string)
            }
        } else {
            if(prevShapeFlag & ShapeFlags.ARRAY_CHILDREN){
                if(shapeFlag & ShapeFlags.ARRAY_CHILDREN){
                    // array 前后对比
                    patchKeyedChildren(c1 as VNode[], c2 as VNodeArrayChildren, container);
                }else{
                    // 卸载组件
                    container.innerHTML = '';
                }
            } else {
                if(prevShapeFlag & ShapeFlags.TEXT_CHILDREN){
                    hostSetElementText(container, '');
                }
                if(shapeFlag & ShapeFlags.ARRAY_CHILDREN){
                    // 加载数组类型组件
                    mountChildren( c2, container)
                }
            }
        }

接下来逻辑最多的就是有 key 节点对比 patchKeyedChildren,这个对比的过程中源码的注释很清晰,这里描述下过程:


1、头比较

                old: (a b) c
            new: (a b) d e
            ----------------------------------- 
        while (i <= e1 && i <= e2) {
            const n1 = c1[i];
            const n2 = c2[i] = normalizeVNode(c2[i]);
            if (isSameVNodeType(n1, n2)) {
                patch(n1, n2, container);
            } else {
                break;
            }
            i++;
        }

2、尾比较

        old: a (b c)
        new: d e (b c)
        ---------------------------------
        while (i <= e1 && i <= e2) {
            const n1 = c1[e1];
            const n2 = c2[e2] = normalizeVNode(c2[e2]);
            if (isSameVNodeType(n1, n2)) {
                patch(n1, n2, container);
            } else {
                break;
            }
            e1--;
            e2--;
        }

3、同序列挂载

        old: (a b)
        new: (a b) c
        // i = 2, e1 = 1, e2 = 2
        或
        old: (a b)
        new: c (a b)
        // i = 0, e1 = -1, e2 = 0
        ----------------------------------
        if (i > e1) {
            while (i <= e2) {
                patch(null, c2[i] = normalizeVNode(c2[i]), container)
                i++;
            }
        }

4、同序列卸载

        old: (a b) c
        new: (a b)
        // i = 2, e1 = 2, e2 = 1
        或
        new: a (b c)
        old:   (b c)
        // i = 0, e1 = 0, e2 = -1
        ---------------------------------
        else if(i > e2){
            while (i <= e1) {
                hostRemove(c1[i].el as any);
                i++;
            }
        }

5、未知序列

    // [i ... e1 + 1]: a b [c d e] f g
    // [i ... e2 + 1]: a b [e c d h] f g
    // i = 2, e1 = 4, e2 = 5

未知序列这里是 diff 的核心逻辑,设计复杂,但目的比较明确,就是保证最小移动从而减小 dom 操作。为了保证找到最小的移动,就是要求出这个最长递增子序列,依据的是新的节点相对于旧节点的位置索引确定,过程如下:

1. 收集新节点 key 和 index(去除相同头尾后的部分)

            // 根据上面的注释数据。。
            const s1 = i, s2 = i;
            // 抛头去尾后 新节点 key 的集合
            const keyToNewIndexMap: Map<string | number, number> = new Map();
            // 将新的节点key和index存入一个map
            for(i = s2; i <= e2; i++){
                const nextChild = (c2[i] = normalizeVNode(c2[i]));
                const key = nextChild.key;
                if(key != null){
                    keyToNewIndexMap.set(key, i);
                }
            }

2. 遍历旧节点(去除相同头尾后的部分),创造一个索引变化的数组,为了后面计算 LIS

            for(i = s1; i <= e1; i++){
                const prevChild = c1[i];
                ...
                let newIndex, prevChildKey = prevChild.key;
                ...
                if(prevChildKey != null){
                    newIndex = keyToNewIndexMap.get(prevChildKey);
                }else{
                    ...
                }
                // 新节点中不存在 旧的需要移除
                if(newIndex === undefined){
                    hostRemove(prevChild.el as any);
                }else{
                    // 这里生成数组
                    newIndexToOldIndexMap[newIndex - s2] = i + 1;
                    // maxNewIndexSoFar 设计的巧妙,比如 c d e 变成 e c d 的时候
                    // 旧节点 遍历到 e 的时候发现 newIndex 是小于 这个变量的
                    // 那么意味这节点需要移动,此时再求最长递增子序列,进行后续的移动操作
                    if (newIndex >= maxNewIndexSoFar) {
                        maxNewIndexSoFar = newIndex;
                    } else {
                        moved = true;
                    }
                    patch(prevChild, c2[newIndex] as VNode, container);
                    patched++;
                }
            }

这一小步中还有其他细节需要考虑,这里不贴具体代码了,列举 case 如下:

                // a b [c d e] f g
                // a b [] f g
                -----------------------
                // a b [c d e q(noKey)] f g
                // a b [e c d h q(noKey)] f g

5.1、最长递增子序列

这里和 vue 源码的写法有些出入,不过核心思路都是一样的,我进行了一个代码的更精简,采用二分法的思路:

首先进入 patchChildren 进行节点类型的对比:

 * 最长递增子序列

 * [6,3,5,10,11,2,9,14,13,7,4,8,16]

 * 

 * 第一步:

 *  按照扑克牌的算法进行二分法查找,得到最终每一堆中牌尾的索引

 *  

 *  堆1   堆2   堆3   堆4   堆5

 *  6     5     10    11    14

 *  3     4     9     8     13

 *  2           7           16

 * 

 * 牌尾:[2, 4, 7, 8, 16] 对应索引:[5, 10, 9, 11, 12]

 * ----------------------------------------------------

 * 

 * 第二步:

 *  求解目标(唯一值)

 *  pArr的计算,新的牌入堆的时候右侧一定大于左侧的值,所以新插入的需要记录上一个堆的牌尾索引

 *  pArr: [-1, -1, 1, 2, 3, -1, 2, 4, 4, 2, 5, 9, 11]

 *  由于堆的最右侧的牌尾是最大的

 *  所以可以从牌尾索引倒序从 pArr 中往前寻找

 *  最终为目标值。

 * 

 *  实际目标不唯一,比如 16 后面是 15 (此时最长公共子序列结果至少有两个)

export function getSequence(arr: number[]) {
    let topIndex = [];
    let pArr = [];
    // 牌堆数初始化为 0
    let piles = 0;
    for (let i = 0, len = arr.length; i < len; i++) {
        let poker = arr[i];
        let left = 0, right = piles;
        while (left < right) {
            let mid = Math.floor((left + right) / 2);
            if (arr[topIndex[mid]] < poker) {
                left = mid + 1;
            } else {
                right = mid;
            }
        }
        // 新建堆
        if (left == piles){
             piles++
        };
        // 记录每一堆牌顶索引
        topIndex[left] = i;
        pArr.push(topIndex[left-1] || -1);
    }
    // 挑选
    let u = topIndex.length;
    let v = topIndex[u - 1];
    while (u-- > 0) {
        topIndex[u] = v;
        v = pArr[v];
    }
    // 增长序列
    return topIndex;
}

5.2、最小移动

            ...
            for (i = toBePatched - 1; i >= 0; i--) {
                ...
                    // [0,1,2,0]
                    // lis: [1, 2]
                    // 此时 1和2保持不动,其他的节点做移动操作,保证了最小移动
                    if (j < 0 || i !== increasingNewIndexSequence[j]) {
                        hostInsert(nextChild.el!, container, anchor)
                    } else {
                        j--;
                    }
                ...
            }


这里完成了一个组件的渲染和更新能力,下一篇继续看生命周期的设计和调度。

3人赞

分享到: