Vue 文件是如何被转换并渲染到页面的?

6,073 阅读5分钟

以前常常觉得,Vue 文件(单文件组件,Single File Component,SFC)的处理非常复杂,以至于很久一段时间,都不敢接触它,直到我看了 @vite/plugin-vue 的源码,才发现,这个过程并没有多复杂。因为 Vue 已经提供了 SFC 的编译能力,我们只需要站在巨人的肩膀上,简单地组合利用这些能力即可。

本文会用一个极其简单的例子,来说明如何处理一个 Vue 文件,并将其展示到页面中。在这个过程中,介绍 Vue 提供的编译能力,以及如何组合利用这些能力。

学完之后,你会明白 Vue 文件是如何一步一步被转换成 js 代码的,也能理解 vite、rollup 这些打包工具,是如何对 Vue 文件进行打包的。

本文用到的项目,在该 Github 仓库中,喜欢自己动手的同学,可以下载下来玩玩

一个简单的例子

有一个 main.vue 文件如下:

<template>
  <div class="message">{{ message }}</div>
</template>

<script>
import { ref } from "vue";

export default {
  name: "Main",
  setup() {
    const message = ref("Main");
    return {
      message,
    };
  },
};
</script>

<style scoped>
.message {
  font-size: 60px;
  font-weight: 900;
}
</style>

接下来,我会一步一步带大家手动处理这个 Vue 文件,并将其展示到页面中。

我们首先来了解一下,如果不使用 Vue 文件,不进行编译,要如何使用 Vue

在浏览器直接使用 Vue

这是 Vue 官方文档提供的一个例子

<!DOCTYPE html>
<html lang="en">
<head>
    <script src="https://unpkg.com/vue@next"></script>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div id="app"></div>
</body>
<script>
    const Counter = {
        data() {
            return {
                counter: 0
            }
        },
        render(){
            return Vue.h('h1','hello-world')
        }
    }

    Vue.createApp(Counter).mount('#app')
</script>
</html>

利用 script 标签全局加载 Vue,通过全局变量 window.Vue 来获取 Vue 模块。然后定义组件,创建 Vue 实例,并挂载到对应的 DOM。

页面效果如下:

image-20220628203903756

上面的例子,是使用 js 来定义组件的。

那么如果我们用 Vue SFC 来定义组件,就需要将 Vue 文件,编译成 js 对象形式的 Vue 组件对象(像上述例子一样)

Vue 文件主要由 3 部分组成:

  • script 脚本
  • template 模板,可选
  • style 样式,可选

要分别将这三部分,转换成 js 并组合成一个 Vue 对象,浏览器才能正确的运行

如何编译 Vue SFC?

Vue 提供了 @vue/compiler-sfc,专门用于 Vue 文件的预编译。下面我会一步一步演示 @vue/compiler-sfc 的使用方法。

解析 Vue 文件

在进行处理之前,首先要读取到代码的字符串

import { readFile, writeFile } from "fs-extra";
const file = await readFile("./src/main.vue", "utf8");

然后用 @vue/compiler-sfc 提供的解析器,对代码进行解析

import { parse } from "@vue/compiler-sfc";
const { descriptor, error } = parse(file);

这个是 Vue 文件的内容

<template>
  <div class="message">{{ message }}</div>
</template>

<script>
import { ref } from "vue";

export default {
  name: "Main",
  setup() {
    const message = ref("Main");
    return {
      message,
    };
  },
};
</script>

<style scoped>
.message {
  font-size: 60px;
  font-weight: 900;
}
</style>

下图是 descriptor 的解析结果

image-20220628204228461

其实 parse 函数,就是把一个 Vue 文件,分成 3 个部分:

  • template 块
  • script 块和 scriptSetup 块
  • 多个style 块

这一步做的是解析,其实并没有对代码进行编译,可以看到,每个块的 content 字段,都是跟 Vue 文件是相同的。

值得注意的是,script 包括 script 块和 scriptSetup 块,scriptSetup 块在图中没有标注,是因为刚好我们的 Vue 文件,没有使用 script setup 的特性,因此它的值为空。

style 块允许有多个,因为可以同时出现多个 style 标签,而其他标签只能有一个(script 和 script setup 能同时存在各一个)。

解析的目的,是将一个 Vue 文件中的内容,拆分成不同的块,然后分别对它们进行编译

编译 script

编译 script 的目的有如下几个:

  • 处理 script setup 的代码, script setup 的代码是不能直接运行的,需要进行转换。
  • 合并 script 和 script setup 的代码。
  • 处理 CSS 变量注入
import { compileScript } from "@vue/compiler-sfc";

// 这个 id 是 scopeId,用于 css scope,保证唯一即可
const id = Date.now().toString();
const scopeId = `data-v-${id}`;

