Vue3.0 核心源码解读 | 生成代码:AST 如何生成可运行的代码?(上) | 蛋烘糕
蛋烘糕.

不写博客的工程师不是好的搬砖工🧱

Vue3.0 核心源码解读 | 生成代码:AST 如何生成可运行的代码?(上)

Cover Image for Vue3.0 核心源码解读 | 生成代码:AST 如何生成可运行的代码?(上)
蛋烘糕
蛋烘糕

纸上得来终觉浅,绝知此事要躬行。

上一篇我们分析了 AST 节点转换的过程,也知道了 AST 节点转换的作用是通过语法分析,创建了语义和信息更加丰富的代码生成节点 codegenNode,便于后续生成代码。

那么这一篇,我们就来分析整个编译的过程的最后一步——代码生成的实现原理。

同样的,代码生成阶段由于要处理的场景很多,所以代码也非常多而复杂。为了方便你理解它的核心流程,我们还是通过这个示例来演示整个代码生成的过程:

<div class="app">
  <hello v-if="flag"></hello>
  <div v-else>
    <p>hello {{ msg + test }}</p>
    <p>static</p>
    <p>static</p>
  </div>
</div>

代码生成的结果是和编译配置相关的,你可以打开官方提供的模板导出工具平台,点击右上角的 Options 修改编译配置。为了让你理解核心的流程,这里我只分析一种配置方案,当然当你理解整个编译核心流程后,也可以修改这些配置分析其他的分支逻辑。

我们分析的编译配置是:mode 为 module,prefixIdentifiers 开启,hoistStatic 开启,其他配置均不开启。

为了让你有个大致印象,我们先来看一下上述例子生成代码的结果:

import {
  resolveComponent as _resolveComponent,
  createVNode as _createVNode,
  createCommentVNode as _createCommentVNode,
  toDisplayString as _toDisplayString,
  openBlock as _openBlock,
  createBlock as _createBlock,
} from "vue";
const _hoisted_1 = { class: "app" };
const _hoisted_2 = { key: 1 };
const _hoisted_3 = /*#__PURE__*/ _createVNode(
  "p",
  null,
  "static",
  -1 /* HOISTED */
);
const _hoisted_4 = /*#__PURE__*/ _createVNode(
  "p",
  null,
  "static",
  -1 /* HOISTED */
);
export function render(_ctx, _cache) {
  const _component_hello = _resolveComponent("hello");
  return (
    _openBlock(),
    _createBlock("div", _hoisted_1, [
      _ctx.flag
        ? _createVNode(_component_hello, { key: 0 })
        : (_openBlock(),
          _createBlock("div", _hoisted_2, [
            _createVNode(
              "p",
              null,
              "hello " + _toDisplayString(_ctx.msg + _ctx.test),
              1 /* TEXT */
            ),
            _hoisted_3,
            _hoisted_4,
          ])),
    ])
  );
}

示例的模板是如何转换生成这样的代码的?在 AST 转换后,会执行 generate 函数生成代码:

return generate(
  ast,
  extend({}, options, {
    prefixIdentifiers,
  })
);

generate 函数的输入就是转换后的 AST 根节点,我们看一下它的实现:

