站在巨人的肩膀上看vue3-第15章 编译器核心技术概览

1,375 阅读5分钟

站在巨人的肩膀上看vue,来自霍春阳的vue设计与实现。作者以问题的形式一步步解读vue3底层的实现。思路非常的巧妙,这里会将书中的主要逻辑进行串联,也是自己读后的记录,希望通过这种形式可以和大家一起交流学习。

开篇

Vue.js的模版编译器用于把模版编译为渲染函数,主要分为3个步骤:

  1. 分析模版,将其解析为模版AST
  2. 将模版AST转换为用于描述渲染函数的JavaScript AST
  3. 根据JavaScript AST生成渲染函数代码

第一步parser过程通过有限状态自动机构造一个词法分析器,就是whiler循环遍历模版。比如

hello
,通过HTML的属性,设置6种状态,switch判断生成一个个Token列表。最后将这些Token列表来用于描述模版的AST。扫描Token列表,通过维护一个栈,每当扫描到一个开始标签就将其压入栈顶,当扫描到结束标签就将其出栈。这样,所有的Token扫描完毕后,即可构建出一颗树型AST。

第二步transform,将模版AST转换成JavaScript AST。访问AST种的节点,采用深度优选的方式对AST进行遍历。对节点访问分为进入和退出阶段,只有在退出节点阶段对当前节点进行处理。从而实现对AST的转换。这里另外有两个需要注意的点,一个是插件机制,另一个是上下文。为了解耦节点的访问和替换操作,可以将节点封装到独立的转换函数中,这些函数可以通过数组的形式注册进去,在transforms 通过回调的形式拿到数组参数遍历并执行。context上下对象会维护程序的当前状态,当前访问的父节点,当前访问节点的位置索引等信息。

第三步,将JavaScript AST转换为render的函数

15.1、模版DSL的编译器

编译器将源代码翻译为目标代码的过程叫作编译。完整的编译过程包括:

💡 源代码 —> (词法分析 —> 语法分析 —> 语义分析) —> (中间代码生成 —> 优化 —> 目标代码生成) —> 目标代码

Vue 模版编译器

💡 词法分析 —> 语法分析 —> 模版AST —> transformer —> javascript AST —> 代码生成
  1. 通过封装 parse 函数来完成对模版的词法分析和语法分析,得到模版AST;
  2. 有了模版AST后,可以对其进行语义分析,并对模版AST进行转换,比如检查 v-else 指令是否存在v-if指令等,分析是否是静态还是常量等
  3. 通过封装transform函数来完成模版AST到JavaScript AST 的转换工作;
  4. 通过generate函数根据 JavaScriptAST 生成渲染函数;
function compiler(template) {
  const templateAST = parser(template) // 模版AST
  const jsAST = transform(templateAST) // javascript AST
  const code = generate(jsAST)  // 代码生成
}

15.2、parser的实现原理和状态机

解析器的入参是字符串模版,解析器会逐个读取字符串模版中的字符,并按照一定的规则将整个字符串切割成一个个 Token。

解析器如何对模版进行切割根据有限状态自动机:在有限个状态,随着字符的输入,解析器会自动地在不同的状态建迁移。

状态机总共分为6中状态:初始状、标签开始状态、标签名状态、文本状态、结束标签状态、结束标签名称状态。

根据这6种状态机遍历template,switch判断命中哪中状态,经过处理,最后输出的格式

const tokens = tokenzie(`<p>Vue</p>`)
// [
//    { type: 'tag', name: 'p' },
//    { type: 'text', content: 'Vue' },
//    { type: 'textEnd', name: 'p' }
// ]

最后,通过有限自动状态机,我们能够将模版解析为一个个Token,进而可以用他们来构建一棵AST。

15.3、构造AST

HTML是一种标记语言,格式非常固定,标签元素天然嵌套,形成父子关系。因此AST的结果也是一颗树形解构,通过一些特定的属性值构建json数据格式来进行描述HTML。

