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

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

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

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

学完之后,你会明白 Vue 文件是如何一步一步被转换成 js 代码的,也能理解 viterollup 这些打包工具,是如何对 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。

页面效果如下:

1686388887341.png

上面的例子,是使用 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 的解析结果

1686388843290.png

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

  • template
  • script 块和 scriptSetup
  • 多个style

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

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

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

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


编译 script


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

  • 处理 script setup 的代码, script setup  的代码是不能直接运行的,需要进行转换。
  • 合并 scriptscript 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 返回结果如下:


1686388813007.png

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

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

如果有 script setup 或者 css 变量注入,编译后的代码就会有变化,感兴趣的可以看看 main-with-css-inject.vuemain-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 函数返回值如下:


1686388780715.png


编译后的 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 渲染函数来看,你就觉得非常熟悉了。

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


组合 script 和 render 函数


目前 scriptrender 函数,它们都是各自一个模块,而我们需要的是一个完整的 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

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

于是我们可以这样做:


// 用于存放代码,最后 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,它的作用如图:

1686388722772.png

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 插件,让 externalVue 模块从 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>

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

1686388659519.png


编译 style


编译 style,编译产物还是 style不是 js,目的是编译 vue 的一些特殊的能力,例如 style scopev-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,
    });
}

编译后的对象如下:

1686388641168.png

编译后的 style 代码:


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

这里加上了传入的 scopeId

为什么编译产物不是 js?

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

css 最后如何转成 js?

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


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

css 其实都是全局的,在这段样式代码被加载时,style 标签就已经被创建,然后插入到页面了。因此 css 需要使用 scope 的方式用做样式的隔离,需要提供 scopeIdcompileStyle 函数,用来生成 [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 中,最终生成一份完整的代码,然后将这份代码进行打包即可。

最终的渲染结果:

1686388599085.png

总结


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

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

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


拓展阅读


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

目录
相关文章
|
1天前
|
存储 JavaScript
Vue当前时间与接口返回时间的判断
Vue当前时间与接口返回时间的判断
7 0
|
1天前
|
JavaScript
vue生成动态表单
vue生成动态表单
6 0
|
1天前
|
JavaScript 前端开发
Vue生成Canvas二维码
Vue生成Canvas二维码
6 0
|
1天前
|
JavaScript
vue项目切换页面白屏的解决方案
vue项目切换页面白屏的解决方案
5 0
|
JavaScript 数据安全/隐私保护
work02_vue页面打印水印
work02_vue页面打印水印
276 0
work02_vue页面打印水印
|
1天前
|
JavaScript 前端开发 开发者
new Vue() 发生了什么
new Vue() 发生了什么
8 1
|
1天前
|
JavaScript 容器
使用Vue写一个日期选择器
使用Vue写一个日期选择器
9 1
|
1天前
|
JavaScript
Vue 中如何模块化使用 Vuex
Vue 中如何模块化使用 Vuex
5 0
|
1天前
|
JavaScript 应用服务中间件 nginx
vue中404解决方法
vue中404解决方法
3 0
|
1天前
|
JavaScript 前端开发
vue中nextTick使用以及原理
vue中nextTick使用以及原理
5 0