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

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

以前常常觉得,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。

页面效果如下:

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 标签,而其他标签只能有一个(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 返回结果如下:


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.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 函数返回值如下:


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

现在有了 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,它的作用如图:

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 插件,让 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>

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

1686388659519.png


编译 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,
    });
}

编译后的对象如下:

1686388641168.png

编译后的 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 中,最终生成一份完整的代码,然后将这份代码进行打包即可。

最终的渲染结果:

1686388599085.png

总结


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

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

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


拓展阅读


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

目录
相关文章
|
JavaScript
Vue中如何实现兄弟组件之间的通信
在Vue中,兄弟组件可通过父组件中转、事件总线、Vuex/Pinia或provide/inject实现通信。小型项目推荐父组件中转或事件总线,大型项目建议使用Pinia等状态管理工具,确保数据流清晰可控,避免内存泄漏。
894 2
|
11月前
|
缓存 JavaScript
vue中的keep-alive问题(2)
vue中的keep-alive问题(2)
774 137
|
人工智能 JavaScript 算法
Vue 中 key 属性的深入解析:改变 key 导致组件销毁与重建
Vue 中 key 属性的深入解析:改变 key 导致组件销毁与重建
1666 0
|
JavaScript UED
用组件懒加载优化Vue应用性能
用组件懒加载优化Vue应用性能
|
JavaScript 前端开发 开发者
Vue 自定义进度条组件封装及使用方法详解
这是一篇关于自定义进度条组件的使用指南和开发文档。文章详细介绍了如何在Vue项目中引入、注册并使用该组件,包括基础与高级示例。组件支持分段配置(如颜色、文本)、动画效果及超出进度提示等功能。同时提供了完整的代码实现,支持全局注册,并提出了优化建议,如主题支持、响应式设计等,帮助开发者更灵活地集成和定制进度条组件。资源链接已提供,适合前端开发者参考学习。
887 17
|
人工智能 JSON JavaScript
VTJ.PRO 首发 MasterGo 设计智能识别引擎,秒级生成 Vue 代码
VTJ.PRO发布「AI MasterGo设计稿识别引擎」,成为全球首个支持解析MasterGo原生JSON文件并自动生成Vue组件的AI工具。通过双引擎架构,实现设计到代码全流程自动化,效率提升300%,助力企业降本增效,引领“设计即生产”新时代。
988 1
|
JavaScript 安全
在 Vue 中,如何在回调函数中正确使用 this?
在 Vue 中,如何在回调函数中正确使用 this?
685 0
|
JavaScript 前端开发 UED
Vue 表情包输入组件实现代码及详细开发流程解析
这是一篇关于 Vue 表情包输入组件的使用方法与封装指南的文章。通过安装依赖、全局注册和局部使用,可以快速集成表情包功能到 Vue 项目中。文章还详细介绍了组件的封装实现、高级配置(如自定义表情列表、主题定制、动画效果和懒加载)以及完整集成示例。开发者可根据需求扩展功能,例如 GIF 搜索或自定义表情上传,提升用户体验。资源链接提供进一步学习材料。
953 1
|
存储 JavaScript 前端开发
如何高效实现 vue 文件批量下载及相关操作技巧
在Vue项目中,实现文件批量下载是常见需求。例如文档管理系统或图片库应用中,用户可能需要一次性下载多个文件。本文介绍了三种技术方案:1) 使用`file-saver`和`jszip`插件在前端打包文件为ZIP并下载;2) 借助后端接口完成文件压缩与传输;3) 使用`StreamSaver`解决大文件下载问题。同时,通过在线教育平台的实例详细说明了前后端的具体实现步骤,帮助开发者根据项目需求选择合适方案。
1201 0
|
JavaScript
vue实现任务周期cron表达式选择组件
vue实现任务周期cron表达式选择组件
1650 4