Vue.js 3.0 核心源码解析 - 前百度、滴滴资深技术专家 - 拉勾教育

上一节课,我们学习了 template 的解析过程,最终拿到了一个 AST 节点对象。这个对象是对模板的完整描述,但是它还不能直接拿来生成代码,因为它的语义化还不够,没有包含和编译优化的相关属性,所以还需要进一步转换。

AST 转换过程非常复杂,有非常多的分支逻辑,为了方便你理解它的核心流程,我精心准备了一个示例,我们只分析示例场景在 AST 转换过程中的相关代码逻辑,不过我希望你在学习完之后,可以举一反三,对示例做一些修改,学习更多场景的代码逻辑。

  1. <div class="app">
  2. <hello v-if="flag"></hello>
  3. <div v-else>
  4. <p>>hello {{ msg + test }}</p>
  5. <p>static</p>
  6. <p>static</p>
  7. </div>
  8. </div>

示例中,我们有普通的 DOM 节点,有组件节点,有 v-bind 指令,有 v-if 指令,有文本节点,也有表达式节点。

对于这个模板,我们通过 parse 生成一个 AST 对象,接下来我们就来分析这个 AST 对象的转换都做了哪些事情。

我们会先通过 getBaseTransformPreset 方法获取节点和指令转换的方法,然后调用 transform 方法做 AST 转换,并且把这些节点和指令的转换方法作为配置的属性参数传入。

  1. const [nodeTransforms, directiveTransforms] = getBaseTransformPreset()
  2. transform(ast, extend({}, options, {
  3. prefixIdentifiers,
  4. nodeTransforms: [
  5. ...nodeTransforms,
  6. ...(options.nodeTransforms || [])
  7. ],
  8. directiveTransforms: extend({}, directiveTransforms, options.directiveTransforms || {}
  9. )
  10. }))

我们先来看一下 getBaseTransformPreset 返回哪些节点和指令的转换方法:

  1. function getBaseTransformPreset(prefixIdentifiers) {
  2. return [
  3. [
  4. transformOnce,
  5. transformIf,
  6. transformFor,
  7. transformExpression,
  8. transformSlotOutlet,
  9. transformElement,
  10. trackSlotScopes,
  11. transformText
  12. ],
  13. {
  14. on: transformOn,
  15. bind: transformBind,
  16. model: transformModel
  17. }
  18. ]
  19. }

这里并不需要你进一步去看每个转换函数的实现,只要大致了解有哪些转换函数即可,这些转换函数会在后续执行 transform 的时候调用。

注意这里我们只分析在 Node.js 环境下的编译过程。Web 环境的编译结果可能会有一些差别,我们会在后续章节说明。

我们主要来看 transform 函数的实现:

  1. function transform(root, options) {
  2. const context = createTransformContext(root, options)
  3. traverseNode(root, context)
  4. if (options.hoistStatic) {
  5. hoistStatic(root, context)
  6. }
  7. if (!options.ssr) {
  8. createRootCodegen(root, context)
  9. }
  10. root.helpers = [...context.helpers]
  11. root.components = [...context.components]
  12. root.directives = [...context.directives]
  13. root.imports = [...context.imports]
  14. root.hoists = context.hoists
  15. root.temps = context.temps
  16. root.cached = context.cached
  17. }

transform 的核心流程主要有四步:创建 transform 上下文、遍历 AST 节点、静态提升以及创建根代码生成节点。接下来,我们就好好分析一下每一步主要做了什么。

创建 transform 上下文

