vue3源码学习--星移斗转compiler

514 阅读4分钟

背景引入

      Vue3 正式发布已经有一段时间了,有些项目已经开始使用vue3,vue3在模板编译方面与vue2有了很大的改进,那么到底都做了哪些优化呢?这些优化到底提升了什么?这些优化怎么做的?带着这些疑问开始这篇文章的探索。

编译叙述

目录介绍

Vue 的编译模块包含 4 个目录:

compiler-core
compiler-dom // 浏览器
compiler-sfc // 单文件组件
compiler-ssr // 服务端渲染

其中 compiler-core 模块是 Vue 编译的核心模块,并且是平台无关的。而剩下的三个都是在 compiler-core 的基础上针对不同的平台作了适配处理

编译过程

    针对下面相同的一段模板代码,我们对比下vue2与vue3编译后到底有什么不同

<div name="test">
               <!-- 这是注释 -->
               <p>{{ test }}</p>一个文本节点
               <div>good job</div>
</div>

vue2编译后

import {
    createCommentVNode as _createCommentVNode,
    toDisplayString as _toDisplayString,
    createElementVNode as _createElementVNode,
    createTextVNode as _createTextVNode,
    openBlock as _openBlock,
    createElementBlock as _createElementBlock
} from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
    return (_openBlock(), _createElementBlock("div", {
        name: "test"
    }, [_createCommentVNode(" 这是注释 "), _createElementVNode("p", null, _toDisplayString(_ctx.test), 1 /* TEXT */ ),
        _createTextVNode(" 一个文本节点 "), _createElementVNode("div", null, "good job")]))
} // Check the console for the AST

vue3编译后:

import {
    createCommentVNode as _createCommentVNode,
    toDisplayString as _toDisplayString,
    createElementVNode as _createElementVNode,
    createTextVNode as _createTextVNode,
    openBlock as _openBlock,
    createElementBlock as _createElementBlock
} from "vue"
const _hoisted_1 = {
    name: "test"
}
const _hoisted_2 = /*#__PURE__*/ _createTextVNode(" 一个文本节点 ") const _hoisted_3 = /*#__PURE__*/ _createElementVNode(
    "div", null, "good job", -1 /* HOISTED */ ) export function render(_ctx, _cache, $props, $setup, $data,
    $options) {
    return (_openBlock(), _createElementBlock("div", _hoisted_1, [_createCommentVNode(" 这是注释 "),
        _createElementVNode("p", null, _toDisplayString(_ctx.test), 1 /* TEXT */ ), _hoisted_2,
        _hoisted_3]))
} // Check the console for the AST

从编译后的内容上看两个有了一些区别,但整个编译过程还可以理解为分为三个阶段:Parsing(解析)、 Transformation(转换)、Code Generation(代码生成)。

编译入口

Vue3 的同学肯定知道 Vue3 引入了新的组合 Api,在组件 mount 阶段会调用 setup 方法,之后会判断 render 方法是否存在,如果不存在会调用 compile 方法将 template 转化为 render

// packages/runtime-core/src/renderer.ts
const mountComponent = (initialVNode, container) => {
    const instance = (
        initialVNode.component = createComponentInstance(
            // ...params
        )
    )
    // 调用 setup
    setupComponent(instance)
}

// packages/runtime-core/src/component.ts
let compile
export function registerRuntimeCompiler(_compile) {
    compile = _compile
}
export function setupComponent(instance) {
    const Component = instance.type
    const {
        setup
    } = Component
    if (setup) {
        // ...调用 setup
    }
    if (compile && Component.template && !Component.render) {
        // 如果没有 render 方法
        // 调用 compile 将 template 转为 render 方法
        Component.render = compile(Component.template, {
            ...
        })
    }
}

这部分都是 runtime-core 中的代码,之前的文章有讲过 Vue 分为完整版和 runtime 版本。如果使用 vue-loader 处理 .vue 文件,一般都会将 .vue 文件中的 template 直接处理成 render 方法。

//  需要编译器
Vue.createApp({
    template: ' {
        {
            hi
        }
    }
    '
})

// 不需要
Vue.createApp({
    render() {
        return Vue.h('div', {}, this.hi)
    }
})

完整版与 runtime 版的差异就是,完整版会引入 compile 方法,如果是 vue-cli 生成的项目就会抹去这部分代码,将 compile 过程都放到打包的阶段,以此优化性能。runtime-dom 中提供了 registerRuntimeCompiler 方法用于注入 compile 方法。

