loader
上一节我们实现了webpack的核心流程,但是webpack只认识js文件,如果我在main.js中引入了json,就会报错。
import user from './user.json';
报错的原因是,babel不能处理json文件。所以在source交给babel之前,先进行处理,把不是js的东西转换成js。
function createAsset(filePath) {
let source = fs.readFileSync(filePath, {
encoding: 'utf-8'
});
// loader对source进行处理
const ast = parser.parse(source, {
sourceType: 'module'
});
}
loader的本质是一个函数,接收source(文件内容),把它转换成js,这样就能被babel处理了。比如我们可以这样实现jsonLoader:
function jsonLoader(source) {
// console.log('JSON loader');
// 返回的内容是js
return `export default ${JSON.stringify(source)}`;
}
json-loader就是如此简单!可以看看webpack是怎么实现的:
我们提供配置文件对象,遍历所有loaders。当use是数组时,要从最后一个loader向前依次处理,靠前的loader依赖靠后loader的处理结果。
export const webpackConfig = {
module: {
rules: [
{
test: /\.json$/,
use: [testLoader, jsonLoader]
}
]
}
};
遍历的逻辑:
const loaders = webpackConfig.module.rules;
loaders.forEach(({ test, use }) => {
if (test.test(filePath)) {
if (Array.isArray(use)) {
// 调用reverse,从最后一个loader开始处理
use.reverse().forEach((loader) => {
source = loader(source);
});
} else {
source = use(source);
}
}
});
我们在配置文件中使用了两个loader,两个loader分别都会在控制台打印信息,可以验证一下它们的执行顺序:
这是bundle.js,可见json已经被打包进去了:
现在我们的webpack就增加了对json的支持,浏览器控制台输出:
plugin
tapable
在实现plugin之前,需要先了解tapable这个库,简单理解的话,它实现了订阅发布机制,可以使用tap订阅,使用emit触发,当然实际比这复杂一些。
这个库提供了很多类型的Hook,有两种常见的:SyncHook(同步), AsyncParallelHook(异步)。
这些Hook在使用的时候需要实例化,可以传入一个数组,数组中是参数列表。可以在触发时传入参数,在注册的回调函数中可以获取到该参数。
空说无益,还是看个例子吧。accelerate是一个同步hook,接收一个参数speed,调用tap方法注册,回调函数可以获取speed参数。调用call方法触发,并提供speed。
class Car {
constructor() {
this.hooks = {
accelerate: new SyncHook(['newSpeed']),
calculateRoutes: new AsyncParallelHook(['source', 'target', 'routesList'])
};
}
setSpeed(newSpeed) {
this.hooks.accelerate.call(newSpeed);
}
}
const car = new Car();
// 注册
car.hooks.accelerate.tap('test 1', (speed) => {
console.log('accelerate', speed);
});
// 触发
car.setSpeed(10);
// 输出:'accelarate' 10
异步的钩子也是同理,.tapPromise注册,.promise.then触发。由.promise.then也可以看出,then中的回调函数一定在toPromise resolve/reject之后调用:
import { SyncHook, AsyncParallelHook } from 'tapable';
class Car {
constructor() {
this.hooks = {
calculateRoutes: new AsyncParallelHook(['source', 'target', 'routesList'])
};
}
useNavigationSystemPromise(source, target) {
const list = ['route 1', 'route 2', 'route 3'];
return this.hooks.calculateRoutes
// tapPromise中的回调,就对应这里的promise
// 那边状态变resolved/rejectd了,才能继续往下
.promise(source, target, list)
.then((res) => {
// res === undefined
// 后
console.log('promise callback');
});
}
}
const car = new Car();
// 注册
car.hooks.calculateRoutes.tapPromise(
'test 2 promise',
(source, target, routesList) =>
new Promise((res, rej) => {
setTimeout(() => {
res();
}, 1000);
}).then(() => {
// 先
console.log('tapPromise', source, target, routesList);
})
);
// 触发
car.useNavigationSystemPromise([1, 2, 3], 1);
// 输出:
// 一秒后:
// 'tapPromise' [1, 2, 3] 1 ['route 1', 'route 2', 'route 3']
// 'promise callback'
实现
webpack中plugin的实现利用了事件机制。
webpack提供了很多的事件钩子,比如下图的hooks.emit,在输出asset到output目录之前执行。plugin需要实现apply方法,在apply中,调用钩子的.tap方法,完成注册。等webpack运行到钩子规定的那个时间,就会.call调用,触发tap内的回调函数,在回调函数内,通过compilation对象,可以使用webpack提供的一些API,从而影响打包结果。
我们实现的webpack,没有实现那么多的钩子,只提供一个tapable同步的钩子意思一下就行了:
const hooks = {
emitFile: new SyncHook(['context'])
};
由于plugin是一个类,需要调用它的apply方法,所以在config中,数组中传的是插件的实例对象。我们实现一个“ChangeOutputPath”插件,作用是改变打包文件的名称(之前叫bundle.js)。上面也说过,apply其实是对tap的封装,作用是完成注册。回调函数怎么写,取决于这个插件的目的。
export class ChangeOutputPath {
apply(hooks) {
hooks.emitFile.tap('changeOutputPath', (context) => {
console.log('changeOutputPath');
// 可以传入context,然后调用context上的方法,从而修改打包后的文件名
context.changeOutputPath('./dist/danmo.js');
});
}
}
把这个插件加入config:
export const webpackConfig = {
module: {
rules: [
{
test: /\.json$/,
use: [testLoader, jsonLoader]
}
]
},
plugins: [new ChangeOutputPath()]
};
获取plugins数组,逐个调用其apply方法,完成注册:
function initPlugins() {
const { plugins } = webpackConfig;
plugins.forEach((plugin) => {
plugin.apply(hooks);
});
}
然后就是用call调用了,因为我们是想修改打包文件的名称,所以在build中,在writeFileSync之前调用call。
function build(graph) {
// ...
let outputPath = './dist/bundle.js';
// 可以在回调函数中获取context,然后调用changeOutputPath方法
// 这种实现,就类似于webpack官网那个图片中,用compilation上面的方法修改输出结果
const context = {
// 修改path
changeOutputPath(path) {
outputPath = path;
}
};
// 调用事件,如果事件注册到对应的hook上,此时就会被调用
hooks.emitFile.call(context);
fs.writeFileSync(outputPath, code);
}
到这里,mini-webpack也就实现完了。
一点想法
从上面的代码可以推断,webpack提供的多个hooks,在其源码的不同位置调用了call。我们在实现插件时,把我们的回调函数注册到了某个hook上,运行webpack源代码时,这些hook会在不同的时间执行call方法(call写在某些特定的位置上,比如我们的例子中,call写在writeFileSync前面),我们在回调函数中就能获取到此时的状态,并用webpack提供的api去做某些修改。
webpack应该是实例化hook的时候传入参数列表['compilation', 'callback'](不确定还有没有别的参数列表),提供compilation对象,上面实现了很多方法,然后每次在call的时候,都把compilation和callback传入。
至于这个callback干了什么,为什么要在插件的最后调用它,暂时还不明白。