通过 Vite 运行手写的模板编译器

前言

我们在前一篇文章中已经实现了对 template 模板中的内容进行解析生成模板 AST,那么接下来就是将模板 AST 转换(transform)成中间表示(Intermediate Representation,简称IR),最后根据中间表示(IR)生成 JavaScript 代码,即渲染函数。

例如我们有以下模板内容:

html 复制代码
<template>
  <div>掘金签约作者:Cobyte</div>
</template>

在我们之前自己所写的 Vue Vapor 框架中,因为还没有实现编译功能,需要自己进行手写编译后的代码,那么实现上述模板的功能需要进行以下的编码:

javascript 复制代码
import { template } from "../runtime-vapor/src"
const App = {
    render() {
        const _tmpl$ = template('<div>掘金签约作者:Cobyte</div>')
        const el = _tmpl$()
        return el
    }
}

而其中 render 部分的代码就是我们要生成的代码,我们可以清楚看到 render 函数本质上是 JavaScript 函数代码,所以我们需要将模板 AST 转换成能描述上述渲染函数的中间表示(IR),再通过中间表示(IR)生成 JavaScript 代码。我们已经通过 parse 函数生成模板 AST 了,接下来我们可以再封装一个 transform 函数来实现将模板 AST 转换成中间表示(IR),最后我们再封装一个 generate 函数来实现将中间表示(IR)生成渲染函数了。

而上述对模板编译生成渲染函数的过程又可以使用代码进行更直观的表达:

javascript 复制代码
// 通过 parse 函数生成模板 AST
const ast = parse(template)
// 通过 transform 函数生成 IR(Intermediate Representation)
const ir = transform(ast)
// 通过 generate 函数生成渲染函数代码
const code = generate(ir)

而最后一步代码生成本质上就是一个字符串拼接的艺术。而这整个流程跟普通 Vue3 的编译流程也是一样的,如果你之前有对 Vue3 编译原理有第一了解,那么 Vue Vapor 的编译也应该信手沾来。最后我们再通过 Vite 插件 @vitejs/plugin-vue 配置我们自己手写的模板编译器实现对模板的编译。

代码生成流程

一般其他讲 Vue 编译模块的时候会按顺序先讲 transform 部分再讲代码生成部分,但一般在实践当中,我们实现一个功能,可以先快速实现一个具体的功能,然后再通过重构进行抽象化。所以我们要实现一个具体的 generate 函数,很简单。代码如下:

javascript 复制代码
export function generate(ast) {
  const preamble = ''
  const code = `
  import { template } from "../runtime-vapor/src"
  \nexport function render() {
    const _tmpl$ = template('<div>掘金签约作者:Cobyte</div>')
    const el = _tmpl$()
    return el
  }
  `
  return {
    code,
    ast,
    preamble,
  }
}

而这里为什么要写成上面这个样子呢?因为 Vue3 编译之后的 render 函数的代码组织方式存在两种模式,一种是 Function 模式,Module 模式,Function 模式就是在浏览器直接使用 Vue 运行时+编译器版本,在这个环境下 Vue 编译器会把模板编译成 Function 模式。

Module 模式的场景则是当使用现代前端构建工具(如webpack、Vite等)构建 Vue3 项目时,.vue 文件会被编译成多个模块。这些模块可能包括:

  • 渲染函数模块:包含组件模板编译后的渲染函数。
  • 脚本模块:包含组件的 <script> 部分编译后的 JavaScript 代码。
  • 样式模块:包含组件的 <style> 部分编译后的CSS代码。

这些模块通过构建工具进行打包处理,最终生成浏览器可以直接执行的 JavaScript 文件。在这个过程中,渲染函数被封装在模块中,并通过模块系统导入到组件的其他部分或项目的其他位置。

因为我们这里是通过 Vite 插件 @vitejs/plugin-vue 配置我们自己手写的模板编译器实现对模板的编译,所以我们这里是需要使用到构建工具 Vite 的,所以我们上述的生成的 render 函数是 Module 模式,即函数前面要加 export 关键词,而关键词 export 还加一个换行转义字符,是因为让生成的代码格式更好看。并且后续还会在 @vitejs/plugin-vue 中通过 \nexport 关键字进行匹配修改,这是属于 .vue 文件如何通过 @vitejs/plugin-vue 插件进行编译的部分内容了,这里暂且不做过多深入的探讨,后续讲到了再继续深入说明,在这里我们稍微介绍一下为什么要这样做就可以了。

我们在开头讲的对模板编译生成渲染函数的过程,通过 parse 函数生成模板 AST 的 parse 函数我们在前面已经实现了,最后通过 generate 函数生成渲染函数代码的 generate 函数,我们上述也初步实现了。而通过 transform 函数生成 IR(Intermediate Representation)的 transform 函数,我们可以先写一个占位的空函数。

javascript 复制代码
function transform(ast) {
  return ast
}

最后我们可以将这三个函数的运行过程封装成一个 compile 函数,因为将 Vue 模板编译为 render 函数最后是通过 compile 函数实现的。