compiler三个阶段

compiler-parse

Vue 在解析模板字符串时,可分为两种情况:以 < 开头的字符串和不以 < 开头的字符串。

不以 < 开头的字符串有两种情况:它是文本节点或 {{ exp }} 插值表达式。

而以 < 开头的字符串又分为以下几种情况:

  • 元素开始标签
  • 元素结束标签
  • 注释节点
  • 文档声明

用伪代码表示,大概过程如下:

e(s.length) {
    if (startsWith(s, '{{')) {
        // 如果以 '{{' 开头
        node = parseInterpolation(context, mode)
    } else if (s[0] === '<') {
        // 以 < 标签开头
        if (s[1] === '!') {
            if (startsWith(s, '<!--')) {
                // 注释
                node = parseComment(context)
            } else if (startsWith(s, '<!DOCTYPE')) {
                // 文档声明,当成注释处理
                node = parseBogusComment(context)
            }
        } else if (s[1] === '/') {
            // 结束标签
            parseTag(context, TagType.End, parent)
        } else if (/[a-z]/i.test(s[1])) {
            // 开始标签
            node = parseElement(context, ancestors)
        }
    } else {
        // 普通文本节点
        node = parseText(context, mode)
    }
}

在源码中对应的几个函数分别是:

  1. parseChildren() ,主入口。
  2. parseInterpolation() ,解析双花插值表达式。
  3. parseComment() ,解析注释。
  4. parseBogusComment() ,解析文档声明。
  5. parseTag() ,解析标签。
  6. parseElement() ,解析元素节点,它会在内部执行 parseTag()
  7. parseText() ,解析普通文本。
  8. parseAttribute() ,解析属性。

每解析完一个标签、文本、注释等节点时,Vue 就会生成对应的 AST 节点,并且 会把已经解析完的字符串给截断

对字符串进行截断使用的是 advanceBy(context, numberOfCharacters) 函数,context 是字符串的上下文对象,numberOfCharacters 是要截断的字符数。

我们用一个简单的例子来模拟一下截断操作:

<div name="test">
    <p></p>
</div>

1、 首先解析 <div ,然后执行 advanceBy(context, 4) 进行截断操作(内部执行的是 s = s.slice(4) ),变成:

name="test"> <p></p></div>

再解析属性,并截断,变成:

 <p></p></div>

></p></div>

模板字符串假设为 s,第一个字符 s[0] 是 < 开头,那说明它只能是刚才所说的四种情况之一。 这时需要再看一下 s[1] 的字符是什么:

  • 如果是 ! ,则调用字符串原生方法 startsWith() 看看是以 '<!--' 开头还是以 '<!DOCTYPE' 开头。虽然这两者对应的处理函数不一样,但它们最终都是解析为注释节点。
  • 如果是 / ,则按结束标签处理。
  • 如果不是 / ,则按开始标签处理。

从我们的示例来看,这是一个 <div> 开始标签。

这里还有一点要提一下,Vue 会用一个栈 stack 来保存解析到的元素标签。当它遇到开始标签时,会将这个标签推入栈,遇到结束标签时,将刚才的标签弹出栈。它的作用是保存当前已经解析了,但还没解析完的元素标签。这个栈还有另一个作用,在解析到某个字节点时,通过 stack[stack.length - 1] 可以获取它的父元素。

主要流程方法有:

parseChildren() // 主入口
parseInterpolation() //解析插值表达式
parseComment()  //解析注释
parseBogusComment() //解析文档声明
parseTag()  //解析标签
parseText()  //解析普通文本
parseAttribute() //解析属性



compiler-transform

转换前:

转换后:

vdom存在的问题

更新最小粒度为组件,虽然 Vue 能够保证触发更新的组件最小化,但在单个组件内部依然需要遍历该组件的整个 vdom 树。

问题是:传统 vdom 的性能跟模版大小正相关,跟动态节点的数量无关。在一些组件整个模版内只有少量动态节点的情况下,静态无关的节点仍要对比,浪费性能

理想情况:

vue3中patchFlag使用

transform 在对 AST 节点进行转换时,会打上 patchflag 参数,这个参数主要用于 diff 比较过程。当 DOM 节点有这个标志并且大于 0,就代表要更新,没有就跳过。

 patchflag 的取值范围:

