简介
我在之前的文章里专门说过 javascript 模块化发展的历史,webpack 其实 javascript 模块化开发历史的必然产物。随着 javascript 项目的复杂程度的增加,模块化就是必然的趋势。模块化的发展的过程肯定需要打包工具,webpack 应运而生。简而言之,webpack 就是为 javascript 模块化而生。
在 webpack 的世界里,不论是 javascript,还是样式文件,或者是图片以及其他静态资源,例如 svg / font 等等,都是可以打包的对象。官网首页的动图就很好的解释了这一切。
可以看到 bundle 后面的内容一直在变,简而言之:Bundle everything!
摘录一段 webpack 官网的文字:
At its core, webpack is astatic module bundler for modern JavaScript applications. When webpack processes your application, it internally builds a dependency graph which maps every module your project needs and generates one or morebundles.
里面的关键词有 static, dependency graph, map, bundle。也就是说 webpack 是静态的模块打包器,通过构建 dependency graph 来映射项目中的所有内容,然后生成一个或者多个 bundle。
前言
日常工作中我们对 webpack 的使用主要就是写配置文件。再具体点说,大部分情况下就是配置 loader 和 plugin,然后偶尔可能还会配置一下 optimization 和 dev 相关的内容。通常这类工作我们都可以通过官网或者搜索引擎搞定,所以关于如何配置 webpack 并不是本文的重点。本文的重点是通过对 webpack 中 loader, plugin 的探究,深入理解一些 webpack 中的进阶概念和内容。比如 loader 的概念和开发;loader 的类型:pre / post / inline loader ;特殊的 loader 形式:pitch 方法;loader 如何生成文件。plugin 是如何注册和执行的;如何写一个 plugin;plugin 背后的 tapable 是什么;webpack 是如何基于 tapable 的架构进行开发的;tapable 这种设计模式的优劣。熟悉完以上内容之后我们最后再回过头来看一下 webpack 背后的逻辑,然后亲自动手写一个简易的 webpack 让我们更加深入理解 webpack 的设计思路。最后我们再做下扫尾工作,学习一下 webpack 生态中一些常见的相关概念,比如 tree shaking / chunk split / pollyfill / shimming / optimization / hot module replacement / hash(介绍一下 hash 的类型,以及常见的如何使用) 等等。在写本文的时候 webpack 5.x release 了,然后如果还有精力和劲头的话,可以看看最新的 webpack v5.x 的新的特性和概念,比如比较火的 module federation 等,这部分内容可能会暂时缺席,等后面有空再研究,本文会持续更新。
我们先来说说 loader。
loader
在 webpack 的体系中,loader 相对其他概念而言是顶层和简单的内容。因为 loader 最终还是通过 plugin 的方式和 webpack 结合的。也就是说 loader 是基于 plugin 的,换句话说 plugin 比 loader 要更底层一些。而 plugin 或者说整个 webpack 更底层的东西是 tapable,tapable 也是整个 webpack 的设计架构,而 tapble 其实是一种架构思想,这个是最核心的内容(吐槽一下,这个 tapable 的 mental modal 和我们常见的 mental modal 很不同,理解这个的过程让人很抓狂,所以说 tapable 虽然是 webpack 的底层基础,但是并不代表 tapable 是最好的,有优势也要劣势,我们后面会讲到)。
官网中也说了,webpack 中的 loader 对比其他构建工具而言,对应的是 task 的概念,就是整个打包流程中的一个任务。虽然说 loader 是最表层和最简单的,但深入看下来也会发现这里面隐藏了很多复杂的细节。loader 的配置是倒序的,这个没什么说的。我们接下来先尝试写一个最简单的同步 loader。接着我们通过对官方的几个 loader 的分析,深入学习一下书写 loader 时的一些常见概念:比如loader 如何获取 option,如何对 option 进行校验,this.callback 和 this.async,什么时候采用异步 loader,loader 缓存。最后通过看 style-loader 的源码,引出 pitch loader 的概念,然后介绍一下 pitch loader 的概念、使用场景(目前我只在 style-loader 中看到了 pitch 的写法,感觉并不是很常用,但是这个方法很有意思,值得说一下)。在这个过程中我们会看到 pre-loader / post-loader / normal-loader / inline-loader 的概念,我们会用简单的代码来解释 pre-loader / post-loader 和 inline-loader,虽然感觉用不到,但是也还是说一下。还有 loader 中的 this,如生成文件的 this.emitFile,还有 this.addDependency 添加依赖方便打包 watch 等。
loader 初探
Loaders are transformations that are applied to the source code of a module. They are written as functions that accept source code as a parameter and return a new version of that code with transformations applied.
简单来说 loader 其实就是处理 source code 的一个函数,再具体一点,我们看看官方给出的例子:
const path = require('path');
module.exports = {
output: {
filename: 'my-first-webpack.bundle.js'
},
module: {
rules: [
{ test: /\.txt$/, use: 'raw-loader' }
]
}
};
"Hey webpack compiler, when you come across a path that resolves to a '.txt' file inside of a
require()/importstatement, use theraw-loaderto transform it before you add it to the bundle."
Loaders are transformations that are applied on the source code of a module. They allow you to pre-process files as you
importor “load” them. Thus, loaders are kind of like “tasks” in other build tools and provide a powerful way to handle front-end build steps.
也就是说 loader 生效的时机,是在 import 的时候。
对于每个 import 的 module,如果 rules 中对应的 test 生效,就用相应的 loader 处理这个文件。我们来写一个非常简单的 loader。
// simle-loader.js
exports.default = function(source) {
return source.replace(/world/, 'loader'); // 把源码中的 world 替换成 loader
};
修改一下 webpack.config.js 文件
{
test: /\.js$/,
use: [
path.resolve(__dirname, './loaders/simle-loader')
]
}
然后通过 npm run build 命令构建一下,可以看到所有 world 都会替换成 loader。
提一句,因为是自定义的 loader,所以用的是 path.resolve 的方式来定义 loader 的路径。也可以通过 resolveLoader 字段来定义获取 loader 的路径。
resolveLoader: {
modules: [path.resolve(__dirname, 'loaders'), 'node_modules']
}
但是实际中 loader 的功能要比这个丰富很多。
Loaders can transform files from a different language (like TypeScript) to JavaScript or inline images as data URLs. Loaders even allow you to do things like
importCSS files directly from your JavaScript modules!
A loader is just a JavaScript module that exports a function. The loader runner calls this function and passes the result of the previous loader or the resource file into it. The
thiscontext of the function is filled-in by webpack and the loader runner with some useful methods that allow the loader (among other things) to change its invocation style to async, or get query parameters.
也就是说 loader 除了可以处理原生的 javascript 之外,还可以处理其他内容,例如 css 和图片等等。loader 是如何处理的呢?简单来说,webpack 会用 loader-runner 模块来调用 loader,调用 loader 的时候,loader 函数中的 this 上会挂载很多 webpack 和 loader-runner 提供的方法方便我们来处理 source code。
我们通过 grep 方法可以找到 loader-runner 是在 node_modules/webpack/lib/NormalModule.js 中的 build 和 doBuild 方法中用到了。
✗ grep -rni 'require.*loader-runner' node_modules/webpack/lib
node_modules/webpack/lib/NormalModule.js:16:const { getContext, runLoaders } = require("loader-runner");
这个 NormalModule.js 就是生成编译代码的模块,就是说我们最后生成 bundle 的过程中有一步是把 source code 打入到 bundle 中,那么这个 source code 可能需要我们进行处理。这个 NormalModule.js 就是在这个过程中生效的。例如,我们通常用最新的语言特性来写代码,那么编译的时候通过它来将这些最新的语言特性转成 es5 或者更低版本的 javascript 以兼容古老的浏览器环境;我们 import 一个图片或者样式文件的时候,通过它来转成 javascript 的一个模块,进而形成 bundle 中的一个部分,etc。
我们可以再深究一下 NormalModule.js,看看 loader 到底是在什么时候生效的,并且做了什么内容,这个不作为重点,只是作为了解。
✗ grep -rni 'require.*normalModule"' node_modules/webpack/lib
node_modules/webpack/lib/AutomaticPrefetchPlugin.js:9:const NormalModule = require("./NormalModule");
node_modules/webpack/lib/dependencies/LoaderPlugin.js:8:const NormalModule = require("../NormalModule");
node_modules/webpack/lib/NormalModuleFactory.js:17:const NormalModule = require("./NormalModule");
AutomaticPrefetchPlugin 好像用的不是很多,没有再看。看看 LoaderPlugin.js 和 NormalModuleFactory.js。
最后深究下去可以看到 loader 也是以 plugin 的形式最终挂载到打包的过程中。并且 NormalModule.js 的执行时机是在 NormalModuleFactory.js 的 afterResolve。这里不再深入了,可以作为后续的研究内容。
官网中说 loader 函数的 this 上挂载了很多有用的内容,那么 this 上挂载的具体内容是什么,以及这些内容都怎么用。我们看看 loader 的更复杂和更高级的用法。
loader 详解
loader 详解这部分我们直接来看官方 loader 的源码,从这些 loader 源码中引出 loader 的常见用法和复杂内容。
我们先看两个简单的同步 loader:file-loader 和 url-loader(url-loader 会依赖 file-loader)。后面我们会以 .scss 文件为例,来看看样式文件相关的 loader:sass-loader, css-loader 和 style-loader。
通过查看这些 loader 的源码,我们可以看到 loader 中的一些常见用法和一些更复杂的概念。
先看 file-loader。
npmjs 中 file-loader 的介绍是:
The
file-loaderresolvesimport/require()on a file into a url and emits the file into the output directory.
也就是说 file-loader 有两个作用:第一,把 import / require 的文件放置到 output 中;第二,将 import / require 的内容转成这个文件的路径返回回来。
举个例子。配置如下内容
{
test: /\.svg$/,
use: [
'file-loader'
]
}
然后在 js 文件中引入一个 .svg 文件
import file from 'test.svg'
console.log(file);
打包之后在 dist 目录下可以找到这个 svg 文件,只不过以 hash 的方式修改了名称,并且在 js 文件中打印出来这个 file 变量就是这个文件在 dist 目录下的名称。当然这个 loader 也提供了很多可以配置的选项。具体的可以参考官方文档,不再详述。
我们来看看 file-loader 的源码,来学习一下 loader 的写法。我们以 v6.1.0 的版本为例:
exports.default = loader; // 导出 loader 函数
// ... 略去一些不重要的内容
var _loaderUtils = _interopRequireDefault(require("loader-utils")); // 主要用来获取 option
var _schemaUtils = _interopRequireDefault(require("schema-utils")); // 对 option 进行校验
var _options = _interopRequireDefault(require("./options.json")); // 检验规则
// ... 略去一些不重要的内容
function loader(content) { // 负责处理业务逻辑的 loader 函数
const options = _loaderUtils.default.getOptions(this);
(0, _schemaUtils.default)(_options.default, options, {
name: 'File Loader',
baseDataPath: 'options'
});
const context = options.context || this.rootContext;
const name = options.name || '[contenthash].[ext]';
const immutable = /\[([^:\]]+:)?(hash|contenthash)(:[^\]]+)?\]/gi.test(name);
const url = _loaderUtils.default.interpolateName(this, name, {
context,
content,
regExp: options.regExp
});
let outputPath = url;
if (options.outputPath) { // 获取 output
if (typeof options.outputPath === 'function') {
outputPath = options.outputPath(url, this.resourcePath, context);
} else {
outputPath = _path.default.posix.join(options.outputPath, url);
}
}
let publicPath = `__webpack_public_path__ + ${JSON.stringify(outputPath)}`;
// 注意,这里的 __webpack_public_path__ 是一个全局的变量,详细说明参见下面链接
// https://webpack.js.org/guides/public-path/#on-the-fly
// https://webpack.js.org/configuration/output/#outputpublicpath
// https://github.com/webpack/webpack/issues/2776#issuecomment-233208623
// https://medium.com/front-end-weekly/change-webpacks-publicpath-on-the-fly-8c7166a031f9 if (options.publicPath) { // 获取 publicPath
if (typeof options.publicPath === 'function') {
publicPath = options.publicPath(url, this.resourcePath, context);
} else {
publicPath = `${options.publicPath.endsWith('/') ? options.publicPath : `${options.publicPath}/`}${url}`;
}
publicPath = JSON.stringify(publicPath);
}
if (options.postTransformPublicPath) {
publicPath = options.postTransformPublicPath(publicPath);
}
if (typeof options.emitFile === 'undefined' || options.emitFile) {
this.emitFile(outputPath, content, null, { // 在 outputPath 中生成 file
immutable
});
}
const esModule = typeof options.esModule !== 'undefined' ? options.esModule : true;
return `${esModule ? 'export default' : 'module.exports ='} ${publicPath};`; // 返回 publicPath
}
file-loader 的源码内容不多,看完之后其实你会发现这个 loader 的两个业务逻辑在源码中已经有很好的体现了。而且如何在 loader 中获取 option,验证 option 的方法在 file-loader 中也有很好的体现。比较特殊的是这里的 this.emitFile 方法,是挂载在 this 上的特殊方法,就是表示生成文件。
我们接下来看 url-loader,源码中我省去了一些不重要的逻辑处理
url-loaderworks likefile-loader, but can return a DataURL if the file is smaller than a byte limit.
也就是说 url-loader 和 file-loader 唯一的区别就是当文件 size 小于某个值的时候返回 DataURL,直接写在文件中。当大于这个值的时候就是用 file-loader 来处理这个文件。
// 候补选项,默认就是 file-loader
var _normalizeFallback = _interopRequireDefault(require("./utils/normalizeFallback"));
function shouldTransform(limit, size) {}
function getMimetype(mimetype, resourcePath) {}
function getEncoding(encoding) {}
function getEncodedData(generator, mimetype, encoding, content, resourcePath) {}
function loader(content) {
// ... 略去获取 option,校验 option 等非主要代码
// 下面是转 base64 的逻辑
if (shouldTransform(options.limit, content.length)) {
const {
resourcePath
} = this;
const mimetype = getMimetype(options.mimetype, resourcePath);
const encoding = getEncoding(options.encoding);
if (typeof content === 'string') {
// eslint-disable-next-line no-param-reassign
content = Buffer.from(content);
}
const encodedData = getEncodedData(options.generator, mimetype, encoding, content, resourcePath);
const esModule = typeof options.esModule !== 'undefined' ? options.esModule : true;
return `${esModule ? 'export default' : 'module.exports ='} ${JSON.stringify(encodedData)}`;
} // Normalize the fallback.
// 下面的代码是 fallback 的逻辑
const {
loader: fallbackLoader,
options: fallbackOptions
} = (0, _normalizeFallback.default)(options.fallback, options); // Require the fallback.
// eslint-disable-next-line global-require, import/no-dynamic-require
const fallback = require(fallbackLoader); // Call the fallback, passing a copy of the loader context. The copy has the query replaced. This way, the fallback
// loader receives the query which was intended for it instead of the query which was intended for url-loader.
const fallbackLoaderContext = Object.assign({}, this, {
query: fallbackOptions
});
return fallback.call(fallbackLoaderContext, content);
}
通过上面的 loader 我们可以看到 loader 的常用写法和一些常见的 api 使用场景。我们接着来看下样式文件的 loader:style-loader,css-loader,sass-loader。
因为 loader 在处理文件的时候是按照配置的方向,从右向左调用的。所以我们看源码的时候也是按照从右向左的方向来看,先看 sass-loader,再看 css-loader,最后看 style-loader。
先看 sass-loader。
Loads a Sass/SCSS file and compiles it to CSS.
sass-loader 的作用就是把 sass/scss 文件转成 css 文件。我们来看源码,v10.0.2 的版本:
var _utils = require("./utils");
function loader(content) {
const implementation = (0, _utils.getSassImplementation)(options.implementation); // 获取处理器,sass 或者 node-sass
const useSourceMap = typeof options.sourceMap === 'boolean' ? options.sourceMap : this.sourceMap;
const sassOptions = (0, _utils.getSassOptions)(this, options, content, implementation, useSourceMap);
const callback = this.async(); // 异步 loader 的写法
const render = (0, _utils.getRenderFunctionFromSassImplementation)(implementation);
render(sassOptions, (error, result) => { // 处理 sass 文件,转成 css
if (error) {
if (error.file) {
this.addDependency(_path.default.normalize(error.file));
}
callback(new _SassError.default(error));
return;
}
let map = result.map ? JSON.parse(result.map) : null;
if (map && useSourceMap) {
map = (0, _utils.normalizeSourceMap)(map, this.rootContext);
}
result.stats.includedFiles.forEach(includedFile => {
this.addDependency(_path.default.normalize(includedFile)); // 对 scss 文件中的引用依次添加到依赖中
});
callback(null, result.css.toString(), map); // 返回处理后的结果
});
}
这里我们看几个常见的处理方式,一个是 this.async() 和 this.callback,这个内容我们单说,先说说 this.addDependency。
关于 this.addDependency 的说明,见官网 webpack.js.org/api/loaders…
Add a file as dependency of the loader result in order to make them watchable. For example,
sass-loader,less-loaderuses this to recompile whenever any importedcssfile changes.
主要的目的就是为了让这些代码也添加进依赖中,便于 wachable,开发的时候能随时触发热更新。
接着我们再来看下 css-loader,
The
css-loaderinterprets@importandurl()likeimport/require()and will resolve them.
css-loader 的源码比较复杂,大概的逻辑就是用 postcss 的 plugin 来处理 css 代码。这个不看太多,简单了解下 this.emitWarning
关于这个 this 的 api 文档,看这里 webpack.js.org/api/loaders…
直接看 loader 的源码,然后一边看一边演示。不是很重要的有:loader-utils 里面的 getOptions,还有 schema-utils 来验证 options。常见的有:this.callback,this.cacheble(), this.emitFile。特殊的形式:pitch 方法。不常见的:this.addDependency,不是重点。
关于如何写一个 loader,看这个文档:webpack.js.org/contribute/…
loader-utils 就是一些常见的 util,比如可以获取给 loader 传入的 options。schema-utils 是用来验证 options 参数是否正确。
如果是同步 loader,可以直接在 loader 函数中 return 内容,也可以通过 this.callback 返回内容。当要返回的内容除了 source code 之外,还有其他内容时就用 this.callback 的形式。参照官网的说明:
this.callback(
err: Error | null,
content: string | Buffer,
sourceMap?: SourceMap,
meta?: any
);
关于第四个参数,webpack 建议可以考虑传入 AST,了解即可,一般用不到。
It can be useful to pass an abstract syntax tree (AST), like
ESTree, as the fourth argument (meta) to speed up the build time if you want to share common ASTs between loaders.
this.cacheable 是用来缓存 loader 处理结果的。这部分的内容也参照官网说明。
this.emitFile 就是用来在最后的打包内容中添加一个文件,以 file-loader 为例:
The
file-loaderresolvesimport/require()on a file into a url and emits the file into the output directory.
最后看到过 this.addDependency,这个作为了解即可。看官方说明。
同步 loader / 异步 loader
loader 分为同步 loader 和异步 loader,如果 loader 对于源码的处理不是很耗时,那么用同步 loader 就可以了。但是对于一些比较耗时的计算过程,就建议采用异步 loader 的方式。
A single result can be returned in sync mode. For multiple results the
this.callback()must be called. In async modethis.async()must be called to indicate that the loader runner should wait for an asynchronous result. It returnsthis.callback(). Then the loader must returnundefinedand call that callback.
Loaders were originally designed to work in synchronous loader pipelines, like Node.js (using enhanced-require), and asynchronous pipelines, like in webpack. However, since expensive synchronous computations are a bad idea in a single-threaded environment like Node.js, we advise making your loader asynchronous if possible. Synchronous loaders are ok if the amount of computation is trivial.
我们尝试来写一个异步 loader。
// loader2.js
exports.default = function(source) {
const callback = this.async(); // 通过 this.async() 定义 callback
setTimeout(() => { // 执行耗时操作
callback(null, source.replace('loader1', '')); // 第一个参数是 error,这里没有错误,我们就传递 null
}, 2000);
}
添加一下 loader 的配置,除了同步 loader 之外,再添加一下异步 loader,并且打印一下同步 loader 和异步 loader 的时间间隔。
{
test: /\.js$/,
use: [
'loader1',
'loader2',
'loader3'
]
}
// loader3.js
exports.default = function(source) {
const callback = this.callback();
// 打印相关信息
console.log('loader2', this.resourcePath);
console.time();
callback(null, source.replace('loader2', ''));
}
我们这里打印了 this.resourcePath,这里的 this 是 loader context,上面绑定了很多相关信息,除了 resourcePath,还有 version, context, data, request, query 等等,具体参见文档。
loader3.js 和 loader2.js 是基本一样的,只是没有打印内容。
// loader3.js
exports.default = function(source) {
console.log('loader3', this.resourcePath);
console.time();
const callback = this.callback();
callback(null, source.replace('loader3', ''));
}
然后我们执行 npm run build,会打印以下信息。
loader3 /Users/xxx/webpackTest/src/index.js
loader2 /Users/xxx/webpackTest/src/index.js
default: 2005.436ms
loader1 /Users/xxx/webpackTest/src/index.js
loader3 /Users/xxx/webpackTest/src/headerTxt.js
loader2 /Users/xxx/webpackTest/src/headerTxt.js
default: 2002.881ms
loader1 /Users/xxx/webpackTest/src/headerTxt.js
并且打印的过程也是可以看到是
实际中哪些 loader 是异步 loader 呢?比如 sass-loader
这里其实就可以有一个优化的地方,就是对于某些比较耗时的操作,如果某个文件确定不需要这个 loader 处理的话,其实可以通过 exclude 的方式排除掉这些文件,避免 loader 对不必要处理的文件进行处理。
可以通过 loader-utils 来获取 options,还可以通过 schema-utils 对 options 进行验证。这个应该比较简单,不赘述了。
this.addDependency
关于 this.addDependency 的信息看下面两个内容:
If a loader uses external resources (i.e. by reading from filesystem), they must indicate it. This information is used to invalidate cacheable loaders and recompile in watch mode. Here's a brief example of how to accomplish this using the
addDependencymethod:
感觉更像是给 loader 用的 dependency 管理方法。
zenorocha.github.io/book-of-mod…
然后再看下如何引入 css 文件,以 scss 文件为例。分别看下 sass-loader, css-loader 和 style-loader。主要看 this.async()。并且引出 style-loader 的 pitch 方法。
style-loader 和 loader.pitch()
看完上面的普通 loader,我们再来看一个比较特殊的 loader: style-loader
通常我们引入样式文件都是通过 css-loader 和 style-loader 来对 css 文件进行处理,最多再加一个预处理器 loader,以 scss 文件为例,配置如下:
{
test: /\.scss$/, // 以 scss 为例
use: [
'style-loader',
'css-loader',
'sass-loader'
]
}
sass-loader 的作用就是把 scss 的写法编译成 css。
css-loader 的作用就是把 css 代码中的 @import 和 url 转成 import/require 的方式。
The
css-loaderinterprets@importandurl()likeimport/require()and will resolve them.
最后 style-loader 的作用就是把最终的 css 代码插入到 HTML 文档中。
sass-loader 和 css-loader 的写法和我们上面的写法一样, 但是我们去查看 style-loader 的时候,会发现 sytle-loader 的代码是下面这种形式,和我们常见的 loader 的写法不同。
const loaderApi = () => {};
loaderApi.pitch = function loader(request) {...} // 一个从未见过的 pitch 方法
export default loaderApi;
最后导出的是一个空函数,就是什么也没有的函数。但是这个函数有个 pitch 方法。
这就不得不提 pitch loader。
详见 webpack.js.org/api/loaders…
我们知道,通常配置中 loader 的执行顺序是逆序的,例如xxx
loader 的处理顺序是,sass-loader 处理完给 css-loader,然后 css-loader 给 style-loader,但是 Pitch loader 正好相反。
stackoverflow.com/questions/5…
最后 style-loader 实际上把 style.scss 转成了这样的一段代码:
var api = require("!../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js");
var content = require("!!../node_modules/css-loader/dist/cjs.js!../node_modules/sass-loader/dist/cjs.js!./styles.scss");
content = content.__esModule ? content.default : content;
if (typeof content === 'string') {
content = [[module.id, content, '']];
}
var options = {};
options.insert = "head";
options.singleton = false;
var update = api(content, options);
module.exports = content.locals || {};
那这里也会引入另外一个内容,就是 inline-loader,也就是上面的 !../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js 和 !!../node_modules/css-loader/dist/cjs.js!../node_modules/sass-loader/dist/cjs.js!./styles.scss 的方式。这里就是通过 inline 的方式指定 loader。具体的参见下面两个内容
loader 的其他相关内容
interpolateName / webpack_output_path