likes
comments
collection
share

Vue3 源码解读之代码生成器

作者站长头像
站长
· 阅读数 15

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

Vue3 源码解读之代码生成器

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

baseCompile源码

// 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 函数签名

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 等,其中,onContextCreated 是一个回调函数,用于在编译上下文创建后执行一些操作。

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

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

生成器的执行流程

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

Vue3 源码解读之代码生成器

代码生成上下文

代码生成过程中的上下文对象,用来维护代码生成过程中程序的运行状态。在 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, // 编译模式,可以是 function 或 module。
    push, // push 函数用来完成代码拼接
    prefixIdentifiers, // 是否需要添加前缀来避免变量名冲突
    indent, // indent 函数用来缩进代码
    deindent, // deindent 函数用来取消缩进
    newline, // newline 函数用来换行
    scopeId, // 作用域 ID,用于在作用域中生成唯一的 CSS 类名
    ssr // 是否为服务端渲染
  } = context
  
  // 省略部分代码
}

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

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

helper 函数

helper源码

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

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

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

push 函数

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 添加到 context.code 属性中,即将代码字符串添加到编译器的代码缓冲区中。

如果 context 对象中存在源码映射信息(即 context.map 属性存在),则需要更新源码映射信息。具体来说,该方法会根据 node 参数的位置信息,更新源码映射信息中的位置信息。

在更新源码映射信息时,需要注意一些细节,例如:

  • 如果 node 是一个简单表达式节点(NodeTypes.SIMPLE_EXPRESSION),且不是静态节点,那么需要将节点的内容中的 _ctx. 前缀去掉,并检查剩余的内容是否是一个合法的标识符,如果是,则将其作为映射信息的名称。
  • 如果 node 有位置信息(即 node.loc 属性存在),则需要在源码映射信息中添加该位置信息对应的映射关系

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

indent 函数

indent源码

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

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

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

deIndent 函数

deIndent源码

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

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

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

首先接受一个可选的布尔型参数 withoutNewLine,用于控制是否需要在减少缩进级别后添加换行符。如果 withoutNewLine 为 true,则不添加换行符;否则,添加一个换行符。

在方法内部,该方法会先将 context.indentLevel 属性减少一个层级,然后根据需要添加换行符。如果 withoutNewLine 为 true,则只是简单地减少缩进级别;否则,调用 newline 方法添加一个换行符,并将缩进级别减少一个层级。

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 的响应式方法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

// 在 Vue.js 3.0 中,`setup()` 函数是组件中的一个新特性,它可以用来替代 Vue.js 2.x 中的 `beforeCreate` 和 `created` 生命周期钩子。`
// setup()` 函数接受两个参数:`props` 和 `context`,可以用来初始化组件的状态、处理 props 等操作。
// 在 `setup()` 函数中,如果使用了内联模式(即将 `setup()` 函数的返回值作为模板中的变量使用),则需要在生成代码时将 `setup()` 函数的返回值作为上下文的一部分进行处理。
// 因此,在这种情况下,需要创建一个新的编译上下文。
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 函数

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) {
        // 因为在 webpack 中进行代码分割时,会将代码分割成多个模块,每个模块都可以单独加载和执行。在这种情况下,如果将导入的变量直接作为函数调用,会导致 webpack 将其包装为一个新的函数调用,例如 Object(a.b) 或 (0,a.b),从而增加了代码的体积和潜在的性能开销。
        // 所以为了避免这种情况,需要将导入的变量赋值给一个新的变量,然后使用新的变量进行函数调用。这样做可以避免 webpack 对导入的变量进行包装,从而减少代码的体积和提高性能。
        // 并且将导入的变量赋值给一个新的变量,可以将常量的开销控制在每个组件的常量大小范围内,而不是随着模板大小的增加而增加。这样做可以保证组件的常量大小是固定的,而不会随着模板的复杂度和大小而变化。
        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 函数

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
  
  // 为 helpers 生成 const 声明
  // 如果启用前缀模式,则将 const 声明放在顶部,只声明一次;
  // 否则,将声明放在 with 块内部,以避免在每次访问 helper 时都进行 in 检查。
  const helpers = Array.from(ast.helpers)
  if (helpers.length > 0) {
    if (!__BROWSER__ && prefixIdentifiers) {
      push(`const { ${helpers.map(aliasHelper).join(', ')} } = ${VueBinding}\n`)
    } else {
      // 在 "with" 模式下,将 helpers 声明放在 with 块内部,以避免 in 检查的开销。
      push(`const _Vue = ${VueBinding}\n`)
      // 由于在 "with" 模式下,helper 函数的声明是在 with 块内部进行的,因此在函数外部无法访问这些函数。
      // 但是,在静态提升节点(hoists)的处理中,需要访问这些 helper 函数,因此需要在函数外部提供这些函数的声明,以便在静态提升节点的处理中使用。
      if (ast.hoists.length) {
        const staticHelpers = [
          CREATE_VNODE,
          CREATE_ELEMENT_VNODE,
          CREATE_COMMENT,
          CREATE_TEXT,
          CREATE_STATIC
        ]
          .filter(helper => helpers.includes(helper))
          .map(aliasHelper)
          .join(', ')
        push(`const { ${staticHelpers} } = _Vue\n`)
      }
    }
  }
  // 服务端导入模块,通过 require 的方式
  // 生成 SSR helpers 的变量声明
  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 生命应该在代码块中,
  // 并且应该重命名结构的变量,防止变量名与用户的变量名冲突
  // 解构变量
  if (hasHelpers) {
    push(
      `const { ${ast.helpers
      .map(s => `${helperNameMap[s]}: _${helperNameMap[s]}`)
      .join(', ')} } = _Vue`
    )
    push(`\n`)
    // 换行
    newline()
  }
  
  // 省略部分代码
  
  // 扩展作用域结束
  if (useWithBlock) {
    deindent()
    push(`}`)
  }
  
}
    1. 首先是确定渲染函数的名称。如果是服务端渲染,渲染函数的名称是 ssrRender。如果是客户端渲染,则是 render
    1. 然后是确定渲染函数的参数,根据是否是服务端渲染,参数会有所不同。函数签名部分会判断语言环境,如果是 TypeScript 环境的话,则需要给参数标记为 any 类型。
    1. 接下来通过 isSetupInlined 变量来判断是使用箭头函数还是普通函数来创建渲染函数。如果是普通函数,则通过 function 关键字来函数来声明函数。
    1. 在创建好函数签名后,在函数体内会判断是否需要使用 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 中有过滤器,则解析过滤器
