关于前端:Vue3-源码解析四代码生成器

41次阅读

共计 8568 个字符,预计需要花费 22 分钟才能阅读完成。

在 Vue3 源码解析系列的第一篇文章中,笔者率领大家一起走了一遍一个 Vue 对象实例化的流程,在一起看 @vue/compiler-core 编译模块的时候,首次呈现了代码生成器 —— generate 模块。为了帮忙大家回顾,咱们再来看一遍 compile 编译过程中产生了什么。

export function baseCompile(
  template: string | RootNode,
  options: CompilerOptions = {}): CodegenResult {
  const onError = options.onError || defaultOnError
  const isModuleMode = options.mode === 'module'
  
  const prefixIdentifiers =
    !__BROWSER__ && (options.prefixIdentifiers === true || isModuleMode)

  // 生成 AST 形象语法树
  const ast = isString(template) ? baseParse(template, options) : template
  const [nodeTransforms, directiveTransforms] = getBaseTransformPreset(prefixIdentifiers)
  // 对 AST 形象语法树执行转换
  transform(
    ast,
    extend({}, options, {})
  )

  // 返回代码生成器生成的代码字符串
  return generate(
    ast,
    extend({}, options, {prefixIdentifiers})
  )
}

在笔者给出的编译模块的简化源码中,能够看到咱们之前几篇文章提及到的生成 AST 形象语法树,以及节点转换器的转换节点的正文,而明天咱们要讲的就是最初一行代码中 generate 函数做了什么事件。

代码生成器是什么

代码生成器是什么?它有什么作用?在答复这些问题以前,咱们还是要从编译流程中说起,在生成一个 Vue 对象的编译过程执行完结时,咱们会从编译的后果中拿到一个名叫 code 的 string 类型的变量。而这个变量就是咱们明天通篇会提及的代码字符串,Vue 会用这个生成的代码字符串,配合 Function 类的构造函数生成 render 渲染函数,最终用生成的渲染函数实现对应组件的渲染,在源码中是如下这样实现的。

function compileToFunction(
  template: string | HTMLElement,
  options?: CompilerOptions
): RenderFunction {
  const key = template
 // 执行编译函数,并从后果中构造出代码字符串
  const {code} = compile(
    template,
    extend(
      {
        hoistStatic: true,
        onError: __DEV__ ? onError : undefined,
        onWarn: __DEV__ ? e => onError(e, true) : NOOP
      } as CompilerOptions,
      options
    )
  )

  // 通过 Function 构造方法,生成 render 函数
  const render = (__GLOBAL__
    ? new Function(code)()
    : new Function('Vue', code)(runtimeDom)) as RenderFunction

  ;(render as InternalRenderFunction)._rc = true

  // 返回生成的 render 函数,并缓存
  return (compileCache[key] = render)
}

那么接下来,笔者就带大家直入代码生成器模块,从 generate 函数动手,查看生成器的工作形式。

代码生成上下文

generate 函数位于 packages/compiler-core/src/codegen.ts 的地位,咱们先看一下它的函数签名。

export function generate(
  ast: RootNode,
  options: CodegenOptions & {onContextCreated?: (context: CodegenContext) => void
  } = {}): CodegenResult {const context = createCodegenContext(ast, options)
    /* 疏忽后续逻辑 */
}

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

generate 函数,接管两个参数,别离是通过转换器解决的 ast 形象语法树,以及 options 代码生成选项。最终返回一个 CodegenResult 类型的对象。

能够看到 CodegenResult 中蕴含了 code 代码字符串、ast 形象语法树、可选的 sourceMap、以及代码字符串的前置局部 preamble。

而 generate 的函数,第一行就是生成一个上下文对象,这里为了语义上的更好了解,咱们称这个 context 为代码生成器的上下文对象,简称生成器上下文。

生成器上下文中除了一些属性外,会留意到它有 5 个工具函数,这里重点看一下 push 函数。

解说 push 之前,没看过代码的敌人可能会有点蛊惑,一个向数组内增加元素的函数有什么好说的呢?然而此 push 非彼 push,笔者先给大家看一下 push 的实现。

