在 Vue 开发中,单文件组件(SFC,.vue 文件)的组件形态很常见,本文意在梳理和分享 SFC 的打包流程,便于大家对 SFC 的解析细节有所了解,也可在扩展 SFC 的能力时,有更合理的方案选择。
一、整体
使用过 Vue 的同学,对于 .vue 单文件组件类型的文件(下文简称 SFC)应该不会陌生。SFC 文件需要通过构建工具(文章以 Webpack4 为例)打包成一个 Bundle,才能被识别和使用。这中间经历了什么、不同的代码块是如何被其他规则识别的、最终生成了什么?带着这些问题,且看下文一一道来。
![2a7705f2afd42a1910be7d1bfeadd41c.png](https://i-blog.csdnimg.cn/blog_migrate/3829bdcae2df43ee49f52e2fd4e112c3.jpeg)
1. SFC的输入和输出
Webpack 需要增加 vue-loader 和 vueLoaderPlugin 对 SFC 进行支持。我们首先聚焦到 vue-loader 的代码:其入口文件为 lib/index.js
,入参 source
是 SFC 源码,经过处理逻辑后,输出export default
的代码字符串。
// vue-loader lib/index.js
// source 是我们写的 SFC 源码
module.exports = function (source) {
...
// 返回值 code 是一段 ESModule 代码字符串
let code = `...`;
code += `n export default component.exports`;
return code;
};
用实际的例子操作一下,demo.vue
经过运行后得到下图的输出。经过观察可以发现,原有的 template ,变成了import ... from './demo.vue?vue&type=template'
,其他代码块也发生了类似的变化。?vue&type=template
新增的 vue 和 type 参数的作用是什么?我们继续往下看。
![0397d28c16219cdc42417a4c5640c6e3.png](https://i-blog.csdnimg.cn/blog_migrate/8ab976af0ef62e2c072906dfaebd5d76.jpeg)
2. template、script、style 代码块切分
上个小节中,可以看到 template、script、style 代码块在输出结果中已经转化为对应的 import 逻辑。这一步是 vue-loader 调用了 @vue/component-compiler-utils 的 parse 函数进行解析后,分别生成了对应的 import 逻辑,相关源码如下:
// vue-loader lib/index.js
const { parse } = require('@vue/component-compiler-utils');
module.exports = function (source) {
// 解析源码,得到描述符
const descriptor = parse({ source, ... });
// 如果 template 块存在
if (descriptor.template) { ... }
// 如果 script 块存在
if (descriptor.script) { ... }
// 如果 style 块存在(支持多 style 块)
if (descriptor.styles.length) { ... }
// Vue 还支持自定义块
if (descriptor.customBlocks && descriptor.customBlocks.length) { ... }
}
下图是 demo.vue 被转化的流程图解。
![3daf72ba88f5f9b0075639d90820f159.png](https://i-blog.csdnimg.cn/blog_migrate/44265fa6c62309521cbf6b2cea744a33.jpeg)
二、VueLoaderPlugin 的作用
前面的章节留了一个疑问,?vue&type=template
的作用是什么?可以从 VueLoaderPlugin 中找出答案,首先我们先了解 Webpack 中的 Plugin 能做什么。
1. Plugin 的特性
Plugin 的作用,主要有以下两条:
- 能够 hook 到在每个编译(compilation)中触发的所有关键事件。
- 在插件实例的 apply 方法中,可以通过
compiler.options
获取 Webpack 配置,并进行修改。
VueLoaderPlugin 通过第二个特性,在初始化阶段,对 module.rules
进行动态修改。
2. VueLoaderPlugin 预处理
VueLoaderPlugin 的处理流程中,修改了module.rules
,在原来的基础上加入了 pitcher 和 cloneRules这一步的作用是:新增的 rule ,能识别形如?vue&type=template
的querystring,让不同语言的代码块匹配到对应的 rule。
class VueLoaderPlugin {
apply (compiler) {
// 标识 VueLoaderPlugin 已被加载
// 可用于 vue-loader 运行时,检测 VueLoaderPlugin 的加载信息
if (webpack4) { ... } esle { ... }
// 重头戏,对 Webpack 配置进行修改
const rawRules = compiler.options.module.rules;
const { rules } = new RuleSet(rawRules);
...
// 替换初始 module.rules,在原有 rule 上,增加 pitcher、clonedRules
compiler.options.module.rules = [
pitcher,
...clonedRules,
...rules
];
}
}
VueLoaderPlugin 对 rules 的修改,用下图可以更直观地理解各部分 Rule 的作用。(这里有一个小的知识点,除了常见的Rule.test
选项外,Rule.resourceQuery
选项可以对资源的 querystring 进行匹配)
![ccb1b21dd95af7f648ae83c689f5c0c7.png](https://i-blog.csdnimg.cn/blog_migrate/49b163cf37893a827ce9176bfc5324b0.jpeg)
三、回到 Loader
上一节梳理了 VueLoaderPlugin 在初始化阶段的预处理,这一节我们继续回到构建阶段中,看看以 VueLoader 为中心如何协调其它 Loader ,得到每个代码块的构建结果。同样地,我们先了解一下 Webpack 的 loader 特性。
1. Webpack 的 loader 运行顺序
对于 loader ,我们知道它们的执行是有顺序的,如果是这样的配置,运行的顺序将是 c-loader -> b-loader -> a-loader。
module.exports = {
module: {
rules: [{
...
use: ['a-loader', 'b-loader', 'c-loader'],
}],
},
};
不过,在实际(从右到左)执行 loader 之前,会先从左到右调用 loader 上的 pitch 方法。
|- a-loader `pitch`
|- b-loader `pitch`
|- c-loader `pitch`
|- requested module is picked up as a dependency
|- c-loader normal execution
|- b-loader normal execution
|- a-loader normal execution
并且在 loader 的 pitch 方法中,如果有实际的返回值,将会跳过后续的 loader,比如在 b-loader 的 pitch 中,如果返回了实际值,将会产生下面的执行顺序。
# 注意 a-loader 依然会正常执行,跳过的是 c-loader
|- a-loader `pitch`
|- b-loader `pitch` returns a module
|- a-loader normal execution
知道这个特性,有利于我们理解 SFC 中各代码块在 loader 中的处理顺序。
2. SFC 转化流程
还记得第一节生成的编译结果吗?每个代码块都导出了对应逻辑,我们以 script 块为例,结合第二节的 PitcherLoader 再次进行转化,转化后的结果为:
![28f17335075512e54afa47fd29fbc834.png](https://i-blog.csdnimg.cn/blog_migrate/a148cdf8d00c8b5d8f7c38024ec74ad9.jpeg)
最后的 import 语句,使用了内联方式的 import 语法,我们拆分一下便于理解
# 原import
-!../../node_modules/babel-loader/lib/index.js??ref--2-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./demo.vue?vue&type=script&lang=js&";
# Part1 -!
# 将禁用所有已配置的 preLoader 和 loader,但是不禁用 postLoaders
# Part2 ../../node_modules/babel-loader/lib/index.js??ref--2-0
# 参考小节:VueLoaderPlugin 的预处理,demo.vue 会自动添加 .js 后缀,以匹配所有 js 的 Rule,这里使用 babel-loader 处理 js 模块
# Part3 ../../node_modules/vue-loader/lib/index.js??vue-loader-options
# /.vue$/ 规则匹配到 demo.vue,并使用 vue-loader 处理 .vue 后缀
# Part4 ./demo.vue?vue&type=script&lang=js&
3. PitchLoader
上述的转化发生在 PitchLoader 中,对 PitchLoader 的实现逻辑感兴趣的同学,可以阅读 loader/pitcher.js 的源码:
// vue-loader lib/loaders/pitcher.js
// PitcherLoader.pitch 方法,所有带 ?vue 的模块请求,都会走到这里
module.exports.pitch = function (remainingRequest) {
// 如 ./demo?vue&type=script&lang=js
// 此时,loaders 是所有能处理 .vue 和 .xxx 的 loader 列表
let loaders = this.loaders;
...
// 得到 -!babel-loader!vue-loader!
const genRequest = loaders => { ... };
// 处理 style 块 和 template 块,支持
if (query.type === 'style') { ... }
if (query.type === 'template') { ... }
// 处理 script 块和 custom 块
return `import mod from ${request}; export default mod; export * from ${request}`;
}
4. 再次执行 VueLoader
细心的同学可能发现了,在 PitchLoader 的转化结果中,还是会以 vue-loader 作为第一个处理的 loader,但 vue-loader 不是一开始就转化过了吗 ?与第一次不同的是,这次 vue-loader 的作用,仅仅是把 SFC 中语法块的源码提取出来,并交给后面的 loader 进行处理
![90a748a5f37da2fac05de771fb3f64ef.png](https://i-blog.csdnimg.cn/blog_migrate/8576ba2a52ed1766624f5107443168fc.jpeg)
// vue-loader lib/index.js
const { parse } = require('@vue/component-compiler-utils');
module.exports = function (source) {
// 如果querystring 包含了 type 参数,则直接返回该块的代码
if (incomingQuery.type) {
return selectBlock( ... );
}
};
// vue-loader lib/select.js
module.exports = function selectBlock (...) {
// 选择不同语法块的内容进行返回
}
至此,vue-loader 里面的处理逻辑基本已经梳理完成~各部分代码块也传入后续的 loader 中进行解析和转化。
四、总结
我们再用一张完整的处理流程图总结一下 SFC 构建流程~
![3d0821d129df174d131349529f85b6e8.png](https://i-blog.csdnimg.cn/blog_migrate/e65254074fd02c87501f29f2bbd95152.jpeg)
五、参考
- SFC
- vue-loader
- 从vue-loader源码分析CSS Scoped的实现
- Webpack LoaderAPI
- Webpack PluginAPI