关于javascript:Vue3-源码分析二编译模块-compiler

3次阅读

共计 11608 个字符,预计需要花费 30 分钟才能阅读完成。

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

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

其中 compiler-core 模块是 Vue 编译的外围模块,并且是平台无关的。而剩下的三个都是在 compiler-core 的根底上针对不同的平台作了适配解决。

Vue 的编译分为三个阶段,别离是:parse、transform、codegen。

其中 parse 阶段将模板字符串转化为语法形象树 AST。transform 阶段则是对 AST 进行了一些转换解决。codegen 阶段依据 AST 生成对应的 render 函数字符串。

Parse

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

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

而以 < 结尾的字符串又分为以下几种状况:

  1. 元素开始标签 <div>
  2. 元素完结标签 </div>
  3. 正文节点 <!-- 123 -->
  4. 文档申明 <!DOCTYPE html>

用伪代码示意,大略过程如下:

while (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>

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

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

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

  <p></p>
</div>

同理,前面的截断状况为:

></p>
</div>
</div>
<!-- 所有字符串曾经解析完 -->

AST 节点

所有的 AST 节点定义都在 compiler-core/ast.ts 文件中,上面是一个元素节点的定义:

export interface BaseElementNode extends Node {
  type: NodeTypes.ELEMENT // 类型
  ns: Namespace // 命名空间 默认为 HTML,即 0
  tag: string // 标签名
  tagType: ElementTypes // 元素类型
  isSelfClosing: boolean // 是否是自闭合标签 例如 <br/> <hr/>
  props: Array<AttributeNode | DirectiveNode> // props 属性,蕴含 HTML 属性和指令
  children: TemplateChildNode[] // 字节点}

一些简略的要点曾经讲完了,上面咱们再从一个比较复杂的例子来具体解说一下 parse 的处理过程。

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

下面的模板字符串假如为 s,第一个字符 s[0] 是 < 结尾,那阐明它只能是方才所说的四种状况之一。
这时须要再看一下 s[1] 的字符是什么:

  1. 如果是 !,则调用字符串原生办法 startsWith() 看看是以 '<!--' 结尾还是以 '<!DOCTYPE' 结尾。尽管这两者对应的处理函数不一样,但它们最终都是解析为正文节点。
  2. 如果是 /,则按完结标签解决。
  3. 如果不是 /,则按开始标签解决。

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

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

从咱们的示例来看,它的出入栈程序是这样的:

1. [div] // div 入栈
2. [div, p] // p 入栈
3. [div] // p 出栈
4. [div, div] // div 入栈
5. [div] // div 出栈
6. [] // 最初一个 div 出栈,模板字符串已解析完,这时栈为空

接着上文持续剖析咱们的示例,这时曾经晓得是 div 标签了,接下来会把曾经解析完的 <div 字符串截断,而后解析它的属性。

Vue 的属性有两种状况:

  1. HTML 一般属性
  2. Vue 指令

依据属性的不同生成的节点不同,HTML 一般属性节点 type 为 6,Vue 指令节点 type 为 7。

所有的节点类型值如下:

ROOT,  // 根节点 0
ELEMENT, // 元素节点 1
TEXT, // 文本节点 2
COMMENT, // 正文节点 3
SIMPLE_EXPRESSION, // 表达式 4
INTERPOLATION, // 双花插值 {{}} 5
ATTRIBUTE, // 属性 6
DIRECTIVE, // 指令 7

属性解析完后,div 开始标签也就解析完了,<div name="test"> 这一行字符串曾经被截断。当初剩下的字符串如下:

  <!-- 这是正文 -->
  <p>{{test}}</p>
  一个文本节点
  <div>good job!</div>
</div>

正文文本和一般文本节点解析规定都很简略,间接截断,生成节点。正文文本调用 parseComment() 函数解决,文本节点调用 parseText() 解决。

双花插值的字符串解决逻辑略微简单点,例如示例中的 {{test}}

  1. 先将双花括号中的内容提取进去,即 test ,再对它执行 trim(),去除空格。
  2. 而后会生成两个节点,一个节点是 INTERPOLATION,type 为 5,示意它是双花插值。
  3. 第二个节点是它的内容,即 test,它会生成一个 SIMPLE_EXPRESSION 节点,type 为 4。
return {
  type: NodeTypes.INTERPOLATION, // 双花插值类型
  content: {
    type: NodeTypes.SIMPLE_EXPRESSION,
    isStatic: false, // 非动态节点
    isConstant: false,
    content,
    loc: getSelection(context, innerStart, innerEnd)
  },
  loc: getSelection(context, start)
}

剩下的字符串解析逻辑和上文的差不多,就不解释了,最初这个示例解析进去的 AST 如下所示:

从 AST 上,咱们还能看到某些节点上有一些别的属性:

  1. ns,命名空间,个别为 HTML,值为 0。
  2. loc,它是一个地位信息,表明这个节点在源 HTML 字符串中的地位,蕴含行,列,偏移量等信息。
  3. {{test}} 解析进去的节点会有一个 isStatic 属性,值为 false,示意这是一个动静节点。如果是动态节点,则只会生成一次,并且在前面的阶段始终复用同一个,不必进行 diff 比拟。

另外还有一个 tagType 属性,它有 4 个值:

export const enum ElementTypes {
  ELEMENT, // 0 元素节点
  COMPONENT, // 1 组件
  SLOT, // 2 插槽
  TEMPLATE // 3 模板
}

次要用于辨别上述四种类型节点。

Transform

在 transform 阶段,Vue 会对 AST 进行一些转换操作,次要是依据不同的 AST 节点增加不同的选项参数,这些参数在 codegen 阶段会用到。上面列举一些比拟重要的选项:

cacheHandlers

如果 cacheHandlers 的值为 true,则示意开启事件函数缓存。例如 @click="foo" 默认编译为 {onClick: foo},如果开启了这个选项,则编译为

{onClick: _cache[0] || (_cache[0] = e => _ctx.foo(e)) }

hoistStatic

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

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

上面两张图,前者是 hoistStatic = false,前面是 hoistStatic = true。大家能够在网站上本人试一下。

prefixIdentifiers

这个参数的作用是用于代码生成。例如 {{foo}} 在 module 模式下生成的代码为 _ctx.foo,而在 function 模式下是 with (this) {...}。因为在 module 模式下,默认为严格模式,不能应用 with 语句。

PatchFlags

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

咱们来看一下 patchflag 的取值范畴:

export const 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)。

在正式开始 transform 前,须要创立一个 transformContext,即 transform 上下文。和这三个属性无关的数据和办法如下:

helpers: new Set(),
hoists: [],

// methods
helper(name) {context.helpers.add(name)
  return name
},
helperString(name) {return `_${helperNameMap[context.helper(name)]}`
},
hoist(exp) {context.hoists.push(exp)
  const identifier = createSimpleExpression(`_hoisted_${context.hoists.length}`,
    false,
    exp.loc,
    true
  )
  identifier.hoisted = exp
  return identifier
},

咱们来看一下具体的 transform 过程是怎么的,用 <p>{{test}}</p> 来做示例。

这个节点对应的是 transformElement() 转换函数,因为 p 没有绑定动静属性,没有绑定指令,所以重点不在它,而是在 {{test}} 上。{{test}} 是一个双花插值表达式,所以将它的 patchFlag 设为 1(动静文本节点),对应的执行代码是 patchFlag |= 1。而后再执行 createVNodeCall() 函数,它的返回值就是这个节点的 codegenNode 值。

node.codegenNode = createVNodeCall(
    context,
    vnodeTag,
    vnodeProps,
    vnodeChildren,
    vnodePatchFlag,
    vnodeDynamicProps,
    vnodeDirectives,
    !!shouldUseBlock,
    false /* disableTracking */,
    node.loc
)

createVNodeCall() 依据这个节点增加了一个 createVNode Symbol 符号,它放在 helpers 里。其实就是要在代码生成阶段引入的帮忙函数。

// createVNodeCall() 外部执行过程,已删除多余的代码
context.helper(CREATE_VNODE)

return {
  type: NodeTypes.VNODE_CALL,
  tag,
  props,
  children,
  patchFlag,
  dynamicProps,
  directives,
  isBlock,
  disableTracking,
  loc
}

hoists

一个节点是否增加到 hoists 中,次要看它是不是动态节点。

<div name="test"> // 属性动态节点
  <!-- 这是正文 -->
  <p>{{test}}</p>
  一个文本节点 // 动态节点
  <div>good job!</div> // 动态节点
</div>

能够看到,下面有三个动态节点,所以 hoists 数组有 3 个值。至于正文为什么不算动态节点,临时没找到起因 …

type 变动

从上图能够看到,最外层的 div 的 type 原来为 1,通过 transform 生成的 codegenNode 中的 type 变成了 13。
这个 13 是代码生成对应的类型 VNODE_CALL。另外还有:

// codegen
VNODE_CALL, // 13
JS_CALL_EXPRESSION, // 14
JS_OBJECT_EXPRESSION, // 15
JS_PROPERTY, // 16
JS_ARRAY_EXPRESSION, // 17
JS_FUNCTION_EXPRESSION, // 18
JS_CONDITIONAL_EXPRESSION, // 19
JS_CACHE_EXPRESSION, // 20

方才提到的例子 {{test}},它的 codegenNode 就是通过调用 createVNodeCall() 生成的:

return {
  type: NodeTypes.VNODE_CALL,
  tag,
  props,
  children,
  patchFlag,
  dynamicProps,
  directives,
  isBlock,
  disableTracking,
  loc
}

能够从上述代码看到,type 被设置为 NodeTypes.VNODE_CALL,即 13。

每个不同的节点都由不同的 transform 函数来解决,因为篇幅无限,具体代码请自行查阅。

Codegen

代码生成阶段最初生成了一个字符串,咱们把字符串的双引号去掉,看一下具体的内容是什么:

const _Vue = Vue
const {createVNode: _createVNode, createCommentVNode: _createCommentVNode, createTextVNode: _createTextVNode} = _Vue

const _hoisted_1 = {name: "test"}
const _hoisted_2 = /*#__PURE__*/_createTextVNode("一个文本节点")
const _hoisted_3 = /*#__PURE__*/_createVNode("div", null, "good job!", -1 /* HOISTED */)

return function render(_ctx, _cache) {with (_ctx) {const { createCommentVNode: _createCommentVNode, toDisplayString: _toDisplayString, createVNode: _createVNode, createTextVNode: _createTextVNode, openBlock: _openBlock, createBlock: _createBlock} = _Vue

    return (_openBlock(), _createBlock("div", _hoisted_1, [_createCommentVNode("这是正文"),
      _createVNode("p", null, _toDisplayString(test), 1 /* TEXT */),
      _hoisted_2,
      _hoisted_3
    ]))
  }
}

代码生成模式

能够看到上述代码最初返回一个 render() 函数,作用是生成对应的 VNode。

其实代码生成有两种模式:module 和 function,由标识符 prefixIdentifiers 决定应用哪种模式。

function 模式的特点是:应用 const {helpers...} = Vue 的形式来引入帮忙函数,也就是是 createVode() createCommentVNode() 这些函数。向外导出应用 return 返回整个 render() 函数。

module 模式的特点是:应用 es6 模块来导入导出函数,也就是应用 import 和 export。

动态节点

另外还有三个变量是用 _hoisted_ 命名的,前面跟着数字,代表这是第几个动态变量。
再看一下 parse 阶段的 HTML 模板字符串:

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

这个示例只有一个动静节点,即 {{test}},剩下的全是动态节点。从生成的代码中也能够看出,生成的节点和模板中的代码是一一对应的。动态节点的作用就是只生成一次,当前间接复用。

仔细的网友可能发现了 _hoisted_2_hoisted_3 变量中都有一个 /*#__PURE__*/ 正文。

这个正文的作用是示意这个函数是纯函数,没有副作用,次要用于 tree-shaking。压缩工具在打包时会将未被应用的代码间接删除(shaking 摇掉)。

再来看一下生成动静节点 {{test}} 的代码:_createVNode("p", null, _toDisplayString(test), 1 /* TEXT */)

其中 _toDisplayString(test) 的外部实现是:

return val == null
    ? ''
    : isObject(val)
      ? JSON.stringify(val, replacer, 2)
      : String(val)

代码很简略,就是转成字符串输入。

_createVNode("p", null, _toDisplayString(test), 1 /* TEXT */) 最初一个参数 1 就是 transform 增加的 patchflag 了。

帮忙函数 helpers

在 transform、codegen 这两个阶段,咱们都能看到 helpers 的影子,到底 helpers 是干什么用的?

// Name mapping for runtime helpers that need to be imported from 'vue' in
// generated code. Make sure these are correctly exported in the runtime!
// Using `any` here because TS doesn't allow symbols as index type.
export const helperNameMap: any = {[FRAGMENT]: `Fragment`,
  [TELEPORT]: `Teleport`,
  [SUSPENSE]: `Suspense`,
  [KEEP_ALIVE]: `KeepAlive`,
  [BASE_TRANSITION]: `BaseTransition`,
  [OPEN_BLOCK]: `openBlock`,
  [CREATE_BLOCK]: `createBlock`,
  [CREATE_VNODE]: `createVNode`,
  [CREATE_COMMENT]: `createCommentVNode`,
  [CREATE_TEXT]: `createTextVNode`,
  [CREATE_STATIC]: `createStaticVNode`,
  [RESOLVE_COMPONENT]: `resolveComponent`,
  [RESOLVE_DYNAMIC_COMPONENT]: `resolveDynamicComponent`,
  [RESOLVE_DIRECTIVE]: `resolveDirective`,
  [WITH_DIRECTIVES]: `withDirectives`,
  [RENDER_LIST]: `renderList`,
  [RENDER_SLOT]: `renderSlot`,
  [CREATE_SLOTS]: `createSlots`,
  [TO_DISPLAY_STRING]: `toDisplayString`,
  [MERGE_PROPS]: `mergeProps`,
  [TO_HANDLERS]: `toHandlers`,
  [CAMELIZE]: `camelize`,
  [CAPITALIZE]: `capitalize`,
  [SET_BLOCK_TRACKING]: `setBlockTracking`,
  [PUSH_SCOPE_ID]: `pushScopeId`,
  [POP_SCOPE_ID]: `popScopeId`,
  [WITH_SCOPE_ID]: `withScopeId`,
  [WITH_CTX]: `withCtx`
}

export function registerRuntimeHelpers(helpers: any) {Object.getOwnPropertySymbols(helpers).forEach(s => {helperNameMap[s] = helpers[s]
  })
}

其实帮忙函数就是在代码生成时从 Vue 引入的一些函数,以便让程序失常执行,从下面生成的代码中就可以看进去。而 helperNameMap 是默认的映射表名称,这些名称就是要从 Vue 引入的函数名称。

另外,咱们还能看到一个注册函数 registerRuntimeHelpers(helpers: any(),它是干什么用的呢?

咱们晓得编译模块 compiler-core 是平台无关的,而 compiler-dom 是浏览器相干的编译模块。为了能在浏览器失常运行 Vue 程序,就得把浏览器相干的 Vue 数据和函数导入进来。
registerRuntimeHelpers(helpers: any() 正是用来做这件事的,从 compiler-dom 的 runtimeHelpers.ts 文件就能看进去:

registerRuntimeHelpers({[V_MODEL_RADIO]: `vModelRadio`,
  [V_MODEL_CHECKBOX]: `vModelCheckbox`,
  [V_MODEL_TEXT]: `vModelText`,
  [V_MODEL_SELECT]: `vModelSelect`,
  [V_MODEL_DYNAMIC]: `vModelDynamic`,
  [V_ON_WITH_MODIFIERS]: `withModifiers`,
  [V_ON_WITH_KEYS]: `withKeys`,
  [V_SHOW]: `vShow`,
  [TRANSITION]: `Transition`,
  [TRANSITION_GROUP]: `TransitionGroup`
})

它运行 registerRuntimeHelpers(helpers: any(),往映射表注入了浏览器相干的局部函数。

helpers 是怎么应用的呢?

在 parse 阶段,解析到不同节点时会生成对应的 type。

在 transform 阶段,会生成一个 helpers,它是一个 set 数据结构。每当它转换 AST 时,都会依据 AST 节点的 type 增加不同的 helper 函数。

例如,假如它当初正在转换的是一个正文节点,它会执行 context.helper(CREATE_COMMENT),外部实现相当于 helpers.add('createCommentVNode')。而后在 codegen 阶段,遍历 helpers,将程序须要的函数从 Vue 里导入,代码实现如下:

// 这是 module 模式
`import { ${ast.helpers
  .map(s => `${helperNameMap[s]} as _${helperNameMap[s]}`)
  .join(',')} } from ${JSON.stringify(runtimeModuleName)}\n`

如何生成代码?

从 codegen.ts 文件中,能够看到很多代码生成函数:

generate() // 代码生成入口文件
genFunctionExpression() // 生成函数表达式
genNode() // 生成 Vnode 节点
...

生成代码则是依据不同的 AST 节点调用不同的代码生成函数,最终将代码字符串拼在一起,输入一个残缺的代码字符串。

老规矩,还是看一个例子:

const _hoisted_1 = {name: "test"}
const _hoisted_2 = /*#__PURE__*/_createTextVNode("一个文本节点")
const _hoisted_3 = /*#__PURE__*/_createVNode("div", null, "good job!", -1 /* HOISTED */)

看一下这段代码是怎么生成的,首先执行 genHoists(ast.hoists, context),将 transform 生成的动态节点数组 hoists 作为第一个参数。genHoists() 外部实现:

hoists.forEach((exp, i) => {if (exp) {push(`const _hoisted_${i + 1} = `);
        genNode(exp, context);
        newline();}
})

从上述代码能够看到,遍历 hoists 数组,调用 genNode(exp, context)genNode() 依据不同的 type 执行不同的函数。

const _hoisted_1 = {name: "test"}

这一行代码中的 const _hoisted_1 = genHoists() 生成,{name: "test"}genObjectExpression() 生成。
同理,剩下的两行代码生成过程也是如此,只是最终调用的函数不同。

Vue3 源码剖析系列文章

  • Vue3 源码剖析(一):响应式模块 reactivity
  • Vue3 源码剖析(二):编译模块 compiler
正文完
 0