前言
loader是webpack打包过程中非常重要的一环,通过了解loader的执行过程,不仅可以学习到很多设计思想,还可以在以后遇到webpack配置问题,处理起来得心应手
如何写一个loader
loader本质是一个函数,接收文件内容,返回处理过后的源码,下面是一个简单的loader示例
module.exports = function(source) {
const code = transform(source) // 在这里你可以对文件内容进行转换或处理
return code
}
以上实现了一个简单的loader, 看起来是不是很简单。下面稍微升级一点难度。实现一个简单的style-loader
function loader(source) {
let script = `let style = document.createElement("style");
style.innerHTML = ${JSON.stringify(source)}; document.head.appendChild(style); `;
return script;
}
module.exports = loader;
当我们配置上style-loader
后,遇到import 'a.css'
时会将其原本的内容替换成一段JS脚本,并将样式代码插入到head
标签中
loader的种类
虽说要实现一个loader很简单,但是需要注意的是,在webpack中loader可以分以下几种类型:
- pre loader
- normal loader
- inline loader
- post loader
以上loader的执行是从上到下
执行的。也就是 pre-loader => normal loader => inline loader => post loader
,我们先来看一个例子。
代码包含两个文件index.js
和test.js
, 在导入test.js
时使用了inline-loader
, 我们先不关心各种Loader是怎么写的。
// index.js
import test from 'inline-loader2!inline-loader1!./test'
export default function func() {
return test
}
// test.js
export default 1
下面的代码配置了另外三种loader
const path = require('path')
function loaderPath(loaders) {
return loaders.map(loader => path.resolve(__dirname, 'loaders', loader + '.js'))
}
module.exports = {
mode: 'development',
entry: './src/index.js',
output: {
filename: 'bundle.js',
},
module: {
rules: [
// pre loader
{
test: /\.js$/,
enforce: 'pre',
use: loaderPath(['pre-loader1', 'pre-loader2'])
},
// normal loader
{
test: /\.js$/,
use: loaderPath(['normal-loader1', 'normal-loader2'])
},
// post loader
{
test: /\.js$/,
enforce: 'post',
use: loaderPath(['post-loader1', 'post-loader2'])
}
]
}
}
上面配置中需要注意的点
- 通过
enforce
属性,设置loader的执行顺序 - 通过
!
分割inline-loader
看下运行结果
// index.js 执行的loader
pre-loader2
pre-loader1
normal-loader2
normal-loader1
post-loader2
post-loader1
// test.js 执行的loader
pre-loader2
pre-loader1
normal-loader2
normal-loader1
inline-loader2
inline-loader1
post-loader2
post-loader1
inline loader的写法
通过上面的示例,我们大体了解了loader的执行顺序,大家先留个印象。但是大家可能比较疑惑,inline-loader
的写法怎么这么奇怪。有时候我们项目在编译的时候经常会看到类似的log, 比如vue编译的时候, 有这么一长串:
-!../node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!../node_modules/vue-loader/lib/index.js??vue-loader-options!./app.vue?vue&type=template&id=5ef48958&scoped=true&
上面的内容其实可以分为四部分
-!../node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options
!../node_modules/vue-loader/lib/index.js??vue-loader-options
!./app.vue
?vue&type=template&id=5ef48958&scoped=true&
inline-loader
其实是通过!
将loader进行分割,例如
import test from 'inline-loader2!inline-loader1!./test'
// 包含 inline-loader2 和 inline-loader1
那么-!
这个前缀又是什么呢,其实前缀有多种写法:
Webpack中文文档
符号 | 变量 | 含义 |
---|---|---|
-! | noPreAutoLoaders | 不要前置和普通 loader |
! | noAutoLoaders | 不要普通 loader |
!! | noPrePostAutoLoaders | 其他loader都不要,只要内联 loader |
比如我们在前面加了!!
前缀,那么normal
, pre
, post
loader都不会执行,
所以内联loader是比较灵活的,在日常项目中并不推荐使用
loader是如何执行的
其实webpack为了实现loader的功能,单独开发了一个loader执行器,也就是loader-runnner。下面看个简单的例子
import { runLoaders } from "loader-runner";
runLoaders({
resource: "/abs/path/to/file.txt?query", // 需要处理的文件路径
loaders: ["/abs/path/to/loader.js"], // loader文件路径
context: { minimize: true }, // loader上下文,可通过this获取
processResource: (loaderContext, resourcePath, callback) => { ... },
readResource: fs.readFile.bind(fs)
}, function(err, result) {
// 处理后的文件内容
})
在执行runLoaders过后,会获取到文件最终的内容。上面的例子在执行后,会经过如下的流程 (注意:enforce的pre、post配置是webpack自身制定的规则,runLoaders只负责执行)
- 按照
post -> inline -> normal -> pre
顺序,从左到右
执行相同类型的loader.pitch
- 按照
pre -> normal -> inline -> post
顺序,从右到左
执行相同类型的loader
pitch和normal执行顺序完全相反,pitch先执行
pitch loader
看了上面loader执行的过程,大家可能又比较疑惑pitch loader
是什么。其实在开发 Loader 时,我们可以在导出的函数上添加一个 pitch
函数,就像下面这样:
function loader(source) {
console.log('normal-loader1')
return source
}
/**
*
* @param {*} remainingRequest 剩余需要执行的pitch loader
* @param {*} precedingRequest 已经执行过得pitch loader
* @param {*} data
*/
loader.pitch = function(remainingRequest, precedingRequest, data) {
console.log(remainingRequest)
console.log(precedingRequest)
console.log(data)
}
module.exports = loader
当文件经过该loader处理时,pitch会先执行,并打印出下面内容
D:\code\pre-loader1.js!D:\code\pre-loader2.js!D:\code\webpack-demo\src\test.js // 剩余需要执行的pitch loader
D:\code\post-loader1.js!D:\code\post-loader2.js!D:\code\normal-loader1.js // 已经执行过得pitch loader
{} // 空对象
再测试下一开始的例子,将会打印下面的内容
// pitch 优先执行了,并且是从post开始
post-loader1 pitch
post-loader2 pitch
inline-loader1 pitch
inline-loader2 pitch
normal-loader1 pitch
normal-loader2 pitch
pre-loader1 pitch
pre-loader2 pitch
pre-loader2
pre-loader1
normal-loader2
normal-loader1
inline-loader2
inline-loader1
post-loader2
post-loader1
pitch loader的熔断机制
当pitch返回一个非空
的值时,将会跳过后面pitch loader
和normal loader
的执行
function loader(source) {
console.log('normal-loader1')
return source
}
loader.pitch = function(remainingRequest, precedingRequest, data) {
console.log('normal-loader1 pitch');
return 'let a = 0' // 这里返回了非空值
}
module.exports = loader
我们在normal-loader1
的pitch函数中返回了非空值测试下:
post-loader1 pitch
post-loader2 pitch
inline-loader1 pitch
inline-loader2 pitch
normal-loader1 pitch
inline-loader2
inline-loader1
post-loader2
post-loader1
可以看到loader只执行到了normal-loader1 pitch
, normal-loader1
自身的loader也不会执行。
并且normal-loader1 pitch
的返回值,将作为inline-loader2
的source
参数(大家注意下面红色箭头)
loader上下文
我们再回到前面的例子
import { runLoaders } from "loader-runner";
runLoaders({
resource: "/abs/path/to/file.txt?query", // 需要处理的文件路径
loaders: ["/abs/path/to/loader.js"], // loader文件路径
context: { minimize: true }, // loader上下文,可通过this获取
processResource: (loaderContext, resourcePath, callback) => { ... },
readResource: fs.readFile.bind(fs)
}, function(err, result) {
// 处理后的文件内容
})
大家会发现有一个context
属性,那它是干嘛的呢。下面举个简单的例子
function loader(source) {
const callback = this.async()
setTimeout(() => {
callback(null, source) // 等同于this.callback
}, 2000)
}
上面的代码,我们通过this
调用了async
方法,获取一个callback
, 这种方式可以让我们在Loader中实现异步操作
。
什么是loader上下文呢,简单来讲就是this
, loader的this上有许多变量和函数,能方便我们获取当前需要处理的文件,或者异步处理文件内容。原理也很简单, 就是通过apply来实现
loader.apply(loaderContext, args)
loader-runner自带的上下文属性
其实loader上下文的属性可以分为loader-runner内置
的上下文属性 和 webpack内置
的上下文属性,什么意思呢?抛开webpack这个构建工具,如我们只是单纯使用loader-runner
它将包含下面这些上下文属性。
function loader(source) {
this.resource // 需要处理的资源路径
this.request // 完整的请求
this.loaders // loader对象数组
this.readResource // 读取资源的方法,默认fs.readFile
this.loaderIndex // 当前正在执行的loader索引
this.callback // 回调方法
this.async // 异步方法,返回一个回调函数
this.remainingRequest // 剩余请求
this.currentRequest // 当前请求
this,previousRequest // 已经处理过得请求
this.data // 当前loader的公共数据
return source
}
module.exports = loader
webpack的loader上下文属性
前面我们知道在执行runLoaders
方法时,可以传一个自己的context
,最终会和内置的上下文属性合并。我们直接来看下webpack的源码。
// webpack\lib\NormalModule.js
doBuild(options, compilation, resolver, fs, callback) {
// 创建loader上下文
const loaderContext = this.createLoaderContext(
resolver,
options,
compilation,
fs
);
// 执行Loader
runLoaders(
{
resource: this.resource,
loaders: this.loaders,
context: loaderContext,
readResource: fs.readFile.bind(fs)
},
(err, result) => {
return callback();
}
);
}
loaderContext源码如下
// webpack\lib\NormalModule.js
createLoaderContext(resolver, options, compilation, fs) {
// ..
const loaderContext = {
version: 2,
emitWarning: warning => {
},
emitError: error => {
},
getLogger: name => {
},
// TODO remove in webpack 5
exec: (code, filename) => {
},
resolve(context, request, callback) {
},
getResolve(options) {
},
emitFile: (name, content, sourceMap, assetInfo) => {
},
rootContext: options.context,
webpack: true,
sourceMap: !!this.useSourceMap,
mode: options.mode || "production",
_module: this,
_compilation: compilation,
_compiler: compilation.compiler,
fs: fs
};
compilation.hooks.normalModuleLoader.call(loaderContext, this);
return loaderContext;
}
从上面的代码可以知道normalModuleLoader hook
可以方便的获取到loaderContext, 并且扩展loader功能
compiler.hooks.compilation.tap("LoaderPlugin", compilation => {
compilation.hooks.normalModuleLoader.tap(
"LoaderPlugin",
(loaderContext, module) => {
// 扩展loaderContext
}
);
});
另外,关于webpack中loaderContext
的属性用法,大家感兴趣可以看下
实现loader-runner
前面介绍了loader-runner
用法,不如趁热打铁实现一波~, 实现起来也是非常简单的, 先来看下整体流程图
在实现之前我们先来回顾下runLoaders
用法
import { runLoaders } from "loader-runner";
runLoaders({
resource: "/abs/path/to/file.txt?query", // 需要处理的文件路径
loaders: ["/abs/path/to/loader.js"], // loader文件路径
context: { minimize: true }, // loader上下文,可通过this获取
processResource: (loaderContext, resourcePath, callback) => { ... },
readResource: fs.readFile.bind(fs)
}, function(err, result) {
// 处理后的文件内容
})
1. 初始化loaderContext
先来实现初始化逻辑
function createLoaderObject(loader) {
// 获取loader函数
let normal = require(loader)
// 获取pitch函数
let pitch = normal.pitch
// 如果为true loader接收的是Buffer,否则是字符串
let raw = normal.raw
return {
path: loader,
normal,
pitch,
raw,
data: {}, // 每个loader可以携带一个自定义的数据对象
pitchExecuted: false, // pitch是否执行
normalExecuted: false // normal是否执行
}
}
function runLoaders(options, finalCallback) {
const {
resource, // 资源路径
loaders = [], // loader配置
context = {}, // 上下文对象
readResource = fs.readFile
} = options
const loaderObjects = loaders.map(createLoaderObject)
const loaderContext = context
loaderContext.resource = resource
loaderContext.loaders = loaderObjects
loaderContext.readResource = readResource
loaderContext.loaderIndex = 0 // 当前正在执行的Loader索引
// 调用它会执行下一个loader
loaderContext.callback = null
// 默认Loader是同步的
loaderContext.async = null
// 定义request getter
Object.defineProperty(loaderContext, 'request', {
get() {
// loader1!loader2!loader3!./a.js
return loaderContext.loaders
.map(loader => loader.path)
.concat(loaderContext.resource)
.join('!')
}
})
// 定义remainingRequest getter
Object.defineProperty(loaderContext, 'remainingRequest', {
get() {
return loaderContext.loaders
.slice(loaderContext.loaderIndex + 1)
.map(loader => loader.path)
.concat(loaderContext.resource)
.join('!')
}
})
// 定义currentRequest getter
Object.defineProperty(loaderContext, 'currentRequest', {
get() {
return loaderContext.loaders
.slice(loaderContext.loaderIndex)
.map(loader => loader.path)
.concat(loaderContext.resource)
.join('!')
}
})
// 定义previousRequest getter
Object.defineProperty(loaderContext, 'previousRequest', {
get() {
return loaderContext.loaders
.slice(0, loaderContext.loaderIndex)
.map(loader => loader.path)
.concat(loaderContext.resource)
.join('!')
}
})
// 定义data getter
Object.defineProperty(loaderContext, 'data', {
get() {
return loaderContext.loaders[loaderContext.loaderIndex]
}
})
let processOptions = {
resourceBuffer: null, // 本次要读取的资源文件Buffer
readResource
}
// 迭代执行pitch
iteratePitchingLoader(processOptions, loaderContext, (err, result) => {
// 最终的回调
finalCallback && finalCallback(err, {
result,
resourceBuffer: processOptions.resourceBuffer
})
})
}
exports.runLoaders = runLoaders
上面的代码中,主要做了这么几件事
- 为每个loader创建loader对象
- 基于传入的
context
,再初始化一些内置上下文 - 定义一些
request
的getter
,因为这样才能根据loaderIndex
实时获取到当前正在执行loader
的request信息 - 迭代pitch
下面我们详细看下iteratePitchingLoader
的实现
2. iteratePitchingLoader
function iteratePitchingLoader(processOptions, loaderContext, pitchingCallback) {
// 从左向右执行,越界了,就可以读取文件了
if (loaderContext.loaderIndex >= loaderContext.loaders.length) {
return processResource(processOptions, loaderContext, pitchingCallback)
}
// 获取当前要执行的loader
let currentLoader = loaderContext.loaders[loaderContext.loaderIndex]
// 没有pitch的情况会执行
if (currentLoader.pitchExecuted) {
loaderContext.loaderIndex++
return iteratePitchingLoader(processOptions, loaderContext, pitchingCallback)
}
let fn = currentLoader.pitch
currentLoader.pitchExecuted = true
// 没有pitch的情况会执行
if (!fn) {
return iteratePitchingLoader(processOptions, loaderContext, pitchingCallback)
}
runSyncOrAsync(fn, loaderContext, [
loaderContext.remainingRequest,
loaderContext.previousRequest,
loaderContext.data
], (err, ...args) => {
// pitch返回值不为空 跳过后续loader, 掉头执行前一个Loader的normal
if (args.length && args.some(e => e)) {
loaderContext.loaderIndex--
iterateNormalLoaders(processOptions, loaderContext, args, pitchingCallback)
} else {
return iteratePitchingLoader(processOptions, loaderContext, pitchingCallback)
}
})
}
上面的代码主要做了这几件事
- 从左向右执行,判断是否越界了,超过就代表就可以读取文件了(调用
processResource
方法),同时也代表pitch
都没有返回值
- 没有pitch的情况, 继续向后迭代,并使
loaderIndex++
- 存在
pitch
, 就调用runSyncOrAsync
3. runSyncOrAsync
function runSyncOrAsync(fn, loaderContext, args, runCallback) {
let isSync = true
loaderContext.callback = (...args) => {
runCallback(...args)
}
loaderContext.async = function() {
isSync = false
return loaderContext.callback
}
const result = fn.apply(loaderContext, args)
if (isSync) {
runCallback(null, result)
}
}
runSyncOrAsync
实现比较简单,只是在loaderContext
上挂载了一些回调方法。其实最后执行的都是loaderContext.callback
。
在执行完上面的内容后,会通过runCallback
拿到返回结果,并判断结果是否为空,如果为空就继续迭代。否则就开始迭代normal loader
4. iterateNormalLoaders
看完上面iteratePitchingLoader的实现后,其实大家也能猜到这个方法的实现了,其实就是反过来迭代了。
function convertArgs(args, raw) {
if (raw && !Buffer.isBuffer(args[0])) {
args[0] = Buffer.from(args[0])
} else if (!raw && Buffer.isBuffer(args[0])) {
args[0] = args[0].toString()
}
}
function iterateNormalLoaders(processOptions, loaderContext, args, pitchingCallback) {
// 如果超出左边边界,就调用结束回调
if (loaderContext.loaderIndex < 0) {
return pitchingCallback(null, ...args)
}
// 获取当前loader
let currentLoader = loaderContext.loaders[loaderContext.loaderIndex]
if (currentLoader.normalExecuted) {
loaderContext.loaderIndex--
return iterateNormalLoaders(processOptions, loaderContext, args, pitchingCallback)
}
let normalFn = currentLoader.normal
currentLoader.normalExecuted = true
convertArgs(args, currentLoader.raw)
// 执行normal loader
runSyncOrAsync(normalFn, loaderContext, args, (err, ...returnArgs) => {
return iterateNormalLoaders(processOptions, loaderContext, returnArgs, pitchingCallback)
})
}
function processResource(processOptions, loaderContext, pitchingCallback) {
// 调用readResource 读取文件内容,读取完成后,拿到文件内容向左迭代
processOptions.readResource(loaderContext.resource, (err, resourceBuffer) => {
processOptions.resourceBuffer = resourceBuffer
loaderContext.loaderIndex--
// 迭代执行normal loader
iterateNormalLoaders(processOptions, loaderContext, [resourceBuffer], pitchingCallback)
})
}
以上就是loader-runner
的执行过程,是不是非常简单~,源码已放入github
加餐:vue-loader源码
前面我们学习了loader的执行过程,并且了解了pitcher基本的使用,甚至自己实现了一个loader-runner
,那么有了上面这些基础,阅读vue-loader
将非常轻松。
我们先看下vue-loader
是如何使用的:
// webpack.config.js
const VuePlugin = require('vue/dist/plugin.js')
module.export = {
//...
module: {
rules: [
{
test: /\.vue$/,
use: ['vue-loader']
},
]
},
plugins: [
new VuePlugin(),
]
//...
}
源码概览
整个源码可以大致分为两个部分
- VuePlugin初始化:为webpack的
rules
中插入了一个pitch loader
- vue-loader处理
-
阶段一:将SFC(单文件组件)拆分成三部分
style
,template
,script
,并生成不同的请求。import { render, staticRenderFns } from "./App.vue?vue&type=template&id=13429420&scoped=true&" import script from "./App.vue?vue&type=script&lang=js&" export * from "./App.vue?vue&type=script&lang=js&" import style0 from "./App.vue?vue&type=style&index=0&id=13429420&scoped=true&lang=scss&" // 省略其他代码...
-
阶段二: 命中
pitch loader
的resourceQuery
规则,包含vue参数,会进入pitch loader
。然后会为每个block添加对应的loader,让它们分别用不同的loader
处理。 -
阶段三:
-
下面我们直接分析vue-loader做了哪些事
阶段一
下面举个例子
<template>
<div id="app">
<img alt="Vue logo" src="./assets/logo.png">
<HelloWorld msg="Welcome to Your Vue.js App"/>
</div>
</template>
<script>
import HelloWorld from './components/HelloWorld.vue'
export default {
name: 'App',
components: {
HelloWorld
}
}
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>
在这个阶段,vue-loader会使用@vue/component-compiler-utils
这个包,将代码解析成descriptor
。
// node_modules\vue-loader\lib\index.js
const descriptor = parse({
source,
compiler: options.compiler || loadTemplateCompiler(loaderContext),
filename,
sourceRoot,
needMap: sourceMap
})
最后生成一系列代码,如下:
import { render, staticRenderFns } from "./App.vue?vue&type=template&id=514e6843&"
import script from "./App.vue?vue&type=script&lang=js&"
export * from "./App.vue?vue&type=script&lang=js&"
import style0 from "./App.vue?vue&type=style&index=0&lang=css&"
import normalizer from "!../node_modules/vue-loader/lib/runtime/componentNormalizer.js"
var component = normalizer(
script,
render,
staticRenderFns,
false,
null,
null,
null
)
export default component.exports
总结一下做了什么
- 将组件文件,分成三部分, 并且添加
vue&type=xxx
,用type来区分。
阶段二
上面生成代码后,webpack会递归依赖,就会进入到pitch loader
的逻辑。在这个阶段会对请求路径添加一些inline-loader
1、template
import { render, staticRenderFns } from "./App.vue?vue&type=template&id=514e6843&"
// 被处理成 export,也就是说,进入上面的文件后,又被导出了
export * from "../node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!../node_modules/vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=template&id=514e6843&"
通过上面的学习,相信大家已经看出来了,会经过这些loader的处理
- templateLoader.js
- vue-loader
2、script
import script from "./App.vue?vue&type=script&lang=js&"
// 被处理成 export,也就是说,进入上面的文件后,又被导出了
import mod from "../node_modules/babel-loader/lib/index.js!../node_modules/vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=script&lang=js&"
export default mod;
export * from "../node_modules/babel-loader/lib/index.js!../node_modules/vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=script&lang=js&"
会经过如下loader的处理
- babel-loader
- vue-loader
3、style
import style0 from "./App.vue?vue&type=style&index=0&lang=css&"
// 被处理成 export,也就是说,进入上面的文件后,又被导出了
export * from "-!../node_modules/mini-css-extract-plugin/dist/loader.js??ref--7-oneOf-1-0!../node_modules/css-loader/dist/cjs.js??ref--7-oneOf-1-1!../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../node_modules/postcss-loader/src/index.js??ref--7-oneOf-1-2!../node_modules/cache-loader/dist/cjs.js??ref--1-0!../node_modules/vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=style&index=0&lang=css&"
会经过如下loader的处理
- mini-css-extract-plugin
- css-loader
- stylePostLoader
- postcss-loader
- vue-loader
阶段三
回调最终的代码
// vue-loader
if (incomingQuery.type) {
return selectBlock(
descriptor,
loaderContext,
incomingQuery,
!!options.appendExtension
)
}