【前端进阶】使用 Vue3 的 complier-core 玩转模版编译

1,754 阅读6分钟

前言 📖

近期,在团队内推自动化表单,主要是为了去掉后台项目中繁多的表单代码。众所周知,表单一直都是后台代码的一个痛点,因为它的代码就是一个字 “长”...所以,作为一名 21 世纪的前端工程师,我们要时刻反省如何提效(能不写代码就不写代码)。

自动化表单的主要设计理念是围绕一个渲染器,通过配置对象来生成对应的表单。那么,这个时候就遇到了一个问题,对象和 UI 之间是脱离的,这就好比很多人习惯用 template 的方式写「Vue」,而不是更好性能的 render 函数,因为前者更加语义化~

那么,有办法实现语义化吗?答案是:当然可以。我们可以规定一个简易的「模版」语法,通过编译「模版」生成对应的 AST 抽象语法树,它的本质也是对象。那么,这个时候刚好“牛头对上马嘴了”,渲染器再基于这个 AST 来渲染表单,从而完成「模版」到 AST 到表单的转化过程~

并且,提及「模版」语法,我想大家立马会想起「Vue」的「模版」(template)语法。所以,今天我们也将借助「Vue」的核心编译能力 compiler-core 来玩转模版编译!

本次文章将分为以下三个部分进行:

  • 了解「Monorepo」以及它在 Vue3 中的运用。
  • 了解 compiler-core 的内部运行原理,掌握模版编译基础。
  • 开搞,玩转模版编译(乞丐版国际化)。

正文开始~

一、Monorepo 以及它在 Vue3 中的运用 👏

首先,我们先来了解一下什么是「Monorepo」,维基百科上对它的介绍:

———— In revision control systems, a monorepo is a software development strategy where code for many projects is stored in the same repository.

简单理解,「Monorepo」指一种将多个项目放到一个仓库的一种管理项目的策略。当然,这只是概念上的理解。而对于实际开发中的场景,「Monorepo」的使用通常是通过 yarn 的 workspaces 工作空间,又或者是 lerna 这种第三方工具库来实现。使用「Monorepo」的方式来管理项目会给我们带来以下这些好处:

  • 只需要一个仓库,就可以便捷地管理多个项目。
  • 可以管理不同项目中的相同第三方依赖,做到依赖的同步更新。
  • 可以使用其他项目中的代码,清晰地建立起项目间的依赖关系。

「Vue3」正是采用的 yarn 的 workspaces 工作空间的方式管理整个项目,而 workspaces 的特点就是在 package.json 中会有这么两句不同于普通项目的声明:

{
    "private": true,
    "workspaces": [
        "packages/*"
    ]
}

可以看到,packages 文件目录下根据「Vue3」实现所需要的能力划分了不同的项目。并且,这里的 compiler-core 目录则是我们本小节要介绍的 compiler-core。所以,packages 下的项目结构会是这样:

那么,了解什么是「Monorepo」以及其在「Vue3」中的运用后,接下来我们开始了解 compiler-core 的内部运行原理~

二、compiler-core 的内部运行原理 🔧

compiler-core 负责「Vue3」中核心编译相关的能力,这包括解析(parse)模板、转化 AST 抽象语法树(transform)、代码生成(generate)等三个过程,它们之间的工作流如下图所示:

可以看到,「Vue3」会先解析模版生成对应的 AST 抽象语法树,其次再 transform 抽象语法树,对 AST 做一些特殊处理,例如打上 shapeFlagpatchFlag 等操作,最后,generate 根据抽象语法树来生成对应的可执行代码,即 render 函数。

不知道什么是 shapeFlagpatchFlag 的同学可以看这两篇文章:《compile 和 runtime 结合的 patch 过程》《从编译过程,理解静态节点提升》

那么,在「Vue3」源码层面,它们都是运行在 baseCompiler 方法中:

// packages/compiler-core/src/compiler.ts
export function baseCompile(
  template: string | RootNode,
  options: CompilerOptions = {}
): CodegenResult {
  ...
  const ast = isString(template) ? baseParse(template, options) : template
  ...
  transform(
    ast,
    extend({}, options, {
      prefixIdentifiers,
      nodeTransforms: [
        ...nodeTransforms,
        ...(options.nodeTransforms || []) // user transforms
      ],
      directiveTransforms: extend(
        {},
        directiveTransforms,
        options.directiveTransforms || {} // user transforms
      )
    })
  )

  return generate(
    ast,
    extend({}, options, {
      prefixIdentifiers
    })
  )
}

可以看到 baseCompiler 进行模版编译相关操作的也就是 baseParsetransformgenerate 这三个方法,它们也分别对应着上面所说的三个阶段。那么,接下来我们将会借助这三者来玩转的模版编译!

三、开搞,玩转模版编译 🚀

既然要玩转模版编译,那么我们就搞点有趣的(骚操作)。我们来实现一个栗子,通过它渲染模版,我们会对文字内容做替换操作,即乞丐版国际化。

3.1 乞丐版国际化 🚄

我们定义一个函数它会根据 key 返回指定的语言 lang 下的文字:

function getWords(key, lang = "EN") {
   const map = new Map([
        ["CN", {
           hi: "你好",
         }],
        ["EN", {
           hi: "hello"
        }]
    ])
    
    return map.get(lang)[key]
}

然后,我们需要对「模版」中出现的 hi 字符串转化为特殊语言下的文字。这里我们需要借助 compiler-core 的提供的四个方法:

  • baseParse 解析「模版」生成 AST 抽象语法树。
  • getBaseTransformPreset 用于创建基础的 transform 函数(需要注意它是必须的)。
  • transform 转化 AST 抽象语法树,可以实现对 AST 节点的替换、删除操作。
  • generate 根据转化后的 AST 抽象语法树生成 render 函数。
const compiler = require("@vue/compiler-core");

function render(template, lang = "CN") {
  const ast = compiler.baseParse(template)
  const transform = (rootNode) => {
      if (rootNode.type === 2) {
          rootNode.content = getWords(rootNode.content)
      }
  }
  const prefixIdentifiers = true

  const [nodeTransforms, directiveTransforms] = compiler.getBaseTransformPreset(
      prefixIdentifiers
    )
  compiler.transform(ast, {
      prefixIdentifiers,
      nodeTransforms: [
          ...nodeTransforms,
          myTransfrom
      ],
  })

  const render = compiler.generate(ast)
  
  return render.code
}

3.2 开箱使用,体验整个过程 😍

我们直接定义一个模版字符串,并将该模版字符串作为参数传给到上面定义好的 render 函数。

const template = `<div>hi</div>`
const renderStr = render(template)

这里我们打印一下生成的 render 函数字符串 renderStr

 'const _Vue = Vue\n' +
  '\n' +
  'return function render(_ctx, _cache) {\n' +
  '  with (_ctx) {\n' +
  '    const { createVNode: _createVNode, openBlock: _openBlock, createBlock: _createBlock } = _Vue\n' +
  '\n' +
  '    return (_openBlock(), _createBlock("div", null, "你好"))\n' +
  '  }\n' +
  '}'

然后,我们执行这一段代码生成的 HTML 会是这样:

<div>你好<div>

如果,我们在调用前面定义的 render 函数时,传入的 langEN,那么输出的 HTML 的会是这样:

<div>hello<div>

结语 🔚

文中介绍的使用 compiler-core 玩转模版编译的栗子只是极简的,如果要具体要具体到业务场景,那就要 fork 一份 compiler-core 来处理一些自定义的操作,这样生成的 AST 才更加贴合我们自己的需求,这期间应该需要一些时间去理解 compiler-core 中更加底层的东西。

所以,这也是为什么文章标题是【前端进阶】的缘故,因为本次介绍的内容涉及到编译的场景,它的最佳演变是形成一种自己规定「模版语法」,你也可以称之为简易版的「DSL」~最后,如果文章中存在表达不当或错误的地方,欢迎各位同学提 Issue~

❤️ 爱心三连击

写作不易,可以的话麻烦点个赞,这会成为我坚持写作的动力,奥力给!!!

我是五柳,喜欢创新、捣鼓源码,专注于 Vue3 源码、Vite 源码、前端工程化等技术领域分享,欢迎关注我的「微信公众号:Code center」。