6

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

compiler-core // 编译核心
Compiler-DOM // 浏览器相关
Compiler-sfc // 单文件组件
Compiler-SSR // 服务端渲染

其中,compiler-core 模块是Vue编译的核心模块,与平台无关。其余三个基于 compiler-core,适用于不同的平台。

Vue 的编译分为三个阶段,即 解析(Parse)、转换(Transform)和代码生成(Codegen)

Parse 阶段将模板字符串转换为语法抽象树 ASTTransform 阶段对 AST 做一些转换处理。Codegen 阶段根据 AST 生成相应的渲染函数字符串。

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)
    }
}

原始代码点这里vue-next parse.ts

相对应的几个函数如下:

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

当标签、文本、注释等每个节点生成相应的AST节点时,Vue 将截断解析的字符串。

字符串被截断是使用 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 节点定义都在 Compiler-core/astts 文件中,下面是元素节点的定义:

export interface BaseElementNode extends Node {
     TYPE: NODETYPES.EEMENT / / Type 类型
     NS: namespace // 名称空间默认为html, ie 0
     Tag: String // 标签名称
     tagType: ElementTypes // 元素类型
     IsselfClosing: boolean // 是否为自闭标记, 例如 <hr />
     Props: Array <Attribute | DirectiveNode> // 属性, 包含 Html 属性和指令
     Children: TemplateChildNode [] // 子级模板指向
}

用一个比较复杂的例子来解释解析过程。

<div name="test">
     <!-- This is a comment-->
  <p>{{ test }}</p>
     A text node
  <div>good job!</div>
</div>

上面的模板字符串假定为 S,第一个字符 S[0] 在开始时为 <,这意味着它只能是刚才提到的四种情况之一。

再看看 S[1] 第二个字符的规则:

  1. 遇到 ! 时,调用字符串原始方法 startsWith(),分析是 <!--的开头, 还是 <!DOCTYPE 的开头,它们对应的处理函数不同,例子中代码最终将解析到注释节点。
  2. 如果是 / ,按结束标签。
  3. 如果不是 /,按开始标签处理。

在我们的示例中,这是一个 <div> 开始标签。

这里要提到的一点,Vue 将使用栈来保存已解析的元素标签。当遇到开始标记时,标签被推入栈中。当遇到结束标记时,将弹出栈。它的作用是保存已解析但尚未解析完的元素标签。在这个栈中还有另一个角色,通过 stack[stack.length-1] ,可以得到它的父元素。

从我们的例子来看,在解析过程中,栈中存储如下:

1. [div] // div 入栈
2. [div, P] // p 入栈
3. [div] // P 弹出
4. [div, div] // div 入栈
5. [div] // div 弹出
6. [] // 最后一个div弹出后,模板字符串已解析,栈为空。

按照上面的例子,接下来将截断 <div 字符串,并解析其属性。

属性有两种情况:

  1. HTML的普通属性
  2. Vue的指令

生成的类型节点值,HTML 普通属性节点类型为6,Vue 指令节点类型为7。

所有节点类型值详情如下:

Root, // 根节点为 0
Element, // 元素节点为 1
Text, // 文本节点为 2
Comment, // 注释节点为 3
Simple_expression, // 简单表达式为 4
Interpolation, // 双花插值 {{}} 为 5
Attribute, // 属性为 6
Directive, // 指令为 7

属性分析后,div 开始标签被分析完毕,<div name="test"> 此行字符串被截断。其余字符串现在如下所示:

<!-- This is a comment -->
  <p>{{ test }}</p>
     A text node
  <div>good job!</div>
</div>

注释文本和普通文本节点解析规则比较简单简单,直接截断,生成节点。注释节点调用 parseComment() 函数处理,Text 节点调用 parseText() 处理。

双花插值 {{test}} 的字符串处理逻辑稍微复杂一些:

  1. 首先提取出双括号内的内容,即 test,调用 trim 函数去掉两边空格。
  2. 然后生成两个节点,一个节点为 INTERPOLATION 类型值为5,表示它是一个双花插值。
  3. 第二个节点是其内容 test,将生成节点为 Simple_expression,类型值为4。
