vue3 源码学习,实现一个 mini-vue(十四):构建 compile 编译器(上)

本文涉及的产品
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: vue3 源码学习,实现一个 mini-vue(十四):构建 compile 编译器(上)

前言

原文来自我的个人博客

接下来的几章中我们将要在 mini-vue 中 实现一个自己的编译器,在上一章我们了解了 compiler 的作用和大致流程。

它主要经历了以下三个大的步骤:

  1. 解析( parse ) template 模板,生成 AST
  2. 转化(transformAST,得到 JavaScript AST
  3. 生成(generaterender 函数

那么本章我们就先来实现编译器的第一步:依据模板生成 AST 抽象语法树

ps: compiler 是一个非常复杂的概念,我们不会实现一个完善的编译器,而是会像之前一样严格遵循: 没有使用就当做不存在 和  最少代码的实现逻辑 这两个标准。只关注  核心 和  当前业务 相关的内容,而忽略其他。

1. 扩展知识:JavaScript与有限自动状态机

想要实现 compiler 第一步是构建 AST 对象。那么想要构建 AST,就需要利用到 有限状态机 的概念。

有限状态机也被叫做 有限自动状态机,表示:有限个状态以及在这些状态之间的转移和动作等行为的数学计算模型)

光看概念,可能难以理解,那么下面我们来看一个具体的例子:

根据 packages/compiler-core/src/compile.ts 中的代码可知,ast 对象的生成是通过 baseParse 方法得到的。

const ast = isString(template) ? baseParse(template, options) : template

而对于 baseParse 方法而言,它接收一个 template 作为参数,返回一个 ast 对象。

即:通过 parse 方法,解析 template,得到 ast 对象。  中间解析的过程,就需要使用到 有限自动状态机。

比如,vue 想要把如下模板解析成 AST,那么就需要利用有限自动状态机对该模板进行分析

<div>hello world</div>

分析的过程中主要包含了三个特性:摘自阮一峰:JavaScript与有限状态机

  1. 状态总数(state)是有限的。

    1. 初始状态
    2. 标签开始状态
    3. 标签名称状态
    4. 文本状态
    5. 结束标签状态
    6. 结束标签名称状态
    7. ...
  2. 任一时刻,只处在一种状态之中。
  3. 某种条件下,会从一种状态转变(transition)到另一种状态。

如下图所示:
image.png

  1. 解析 <:由 初始状态 进入 标签开始状态
  2. 解析 div:由 标签开始状态 进入 标签名称状态
  3. 解析 >:由 标签名称状态 进入 初始状态
  4. 解析 hello world:由 初始状态 进入 文本状态
  5. 解析 <:由 文本状态 进入 标签开始状态
  6. 解析 /:由 标签开始状态 进入 结束标签状态
  7. 解析 div:由 结束标签状态 进入 结束标签名称状态
  8. 解析 >:由 结束标签名称状态 进入 初始状态

经过这样一些列的解析,对于:

<div>hello world</div>

而言,我们将得到三个 token

开始标签:<div>
文本节点:hello world
结束标签:</div>

而这样一个利用有限自动状态机的状态迁移,来获取 tokens 的过程,可以叫做:对模板的标记化

总结

这一小节,我们了解了什么是有限自动状态机,也知道了它的三个特性。

vue 利用它来实现了对模板的标记化,得到了对应的 token

关于这些 token 有什么用,我们下一小节说。

2. 扩展知识:扫描 tokens 构建 AST 结构的方案

在上一小节中,我们已经知道可以通过自动状态机解析模板为 tokens,而解析出来的 tokens 就是生成 AST 的关键。

生成 AST 的过程,就是 tokens 扫描的过程

我们以以下 html 结构为例:

<div>
  <p>hello</p>  
  <p>world</p>  
</div>

该 html 可以被解析为如下 tokens

开始标签:<div>
开始标签:<p>
文本节点:hello
结束标签:</p>
开始标签:<p>
文本节点:world
结束标签:</p>
结束标签:</div>

具体的扫描过程为:

1. 初始状态:

image.png

2. 扫描开始标签 <div>,在 AST 下 会生成 Element<div>

image.png

3. <p>hello</p> 标签依次入栈, AST 生成对应 Element<p>

image.png

  1. <p>hello</p> 标签依次出栈,<p>world</p> 标签依次入栈,AST 生成对应 Element<p>

image.png

5. </div>结束标签入栈再与<div>开始标签组合依次出栈。
image.png

6. 结束状态

image.png

在以上的图示中,我们通过 递归下降算法? 这样的一种扫描形式把 tokens 通过  解析成了 AST(抽象语法树)

3. 源码阅读:依据模板生成 AST 抽象语法树

前面做了这么多铺垫,我们中行与可以来看一下 vue 中生成 AST 的代码了。vue 的这部分代码全部放在了 packages/compiler-core/src/parse.ts 中:

image.png

从这个文件可以看出,整体 parse 的逻辑非常复杂,整体文件有 1175 行代码。

通过 packages/compiler-core/src/compile.ts 中的 baseCompile 方法可以知道,整个 parse 的过程是从 baseParse 开始的,所以我们可以直接从这个方法开始进行 debugger

创建测试实例:

<script>
  const { compile, h, render } = Vue
  // 创建 template
  const template = `<div>hello world</div>`

  // 生成 render 函数
  const renderFn = compile(template)
</script>

当前的 template 对应的目标极简 AST 为(我们不再关注其他的属性生成):

const ast = {
  "type": 0,
  "children": [
    {
      "type": 1,
      "tag": "div",
      "tagType": 0,
      // 属性,目前我们没有做任何处理。但是需要添加上,否则,生成的 ats 放到 vue 源码中会抛出错误
      "props": [],
      "children": [{ "type": 2, "content": " hello world " }]
    }
  ],
  // loc:位置,这个属性并不影响渲染,但是它必须存在,否则会报错。所以我们给了他一个 {}
  "loc": {}
}

模板解析的 token 流程为(以 <div>hello world</div> 为例):

1. <div
2. >
3. hello world
4. </div
5. >

明确好以上内容之后,我们开始。Here we go

  1. 进入 baseParse 方法:

image.png

  1. 上图描述的应该比较详细了,在 baseParse 方法中,程序首先会进入 createParserContext 方法中,这个方法会返回一个 ParserContext 类型的对象,这个对象比较复杂,我们只需要关注 source(模板源代码)属性即可
  2. 此时 context.source = "<div> hello world </div>",程序接着执行:

image.png

  1. createParserContext 方法跳出后,程序接着执行 getCursor 方法,这个方法主要获取 loc(即:location 位置),与我们的极简 `AST 无关,无需关注,接着调试:

image.png

  1. 接着,程序会先后执行 parseChildrengetSelectioncreateRoot 三个方法,最后返回 ast
  2. 执行 parseChildren 方法(解析子节点) ,这个方法 非常重要,是生成 AST 的核心方法:

image.png

  1. parseChildren 方法中的核心逻辑都在 while 循环中,它的目的就是循环解析模板数据,生成 AST 中的 children 的,最后生成的 nodes:(关于 while 循环中的逻辑是很复杂的,碍于篇幅这里做了点省略,具体逻辑可以参考我下面的框架实现)

image.png

  1. 接下来执行 getSelection 方法:

image.png

  1. 这个函数非常简单,也不是那么重要,我们只要知道是关于 location 的生成就行了。
  2. 最后,执行 createRoot 方法,这个方法也非常简单,就是整合了前两个函数生成的 childrenloc 成一个 RootNode 对象(最后的 AST)并返回

image.png

4. 框架实现

AST 对象的生成颇为复杂,我们把整个过程分为成三步进行处理。

  1. 构建 parse 方法,生成 context 实例
  2. 构建 parseChildren ,处理所有子节点(最复杂

    1. 构建有限自动状态机解析模板
    2. 扫描 token 生成 AST 结构
  3. 生成 AST,构建测试

4.1 构建 parse 函数,生成 context 实例

  1. 创建 packages/compiler-core/src/compile.ts 模块,写入如下代码:
export function baseCompile(template: string, options) {
  return {}
}
  1. 创建 packages/compiler-dom/src/index.ts 模块,导出 compile 方法:
import { baseCompile } from 'packages/compiler-core/src/compile'

export function compile(template: string, options) {
  return baseCompile(template, options)
}
  1. 在 packages/vue/src/index.ts 中,导出 compile 方法:
export { render, compile } from '@vue/runtime-dom'
  1. 创建 packages/compiler-core/src/parse.ts 模块下创建 baseParse 方法:
/**
 * 基础的 parse 方法,生成 AST
 * @param content tempalte 模板
 * @returns
 */
export function baseParse(content: string) {
  return {}
}
  1. 在 packages/compiler-core/src/compile.ts 模块下的 baseCompile 中,使用 baseParse 方法:
import { baseParse } from './parse'

export function baseCompile(template: string, options) {
  const ast = baseParse(template)
  console.log(JSON.stringify(ast))

  return {}
}

至此,我们就成功的触发了 baseParse。接下来我们去生成 context 上下文对象。

  1. 在 packages/compiler-core/src/parse.ts 中创建 createParserContext 方法,用来生成上下文对象:
/**
 * 创建解析器上下文
 */
function createParserContext(content: string): ParserContext {
  // 合成 context 上下文对象
  return {
    source: content
  }
}
  1. 创建 ParserContext 接口:
/**
 * 解析器上下文
 */
export interface ParserContext {
  // 模板数据源
  source: string
}
  1. 在 baseParse 中触发该方法:
export function baseParse(content: string) {
  // 创建 parser 对象,未解析器的上下文对象
  const context = createParserContext(content)
  console.log(context)
  return {}
}

至此我们成功得到了 context 上下文对象。

创建测试实例 packages/vue/examples/compiler/compiler-ast.html

<script>
  const { compile } = Vue
  // 创建 template
  const template = `<div> hello world </div>`

  // 生成 render 函数
  const renderFn = compile(template)
</script>

可以成功打印 context

image.png

4.2 构建有限自动状态机解析模板,扫描 token 生成 AST 结构

接下来我们通过 parseChildren 方法处理所有的子节点,整个处理的过程分为两大块:

  1. 构建有限自动状态机解析模板
  2. 扫描 token 生成 AST 结构

接下来我们来进行实现:

  1. 创建 parseChildren 方法:
/**
 * 解析子节点
 * @param context 上下文
 * @param mode 文本模型
 * @param ancestors 祖先节点
 * @returns
 */
function parseChildren(context: ParserContext, ancestors) {
  // 存放所有 node节点数据的数组
  const nodes = []

  /**
   * 循环解析所有 node 节点,可以理解为对 token 的处理。
   * 例如:<div>hello world</div>,此时的处理顺序为:
   * 1. <div
   * 2. >
   * 3. hello world
   * 4. </
   * 5. div>
   */
  while (!isEnd(context, ancestors)) {
    /**
     * 模板源
     */
    const s = context.source
    // 定义 node 节点
    let node

    if (startsWith(s, '{{')) {
    }
    // < 意味着一个标签的开始
    else if (s[0] === '<') {
      // 以 < 开始,后面跟a-z 表示,这是一个标签的开始
      if (/[a-z]/i.test(s[1])) {
        // 此时要处理 Element
        node = parseElement(context, ancestors)
      }
    }

    // node 不存在意味着上面的两个 if 都没有进入,那么我们就认为此时的 token 为文本节点
    if (!node) {
      node = parseText(context)
    }

    pushNode(nodes, node)
  }

  return nodes
}
  1. 以上代码中涉及到了 个方法:

    1. isEnd:判断是否为结束节点
    2. startsWith:判断是否以指定文本开头
    3. pushNode:为 array 执行 push 方法
    4. 复杂: parseElement:解析 element
    5. 复杂: parseText:解析 text
  2. 我们先实现前三个简单方法:
  3. 创建 startsWith 方法:
/**
 * 是否以指定文本开头
 */
function startsWith(source: string, searchString: string): boolean {
  return source.startsWith(searchString)
}
  1. 创建 isEnd 方法:
/**
 * 判断是否为结束节点
 */
function isEnd(context: ParserContext, ancestors): boolean {
  const s = context.source

  // 解析是否为结束标签
  if (startsWith(s, '</')) {
    for (let i = ancestors.length - 1; i >= 0; --i) {
      if (startsWithEndTagOpen(s, ancestors[i].tag)) {
        return true
      }
    }
  }
  return !s
}

/**
 * 判断当前是否为《标签结束的开始》。比如 </div> 就是 div 标签结束的开始
 * @param source 模板。例如:</div>
 * @param tag 标签。例如:div
 * @returns
 */
function startsWithEndTagOpen(source: string, tag: string): boolean {
  return (
    startsWith(source, '</') &&
    source.slice(2, 2 + tag.length).toLowerCase() === tag.toLowerCase() &&
    /[\t\r\n\f />]/.test(source[2 + tag.length] || '>')
  )
}
  1. 创建 pushNode 方法:
/**
 * nodes.push(node)
 */
function pushNode(nodes, node): void {
  nodes.push(node)
}

至此三个简单的方法都被构建完成。

接下来我们来处理 parseElement,在处理的过程中,我们需要使用到 NodeTypes 和 ElementTypes 这两个 enum 对象,所以我们需要先构建它们(直接从 vue 源码复制):

  1. 创建 packages/compiler-core/src/ast.ts 模块:
/**
 * 节点类型(我们这里复制了所有的节点类型,但是我们实际上只用到了极少的部分)
 */
export const enum NodeTypes {
  ROOT,
  ELEMENT,
  TEXT,
  COMMENT,
  SIMPLE_EXPRESSION,
  INTERPOLATION,
  ATTRIBUTE,
  DIRECTIVE,
  // containers
  COMPOUND_EXPRESSION,
  IF,
  IF_BRANCH,
  FOR,
  TEXT_CALL,
  // codegen
  VNODE_CALL,
  JS_CALL_EXPRESSION,
  JS_OBJECT_EXPRESSION,
  JS_PROPERTY,
  JS_ARRAY_EXPRESSION,
  JS_FUNCTION_EXPRESSION,
  JS_CONDITIONAL_EXPRESSION,
  JS_CACHE_EXPRESSION,

  // ssr codegen
  JS_BLOCK_STATEMENT,
  JS_TEMPLATE_LITERAL,
  JS_IF_STATEMENT,
  JS_ASSIGNMENT_EXPRESSION,
  JS_SEQUENCE_EXPRESSION,
  JS_RETURN_STATEMENT
}

/**
 * Element 标签类型
 */
export const enum ElementTypes {
  /**
   * element,例如:<div>
   */
  ELEMENT,
  /**
   * 组件
   */
  COMPONENT,
  /**
   * 插槽
   */
  SLOT,
  /**
   * template
   */
  TEMPLATE
}

下面就可以构建 parseElement 方法了 ,该方法的作用主要为了解析 Element 元素:

  1. 创建 parseElement
/**
 * 解析 Element 元素。例如:<div>
 */
function parseElement(context: ParserContext, ancestors) {
  // -- 先处理开始标签 --
  const element = parseTag(context, TagType.Start)

  //  -- 处理子节点 --
  ancestors.push(element)
  // 递归触发 parseChildren
  const children = parseChildren(context, ancestors)
  ancestors.pop()
  // 为子节点赋值
  element.children = children

  //  -- 最后处理结束标签 --
  if (startsWithEndTagOpen(context.source, element.tag)) {
    parseTag(context, TagType.End)
  }

  // 整个标签处理完成
  return element
}
  1. 构建 TagType enum
/**
 * 标签类型,包含:开始和结束
 */
const enum TagType {
  Start,
  End
}
  1. 处理开始标签,构建 parseTag
/**
 * 解析标签
 */
function parseTag(context: any, type: TagType): any {
  // -- 处理标签开始部分 --

  // 通过正则获取标签名
  const match: any = /^<\/?([a-z][^\r\n\t\f />]*)/i.exec(context.source)
  // 标签名字
  const tag = match[1]

  // 对模板进行解析处理
  advanceBy(context, match[0].length)

  // -- 处理标签结束部分 --

  // 判断是否为自关闭标签,例如 <img />
  let isSelfClosing = startsWith(context.source, '/>')
  // 《继续》对模板进行解析处理,是自动标签则处理两个字符 /> ,不是则处理一个字符 >
  advanceBy(context, isSelfClosing ? 2 : 1)

  // 标签类型
  let tagType = ElementTypes.ELEMENT

  return {
    type: NodeTypes.ELEMENT,
    tag,
    tagType,
    // 属性,目前我们没有做任何处理。但是需要添加上,否则,生成的 ats 放到 vue 源码中会抛出错误
    props: []
  }
}
  1. 解析标签的过程,其实就是一个自动状态机不断读取的过程,我们需要构建 advanceBy 方法,来标记进入下一步:
/**
 * 前进一步。多次调用,每次调用都会处理一部分的模板内容
 * 以 <div>hello world</div> 为例
 * 1. <div
 * 2. >
 * 3. hello world
 * 4. </div
 * 5. >
 */
function advanceBy(context: ParserContext, numberOfCharacters: number): void {
  // template 模板源
  const { source } = context
  // 去除开始部分的无效数据
  context.source = source.slice(numberOfCharacters)
}

至此 parseElement 构建完成。此处的代码虽然不多,但是逻辑非常复杂。在解析的过程中,会再次触发 parseChildren,这次触发表示触发 文本解析,所以下面我们要处理 parseText 方法。

  1. 创建 parseText 方法,解析文本:
/**
 * 解析文本。
 */
function parseText(context: ParserContext) {
  /**
   * 定义普通文本结束的标记
   * 例如:hello world </div>,那么文本结束的标记就为 <
   * PS:这也意味着如果你渲染了一个 <div> hell<o </div> 的标签,那么你将得到一个错误
   */
  const endTokens = ['<', '{{']
  // 计算普通文本结束的位置
  let endIndex = context.source.length

  // 计算精准的 endIndex,计算的逻辑为:从 context.source 中分别获取 '<', '{{' 的下标,取最小值为 endIndex
  for (let i = 0; i < endTokens.length; i++) {
    const index = context.source.indexOf(endTokens[i], 1)
    if (index !== -1 && endIndex > index) {
      endIndex = index
    }
  }

  // 获取处理的文本内容
  const content = parseTextData(context, endIndex)

  return {
    type: NodeTypes.TEXT,
    content
  }
}
  1. 解析文本的过程需要获取到文本内容,此时我们需要构建 parseTextData 方法:
/**
 * 从指定位置(length)获取给定长度的文本数据。
 */
function parseTextData(context: ParserContext, length: number): string {
  // 获取指定的文本数据
  const rawText = context.source.slice(0, length)
  // 《继续》对模板进行解析处理
  advanceBy(context, length)
  // 返回获取到的文本
  return rawText
}

最后在 baseParse 中触发 parseChildren 方法:

此时运行测试实例,打印出如下内容:

image.png

4.3 生成 AST,测试

当 parseChildren 处理完成之后,我们可以到 children,那么最后我们就只需要利用 createRoot 方法,把 children 放到 ROOT 节点之下即可。

  1. 创建 createRoot 方法:
/**
 * 生成 root 节点
 */
export function createRoot(children) {
  return {
    type: NodeTypes.ROOT,
    children,
    // loc:位置,这个属性并不影响渲染,但是它必须存在,否则会报错。所以我们给了他一个 {}
    loc: {}
  }
}
  1. 在 baseParse 中使用该方法:
/**
 * 基础的 parse 方法,生成 AST
 * @param content tempalte 模板
 * @returns
 */
export function baseParse(content: string) {
  // 创建 parser 对象,未解析器的上下文对象
  const context = createParserContext(content)
  const children = parseChildren(context, [])
  return createRoot(children)
}

至此整个 parse 解析流程完成。我们可以在 packages/compiler-core/src/compile.ts 中打印得到的 AST

export function baseCompile(template: string, options) {
  const ast = baseParse(template)
  console.log(JSON.stringify(ast), ast)

  return {}
}

得到的内容为:

{
  "type": 0,
  "children": [
    {
      "type": 1,
      "tag": "div",
      "tagType": 0,
      "props": [],
      "children": [{ "type": 2, "content": " hello world " }]
    }
  ],
  "loc": {}
}

我们可以把得到的该 AST 放入到 vue 的源码中进行解析,以此来验证是否正确。

在 vue 源码的 packages/compiler-core/src/compile.ts 模块下 baseCompile 方法中:

image.png

运行源码的 compile 方法,浏览器中依然可以渲染 hello world

<script>
  const { compile, h, render } = Vue
  // 创建 template
  const template = `<div>hello world</div>`

  // 生成 render 函数
  const renderFn = compile(template)

  // 创建组件
  const component = {
    render: renderFn
  }

  // 通过 h 函数,生成 vnode
  const vnode = h(component)

  // 通过 render 函数渲染组件
  render(vnode, document.querySelector('#app'))
</script>

成功运行,标记着我们的 AST 处理完成。

5. 扩展知识:AST 到 JavaScript AST 的转换策略和注意事项

相关文章
|
18小时前
|
缓存 JavaScript UED
Vue3中v-model在处理自定义组件双向数据绑定时有哪些注意事项?
在使用`v-model`处理自定义组件双向数据绑定时,要仔细考虑各种因素,确保数据的准确传递和更新,同时提供良好的用户体验和代码可维护性。通过合理的设计和注意事项的遵循,能够更好地发挥`v-model`的优势,实现高效的双向数据绑定效果。
101 64
|
18小时前
|
JavaScript 前端开发 API
Vue 3 中 v-model 与 Vue 2 中 v-model 的区别是什么?
总的来说,Vue 3 中的 `v-model` 在灵活性、与组合式 API 的结合、对自定义组件的支持等方面都有了明显的提升和改进,使其更适应现代前端开发的需求和趋势。但需要注意的是,在迁移过程中可能需要对一些代码进行调整和适配。
|
25天前
|
存储 JavaScript 前端开发
vue3的脚手架模板你真的了解吗?里面有很多值得我们学习的地方!
【10月更文挑战第21天】 vue3的脚手架模板你真的了解吗?里面有很多值得我们学习的地方!
vue3的脚手架模板你真的了解吗?里面有很多值得我们学习的地方!
|
22天前
|
JavaScript 前端开发 开发者
Vue 3中的Proxy
【10月更文挑战第23天】Vue 3中的`Proxy`为响应式系统带来了更强大、更灵活的功能,解决了Vue 2中响应式系统的一些局限性,同时在性能方面也有一定的提升,为开发者提供了更好的开发体验和性能保障。
50 7
|
24天前
|
前端开发 数据库
芋道框架审批流如何实现(Cloud+Vue3)
芋道框架审批流如何实现(Cloud+Vue3)
41 3
|
22天前
|
JavaScript 数据管理 Java
在 Vue 3 中使用 Proxy 实现数据双向绑定的性能如何?
【10月更文挑战第23天】Vue 3中使用Proxy实现数据双向绑定在多个方面都带来了性能的提升,从更高效的响应式追踪、更好的初始化性能、对数组操作的优化到更优的内存管理等,使得Vue 3在处理复杂的应用场景和大量数据时能够更加高效和稳定地运行。
39 1
|
22天前
|
JavaScript 开发者
在 Vue 3 中使用 Proxy 实现数据的双向绑定
【10月更文挑战第23天】Vue 3利用 `Proxy` 实现了数据的双向绑定,无论是使用内置的指令如 `v-model`,还是通过自定义事件或自定义指令,都能够方便地实现数据与视图之间的双向交互,满足不同场景下的开发需求。
44 1
|
25天前
|
前端开发 JavaScript
简记 Vue3(一)—— setup、ref、reactive、toRefs、toRef
简记 Vue3(一)—— setup、ref、reactive、toRefs、toRef
|
25天前
Vue3 项目的 setup 函数
【10月更文挑战第23天】setup` 函数是 Vue3 中非常重要的一个概念,掌握它的使用方法对于开发高效、灵活的 Vue3 组件至关重要。通过不断的实践和探索,你将能够更好地利用 `setup` 函数来构建优秀的 Vue3 项目。
|
28天前
|
JavaScript 前端开发 持续交付
构建现代Web应用:Vue.js与Node.js的完美结合
【10月更文挑战第22天】随着互联网技术的快速发展,Web应用已经成为了人们日常生活和工作的重要组成部分。前端技术和后端技术的不断创新,为Web应用的构建提供了更多可能。在本篇文章中,我们将探讨Vue.js和Node.js这两大热门技术如何完美结合,构建现代Web应用。
26 4
下一篇
无影云桌面