push(code, node) {
  context.code += code
  if (!__BROWSER__ && context.map) {if (node) {
      let name
      /* 疏忽逻辑 */
      addMapping(node.loc.start, name)
    }
    advancePositionWithMutation(context, code)
    if (node && node.loc !== locStub) {addMapping(node.loc.end)
    }
  }
}

看完上方 push 的实现,可能发现 push 并非是向数组中推送元素,而是拼接字符串,将传入的字符串拼接入上下文中的 code 属性中。并且会调用 addMapping 生成对应的 sourceMap。这个函数是作用很重要,当生成器解决完 ast 树中的每个节点时,都会调用 push,向之前曾经生成好的代码字符串中去拼接新生成的字符串。直至最终,拿到残缺的代码字符串,并作为后果返回。

context 中除了 push,还有 indent、deindent、newline 这些解决字符串地位的函数,别离的作用是缩进、回退缩进、插入新的一行。是用来辅助生成的代码字符串,格式化构造用的,让生成的代码字符串十分直观,就像在 ide 中敲入的一样。

而 context 中还有

执行流程

当生成器上下文创立好之后,generate 函数会接着向下执行,接下来笔者就和大家持续往下浏览,剖析生成器的执行流程。在本节中我放入的代码全部都是 generate 函数体内的,所以为了更简短的篇幅,generate 的函数签名就不会再反复放入了。

代码字符串 前置内容生成

const hasHelpers = ast.helpers.length > 0 // 是否存在 helpers 辅助函数
const useWithBlock = !prefixIdentifiers && mode !== 'module' // 应用 with 扩大作用域
const genScopeId = !__BROWSER__ && scopeId != null && mode === 'module'

// 不在浏览器的环境且 mode 是 module
if (!__BROWSER__ && mode === 'module') {
  // 应用 ES module 规范的 import 来导入 helper 的辅助函数,解决生成代码的前置局部
  genModulePreamble(ast, preambleContext, genScopeId, isSetupInlined)
} else {// 否则生成的代码前置局部是一个繁多的 const { helpers...} = Vue 解决代码前置局部
  genFunctionPreamble(ast, preambleContext)
}

在创立完上下文,从上下文中解构完一些对象后,会生成代码字符串的前置局部,这里有个要害判断是 mode 属性,依据 mode 属性来判断应用何种形式引入 helpers 辅助函数的申明。

mode 有两个选项,’module’ 或 ‘function’。当传入的参数是 module 时,会通过 ES module 的 import 来导入 ast 中的 helpers 辅助函数,并用 export 默认导出 render 函数。当传入的参数是 function 时,就会生成一个繁多的 const {helpers...} = Vue 申明,并且 return 返回 render 函数,而不是通过 export 导出。上面的代码框注中我放入了两种模式生成的代码前置局部的区别。

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

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

return '

要留神以上代码仅仅是代码前置局部,咱们还没有开始解析其余资源和节点,所以仅仅是到了 export 或者 return 就戛然而止了。

在明确了前置局部的区别后,咱们接着往下看代码。

生成 render 函数签名

接下来生成器会开始生成 render 函数的函数体,首先从函数名、以及给 render 函数的传参开始。当确定了函数签名后,如果 mode 是 function 的状况,生成器会应用 with 来扩大作用域,最初生成的模样在第一篇编译流程中也曾经展现过。

首先会依据是否是服务端渲染,ssr 的标记来确定函数名 functionName 以及要传入函数的参数 args,并且在函数签名局部会判断是否是 TypeScript 的环境,如果是 TypeScript 的话,会给参数标记为 any 类型。

之后会判断是通过箭头函数还是函数申明来创立函数。

在函数创立好后,函数体内会判断是否须要通过 with 来扩大作用域,并且此时如果有 helpers 辅助函数,也会解构在 with 的块级作用域内,解构当前也会重命名变量,避免与用户的变量名抵触。

具体的代码逻辑在下方。

