Vue3 源码解读之代码生成器

729 阅读6分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情 >>

版本:3.2.31

代码生成器 generate 在编译器的编译过程中负责将 JavaScript AST 转换成渲染函数,如下图所示:

代码生成也是编译器的最后一步,如下面的源码所示:

// packages/compiler-core/src/compile.ts
export function baseCompile(
  template: string | RootNode,
  options: CompilerOptions = {}
): CodegenResult {

  // 省略部分代码

  // 1. 生成模板AST
  const ast = isString(template) ? baseParse(template, options) : template
  
  // 省略部分代码

  // 2. 将 模板AST 转换成 JavaScript AST
  transform(
    ast,
    extend({}, options, {
      prefixIdentifiers,
      nodeTransforms: [
        ...nodeTransforms,
        ...(options.nodeTransforms || []) // user transforms
      ],
      directiveTransforms: extend(
        {},
        directiveTransforms,
        options.directiveTransforms || {} // user transforms
      )
    })
  )

  // 3. 将JavaScript AST 转换成渲染函数,generate函数会将渲染函数的代码以字符串的形式返回。
  return generate(
    ast,
    extend({}, options, {
      prefixIdentifiers
    })
  )
}

下面,我们从代码生成器的入口函数 generate 入手,来探究生成器的工作方式。

generate 函数签名

// packages/compiler-core/src/codegen.ts

export function generate(
  ast: RootNode, // JavaScript AST
  options: CodegenOptions & {
    onContextCreated?: (context: CodegenContext) => void
  } = {}
): CodegenResult {}


export interface CodegenResult {
  code: string
  preamble: string
  ast: RootNode
  map?: RawSourceMap
}

从上面的源码中可以看到,generate 函数接收两个参数:

  • ast:经过 transform 转换器处理后的 JavaScript AST
  • options:代码生成选项,如生成的代码模式 mode,是否生成 source map 等

函数最终返回一个 CodegenResult 类型的对象,其中包含了最终生成的渲染函数的代码字符串,代码字符串的前置部分 preamble、JavaScript AST 抽象语法树以及可选的 source map。

看完了 generate 函数的签名部分,我们开始进入函数的函数体部分。

生成器的执行流程

在深入分析生成器的执行流程前,我们先通过一个流程图来了解一下生成器的执行流程有哪些。如下图:

代码生成上下文

代码生成过程中的上下文对象,用来维护代码生成过程中程序的运行状态。在 generate 函数体中首先要做的事情就是创建一个上下文对象,如下面的源代码所示:

// packages/compiler-core/src/codegen.ts

export function generate(
  ast: RootNode, // JavaScript AST
  options: CodegenOptions & {
    onContextCreated?: (context: CodegenContext) => void
  } = {}
): CodegenResult {

  // 创建生成器上下文对象,该上下文对象用来维护代码生成过程中程序的运行状态
  const context = createCodegenContext(ast, options)
  if (options.onContextCreated) options.onContextCreated(context)

  // 解构上下文对象中的属性
  const {
    mode, // mode 的值有两个:function/module
    push, // push 函数用来完成代码拼接
    prefixIdentifiers,
    indent, // indent 函数用来缩进代码
    deindent, // deindent 函数用来取消缩进
    newline, // newline 函数用来换行
    scopeId,
    ssr
  } = context
  
  // 省略部分代码
}

可以看到,代码生成上下文中包含mode、push、indent、deindent、newline 以及其它一些属性。

在代码生成上下文context的属性定义中,一共定义了5个方法,它们是 helper、push、indent、deIndent、newline。下面,我们来看看5个函数的实现。

helper 函数

// packages/compiler-core/src/codegen.ts

helper(key) {
  return `_${helperNameMap[key]}`
},

helper 函数用来返回用于标识唯一值的 symbol 的字符串形式。

push 函数

// packages/compiler-core/src/codegen.ts

push(code, node) {
  // 拼接代码字符串
  context.code += code
  
  // 生成对应的 sourceMap
  if (!__BROWSER__ && context.map) {
    if (node) {
      let name
      if (node.type === NodeTypes.SIMPLE_EXPRESSION && !node.isStatic) {
        const content = node.content.replace(/^_ctx\./, '')
        if (content !== node.content && isSimpleIdentifier(content)) {
          name = content
        }
      }
      addMapping(node.loc.start, name)
    }
    advancePositionWithMutation(context, code)
    if (node && node.loc !== locStub) {
      addMapping(node.loc.end)
    }
  }
},

