Vue3 编译原理直通车💥——transform 篇

在上篇文章 《Vue3 编译原理直通车💥------parser 篇》 中,我们介绍了 Vue 的解析器 parser 是如何将我们的模板字符串编译成模板 AST 的;这次,我们再来 look look 模板 AST 转换为 JavaScript AST 的过程。

JavaScript AST

老规矩,我们先来看看什么是 JavaScript AST

其实,和模板 AST 是使用 JavaScript 来描述我们的模板结构一样;JavaScript AST 实际上就是用 JavaScript 对我们的最终代码进行一层抽象

比如下面这样一段模板代码:

vue 复制代码
<template>
  <div>
    <h1>{{ message }}</h1>
  </div>
</template>

最终生成的 render 函数如下:

js 复制代码
function render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("h1", null, _toDisplayString(message), 1 /* TEXT */)
  ]))
}

在 Vue3 中,对应的用来描述这段渲染函数的 Javascript AST 如下:

js 复制代码
const ast = {
  type: 'VNODE_CALL', // 表示需要创建一个虚拟节点
  tag: 'div', // 虚拟节点对应的标签名称
  children: [
    {
      type: 'VNODE_CALL', // 表示需要创建一个虚拟节点
      tag: 'h1', // 虚拟节点对应的标签名称
      children: {
        type: 'INTERPOLATION', // h1 的 children 类型是一个插值表达式
        content: { // 对 INTERPOLATION 类型内容的补充
          type: 'SIMPLE_EXPRESSION', // 是个简单表达式 
          content: 'message' // 表达式内容为 message
        }
      },
      patchFlag: 'TEXT', // patchFlag 属性用于编译优化
      isBlock: false,
    }
  ],
  isBlock: true // 表示需要调用 openBlock 函数
}

通过上面的 Javascript AST ,再配合 generate 函数使用就能生成对应的渲染函数。

例如:针对我们的节点当 typeVNODE_CALL 时,会调用 genVNodeCall 函数,而 genVNodeCall 函数的逻辑就是根据 isBlock 的值来判断是否要拼接 openBlock 函数,以及将 tagprops 等属性的值拼接起来。

js 复制代码
// 最终生成的 render 函数字符串
let code = ''

function generate (node) {
  switch (node.type) {
    case 'VNODE_CALL':
      genVNodeCall(node, context)
      break
  }
}

function genVNodeCall(node) {
  const {
    tag,
    props,
    children,
    patchFlag,
    isBlock,
  } = node
  // 如果 isBlock 属性为 true, 字符串拼接 (_openBlock()
  if (isBlock) {
    code += '(_openBloack(),'
  }
  // 字符串拼接 _createElementBlock 部分
  code += '_createElementBlock('
  // 遍历这些属性,将其依次拼接起来
  const nodes = [tag, props, children, patchFlag]
  for (let i = 0; i < nodes.length; i++) {
    const node = nodes[i]
    code += node
    if (i < nodes.length - 1) {
      code += ','
    }
  }
  code += ')'
  // 补充 _openBlock 的半边括号
  if (isBlock) {
    code += ')'
  }
}

其实 generate 生成最终渲染函数的过程,本质就是一个字符串的拼接的过程 。所以对于 JavaScript AST 的结构该如何设计我认为是没有一个标准答案的,只要保证前后的逻辑能够自洽就 ok 啦。

Vue3 的性能的很大一部分提升归功于编译时优化 ,而这里的_openBlock_createElementBlock 函数,可以简单理解为是包含了编译优化信息的 h 函数,作用也是用于创建虚拟节点。

模板 AST 转换为 JavaScript AST

为了将模板 AST 转换成 JavaScript AST;就需要访问模板 AST 中的每一个节点,从而对这些节点执行一些特定的操作。

从代码中可以看出,无论是我们的模板 AST 还是 JavaScript AST 都是树型结构,而树型结构访问节点的过程,其实就是对树的深度优先遍历的过程,在 Vue3 中也是通过递归的方式来实现的:

js 复制代码
funciton transform (ast) {
  traverseNode(ast)
}
function traverseNode (node) {
  // 拿到当前节点
  const currentNode = node
  // 当前节点的 children
  const children = currentNode.children
  // 如果有 children 的话,递归调用 traverseNode,实现对 children 的访问
  if (children) {
    for (let i = 0; i < children.length; i++) {
      traverseNode(children[i])
    }
  }
}

通过递归调用 traverseNode 函数,Vue3 就能实现对整棵 AST 节点的访问 ;但是仅仅是访问还不够,因为我们的最终目的是生成 JavaScript AST,因此还需要对访问到的节点进行相关的转换

那么都要进行哪些转换呢?比如说下面这段模板 AST