遍历Token列表,维护一个栈elementStack,当遇到一个开始标签压入栈中,遇到一个结束标签,将当前栈定的元素弹出。whiler 循环扫描tokens,直到所有的token都被扫描完毕,最后生成一个AST树。

code-for-vue-3-book/code-15.1.html at master · HcySunYang/code-for-vue-3-book

15.4、AST的转换与插件化系统

15.4.1、节点访问

对AST进行转换,需要访问AST的每一个节点,这才有机会对待定点进行修改、替换、删除等操作。

function traverseNode(ast) {
  const currentNode = ast
  const children = currentNode.children
  if (children) {
    for (let i = 0;i < children.length; i++) {
      traverseNode(children[i])
    }
  }
}

通过回调函数将替换逻辑进行解耦,避免traverseNode逻辑过于臃肿。传入context,注入nodeTransforms数组方法。

function traverseNode(ast, context) {
  const currentNode = ast
  const transforms = context.nodeTransforms
  for (let i = 0; i < transforms.length; i++) {
    transforms[i](currentNode, context)
  }
  const children = currentNode.children
  if (children) {
    for (let i = 0;i < children.length; i++) {
      traverseNode(children[i])
    }
  }
}

15.4.2、转换上下文与节点操作

程序中的context上下文其实就是程序在某个范围内的“全局变量”,context可以看作是AST转换函数过程中的上下文数据。所有AST转换函数都可以通过context来共享节点。通过设置context的值保证在递归转换中,context对象所存储的信息都是正确的。

function traverseNode(ast, context) {
  context.currentNode = ast
  const transforms = context.nodeTransforms
  for (let i = 0; i < transforms.length; i++) {
    transforms[i](context.currentNode, context)
  }
  const children = context.currentNode.children
  if (children) {
    for (let i = 0;i < children.length; i++) {
      context.parent = context.currentNode
      context.childIndex = i
      traverseNode(children[i], context)
    }
  }
}

因此就可以进行节点的新增、替换或者删除了。

function transform(ast) {
  const context = {
    currentNode : null,
    childIndex: 0,
    parent: null,
    // 替换节点
    replaceNode(node) {
      context.parent.children[context.childIndex] = node
      context.currentNode = node
    },
    // 删除节点
    removeNode() {
      if (context.parent) {
        context.parent.children.splice(context.childIndex, 1)
        context.currentNode = null
      }
    },
    nodeTransforms: [
      transformElement,
      transformText
    ]
  }
  traverseNode(ast, context)
}

15.4.3、进入与退出

在转化AST节点的过程中,往往需要根据子节点的情况来决定该如何对当前节点进行转换,这就要求父节点的转换操作必须等待其所有子节点全部转换完毕后再操作。

对节点的访问分为两个阶段,即进入阶段后退出阶段,当转换函数处于进入阶段,心进入父节点,再进入子节点。当转换函数处于退出阶段时,先退出子节点再退出父节点。这样,只要我们再退出节点阶段当对当前访问的节点进行处理,就一定能保证其子节点全部处理完毕。

通过设置一个exitFns 的一个数组,在遍历transforms 中方法的时候将方法push到exitFns中。最后while循环执行exitFns 函数,利用队列先进后出的逻辑。

15.5、将模版AST 转为Javascript AST

Vue

Template

需要转换为

function render() {
	return h('div', {
		h('p', 'Vue'),
		h('p', 'Template')
	})
}

上一节中生成来模版的AST,需要将模版AST 转为Javascript AST,JavaScript AST的描述,

const FunctionDeclNode = {
  type: 'FunctionDecl', // 代表该节点是用函数声明的
  id: {
    type: 'Identifier',
    name: 'render' // name 用来存储标识符的名称
  },
  params: [], // 渲染函数的参数
  body: [
    {
      type: 'ReturnStatement',
      return: null
    }
  ]
}

code-for-vue-3-book/code-15.5.html at master · HcySunYang/code-for-vue-3-book

15.6、代码生成

将上述生成的JavaScript转换为render函数格式

code-for-vue-3-book/code-15.6.html at master · HcySunYang/code-for-vue-3-book