最近很火的Vue Vine是如何实现一个文件中写多个组件

前言

在今年的Vue Conf 2024大会上,沈青川大佬(维护Vue/Vite 中文文档)在会上介绍了他的新项目Vue Vine。Vue Vine提供了全新Vue组件书写方式, 主要的卖点是可以在一个文件里面写多个vue组件 。相信你最近应该看到了不少介绍Vue Vine的文章,这篇文章我们另辟蹊径来讲讲Vue Vine是如何实现在一个文件里面写多个vue组件。

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

看个demo

我们先来看普通的vue组件, about.vue 代码如下:

<template>
  <h3>i am about page</h3>
</template>

<script lang="ts" setup></script>

我们在浏览器中来看看编译后的js代码,代码如下:

const _sfc_main = {};

function _sfc_render(_ctx, _cache) {
  return _openBlock(), _createElementBlock("h3", null, "i am about page");
}

_sfc_main.render = _sfc_render;
export default _sfc_main;

从上面的代码可以看到普通的vue组件编译后生成的js文件会 export default 导出一个 _sfc_main 组件对象,并且这个组件对象上面有个大名鼎鼎的 render 函数。父组件只需要import导入子组件里面 export default 导出的 _sfc_main 组件对象就可以啦。

搞清楚普通的vue组件编译后是什么样的,我们接着来看一个Vue Vine的demo,Vue Vine的组件必须以 .vine.ts 结尾, home.vine.ts 代码如下:

async function ChildComp() {
  return vine`
    <h3>我是子组件</h3>
  `;
}

export async function Home() {
  return vine`
  <h3>我是父组件</h3>
    <ChildComp  />
  `;
}

如果你熟悉react,你会发现Vine 组件函数和react比较相似,不同的是return的时候需要在其返回值上显式使用 vine 标记的模板字符串。

在浏览器中来看看 home.vine.ts 编译后的代码,代码如下:

export const ChildComp = (() => {
  const __vine = _defineComponent({
    name: "ChildComp",
    setup(__props, { expose: __expose }) {
      // ...省略
    },
  });

  function __sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
    return _openBlock(), _createElementBlock("h3", null, "我是子组件");
  }

  __vine.render = __sfc_render;
  return __vine;
})();

export const Home = (() => {
  const __vine = _defineComponent({
    name: "Home",
    setup(__props, { expose: __expose }) {
      // ...省略
    },
  });

  function __sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
    return (
      _openBlock(),
      _createElementBlock(
        _Fragment,
        null,
        [_hoisted_1, _createVNode($setup["ChildComp"])],
        64,
      )
    );
  }

  __vine.render = __sfc_render;
  return __vine;
})();

从上面的代码可以看到组件 ChildComp Home 编译后是一个立即调用函数,在函数中return了 __vine 组件对象,并且这个组件对象上面也有render函数。想必细心的你已经发现了在同一个文件里面定义的多个组件经过编译后,从常规的export default导出一个默认的vue组件对象变成了export导出多个具名的vue组件对象。

接下来我们将通过debug的方式带你搞清楚Vue Vine是如何实现一个文件内导出多个vue组件对象。

createVinePlugin 函数

我们遇见的第一个问题是需要找到从哪里开始着手debug?

来看一下官方文档是接入vue vine的,如下图:

从上图中可以看到vine是一个vite插件,以插件的形式起作用的。

现在我们找到了一切起源就是这个 VineVitePlugin 函数,所以我们需要给 vite.config.ts 文件中的 VineVitePlugin 函数打个断点。如下图:

接下来我们需要启动一个debug终端。这里以 vscode 举例,打开终端然后点击终端中的 + 号旁边的下拉箭头,在下拉中点击 Javascript Debug Terminal 就可以启动一个 debug 终端。

在debug终端执行 yarn dev ,在浏览器中打开对应的页面,比如: http://localhost:3333/ 。此时代码将会停留在我们打的断点 VineVitePlugin 函数调用处,让代码走进 VineVitePlugin 函数,发现这个函数实际定义的名字叫 createVinePlugin ,在我们这个场景中简化后的 createVinePlugin 函数代码如下:

function createVinePlugin() {
  return {
    name: "vue-vine-plugin",
    async resolveId(id) {
      // ...省略
    },
    async load(id) {
      // ...省略
    },
    async transform(code, id) {
      const { fileId, query } = parseQuery(id);
      if (!fileId.endsWith(".vine.ts") || query.type === QUERY_TYPE_STYLE) {
        return;
      }
      return runCompileScript(code, id);
    },
    async handleHotUpdate(ctx) {
      // ...省略
    }
  };
}