可以看到,在 push 函数,通过字符串拼接的方式将字符串存储在上下文对象中的 code 属性中,从而完成代码的拼接。并且调用 addMapping 函数生成对应的 sourceMap。push 函数十分重要,在代码生成的过程中,编译器每处理一个JavaScript AST 节点时,都会调用 push 函数,向之前已经生成好的代码字符串中去拼接新生成的字符串。直到最后,拿到完整的代码字符串,并作为结果返回。

indent 函数

// packages/compiler-core/src/codegen.ts

indent() {
  newline(++context.indentLevel)
},

indent 函数,用来缩进代码,即让 indentLevel 自增后,再调用 newline换行函数,如上面的源码所示。

deIndent 函数

// packages/compiler-core/src/codegen.ts

deindent(withoutNewLine = false) {
  if (withoutNewLine) {
    --context.indentLevel
  } else {
    newline(--context.indentLevel)
  }
},

deIndent 函数,用来取消缩进,即让 indentLevel 自减后,再调 newline用换行函数

newline 函数

// packages/compiler-core/src/codegen.ts

newline() {
    newline(context.indentLevel)
  }

  function newline(n: number) {
    context.push('\n' + `  `.repeat(n))
  }

newline 函数,用来换行,每次调用该函数时,都会在代码字符串后面追加换行符 \n。由于换行时,需要保留缩进,所以还要追加 currentIndent * 2 个空格符。

生成前置内容

我们在编写一个js文件时,通常需要引入某些模块的变量或方法,例如通过 import 语句引入 vue 的r响应式方法reactive,如下面的代码所示:

import { reactive } from 'vue'

这里的 import 语句就是我们在生成渲染函数时要生成的前置内容。

我们来看源码中关于生成前置内容的代码,如下面所示:

  // 是否存在 helpers 辅助函数
  const hasHelpers = ast.helpers.length > 0  
  // 使用 with 扩展作用域
  const useWithBlock = !prefixIdentifiers && mode !== 'module' 
  // 不是浏览器环境且 mode 是 module,genScopeId 为 true时,要生成 scopeId
  const genScopeId = !__BROWSER__ && scopeId != null && mode === 'module'
  // 是否使用的是箭头函数创建渲染函数
  const isSetupInlined = !__BROWSER__ && !!options.inline

  // preambles
  // in setup() inline mode, the preamble is generated in a sub context
  // and returned separately.
  const preambleContext = isSetupInlined
    ? createCodegenContext(ast, options)
    : context

  // 不是浏览器换并且 mode 是 module 
  if (!__BROWSER__ && mode === 'module') {
    // 使用 ES module 标准的 import 来导入 helper 的辅助函数,处理生成代码的前置部分
    // 例如:'import { createVNode as _createVNode, resolveDirective as _resolveDirective } from "vue"
    // 生成 ES module 标准的 import 语句
    genModulePreamble(ast, preambleContext, genScopeId, isSetupInlined)
  } else {
    // 否则生成的代码的前置部分是一个单一的 const { helpers, ... }  = Vue 或 const { helpers, ... }  = require('Vue')
    // 例如:'const { createVNode: _createVNode, resolveDirective: _resolveDirective } = Vue
    // 生成 CommonJS 语法的变量导入语句
    genFunctionPreamble(ast, preambleContext)
  }

在这段代码中,有个关键的属性 mode,vue 根据该属性来判断使用何种方式引入 helpers 辅助函数的声明。

mode 属性的值有两个选项,'module' 和 'function'。如果 mode 的值传入的是 module,则调用 genModulePreamble 函数生成前置内容。如果 mode 的值传入的是 function,则调用genFunctionPreamble 生成前置内容。

我们分别来看看生成这两种前置内容的两个函数。

genModulePreamble 函数

// packages/compiler-core/src/codegen.ts