return {
  TYPE: NODETYPES.ITERPOLATION, // 双花括号类型
  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,表示这是一个动态节点。如果是静态节点,则只生成一次,并且会复用相同的节点,不需要进行差异比较。

还有一个标签类型值,它有4个值:

export const enum ElementTypes {
  ELEMENT, // 0 元素节点 
  Component, // 1 注释节点
  Slot, // 2 插槽节点
  Template // 3 模板
}

主要用于区分以上四种类型的节点。

Transform 阶段

在转换阶段,Vue 将对 AST 执行一些转换操作,主要是根据 CodeGen阶段 使用的不同 AST节点添加不同的选项参数。以下是一些重要的选项:

cacheHandlers 缓存处理程序

如果 CacheHandlers 的值为 true,则启用函数缓存。例如 @click="foo" 默认情况下编译为 {onClick:foo},如果打开此选项,则编译为:

{ onClick: _cache[0] || (_cache[0] = e => _ctx.foo(e)) }  // 具备缓存功能

hoistStatic 静态提升

hoistStatic 是一个标识符,表示是否应启用静态节点提升。如果值为 true ,静态节点将被提升在 render() 函数外部,生成名为 _hoisted_x 的变量。

例如,文本 A text node 生成的代码为 const hoisted_2 = / # pure / createtextVNode ("a text node")

在下面两张图片中,前一张为 hoistStatic=false,后一张为 hoistStatic=true,都可以自己尝试一下 地址

prefixIdentifiers 前缀标识

此参数的角色用于代码生成。例如,{{ foo }} 模块(module)模式下生成的代码是 _ctx.foo,函数(function)模式下生成的代码是 width(this){…}。因为在模块(module)模式下,默认为严格模式,不能使用 with 语句。

PatchFlags 补丁标识

转换为 AST 节点时,使用 PatchFlag 参数,该参数主要用于差异比较 diff 过程。当 DOM 节点具有此标志且大于0时,它将被更新,并且不会跳过。

来看看 PatchFlag 的值:

export const enum PatchFlags {
  // 动态文本节点
  TEXT = 1,
    // 动态类
  CLASS = 1 << 1, // 2
    // 动态Style
  STYLE = 1 << 2, // 4
    // 动态属性,但不包括 calss 和 style
  // 如果是组件,则可以包含 calss 和 style。
  PROPS = 1 << 3, // 8
  // 具有动态键属性,当键更改时,需要进行完整的 DIFF 差异比较
  FULL_PROPS = 1 << 4, // 16
    // 具有侦听事件的节点
  HYDRATE_EVENTS = 1 << 5, // 32
    // 不改变子序列的片段
  STABLE_FRAGMENT = 1 << 6, // 64
    // 具有key属性的片段或部分子字节具有key
  KEYED_FRAGMENT = 1 << 7, // 128
  // 子节点没有密钥的 key
  UNKEYED_FRAGMENT = 1 << 8, // 256
  // 节点将仅执行 non-PROPS 比较
  NEED_PATCH = 1 << 9, // 512
  // 动态插槽
  DYNAMIC_SLOTS = 1 << 10, // 1024
  // 静态节点
  HOISTED = -1,
  // 退出 DIFF 差异比较优化模式
  BAIL = -2
}

从上面的代码可以看出,PatchFlag 使用 bit-map 来表示不同的值,每个值都有不同的含义。 Vue 会在 diff 过程中根据不同的修补标志使用不同的修补方法。

下图为变换后的 AST

可以看到 CodegenNodeHelpersHoists 已填充了相应的值。CodegenNode 是生成要使用的代码的数据。Hoists 存储静态节点。Helpers 存储创建 vNode 的函数名(实际上是 Symbol)。

在正式开始转换之前,需要创建一个 transformContext,即转换上下文。与这三个属性相关的数据和方法如下:

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
},

让我们来看看具体的转换过程是如何使用的。用 <p>{{ test }}</p> 举例说明。

此节点对应 TransformElement() 转换函数,因为 p 没有绑定动态属性,没有绑定指令,所以焦点不在它上面。而 {{test}} 是一个双花插值表达式,所以将其 patchflag 设置为1(动态文本节点),相应的执行代码 patchFlag |=1。然后执行 createVNodeCall() 函数,其返回值为该节点的 codegennode 值。

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

createVNodeCall() 会相应的在 createVNode() 中添加一个符号,它放置在 helpers 中。事实上,helpers 功能将在代码生成阶段引入。

// createVNodeCall () 内部执行过程,多余代码已删除
context.helper(CREATE_VNODE)
return {
  type: NodeTypes.VNODE_CALL,
  tag,
  props,
  children,
  patchFlag,
  dynamicProps,
  directives,
  isBlock,
  disableTracking,
  loc
}

