本次讲解以 webapck 5.75.0 为基础进行演示,将从loader的 相关概念,分类,使用方式,手写练习,执行原理 五个章节为大家展开讲解。老规矩,相关案例代码已全部上传至 Git ,欢迎自取,不嫌麻烦的话欢迎点个star。接下来闲言少叙,大家坐稳扶好,我们发车!
本章节将通过 定义,基础结构 的讲解让大家对loader有基本的认知。
我们之前用大白话聊过loader的作用:loader是一个翻译,把webpack不能直接处理的资源,翻译成能直接处理的。究其本质loader到底是什么?这里有一段 官方 的定义:
loader 本质上是导出为函数的 JavaScript 模块。loader runner 会调用此函数,然后将上一个 loader 产生的结果或者资源文件传入进去。函数中的 this
作为上下文会被 webpack 填充,并且 loader runner 中包含一些实用的方法,比如可以使 loader 调用方式变为异步,或者获取 query 参数。
到这里就非常清楚了,loader的本质就是函数模块,既然是函数,我们关注这个函数的入参,出参,功能,即可。接下来我们搭建webpack的基础环境,给大家展示最基本的loader结构。
一个loader的基础结构如下所,其中 map 和 meta 是可选参。
/**
*
* @param {string|Buffer} content 源文件的内容
* @param {object} [map] 可以被 https://github.com/mozilla/source-map 使用的 SourceMap 数据
* @param {any} [meta] meta 数据,可以是任何内容
*/
function webpackLoader(content, map, meta) {// 你的 webpack loader 代码
}
module.exports = webpackLoader
为了更直观的演示,这里我们搭建好自己的webpack环境。
yarn add webpack webpack-cli -D
├─ src
│├─ loaders
││ └─ loader1.js
│└─ index.js
├─ webpack.config.js
├─ .gitignore
├─ package.json
└─ README.md
const path = require('path')
// 相对路径转绝对路径
const resolvePath = _path => path.resolve(__dirname, _path)
module.exports = {entry: resolvePath('./src/index.js'),output: {path: resolvePath('./dist'),clean: true},module:{rules:[{test: /\.js$/,loader: resolvePath('./src/loaders/loader1.js')}]},mode: 'development'
}
const loader1 = function (content, map, meta) {console.log('loader1',content)return content
}
module.exports= loader1
console.log('Hello Cengfan!')
环境搭建完成,打开终端运行webpack看看控制台会输出什么内容。
可以看到 src/index.js 中的内容被原封不动地打印出来了,这个就是参数 content 的内容。它被处理后,会输出交给下一个loader继续调用。 如此,一个最基础的loader结构就展示出来了,接下来我们看看loader的分类。
本章节将按照 执行顺序,示例 跟大家讲解loader的分类。
loader在执行顺序上分为以下4类,仅需有印象即可,后续会进行大量的演示。
pre:前置loader* normal:普通loader* inline:内联loader* post:后置loaderloader的执行顺序遵循以下原则:
默认的执行优先级为 pre,normal,inline,post* 相同优先级的loader执行顺序为 从右往左,从下往上了解loader的基本分类后,我们来看看它的示例。
接下来更改项目中的文件,用以演示loader的默认执行顺序。
├─ src
│├─ loaders
││ ├─ loader1.js
││ ├─ loader2.js
││ ├─ loader3.js
│└─ index.js
├─ webpack.config.js
├─ .gitignore
├─ package.json
└─ README.md
const loader2 = function (content, map, meta) {console.log('loader2')return content}module.exports= loader2
const loader3 = function (content, map, meta) {console.log('loader3')return content}module.exports= loader3
module.exports = {// ...module:{rules:[{test: /\.js$/,loader: resolvePath('./src/loaders/loader1.js')},{test: /\.js$/,loader: resolvePath('./src/loaders/loader2.js')},{test: /\.js$/,loader: resolvePath('./src/loaders/loader3.js')}]},
}
完成后终端执行webpack,查看输出结果。
从打印的结果来看loader的执行顺序符合 从下往上,从右往左 的原则。接下来我们更改 rule 对象的 enforece 属性来更改loader的执行顺序。
更改 webpack.config.js 的内容
module.exports = {// ...module:{rules:[{test: /\.js$/,loader: resolvePath('./src/loaders/loader1.js'),enforce: 'pre'},{// 无enforce属性,默认为 normal loadertest: /\.js$/,loader: resolvePath('./src/loaders/loader2.js'),},{test: /\.js$/,loader: resolvePath('./src/loaders/loader3.js'),enforce: 'post'}]},
}
运行webpack查看结果。loader的打印结果从原先的 loader3,loader2,loader1 变成了倒序输出。输出优先级与loader中 enforce 的属性值相关,优先级为 pre,normal,post loader。
内联的 inline loader 不在webpack的配置文件中配置,它仅在业务代码中配置且使用不多,所以不展开讲解。到这里我们已经清楚了loader的基础结构和执行顺序,接下来我们看看loader的使用方式。
loader在使用方式上分为 同步 loader,异步 loader,raw loader,pitch loader 这4类,在正式开始讲解前我们先对webpack的配置文件进行优化工作。
在当前配置文件中,所有自定义loader的使用都需要用 resolvePath 这个方法指定loader的使用路径。这里我们可以修改配置文件,新增 resolveLoader 属性,让webpack默认寻找自定义的loader文件夹,之后直接使用loader的名称即可。
module.exports = {// ...module:{rules:[{test: /\.js$/,loader: 'loader1',enforce: 'pre'},{// 无enforce属性,默认为 normal loadertest: /\.js$/,loader: 'loader2',},{test: /\.js$/,loader: 'loader3',enforce: 'post'}]},resolveLoader:{modules:[// 默认在 node_modules 与 src/loaders 的目录下寻找loader'node_modules',resolvePath('./src/loaders')]},
}
运行webpack查看结果,loader内容正常输出。
优化工作完毕,接下来我们正式进入loader使用方式的学习。
同步loader顾名思义在整个loader的执行流程中为同步执行。它的使用非常简单,我们用 loader1 举例,修改它的内容。
const loader1 = function (content, map, meta) {console.log('loader1')/* param1:error 是否有错误param2:content 处理后的内容param3:source-map 信息可继续传递 source-mapparam4:meta 给下一个 loader 传递的参数*/this.callback(null, content, map, meta)
}
module.exports = loader1
使用 this.callback 方法替代原先的 return 语句,运行webpack查看结果。
同步loader的使用仅仅是替换了 return 语句,使用起来非常简单,接下来我们看看异步loader的使用。
异步loader并不是让渡当前loader的执行权力,给下一个loader先执行。而是卡住当前的执行进程,方便你在异步的时间里去进行一些额外的操作。待这些操作完成后,任务进程交给下一个loader。 接下来我们演示异步loader,为了演示方便,先去除配置文件中的 enforce 配置。
module.exports = {// ...module:{rules:[{test: /\.js$/,loader: 'loader1',// enforce: 'pre'},{// 无enforce属性,默认为 normal loadertest: /\.js$/,loader: 'loader2',},{test: /\.js$/,loader: 'loader3',// enforce: 'post'}]},// ...
}
修改 loader2 的内容,之后运行webpack查看输出结果。
const loader2 = function (content, map, meta) {console.log('loader2')// this.async 告诉 loader-runner 这个 loader 将会异步地回调。返回 this.callback。const callback = this.async()setTimeout(() => {console.log('async loader2')// 调用 callback 后,才会执行下一个 loadercallback(null, content, map, meta)},500)
}
module.exports = loader2
可以看到这里先输出了 loader3,loader2 ,500ms后输出了 async loader2,loader1 。这说明在异步loader执行完成之前,是不会执行下一个loader的这里的异步loader执行机制可以用 async 中遇到 await 就暂停运行,等待 await 返回结果后才运行后续的代码去类比理解。
下面我们进行异步loader错误的使用演示,在异步中使用同步loader输出。
const loader3 = function (content, map, meta) {console.log('loader3')setTimeout(() => {console.log('async loader3')this.callback(null, content, map, meta)},500)
}
module.exports = loader3
setTimeout(() => {console.log('async loader2', content)// 调用 callback 后,才会执行下一个 loadercallback(null, content, map, meta)},500)
可以看到 loader2 先于 async loader3 输出,由于执行过程未等待,content 也没有传入 loader2 中,所以打印值为 undefined。
这个就是异步loader的使用,接下来看看raw loader的使用。
raw loader 一般用于处理 Buffer 数据流的文件。在处理图片,字体图标等经常会使用它,这里为了演示方便,我们用它处理js。
const rawLoader = function(content) {console.log(content)return content
}
rawLoader.raw = true
module.exports = rawLoader
module.exports = {// ...module:{/* rules:[{test: /\.js$/,loader: 'loader1',// enforce: 'pre'},{// 无enforce属性,默认为 normal loadertest: /\.js$/,loader: 'loader2',},{test: /\.js$/,loader: 'loader3',// enforce: 'post'}] */rules:[{test: /\.js$/,loader: 'raw-loader',}]},// ...
}
运行webpack查看结果。
这就是 raw-loader 接下来我们看看 pitch-loader。
loader模块中导出函数的 pitch 属性指向的函数就叫 pitch loader。它的使用场景是 当前loader依赖上个loader的输出结果,且该结果为js而非webpack处理后的资源。 此时loader的逻辑处理更适合放在pitch loader。记住它的使用场景,下一章节我们手写 style-loader 时会进行详细讲解。
本章节将通过 基础结构,执行顺序,熔断机制,函数入参 这些小节,跟大家详细讲解 pitch loader。
它的基础结构如下,参数均为可选参。
/** * @remainingRequest 剩余请求 * @precedingRequest 前置请求 * @data 数据对象
*/
function (remainingRequest, precedingRequest, data) { // code
};
loader的执行顺序是 从右往左,从下往上 ,但pitch loader的执行顺序正好相反,它是 从左往右,从上往下 。接下来我们分步写好pitch loader来观察它的执行顺序。
const loader1 = function (content, map, meta) {console.log('loader1')/* param1:error 是否有错误param2:content 处理后的内容param3:source-map 信息可继续传递 source-mapparam4:meta 给下一个 loader 传递的参数*/this.callback(null, content, map, meta)
}
const pitch1 = function() {console.log('pitch loader1')
}
module.exports = loader1
module.exports.pitch = pitch1
const loader2 = function (content, map, meta) {console.log('loader2')// this.async 告诉 loader-runner 这个 loader 将会异步地回调。返回 this.callback。const callback = this.async()setTimeout(() => {console.log('async loader2')// 调用 callback 后,才会执行下一个 loadercallback(null, content, map, meta)},500)
}
const pitch2 = function() {console.log('pitch loader2')
}
module.exports = loader2
module.exports.pitch = pitch2
const loader3 = function (content, map, meta) {console.log('loader3')this.callback(null, content, map, meta)
}
const pitch3 = function(remainingRequest, precedingRequest, data) {console.log('pitch loader3')
}
module.exports = loader3
module.exports.pitch = pitch3
module.exports = {// ...module:{rules:[{test: /\.js$/,loader: 'loader1',// enforce: 'pre'},{// 无enforce属性,默认为 normal loadertest: /\.js$/,loader: 'loader2',},{test: /\.js$/,loader: 'loader3',// enforce: 'post'}]/* rules:[{test: /\.js$/,loader: 'raw-loader',}] */},// ...
}
运行webpack,查看输出结果。
这下明白了吧,pitch loader 会先于 normal loader 执行,下图即为它们的执行顺序。
上述都是pitch loader无返回值时的执行顺序,如果在整个执行链中,某个pitch loader有返回值,执行顺序又会发生改变。我们修改 loader2 中的pitch loader之后运行webpack查看输出结果。
const pitch2 = function() {console.log('pitch loader2')return 'cengfan'
}
执行到 loader2 时由于 pitch loader2 有返回值,导致后面所有的loader都不再执行,转而回到上一个loader的 normal loader。执行顺序如下图,这就是loader执行的熔断机制。
演示完毕,接下来为了其他演示效果正常,这里我们去掉loader2中的return语句。
接下来我们处理 loader1,loader2,loader3 加上对应的输出语句和pitch loader的入参,以此进一步了解pitch loader。
const loader1 = function (content, map, meta) {console.log('loader1',content, map, meta)/* param1:error 是否有错误param2:content 处理后的内容param3:source-map 信息可继续传递 source-mapparam4:meta 给下一个 loader 传递的参数*/this.callback(null, content, map, meta)
}
const pitch1 = function(remainingRequest, precedingRequest, data) {console.log('pitch loader1')console.log('remainingRequest:',remainingRequest, 'precedingRequest:',precedingRequest, 'data:',data)
}
module.exports = loader1
module.exports.pitch = pitch1
const loader2 = function (content, map, meta) {console.log('loader2')// this.async 告诉 loader-runner 这个 loader 将会异步地回调。返回 this.callback。const callback = this.async()setTimeout(() => {console.log('async loader2')// 调用 callback 后,才会执行下一个 loadercallback(null, content, map, this.data.value)},500)
}
const pitch2 = function(remainingRequest, precedingRequest, data) {data.value = 999console.log('pitch loader2')console.log('remainingRequest:',remainingRequest, 'precedingRequest:',precedingRequest, 'data:',data)
}
module.exports = loader2
module.exports.pitch = pitch2
const loader3 = function (content, map, meta) {console.log('loader3')this.callback(null, content, map, meta)
}
const pitch3 = function(remainingRequest, precedingRequest, data) {console.log('pitch loader3')console.log('remainingRequest:',remainingRequest, 'precedingRequest:',precedingRequest, 'data:',data)
}
module.exports = loader3
module.exports.pitch = pitch3
执行webpack查看输出结果。
这里的输出结果分两步分讲解,首先是 remainingRequest:剩余请求;precedingRequest:前置请求,然后是 data。
由于输出内容太长且不直观,我把每个loader remainingRequest,precedingRequest 的输出结果汇总成了下表。帮助大家理解这两个参数的含义。
remainingRequest | precedingRequest | |
---|---|---|
pitch loader1 | index.js;loader2.js;loader3.js | |
pitch loader2 | index.js;loader3.js | loader1.js |
pitch loader3 | index.js | loader1.js;loader2.js |
你会发现这哥俩的含义属实是顾名思义了,就是告诉你当前的pitch loader中有哪些未执行的请求,和已经执行的请求。
前两个参数聊完后我们看看data,它可用于捕获并共享前面的信息。注意图中标红的地方,我们在 loader2 的 pitch loader 中添加了 data.value 属性。并在其异步调用的 callback 中将data作为参数传入以供loader1后续使用。之后我们在 loader1 中进行了data的输出。
这就是它捕获共享的作用。到这里我们已经讲解完pitch loader的3个参数了。稍后我们在手写练习中通过 style loader 对 pitch loader 的使用场景进行详细的解析。
在对loader有个大概的了解后,我们来手写 clean-log-loader,banner-loader,babel-loader,style-loader 这几个loader。
本loader用于清除webpack打包时检测到的所有console语句。接下来我们分步实现它。
├─ src
│├─ loaders
││ ├─ clean-log-loader
││ │└─ index.js
││ ├─ loader1.js
││ ├─ loader2.js
││ ├─ loader3.js
││ └─ raw-loader.js
│└─ index.js
const cleanLogLoader = function(content) {// 使用正则将 content 文件中所有的 console 语句替换成空return content.replace(/console\.log\(.*\);?/g,'')
}
module.exports = cleanLogLoader
module.exports = {// ...module: {rules:[{test:/\.js$/,loader:'clean-log-loader'}]/* rules: [{test: /\.js$/,loader: 'loader1',// enforce: 'pre'}, {// 无enforce属性,默认为 normal loadertest: /\.js$/,loader: 'loader2',}, {test: /\.js$/,loader: 'loader3',// enforce: 'post'}] *//* rules:[{test: /\.js$/,loader: 'raw-loader',}] */},// ...
}
这里我们注释之前使用的loader。执行webpack查看打包结果。
可以看到 src/index.js 中的 console.log(‘Hello Cengfan!’) 语句在打包后被清除,如果我们不引用 clean-log-loader,它会被重新打包。
如此,一个基础的 clean-log-loader 便完成了,那这个loader还能整点活儿出来吗?是可以的,比如我们在开发中,希望保留一些关键的输出信息区别于一般的console。此时我们可以这样修改自己的loader。
const cleanLogLoader = function(content) {// 使用正则将 content 文件中所有不带 '@' 的 console 语句替换成空return content.replace(/console\.log\([^@]*\);?/g,'')
}
module.exports = cleanLogLoader
console.log('Hello Cengfan!')
console.log('@','Hello Cengfan!')
执行webpack,查看输出结果,可以看到只有带 ‘@’ 符号的 console 语句被保留了下来。
这里想区别于其他console的符号你可以自定义,只要把 clean-log-loader 中 replace 的正则匹配修改成你想用的符号即可。
好了到这里我们的 clean-log-loader 就正式写完了,接下来我们开始写下一个loader。
本loader用于给代码添加注释信息,如作者姓名等。接下来我们分步实现它。
├─ src
│├─ loaders
││ ├─ banner-loader
││ │├─ index.js
││ │└─ schema.json
││ ├─ clean-log-loader
││ │└─ index.js
││ ├─ loader1.js
││ ├─ loader2.js
││ ├─ loader3.js
││ └─ raw-loader.js
│└─ index.js
{"type":"object","properties":{"author":{"type": "string"},"age":{"type": "number"}},"additionalProperties" : false
}
const schema = require('./schema.json')
const bannerLoader = function (content) {// 使用引入的 schema 验证获取的 options,options 在 webpack.config.js 中使用本 loader 时传递const options = this.getOptions(schema)const prefix = `/** Author: ${options.author}* age: ${options.age}*/`;return prefix + content
}
module.exports = bannerLoader
module.exports = {// ...module: {rules:[{test:/\.js$/,loader:'clean-log-loader'},{test:/\.js$/,loader:'banner-loader',options:{author:'Cengfan',age:18}}]// ...},// ...
}
运行webpack查看结果。需要添加的自定义信息已被打包。
如果我们在配置文件中给 options 新增字段,由于 schema.json 中的 additionalProperties 属性为 false,在打包时会报错。
这就是我们的 banner-loader,接下来我们开始写下一个loader。
本loader用于将 ES next 转换为 ES5,babel-loader 之前的文章已经列举过它的详细作用,这里我们通过引入官方的预设,分步手写一个 babel-loader。
yarn add @babel/core @babel/preset-env -D
├─ src
│├─ loaders
││ ├─ babel-loader
││ │├─ index.js
││ │└─ schema.json
││ ├─ banner-loader
││ │├─ index.js
││ │└─ schema.json
││ ├─ clean-log-loader
││ │└─ index.js
││ ├─ loader1.js
││ ├─ loader2.js
││ ├─ loader3.js
││ └─ raw-loader.js
│└─ index.js
{"type":"object","properties":{"presets":{"type": "array"}},"additionalProperties" : true
}
const babel = require('@babel/core')
const schema = require('./schema.json')
const babelLoader = function (content) {const callback = this.async()const options = this.getOptions(schema)/* 使用 babel 编译代码param1: code 代码内容param2: options 对应的预设param3: callback 回调函数,其中 result 返回值为 { code, map, ast } 对象*/babel.transform(content, options, function (err, result) {// result 的返回值为 { code, map, ast },这里直接获取解析后的 code 传入给异步 loader 执行即可if (err) callback(err)else callback(null, result.code)})
}
module.exports = babelLoader
module.exports = {// ...module: {rules:[{test:/\.js$/,loader:'clean-log-loader'},{test:/\.js$/,loader:'banner-loader',options:{author:'Cengfan',age:18,}},{test:/\.js$/,loader:'babel-loader',options:{presets: ['@babel/preset-env']}}]// ...},// ...
}
const add = (...args) => args.reduce((prev,curr) => prev,curr,0)
console.log('Hello Cengfan!')
console.log('@','Hello Cengfan!')
全部操作完成,执行webpack查看打包结果。
可以看到这里用ES6声明的语法全部变成了ES5。如果不调用我们自己的babel loader打包后的结果会变成这样。
这就是我们的 babel-loader,接下来我们开始写下一个loader。
本loader会动态创建 style 标签,将处理好的样式插入到 head 标签中。全体注意!本loader会充分体现 pitch loader 的使用场景。接下来我们分步实现它。
yarn add html-webpack-plugin css-loader -D
├─ src
│├─ assets
││ ├─ img
││ │├─ mk3.jpg
││ │└─ mk5.jpg
││ │└─ mk6.jpg
│├─ css
││ └─ index.css
│├─ loaders
││ ├─ babel-loader
││ │├─ index.js
││ │└─ schema.json
││ ├─ banner-loader
││ │├─ index.js
││ │└─ schema.json
││ ├─ clean-log-loader
││ │└─ index.js
││ ├─ style-loader
││ │└─ index.js
││ ├─ loader1.js
││ ├─ loader2.js
││ ├─ loader3.js
││ └─ raw-loader.js
│├─ index.html
│└─ index.js
Webpack-loader-plugin
Webpack-loader-plugin
h2{background: #4285f4;color: #fff;
}
.picture div{width: 300px;height: 300px;float: left;margin: 0 5px;
}
.picture div:first-child {background: url(../assets/img/mk3.jpg) no-repeat center /contain;
}
.picture div:nth-child(2) {background: url(../assets/img/mk5.jpg) no-repeat center /contain;
}
.picture div:last-child {background: url(../assets/img/mk6.jpg) no-repeat center /contain;
}
import './css/index.css'
const add = (...args) => args.reduce((prev,curr) => prev,curr,0)
console.log('Hello Cengfan!')
console.log('@','Hello Cengfan!')
const styleLoader = function(content) {// 创建 style 标签,将 css-loader 处理后的内容插入到 html 中const script = `const styleEl = document.createElement('style')styleEl.innerHTML = ${JSON.stringify(content)}document.head.appendChild(styleEl)`return script
}
module.exports = styleLoader
const HtmlWebpackPlugin = require('html-webpack-plugin')
// ...
module.exports = {// ...module: {rules:[{test:/\.js$/,loader:'clean-log-loader'},{test:/\.js$/,loader:'banner-loader',options:{author:'Cengfan',age:18,}},{test:/\.js$/,loader:'babel-loader',options:{presets: ['@babel/preset-env']}},{test:/\.css$/,use: ['style-loader', 'css-loader'],}]// ...},// ...plugins: [new HtmlWebpackPlugin({template: resolvePath('./src/index.html'),})],// ...
}
配置完毕,接下来运行查看打包后的html样式是否生效。
全不生效,诶就是玩儿!这到底是怎么回事呢?F12查看生成的style标签。
可以发现插入到style标签的内容也就是 style loader 接收的 content 参数,并不是我们预想的直接被 css-loader 处理好的样式内容,而是一段js脚本。css-loader 会将css文件处理成commonJs模块放入js中,这样插入的内容当然不会生效。
如何解决遇到的问题?可以看到这段js脚本作为模块最后默认暴露了出来。我们需要引用这段脚本并执行它。现在摆在面前的有两种选择。
1.在style loader的 normal 阶段实现能执行js的逻辑,并获取 css loader 返回的样式内容;2.将style loader的逻辑放在 pitch 阶段,通过 pitch loader 函数的 remainingRequest 参数,获取 css loader 的相对路径,把它作为模块引入style loader中,然后让webpack通过 import 语句递归执行引入模块的运算结果。最后输出样式内容。显然,最大程度利用webpack自身特点帮我们处理事务是最优解。style loader官方 也是在 pitch 阶段处理所有逻辑。因此我们的 style loader 需要这样改进一下。
const styleLoader = function(content){}
const styleLoaderPitch = function(remainingRequest) {/* 将绝对路径:C:\Front End\projects\webpack-loader-plugin\node_modules\css-loader\dist\cjs.js!C:\Front End\projects\webpack-loader-plugin\src\css\index.css转换为相对路径:../../node_modules/css-loader/dist/cjs.js!./index.css*/const resolvePath = remainingRequest.split('!').map(absolutePath => {// 通过本 loader 所在的上下文环境和绝对路径,返回一个相对路径return this.utils.contextify(this.context,absolutePath)}).join('!')// 创建 style 标签,将 css-loader 处理后的内容插入到 html 中// '!!' 在 inline loader内跳过 pre,normal,post loader的执行,这里跳过引入的 css loader 后续阶段的自动执行const script = `import style from '!!${resolvePath}'const styleEl = document.createElement('style')styleEl.innerHTML = style document.head.appendChild(styleEl)`// 熔断后续 loader 的执行return script
}
module.exports = styleLoader
module.exports.pitch = styleLoaderPitch
改进后运行webpack,查看打包结果。此时我们的样式全部正常显示了。还记得之前说过的 pitch loader 的使用场景吗?当前loader依赖上个loader的输出结果,且该结果为js而非webpack处理后的资源。 结合这个案例这下明白了吧。
这就是我们的 style-loader,到这里手写loader的练习已经全部完成,相信你已经对loader有了全面的认知,接下来我们深入它的内核,了解它的原理。
本章我将按照 loader的执行流程,执行顺序,异步处理,this 这几个常见的问题跟大家进行讲解,接下来我们开始执行流程的讲解。
webpack的运行流程较为复杂不展开讲解,这里我们专注于loader部分。loader大致的执行流程为以下4个步骤。
1.webpack 的 Compiler 对象会将用户配置 webpack.config.js 和它自己的默认配置合并。2.webpack根据配置创建 ContextModuleFactory 和 NormalModuleFactory 两个类,这两个类都是工厂函数,会生成对应的 ContextModule 和 NormalModule 的实例。工厂类的作用如下表。| ContextModuleFactory | NormalModuleFactory |
| — | — |
| 它会解析请求的目录,为每个文件生成请求,并依据传递来的 regExp 进行过滤。最后匹配成功的依赖关系将被传入NormalModuleFactory(生成依赖) | 从入口点开始,此模块会分解每个请求,解析文件内容以查找进一步的请求,然后通过分解所有请求以及解析新的文件来爬取全部文件。在最后阶段,每个依赖项都会成为一个模块实例(依据依赖生成模块实例) |
3.NormalModule 实例创建后,通过 build 方法构建模块。在构建中首先就要使用 loader runner 调用loader编译模块内容。4.输出 loader 编译后的内容,进入后续编译流程。好了这就是loader大概的执行流程,接下来我们讲解它的执行顺序。
前文讲解过loader的执行顺序。 normal loader 从下往上,从右往左;pitch loader 与之相反。优先级权重为 pre,normal,inline,post。熔断时 pitch loader 返回上一个loader的 normal loader。
这里搞清楚3个问题,loader的执行顺序也就清晰了。
1.loader的执行栈是怎么来的?2.loader runner如何运行这个执行栈?3.熔断时发生了什么?接下来我们分小节逐一解答这个问题。
loader的执行栈生成分两步,loader分类,生成执行栈。下面我们分步讲解。
loader分类的主要作用是将webpack获取到的loader按 pre,normal,post 分类,其中 webpack4 和 webpack5 在逻辑上有所不同。
webpack4 中通过 this.ruleSet.exec 传入源码模块的路径,返回的 result 即为获取的loader,因此我们在 webpack.config.js 的 rule 对象设置的 enforce 属性也可以被获取到。获取该属性后,对loader进行分类。
for (const r of result) {if (r.type === "use") {if (r.enforce === "post" && !noPrePostAutoLoaders) {useLoadersPost.push(r.value);} else if (r.enforce === "pre" &&!noPreAutoLoaders &&!noPrePostAutoLoaders) {useLoadersPre.push(r.value);} else if (!r.enforce &&!noAutoLoaders &&!noPrePostAutoLoaders) {useLoaders.push(r.value);}} // ...
}
webpack5 中通过 resolveRequestArray 方法对 loader 进行分类。
this.resolveRequestArray(contextInfo,this.context,useLoadersPost,loaderResolver,resolveContext,(err, result) => {postLoaders = result;continueCallback(err);}
);
this.resolveRequestArray(contextInfo,this.context,useLoaders,loaderResolver,resolveContext,(err, result) => {normalLoaders = result;continueCallback(err);}
);
this.resolveRequestArray(contextInfo,this.context,useLoadersPre,loaderResolver,resolveContext,(err, result) => {preLoaders = result;continueCallback(err);}
);
当loader归类完成后,就需要把它们组装起来,同样 webpack4 和 webpack5 在这里表现也不一样。
webpack4 中使用 neo-async 并行解析loader数组。
asyncLib.parallel([this.resolveRequestArray.bind(this,contextInfo,this.context,useLoadersPost,loaderResolver),this.resolveRequestArray.bind(this,contextInfo,this.context,useLoaders,loaderResolver),this.resolveRequestArray.bind(this,contextInfo,this.context,useLoadersPre,loaderResolver)],(err, results) => {// ...}
);
最终执行栈如下:
/* results[0]: post loaderloaders: inline loaderresults[1]: normal loaderresults[2]: pre loader
*/
loaders = results[0].concat(loaders, results[1], results[2]);
webpack5 通过调用 continueCallback 方法,将匹配到的 loader 按 post,normal,pre 的顺序推入 loader 执行栈。
let postLoaders, normalLoaders, preLoaders;
const continueCallback = needCalls(3, err => {if (err) {return callback(err);}// 默认赋值为 post loaderconst allLoaders = postLoaders;if (matchResourceData === undefined) {for (const loader of loaders) allLoaders.push(loader);for (const loader of normalLoaders) allLoaders.push(loader);} else {for (const loader of normalLoaders) allLoaders.push(loader);for (const loader of loaders) allLoaders.push(loader);}for (const loader of preLoaders) allLoaders.push(loader);// ...
});
到这一步,loader执行栈就彻底形成了,可以发现无论是webpack4还是5,组装的执行栈顺序都是 post,inline,normal,pre 这与之前所说的loader执行顺序正好相反。别慌,因为真实的loader执行顺序其实是反向的。具体我们看下一小节的内容 loader runner 的运行。
loader runner 用于运行 loader。它运行 loader 时对应的 pitch,normal 2个阶段,分别对应 loader runner 中的 iteratePitchingLoaders,iterateNormalLoaders 2个方法。
iteratePitchingLoaders 会递归执行,同时记录 loader 的 pitch 状态,与当前的 loaderIndex。当它达到最大值(loader执行栈的长度)时,即所有loader的pitch loader已经执行完毕后,开始处理实际的module。此时调用 processResource 方法处理模块资源(添加当前模块为依赖,读取模块内容)。然后 loaderIndex–,并递归执行 iterateNormalLoaders 。
// abort after last loader
if(loaderContext.loaderIndex >= loaderContext.loaders.length) return processResource(options, loaderContext, callback);
// iterate
if(currentLoaderObject.pitchExecuted) {loaderContext.loaderIndex++;return iteratePitchingLoaders(options, loaderContext, callback);
}
loaderContext.loaderIndex--;
iterateNormalLoaders(options, loaderContext, args, callback);
这套流程走下来就完成了loader在 pitch,normal 阶段的执行顺序。
来,全体目光向这段代码看齐。要记得在 loader runner 中,当 loaderIndex 达不到 loader 本身的长度时(有 pitch loader 提前 return 发生了熔断)时, processResource 这个方法是不会触发的,这就导致 addDependency 这个方法也不会触发,因此不会将该模块资源添加进依赖,无法读取模块的内容。继而熔断后续操作。
if(loaderContext.loaderIndex >= loaderContext.loaders.length) return processResource(options, loaderContext, callback);
好了到这里 loader 的执行顺序就彻底讲解完毕了,下面进入异步处理的讲解。
无论是 loader 的 pitch 还是 normal 阶段,最终是在 loader runner 的 runSyncOrAsync 方法中执行。
在loader中调用 this.async 时,实际是将 loaderContext 上的 async 属性赋值为一个函数。isSync 变量默认为 true,当 loader 中使用 this.async 时,它被置为 false,并返回一个 innerCallback 作为异步回调完成的通知。
context.async = function async() {if(isDone) {if(reportedError) return; // ignorethrow new Error("async(): The callback was already called.");}isSync = false;return innerCallback;
}
当 isSync 为 true 时,会在 loader function 执行完毕后同步回调 callback 继续 loader runner 的执行流程。
if(isSync) {isDone = true;if(result === undefined)return callback();if(result && typeof result === "object" && typeof result.then === "function") {return result.then(function(r) {callback(null, r);}, callback);}return callback(null, result);
}
到这里loader的同步,异步原理就彻底讲解完毕了,下面我们讲讲loader中的this。
webpack官方对于loader的定义中有这样一段,函数中的 this 作为上下文会被 webpack 填充。那这个this到底是什么呢?是webpack的实例吗,其实不是,这个this是 loader runner 中的 loaderContext,我们熟悉的 async,callback 等都来自于这个对象。
// prepare loader objects
loaders = loaders.map(createLoaderObject);
loaderContext.context = contextDirectory;
loaderContext.loaderIndex = 0;
loaderContext.loaders = loaders;
loaderContext.resourcePath = resourcePath;
loaderContext.resourceQuery = resourceQuery;
loaderContext.resourceFragment = resourceFragment;
loaderContext.async = null;
loaderContext.callback = null;
// ...
好了,到这里loader关键部分的原理已经全部讲解完毕了,如有疏漏欢迎在评论区补充。
最近找到一个VUE的文档,它将VUE的各个知识点进行了总结,整理成了《Vue 开发必须知道的36个技巧》。内容比较详实,对各个知识点的讲解也十分到位。
有需要的小伙伴,可以点击下方卡片领取,无偿分享