// 编译 script,因为可能有 script setup,还要进行 css 变量注入
const script = compileScript(descriptor, { id: scopeId });

compileScript 返回结果如下:

image-20220627222759869

import { ref } from "vue";

export default {
  name: "Main",
  setup() {
    const message = ref("Main");
    return {
      message,
    };
  },
};

可以看出编译后的 script没有变化,因为这里的确不需要任何处理。

如果有 script setup 或者 css 变量注入,编译后的代码就会有变化,感兴趣的可以看看 main-with-css-inject.vue 或 main-with-script-setup.vue 这两个文件的编译结果。

编译 template

编译 template,目的是将 template 转成 render 函数

import { compileTemplate } from "@vue/compiler-sfc";

// 编译模板,转换成 render 函数
const template = compileTemplate({
  source: descriptor.template.content,
  filename: "main.vue", // 用于错误提示
  id: scopeId,
});

compileTemplate 函数返回值如下:

image-20220627230039224

编译后的 render 函数如下:

import { toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = { class: "message" }

export function render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock("div", _hoisted_1, _toDisplayString(_ctx.message), 1 /* TEXT */))
}

这段代码,看起来好像一个函数都不认识。但其实,你只要把 _createElementBlock 当成 Vue.h 渲染函数来看,你就觉得非常熟悉了。

现在有了 script 和 render 函数,其实已经是可以把一个组件显示到页面上了,样式可以先不管,我们先把组件渲染出来,然后再加上样式

组合 script 和 render 函数

目前 script 和 render 函数,它们都是各自一个模块,而我们需要的是一个完整的 Vue 对象,即 render 函数需要作为 Vue 对象的一个属性。

可以采用以下这种方案:

// 将 script 保存到 main.vue.script.js,拿到的是 Vue 对象
import script from '/src/main.vue.script.js'

// 将 render 函数保存到 main.vue.template.js,拿到的是 render 函数
import { render } from '/src/main.vue.template.js'

// 将 style 函数保存到 main.vue.style.js,import 之后就直接创建 <style> 标签了
// 这个先不加,style 还没编译
// import '/src/main.style.template.js'

// 给 Vue 对象设置 render 函数
script.render = render

// 设置一些组件的信息,用于开发环境
script.__file = 'example.vue'
script.__scopeId = 'xxxxxx'

// 这里可以加入其它代码,例如热更新

export default script

但我们其实有更简洁的方式,就是直接将 script 和 template 这两个模块内联到代码中,这样就只有一个文件了。

于是我们可以这样做:

// 用于存放代码,最后 join('\n') 合并成一份完整代码
const codeList = [];
codeList.push(script.content);
codeList.push(template.code);

const code = codeList.join('\n')

但这样做,其实是不行的,因为你会得到以下内容:

import { ref } from "vue";

export default {
  setup() {
     // setup 实现
  },
};

// ----- 上面是 script 的内容,下面是 template 的内容

import { xxx } from "vue"

export function render(_ctx, _cache) {
  // render 函数实现
}

因为用的是 export default,组件没有存储到变量中,我们没法给 Vue 组件设置 render 函数

因此,@vue/compiler-sfc 贴心地给我们提供了一个工具函数 rewriteDefault,它的作用如图:

image-20220628192546845

将 export default 改成 const 定义的变量。

那我们现在就可以合成代码了:

import { compileScript, compileTemplate, rewriteDefault } from "@vue/compiler-sfc";

// 这个 id 是 scopeId,用于 css scope,保证唯一即可
const id = Date.now().toString();
const scopeId = `data-v-${id}`;

// 编译 script,因为可能有 script setup,还要进行 css 变量注入
const script = compileScript(descriptor, { id: scopeId });

// 用于存放代码,最后 join('\n') 合并成一份完整代码
const codeList = [];

// 重写 default
codeList.push(rewriteDefault(script.content, "__sfc_main__"));
codeList.push(`__sfc_main__.__scopeId='${scopeId}'`);

// 编译模板,转换成 render 函数
const template = compileTemplate({
    source: descriptor.template!.content,
    filename: "main.vue", // 用于错误提示
    id: scopeId,
});

codeList.push(template.code);
codeList.push(`__sfc_main__.render=render`);
codeList.push(`export default __sfc_main__`);

// 将合成的代码写到本地
await writeFile("build.temp.js", code);

得到的代码如下:

import { ref } from "vue";

// vue 组件
const __sfc_main__ = {
  name: "Main",
  setup() {
    const message = ref("Main");
    return {
      message,
    };
  },
};

__sfc_main__.__scopeId='data-v-1656415804393'
import { toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = { class: "message" }

// render 函数
export function render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock("div", _hoisted_1, _toDisplayString(_ctx.message), 1 /* TEXT */))
}
// 设置 render 函数到组件
__sfc_main__.render=render
export default __sfc_main__