首先,我们来看创建 transform 上下文的过程,其实和 parse 过程一样,在 transform 阶段会创建一个上下文对象,它的实现过程是这样的:

  1. function createTransformContext(root, { prefixIdentifiers = false, hoistStatic = false, cacheHandlers = false, nodeTransforms = [], directiveTransforms = {}, transformHoist = null, isBuiltInComponent = NOOP, expressionPlugins = [], scopeId = null, ssr = false, onError = defaultOnError }) {
  2. const context = {
  3. prefixIdentifiers,
  4. hoistStatic,
  5. cacheHandlers,
  6. nodeTransforms,
  7. directiveTransforms,
  8. transformHoist,
  9. isBuiltInComponent,
  10. expressionPlugins,
  11. scopeId,
  12. ssr,
  13. onError,
  14. root,
  15. helpers: new Set(),
  16. components: new Set(),
  17. directives: new Set(),
  18. hoists: [],
  19. imports: new Set(),
  20. temps: 0,
  21. cached: 0,
  22. identifiers: {},
  23. scopes: {
  24. vFor: 0,
  25. vSlot: 0,
  26. vPre: 0,
  27. vOnce: 0
  28. },
  29. parent: null,
  30. currentNode: root,
  31. childIndex: 0,
  32. helper(name) {
  33. context.helpers.add(name)
  34. return name
  35. },
  36. helperString(name) {
  37. return `_${helperNameMap[context.helper(name)]}`
  38. },
  39. replaceNode(node) {
  40. context.parent.children[context.childIndex] = context.currentNode = node
  41. },
  42. removeNode(node) {
  43. const list = context.parent.children
  44. const removalIndex = node
  45. ? list.indexOf(node)
  46. : context.currentNode
  47. ? context.childIndex
  48. : -1
  49. if (!node || node === context.currentNode) {
  50. context.currentNode = null
  51. context.onNodeRemoved()
  52. }
  53. else {
  54. if (context.childIndex > removalIndex) {
  55. context.childIndex--
  56. context.onNodeRemoved()
  57. }
  58. }
  59. context.parent.children.splice(removalIndex, 1)
  60. },
  61. onNodeRemoved: () => { },
  62. addIdentifiers(exp) {
  63. },
  64. removeIdentifiers(exp) {
  65. },
  66. hoist(exp) {
  67. context.hoists.push(exp)
  68. const identifier = createSimpleExpression(`_hoisted_${context.hoists.length}`, false, exp.loc, true)
  69. identifier.hoisted = exp
  70. return identifier
  71. },
  72. cache(exp, isVNode = false) {
  73. return createCacheExpression(++context.cached, exp, isVNode)
  74. }
  75. }
  76. return context
  77. }

其实,这个上下文对象 context 维护了 transform 过程的一些配置,比如前面提到的节点和指令的转换函数等;还维护了 transform 过程的一些状态数据,比如当前处理的 AST 节点,当前 AST 节点在子节点中的索引,以及当前 AST 节点的父节点等。此外,context 还包含了在转换过程中可能会调用的一些辅助函数,和一些修改 context 对象的方法。

你现在也没必要去了解它的每一个属性和方法的含义,只需要你大致有一个印象即可,未来分析某个具体场景,再回过头了解它们的实现即可。

创建完上下文对象后,接下来就需要遍历 AST 节点。

遍历 AST 节点

遍历 AST 节点的过程很关键,因为核心的转换过程就是在遍历中实现的:

  1. function traverseNode(node, context) {
  2. context.currentNode = node
  3. const { nodeTransforms } = context
  4. const exitFns = []
  5. for (let i = 0; i < nodeTransforms.length; i++) {
  6. const onExit = nodeTransforms[i](node, context)
  7. if (onExit) {
  8. if (isArray(onExit)) {
  9. exitFns.push(...onExit)
  10. }
  11. else {
  12. exitFns.push(onExit)
  13. }
  14. }
  15. if (!context.currentNode) {
  16. return
  17. }
  18. else {
  19. node = context.currentNode
  20. }
  21. }
  22. switch (node.type) {
  23. case 3 :
  24. if (!context.ssr) {
  25. context.helper(CREATE_COMMENT)
  26. }
  27. break
  28. case 5 :
  29. if (!context.ssr) {
  30. context.helper(TO_DISPLAY_STRING)
  31. }
  32. break
  33. case 9 :
  34. for (let i = 0; i < node.branches.length; i++) {
  35. traverseNode(node.branches[i], context)
  36. }
  37. break
  38. case 10 :
  39. case 11 :
  40. case 1 :
  41. case 0 :
  42. traverseChildren(node, context)
  43. break
  44. }
  45. let i = exitFns.length
  46. while (i--) {
  47. exitFns[i]()
  48. }
  49. }

这里,traverseNode 函数的基本思路就是递归遍历 AST 节点,针对每个节点执行一系列的转换函数,有些转换函数还会设计一个退出函数,当你执行转换函数后,它会返回一个新函数,然后在你处理完子节点后再执行这些退出函数,这是因为有些逻辑的处理需要依赖子节点的处理结果才能继续执行。

Vue.js 内部大概内置了八种转换函数,分别处理指令、表达式、元素节点、文本节点等不同的特性。限于篇幅,我不会介绍所有转换函数,感兴趣的同学可以后续自行分析。

