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 的处理,真的没有多复杂,很多时候,一个技术看起来很复杂,但当你潜下心往里面钻研时,其实想要理解它,也并非那么困难


拓展阅读


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

目录
相关文章
|
3月前
|
人工智能 JavaScript 算法
Vue 中 key 属性的深入解析:改变 key 导致组件销毁与重建
Vue 中 key 属性的深入解析:改变 key 导致组件销毁与重建
485 0
|
3月前
|
JavaScript UED
用组件懒加载优化Vue应用性能
用组件懒加载优化Vue应用性能
|
3月前
|
JavaScript 前端开发 开发者
Vue 自定义进度条组件封装及使用方法详解
这是一篇关于自定义进度条组件的使用指南和开发文档。文章详细介绍了如何在Vue项目中引入、注册并使用该组件,包括基础与高级示例。组件支持分段配置(如颜色、文本)、动画效果及超出进度提示等功能。同时提供了完整的代码实现,支持全局注册,并提出了优化建议,如主题支持、响应式设计等,帮助开发者更灵活地集成和定制进度条组件。资源链接已提供,适合前端开发者参考学习。
336 17
|
3月前
|
JavaScript 前端开发 UED
Vue 表情包输入组件实现代码及详细开发流程解析
这是一篇关于 Vue 表情包输入组件的使用方法与封装指南的文章。通过安装依赖、全局注册和局部使用,可以快速集成表情包功能到 Vue 项目中。文章还详细介绍了组件的封装实现、高级配置(如自定义表情列表、主题定制、动画效果和懒加载)以及完整集成示例。开发者可根据需求扩展功能,例如 GIF 搜索或自定义表情上传,提升用户体验。资源链接提供进一步学习材料。
182 1
|
JavaScript 索引
VUE文件的创建
第一步创建完VUE 项目之后 可以根据自己的需求 创建自己的文件目录(下图是我的文件目录,当然每个人的创建目录的风格也是不同的所以不做严格的规范) 模块文生成    components文件夹下创建  模块文件名字.
833 0
|
5月前
|
JavaScript
vue实现任务周期cron表达式选择组件
vue实现任务周期cron表达式选择组件
659 4
|
4月前
|
JavaScript 数据可视化 前端开发
基于 Vue 与 D3 的可拖拽拓扑图技术方案及应用案例解析
本文介绍了基于Vue和D3实现可拖拽拓扑图的技术方案与应用实例。通过Vue构建用户界面和交互逻辑,结合D3强大的数据可视化能力,实现了力导向布局、节点拖拽、交互事件等功能。文章详细讲解了数据模型设计、拖拽功能实现、组件封装及高级扩展(如节点类型定制、连接样式优化等),并提供了性能优化方案以应对大数据量场景。最终,展示了基础网络拓扑、实时更新拓扑等应用实例,为开发者提供了一套完整的实现思路和实践经验。
482 77
|
5月前
|
缓存 JavaScript 前端开发
Vue 基础语法介绍
Vue 基础语法介绍
|
3月前
|
监控 JavaScript 前端开发
Vue 文件批量下载组件封装完整使用方法及优化方案解析
本文详细介绍了批量下载功能的技术实现与组件封装方案。主要包括两种实现方式:**前端打包方案(基于file-saver和jszip)** 和 **后端打包方案**。前者通过前端直接将文件打包为ZIP下载,适合小文件场景;后者由后端生成ZIP文件流返回,适用于大文件或大量文件下载。同时,提供了可复用的Vue组件`BatchDownload`,支持进度条、失败提示等功能。此外,还扩展了下载进度监控和断点续传等高级功能,并针对跨域、性能优化及用户体验改进提出了建议。可根据实际需求选择合适方案并快速集成到项目中。
323 17
|
3月前
|
JavaScript 前端开发 UED
Vue 手风琴实现的三种常用方式及长尾关键词解析
手风琴效果是Vue开发中常见的交互组件,可节省页面空间、提升用户体验。本文介绍三种实现方式:1) 原生Vue结合数据绑定与CSS动画;2) 使用Element UI等组件库快速构建;3) 自定义指令操作DOM实现独特效果。每种方式适用于不同场景,可根据项目需求选择。示例包括产品特性页、后台菜单及FAQ展示,灵活满足多样需求。附代码示例与资源链接,助你高效实现手风琴功能。
142 10