function generate(ast, options = {}) {
  // 创建代码生成上下文
  const context = createCodegenContext(ast, options);
  const {
    mode,
    push,
    prefixIdentifiers,
    indent,
    deindent,
    newline,
    scopeId,
    ssr,
  } = context;
  const hasHelpers = ast.helpers.length > 0;
  const useWithBlock = !prefixIdentifiers && mode !== "module";
  const genScopeId = scopeId != null && mode === "module";
  // 生成预设代码
  if (mode === "module") {
    genModulePreamble(ast, context, genScopeId);
  } else {
    genFunctionPreamble(ast, context);
  }
  if (!ssr) {
    push(`function render(_ctx, _cache) {`);
  } else {
    push(`function ssrRender(_ctx, _push, _parent, _attrs) {`);
  }
  indent();
  if (useWithBlock) {
    // 处理带 with 的情况,Web 端运行时编译
    push(`with (_ctx) {`);
    indent();
    if (hasHelpers) {
      push(
        `const { ${ast.helpers
          .map((s) => `${helperNameMap[s]}: _${helperNameMap[s]}`)
          .join(", ")} } = _Vue`
      );
      push(`\n`);
      newline();
    }
  }
  // 生成自定义组件声明代码
  if (ast.components.length) {
    genAssets(ast.components, "component", context);
    if (ast.directives.length || ast.temps > 0) {
      newline();
    }
  }
  // 生成自定义指令声明代码
  if (ast.directives.length) {
    genAssets(ast.directives, "directive", context);
    if (ast.temps > 0) {
      newline();
    }
  }
  // 生成临时变量代码
  if (ast.temps > 0) {
    push(`let `);
    for (let i = 0; i < ast.temps; i++) {
      push(`${i > 0 ? `, ` : ``}_temp${i}`);
    }
  }
  if (ast.components.length || ast.directives.length || ast.temps) {
    push(`\n`);
    newline();
  }
  if (!ssr) {
    push(`return `);
  }
  // 生成创建 VNode 树的表达式
  if (ast.codegenNode) {
    genNode(ast.codegenNode, context);
  } else {
    push(`null`);
  }
  if (useWithBlock) {
    deindent();
    push(`}`);
  }
  deindent();
  push(`}`);
  return {
    ast,
    code: context.code,
    map: context.map ? context.map.toJSON() : undefined,
  };
}

generate 主要做五件事情:创建代码生成上下文,生成预设代码,生成渲染函数,生成资源声明代码,以及生成创建 VNode 树的表达式。接下来,我们就依次详细分析这几个流程。

1 创建代码生成上下文

首先,是通过执行 createCodegenContext 创建代码生成上下文,我们来看它的实现:

function createCodegenContext(
  ast,
  {
    mode = "function",
    prefixIdentifiers = mode === "module",
    sourceMap = false,
    filename = `template.vue.html`,
    scopeId = null,
    optimizeBindings = false,
    runtimeGlobalName = `Vue`,
    runtimeModuleName = `vue`,
    ssr = false,
  }
) {
  const context = {
    mode,
    prefixIdentifiers,
    sourceMap,
    filename,
    scopeId,
    optimizeBindings,
    runtimeGlobalName,
    runtimeModuleName,
    ssr,
    source: ast.loc.source,
    code: ``,
    column: 1,
    line: 1,
    offset: 0,
    indentLevel: 0,
    pure: false,
    map: undefined,
    helper(key) {
      return `_${helperNameMap[key]}`;
    },
    push(code) {
      context.code += code;
    },
    indent() {
      newline(++context.indentLevel);
    },
    deindent(withoutNewLine = false) {
      if (withoutNewLine) {
        --context.indentLevel;
      } else {
        newline(--context.indentLevel);
      }
    },
    newline() {
      newline(context.indentLevel);
    },
  };
  function newline(n) {
    context.push("\n" + `  `.repeat(n));
  }
  return context;
}

这个上下文对象 context 维护了 generate 过程的一些配置,比如 mode、prefixIdentifiers;也维护了 generate 过程的一些状态数据,比如当前生成的代码 code,当前生成代码的缩进 indentLevel 等。

此外,context 还包含了在 generate 过程中可能会调用的一些辅助函数,接下来我会介绍几个常用的方法,它们会在整个代码生成节点过程中经常被用到。

  • push(code),就是在当前的代码 context.code 后追加 code 来更新它的值。

  • indent(),它的作用就是增加代码的缩进,它会让上下文维护的代码缩进 context.indentLevel 加 1,内部会执行 newline 方法,添加一个换行符,以及两倍 indentLevel 对应的空格来表示缩进的长度。

  • deindent(),和 indent 相反,它会减少代码的缩进,让上下文维护的代码缩进 context.indentLevel 减 1,在内部会执行 newline 方法去添加一个换行符,并减少两倍 indentLevel 对应的空格的缩进长度。

