在这一篇文章vue-cli 源码分析一(插件化机制原理实战)关于vue-cli 的使用,我在这里就不再赘述了。相信大家使用已经很多了 - 掘金中我们已经深入的探讨了 vue-cli 的整体架构以及核心的源码分析。今天我们将主要来看一下 vue-cli-setvice 的源码分析,并且借助这个机会来一起深入探讨 webpack 的原理。
vue-cli-service 入口模块分析:
打开 @vue/cli-service 这个子包,我们可以很清晰的看到,它本质上特是一个 cli 命令行工具包,并且,我们可以清晰的找到cli的入口程序:
有了这个分析,我们直接进入到这个入口可执行文件:
内容其实很好理解,总体上就分为 3 个部分:
- 校验 node 版本
- 利用 minimist 这个库方便的解析命令行参数
- 利用用户输入的命令来启动 service 服务。
开始 debug
我们在之前已经创建的测试项目中添加一个 debug 命令:
然后打上断点:
最后以 debug 模式执行命令:
我们输入的 serve 命令就已经被注入到 run 函数中进行执行了:
Servive 是一个核心的服务器类,在前面已经实例化出了一个对象了:
run 函数核心流程分析:
不管是在分析源码还是日常解决特别复杂的问题的时候,我们一定要保持清醒的头脑。就是先站在全局的视角,自顶向下去俯视这些复杂的事物,对事物的整体有了比较清晰的认知之后,再去逐步了解每一个部分的细节。分析 run 函数就必须按照这样的思路,不然就会陷入细节的泥塘,把我不住主线的方向:
第一步确认当前 webpack 的执行模式,因为我们目前是通过 serve 命令启动的,所以 webpack 的模式是 "development"。
第二步进行初始化,包括:
加载用户配置的环境变量内容:
加载用户配置的 vue-config.js 文件:
解析出配置文件中所有 chainWebpack 的配置:
第三步:通过策略模式,根据输入的 command name 调用对应的启动函数来启动命令的执行:
策略模式就是处理动态调用执行命令非常好的方案,通过定义统一的入参以及返回结果,抹平掉各个命令底层的实现差异,使得整个代码非常的好扩展。将来 vue-cli 需要进行扩展的时候将会非常轻松。
至此,我们就从整体上把控住了整个 run 函数的核心脉络。有了自顶向下进行抽象的意识,是不是阅读源码以及解决复杂问题就非常的轻松了呢。
深入 serve 命令的实现细节:
插件化处理机制分析
整体把控之后,我们就可以集中精力分析 serve 命令的处理函数的细节了:
我们可以看到这所有的命令的入口都是按照策略模式分门别类的罗列在这儿:
非常的好阅读。
我们直接进入到 serve 命令的入口:
这又是标准的大型项目的设计方式,注入统一的插件管理的 api,注册 serve 命令对应的插件。按照插件协议的设计,参数一共有3个:
第一个参数,command 的名称,这里是serve 第二个参数:command基本的配置信息 第三个参数:command 对应的执行函数
这个插件的加载逻辑将在 vue-cli-service 初始化的时候进行注册。
这种 command 插件化设计其实和我们上一篇 vue-cli 源码分析文章vue-cli 源码分析一(插件化机制原理实战)关于vue-cli 的使用,我在这里就不再赘述了。相信大家使用已经很多了 - 掘金末尾提出的 command 插件化设计方案其实不谋而合。其实像打造一个开放的,可扩展的插件化系统,方案都是类似的处理,大家可以学习一下。
serve 命令核心流程分析:
核心流程其实并没有那么难以理解:
首先利用 webpack-chain 这个工具来操作 webpack 以及 devServer 的配置:
然后,直接基于 webpack-chain 操作的产物,导出最终的 webpack 配置:
校验 webpack 的配置是否合法:
合并产生出最终的 devServer 配置:
确认 webpack 的打包入口地址:
下面一大段来处理总的来说就是对于 webpack-dev-server 的自定义处理了,包括 hmr 热更新等等:
这里我们就不再一行一行的带大家去看了。感兴趣大家可以自行查看。
这里面比较核心的就是会启动 webpack 的构建:
并且将 webpack 的编译器对象传入自定义 webpack-dev-server 中,并且充分的进行自定义,达到更好的输出 webpack 编译和打包的情况的效果。
最后返回了一个 Promise 对象,并且启动 webpack-dev-server 开发服务器。
至此我们就分析完了 serve 命令最核心的流程。
webpack-chain 比较好的封装示例:
我们来学习一下 vue-cli 是怎么来封装 webpack-chain,并且利用这个库来高效的操作 webpack 的:
首先,导入了 webpack-chain 模块:
vue-cli 的处理方案非常值得学习,它依然用到了插件化的设计模式:
- 首先它抽象出来了一个专门存储 chain 操作集合的数组:
- 它规定这个数组存储里面每一项都存储一个函数:
这些函数统一接收 WebpackChain 中 Config 类的实例化对象作为入参,利用类似于构造者的模式,在每一个函数内部自定义自己需要操作的 webpack 的配置:
这就是一个典型的插件,自定义 serve 命令中需要操作的 webpack 的核心配置。
在所有的插件都注册完毕之后,我们通过调用
这个函数遍历当前注册的所有的插件,产生最终的 webpack 配置。
所以说,插件化在设计一些高度通用复杂的库的时候会非常有用。
webpack 插件的本质
分析完了 serve 命令,其实接着去分析 build 命令以及其他命令都非常简单了。因为插件化的好处,它们都是按照统一的协议去进行设计的。我在这里就不再过多赘述了。 vue-cli 本身还内置了一些自定义的 webpack 插件,我们来以这些为例子来探究一下 webpack 插件的本质。
插件化的本质
让我们再次来回顾插件化设计的核心要点。插件化核心就是设计两个3个部分:
- 插件调度中心,负责加载插件以及执行插件
- 插件接入协议
- 插件调度协议
按照这个理论我们站在更搞的维度宏观的分析各个构建工具的插件化设计,其本质都是一致的:
- 插件接入协议,总的来说,每一个插件都必须返回一个对象,这个对象必须是一个访问器对象。上面挂载了打包各个重要节点的勾子函数。
- 插件调度协议,总的来说,每当打包工具在编译和处理任意的资源的过程中,每当进入一个新的处理阶段,都会触发对应勾子的调用。并且向勾子中注入需要的上下文信息。
有了这个理论,理解webpack插件化的本质就很容易了。
webpack 插件化的设计:
首先webpack抽象出来了两个很重要的对象:compiler、compilation。这两个对象其实也很好理解:
compiler 对象负责整个 webpack 打包过程的控制,所以它上面天然就可以管理 webpack 整个打包过程生命周期勾子的控制。
compilation 对象一般会由 webpack 在打包过程中自动注入到注册的生命周期勾子函数中,这个对象包含了本次打包执行到目前勾子函数的时候当前的打包产物以及文件信息。
基于上面的理解,我们必须要清楚,compiler 不管是在webpack生产模式下打包还是在开发服务器(dev-server)模式下进行打包,都只会有一个,负责webpack打包的整个流程的控制。
compilation 就不一样了,在开发模式下,每一次重新构建,待打包的文件内容可能都会发生变化,所以每一次重新构建,webpack都会基于当前打包的文件信息,产生一个新的 compilation 对象,并且注入到勾子函数中。
理解了上面两个非常重要的概念之后,我们紧接着理解webpack的插件协议:
webpack规定所有的插件都必须是一个 class,用户可以在class上挂载各类自定义的属性和方法,但是必须导出一个 apply 方法。这个 apply 方法的入参,正是我们上面提到的 compiler 编译器对象。
讨论到这里,如何自定义 webpack 插件,其实就很好理解了:
js
module.exports = class MovePlugin {
constructor (from, to) {
this.from = from
this.to = to
}
apply (compiler) {
compiler.hooks.done.tap('move-plugin', (ID, compilation) => {
})
}
}
本质上就是通过 compiler 对象来往指定的 hook 上挂载勾子函数。webpack 会在当前勾子触发的时候自动调用这个函数。并且会把当前文件id传入,文件信息通过 compilation 参数注入。
js
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = class CorsPlugin {
constructor ({ publicPath, crossorigin, integrity }) {
this.crossorigin = crossorigin
this.integrity = integrity
this.publicPath = publicPath
}
apply (compiler) {
const ID = `vue-cli-cors-plugin`
// 定义勾子函数,该勾子在webpack打包初始化阶段会触发
compiler.hooks.compilation.tap(ID, compilation => {
// compilation 会接收到本次构建的文件信息
const ssri = require('ssri')
const computeHash = url => {
const filename = url.replace(this.publicPath, '')
const asset = compilation.assets[filename]
if (asset) {
const src = asset.source()
const integrity = ssri.fromData(src, {
algorithms: ['sha384']
})
return integrity.toString()
}
}
// 可以在勾子内部自定义修改任意webpack以及其他插件的配置
// 这里就是在修改 HtmlWebpackPlugin 插件的相关配置,这里主要是动了它的两个
// 生命周期勾子函数,自定义了勾子函数的内容
HtmlWebpackPlugin.getHooks(compilation).alterAssetTagGroups.tap(ID, data => {
const tags = [...data.headTags, ...data.bodyTags]
if (this.crossorigin != null) {
// 如果用户开启了 crossorigin 配置,用户一般通过 webpack 配置或者vue-cli配置文件设置
tags.forEach(tag => {
if (tag.tagName === 'script' || tag.tagName === 'link') {
// 在资源文件中设置了crossorigin属性
tag.attributes.crossorigin = this.crossorigin
}
})
}
if (this.integrity) {
// 用户开启了资源完整性安全机制开关,那么就对重要的静态资源添加完整性校验 hash
tags.forEach(tag => {
if (tag.tagName === 'script') {
const hash = computeHash(tag.attributes.src)
if (hash) {
tag.attributes.integrity = hash
}
} else if (tag.tagName === 'link' && tag.attributes.rel === 'stylesheet') {
const hash = computeHash(tag.attributes.href)
if (hash) {
tag.attributes.integrity = hash
}
}
})
// when using SRI, Chrome somehow cannot reuse
// the preloaded resource, and causes the files to be downloaded twice.
// this is a Chrome bug (https://bugs.chromium.org/p/chromium/issues/detail?id=677022)
// for now we disable preload if SRI is used.
data.headTags = data.headTags.filter(tag => {
return !(
tag.tagName === 'link' &&
tag.attributes.rel === 'preload'
)
})
}
})
HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tap(ID, data => {
data.html = data.html.replace(/\scrossorigin=""/g, ' crossorigin')
})
})
}
}
以上是 vue-cli 提供的 CorsPlugin 插件,它的目的是为各个文件提供跨域资源共享的支持。 实现思路其实很好理解,大家可以自行查看。我们在这里还可以看到 webpack 自定义插件的一个很重要的能力:
就是可以调整 webpack 配置以及其他所有自定义或者三方插件的默认配置,进行配置的自定义。但凡我们在打包的过程中遇到这类需求,就可以通过自定义webpack插件来解决。
最后总结一下 webpack 中loader以及插件的核心目的:
loader:本质上是一个文件内容转换的函数,接收原始文件内容作为入参,返回转化之后的内容。 plugin:本质上是一个class,必须包含apply方法,可以在里面自定义勾子以及在勾子内部方便的操作webpack以及插件的配置。
当然,要彻底理解 webpack 插件化调度底层的实现逻辑,必须深入理解 webpack tapable 这个任务编排库。这个我们后面有时间可以详细展开,