javascript 复制代码
export function compile(template) {
  const ast = baseParse(template)
  const ir = transform(ast)
  return generate(ir)
}

我们将之前写的 Parse 部分的代码设置到我们项目的 ./compiler-core/src/index.js 文件中,而上述的代码则设置到 ./compiler-vapor/src/index.js 文件中。至此我们的最简单的一个 Vue Vapor 的模板编译器就算完成了,接下来我们就需要通过 Vite 去运行我们写的模板编译器。

设置 Vite 编译配置

首先我们需要安装 @vitejs/plugin-vue 依赖:

sql 复制代码
pnpm add @vitejs/plugin-vue --save-dev

然后我们在根目录下创建一个 vite.config.js 文件,设置如下:

javascript 复制代码
import { defineConfig } from 'vite'
import Vue from '@vitejs/plugin-vue'
import * as CompilerVapor from './compiler-vapor/src/index'

export default defineConfig({
  plugins: [
    Vue({
      template: {
        compiler: CompilerVapor
      }
    }),
  ]
})

其中 @vitejs/plugin-vue 插件的 template 选项的 compiler 设置为我们自己编写的编译器,这样 Vite 在编译 .vue 文件的时候,遇到 template 部分就使用我们自己编写的编译器进行编译。

在 Vite 中运行自定义编译器

接着我们在 src 目录下创建一个 App.vue 文件,内容如下:

html 复制代码
<template><div>i am Cobyte ~</div></template>
<script setup>
console.log('编译成功')
</script>

然后在 src/main.js 文件中引入我们写的 App.vue 文件,内容如下:

javascript 复制代码
import { createApp } from '../runtime-vapor/src'
import App from './App.vue'

const app = createApp(App)
app.mount('#app')

最后我们启动我们的项目看看运行结果。

启动项目:

复制代码
pnpm dev

渲染内容如下:

我们现在渲染的内容还不是 App.vue 文件的内容,因为我们在生成代码的时候写死了:

所以接下来我们就需要通过编译 App.vue 文件的内容,并且动态地把它渲染出来。

抽象化 generate 函数

我们观察上面写死的 generate 函数,其中需要动态变化的是 template 函数执行的时候传进去的模板字符串:'<div>掘金签约作者:Cobyte</div>'

现在我们已经把 template 中的内容编译成了 AST 数据,具体如下:

我们现在需要做的就是把上述的 AST 数据再次转换成一个 HTML 模板字符串,即:'<div>i am Cobyte ~</div>'。我们把转换后的 HTML 模板字符串存储在 generate 函数传进来的 ast 的 template 属性上,这样我们的 generate 函数则可以做以下修改。

javascript 复制代码
export function generate(ast) {
    let preamble = 'import { template } from "../runtime-vapor/src"\n'
    preamble += `const _tmpl$ = template('${ast.template}')`
    const code = preamble + `
    \nexport function render() {
        const el = _tmpl$()
        return el
    }
    `
    return {
      code,
      ast,
      preamble,
    }
}

我们同时把生成模板 DOM 的代码也放到 render 函数外面,这样就不用每次执行 render 函数的时候需要重复生成模板 DOM 实例。

接下来我们去实现把 AST 转换成 HTML 模板字符串,也就是 transform 部分的实现。

transform 部分的实现

我们上面已经知道 template 中的内容编译成了 AST 数据,再来看一下它的数据结构:

如果在现实开发中给你这么一个数据结构,你怎么处理成一个 HTML 模板字符串,即:'<div>i am Cobyte ~</div>'呢?

首先我们来观察一下这个 AST 数据的结构,它是一个数组且是树的结构,其次每一个元素的 type 属性都标记着具体的数据类型,这样我们就可以通过深度遍历来处理每一个元素,在处理具体元素的时候,我们又可以通过元素上的 type 属性的值进行不同的处理。

首先我们对 transform 代码设计如下:

javascript 复制代码
const IRNodeTypes = {
  ROOT: 1, // 表示根节点的类型,在后续的迭代中会有更多的节点类型
}
export function transform(root) {
  // 调用 transformChildren 函数处理 root 的子节点,将处理 AST 数据的子节点封装成一个函数
  const template = transformChildren(root.children)
  // 回一个对象,其中包含生成的模板字符串和节点类型(这里是 ROOT)
  return {
    type: IRNodeTypes.ROOT, // 表示根类型
    template, // 因为我们在上述 generate 部分就设计了要由 transform 转换之后的返回的数据当中的 template 属性就是 HTML 模板
  }
}

因为我们在上述 generate 部分就设计了要由 transform 转换之后的返回的数据当中的 template 属性就是 HTML 模板,所以我们在返回的数据结构当中就必须返回一个带 template 属性的数据。而 template 属性的内容就是 AST 子节点的转换处理结果。其次我们设计了中间表示的节点类型:IRNodeTypes,目前只有一个表示根节点的类型,在后续的迭代中会有更多的节点类型。最后 transform 函数返回的对象,结构为生成的模板字符串和节点类型(这里是 ROOT)。

