代码生成器 generate 在编译器的编译过程中负责将 JavaScript AST 转换成渲染函数,如下图所示:
代码生成也是编译器的最后一步,如下面的源码所示:
js
// 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 函数签名
js
// 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 函数的签名部分,我们开始进入函数的函数体部分。
生成器的执行流程
在深入分析生成器的执行流程前,我们先通过一个流程图来了解一下生成器的执行流程有哪些。如下图:
代码生成上下文
代码生成过程中的上下文对象,用来维护代码生成过程中程序的运行状态。在 generate 函数体中首先要做的事情就是创建一个上下文对象,如下面的源代码所示:
js
// 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 函数
js
// packages/compiler-core/src/codegen.ts
helper(key) {
return `_${helperNameMap[key]}`
},
helper 函数用来返回用于标识唯一值的 symbol 的字符串形式。
push 函数
js
// 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 函数
scss
// packages/compiler-core/src/codegen.ts
indent() {
newline(++context.indentLevel)
},
indent
函数,用来缩进代码,即让 indentLevel
自增后,再调用 newline
换行函数,如上面的源码所示。
deIndent 函数
scss
// 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 函数
scss
// 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,如下面的代码所示:
js
import { reactive } from 'vue'
这里的 import 语句就是我们在生成渲染函数时要生成的前置内容。
我们来看源码中关于生成前置内容的代码,如下面所示:
js
// 是否存在 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 函数
js
// 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 将渲染函数作为默认导出。其生成的前置内容如下所示:
注意:这是一个字符串
rust
// mode === 'module' 生成的前置部分
'import { createVNode as _createVNode, resolveDirective as _resolveDirective } from "vue"
export '
genFunctionPreamble 函数
js
// 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
的方式返回渲染函数。其生成的前置内容如下所示:
js
// mode === 'function' 生成的前置部分
'const { createVNode: _createVNode, resolveDirective: _resolveDirective } = Vue
return '
生成渲染函数的签名
接下来生成器开始生成渲染函数,如下面的代码所示:
js
// 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(`}`)
}
}
-
- 首先是确定渲染函数的名称。如果是服务端渲染,渲染函数的名称是
ssrRender
。如果是客户端渲染,则是render
。
- 首先是确定渲染函数的名称。如果是服务端渲染,渲染函数的名称是
-
- 然后是确定渲染函数的参数,根据是否是服务端渲染,参数会有所不同。函数签名部分会判断语言环境,如果是
TypeScript
环境的话,则需要给参数标记为any
类型。
- 然后是确定渲染函数的参数,根据是否是服务端渲染,参数会有所不同。函数签名部分会判断语言环境,如果是
-
- 接下来通过
isSetupInlined
变量来判断是使用箭头函数还是普通函数来创建渲染函数。如果是普通函数,则通过function
关键字来函数来声明函数。
- 接下来通过
-
- 在创建好函数签名后,在函数体内会判断是否需要使用
with()
函数来扩展作用域。如果此时有helpers
辅助函数,则会将其结构在with
的块级作用域内,结构后的变量会被重新命名,以防止与用户的变量名冲突。
- 在创建好函数签名后,在函数体内会判断是否需要使用
资源的分解声明
在编译的过程,从 JavaScript AST
抽象语法树中解析出的 components
组件、directives
指令、filters
过滤器以及 temps
临时变量,会被生成器当作资源来处理。如下面的代码所示:
js
// 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 函数的源码如下所示:
js
// 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 函数完成字符串的拼接。
返回结果的方式
scss
// 将字符串 "return" 推入生成的代码中,以便在函数体的最后返回生成的 VNode 树表达式。
if (!ssr) {
push(`return `)
}
从上面的源码中可以看到,在渲染函数中,将通过 return 的方式返回节点转换成字符串后的代码。
生成节点代码字符串
接下来生成器要做的事情就是完成节点代码字符串的生成工作,如下面的源码所示:
scss
// 生成 节点
if (ast.codegenNode) {
// genNode 函数用来完成代码生成的工作
genNode(ast.codegenNode, context)
} else {
push(`null`)
}
可以看到,当生成器判断 ast
中有 codegenNode
的节点属性后,会调用 genNode
函数来生成节点对应的代码字符串。
生成器的返回结果
js
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
函数。源码如下所示:
js
// 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
类型的节点,会直接调用转换上下文对象 context
的 push
方法将节点拼接进代码字符串中。
接着使用 switch
语句来匹配不同类型的节点,并调用与之对应的生成器函数。
当节点类型是 Element、IF、FOR
类型时,则递归调用 genNode
函数,继续去生成这三种类型节点的子节点,这样能够保证遍历的完整性。
当节点类型是文本类型 时,则调用与其对应的 genText
函数,通过 JSON.stringify
将文本序列化,然后拼接进代码字符串中。genText
函数的源码如下所示:
js
// 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
函数的源码如下所示:
js
// 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
函数的源码如下所示:
js
// 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
函数的源码如下所示:
js
// 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
}
genHoists
函数接收两个参数,第一个参数是需要静态提升的节点集合hoists
,第二个参数则是生成器上下文context
。- 在
genHoists
函数中,首先判断hoists
数组是否有元素,如果没有,说明不存在需要静态提升的节点,那么直接返回,不做后续处理。 - 如果
hoists
数组有元素,说明存在需要静态提升的节点,那么将生成器上下文context
的pure
属性设置为true,然后从上下文context
中取出相关的工具函数。 - 然后是生成
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 的功能。
- 4.1. 首先判断是否需要生成
- 接着遍历
hoists
数组,即遍历需要静态提升的节点,根据数组的index
生成静态提升的变量名_hoisted_${i + 1}
,然后调用genNode
函数生成静态提升节点的代码字符串,并赋值给之前声明的变量名_hoisted_${i + 1}
。 - 最后,在遍历完所有需要静态提升的节点后,经
context
的pure
属性重置为false
。而这里pure
属性的作用,就是在某些节点类型生成字符串前,添加/*#__PURE__*/
注释前缀,表明该节点是静态节点。
总结
本文介绍了编译器编译过程中的代码生成器 generate
。代码生成本质上的字符串拼接 的艺术,我们访问 JavaScript AST
中的节点,为每一种类型的节点生成相符的 JavaScript
代码。
接下来介绍了生成器的执行流程,首先是构建代码生成上下文 ,接下来是生成前置内容 、生成渲染函数的签名 、到返回结果的方式 、再到生成节点代码字符串 ,到最后生成器的返回结果。
然后深入分析了生成器是如何根据不同节点的类型,调用与之对应的生成函数,将节点生成代码字符串后拼接到代码字符串中的。
最后介绍了生成器也是通过调用 genNode
函来处理需要静态提升的节点。