虽然代码有点丑,但还是能看出来,它的是个 Vue 组件。

那么这个代码是不是能直接给浏览器用呢?

答案还是不能,因为浏览器无法导入裸模块,即 import "vue",浏览器是无法识别的,不知道从哪里获取 Vue 模块。

我们可以手动将 import { ref } from "vue"; 改成 Vue.ref,但是我们有更自动的方法 —— 用打包工具打包一遍。

打包代码

直接使用 esbuild 进行打包。

import { build } from "esbuild";
import { externalGlobalPlugin } from "esbuild-plugin-external-global";

await build({
  entryPoints: ["build.temp.js"],	// 入口文件
  format: "esm",			// 打包成 esm
  outfile: "bundle.js",		// 设置打包文件的名字
  bundle: true,				// bundle 为 true 才是打包模式
  external: ["vue"],
  plugins: [
    externalGlobalPlugin({
      vue: "window.Vue",	// 将 import vue 模块,替换成 window.Vue
    }),
  ],
});
  1. 将 vue 模块 external,即不参与打包(因为我们在 index.html 已经全局引入了 Vue,如果不全局引入 Vue,则需要将 vue 也打包到代码中)

  2. 使用 externalGlobalPlugin 插件,让 external 的 Vue 模块从 window.Vue 中获取。

打包完成的代码,就可以直接给浏览器使用了

<!DOCTYPE html>
<html lang="en">
<head>
    <script src="https://unpkg.com/vue@next"></script>
</head>
<body>
<div id="app"></div>
</body>
<script type="module">
    // 引入刚刚打包好的代码
    import Comp from './bundle.js'
    Vue.createApp(Comp).mount('#app')
</script>
</html>

现在组件已经渲染到界面中了:

image-20220628194219684

编译 style

编译 style,编译产物还是 style,不是 js,目的是编译 vue 的一些特殊的能力,例如 style scope、v-bind()、:deep() 等

import { compileStyle } from "@vue/compiler-sfc";

// 一个 Vue 文件,可能有多个 style 标签
for (const styleBlock of descriptor.styles) {
    const styleCode = compileStyle({
        source: styleBlock.content,
        id,		// style 的 scope id,
        filename: "main.vue",
        scoped: styleBlock.scoped,
    });
}

编译后的对象如下:

image-20220628200219668

编译后的 style 代码:

.message[data-v-1656417674368] {
  font-size: 60px;
  font-weight: 900;
}

这里加上了传入的 scopeId

为什么编译产物不是 js?

因为 style 使用的不一定是 css,还可能是 less、sass 等语法,还需要交给其他预处理器以及后处理器,进行处理

css 最后如何转成 js?

直接用 createElement 创建 style 标签,然后拼接到页面 body 即可

const styleDOM = `
  var el = document.createElement('style')
  el.innerHTML =  \`${styleCode.code}\`
  document.body.append(el);
`;

css 其实都是全局的,在这段样式代码被加载时,style 标签就已经被创建,然后插入到页面了。因此 css 需要使用 scope 的方式用做样式的隔离,需要提供 scopeId 给 compileStyle 函数,用来生成 [data-v-1656417674368] 这种选择器,以免影响到全局样式。

style 完整的代码如下(放在 esbuild 编译前):

import { compileStyle } from "@vue/compiler-sfc";

for (const styleBlock of descriptor.styles) {
  const styleCode = compileStyle({
    source: styleBlock.content,
    id,
    filename: "main.vue",
    scoped: styleBlock.scoped,
  });
  const styleDOM = `
    var el = document.createElement('style')
    el.innerHTML =  \`${styleCode.code}\`
    document.body.append(el);
  `;
  codeList.push(styleDOM);
}

编译后的代码,加入到 codeList 中,最终生成一份完整的代码,然后将这份代码进行打包即可。

最终的渲染结果:

image-20220628201432996

总结

我们从一个非常简单的 Vue 文件,使用 @vue/compiler-sfc,一步一步地将 Vue 文件进行编译处理,分别编译 script、template、style,并将这三部分组装到一起,最后将其进行打包,打包后的文件就能够在浏览器中正确运行,并渲染出界面。

其实@vite/plugin-vue 的处理过程,与我们手动处理的过程,大致相同,不过还加上了热更新、编译缓存、拆分成虚拟模块等能力。

明白这个过程之后,回头看,其实 Vue 的处理,真的没有多复杂,很多时候,一个技术看起来很复杂,但当你潜下心往里面钻研时,其实想要理解它,也并非那么困难。

拓展阅读

如果这篇文章对您有所帮助,请帮忙点个赞👍,您的鼓励是我创作路上的最大的动力。