// __COMPAT__ 的作用是检查是否处于兼容模式下(因为在 Vue3 中,由于一些 API 和行为发生了变化,可能会导致一些现有的代码无法正常工作。为了解决这个问题,Vue.js 3.0 提供了兼容模式,可以在一定程度上保持与 Vue.js 2.x 的兼容性)
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源码

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]
    // 如果组件或指令的名称以 `__self` 结尾,就需要将其截取掉,并将 `true` 作为第二个参数传递给 `resolver` 函数,表示这是一个隐式的自引用
    const maybeSelfReference = id.endsWith('__self')
    if (maybeSelfReference) {
      id = id.slice(0, -6)
    }
    // 解析对应的资源,将其拼接到代码字符串中,通过资源类型 + 资源ID拼接作为变量名
    push(
      `const ${toValidAssetId(id, type)} = ${resolver}(${JSON.stringify(id)}${
        maybeSelfReference ? `, true` : ``
      })${isTS ? `!` : ``}`
    )
    if (i < assets.length - 1) {
      newline()
    }
  }
}

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

返回结果的方式

// 将字符串 "return" 推入生成的代码中,以便在函数体的最后返回生成的 VNode 树表达式。
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 : ``, // 表示在 setup 函数中内联的代码,如果没有内联,则为空字符串
  // 虽然 `SourceMapGenerator` 类型中有 `toJSON()` 方法,但是在 TypeScript 的类型定义文件中并没有声明该方法
  map: context.map ? (context.map as any).toJSON() : undefined
}

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

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

genNode 生成节点代码字符串

在生成器的执行流程中,当生成器判断 ast 中有 codegenNode 的节点属性后,会调用 genNode 函数来生成节点对应的代码字符串。接下来,我们就来详细看下 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
    // VNode 节点
    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)时使用,调用对应的函数生成代码。
    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

    // 这个节点类型不需要生成代码,因此直接忽略
    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 类型的节点,会直接调用转换上下文对象 contextpush 方法将节点拼接进代码字符串中。

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

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

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

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 函数的源码如下所示:

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 函数的源码如下所示:

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

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

处理静态提升

生成器在生成前置内容时,无论是生成 modemodule 模式的前置内容还是生成 modefunction 模式的前置内容,在它们各自的生成函数中,都调用了 genHoists 函数来处理静态提升。下面我们就来看看生成器是怎么处理静态提升的。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()

  // 在组件渲染函数中处理作用域 ID,需要为每个组件生成一个唯一的作用域 ID
  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 数组有元素,说明存在需要静态提升的节点,那么将生成器上下文contextpure 属性设置为true,然后从上下文context中取出相关的工具函数。
  4. 然后是生成 withScopeId 帮助函数的部分,需要为每个组件生成一个唯一的作用域 ID,以便在样式中使用作用域限定符(scoped CSS)。
    • 4.1. 首先判断是否需要生成 withScopeId 帮助函数,如果需要,则使用 push 函数将生成的代码推入到代码生成器(CodegenContext)中。
    • 4.2. 生成的代码中,将组件渲染函数作为参数传递给 withScopeId 函数,
      • PUSH_SCOPE_ID: 然后在函数体中先将作用域 ID 推入作用域 ID 栈中
      • n: 接下来调用组件渲染函数并将其返回值赋给变量 n
      • POP_SCOPE_ID: 最后弹出作用域 ID 栈并返回 n
      • 这样,就实现了在组件渲染函数中处理作用域 ID 的功能。
  5. 接着遍历 hoists 数组,即遍历需要静态提升的节点,根据数组的 index 生成静态提升的变量名 _hoisted_${i + 1},然后调用 genNode 函数生成静态提升节点的代码字符串,并赋值给之前声明的变量名 _hoisted_${i + 1}
  6. 最后,在遍历完所有需要静态提升的节点后,经 contextpure 属性重置为 false 。而这里 pure 属性的作用,就是在某些节点类型生成字符串前,添加 /*#__PURE__*/ 注释前缀,表明该节点是静态节点。

总结

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

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

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

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