Vue3中的编译优化

169 阅读6分钟

背景知识

简单的二进制操作

/** &,与运算,如果两个都是1,则结果为1 */
5 & 3 // (二进制:101 & 011  001  1)

/** |,或运算,如果至少有一个是1,则结果为1 */
5 | 3 // (二进制:101 | 011  111  7)

/** <<,左移,将二进制位向左移动指定的位数,右边用0填充 */
5 << 1 // (二进制:101  1010  10)

/** <<,右移,将二进制位向右移动指定的位数,符号位填充(如果是负数,填充1;如果是正数,填充0)。 */
5 >> 1 // (二进制:101  10  2)

/** 负数,32位数字第一位是1为负数,第一位0为正数,取反后+1就是它的值 */
1111 1111 1111 1111 1111 1111 1111 1010
// 取反 -> 0000 0000 0000 0000 0000 0000 0000 0101
// + 1 -> 0000 0000 0000 0000 0000 0000 0000 0110
// 所以1111 1111 1111 1111 1111 1111 1111 1010,= 6

/** 取反,反转每一位 */
~5 // 6
// 0000 0000 0000 0000 0000 0000 0000 0101
// 1111 1111 1111 1111 1111 1111 1111 1010
// 6

编译优化

编译优化的目标

<div id="foo">
  <p class="bar">{{ text }}</p>
</div>

传统的diff算法会进行如下比较:

  • 比较div的属性与子节点
  • 比较p的属性与子节点
  • 比较p的文本子节点

但是只有p的文本节点是动态的,如果能够直接比较文本节点,diff性能会大幅提升。编译优化的目标就是要在虚拟DOM上体现出节点的动态性,具体有两点:

  • 当前node后代有哪些动态节点;
  • 当前node有哪些属性是动态的

传统的虚拟dom:

const vnode = {
    tag: "div",
    children: [
        { tag: "div", children: "foo" },
        { tag: "p", children: ctx.bar },
    ],
};

目标的虚拟dom

const vnode = {
    tag: "div",
    children: [
        { tag: "div", children: "foo" },
        { tag: "p", children: ctx.bar, patchFlag: 1 }, // 这是动态节点
    ],
    // 后代元素中的动态节点
    dynamicChildren: [],
};

怎么标记节点的动态性

patchFlag有以下值:

export const enum PatchFlags {
  // 动态文字内容
  TEXT = 1, // 0000000001

  // 动态 class
  CLASS = 1 << 1, // 0000000010

  // 动态样式
  STYLE = 1 << 2, // 0000000100

  // 动态 props
  PROPS = 1 << 3,

  // 有动态的key,也就是说props对象的key不是确定的
  FULL_PROPS = 1 << 4,

  // 合并事件
  HYDRATE_EVENTS = 1 << 5,

  // children 顺序确定的 fragment
  STABLE_FRAGMENT = 1 << 6,

  // children中有带有key的节点的fragment
  KEYED_FRAGMENT = 1 << 7,

  // 没有key的children的fragment
  UNKEYED_FRAGMENT = 1 << 8,

  // 只有非props需要patch的,比如`ref`
  NEED_PATCH = 1 << 9,

  // 动态的插槽
  DYNAMIC_SLOTS = 1 << 10,

  // SPECIAL FLAGS -------------------------------------------------------------

  // 以下是特殊的flag,不会在优化中被用到,是内置的特殊flag

  // 表示他是静态节点,他的内容永远不会改变,对于hydrate的过程中,不会需要再对其子节点进行diff
  HOISTED = -1,

  // 用来表示一个节点的diff应该结束
  BAIL = -2,
}

一个节点可能会有多个动态属性,例如动态文本和动态class,可以用PatchFlags.TEXT | PatchFlags.CLASS来得到0000000011,这个节点patchFlag = 0000000011,表示它同时拥有动态文本和动态class。

要判断这个节点是否拥有动态文本:patchFlag & PatchFlags.TEXT > 0即可

怎么提取后代元素中的动态节点

约定组件模版的根节点必须作为一个Block,考虑到组件嵌套层级,使用栈结构来存储动态节点,栈顶数组存储当前组件的动态节点

