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

本文涉及的产品
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
简介: 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 的处理,真的没有多复杂,很多时候,一个技术看起来很复杂,但当你潜下心往里面钻研时,其实想要理解它,也并非那么困难


拓展阅读


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

目录
相关文章
|
7天前
|
JavaScript 前端开发
如何在 Vue 项目中配置 Tree Shaking?
通过以上针对 Webpack 或 Rollup 的配置方法,就可以在 Vue 项目中有效地启用 Tree Shaking,从而优化项目的打包体积,提高项目的性能和加载速度。在实际配置过程中,需要根据项目的具体情况和需求,对配置进行适当的调整和优化。
|
8天前
|
存储 缓存 JavaScript
如何在大型 Vue 应用中有效地管理计算属性和侦听器
在大型 Vue 应用中,合理管理计算属性和侦听器是优化性能和维护性的关键。本文介绍了如何通过模块化、状态管理和避免冗余计算等方法,有效提升应用的响应性和可维护性。
|
7天前
|
JavaScript 前端开发 UED
vue学习第二章
欢迎来到我的博客!我是一名自学了2年半前端的大一学生,熟悉JavaScript与Vue,目前正在向全栈方向发展。如果你从我的博客中有所收获,欢迎关注我,我将持续更新更多优质文章。你的支持是我最大的动力!🎉🎉🎉
|
7天前
|
JavaScript 前端开发 开发者
vue学习第一章
欢迎来到我的博客!我是瑞雨溪,一名热爱JavaScript和Vue的大一学生。自学前端2年半,熟悉JavaScript与Vue,正向全栈方向发展。博客内容涵盖Vue基础、列表展示及计数器案例等,希望能对你有所帮助。关注我,持续更新中!🎉🎉🎉
|
JavaScript 索引
VUE文件的创建
第一步创建完VUE 项目之后 可以根据自己的需求 创建自己的文件目录(下图是我的文件目录,当然每个人的创建目录的风格也是不同的所以不做严格的规范) 模块文生成    components文件夹下创建  模块文件名字.
801 0
|
8天前
|
存储 缓存 JavaScript
在 Vue 中使用 computed 和 watch 时,性能问题探讨
本文探讨了在 Vue.js 中使用 computed 计算属性和 watch 监听器时可能遇到的性能问题,并提供了优化建议,帮助开发者提高应用性能。
|
8天前
|
存储 缓存 JavaScript
Vue 中 computed 和 watch 的差异
Vue 中的 `computed` 和 `watch` 都用于处理数据变化,但使用场景不同。`computed` 用于计算属性,依赖于其他数据自动更新;`watch` 用于监听数据变化,执行异步或复杂操作。
|
9天前
|
存储 JavaScript 开发者
Vue 组件间通信的最佳实践
本文总结了 Vue.js 中组件间通信的多种方法,包括 props、事件、Vuex 状态管理等,帮助开发者选择最适合项目需求的通信方式,提高开发效率和代码可维护性。
|
9天前
|
存储 JavaScript
Vue 组件间如何通信
Vue组件间通信是指在Vue应用中,不同组件之间传递数据和事件的方法。常用的方式有:props、自定义事件、$emit、$attrs、$refs、provide/inject、Vuex等。掌握这些方法可以实现父子组件、兄弟组件及跨级组件间的高效通信。
|
14天前
|
JavaScript
Vue基础知识总结 4:vue组件化开发
Vue基础知识总结 4:vue组件化开发