下面我会介绍四种类型的转换函数,并结合前面的示例来分析。

Element 节点转换函数

首先,我们来看一下 Element 节点转换函数的实现:

  1. const transformElement = (node, context) => {
  2. if (!(node.type === 1 &&
  3. (node.tagType === 0 ||
  4. node.tagType === 1 ))) {
  5. return
  6. }
  7. return function postTransformElement() {
  8. const { tag, props } = node
  9. const isComponent = node.tagType === 1
  10. const vnodeTag = isComponent
  11. ? resolveComponentType(node, context)
  12. : `"${tag}"`
  13. const isDynamicComponent = isObject(vnodeTag) && vnodeTag.callee === RESOLVE_DYNAMIC_COMPONENT
  14. let vnodeProps
  15. let vnodeChildren
  16. let vnodePatchFlag
  17. let patchFlag = 0
  18. let vnodeDynamicProps
  19. let dynamicPropNames
  20. let vnodeDirectives
  21. let shouldUseBlock =
  22. isDynamicComponent ||
  23. (!isComponent &&
  24. (tag === 'svg' ||
  25. tag === 'foreignObject' ||
  26. findProp(node, 'key', true)))
  27. if (props.length > 0) {
  28. const propsBuildResult = buildProps(node, context)
  29. vnodeProps = propsBuildResult.props
  30. patchFlag = propsBuildResult.patchFlag
  31. dynamicPropNames = propsBuildResult.dynamicPropNames
  32. const directives = propsBuildResult.directives
  33. vnodeDirectives =
  34. directives && directives.length
  35. ? createArrayExpression(directives.map(dir => buildDirectiveArgs(dir, context)))
  36. : undefined
  37. }
  38. if (node.children.length > 0) {
  39. if (vnodeTag === KEEP_ALIVE) {
  40. shouldUseBlock = true
  41. patchFlag |= 1024
  42. if ((process.env.NODE_ENV !== 'production') && node.children.length > 1) {
  43. context.onError(createCompilerError(42 , {
  44. start: node.children[0].loc.start,
  45. end: node.children[node.children.length - 1].loc.end,
  46. source: ''
  47. }))
  48. }
  49. }
  50. const shouldBuildAsSlots = isComponent &&
  51. vnodeTag !== TELEPORT &&
  52. vnodeTag !== KEEP_ALIVE
  53. if (shouldBuildAsSlots) {
  54. const { slots, hasDynamicSlots } = buildSlots(node, context)
  55. vnodeChildren = slots
  56. if (hasDynamicSlots) {
  57. patchFlag |= 1024
  58. }
  59. }
  60. else if (node.children.length === 1 && vnodeTag !== TELEPORT) {
  61. const child = node.children[0]
  62. const type = child.type
  63. const hasDynamicTextChild = type === 5 ||
  64. type === 8
  65. if (hasDynamicTextChild && !getStaticType(child)) {
  66. patchFlag |= 1
  67. }
  68. if (hasDynamicTextChild || type === 2 ) {
  69. vnodeChildren = child
  70. }
  71. else {
  72. vnodeChildren = node.children
  73. }
  74. }
  75. else {
  76. vnodeChildren = node.children
  77. }
  78. }
  79. if (patchFlag !== 0) {
  80. if ((process.env.NODE_ENV !== 'production')) {
  81. if (patchFlag < 0) {
  82. vnodePatchFlag = patchFlag + ` `
  83. }
  84. else {
  85. const flagNames = Object.keys(PatchFlagNames)
  86. .map(Number)
  87. .filter(n => n > 0 && patchFlag & n)
  88. .map(n => PatchFlagNames[n])
  89. .join(`, `)
  90. vnodePatchFlag = patchFlag + ` `
  91. }
  92. }
  93. else {
  94. vnodePatchFlag = String(patchFlag)
  95. }
  96. if (dynamicPropNames && dynamicPropNames.length) {
  97. vnodeDynamicProps = stringifyDynamicPropNames(dynamicPropNames)
  98. }
  99. }
  100. node.codegenNode = createVNodeCall(context, vnodeTag, vnodeProps, vnodeChildren, vnodePatchFlag, vnodeDynamicProps, vnodeDirectives, !!shouldUseBlock, false , node.loc)
  101. }
  102. }