从上面的代码可以看到插件中有不少钩子函数, vite 会在对应的时候调用这些插件的钩子函数,比如当 vite 解析每个模块时就会调用 transform 等函数。

transform 钩子函数的接收的第一个参数为 code ,是当前文件的code代码字符串。第二个参数为id,是当前文件路径,这个路径可能带有query。

transform 钩子函数中先调用 parseQuery 函数根据当前文件路径拿到去除query的文件路径,以及query对象。

!fileId.endsWith(".vine.ts") 的意思是判断当前文件是不是 .vine.ts 结尾的文件,如果不是则不进行任何处理,这也就是为什么文档中会写Vue Vine只支持 .vine.ts 结尾的文件。

query.type === QUERY_TYPE_STYLE 的意思是判断当前文件是不是css文件,因为同一个vue文件会被处理两次,第一次处理时只会处理template和script这两个模块,第二次再去单独处理style模块。

transform 钩子函数的最后就是调用 runCompileScript(code, id) 函数,并且将其执行结果进行返回。

runCompileScript 函数

接着将断点走进 runCompileScript 函数,在我们这个场景中简化后的 runCompileScript 函数代码如下:

const runCompileScript = (code, fileId) => {
  const vineFileCtx = compileVineTypeScriptFile(
    code,
    fileId,
    compilerHooks,
    fileCtxMap,
  );

  return {
    code: vineFileCtx.fileMagicCode.toString(),
  };
};

从上面的代码可以看到首先会以 code (当前文件的code代码字符串)为参数去执行 compileVineTypeScriptFile 函数,这个函数会返回一个 vineFileCtx 上下文对象。这个上下文对象的 fileMagicCode.toString(), 就是前面我们在浏览器中看到的最终编译好的js代码。

compileVineTypeScriptFile 函数

接着将断点走进 compileVineTypeScriptFile 函数,在我们这个场景中简化后的 compileVineTypeScriptFile 函数代码如下:

function compileVineTypeScriptFile(
  code: string,
  fileId: string,
  compilerHooks: VineCompilerHooks,
  fileCtxCache?: VineFileCtx,
) {
  const vineFileCtx: VineFileCtx = createVineFileCtx(
    code,
    fileId,
    fileCtxCache,
  );
  const vineCompFnDecls = findVineCompFnDecls(vineFileCtx.root);
  doAnalyzeVine(compilerHooks, vineFileCtx, vineCompFnDecls);
  transformFile(
    vineFileCtx,
    compilerHooks,
    compilerOptions?.inlineTemplate ?? true,
  );

  return vineFileCtx;
}

在执行 compileVineTypeScriptFile 函数之前,我们在debug终端来看看接收的第一个参数 code ,如下图:

从上图中可以看到第一个参数 code 就是我们写的 home.vine.ts 文件中的源代码。

createVineFileCtx 函数

接下来看第一个函数调用 createVineFileCtx ,这个函数返回一个 vineFileCtx 上下文对象。将断点走进 createVineFileCtx 函数,在我们这个场景中简化后的 createVineFileCtx 函数代码如下:

import MagicString from 'magic-string'

function createVineFileCtx(code: string, fileId: string) {
  const root = babelParse(code);
  const vineFileCtx: VineFileCtx = {
    root,
    fileMagicCode: new MagicString(code),
    vineCompFns: [],
    // ...省略
  };
  return vineFileCtx;
}

由于Vue Vine中的组件和react相似是组件函数,组件函数中当然全部都是js代码。既然是js代码那么就可以使用babel的 parser 函数将组件函数的js代码编译成AST抽象语法树,所以第一步就是使用 code 去调用babel的 parser 函数生成AST抽象语法树,然后赋值给 root 变量。

我们在debug终端来看看得到的AST抽象语法树是什么样的,如下图:

从上图中可以看到在body数组中有两项,分别对应的就是 ChildComp 组件函数和 Home 组件函数。

接下来就是return返回一个 vineFileCtx 上下文对象,对象上面的几个属性我们需要讲一下。

  • root :由 .vine.ts 文件转换后的AST抽象语法树。

  • vineCompFns :数组中存了文件中定义的多个vue组件,初始化时为空数组。

  • fileMagicCode :是一个由 magic-string 库new的一个对象,对象中存了在编译时生成的js代码字符串。

    magic-string 是由svelte的作者写的一个库,用于处理字符串的 JavaScript 库。它可以让你在字符串中进行插入、删除、替换等操作,在编译时就是利用这个库生成编译后的js代码。

    toString 方法返回经过处理后的字符串,前面的 runCompileScript 函数中就是最终调用 vineFileCtx.fileMagicCode.toString() 方法返回经过编译阶段处理得到的js代码。

findVineCompFnDecls 函数