function genModulePreamble(
  ast: RootNode,
  context: CodegenContext,
  genScopeId: boolean,
  inline?: boolean
) {
  const {
    push,
    newline,
    optimizeImports,
    runtimeModuleName,
    ssrRuntimeModuleName
  } = context

  if (genScopeId && ast.hoists.length) {
    ast.helpers.push(PUSH_SCOPE_ID, POP_SCOPE_ID)
  }

  // generate import statements for helpers
  // 生成 import 导入语句
  if (ast.helpers.length) {
    if (optimizeImports) {
      // when bundled with webpack with code-split, calling an import binding
      // as a function leads to it being wrapped with `Object(a.b)` or `(0,a.b)`,
      // incurring both payload size increase and potential perf overhead.
      // therefore we assign the imports to variables (which is a constant ~50b
      // cost per-component instead of scaling with template size)
      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`
      )
    }
  }

  // 服务端渲染时的处理,生成 import 语句,通过 import 导入的变量要重命名
  if (ast.ssrHelpers && ast.ssrHelpers.length) {
    push(
      `import { ${ast.ssrHelpers
        .map(s => `${helperNameMap[s]} as _${helperNameMap[s]}`)
        .join(', ')} } from "${ssrRuntimeModuleName}"\n`
    )
  }

  if (ast.imports.length) {
    // 生成 import 语句,import { ... } from 'xxx'
    genImports(ast.imports, context)
    newline()
  }

  genHoists(ast.hoists, context)
  newline()

  // 通过 export 的方式导出渲染函数
  if (!inline) {
    push(`export `)
  }
}

可以看到,当 mode 的值为 module时,使用 ES module 标准的 import 语句来导入 ast 中的 helpers 辅助函数,并使用 export 将渲染函数作为默认导出。其生成的前置内容如下所示:

// mode === 'module' 生成的前置部分
'import { createVNode as _createVNode, resolveDirective as _resolveDirective } from "vue"

export '

genFunctionPreamble 函数

// packages/compiler-core/src/codegen.ts

function genFunctionPreamble(ast: RootNode, context: CodegenContext) {
  const {
    ssr,
    prefixIdentifiers,
    push,
    newline,
    runtimeModuleName,
    runtimeGlobalName,
    ssrRuntimeModuleName
  } = context
  const VueBinding =
    !__BROWSER__ && ssr
      ? `require(${JSON.stringify(runtimeModuleName)})`
      : runtimeGlobalName
  const aliasHelper = (s: symbol) => `${helperNameMap[s]}: _${helperNameMap[s]}`
  // Generate const declaration for helpers
  // In prefix mode, we place the const declaration at top so it's done
  // only once; But if we not prefixing, we place the declaration inside the
  // with block so it doesn't incur the `in` check cost for every helper access.
  
  // 通过 
  if (ast.helpers.length > 0) {
    if (!__BROWSER__ && prefixIdentifiers) {
      push(
        `const { ${ast.helpers.map(aliasHelper).join(', ')} } = ${VueBinding}\n`
      )
    } else {
      // "with" mode.
      // save Vue in a separate variable to avoid collision
      push(`const _Vue = ${VueBinding}\n`)
      // in "with" mode, helpers are declared inside the with block to avoid
      // has check cost, but hoists are lifted out of the function - we need
      // to provide the helper here.
      if (ast.hoists.length) {
        const staticHelpers = [          CREATE_VNODE,          CREATE_ELEMENT_VNODE,          CREATE_COMMENT,          CREATE_TEXT,          CREATE_STATIC        ]
          .filter(helper => ast.helpers.includes(helper))
          .map(aliasHelper)
          .join(', ')
        push(`const { ${staticHelpers} } = _Vue\n`)
      }
    }
  }
  // generate variables for ssr helpers
  // 服务端导入模块,通过 require 的方式
  if (!__BROWSER__ && ast.ssrHelpers && ast.ssrHelpers.length) {
    // ssr guarantees prefixIdentifier: true
    push(
      `const { ${ast.ssrHelpers
        .map(aliasHelper)
        .join(', ')} } = require("${ssrRuntimeModuleName}")\n`
    )
  }
  // 静态提升节点的生成
  genHoists(ast.hoists, context)
  // 换行
  newline()
  // 通过 return 的方式返回 渲染函数
  push(`return `)
}

可以看到,当 mode 的值为 function 时,会生成一个单一的 const { helpers… } = Vue 声明,并且通过 return 的方式返回渲染函数。其生成的前置内容如下所示:

// mode === 'function' 生成的前置部分
'const { createVNode: _createVNode, resolveDirective: _resolveDirective } = Vue

return '

生成渲染函数的签名

接下来生成器开始生成渲染函数,如下面的代码所示;

// enter render function
// 如果是服务端渲染,渲染函数的名称是 ssrRender
// 如果是客户端渲染,渲染函数的名称是 render
const functionName = ssr ? `ssrRender` : `render`

// 渲染函数的参数的处理
const args = ssr ? ['_ctx', '_push', '_parent', '_attrs'] : ['_ctx', '_cache']
if (!__BROWSER__ && options.bindingMetadata && !options.inline) {
  // binding optimization args
  args.push('$props', '$setup', '$data', '$options')
}

// 函数签名,如果 TypeScript ,参数要标记为 any 类型
const signature =
      !__BROWSER__ && options.isTS
// TS 语言,加上类型
? args.map(arg => `${arg}: any`).join(',')
// JS 语言
: args.join(', ')

// 使用 箭头函数还是 普通函数 来创建渲染函数
if (isSetupInlined) {
  push(`(${signature}) => {`)  // 使用箭头函数
} else {
  push(`function ${functionName}(${signature}) {`) // 使用普通函数
}

// 缩进代码
indent()

// with() 函数处理
// with(obj)作用就是将后面的{}中的语句块中的缺省对象设置为obj
if (useWithBlock) {
  push(`with (_ctx) {`)
  // 缩进代码
  indent()
  // function mode const declarations should be inside with block
  // also they should be renamed to avoid collision with user properties
  
  // 在 function mode 中,const 生命应该在代码块中,
  // 并且应该重命名结构的变量,防止变量名与用户的变量名冲突
  // 解构变量
  if (hasHelpers) {
    push(
      `const { ${ast.helpers
      .map(s => `${helperNameMap[s]}: _${helperNameMap[s]}`)
      .join(', ')} } = _Vue`
    )
    push(`\n`)
    // 换行
    newline()
  }
  
  // 省略部分代码
  
  // 扩展作用域结束
  if (useWithBlock) {
    deindent()
    push(`}`)
  }
  
}
  1. 首先是确定渲染函数的名称。如果是服务端渲染,渲染函数的名称是 ssrRender。如果是客户端渲染,则是 render。
  2. 然后是确定渲染函数的参数,根据是否是服务端渲染,参数会有所不同。函数签名部分会判断语言环境,如果是 TypeScript 环境的话,则需要给参数标记为 any 类型。
  3. 接下来通过 isSetupInlined 变量来判断是使用箭头函数还是普通函数来创建渲染函数。如果是普通函数,则通过 function 关键字来函数来声明函数。
  4. 在创建好函数签名后,在函数体内会判断是否需要使用 with() 函数来扩展作用域。如果此时有 helpers 辅助函数,则会将其结构在 with 的块级作用域内,结构后的变量会被重新命名,以防止与用户的变量名冲突。

资源的分解声明

在编译的过程,从 JavaScript AST 抽象语法树中解析出的 components 组件、directives 指令、filters 过滤器以及 temps 临时变量,会被生成器当作资源来处理。如下面的代码所示:

// generate asset resolution statements
// 如果 AST 中有组件,则解析组件
if (ast.components.length) {
  genAssets(ast.components, 'component', context)
  if (ast.directives.length || ast.temps > 0) {
    newline()
  }
}

// 如果 AST 中有指令,则解析指令
if (ast.directives.length) {
  genAssets(ast.directives, 'directive', context)
  if (ast.temps > 0) {
    newline()
  }

// 如果 AST 中有过滤器,则解析过滤器
if (__COMPAT__ && ast.filters && ast.filters.length) {
  newline()
  genAssets(ast.filters, 'filter', context)
  newline()
}

// 如果有临时变量,则通过 let 声明
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()
}

生成器会将 temps 临时变量生成使用 let 关键字声明的资源变量。将 components 组件、directives 指令、filters 过滤器这三种资源传入 genAssets 函数,生成对应的资源变量。

genAssets 函数

genAssets 函数的源码如下所示:

// packages/compiler-core/src/codegen.ts

function genAssets(
  assets: string[],
  type: 'component' | 'directive' | 'filter',
  { helper, push, newline, isTS }: CodegenContext
) {
  // 通过资源的类型获取对应的资源解析器
  const resolver = helper(
    __COMPAT__ && type === 'filter'
      ? RESOLVE_FILTER
      : type === 'component'
      ? RESOLVE_COMPONENT
      : RESOLVE_DIRECTIVE
  )
  for (let i = 0; i < assets.length; i++) {
    let id = assets[i]
    // potential component implicit self-reference inferred from SFC filename
    const maybeSelfReference = id.endsWith('__self')
    if (maybeSelfReference) {
      id = id.slice(0, -6)
    }
    // 解析对应的资源,将其拼接到代码字符串中
    push(
      `const ${toValidAssetId(id, type)} = ${resolver}(${JSON.stringify(id)}${
        maybeSelfReference ? `, true` : ``
      })${isTS ? `!` : ``}`
    )
    if (i < assets.length - 1) {
      newline()
    }
  }
}

可以看到,genAssets 函数做的事情很简单,就是根据资源类型获取对应的resolve 函数,然后根据资源类型 + 资源ID 当作变量名,将资源ID传入该资源对应的 resolve 函数,并将解析后的结果赋值声明的变量,最后调用 push 函数完成字符串的拼接。

返回结果的方式

// generate the VNode tree expression
if (!ssr) {
  push(`return `)
}

从上面的源码中可以看到,在渲染函数中,将通过 return 的方式返回节点转换成字符串后的代码。

生成节点代码字符串

接下来生成器要做的事情就是完成节点代码字符串的生成工作,如下面的源码所示:

// 生成 节点
if (ast.codegenNode) {
  // genNode 函数用来完成代码生成的工作
  genNode(ast.codegenNode, context)
} else {
  push(`null`)
}

可以看到,当生成器判断 ast 中有 codegenNode 的节点属性后,会调用 genNode 函数来生成节点对应的代码字符串。

生成器的返回结果

return {
  ast, // JavaScript AST
  code: context.code,  // 渲染函数的代码
  preamble: isSetupInlined ? preambleContext.code : ``,
  // SourceMapGenerator does have toJSON() method but it's not in the types
  map: context.map ? (context.map as any).toJSON() : undefined
}

在 generate 函数的最后,通过对象的形式返回了当前的 JavaScript AST 抽象语法树,经过生成器生成后的渲染函数的代码字符串及sourceMap 等。

上面我们介绍了生成器的执行流程,下面,我们来看看生成器是如何将 JavaScript AST 抽象语法树中的节点生成对应的代码字符串的。

genNode 生成节点代码字符串

在生成器的执行流程中,当生成器判断 ast 中有 codegenNode 的节点属性后,会调用 genNode 函数来生成节点对应的代码字符串。接下来,我们就来详细看下 genNode 函数。源码如下所示:

// packages/compiler-core/src/codegen.ts

function genNode(node: CodegenNode | symbol | string, context: CodegenContext) {
  // 如果当前节点是字符串,则直接将其拼接到字符字符串中
  if (isString(node)) {
    context.push(node)
    return
  }

  // 如果 node 是 symbol 类型,则将其传入辅助函数helper中,再将其生成代码字符串
  if (isSymbol(node)) {
    context.push(context.helper(node))
    return
  }

  // 判断节点类型,根据节点类型调用对应的生成函数
  switch (node.type) {
    case NodeTypes.ELEMENT:
    case NodeTypes.IF:
    case NodeTypes.FOR:
      __DEV__ &&
        assert(
          node.codegenNode != null,
          `Codegen node is missing for element/if/for node. ` +
            `Apply appropriate transforms first.`
        )
      
      // 节点类型是 Element、if、for 类型,递归调用 genNode
      // 继续生成这三种节点的子节点
      genNode(node.codegenNode!, context)
      break
      
    // 文本类型
    case NodeTypes.TEXT:
      genText(node, context)
      break

    // 简单表达式类型
    case NodeTypes.SIMPLE_EXPRESSION:
      genExpression(node, context)
      break
    
    // 插值类型
    case NodeTypes.INTERPOLATION:
      genInterpolation(node, context)
      break
    
    // 文本调用类型
    case NodeTypes.TEXT_CALL:
      genNode(node.codegenNode, context)
      break
      
    // 
    case NodeTypes.COMPOUND_EXPRESSION:
      genCompoundExpression(node, context)
      break
    case NodeTypes.COMMENT:
      genComment(node, context)
      break
    case NodeTypes.VNODE_CALL:
      genVNodeCall(node, context)
      break

    case NodeTypes.JS_CALL_EXPRESSION:
      genCallExpression(node, context)
      break
    case NodeTypes.JS_OBJECT_EXPRESSION:
      genObjectExpression(node, context)
      break
    case NodeTypes.JS_ARRAY_EXPRESSION:
      genArrayExpression(node, context)
      break
    case NodeTypes.JS_FUNCTION_EXPRESSION:
      genFunctionExpression(node, context)
      break
    case NodeTypes.JS_CONDITIONAL_EXPRESSION:
      genConditionalExpression(node, context)
      break
    case NodeTypes.JS_CACHE_EXPRESSION:
      genCacheExpression(node, context)
      break
    case NodeTypes.JS_BLOCK_STATEMENT:
      genNodeList(node.body, context, true, false)
      break

    // SSR only types
    case NodeTypes.JS_TEMPLATE_LITERAL:
      !__BROWSER__ && genTemplateLiteral(node, context)
      break
    case NodeTypes.JS_IF_STATEMENT:
      !__BROWSER__ && genIfStatement(node, context)
      break
    case NodeTypes.JS_ASSIGNMENT_EXPRESSION:
      !__BROWSER__ && genAssignmentExpression(node, context)
      break
    case NodeTypes.JS_SEQUENCE_EXPRESSION:
      !__BROWSER__ && genSequenceExpression(node, context)
      break
    case NodeTypes.JS_RETURN_STATEMENT:
      !__BROWSER__ && genReturnStatement(node, context)
      break

    /* istanbul ignore next */
    case NodeTypes.IF_BRANCH:
      // noop
      break
    default:
      if (__DEV__) {
        assert(false, `unhandled codegen node type: ${(node as any).type}`)
        // make sure we exhaust all possible types
        const exhaustiveCheck: never = node
        return exhaustiveCheck
      }
  }
}

genNode 函数用来完成代码生成的工作。代码生成的原理很简单,只需要匹配各种类型的 JavaScript AST 节点,并调用对应的生成函数即可。

对于字符串或 symbol 类型的节点,会直接调用转换上下文对象context 的 push 方法将节点拼接进代码字符串中。

接着使用 switch 语句来匹配不同类型的节点,并调用与之对应的生成器函数。

当节点类型是 Element、IF、FOR 类型时,则递归调用 genNode 函数,继续去生成这三种类型节点的子节点,这样能够保证遍历的完整性。

当节点类型是文本类型时,则调用与其对应的 genText 函数,通过 JSON.stringify 将文本序列后后拼接进代码字符串中。genText 函数的源码如下所示:

// packages/compiler-core/src/codegen.ts

function genText(
  node: TextNode | SimpleExpressionNode,
  context: CodegenContext
) {
  context.push(JSON.stringify(node.content), node)
}

当节点是一个简单表达式时,则调用 genExpression 函数,判断该表达式是否是静态的,如果是静态的,则通过 JSON.stringify 将表达式序列化后拼接进代码字符串中。否则直接拼接表达式对应的 content。genExpression 函数的源码如下所示:

// packages/compiler-core/src/codegen.ts

function genExpression(node: SimpleExpressionNode, context: CodegenContext) {
  const { content, isStatic } = node
  context.push(isStatic ? JSON.stringify(content) : content, node)
}

当节点是一个插值时,则调用 genInterpolation 函数,在该函数中继续调用 genNode 函数来继续生成插值类型节点的子节点。genInterpolation 函数的源码如下所示:

// packages/compiler-core/src/codegen.ts

function genInterpolation(node: InterpolationNode, context: CodegenContext) {
  const { push, helper, pure } = context
  if (pure) push(PURE_ANNOTATION)
  push(`${helper(TO_DISPLAY_STRING)}(`)
  genNode(node.content, context)
  push(`)`)
}

通过以上几种类型节点的分析,我们能知道生成器其实是根据不同节点的类型,调用与之对应的生成函数,通过调用转换上下文对象context 的 push 方法将节点字符串化后拼接到代码字符串中。对于存在子节点的节点,则调用 genNode 函数递归遍历,确保每个节点都能生成对应的代码字符串。

处理静态提升

生成器在生成前置内容时,无论是生成 mode 为 module 模式的前置内容还是生成 mode 为 function 模式的前置内容,在它们各自的生成函数中,都调用了 genHoists 函数来处理静态提升。下面我们就来看看生成器是怎么处理静态提升的。genHoists 函数的源码如下所示:

// packages/compiler-core/src/codegen.ts

function genHoists(hoists: (JSChildNode | null)[], context: CodegenContext) {

  // 说明不存在需要静态提升的节点,那么直接返回,不做后续处理
  if (!hoists.length) {
    return
  }

  // 将上下文的 pure 设置为 true
  context.pure = true
  const { push, newline, helper, scopeId, mode } = context
  const genScopeId = !__BROWSER__ && scopeId != null && mode !== 'function'
  newline()

  // generate inlined withScopeId helper
  if (genScopeId) {
    push(
      `const _withScopeId = n => (${helper(
        PUSH_SCOPE_ID
      )}("${scopeId}"),n=n(),${helper(POP_SCOPE_ID)}(),n)`
    )
    newline()
  }

  // 遍历需要静态提升的节点,根据数组的index 生成静态提升的变量名
  for (let i = 0; i < hoists.length; i++) {
    const exp = hoists[i]
    if (exp) {
      const needScopeIdWrapper = genScopeId && exp.type === NodeTypes.VNODE_CALL
      // 生成静态提升的变量名 const _hoisted_${i + 1}
      push(
        `const _hoisted_${i + 1} = ${
          needScopeIdWrapper ? `${PURE_ANNOTATION} _withScopeId(() => ` : ``
        }`
      )

      // 生成静态节点的代码字符串,赋值给静态提升的变量名 const _hoisted_${i + 1}
      genNode(exp, context)
      if (needScopeIdWrapper) {
        push(`)`)
      }
      newline()
    }
  }

  context.pure = false
}
  1. genHoists 函数接收两个参数,第一个参数是需要静态提升的节点集合hoists,第二个参数则是生成器上下文context。
  2. 在 genHoists 函数中,首先判断 hoists 数组是否有元素,如果没有,说明不存在需要静态提升的节点,那么直接返回,不做后续处理。
  3. 如果 hoists 数组有元素,说明存在需要静态提升的节点,那么将生成器上下文context的 pure 属性设置为true,然后从上下文context中取出相关的工具函数。
  4. 接着使用 forEach 遍历 hoists 数组,即遍历需要静态提升的节点,根据数组的 index 生成静态提升的变量名 _hoisted_${i + 1},然后调用 genNode 函数生成静态提升节点的代码字符串,并赋值给之前声明的变量名 _hoisted_${i + 1}
  5. 最后,在遍历完所有需要静态提升的节点后,经 context 的 pure 属性重置为 false 。而这里 pure 属性的作用,就是在某些节点类型生成字符串前,添加 /*#__PURE__*/ 注释前缀,表明该节点是静态节点。

总结

本文介绍了编译器编译过程中的代码生成器 generate 。代码生成本质上是字符串拼接的艺术,我们访问 JavaScript AST 中的节点,为每一种类型的节点生成相符的 JavaScript 代码。

接下来介绍了生成器的执行流程,首先是构建代码生成上下文,接下来是生成前置内容、生成渲染函数的签名、到返回结果的方式、再到生成节点代码字符串,到最后生成器的返回结果。

然后深入分析了生成器是如何根据不同节点的类型,调用与之对应的生成函数,将节点生成代码字符串后拼接到代码字符串中的。

最后介绍了生成器也是通过调用 genNode 函来处理需要静态提升的节点。