可以看到,只有当 AST 节点是组件或者普通元素节点时,才会返回一个退出函数,而且它会在该节点的子节点逻辑处理完毕后执行。

分析这个退出函数前,我们需要知道节点函数的转换目标,即创建一个实现 VNodeCall 接口的代码生成节点,也就是说,生成这个代码生成节点后,后续的代码生成阶段可以根据这个节点对象生成目标代码。

知道了这个目标,我们再去理解 transformElement 函数的实现就不难了。

首先,判断这个节点是不是一个 Block 节点

为了运行时的更新优化,Vue.js 3.0 设计了一个 Block tree 的概念。Block tree 是一个将模版基于动态节点指令切割的嵌套区块,每个区块只需要以一个 Array 来追踪自身包含的动态节点。借助 Block tree,Vue.js 将 vnode 更新性能由与模版整体大小相关提升为与动态内容的数量相关,极大优化了 diff 的效率,模板的动静比越大,这个优化就会越明显。

因此在编译阶段,我们需要找出哪些节点可以构成一个 Block,其中动态组件、svg、foreignObject 标签以及动态绑定的 prop 的节点都被视作一个 Block。

其次,是处理节点的 props

这个过程主要是从 AST 节点的 props 对象中进一步解析出指令 vnodeDirectives、动态属性 dynamicPropNames,以及更新标识 patchFlag。patchFlag 主要用于标识节点更新的类型,在组件更新的优化中会用到,我们在后续章节会详细讲。

接着,是处理节点的 children

对于一个组件节点而言,如果它有子节点,则说明是组件的插槽,另外还会有对一些内置组件比如 KeepAlive、Teleport 的处理逻辑。

对于一个普通元素节点,我们通常直接拿节点的 children 属性给 vnodeChildren 即可,但有一种特殊情况,如果节点只有一个子节点并且是一个普通文本节点插值或者表达式那么直接把节点赋值给 vnodeChildren

然后,会对前面解析 props 求得的 patchFlag 和 dynamicPropNames 做进一步处理

在这个过程中,我们会根据 patchFlag 的值从 PatchFlagNames 中获取 flag 对应的名字,从而生成注释,因为 patchFlag 本身就是一个个数字,通过名字注释的方式,我们就可以一眼从最终生成的代码中了解到 patchFlag 代表的含义。

另外,我们还会把数组 dynamicPropNames 转化生成 vnodeDynamicProps 字符串,便于后续对节点生成代码逻辑的处理。

最后,通过 createVNodeCall 创建了实现 VNodeCall 接口的代码生成节点,我们来看它的实现:

  1. function createVNodeCall(context, tag, props, children, patchFlag, dynamicProps, directives, isBlock = false, disableTracking = false, loc = locStub) {
  2. if (context) {
  3. if (isBlock) {
  4. context.helper(OPEN_BLOCK)
  5. context.helper(CREATE_BLOCK)
  6. }
  7. else {
  8. context.helper(CREATE_VNODE)
  9. }
  10. if (directives) {
  11. context.helper(WITH_DIRECTIVES)
  12. }
  13. }
  14. return {
  15. type: 13 ,
  16. tag,
  17. props,
  18. children,
  19. patchFlag,
  20. dynamicProps,
  21. directives,
  22. isBlock,
  23. disableTracking,
  24. loc
  25. }
  26. }

createVNodeCall 的实现很简单,它最后返回了一个对象,包含了传入的参数数据。这里要注意 context.helper 函数的调用,它会把一些 Symbol 对象添加到 context.helpers 数组中,目的是为了后续代码生成阶段,生成一些辅助代码。

对于我们示例中的根节点:

它转换后生成的 node.codegenNode :

  1. {
  2. "children": [
  3. ],
  4. "directives": undefined,
  5. "dynamicProps": undefined,
  6. "isBlock": false,
  7. "isForBlock": false,
  8. "patchFlag": undefined,
  9. "props": {
  10. },
  11. "tag": "div",
  12. "type": 13
  13. }

这个 codegenNode 相比之前的 AST 节点对象,多了很多和编译优化相关的属性,它们会在代码生成阶段会起到非常重要作用,在后续的章节你就可以深入了解到。

表达式节点转换函数