// 动态节点栈
const dynamicChildrenStack = [];
// 当前动态节点集合
let currentDynamicChildren = null;
// openBlock 用来创建一个新的动态节点集合,并将该集合压入栈中
function openBlock() {
    dynamicChildrenStack.push((currentDynamicChildren = []));
}
// closeBlock 用来将通过 openBlock 创建的动态节点集合从栈中弹出
function closeBlock() {
    currentDynamicChildren = dynamicChildrenStack.pop();
}

创建当前虚拟节点时,如果当前节点是动态的,就存入

function createVNode(tag, props, children, flags) {
    const key = props && props.key;
    props && delete props.key;

    const vnode = {
        tag,
        props,
        children,
        key,
        patchFlags: flags,
    };

    if (typeof flags !== "undefined" && currentDynamicChildren) {
        // 动态节点,将其添加到当前动态节点集合中
        currentDynamicChildren.push(vnode);
    }

    return vnode;
}

一层一层的创建vnode,先得到内层的结果,再得到外层的结果

function render() {
    return createVNode("div", { id: "foo" }, [
        createVNode("p", { class: "bar" }, text, PatchFlags.TEXT), // PatchFlags.TEXT 就是补丁标志
    ]);
}

因此,到最外层执行结束,currentDynamicChildren中存储了所有后代节点的动态节点,把currentDynamicChildren挂载到当前Block上

function render() {
    // 1. 使用 createBlock 代替 createVNode 来创建 block
    // 2. 每当调用 createBlock 之前,先调用 openBlock
    return (
        openBlock(),
        createBlock("div", null, [
            createVNode("p", { class: "foo" }, null, 1 /* patch flag */),
            createVNode("p", { class: "bar" }, null),
        ])
    );
}

function createBlock(tag, props, children) {
    // block 本质上也是一个 vnode
    const block = createVNode(tag, props, children);
    // 将当前动态节点集合作为 block.dynamicChildren
    block.dynamicChildren = currentDynamicChildren;

    // 关闭 block
    closeBlock();
    // 返回
    return block;
}

diff时的优化

目前已经有了vnode.dynamicChildren,以及vnode.patchFlag,基于这两点,就可以实现靶向更新了。

只比较动态子节点

function patchElement(n1, n2) {
    const el = (n2.el = n1.el);
    const oldProps = n1.props;
    const newProps = n2.props;

    // 省略部分代码

    if (n2.dynamicChildren) {
        // 调用 patchBlockChildren 函数,这样只会更新动态节点
        patchBlockChildren(n1, n2);
    } else {
        patchChildren(n1, n2, el);
    }
}

function patchBlockChildren(n1, n2) {
    // 只更新动态节点即可
    for (let i = 0; i < n2.dynamicChildren.length; i++) {
        patchElement(n1.dynamicChildren[i], n2.dynamicChildren[i]);
    }
}

节点只比较动态属性

function patchElement(n1, n2) {
    const el = n2.el = n1.el
    const oldProps = n1.props
    const newProps = n2.props

    if (n2.patchFlags) {
        // 靶向更新
        if (n2.patchFlags === 1) {
        // 只需要更新 class
        } else if (n2.patchFlags === 2) {
        // 只需要更新 style
        } else if (...) {
                // ...
        }
    } else {
        // 全量更新
        for (const key in newProps) {
          if (newProps[key] !== oldProps[key]) {
              patchProps(el, key, oldProps[key], newProps[key])
          }
        }
        for (const key in oldProps) {
        if (!(key in newProps)) {
            patchProps(el, key, oldProps[key], null)
        }
        }
    }

    // 在处理 children 时,调用 patchChildren 函数
    patchChildren(n1, n2, el)
}

总结

  1. 编译优化的目标是区分动态节点和静态节点,只对动态节点打补丁
  2. 实现的关键在于两个属性,一个是patchFlag,在模版编译成vnode过程中,会传递patchFlag,所有动态vnode都有patchFlag,表示当前vnode的哪些部分是动态的
  3. 组件级别(或者v-if/v-else)的vnode会有dynamicChildren属性,收集所有后代动态节点
  4. diff时,节点树只比较dynamicChildren,节点只比较patchFlag部分

参考

你和大神的距离可能就在这里,Vue3的patchFlags超详细讲解

Vue.js设计与实现