hoists 提升

是否将节点提升,主要看它是否是静态节点。

<div name = "test"> // 静态属性节点
     <! - This is a comment->
  <p>{{ test }}</p>
     A text node // 静态节点
     <div> good job! </div> // 静态节点
</div>

可以看到,上面有三个静态节点,因此 hoists 数组有3个值。注释为什么不算静态节点,暂时还没有找到原因。。。

TYPE changes 类型改变

从上图中可以看出,最外层 div 的类型为1,由 Transform 生成的 CodeGen node 中的类型为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 }}, 其 codegen nodecreateVnodeCall 函数生成。

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

从上面的代码可以看出,type 设置为 nodetypes.VNODE_CALL,即13。
每个不同的节点由不同的变换函数处理。可以自己再深入的了解。

Codegen阶段

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

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

const _hoisted_1 = { name: "test" }
 const _hoisted_2 = / * # __ pure __ * / _ createtextVNode ("a text node")
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 ("This is a comment"),
      _createVNode("p", null, _toDisplayString(test), 1 /* TEXT */),
      _hoisted_2,
      _hoisted_3
    ]))
  }
}

代码生成模式

可以看到上面的代码最终返回了 render() 函数,生成相应的 VNODE

实际上,代码生成有两种模式:模块和函数。选取哪种模式由前缀标识符决定。

函数模式功能:使用 const {helpers…}=Vue 获取帮助函数的方法,即 createVode()createCommentVNode() 这些函数,最后返回 render() 函数。

模块模式为:使用ES6模块导入导出功能,即 importexport

Static node 静态节点

此外,还有三个变量。以 hoisted 命名,后面跟数字,表示这是静态变量。

看看解析阶段的 HTML 模板字符串:

<div name="test">
     <! - This is a comment->
  <p>{{ test }}</p>
     A text node
  <div>good job!</div>
</div>

这个示例只有一个动态节点,即 {{test},其余的都是静态节点。从生成的代码中还可以看出,生成的节点和模板中的代码对应于一个或多个节点。静态节点的作用是只生成一次,以后直接重用。

细心的你可能会发现 Highed_2Highed_3 变量有一个 /#\_PURE_/ 的注释。

此注释的作用是表明此功能是纯功能,无副作用,主要用于Tree-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 /) 的最后一个参数增加转换时的 Patchflag 值。

Help function 辅助函数

TransformCodegen 阶段,都看到了 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.
// 需要从生成代码中的“vue”导入的运行时帮助程序的名称映射。
// 确保这些文件在运行时正确导出!
// 此处使用'any',因为TS不允许将符号作为索引类型。
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() 是做什么用的呢?

我们知道编译模块的编译器核心是一个独立于平台的,而编译Dom是一个与浏览器相关的编译模块。要在浏览器中运行 Vue 程序,请导入与浏览器相关的 Vue 数据和功能。

registerRuntimeHelpers(helpers: any() 用于执行此操作,可以从 Compiler-domruntimehelpers.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() 映射表被注入与浏览器相关的函数。

如何使用这些辅助函数?

在解析阶段,解析不同节点时会生成相应的类型。

在转换阶段,生成一个辅助对象,它是一个集合数据结构。每当转换 AST 时,都会根据 AST 节点的类型添加不同的帮助器函数。

例如,假设现在正在转换注释节点,它将执行 context.helper(CREATE_COMMENT) ,内部通过 helpers.add('createCommentVNode') 添加。

然后在 Codegen 阶段,遍历 helpers,从 Vue 导入所需的函数,代码实现如下:

// 这是模块模式
`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 ("a text node")
const _hoisted_3 = /*#__PURE__*/_createVNode("div", null, "good job!", -1 /* HOISTED */)

看看这段代码是如何生成的,内部会执行 genHoists(ast.hoists, context), 提升的静态节点作为第一个参数,genHoists() 内部简化实现:

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

从上面的代码可以看出,遍历 hoists 数组,调用 genNode(exp, context) 函数。genNode() 根据不同的类型执行不同的功能。

const _hoisted_1 = { name: "test" }

这一行的 const _hoisted_1 = 通过 genHoists() 函数生成,{ name: "test" } 是通过 genObjectExpression() 函数生成。


戎马
2.4k 声望346 粉丝

前端码农一枚,上班一族,爱文学一本。ส็็็็็็็็็็็็็็ ส้้้้้้้้้้้้้้้้้้้。