我们接着来看 compileVineTypeScriptFile 函数中的第二个函数调用 findVineCompFnDecls

function compileVineTypeScriptFile(
  code: string,
  fileId: string,
  compilerHooks: VineCompilerHooks,
  fileCtxCache?: VineFileCtx,
) {
  // ...省略
  const vineCompFnDecls = findVineCompFnDecls(vineFileCtx.root);
  // ...省略
}

通过前一步我们拿到了一个 vineFileCtx 上下文对象, vineFileCtx.root 中存的是编译后的AST抽象语法树。

所以这一步就是调用 findVineCompFnDecls 函数从AST抽象语法树中提取出在 .vine.ts 文件中定义的多个vue组件对象对应的Node节点。我们在debug终端来看看组件对象对应的Node节点组成的数组 vineCompFnDecls ,如下图:

从上图中可以看到数组由两个Node节点组成,分别对应的是 ChildComp 组件函数和 Home 组件函数。

doAnalyzeVine 函数

我们接着来看 compileVineTypeScriptFile 函数中的第三个函数调用 doAnalyzeVine

function compileVineTypeScriptFile(
  code: string,
  fileId: string,
  compilerHooks: VineCompilerHooks,
  fileCtxCache?: VineFileCtx,
) {
  // ...省略
  doAnalyzeVine(compilerHooks, vineFileCtx, vineCompFnDecls);
  // ...省略
}

经过上一步的处理我们拿到了两个组件对象的Node节点,并且将这两个Node节点存到了 vineCompFnDecls 数组中。

由于组件对象的Node节点是一个标准的AST抽象语法树的Node节点,并不能清晰的描述一个vue组件对象。所以接下来就是调用 doAnalyzeVine 函数遍历组件对象的Node节点,将其转换为能够清晰的描述一个vue组件的对象,将这些vue组件对象组成数组塞到 vineFileCtx 上下文对象的 vineCompFns 属性上。

我们在debug终端来看看经过 doAnalyzeVine 函数处理后生成的 vineFileCtx.vineCompFns 属性是什么样的,如下图:

从上图中可以看到 vineCompFns 属性中存的组件对象已经能够清晰的描述一个vue组件,上面有一些我们熟悉的属性 props slots 等。

transformFile 函数

我们接着来看 compileVineTypeScriptFile 函数中的第四个函数调用 transformFile

function compileVineTypeScriptFile(
  code: string,
  fileId: string,
  compilerHooks: VineCompilerHooks,
  fileCtxCache?: VineFileCtx,
) {
  // ...省略
  transformFile(
    vineFileCtx,
    compilerHooks,
    compilerOptions?.inlineTemplate ?? true,
  );
  // ...省略
}

经过上一步的处理后在 vineFileCtx 上下文对象的 vineCompFns 属性数组中已经存了一系列能够清晰描述vue组件的对象。

在前面我们讲过了 vineFileCtx.vineCompFns 数组中存的对象能够清晰的描述一个vue组件,但是对象中并没有我们期望的render函数、setup函数等。

所以接下来就需要调用 transformFile 函数,遍历上一步拿到的 vineFileCtx.vineCompFns 数组,将所有的vue组件转换成对应的立即调用函数。在每个立即调用函数中都会return一个 __vine 组件对象,并且这个 __vine 组件对象上都有一个render属性。

之所以包装成一个立即调用函数,是因为每个组件都会生成一个名为 __vine 组件对象,所以才需要立即调用函数将作用域进行隔离。

我们在debug终端来看看经过 transformFile 函数处理后拿到的js code代码字符串,如下图:

从上图中可以看到此时的js code代码字符串已经和我们之前在浏览器中看到的编译后的代码一模一样了。

总结

Vue Vine是一个vite插件,vite解析每个模块时都会触发插件的 transform 钩子函数。在钩子函数中会去判断当前文件是否以 .vine.ts 结尾的,如果不是则return。

transform 钩子函数中会去调用 runCompileScript 函数, runCompileScript 函数并不是实际干活的地方,而是去调用 compileVineTypeScriptFile 函数。

compileVineTypeScriptFile 函数中先new一个 vineFileCtx 上下文对象,对象中的 root 属性存了由 .vine.ts 文件转换成的AST抽象语法树。

接着就是调用 findVineCompFnDecls 函数从AST抽象语法树中找到组件对象对应的Node节点。

由于Node节点并不能清晰的描述一个vue组件,所以需要调用 doAnalyzeVine 函数将这些Node节点转换成能够清晰描述vue组件的对象。

最后就是遍历这些vue组件对象将其转换成立即调用函数。在每个立即调用函数中都会return一个 __vine 组件对象,并且这个 __vine 组件对象上都有一个render属性。

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

标签:游戏攻略