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!小伙伴们学废了吗 😁

相关推荐
C语言魔术师14 分钟前
【小游戏篇】三子棋游戏
前端·算法·游戏
匹马夕阳1 小时前
Vue 3中导航守卫(Navigation Guard)结合Axios实现token认证机制
前端·javascript·vue.js
你熬夜了吗?1 小时前
日历热力图,月度数据可视化图表(日活跃图、格子图)vue组件
前端·vue.js·信息可视化
桂月二二8 小时前
探索前端开发中的 Web Vitals —— 提升用户体验的关键技术
前端·ux
沈梦研9 小时前
【Vscode】Vscode不能执行vue脚本的原因及解决方法
ide·vue.js·vscode
hunter2062069 小时前
ubuntu向一个pc主机通过web发送数据,pc端通过工具直接查看收到的数据
linux·前端·ubuntu
qzhqbb9 小时前
web服务器 网站部署的架构
服务器·前端·架构
刻刻帝的海角9 小时前
CSS 颜色
前端·css
轻口味9 小时前
Vue.js 组件之间的通信模式
vue.js
九酒9 小时前
从UI稿到代码优化,看Trae AI 编辑器如何帮助开发者提效
前端·trae