上下文创建完毕后,接下来就到了真正的代码生成阶段,在分析的过程中我会结合示例讲解,让你更直观地理解整个代码的生成过程,我们先来看生成预设代码。

2 生成预设代码

因为 mode 是 module,所以会执行 genModulePreamble 生成预设代码,我们来看它的实现:

function genModulePreamble(ast, context, genScopeId) {
  const { push, newline, optimizeBindings, runtimeModuleName } = context;

  // 处理 scopeId

  if (ast.helpers.length) {
    // 生成 import 声明代码
    if (optimizeBindings) {
      push(
        `import { ${ast.helpers
          .map((s) => helperNameMap[s])
          .join(", ")} } from ${JSON.stringify(runtimeModuleName)}\n`
      );
      push(
        `\n// Binding optimization for webpack code-split\nconst ${ast.helpers
          .map((s) => `_${helperNameMap[s]} = ${helperNameMap[s]}`)
          .join(", ")}\n`
      );
    } else {
      push(
        `import { ${ast.helpers
          .map((s) => `${helperNameMap[s]} as _${helperNameMap[s]}`)
          .join(", ")} } from ${JSON.stringify(runtimeModuleName)}\n`
      );
    }
  }
  // 处理 ssrHelpers

  // 处理 imports

  // 处理 scopeId

  genHoists(ast.hoists, context);
  newline();
  push(`export `);
}

下面我们结合前面的示例来分析这个过程,此时 genScopeId 为 false,所以相关逻辑我们可以不看。ast.helpers 是在 transform 阶段通过 context.helper 方法添加的,它的值如下:

[
  Symbol(resolveComponent),
  Symbol(createVNode),
  Symbol(createCommentVNode),
  Symbol(toDisplayString),
  Symbol(openBlock),
  Symbol(createBlock),
];

ast.helpers 存储了 Symbol 对象的数组,我们可以从 helperNameMap 中找到每个 Symbol 对象对应的字符串,helperNameMap 的定义如下:

const helperNameMap = {
  [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`,
  [SET_BLOCK_TRACKING]: `setBlockTracking`,
  [PUSH_SCOPE_ID]: `pushScopeId`,
  [POP_SCOPE_ID]: `popScopeId`,
  [WITH_SCOPE_ID]: `withScopeId`,
  [WITH_CTX]: `withCtx`,
};

由于 optimizeBindings 是 false,所以会执行如下代码:

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

最终会生成这些代码,并更新到 context.code 中:

import {
  resolveComponent as _resolveComponent,
  createVNode as _createVNode,
  createCommentVNode as _createCommentVNode,
  toDisplayString as _toDisplayString,
  openBlock as _openBlock,
  createBlock as _createBlock,
} from "vue";

通过生成的代码,我们可以直观地感受到,这里就是从 Vue 中引入了一些辅助方法,那么为什么需要引入这些辅助方法呢,这就和 Vue.js 3.0 的设计有关了。

在 Vue.js 2.x 中,创建 VNode 的方法比如 $createElement、_c 这些都是挂载在组件的实例上,在生成渲染函数的时候,直接从组件实例 vm 中访问这些方法即可。

而到了 Vue.js 3.0,创建 VNode 的方法 createVNode 是直接通过模块的方式导出,其它方法比如 resolveComponent、openBlock ,都是类似的,所以我们首先需要生成这些 import 声明的预设代码。

我们接着往下看,ssrHelpers 是 undefined,imports 的数组长度为空,genScopeId 为 false,所以这些内部逻辑都不会执行,接着执行 genHoists 生成静态提升的相关代码,我们来看它的实现:

function genHoists(hoists, context) {
  if (!hoists.length) {
    return;
  }
  context.pure = true;
  const { push, newline } = context;

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

  context.pure = false;
}

首先通过执行 newline 生成一个空行,然后遍历 hoists 数组,生成静态提升变量定义的方法。此时 hoists 的值是这样的:

[
  {
    type: 15 /* JS_OBJECT_EXPRESSION */,
    properties: [
      {
        type: 16 /* JS_PROPERTY */,
        key: {
          type: 4 /* SIMPLE_EXPRESSION */,
          isConstant: false,
          content: "class",
          isStatic: true,
        },
        value: {
          type: 4 /* SIMPLE_EXPRESSION */,
          isConstant: false,
          content: "app",
          isStatic: true,
        },
      },
    ],
  },
  {
    type: 15 /* JS_OBJECT_EXPRESSION */,
    properties: [
      {
        type: 16 /* JS_PROPERTY */,
        key: {
          type: 4 /* SIMPLE_EXPRESSION */,
          isConstant: false,
          content: "key",
          isStatic: true,
        },
        value: {
          type: 4 /* SIMPLE_EXPRESSION */,
          isConstant: false,
          content: "1",
          isStatic: false,
        },
      },
    ],
  },
  {
    type: 13 /* VNODE_CALL */,
    tag: '"p"',
    children: {
      type: 2 /* ELEMENT */,
      content: "static",
    },
    patchFlag: "-1 /* HOISTED */",
    isBlock: false,
    disableTracking: false,
  },
  {
    type: 13 /* VNODE_CALL */,
    tag: '"p"',
    children: {
      type: 2 /* ELEMENT */,
      content: "static",
    },
    patchFlag: "-1 /* HOISTED */",
    isBlock: false,
    disableTracking: false,
  },
];

这里,hoists 数组的长度为 4,前两个都是 JavaScript 对象表达式节点,后两个是 VNodeCall 节点,通过 genNode 我们可以把这些节点生成对应的代码,这个方法我们后续会详细说明,这里先略过。

然后通过遍历 hoists 我们生成如下代码:

import {
  resolveComponent as _resolveComponent,
  createVNode as _createVNode,
  createCommentVNode as _createCommentVNode,
  toDisplayString as _toDisplayString,
  openBlock as _openBlock,
  createBlock as _createBlock,
} from "vue";
const _hoisted_1 = { class: "app" };
const _hoisted_2 = { key: 1 };
const _hoisted_3 = /*#__PURE__*/ _createVNode(
  "p",
  null,
  "static",
  -1 /* HOISTED */
);
const _hoisted_4 = /*#__PURE__*/ _createVNode(
  "p",
  null,
  "static",
  -1 /* HOISTED */
);

可以看到,除了从 Vue 中导入辅助方法,我们还创建了静态提升的变量。

我们回到 genModulePreamble,接着会执行newline()push(export ),非常好理解,也就是添加了一个空行和 export 字符串。

至此,预设代码生成完毕,我们就得到了这些代码:

import { resolveComponent as _resolveComponent, createVNode as _createVNode, createCommentVNode as _createCommentVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createBlock as _createBlock } from "vue"
const _hoisted_1 = { class: "app" }
const _hoisted_2 = { key: 1 }
const _hoisted_3 = /*#__PURE__*/_createVNode("p", null, "static", -1 /* HOISTED */)
const _hoisted_4 = /*#__PURE__*/_createVNode("p", null, "static", -1 /* HOISTED */)
export

3 生成渲染函数

接下来,就是生成渲染函数了,我们回到 generate 函数:

if (!ssr) {
  push(`function render(_ctx, _cache) {`);
} else {
  push(`function ssrRender(_ctx, _push, _parent, _attrs) {`);
}
indent();

由于 ssr 为 false, 所以生成如下代码:

import { resolveComponent as _resolveComponent, createVNode as _createVNode, createCommentVNode as _createCommentVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createBlock as _createBlock } from "vue"
const _hoisted_1 = { class: "app" }
const _hoisted_2 = { key: 1 }
const _hoisted_3 = /*#__PURE__*/_createVNode("p", null, "static", -1 /* HOISTED */)
const _hoisted_4 = /*#__PURE__*/_createVNode("p", null, "static", -1 /* HOISTED */)
export function render(_ctx, _cache) {

注意,这里代码的最后一行有 2 个空格的缩进

另外,由于 useWithBlock 为 false,所以我们也不需生成 with 相关的代码。而且,这里我们创建了 render 的函数声明,接下来的代码都是在生成 render 的函数体。

4 生成资源声明代码

在 render 函数体的内部,我们首先要生成资源声明代码:

// 生成自定义组件声明代码
if (ast.components.length) {
  genAssets(ast.components, "component", context);
  if (ast.directives.length || ast.temps > 0) {
    newline();
  }
}
// 生成自定义指令声明代码
if (ast.directives.length) {
  genAssets(ast.directives, "directive", context);
  if (ast.temps > 0) {
    newline();
  }
}
// 生成临时变量代码
if (ast.temps > 0) {
  push(`let `);
  for (let i = 0; i < ast.temps; i++) {
    push(`${i > 0 ? `, ` : ``}_temp${i}`);
  }
}

在我们的示例中,directives 数组长度为 0,temps 的值是 0,所以自定义指令和临时变量代码生成的相关逻辑跳过,而这里 components 的值是["hello"]

接着就通过 genAssets 去生成自定义组件声明代码,我们来看一下它的实现:

function genAssets(assets, type, { helper, push, newline }) {
  const resolver = helper(
    type === "component" ? RESOLVE_COMPONENT : RESOLVE_DIRECTIVE
  );
  for (let i = 0; i < assets.length; i++) {
    const id = assets[i];
    push(
      `const ${toValidAssetId(id, type)} = ${resolver}(${JSON.stringify(id)})`
    );
    if (i < assets.length - 1) {
      newline();
    }
  }
}

这里的 helper 函数就是从前面提到的 helperNameMap 中查找对应的字符串,对于 component,返回的就是 resolveComponent。

接着会遍历 assets 数组,生成自定义组件声明代码,在这个过程中,它们会把变量通过 toValidAssetId 进行一层包装:

function toValidAssetId(name, type) {
  return `_${type}_${name.replace(/[^\w]/g, "_")}`;
}

比如 hello 组件,执行 toValidAssetId 就变成了 _component_hello。

因此对于我们的示例而言,genAssets 后生成的代码是这样的:

import { resolveComponent as _resolveComponent, createVNode as _createVNode, createCommentVNode as _createCommentVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createBlock as _createBlock } from "vue"
const _hoisted_1 = { class: "app" }
const _hoisted_2 = { key: 1 }
const _hoisted_3 = /*#__PURE__*/_createVNode("p", null, "static", -1 /* HOISTED */)
const _hoisted_4 = /*#__PURE__*/_createVNode("p", null, "static", -1 /* HOISTED */)
export function render(_ctx, _cache) {
  const _component_hello = _resolveComponent("hello")

这很好理解,通过 resolveComponent,我们就可以解析到注册的自定义组件对象,然后在后面创建组件 vnode 的时候当做参数传入。

回到 generate 函数,接下来会执行如下代码:

if (ast.components.length || ast.directives.length || ast.temps) {
  push(`\n`);
  newline();
}
if (!ssr) {
  push(`return `);
}

这里是指,如果生成了资源声明代码,则在尾部添加一个换行符,然后再生成一个空行,并且如果不是 ssr,则再添加一个 return 字符串,此时得到的代码结果如下:

import { resolveComponent as _resolveComponent, createVNode as _createVNode, createCommentVNode as _createCommentVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createBlock as _createBlock } from "vue"
const _hoisted_1 = { class: "app" }
const _hoisted_2 = { key: 1 }
const _hoisted_3 = /*#__PURE__*/_createVNode("p", null, "static", -1 /* HOISTED */)
const _hoisted_4 = /*#__PURE__*/_createVNode("p", null, "static", -1 /* HOISTED */)
export function render(_ctx, _cache) {
  const _component_hello = _resolveComponent("hello")
  return

好的,我们就先分析到这里,下一篇继续来看生成创建 VNode 树的表达式的过程。

本篇的相关代码在源代码中的位置如下
packages/compiler-core/src/codegen.ts

Vue3.0 核心源码解读 | AST 转换:AST 节点内部做了哪些转换?(下)
Vue3.0核心源码解读 | 生成代码:AST 如何生成可运行的代码?(下)
博客日历
2022年10月
26
27
28
29
30
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
01
02
03
04
05
06
更多