在上述 transform 函数中我们将处理 AST 数据的子节点封装成一个函数:transformChildren,接下来我们实现这个函数吧。代码如下:

javascript 复制代码
// 将处理 AST 数据的子节点封装成一个函数
function transformChildren(children) {
  let template = ''
  // 遍历每个子节点并根据其类型进行处理
  children.forEach(node => {
    switch (node.type) {
      case NodeTypes.ELEMENT: {
        // 对于 ELEMENT 类型的节点,调用 transformElement 函数处理,并将结果追加到 template 字符串中
        template += transformElement(node)
        break
      }
      case NodeTypes.TEXT: {
        // 对于 TEXT 类型的节点,直接将其内容追加到 template 字符串中
        template += node.content
        break
      }
    }
  })
  return template
}

那么正如我们前面所说的这个 AST 数据的结构,它是一个数组且是树的结构,其次每一个元素的 type 属性都标记着具体的数据类型,这样我们就可以通过深度遍历来处理每一个元素,在处理具体元素的时候,我们又可以通过元素上的 type 属性的值进行不同的处理。

我们这里具体就是遍历每个子节点并根据其类型进行处理

  • 对于 ELEMENT 类型的节点,调用 transformElement 函数处理,并将结果追加到 template 字符串中。

  • 对于 TEXT 类型的节点,直接将其内容追加到 template 字符串中。

我们目前就处理了这两种类型,在后续的迭代当中我们再处理其他类型。

接着我们来实现处理元素节点的 transformElement 函数,代码如下:

javascript 复制代码
// 处理元素节点
function transformElement(node) {
  // 提取节点的 tag 和 children 属性
  const { tag, children } = node
  // 生成元素的开标签
  let template = `<${tag}`
  // todo 处理属性
  template += `>`

  if (children.length > 0) {
    // 子节点的转换结果
    template += transformChildren(children)
  }
  // 最后生成闭标签
  template += `</${tag}>`

  return template
}

我们目前对元素节点的处理是很简单的,具体就是提取节点的 tagchildren 属性,生成元素的开标签,中间插入子节点的转换结果,最后生成闭标签。

至此我们 transform 生成中间表示部分代码也已经实现了,我们可以重新启动测试代码,看看我们的运行结果了。

新的运行结果如下:

我们发现我们成功把 App.vue 文件中的 template 内容渲染到页面上了。

总结

在这篇文章中,我们详细探讨了如何从模板 AST(抽象语法树)转换到中间表示(IR),并最终生成渲染函数的代码。这一过程是 Vue 编译器的核心部分,它使得我们能够将 Vue 模板编译为高效的 JavaScript 代码,从而在浏览器中快速渲染页面。

我们首先介绍了代码生成的基本流程,并实现了一个简单的 generate 函数,该函数最初是硬编码的,用于生成一个特定的渲染函数。然后,我们逐步改进这个函数,使其能够接受从 transform 函数生成的中间表示,并动态地生成渲染函数代码。

接下来,我们实现了 transform 函数,该函数负责将模板 AST 转换为中间表示。我们设计了一个简单的中间表示结构,并实现了对元素节点和文本节点的处理。通过递归地遍历 AST 的子节点,我们能够构建一个表示模板结构的字符串。

最后,我们将这些函数集成到一个完整的编译流程中,并通过 Vite 配置了我们自定义的编译器。在测试了编译后的应用程序后,我们成功地看到了从 App.vue 文件中模板内容渲染到页面上的结果。

这一系列的步骤展示了 Vue 编译器的核心原理,并为我们进一步扩展和优化编译器提供了基础。在未来的迭代中,我们可以添加对更多节点类型的支持,如指令、插值表达式和事件绑定等,以丰富我们的 Vue Vapor 框架的功能。

相关推荐
phltxy1 小时前
LangChain_v1_Agent快速开发和更新说明
前端·javascript·langchain
Hyyy1 小时前
Electron多进程
前端
swipe1 小时前
08|(前端转全栈)一个商品详情接口背后的完整链路:HTTP、Redis、MySQL 与 JSON
前端·后端·全栈
用户298698530142 小时前
React 前端处理 Excel 工作表复制的技术实践
javascript·react.js·excel
梦想很大很大2 小时前
Synclan:一个基于局域网的即时通讯式文件传输工具
前端·后端·开源
光影少年2 小时前
RN原生交互 & 桥接
前端·javascript·react native·react.js·前端框架
张人玉2 小时前
基于 Vue 3 + ECharts + Express + SQLite 构—— LDA 模型的新闻舆论主题识别与情感分析系统
vue.js·echarts·express
东风破_2 小时前
React 状态更新为什么不是立即赋值?从 useState、状态快照到更新队列
前端
用户526835677902 小时前
云原生运维实践:构建 Prometheus + 自定义 Webhook 的硬件声光告警闭环
javascript