enum PatchFlags {
    // 动态文本节点
    TEXT = 1,

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

        // 动态 style
        STYLE = 1 << 2, // 4

        // 动态属性,但不包含类名和样式
        // 如果是组件,则可以包含类名和样式
        PROPS = 1 << 3, // 8

        // 具有动态 key 属性,当 key 改变时,需要进行完整的 diff 比较。
        FULL_PROPS = 1 << 4, // 16

        // 带有监听事件的节点
        HYDRATE_EVENTS = 1 << 5, // 32

        // 一个不会改变子节点顺序的 fragment
        STABLE_FRAGMENT = 1 << 6, // 64

        // 带有 key 属性的 fragment 或部分子字节有 key
        KEYED_FRAGMENT = 1 << 7, // 128

        // 子节点没有 key 的 fragment
        UNKEYED_FRAGMENT = 1 << 8, // 256

        // 一个节点只会进行非 props 比较
        NEED_PATCH = 1 << 9, // 512

        // 动态 slot
        DYNAMIC_SLOTS = 1 << 10, // 1024

        // 静态节点
        HOISTED = -1,

        // 指示在 diff 过程应该要退出优化模式
        BAIL = -2
}

从上述代码可以看出 patchflag 使用一个 11 位的位图来表示不同的值,每个值都有不同的含义。Vue 在 diff 过程会根据不同的 patchflag 使用不同的 patch 方法。

transform 后的 AST:

codegenNode、helpers 和 hoists 已经被填充上了相应的值。codegenNode 是生成代码要用到的数据,hoists 存储的是静态节点,helpers 存储的是创建 VNode 的函数名称(其实是 Symbol)

靶向更新:

靶向更新 — 哪些节点是动态节点,以及为什么它是动态的(是绑定了动态的class?还是绑定了动态的style?亦或是其它动态的属性?)

hoists(静态提升)

hoistStatic 是一个标识符,表示要不要开启静态节点提升。如果值为 true,静态节点将被提升到 render() 函数外面生成,并被命名为 _hoisted_x 变量。

例如 一个文本节点 生成的代码为 const _hoisted_2 = /*#__PURE__*/_createTextVNode(" 一个文本节点 ")

  • 静态提升以节点树为单位提升
  • 动态子节点的静态属性可提升
  • 源码:hoistStatic (transform.ts—transform())

相关方法:

  • transformElement() //负责节点转换
  • transformExpression() //节点中表达式的转化
  • transformText() //负责节点文本转换
  • buildProps() //解析属性设置patchFlag
  • hoistStatic() // 静态节点提升

compiler—generate

经过前面对AST进行了优化之后,需要将整个AST变成一个可执行的代码块,也就是render函数。于是模板编译器使用了generate对AST进行了代码生成

generate函数很简单,首先传入options实例化一个CodegenState用于接下来的代码生成,然后调用genElement生成代码块。

export function generate(
    ast: ASTElement | void,
    options: CompilerOptions
): CodegenResult {
    const state = new CodegenState(options);
    const code = ast ? genElement(ast, state) : '_c("div")';
    return {
        render: `with(this){return ${code}}`,
        staticRenderFns: state.staticRenderFns,
    };
}

generate() //代码生成入口
genModule / genFunction (基于不同mode)
genHoists() //生成静态节点提升代码
genNode() //具体节点代码生成逻辑

相关流程

generate主要就是将AST模型转成render表达式,其中采用了大量的递归调用,主要的流程如下:

优化策略

1:静态节点提升
静态不变的节点,直接把它创建了,放在渲染函数的外部,直接使用,不需要频繁创建了;

2补丁标记和动态属性记录
添加动态补丁

hello world

对title做动态变化的标记,等之后做diff对比的时候,只做当前的动态值做比对,减少递归遍历的过程。

3缓存事件处理程序
对事件进行缓存,尝试从缓存中获取,render函数执行的时候,不会再重新创建;

4块block

{{msg}}
对块级中只有一个动态的变量进行创建,等执行更新的时候,会定向的对区块儿中的动态元素单独更新。

5、 v-once指令: 缓存全部或部分虚拟节点,能够避免组件更新时重新创建虚拟DOM带来的性能开销, 也可以避免无用的Diff操作

补充说明

vue3知识图谱: www.processon.com/view/link/5…

vue3快了吗: github.com/shengxinjin…

vue3其他学习,未完待续......