js 复制代码
const templateAst = {
  type: 'Element',
  tag: 'div',
  children: [
    {
      type: 'Text',
      content: 'hello'
    }
  ]
}

它对应的 JavaScript AST 如下:

js 复制代码
const ast = {
  type: 'VNODE_CALL', // 表示需要创建一个虚拟节点
  tag: 'div', // 虚拟节点对应的标签名称
  children: [
    {
      type: 'TEXT', // 表示是文本
      content: 'hello' // 文本内容
    }
  ],
  isBlock: true // 表示需要调用 openBlock 函数
}

transformElement 函数

针对 Element 类型的节点就可以编写一个 transformElement 函数来进行转换:

js 复制代码
function transformElement (node) {
  // 只有节点类型为 Element 时才进行处理
  if (node.type !== 'Element') return
  // 拿到节点标签名称,针对我们的例子就是 'div'
  const { tag } = node
  // 判断是否使用 openBlock 函数
  let shouldUseBlock = false
  if (hasChildren) {
    shouldUseBlock = true
  }
  // 调用 createVNodeCall 返回对应的节点属性
  // 返回的值挂载在 codegen 属性上
  node.codegen = createVNodeCall(
    tag,
    children,
    shouldUseBlock
  )
}

// 辅助函数,用于创建一个 vnodeCall 类型的节点
function createVNodeCall (
  tag,
  children,
  isBlock
) {
  return {
    type: 'VNODE_CALL',
    tag,
    isBlock,
    children
  }
}

traverseNode 函数中就可以去调用 transformElement 函数,来对指定的节点进行转换:

js 复制代码
function traverseNode (node) {
  const currentNode = node
  // 调用 `transformElement` 函数,对 node 实现转换
  transformElement(node)
  const children = currentNode.children
  if (children) {
    for (let i = 0; i < children.length; i++) {
      traverseNode(children[i])
    }
  }
}

这样一来,就能将 Element 类型的模板 AST 转换为对应的 JavaScript AST

针对不同 type 做处理的相关 transform 转换函数逻辑在目录: core-main/packages/compiler-core/src/transforms 下;

而创建 JavaScript AST 辅助函数相关逻辑在:core-main/packages/compiler-core/src/ast.ts 文件中。

插件化架构

实际上,像 transformElement 这样针对不同 type 做处理的转换函数还有很多;如果将其调用逻辑全都写在 traverseNode 函数中代码就不那么优雅了。

所以 Vue3 源码中是通过插件的形式将这些转换函数注册到 transform 函数中

js 复制代码
function baseCompile (template) {
  const ast = baseParse(template)
  // 创建一个共享上下文 context
  const context = {
    // 将 transform 相关函数注册到这个全局上下文中
    nodeTransforms: [
      transformIf,
      transformFor,
      transformSlotOutlet,
      transformElement,
      trackSlotScopes,
      transformText
    ]
  }
  transform(ast, context)
}

baseCompile 函数中,我们创建了一个 context 对象,将转换的相关函数注册到这个 context 对象中,然后再传递给 transform

js 复制代码
funciton transform (ast, options) {
  // 创建一个共享上下文 context
  const context = createTransformContext(root, options)
  // 将 context 作为第二个参数传递给 traverseNode 函数
  traverseNode(ast, context)
}
// 通过 createTransformContext 方法对 context 对象进一步增强,混入一些节点通用的属性和方法
function createTransformContext (root, {
  nodeTransforms = []
}) {
  const context = {
    nodeTransforms, // 节点转换方法
    currentNode: root, // 当前节点
    parent: null, // 当前节点的父节点
    removeNode: () => {}, // 移除节点
    replaceNode: () => {}, // 替换节点
  }
  return context
}

transform 函数中,通过调用 createTransformContext 方法对 context 对象进一步增强,混入一些节点通用的属性和方法 ;最后再将 context 对象传递给 traverseNode 函数:

js 复制代码
function traverseNode (node, context) {
  // 更新当前节点
  context.currentNode = node
  // 拿到节点转换方法
  const { nodeTransforms } = context
  // 遍历节点转换方法
  for (let i = 0; i < nodeTransforms.length; i++) {
    // 执行节点转换方法
    nodeTransforms[i](node, context)
  }
  const currentNode = node
  const children = currentNode.children
  if (children) {
    for (let i = 0; i < children.length; i++) {
      // 递归前更新当前节点的父节点
      context.parent = context.currentNode
      traverseNode(children[i])
    }
  }
}

traverseNode 函数中会去更新 context 中的 currentNode 属性,也就是当前节点的指向。

接着遍历了预先注册好的节点转换方法,并将当前节点传入处理

最后在递归之前,会更新 context 中的 parent 属性值为当前节点,这样在递归到子节点之后就能通过 context.parent 来访问到它的父节点

节点的访问与退出阶段