// 生成后的函数名
const functionName = ssr ? `ssrRender` : `render`
// 函数的传参
const args = ssr ? ['_ctx', '_push', '_parent', '_attrs'] : ['_ctx', '_cache']
/* 疏忽逻辑 */

// 函数签名,是 TypeScript 的话标记为 any 类型
const signature =
  !__BROWSER__ && options.isTS
    ? args.map(arg => `${arg}: any`).join(',')
    : args.join(',')

/* 疏忽逻辑 */

// 应用箭头函数还是函数申明来创立渲染函数
if (isSetupInlined || genScopeId) {push(`(${signature}) => {`)
} else {push(`function ${functionName}(${signature}) {`)
}
indent()

// 应用 with 扩大作用域 
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()}
}

资源的合成申明

在看到“资源的合成申明”这个小标题之前,咱们先须要搞明确生成器把什么定义成资源。生成器将 AST 形象语法树中解析出的 components 组件,directives 指令,temps 长期变量,以及上个月尤大又在 Vue3 中兼容了 Vue2 filters 过滤器这四样类型当做资源。

在 render 函数中,该局部的解决会将上述资源都提前申明进去,将 AST 树中解析出的资源 id 传入每个资源对应的处理函数,并生成对应的资源变量。

// 如果 ast 中有组件,解析组件
if (ast.components.length) {genAssets(ast.components, 'component', context)
  if (ast.directives.length || ast.temps > 0) {newline()
  }
}
/* 省略 指令和过滤器,逻辑与组件统一 */
if (ast.temps > 0) {push(`let `)
  for (let i = 0; i < ast.temps; i++) {push(`${i > 0 ? `, ` : ``}_temp${i}`) // 通过 let 申明变量
  }
}

在下面源码中,我放入了两个典型代表,components 以及 temps。举个例子,给大家看看生成代码后的后果。

components: [`Foo`, `bar-baz`, `barbaz`, `Qux__self`],
directives: [`my_dir_0`, `my_dir_1`],
temps: 3

假如在 AST 中有如下资源,4 个组件,ID 别离为 Foo、bar-baz、barbaz、Qux__self。2 个指令,ID 别离为 my_dir_0, my_dir_1,以及有 3 个长期变量。这些资源被解析后生成如下所示的代码字符串。

const _component_Foo = _resolveComponent("Foo")
const _component_bar_baz = _resolveComponent("bar-baz")
const _component_barbaz = _resolveComponent("barbaz")
const _component_Qux = _resolveComponent("Qux", true)
const _directive_my_dir_0 = _resolveDirective("my_dir_0")
const _directive_my_dir_1 = _resolveDirective("my_dir_1")
let _temp0, _temp1, _temp2

不用去纠结 resolve 函数中做了什么事件,咱们只须要晓得代码生成器会生成怎么的代码即可。

所以从后果去倒推 genAssets 函数做过的事件,就是依据资源类型 + 资源 ID 当做变量名,并将资源 ID 传入类型对应的 resolve 函数,并将后果赋值给申明的变量。

而 temps 的解决在上方源码曾经写的很分明了。

返回后果

在生成 render 函数体,解决完资源后,生成器会开始最要害一步——生成节点对应的代码字符串,在解决完所有节点后,会将生成的后果返回。因为节点的重要性,咱们抉择将此局部放在前面独自说。至此代码字符串生成结束,最终会返回一个 CodegenResult 类型的对象。

生成节点

if (ast.codegenNode) {genNode(ast.codegenNode, context)
}

当生成器判断 ast 中有 codegenNode 的节点属性后,会调用 genNode 来生成节点对应的代码字符串。接下来咱们就来具体看一下 genNode 函数。

function genNode(node: CodegenNode | symbol | string, context: CodegenContext) {
  // 如果是字符串,间接 push 入代码字符串
  if (isString(node)) {context.push(node)
    return
  }
  // 如果 node 是 symbol 类型,传入辅助函数生成的代码字符串
  if (isSymbol(node)) {context.push(context.helper(node))
    return
  }
  // 判断 node 类型
  switch (node.type) {
    case NodeTypes.ELEMENT:
    case NodeTypes.IF:
    case NodeTypes.FOR:
      genNode(node.codegenNode!, context)
      break
    case NodeTypes.TEXT:
      genText(node, context)
      break
    case NodeTypes.SIMPLE_EXPRESSION:
      genExpression(node, context)
      break
    /* 疏忽残余 case 分支 */
  }
}

