玩转 webpack5(下)原创
前言
这是接玩转 webpack(上) 的续集。
本篇长文是学习程柳峰老师开设的《玩转 webpack》专栏的实践笔记,和专栏不一样的是,我的实战源码是基于 webpack5,它的配置和源码实现上与 webpack4 有许多不同的地方,感兴趣的同学可以结合我在上面放出的源码仓库进行学习,相信会有不小的收获。
看完本篇长文,你将收获:
- 能够根据项目需求灵活的进行 webpack 的配置。
- 理解 Tree Shaking、Scope Hoisting 等原理。
- 能够实现打包多页应用、组件库和基础库、SSR 等。
- 编写可维护的 webpack 配置,结合单元测试、冒烟测试等控制代码质量,使用 Travis 实现持续集成。
- 知道如何优化构建速度和构建资源体积。
- 通过源代码掌握 webpack 打包原理
- 编写 loader 和 plugin。
我将源码放在我的仓库中,可以对照着文档阅读,另外由于掘金字数限制,只能分成两篇文章,想要更好的阅读体验可以移步到我的博客。源码地址:玩转 webpack
webpack 构建速度和体积优化策略
初级分析:使用 stats
在 webpack5 中可以得到构建各个阶段的处理过程、耗费时间以及缓存使用的情况。
module.exports = {
stats: 'verbose', // 输出所有信息 normal: 标准信息; errors-only: 只有错误的时候才输出信息
};
复制代码
在根目录下生成 stats.json,包含了构建的信息。
{
"scripts": {
"****yze:stats": "webpack --config config/webpack.prod.js --json stats.json"
}
}
复制代码
速度分析:使用 speed-measure-webpack-plugin
这个插件在 webpack5 中已经用不到了,可以使用内置的 stats 替代。
作用:
- 分析整个打包总耗时
- 每个插件和 loader 的耗时情况
yarn add speed-measure-webpack-plugin -D
复制代码
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');
const **p = new SpeedMeasurePlugin();
const webpackConfig = **p.wrap({
plugins: [new MyPlugin(), new MyOtherPlugin()],
});
复制代码
体积分析:webpack-bundle-****yzer
分析:
- 依赖的大小
- 业务组件代码的大小
yarn add webpack-bundle-****yzer
复制代码
const { BundleAnalyzerPlugin } = require('webpack-bundle-****yzer');
module.exports = {
plugins: [new BundleAnalyzerPlugin()],
};
复制代码
执行完成后会自动打开 http://127.0.0.1:8888/
,如下图所示:
使用更高版本的 webpack 和 nodejs
webpack4 和 nodejs 高版本较之前所做的优化:
- V8 带来的优化:for of 替代 forEach; Map/Set 替代 Object; includes 替代 indexOf。
- md5 → md4 算法。
- 使用字符串方法替代正则表达式。
webpack5 的主要优化及特性:
- 持久化缓存。可以设置基于内存的临时缓存和基于文件系统的持久化缓存。
- 一旦开启,会忽略其它插件的缓存设置。
- Tree Shaking
- 增加了对嵌套模块的导出跟踪功能,能够找到那些嵌套在最内层而未被使用的模块属性。
- 增加了对 cjs 模块代码的静态分析功能。
- Webpack 5 构建输出的日志要丰富完整得多,通过这些日志能够很好地反映构建各阶段的处理过程、耗费时间,以及缓存使用的情况。
- 新增了改变微前端构建运行流程的 Module Federation。
- 对产物代码进行优化处理 Runtime Modules。
- 优化了处理模块的工作队列。
- 在生命周期中增加了 stage 选项。
多进程/多实例构建
可选方案:
- thread-loader
- parallel-webpack
- 一些插件内置的 parallel 参数(如 TerserWebpackPlugin, CssMinimizerWebpackPlugin, HtmlMinimizerWebpackPlugin)
- HappyPack(作者已经不维护)
thread-loader
原理:每次 webpack 解析一个模块,thread-loader 会将它及它的依赖分配给 worker 线程中。
module.exports = {
module: {
rules: [
{
test: /\.jsx?$/,
use: [
{
loader: 'thread-loader',
options: {
workder: 3,
},
},
'babel-loader',
'eslint-loader',
],
},
],
},
};
复制代码
并行压缩
可以配置并行压缩的插件:
- terser-webpack-plugin
- css-minimizer-webpack-plugin
- html-minimizer-webpack-plugin
module.exports = {
optimization: {
minimize: true,
minimizer: [
new CssMinimizerPlugin(),
new TerserPlugin({ parallel: 2 }),
'...',
],
},
};
复制代码
进一步分包:预编译资源模块(DLL)
回顾之前分包的思路:
使用 SplitChunkPlugin 将 react, react-dom 等基础库分离成单独的 chunk。
缺点是每次打包时仍然会对基础包进行解析编译,更好的方式是进行预编译资源模块,通过 DLLPlugin, DllReferencePlugin 实现。
预编译资源模块
思路:将 react, react-dom, redux, react-redux 基础包和业务基础包打包成一个文件,可以提供给其它项目使用。
方法:使用 DLLPlugin 进行分包,DllReferencePlugin 对 manifest.json 引用。
首先定义一个 config/webpack.dll.js
, 用于将基础库进行分离:
const path = require('path');
const webpack = require('webpack');
module.exports = {
mode: 'production',
entry: {
library: ['react', 'react-dom'],
},
output: {
filename: '[name]_[chunkhash].dll.js',
path: path.resolve(__dirname, '../build/library'),
library: '[name]',
},
plugins: [
new webpack.DllPlugin({
context: __dirname,
name: '[name]_[hash]',
path: path.join(__dirname, '../build/library/[name].json'),
}),
],
};
复制代码
然后在 webpack.common.js
中将预编译资源模块引入:
module.exports = {
plugins: [
new webpack.DllReferencePlugin({
context: __dirname,
manifest: require('../build/library/library.json'),
scope: 'xyz',
sourceType: 'commonjs2',
}),
],
};
复制代码
充分利用缓存提升二次构建速度
目的:提升二次构建速度。
缓存思路:
- webpack5 内置的基于内存的临时缓存和基于文件系统的持久化缓存。
- cache-loader。
- terser-webpack-plugin 开启缓存。
基于文件系统的持久化缓存,在 node_modules 下会生成 .cache 目录:
module.exports = {
cache: {
type: 'filesystem', // memory 基于内存的临时缓存
// cacheDirectory: path.resolve(__dirname, '.temp_cache'),
},
};
复制代码
缩小构建目标
目的:减少需要解析的模块。
- babel-loader 不解析 node_modules
减少文件搜索范围:
- resolve.modules 减少模块搜索层级,指定当前 node_modules。
- resovle.mainFields 指定入口文件。
- resolve.extension 对于没有指定后缀的引用,指定解析的文件后缀算法。
- 合理使用 alias,引用三方依赖的生成版本。
module.exports = {
resolve: {
alias: {
react: path.resolve(__dirname, './node_modules/react/dist/react.min.js'),
},
modules: [path.resolve(__dirname, './node_modules')],
extensions: ['.js', '.jsx', '.json'],
mainFields: ['main'],
},
};
复制代码
Tree Shaking 擦除无用的 css
前面已经介绍了使用 Tree Shaking 擦除无用的 js,这在 webpack5 中已经内置了,这一小节介绍如何擦除无用的 css。
- PurifyCSS: 遍历代码,识别已经用到的 css class。
- uncss: html 需要通过 jsdom 加载,所有的样式通过 PostCSS 解析,通过 document.querySelector 识别 html 文件中不存在的选择器。
在 webpack 中使用 PurifyCSS:
- 使用 purgecss-webpack-plugin
- 和 mini-css-extract-plugin 配合使用
const PurgeCSSPlugin = require('purgecss-webpack-plugin');
const glob = require('glob');
const PATHS = { src: path.resolve('../src') };
module.exports = {
plugins: [
new PurgeCSSPlugin({
paths: glob.sync(`${PATHS.src}/**/*`, { nodir: true }),
}),
],
};
复制代码
图片压缩
yarn add image-minimizer-webpack-plugin
复制代码
无损压缩推荐使用下面依赖:
yarn add imagemin-gifsicle imagemin-jpegtran imagemin-optipng imagemin-svgo
复制代码
const ImageMinimizerPlugin = require('image-minimizer-webpack-plugin');
module.exports = {
plugins: [
new ImageMinimizerPlugin({
minimizerOptions: {
plugins: [['jpegtran', { progressive: true }]],
},
}),
],
};
复制代码
使用动态 Polyfill 服务
用于体积优化。
polyfill-service: 只给用户返回需要的 polyfill,国内部分浏览器可能无法识别 User Agent,可以采用优雅降级的方案。
polyfill-service 原理:识别 User Agent,下发不同的 polyfill,做到按需加载需要的 polyfill。
体积优化策略总结
- Scope Hoisting
- Tree Shaking
- 公共资源分离
- SplitChunks
- 预编译资源模块
- 图片压缩
- 动态 Polyfill
通过源代码掌握 webpack 打包原理
webpack 启动过程分析
开始:从 webpack 命令行说起。
- 通过 npm scripts 运行 webpack
- 开发环境: npm run dev
- 生产环境: npm run build
- 通过 webpack 直接运行
- webpack entry.js bundle.js
这个过程发生了什么?
执行上述命令之后,npm 会让命令行进入 node_modules/.bin
目录下查找 webpack.js
,如果存在就执行,不存在就抛出错误。
.bin
目录下的文件实际上是软链接,webpack.js
真正指向的文件是 node_modules/webpack/bin/webpack.js
。
分析 webpack 的入口文件:webpack.js
认识几个关键函数:
- runCommand -> 运行命令
- isInstalled -> 判断某个包是否安装
- runCli -> 执行 webpack-cli
执行流程:
- 判断 webpack-cli 是否存在。
- 不存在则抛出异常,存在则直接到第 6 步。
- 判断当前使用的包管理器是 yarn/npm/pnpm 中的哪一种。
- 使用包管理器自动安装 webpack-cil(runCommand ->
yarn webpack-cli -D
) - 安装成功后执行 runCli
- 执行 runCli(执行
webpack-cli/bin/cli.js
)
总结:webpack 最终会找到 webpack-cli 这个包,并且执行 webpack-cli。
webpack-cli 源码阅读
webpack-cli 做的事情:
- 引入
Commander.js
,对命令行进行定制。 - 分析命令行参数,对各个参数进行转换,组成编译配置项。
- 引用 webpack,根据生成的配置项进行编译和构建。
命令行工具包 Commander.js
介绍
完成的
nodejs
命令行解决方案。
- 提供命令和分组参数。
- 动态生成 help 帮助信息。
具体的执行流程:
- 接着上面一小节的执行 webpack-cli,也就是执行
node_modules/webpack-cli/bin/cli.js
。 - 检查是否有
webpack
,有的话执行runCli
,它主要做的是实例化node_modules/webpack-cli/webpack-cli.js
,然后调用它的run
方法。 - 使用
Commander.js
定制命令行参数。 - 解析命令行的参数,如果是内置参数,则调用
createCompiler
,主要做的事是想将得到的参数传递给webpack
,生成实例化对象compiler
。
总结:webpack-cli 对命令行参数进行转换,最终生成配置项参数 options,将 options 传递给 webpack 对象,执行构建流程(最后会判断是否有监听参数,如果有,就执行监听的动作)。
Tapable 插件架构与 Hooks 设计
webpack 的本质:webpack 可以将其理解成是一种基于事件流(发布订阅模式)的编程范例,一系列的插件运行。
Compiler 和 Compilation 都是继承 Tapable,那么 Tapable 是什么呢?
Tapable 是一个类似于 nodejs 的 EventEmitter 的库,主要是控制钩子函数的发布与订阅,控制着 webpack 的插件系统。
- Tapable 暴露了很多 Hook 类,为插件提供挂载的钩子。
Tapable hooks 类型:
Tapable 提供了同步和异步绑定钩子的方法,并且都有绑定事件和执行事件对应的方法。
实现一个 Car
类,其中有个 hooks 对象,包含了加速、刹车、计算路径等 hook,对其分别注册事件和触发事件:
console.time('cost');
class Car {
constructor() {
this.hooks = {
acclerate: new SyncHook(['newspped']), // 加速
brake: new SyncHook(), // 刹车
calculateRoutes: new AsyncSeriesHook(['source', 'target', 'routes']), // 计算路径
};
}
}
const myCar = new Car();
// 绑定同步钩子
myCar.hooks.brake.tap('WarningLmapPlugin', () => {
console.log('WarningLmapPlugin');
});
// 绑定同步钩子并传参
myCar.hooks.acclerate.tap('LoggerPlugin', (newSpeed) => {
console.log(`accelerating spped to ${newSpeed}`);
});
// 绑定一个异步的 promise
myCar.hooks.calculateRoutes.tapPromise(
'calculateRoutes tabPromise',
(params) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log(`tapPromise to ${params}`);
resolve();
}, 1000);
});
}
);
// 触发同步钩子
myCar.hooks.brake.call();
// 触发同步钩子并传入参数
myCar.hooks.acclerate.call(120);
// 触发异步钩子
myCar.hooks.calculateRoutes
.promise(['Async', 'hook', 'demo'])
.then(() => {
console.timeEnd('cost');
})
.catch((err) => {
console.error(err);
console.timeEnd('cost');
});
复制代码
Tapable 是如何和 webpack 进行关联起来的
上面说到 webpack-cli.js
中执行 createCompiler
的时候,将转换后得到的 options
传递给 webpack 方法然后生成 compiler
对象,接下来说说 webpack.js
中做的事情,我将 createCompiler
的源码贴出,便于理解:
const createCompiler = (rawOptions) => {
const options = getNormalizedWebpackOptions(rawOptions);
applyWebpackOptionsBaseDefaults(options);
const compiler = new Compiler(options.context); // 实例化 compiler
compiler.options = options;
new NodeEnvironmentPlugin({
infrastructureLogging: options.infrastructureLogging,
}).apply(compiler);
if (Array.isArray(options.plugins)) {
// 遍历并调用插件
for (const plugin of options.plugins) {
if (typeof plugin === 'function') {
plugin.call(compiler, compiler);
} else {
plugin.apply(compiler);
}
}
}
applyWebpackOptionsDefaults(options);
// 触发监听的 hooks
compiler.hooks.environment.call();
compiler.hooks.afterEnvironment.call();
new WebpackOptionsApply().process(options, compiler); // 注入内部插件
compiler.hooks.initialize.call();
return compiler; // 将实例返回
};
复制代码
通过上述代码可以得到两个关于插件的结论:
- 插件就是监听 compiler 对象上的 hooks。
- 执行插件需要调用插件的 apply 方法,并将 compiler 对象作为参数传入。
webpack.js
:
- webpack 中也有 createCompiler 方法,它会先实例化
Compiler
对象,生成 compiler 实例。 - Compiler 中的核心在于挂载了许多继承自 Tapable 的 hooks,其它地方可以使用 compiler 实例注册和触发事件,在 webpack 构建的不同阶段,会触发不同的 hook。
options.plugins
即配置的一系列插件,在 createCompiler 中,生成 compiler 实例后,如果options.plugins
是数组类型,则会遍历调用它,并传入 compiler,形如plugin.apply(compiler)
,内部绑定 compiler 上的一些 hooks 事件。
简易模拟 Compiler 和插件的实现:
// Compiler 对象,挂载了一些 hook
const { SyncHook, AsyncSeriesHook } = require('tapable');
module.exports = class Compiler {
constructor() {
this.hooks = {
acclerate: new SyncHook(['newspped']),
brake: new SyncHook(),
calculateRoutes: new AsyncSeriesHook(['source', 'target', 'routesList']),
};
}
run() {
this.acclerate(100);
this.brake();
this.calculateRoutes('Async', 'hook', 'demo');
}
acclerate(speed) {
this.hooks.acclerate.call(speed);
}
brake() {
this.hooks.brake.call();
}
calculateRoutes(...params) {
this.hooks.calculateRoutes.promise(...params).then(
() => {},
(err) => {
console.log(err);
}
);
}
};
复制代码
webpack 插件,根据传入的 compiler 对象,选择性监听了一些 hook:
const Compiler = require('./compiler');
class MyPlugin {
apply(compiler) {
// 绑定事件
compiler.hooks.acclerate.tap('打印速度', (newSpeed) =>
console.log(`speed acclerating to ${newSpeed}`)
);
compiler.hooks.brake.tap('刹车警告', () => console.log('正在刹车'));
compiler.hooks.calculateRoutes.tapPromise(
'计算路径',
(source, target, routesList) =>
new Promise((resolve, reject) => {
setTimeout(() => {
console.log(`计算路径: ${source} ${target} ${routesList}`);
resolve();
}, 1000);
})
);
}
}
// 模拟插件执行
const compiler = new Compiler();
const myPlugin = new MyPlugin();
// 模拟 webpack.config.js 的 plugins 配置
const options = { plugins: [myPlugin] };
for (const plugin of options.plugins) {
if (typeof plugin === 'function') {
plugin.call(compiler, compiler);
} else {
plugin.apply(compiler); // 绑定事件
}
}
compiler.run(); // 触发事件
复制代码
webpack 流程篇:准备阶段
webpack 的打包流程可以分为三个阶段:
- 准备:初始化参数,为对应的参数注入插件
- 模块编译和打包
- 模块优化、代码生成和输出到磁盘。
webpack 的编译按照下面钩子的调用顺序进行:
- entry-option: 初始化 option。
- run: 开始编译。
- make: 从 entry 开始递归地分析依赖,对每个依赖模块进行 build。
- before-resolve: 对模块位置进行解析。
- build-module: 开始构建某个模块。
- normal-module-loader: 将 loader 加载完成的 module 进行编译,生成 AST 树。
- program: 遍历 AST,当遇到 require 等一些调用表达式,收集依赖。
- seal: 所有依赖 build 完成,开始优化。
- emit: 输出到 dist 目录。
entry-option
首先第一步,在目录下查询 entryOption
字符串的位置:
grep "\.entryOption\." -rn ./node_modules/webpack
复制代码
得到如下结果:
可以看到,在 EntryOptionPlugin
和 DllPlugin
中有绑定该 hook,在 WebpackOptionsApply
中触发该 hook。
WebpackOptionsApply
- 将所有的配置 options 参数转换成 webpack 内部插件。
如:
- options.externals 对应 ExternalsPlugin。
- options.output.clean 对应 CleanPlugin。
- options.experiments.syncWebAssembly 对应 WebAssemblyModulesPlugin。
- 绑定 entryOption hook 并触发它。
最后准备阶段以一张较为完整的流程图结束:
webpack 流程篇:模块构建和 chunk 生成阶段
相关的 hook
流程相关:
- (before-)run
- (before-/after-)compile
- make
- (after-)emit
- done
监听相关:
- watch-run
- watch-close
Compilation
Compiler 调用 Compilation 生命周期方法:
- addEntry -> addModuleChain
- finish(上报模块错误)
- seal
ModuleFactory
Compiler 会创建两个工厂函数,分别是 NormalModuleFactory 和 ContextModuleFactory,均继承 ModuleFactory。
- NormalModuleFactory: 普通模块名导入。
- ContextModuleFactory: 以路径形式导入的模块。
NormalModule
Build-构建阶段:
- 使用 loader-runner 运行 loaders 解析模块生成 js 代码。
- 通过 Parser 解析(内部使用 acron) 解析依赖,
- ParserPugins 添加依赖 所有依赖解析完成后,make 阶段就结束了。
具体构建流程
- compiler.compile: hooks.compile -> hooks.make(开始构建) -> compilation.addEntry(添加入口文件)。
查看绑定和触发 hooks.make 的地方:
-
模块构建完成后,触发 hook.finishMake -> compilation.finish -> compilation.seal -> hooks.afterCompile,最终得到经过 loaders(loader-runner) 解析生成的代码。
-
以 NormalModule 的构建为例,说说它的过程:
- 构建,通过 loader 解析:build -> doBuild -> runLoaders。
- 分析及添加依赖:parser.parse(将 loader 编译过得代码使用 acron 解析并添加依赖)。
- 将最终得到的结果存储到 compilation.modules。
- hook.finishMake 完成构建。
chunk 生成算法
- webpack 先将 entry 中对应的 module 都生成一个新的 chunk。
- 遍历 module 的依赖列表,将依赖的 module 也加入到 chunk 中。
- 如果一个依赖 module 是动态引入的模块,那么就会根据这个 module 创建一个新的 chunk,继续遍历依赖。
- 重复上面的过程,知道生成所有的 chunk。
webpack 流程篇:文件生成
完成构建后,执行 hooks.seal、hooks.optimize 对构建结果进行优化,优化完成后出触发 hooks.emit,将构建结果输出到磁盘上。
动手编写一个简易的 webpack
模块化:增强代码可读性和维护性
闭包 + 立即执行函数 → angularjs 的依赖注入 → nodejs 的 commonjs -> AMD -> es2015 的 e**odule。
- 传统的网页开发转变成 Web App 开发。
- 代码复杂度在逐步增高。
- 分离的 js 文件/模块,便于后续代码的维护。
- 部署时希望把代码优化成多个 HTTP 请求。
常见的几种模块化方式:
// e**odule
// 静态分析,不能动态,只能在文件最顶层导入。
import * as largeNumber from 'large-number';
largeNumber('99');
复制代码
// commonjs
// nodejs 默认遵循的规范,支持动态导入
const largeNumber = require('large-number');
largeNumber('99');
复制代码
// AMD
// 借鉴 commonjs,浏览器中经常使用
require(['large-number'], function (largeNumber) {
largeNumber.add('99');
});
复制代码
AST 基础知识
AST 即抽象语法树,是源代码的抽象语法结构的树状表现形式。
AST 的使用场景:
- 模板引擎实现的两种方式
- 正则匹配
- AST
- es6 → es5 或 ts → js
webpack 的模块机制
- 打包出来的是一个 IIFE(匿名闭包函数)。
- modules 是一个数组,每一项是一个模块初始化函数。
__webpack_require__
用来加载模块,返回module.exports
。- 通过
WEBPACK_REQUIRE_METHOD(0)
启动程序。
一个简易的 webpack 需要支持一下特性:
- 支持将 es6 转换成 es5。
- 通过
parse
生成 AST。 - 通过
transformFromAstSync
将 AST 重新生成 es5 源码。
- 通过
- 可以分析模块之间的依赖关系。
- 通过
traverse
的ImportDeclaration
方法获取依赖属性。
- 通过
- 生成的 js 文件可以在浏览器中运行。
编写步骤
- 编写
minipack.config.js
。 - 编写
parser.js
,实现将 es6 的代码转换成 AST,然后分析依赖,将 AST 转换成 es5 代码。 - 编写
compiler.js
,实现开始构建、构建模块、将结果输出到磁盘功能。
实现的源码我放在了这里,点击查看
编写 loader 和插件
loader 的链式调用和执行顺序
一个最简单的 loader 代码结构
定义:loader 是一个导出为函数的 js 模块:
module.exports = function (source) {
return source;
};
复制代码
多 loader 时的执行顺序
- 串行执行:前一个的执行结果会传递给后一个 loader。
- 按从右往左的顺序执行。
module.exports = {
module: {
rules: [
{
test: /\.less/,
use: ['style-loader', 'css-loader', 'less-loader'],
},
],
},
};
复制代码
函数组合的两种情况
- Unix 中的 pipline
- Compose
const compose =
(f, g) =>
(...args) =>
f(g(...args));
复制代码
验证 loader 顺序的执行
执行 yarn build
能查看 loader 日志打印的顺序。
使用 loader-runner 高效进行 loader 的调试
上一小节验证 loader 执行顺序的时候,需要先安装
webpack webpack-cli
,然后编写webpack.config.js
,将编写的 loader 引入对应的配置文件中,这个过程比较繁琐,可以使用更高效的 loader-runner,进行 loader 的开发和调试。它允许在不安装 webpack 的情况下运行 loader。
loader-runner 的作用:
- 作为 webpack 的依赖,webpack 中使用它执行 loader。
- 进行 loader 的开发和调试。
编写 raw-loader,使用 loader-runner 运行
实现 raw-loader
:
module.exports = function rawLoader(source) {
const str = JSON.stringify(source)
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029'); // 模板字符串存在安全问题,这里对模板字符串进行转义处理
return `export default ${str}`; // 将文件内容转换成模块
};
复制代码
在 loader-runner 中调用 raw-loader
:
const path = require('path');
const { runLoaders } = require('loader-runner');
const fs = require('fs');
runLoaders(
{
resource: path.join(__dirname, './src/info.txt'),
loaders: [path.join(__dirname, './loaders/raw-loader.js')],
context: { minimize: true }, // 接收的上下文
readResource: fs.readFile.bind(fs), // 读取文件的方式
},
(err, result) => {
if (err) {
console.log(err);
} else {
console.log(result);
}
}
);
复制代码
执行 node run-loader.js
,以下是输出结果:
更复杂的 loader 的开发场景
loader 的参数获取
通过 loader-utils
的 getOptions 方法获取。
修改 run-loader.js
,改成可以传递参数的形式:
runLoaders(
{
resource: path.join(__dirname, './src/info.txt'),
loaders: [
{
loader: path.join(__dirname, './loaders/raw-loader.js'),
options: { name: 'ywhoo' }, // 传递了 name 参数
},
],
context: { minimize: true },
readResource: fs.readFile.bind(fs),
},
(err, result) => {
if (err) {
console.log(err);
} else {
console.log(result);
}
}
);
复制代码
在 raw-loader.js
中使用该参数:
const loaderUtils = require('loader-utils');
module.exports = function rawLoader(source) {
const { name } = loaderUtils.getOptions(this); // 引入参数
console.log(name, 'name');
const str = JSON.stringify(source)
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
return `export default ${str}`;
};
复制代码
loader 的异常处理
同步:
- throw
- this.callback
- 返回处理结果
- 抛出异常
- 回传更多的值
// throw
throw new Error('error');
// this.callback
this.callback(err: Error | null, content: string | Buffer, sourceMap?: SourceMap, meta?: any);
复制代码
异步处理
在开发 loader 过程中,可能需要处理异步,如异步读取文件,等读取完成后将内容返回。
可以通过 this.async()
实现,修改 raw-loader
中的例子,增加异步文件的读取:
// ...
module.exports = function rawLoader(source) {
const callback = this.async();
fs.readFile(
path.join(__dirname, '../src/async.txt'),
'utf-8',
(err, data) => {
callback(null, data);
}
);
};
复制代码
在 loader 中使用缓存
- webpack 中默认开启 loader 缓存
- 使用
this.cacheable(false)
关闭默认缓存
- 使用
- 缓存条件:loader 的结果在相同的输入下有确定的输出
- 有依赖的 loader 无法使用缓存
关掉缓存:
module.exports = function () {
this.cacheable(false);
};
复制代码
在 loader 中输出文件到磁盘中
使用 this.emitFile
进行文件写入。
在 loader-a.js
中输出文件,最终会在 dist
下生成 demo.txt
:
module.exports = function (source) {
console.log('loader a is running');
this.emitFile('demo.txt', '在 loader 中输出文件。');
return source;
};
复制代码
或使用 loader-utils
,在本例中,会在 dist
下生成 index.js
:
const loaderUtils = require('loader-utils');
module.exports = function (source) {
console.log('loader a is running');
// 匹配出符合规则的文件名称,如这里会匹配到 index.js
const filename = loaderUtils.interpolateName(this, '[name].[ext]', source);
this.emitFile(filename, source);
return source;
};
复制代码
实战开发一个自动合成雪碧图的 loader
雪碧图的应用可以减少 http 的请求次数,有效提升页面加载的速度。
实现将多张图片合成一张,支持如下效果:
如何将两张图片合成一张图片
使用 sprite**ith
。
实现 sprite-loader
:
const path = require('path');
const Sprite**ith = require('sprite**ith');
const fs = require('fs');
module.exports = function (source) {
const callback = this.async();
const regex = /url\((\S*)\?__sprite\S*\)/g;
let imgs = source.match(regex); // [ "url('./images/girl.jpg?__sprite", "url('./images/glasses.jpg?__sprite" ]
imgs = imgs.map((img) => {
const imgPath = img.match(/\/(images\/\S*)\?/)[1];
return path.join(__dirname, '../src', imgPath);
});
Sprite**ith.run({ src: imgs }, function handleResult(err, result) {
// 将生成的图片写入 dist/sprites.jpg
// 在 webpack 中,应该使用 emitFile 来写入文件
fs.writeFileSync(
path.join(process.cwd(), 'dist/sprites.jpg'),
result.image
);
const code = source.replace(regex, (match) => "url('./sprites.jpg')");
// 输出 index.css
fs.writeFileSync(path.join(process.cwd(), 'dist/index.css'), code);
callback(null, code);
});
return source;
};
复制代码
在 loader-runner
中使用 sprite-loader
:
const path = require('path');
const { runLoaders } = require('loader-runner');
const fs = require('fs');
runLoaders(
{
resource: path.join(__dirname, './src/index.css'),
loaders: [
{
loader: path.join(__dirname, './loaders/sprites-loader.js'),
},
],
context: { minimize: true },
readResource: fs.readFile.bind(fs),
},
(err, result) => {
if (err) {
console.log(err);
} else {
console.log(result);
}
}
);
复制代码
执行 node run-sprites-loader.js
,结果如下:
body {
background: url('./sprites.jpg');
}
.banner {
background: url('./sprites.jpg');
}
复制代码
插件基本结构介绍
loader 负责处理资源,即将各种资源当成模块来处理。而插件可以介入 webpack 构建的生命周期中。
- 插件没有像 loader 那样独立的运行环境(loader-runner)。
- 只能在 webpack 里面运行。
例子:
module.exports = class MyPlugin {
constructor(options) {
console.log(options);
}
apply(compiler) {
console.log('执行 my-plugin');
}
};
复制代码
const path = require('path');
const MyPlugin = require('./plugins/my-plugin'); // 自定义插件
module.exports = {
entry: path.join(__dirname, './src/index.js'),
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, './dist'),
},
plugins: [new MyPlugin({ name: 'ywhoo' })],
};
复制代码
更复杂的插件开发场景
插件的错误处理
throw new Error('error');
。- 通过 compilation 对象的 warnings 和 errors 接收。
compilation.warnings.push('warning');
compilation.errors.push('error');
通过 Compilation 进行文件写入
文件的生成在 emit
阶段,可以监听 emit
,然后获取到 compilation
对象。
Compilation 上的 assets 可以用于文件写入
- 可以将 zip 资源包设置到 compilation.assets 对象上。
文件写入需要使用 webpack-sources
库,示例:
const { RawSource } = require('webpack-sources');
module.exports = class DemoPlugin {
constructor(options) {
this.options = options;
}
apply(compiler) {
const { name } = this.options;
compiler.compilation.hooks.emit.tap('emit', (compilation, cb) => {
compilation.assets[name] = new RawSource('demo');
cb();
});
}
};
复制代码
插件扩展:编写插件的插件
插件自身也可以通过暴露 hooks 的方式进行自身扩展,以 html-webpack-plugin 为例,它支持一下 hook:
- html-webpack-plugin-after-chunks(sync)
- html-webpack-plugin-before-html-generation(async)
- html-webpack-plugin-after-asset-tags(async)
- html-webpack-plugin-after-html-processing(async)
- html-webpack-plugin-after-emit(async)
实战开发一个压缩构建资源为 zip 包的插件
要求:
- 生成的 zip 包文件名称可以通过插件传入。
- 需要使用 compiler 对象上的 hooks 进行资源的生成。
准备知识
nodejs 里使用 jszip 创建和编辑 zip 包。
复习:Compiler 上负责文件生成的 hook
emit, 一个异步的 hook(AsyncSeriesHook)
emit 生成文件阶段,读取的是 compilation.assets 对象的值。
- 将 zip 资源包设置到 compilation.assets 对象上。
实现 zip-plugin.js
:
const JSZip = require('jszip');
const path = require('path');
const { Compilation, sources } = require('webpack');
module.exports = class ZipPlugin {
constructor(options) {
this.options = options;
}
apply(compiler) {
const { filename } = this.options;
// 监听 compilation 的 hooks
compiler.hooks.compilation.tap('ZipPlugin', (compilation) => {
// 监听 processAssets hook,即在处理构建资源的过程中,可以拿到静态资源
compilation.hooks.processAssets.tapPromise(
{
name: 'ZipPlugin',
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
},
(assets) => {
return new Promise((resolve, reject) => {
const zip = new JSZip();
// 创建压缩包
const folder = zip.folder(filename);
Object.entries(assets).forEach(([fname, source]) => {
// 将打包好的资源文件添加到压缩包中
folder.file(fname, source.source());
});
zip.generateAsync({ type: 'nodebuffer' }).then(function (content) {
// /Users/yewei/Project/source-code-realize/play-webpack/source/mini-plugin/dist/ywhoo.zip
const outputPath = path.join(
compilation.options.output.path,
`${filename}.zip`
);
// 相对路径 ywhoo.zip
const relativeOutputPath = path.relative(
compilation.options.output.path,
outputPath
);
// 将 buffer 转船 raw source
// 将 zip 包添加到 compilation 的构建资源中
compilation.emitAsset(
relativeOutputPath,
new sources.RawSource(content)
);
resolve();
});
}).catch((e) => {
console.log(e, 'e');
});
}
);
});
}
};
复制代码
使用插件:
const path = require('path');
const ZipPlugin = require('./plugins/zip-plugin');
module.exports = {
entry: path.join(__dirname, './src/index.js'),
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, './dist'),
},
plugins: [new ZipPlugin({ filename: 'ywhoo' })],
};
复制代码
运行 yarn build
,会在 dist 目录下生成 ywhoo.zip
。
总结
如果跟着实践笔记走到了这里,相信大家对 webpack 不再是那么陌生,而且对原理也有了一定程度的了解,能够编写 loader 处理静态资源,编写插件控制 webpack 构建过程中的每一个阶段。最后想说一句话与大家共勉,行路虽难,但贵在看到曙光的那一刻。