之前的部分我们简单介绍了一下如何递归访问模板 AST 上的每个节点 ;其实在递归的过程中,针对每个节点都有一个进入阶段 以及退出阶段

比如一个模板这段模板: <div><p>hello</p></div>,它的访问过程用一张图来表示的话就像下面这样:

简单来说一个节点的进入阶段,就是这个节点的子节点还没开始递归访问的时候;而节点的结束阶段,则是这个节点的子节点都被访问过的时候 ,也就是 traverseNode 函数的前后;

js 复制代码
function traverseNode (node, context) {
  // traverseNode 上面的代码,代表了节点访问的进入阶段
  traverseNode(children[i])
  // traverseNode 往下的代码,代表了节点访问的退出阶段
}

在前面的代码中,我们的转换逻辑都是写在节点 进入阶段 的;但是,一些节点的转换逻辑是需要在 退出阶段再执行 ;例如前面介绍的 transformElement 转换 Element 类型节点的这个函数。

对于父节点的转换依赖于子节点提供的信息,所以父节点的转换逻辑应该在子节点全部被处理完毕后再执行

因此,我们需要改造一下 transformElement 函数:

js 复制代码
function transformElement (node) {
  // 通过 transformElement 返回一个函数
  return () => {
    if (node.type !== 'Element') return
    const { tag } = node
    let shouldUseBlock = false
    if (hasChildren) {
      shouldUseBlock = true
    }
    node.codegen = createVNodeCall(
      tag,
      children,
      shouldUseBlock
    )
  }
}

通过上面的改造,transformElement 函数被调用时会返回另外一个匿名函数 ;接下来还需要对 traverseNode 函数进行改造:

js 复制代码
function traverseNode (node, context) {
  context.currentNode = node
  const { nodeTransforms } = context
  // 节点进入阶段
  // 通过一个数组来接收退出阶段要执行的函数
  const exitFns = []
  for (let i = 0; i < nodeTransforms.length; i++) {
    // 接收转换函数的返回值
    const onExit = nodeTransforms[i](node, context)
    // 如果转换函数存在返回值,将其放入 exitFns 数组中
    if (onExit) {
      exitFns.push(onExit)
    }
  }
  const currentNode = node
  const children = currentNode.children
  if (children) {
    for (let i = 0; i < children.length; i++) {
      context.parent = context.currentNode
      traverseNode(children[i])
    }
  }
  
  // 节点退出阶段
  // 反序遍历 exitFns 数组,并执行其中的函数
  let i = exitFns.length
  while (i--) {
    exitFns[i]()
  }
}

上面的代码中,在节点进入阶段 增加了一个数组 exitFns储存转换函数调用后的返回值 ;然后在节点退出阶段倒序执行 exitFns 数组中的函数,这个时候当前节点的子节点已经全部处理完毕了,就能保证当前节点也能够被正确处理啦。

总结

不知不觉又啰里吧嗦了近一万字,最后我们一起来简单总结一下从模板 ASTJavaScript AST 的这个过程吧~

首先,Vue3 通过递归函数来对模板 AST 进行 深度优先遍历 ;在遍历的过程中就能够访问到对应的节点,从而通过各种转换函数将节点转换为相应 JavaScript AST

同时,在递归过程中会维护一个 context 对象,这个对象上会挂载全局共享的一些信息,比如:当前节点、当前节点的父节点等等;而 Vue3 通过插件化的架构,将转换函数注册到 context 对象中,从而实现了代码的解耦;

最后,递归过程中的每个节点都存在 进入阶段和退出阶段 ,处理那些依赖子节点信息的节点时,需要将它们的处理逻辑放到 退出阶段 来执行。

That's all!小伙伴们学废了吗 😁

相关推荐
gopher95116 分钟前
HTML详解
前端·html
Tiny20177 分钟前
前端模块化CommonJs、ESM、AMD总结
前端
吕永强9 分钟前
CSS相关属性和显示模式
前端·css·css3
结衣结衣.14 分钟前
python中的函数介绍
java·c语言·开发语言·前端·笔记·python·学习
全栈技术负责人15 分钟前
前端提升方向
前端
赵锦川15 分钟前
css三角形:css画箭头向下的三角形
前端·css
qbbmnnnnnn20 分钟前
【WebGis开发 - Cesium】如何确保Cesium场景加载完毕
前端·javascript·vue.js·gis·cesium·webgis·三维可视化开发
f8979070701 小时前
layui动态表格出现 横竖间隔线
前端·javascript·layui
鱼跃鹰飞1 小时前
Leecode热题100-295.数据流中的中位数
java·服务器·开发语言·前端·算法·leetcode·面试
杨荧2 小时前
【JAVA开源】基于Vue和SpringBoot的水果购物网站
java·开发语言·vue.js·spring boot·spring cloud·开源