genNode 函数会先判断节点的类型,对于字符串或 symbol 类型的节点,会间接拼接进代码字符串中,之后通过一个 Switch-Case 条件分支判断 node 节点的类型。而因为判断条件很多,这里会疏忽大部分条件,只举几个典型的类型来剖析。

首先是第一个 case,当遇到 Element、IF 或 FOR 类型的节点类型时,会递归的调用 genNode,持续去生成这三种节点类型的子节点,这样可能保障遍历的完整性。

而当节点是一个文本类型时,会调用 genText 函数,间接将文本通过 JSON.stringify 序列化拼接进代码字符串中。

当节点是一个简略表达式时,会判断该表达式是否是动态的,如果是动态的,则通过 JSON 字符串序列化后拼入代码字符串,否则间接拼接表达式对应的 content。

通过这三个节点的剖析,咱们能晓得其实生成器是依据不同节点的类型,push 进不同的代码字符串,而对于存在子节点的节点,又回去递归遍历,确保每个节点都能生成对应的代码字符串。

解决动态晋升

在笔者讲述生成器生成代码前置局部时,看源码会发现依据 mode 类型,调用了 genModulePreamblegenFunctionPreamble 函数。而在这两个函数中,都有一行雷同的代码: genHoists(ast.hoists, context)

这个函数就是用来解决动态晋升的,在上一篇文章中,笔者给大家介绍了动态晋升,并举了例子,阐明动态晋升会提前将动态节点提取进去,生成对应的序列化字符串。而明天笔者筹备接上一篇文章的内容,再跟大家一起探索生成器是怎么解决动态晋升的。

function genHoists(hoists: (JSChildNode | null)[], context: CodegenContext) {if (!hoists.length) {return}
  context.pure = true
  const {push, newline, helper, scopeId, mode} = context
  newline()

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

  context.pure = false
}

这里我间接放上了生成动态晋升的 genHoists 代码,逐渐剖析逻辑。首先呢,函数承受 ast 树中 hoists 的属性的入参,是一组节点类型的汇合的数组,并承受生成器上下文,一共有两个参数。

如果 hoists 数组中没有元素,阐明不存在须要动态晋升的节点,那间接返回即可。

否则就是存在须要晋升的节点,那么将上下文的 pure 标记置为 true。

之后 forEach 遍历 hoists 数组,并且依据数组的 index 生成动态晋升的变量名 _hoisted_${index + 1},之后调用 genNode 函数,生成动态晋升节点的代码字符串,赋值给之前申明的变量 _hoisted_${index + 1}

在遍历完所有的须要晋升的变量后,将 pure 标记置为 false。

而这里 pure 标记的作用,就是在某些节点类型生成字符串前,增加 /*#__PURE__*/ 正文前缀,表明该节点是动态节点。

总结

在本文中,笔者率领大家一起浏览了生成器 generate 模块的源码,介绍了生成器的作用,以及介绍了生成器上下文,并且阐明是生成器上下文中的工具函数的用法。之后笔者对 generate 函数的整体执行流程进行解说,从渲染函数的前置内容,到函数签名的生成,再到解决 ast 中的资源,到最终返回代码字符串的后果,如果有人对后果感兴趣,能够去看本系列的第一篇文章,在那篇文章中有 render 函数最终返回的示例。最初咱们又着重讲了生成节点 genNode 函数,以及为了响应上篇文章动态晋升篇,笔者又给大家解说了动态节点的生成函数,以及生成过程。

最初,如果这篇文章可能帮忙到你再深一点的了解 Vue3 的个性,心愿能给本文点一个喜爱❤️。如果想持续追踪后续文章,也能够关注我的账号或 follow 我的 github,再次谢谢各位可恶的看官老爷。

正文完
 0