接下来,我们来看一下表达式节点转换函数的实现:

  1. const transformExpression = (node, context) => {
  2. if (node.type === 5 ) {
  3. node.content = processExpression(node.content, context)
  4. }
  5. else if (node.type === 1 ) {
  6. for (let i = 0; i < node.props.length; i++) {
  7. const dir = node.props[i]
  8. if (dir.type === 7 && dir.name !== 'for') {
  9. const exp = dir.exp
  10. const arg = dir.arg
  11. if (exp &&
  12. exp.type === 4 &&
  13. !(dir.name === 'on' && arg)) {
  14. dir.exp = processExpression(exp, context, dir.name === 'slot')
  15. }
  16. if (arg && arg.type === 4 && !arg.isStatic) {
  17. dir.arg = processExpression(arg, context)
  18. }
  19. }
  20. }
  21. }
  22. }

由于表达式本身不会再有子节点,所以它也不需要退出函数,直接在进入函数时做转换处理即可。

需要注意的是,只有在 Node.js 环境下的编译或者是 Web 端的非生产环境下才会执行 transformExpression,原因我稍后会告诉你。

transformExpression 主要做的事情就是转换插值和元素指令中的动态表达式,把简单的表达式对象转换成复合表达式对象,内部主要是通过 processExpression 函数完成。举个例子,比如这个模板:{{msg + test}},它执行 parse 后生成的表达式节点 node.content 值为一个简单的表达式对象:

  1. {
  2. "type": 4,
  3. "isStatic": false,
  4. "isConstant": false,
  5. "content": "msg + test"
  6. }

经过 processExpression 处理后,node.content 的值变成了一个复合表达式对象:

  1. {
  2. "type": 8,
  3. "children": [
  4. {
  5. "type": 4,
  6. "isConstant": false,
  7. "content": "_ctx.msg",
  8. "isStatic": false
  9. },
  10. " + ",
  11. {
  12. "type": 4,
  13. "isConstant": false,
  14. "content": "_ctx.test",
  15. "isStatic": false
  16. }
  17. ],
  18. "identifiers": []
  19. }

这里,我们重点关注对象中的 children 属性,它是一个长度为 3 的数组,其实就是把表达式msg + test拆成了三部分,其中变量 msg 和 test 对应都加上了前缀 _ctx。

那么为什么需要加这个前缀呢?

我们就要想到模板中引用的的 msg 和 test 对象最终都是在组件实例中访问的,但为了书写模板方便,Vue.js 并没有让我们在模板中手动加组件实例的前缀,例如:{{this.msg + this.test}},这样写起来就会不够方便,但如果用 JSX 写的话,通常要手动写 this。

你可能会有疑问,为什么 Vue.js 2.x 编译的结果没有 _ctx 前缀呢?这是因为 Vue.js 2.x 的编译结果使用了” 黑魔法 “ with,比如上述模板,在 Vue.js 2.x 最终编译的结果:with(this){return _s(msg + test)}

它利用 with 的特性动态去 this 中查找 msg 和 test 属性,所以不需要手动加前缀。

但是,Vue.js 3.0 在 Node.js 端的编译结果舍弃了 with,它会在 processExpression 过程中对表达式动态分析,给该加前缀的地方加上前缀。

processExpression 的详细实现我们不会分析,但你需要知道,这个过程肯定有一定的成本,因为它内部依赖了 @babel/parser 库去解析表达式生成 AST 节点,并依赖了 estree-walker 库去遍历这个 AST 节点,然后对节点分析去判断是否需要加前缀,接着对 AST 节点修改,最终转换生成新的表达式对象。

@babel/parser 这个库通常是在 Node.js 端用的,而且这库本身体积非常大,如果打包进 Vue.js 的话会让包体积膨胀 4 倍,所以我们并不会在生产环境的 Web 端引入这个库,Web 端生产环境下的运行时编译最终仍然会用 with 的方式。

因为用 with 的话就完全不需要对表达式做转换了,这也就回答我前面的问题:只有在 Node.js 环境下的编译或者是 Web 端的非生产环境下才会执行 transformExpression。

这部分内容比较多,所以本课时的内容就先到这。下节课,我们接着分析遍历 AST 节点中的 Text 节点的转换函数。

本节课的相关代码在源代码中的位置如下:
packages/compiler-core/src/compile.ts
packages/compiler-core/src/transform.ts
packages/compiler-core/src/ast.ts
packages/compiler-core/src/transforms/transformElement.ts
packages/compiler-core/src/transforms/transformExpression.ts