书接上篇。
libraryTarget 和 library
当用 Webpack 去构建一个可以被其他模块导入使用的库时需要用到它们。 output.library 配置导出库的名称 output.libraryExport 配置要导出的模块中哪些子模块需要被导出。 它只有在 output.libraryTarget 被设置成 commonjs 或者 commonjs2 时使用才有意义 output.libraryTarget 配置以何种方式导出库,是字符串的枚举类型,支持以下配置
| libraryTarget | 使用者的引入方式 | 使用者提供给被使用者的模块的方式 |
|---|---|---|
| var | 只能以script标签的形式引入我们的库 | 只能以全局变量的形式提供这些被依赖的模块 |
| commonjs | 只能按照commonjs的规范引入我们的库 | 被依赖模块需要按照commonjs规范引入 |
| commonjs2 | 只能按照commonjs2的规范引入我们的库 | 被依赖模块需要按照commonjs2规范引入 |
| amd | 只能按amd规范引入 | 被依赖的模块需要按照amd规范引入 |
| this | - | - |
| window | - | - |
| global | - | - |
| umd | 可以用script、commonjs、amd引入 | 按对应的方式引入 |
commonjs 和 commonjs2 的区别在于,commonjs2 会给 module.exports 赋值,其实没什么太大区别,不过 commonjs2 就不需要再额外设置 library 属性了,因为不会用到,修改 src/index.js。
// commonjs
// export.add = function(a, b) {
// return a + b;
// }
// commonjs2
module.exports = {
add(a, b) {
return a + b;
}
}
var
修改 webpack.config.js
const path = require('path');
module.exports = {
entry: './src/index',
devtool: false,
output: {
path: path.resolve('build'),
filename: 'bundle.js',
library: 'calculator',
libraryTarget: 'var'
},
mode: 'development'
}
npm run build,查看生成的文件内容~
var calculator; // 全局变量
(() => {
var __webpack_modules__ = ({
"./src/index.js":
((module) => {
module.exports = {
add(a, b) {
return a + b;
}
}
})
});
var __webpack_module_cache__ = {};
function __webpack_require__(moduleId) {
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
var module = __webpack_module_cache__[moduleId] = {
exports: {}
};
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
return module.exports;
}
var __webpack_exports__ = __webpack_require__("./src/index.js");
calculator = __webpack_exports__; // 模块导出结果赋值给全局变量
})()
;
commonjs & commonjs2
尝试下 commonjs
修改 webpack.config.js
```js
const path = require('path');
module.exports = {
entry: './src/index',
devtool: false,
output: {
path: path.resolve('build'),
filename: 'bundle.js',
library: 'calculator',
- libraryTarget: 'var'
+ libraryTarget: 'commonjs'
},
mode: 'development'
}
npm run build,查看生成的文件内容~
(() => {
var __webpack_modules__ = ({
"./src/index.js":
((module) => {
module.exports = {
add(a, b) {
return a + b;
}
}
})
});
var __webpack_module_cache__ = {};
function __webpack_require__(moduleId) {
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
var module = __webpack_module_cache__[moduleId] = {
exports: {}
};
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
return module.exports;
}
var __webpack_exports__ = __webpack_require__("./src/index.js");
exports.calculator = __webpack_exports__; // 放在 exports 属性上
})()
;
commonjs2 只是将最后的导出方式换成了 module.exports.calculator = webpack_exports; 这里不再过多演示。
this
(() => {
var __webpack_modules__ = ({
"./src/index.js":
((module) => {
module.exports = {
add(a, b) {
return a + b;
}
}
})
});
var __webpack_module_cache__ = {};
function __webpack_require__(moduleId) {
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
var module = __webpack_module_cache__[moduleId] = {
exports: {}
};
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
return module.exports;
}
var __webpack_exports__ = __webpack_require__("./src/index.js");
this.calculator = __webpack_exports__; // this -> module.exports -> exports
})()
;
this 在浏览器环境指向 window,在 node 环境指向 exports,剩下的不再一一演示了。
hash、chunkhash 和 contenthash
文件指纹是指打包后输出的文件名和后缀- hash一般是结合CDN缓存来使用,通过webpack构建之后,生成对应文件名自动带上对应的MD5值。如果文件内容改变的话,那么对应文件哈希值也会改变,对应的HTML引用的URL地址也会改变,触发CDN服务器从源服务器上拉取对应数据,进而更新本地缓存。
指纹占位符
| 占位符名称 | 含义 |
|---|---|
| ext | 资源后缀名 |
| name | 文件名称 |
| path | 文件的相对路径 |
| folder | 文件所在的文件夹 |
| hash | 每次webpack构建时生成一个唯一的hash值,全体模块共享一个hash值,多入口一个hash,或者单入口,splitChunks 拆分后的文件,chunkhash 也是不一样的哦。 |
| chunkhash | 根据chunk生成hash值,来源于同一个chunk,则hash值就一样,顾名思义,每个代码块(chunk)一个hash,多入口多个hash |
| contenthash | 根据内容生成hash值,文件内容相同hash值就相同 |
hash 用法
整个项目公用一个hash,一个入口文件改变后,缓存全部失效。
module.exports = {
entry: {
main: './src/index.js',
vendor: ['lodash']
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[hash:6].js' // 都用hash的话,如果改了main入口的内容,vendor缓存也会失效
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[hash].css',
})
]
}
chunkhash 用法
每个 chunk 一个hash,一个入口文件改变后,只影响它对应的 chunk,注意,css 是从 main 中抽离出来的,公用一个 hash,也就是 main hash 改变后,css 缓存也失效,注意,splitChunks 拆分后的文件,chunkhash 也是不一样的哦。
module.exports = {
entry: {
main: './src/index.js',
vendor: ['lodash']
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[chunkhash:6].js' // 如果改了main入口的内容,vendor缓存不会失效
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[chunkhash].css',
})
]
}
contenthash 用法
对css使用contenthash,即使main改变,只要css没变就不会重新打包。
module.exports = {
entry: {
main: './src/index.js',
vendor: ['lodash']
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[chunkhash:6].js' // 如果改了main入口的内容,vendor缓存不会失效
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css',
})
]
}
怎么选呢?
效率方面: hash > chunkhash > contenthash 精度方法: contenthash > chunkhash > hash 如果一个文件变化的概率特别小,可以选择 contenthash,其他的看情况选择就好了。
打包多页应用
多页应用比单页应用复杂的一点的是,多个入口,多个出口。
let path = require('path');
let HtmlWebpackPlugin = require('html-webpack-plugin'); // 抽离html
module.exports = {
mode: 'development',
entry: { // 多入口
home: './src/index.js',
other: './src/other.js'
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist')
},
plugins: [ // 多出口
new HtmlWebpackPlugin({
template: './index.html',
filename: 'home.html',
chunks: ['home'] // 该页使用到的代码块儿
}),
new HtmlWebpackPlugin({
template: './index.html',
filename: 'other.html',
chunks: ['other']
}),
]
}
watch 监听文件改动 可以看到实体文件
就算用 webpack-dev-server 启动, 监测到代码变化后,浏览器可以看到及时更新的效果,但是并没有自动打包修改目录中的代码,是在内存中打包,我们可以用 watch 方法来实现监测到代码变化后自动打包修改的代码,需要注意的是,watch 是给 npm run build 使用的,如果通过 devServer 启动的服务自然是不需要 watch 啦,旨在防止 build 完之后关闭进程。
module.exports = {
watch: true
}
这时候执行 npm run build 就不会退出 build 进程,会等待文件改变,并实时打包。 也可以加一些额外配置
module.exports = {
watch: true,
watchOptions: { // 监控选项
poll: 1000, // 每秒检查 1000 次
aggregateTimeout: 500, // 防抖 500ms触发一次
ignored: /node_modules/ // 忽略文件
},
}
copy-webpack-plugin 拷贝文件到打包目录
yarn add copy-webpack-plugin -D
文件根目录创建 doc文件夹,新增 doc/a.txt
修改 webpack 配置
module.exports = {
plugins: [
new CopyWebpackPlugin({ // 可以执行多个拷贝
patterns: [
{ from: "doc", to: "doc" },
],
})
]
}
clean-webpack-plugin 文件清理
重新构建之前执行,会清空 dist 目录。
new CleanWebpackPlugin({
cleanOnceBeforeBuildPatterns: ['**/**'] // 清理输出目录下的所有文件
})
bannerPlugin 代码版权声明插件
webpack内置插件,用于代码前署名。
修改 webpack 配置
import webpack from 'webpack';
module.exports = {
plugins: [ // 多出口
new webpack.BannerPlugin('make 2020 by ys'), // 代码版权声明
]
}
打包可以看见版权的声明
/*! make 2020 by ys */body,**
/*! make 2020 by ys */!function(n)**
devServer 配置跨域 & mock数据
设置代理
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://xxx.com', // 配置api代理 转发到一个地址
pathRewrite: { '/api': '' }, // 匹配到之后 转发时去掉 /api
}
}
}
}
因为 webpack-dev-server 也是启动了一个 express 服务,所以我们如果可以拿到 app 实例,是不是就可以 mock 接口了? 方便的是,devServer 提供了这样的钩子。
module.exports = {
devServer: {
before(app) {
app.get('/user', (req, res) => {
res.json({
name: 'ys',
action: 'moce data'
});
});
}
}
}
这样配合上面,我们就可以在前端通过 /api/user 访问到这个接口啦。
resolve 配置解析三方包规则 & 别名
我们可以通过配置 resolve 设置解析第三方包的范围,入口文件,别名等。
module.exports = {
resolve: {
// 包的查找范围只在本级目录下 node_modus
modules: [path.resolve('node_modules')],
// 引入包时 后缀查找规则 先后排序
extensions: ['.js', '.css', '.json', '.vue'],
// 引入包时 默认读包 package.json 中字段找入口时候的优先级(默认先找main属性)
mainFields: ['style', 'main'],
// 入口文件的名字 默认是 index.js
mainFiles: ['base.js', 'index.js'],
// 配置解析别名 解决引入包名过长问题
alias: {
'@/assets': path.resolve(__dirname, '..', 'src/assets'),
'@/libs': path.resolve(__dirname, '..', 'src/libs'),
},
}
}
利用DefinePlugin 定义环境变量(其实是全局变量哦)
我们可以通过 webpack 内置插件 DefinePlugin 来为页面注入全局变量,注意,这种方式不会保存任何变量,而是纯纯的编译时字符串替换,所以即使我们定义了 ENV,window.ENV 也是不存在的,这也是为什么他外面要多加一层字符串的原因。
import webpack from 'webpack';
module.exports = {
plugins: [ // 多出口
new webpack.DefinePlugin({
ENV: '"dev"', // 页面就能拿到啦 这个可以动态给值 注意引号
flag: 'true', // Boolean true
SUM: '1+1' // 2
})
]
}
webpack-merge 区分环境做配置合并
yarn add webpack-merge -D
// webpack.dev.js
let { smart } = require('webpack-merge');
let base = require('./webpack.base.js');
module.exports = smart(base, {
mode: 'developmeent',
devServer: {
// do something on dev
},
devtool: 'source-map'
});
import() 懒加载
比如页面有个按钮,点击按钮,我希望加载某段js。
新建 src/source.js
// source.js
export default 'ys'
// index.js
let button = document.createElement('button');
button.innerHTML = '杨帅加油';
button.addEventListener('click', function() {
// es2020 草案中的语法 jsonp实现动态加载文件 返回一个 promise 实例
import('./source.js').then(data => {
console.log(data.default);
});
});
document.body.appendChild(button);
我们点击按钮,可以看到 network加载了一段 js
// 5.js
(window.webpackJsonp=window.webpackJsonp||[]).push([[5],{39:function(n,s,w){"use strict";w.r(s),s.default="ys"}}]);
同时页面打印出 模块的导出值 ys
import()函数可以用在任何地方,不仅仅是模块,非模块的脚本也可以使用。它是也是 vue 和 react 路由懒加载的实现,它是运行时执行,也就是说,什么时候运行到这一句,就会加载指定的模块。另外,import()函数与所加载的模块没有静态连接关系,这点也是与import语句不相同。import()类似于 Node 的require方法,区别主要是前者是异步加载,后者是同步加载。
热更新
在之前,每次更改代码,都会导致页面重新刷新,我们能不能做到,只更新某一部分,let do it
// source.js
export default 'log from source!'
// index.js
import str from './source';
console.log(str);
// 热更关键代码
if (module.hot) {
module.hot.accept('./source', () => {
console.log('文件更新了');
let str = require('./source'); // 因为 import只能在顶上用
console.log(str.default);
});
}
// webpack.config.js
module.exports = {
devServer: {
hot: true, // 注意加了这行代码
port: 3000,
open: true,
contentBase: './dist'
},
plugins: [
new webpack.HotModuleReplacementPlugin() // 热更新插件
]
}
这时候修改代码,页面就会热更啦
Tapable - webpack核心之事件流
webpack 本质上是一种事件流的机制,它的工作流程就是将各种插件串联起来,而实现这一切的核心就是 Tapable,Tapable有点类似于 nodejs 的 events 库,核心原理也是依赖于发布订阅模式。
在 webpack/lib/Compiler.js中
const {
Tapable,
SyncHook, // 同步钩子
SyncBailHook, // 同步钩子
AsyncParallelHook, // 异步钩子
AsyncSeriesHook // 异步钩子
} = require("tapable");
这里我们就来应用下这些方法,并且实现它的原理,为后面手写代码做准备。
安装 tapable
yarn add tapable
SyncHook -- 同步钩子
维护同步函数队列,调用时依次执行,不可以中止执行。
// start.js
let { SyncHook } = require('tapable'); // 提供 tap 方法注册监听函数 call 方法执行回调
class Lesson {
constructor() {
this.hooks = {
// 定义一些钩子
arch: new SyncHook(['name']), // new 出来一个同步钩子 接收一个参数
}
}
tap() { // 注册监听函数
this.hooks.arch.tap('webpack4', function(name) {
console.log('webpakc4 学习', name);
})
this.hooks.arch.tap('vue', function(name) {
console.log('vue 学习', name);
})
}
// 启动钩子的方法
start() {
this.hooks.arch.call('ys'); // 挨个触发已注册函数的回调 并传参
}
}
let l = new Lesson();
l.tap(); // 注册了这两个事件
l.start(); // 执行这两个事件
执行此文件,笔者使用的是 run code 插件
webpakc4 学习 ys
vue 学习 ys
实现这样一个同步的钩子也很简单
// ysSyncHook.js
class SyncHook {
constructor(args) { // args => ['name'] 并没有实际意义 作为一个参数标识
this.tasks = [];
}
tap(name, task) {
this.tasks.push(task);
}
call(...args) {
this.tasks.forEach(task => task(...args));
}
}
module.exports = { SyncHook }
SyncBailHook -- 带保险(可以控制是否往下执行)的同步钩子
维护同步函数队列,调用时依次执行,可以中止执行。
// start.js
let { SyncBailHook } = require('tapable'); // 提供 tap 方法注册监听函数 call 方法执行回调
class Lesson {
constructor() {
this.hooks = {
// 定义一些钩子
arch: new SyncBailHook(['name']), // new 出来一个同步钩子 接收一个参数
}
}
tap() { // 注册监听函数
this.hooks.arch.tap('webpack4', function(name) {
console.log('webpakc4 学习', name);
return '太难了,学不会了'; // 返回一个非 undefined 的值 即终止!
})
this.hooks.arch.tap('vue', function(name) {
console.log('vue 学习', name);
})
}
// 启动钩子的方法
start() {
this.hooks.arch.call('ys'); // 挨个触发已注册函数的回调 并传参
}
}
let l = new Lesson();
l.tap(); // 注册了这两个事件
l.start(); // 执行这两个事件
执行此文件,笔者使用的是 run code 插件
webpakc4 学习 ys
手动实现也特别简单,只需要修改边界条件即可
// 手动实现 SyncBailHook
class SyncBailHook {
constructor(args) { // args => ['name'] 并没有实际意义 作为一个参数标识
this.tasks = [];
}
tap(name, task) {
this.tasks.push(task);
}
call(...args) {
for (let i = 0; i < this.tasks.length; i++) {
let ret = this.tasks[i](...args);
// 终止条件为某次执行结果不为undefined 或者全部执行完毕
if (ret !== undefined || i == this.tasks.length) break;
}
}
}
module.exports = { SyncBailHook };
SyncWaterfallHook -- 函数执行结果依次传递的同步钩子
// start.js
let { SyncWaterfallHook } = require('tapable'); // 提供 tap 方法注册监听函数 call 方法执行回调
class Lesson {
constructor() {
this.hooks = {
// 定义一些钩子
arch: new SyncWaterfallHook(['name']), // new 出来一个同步钩子 接收一个参数
}
}
tap() { // 注册监听函数
this.hooks.arch.tap('webpack4', function(name) {
console.log('webpakc4 学习', name);
return 'webpack4 学会了!!'; // 传递给下一个函数的data
})
this.hooks.arch.tap('vue', function(data) {
console.log('vue 学习', data);
})
}
// 启动钩子的方法
start() {
this.hooks.arch.call('ys'); // 挨个触发已注册函数的回调 并传参
}
}
let l = new Lesson();
l.tap(); // 注册了这两个事件
l.start(); // 执行这两个事件
执行此文件,笔者使用的是 run code 插件
webpakc4 学习 ys
vue 学习 webpack4 学会了!!
emm.. 实现起来也比较简单
// 手动实现 SyncWaterfallHook
class SyncWaterfallHook {
constructor(args) { // args => ['name'] 并没有实际意义 作为一个参数标识
this.tasks = [];
}
tap(name, task) {
this.tasks.push(task);
}
call(...args) {
let ret;
for (let i = 0; i < this.tasks.length; i++) {
// 有结果就传递结果给下个函数 没有结果则传递 args
if (ret) {
ret = this.tasks[i](ret);
} else {
ret = this.tasks[i](...args);
}
}
}
}
module.exports = { SyncWaterfallHook };
SyncLoopHook -- 循环执行的可控制同步钩子
webpack 本身没有用到这个方法,但这个方法挺有意思的,这个钩子一旦被调用,就会循环执行,直到钩子回调函数返回 undefined。
比如下面这个例子,webpack4 我想学习三遍之后,再学习 vue。
let { SyncLoopHook } = require('tapable'); // 提供 tap 方法注册监听函数 call 方法执行回调
// let { SyncLoopHook } = require('./ysSyncLoopHook');
class Lesson {
constructor() {
this.index = 0;
this.hooks = {
// 定义一些钩子
arch: new SyncLoopHook(['name']), // new 出来一个同步钩子 接收一个参数
}
}
tap() { // 注册监听函数
const _this = this;
this.hooks.arch.tap('webpack4', function(name) {
console.log('webpakc4 学习', name);
return ++_this.index == 3 ? undefined : '继续学习 webpack4';
})
this.hooks.arch.tap('vue', function(name) {
console.log('vue 学习', name);
})
}
// 启动钩子的方法
start() {
this.hooks.arch.call('ys'); // 挨个触发已注册函数的回调 并传参
}
}
let l = new Lesson();
l.tap(); // 注册了这两个事件
l.start(); // 执行这两个事件
执行此文件,笔者使用的是 run code 插件
webpakc4 学习 ys
webpakc4 学习 ys
webpakc4 学习 ys
vue 学习 ys
哈.. 实现起来也比较简单
// 手动实现 SyncLoopHook
class SyncLoopHook {
constructor(args) { // args => ['name'] 并没有实际意义 作为一个参数标识
this.tasks = [];
}
tap(name, task) {
this.tasks.push(task);
}
call(...args) {
for (let i = 0; i < this.tasks.length; i++) {
let ret = this.tasks[i](...args);
// 某次循环终止条件为返回值等于 undefined
if (ret !== undefined) i--;
}
}
}
module.exports = { SyncLoopHook };
AsyncParallelHook -- 异步并行的钩子(tapAsync版)
异步钩子的用法和同步区别在于,异步钩子是用 tapAsync 方法注册监听函数 callAsync 方法执行回调,并且每个监听函数都要调用 cb。
let { AsyncParallelHook } = require('tapable'); // 提供 tapAsync 方法注册监听函数 callAsync 方法执行回调
// let { AsyncParallelHook } = require('./ysAsyncParallelHook');
class Lesson {
constructor() {
this.hooks = {
// 定义一些钩子
arch: new AsyncParallelHook(['name']), // new 出来一个同步钩子 接收一个参数
}
}
tap() { // 注册监听函数
this.hooks.arch.tapAsync('webpack4', function(name, cb) {
setTimeout(() => {
console.log('webpack4 学习', name);
cb(); // webpack4 学习 和 vue 学习 全部执行完 才会执行回调
}, 2000);
})
this.hooks.arch.tapAsync('vue', function(name, cb) {
setTimeout(() => {
console.log('vue 学习', name);
cb(); // webpack4 学习 和 vue 学习 全部执行完 才会执行回调
}, 1000);
})
}
// 启动钩子的方法
start() {
this.hooks.arch.callAsync('ys', function() { // 这里改成 callAsync 全部执行完毕后 执行该回调
console.log('学习结束啦!');
});
}
}
let l = new Lesson();
l.tap(); // 注册了这两个事件
l.start(); // 执行这两个事件
执行此文件,笔者使用的是 run code 插件
webpack4 学习 ys // 2s later
vue 学习 ys // 1s later
学习结束啦!// 2s later
可以看到,上面代码监听函数每次回调中都执行了cb(),原理是存在着一个计数器,如果执行完毕的任务数等于注册的监听函数数量,则执行我们传入的回调函数,它的实现特别像 Promise.all。
// 手动实现 AsyncParallelHook
class AsyncParallelHook {
constructor(args) { // args => ['name'] 并没有实际意义 作为一个参数标识
this.tasks = [];
}
tapAsync(name, task) {
this.tasks.push(task);
}
callAsync(...args) {
let finallyCb = args.pop(); // 弹出最后一个参数
let count = 0; // 计数
let done = () => {
count++;
if (count == this.tasks.length) {
finallyCb();
}
}
// 并发执行 forEach
this.tasks.forEach(task => {
task(...args, done);
});
}
}
module.exports = { AsyncParallelHook };
AsyncParallelHook -- 异步并行的钩子(tapPromise版)
let { AsyncParallelHook } = require('tapable'); // 提供 tapPromise 方法注册监听函数 promise 方法执行回调
class Lesson {
constructor() {
this.hooks = {
// 定义一些钩子
arch: new AsyncParallelHook(['name']), // new 出来一个同步钩子 接收一个参数
}
}
tap() { // 注册监听函数
this.hooks.arch.tapPromise('webpack4', function(name) {
return new Promise((resolve) => {
console.log('webpack4 学习', name);
resolve();
})
})
this.hooks.arch.tapPromise('vue', function(name, cb) {
return new Promise((resolve) => {
console.log('vue 学习', name);
resolve();
})
})
}
// 启动钩子的方法
start() {
this.hooks.arch.promise('ys').then(function() { // 这里改成 callAsync 全部执行完毕后 执行该回调
console.log('学习结束啦!');
});
}
}
let l = new Lesson();
l.tap(); // 注册了这两个事件
l.start(); // 执行这两个事件
AsyncParallelBillHook -- 异步并行可取消的钩子 不再赘述
AsyncSeriesHook -- 异步串行的钩子(tapAsync版)
如果我们下一个注册的监听函数回调依赖了上一个函数回调的返回结果,这时候就不能并发执行了。
let { AsyncSeriesHook } = require('tapable'); // 提供 tapAsync 方法注册监听函数 callAsync 方法执行回调
// let { AsyncSeriesHook } = require('./ysAsyncSeriesHook');
class Lesson {
constructor() {
this.hooks = {
// 定义一些钩子
arch: new AsyncSeriesHook(['name']), // new 出来一个同步钩子 接收一个参数
}
}
tap() { // 注册监听函数
this.hooks.arch.tapAsync('webpack4', function(name, cb) {
setTimeout(() => {
console.log('webpack4 学习', name);
cb(); // webpack4 学习 和 vue 学习 全部执行完 才会执行回调
}, 2000);
})
this.hooks.arch.tapAsync('vue', function(name, cb) {
setTimeout(() => {
console.log('vue 学习', name);
cb(); // webpack4 学习 和 vue 学习 全部执行完 才会执行回调
}, 1000);
})
}
// 启动钩子的方法
start() {
this.hooks.arch.callAsync('ys', function() { // 这里改成 callAsync 全部执行完毕后 执行该回调
console.log('学习结束啦!');
});
}
}
let l = new Lesson();
l.tap(); // 注册了这两个事件
l.start(); // 执行这两个事件
执行此文件,笔者使用的是 run code 插件
webpack4 学习 ys // 2s later
vue 学习 ys // 3s later
学习结束啦!// 3s later
手动实现它并不费劲
// 手动实现 AsyncSeriesHook
class AsyncSeriesHook {
constructor(args) { // args => ['name'] 并没有实际意义 作为一个参数标识
this.tasks = [];
}
tapAsync(name, task) {
this.tasks.push(task);
}
callAsync(...args) {
let finallyCb = args.pop(); // 全部执行完毕 触发最终钩子回调
let index = 0;
// 每个函数回调内执行后(cb执行),调用该方法执行下一个函数(递归)
let next = () => {
if (index == this.tasks.length) return finallyCb();
let tasks = this.tasks[index++];
tasks(...args, next);
}
next();
}
}
module.exports = { AsyncSeriesHook };
AsyncSeriesHook -- 异步串行的钩子(tapPromise版)
let { AsyncSeriesHook } = require('tapable'); // 提供 tapPromise 方法注册监听函数 promise 方法执行回调
// let { AsyncSeriesHook } = require('./ysAsyncSeriesHookPromise'); // promise 版 同步并发
class Lesson {
constructor() {
this.hooks = {
// 定义一些钩子
arch: new AsyncSeriesHook(['name']), // new 出来一个同步钩子 接收一个参数
}
}
tap() { // 注册监听函数
this.hooks.arch.tapPromise('webpack4', function(name) {
return new Promise((resolve) => {
setTimeout(() => {
console.log('webpack4 学习', name);
resolve();
}, 2000)
})
})
this.hooks.arch.tapPromise('vue', function(name, cb) {
return new Promise((resolve) => {
setTimeout(() => {
console.log('vue 学习', name);
resolve();
}, 1000);
})
})
}
// 启动钩子的方法
start() {
this.hooks.arch.promise('ys').then(function() { // 这里改成 promise 全部执行完毕后 执行该回调
console.log('学习结束啦!');
});
}
}
let l = new Lesson();
l.tap(); // 注册了这两个事件
l.start(); // 执行这两个事件
执行此文件,笔者使用的是 run code 插件
webpack4 学习 ys // 2s later
vue 学习 ys // 3s later
学习结束啦!// 3s later
ok,我们来实现它
// 手动实现 AsyncParallelHook
class AsyncSeriesHook {
constructor(args) {
this.tasks = [];
}
tapPromise(name, task) {
this.tasks.push(task);
}
promise(...args) {
let [first, ...other] = this.tasks; // 取出第一个任务
return other.reduce((p, next) => { // 类似 redux 源码
return p.then(() => next(...args));
}, first(...args));
}
}
module.exports = { AsyncSeriesHook };
AsyncSeriesWaterfallHook -- 传递结果的异步串行钩子(tapAsync版)
let { AsyncSeriesWaterfallHook } = require('tapable'); // 提供 tapAsync 方法注册监听函数 callAsync 方法执行回调
// let { AsyncSeriesWaterfallHook } = require('./ysAsyncSeriesWaterfallHook');
class Lesson {
constructor() {
this.hooks = {
// 定义一些钩子
arch: new AsyncSeriesWaterfallHook(['name']),
}
}
tap() { // 注册监听函数
this.hooks.arch.tapAsync('webpack4', function(name, cb) {
setTimeout(() => {
console.log('webpack4 学习', name);
// cb第一个参数为error 则不继续调用 为null 则往下继续调用 第二个参数为传递的参数
cb(null, 'result');
}, 2000);
})
this.hooks.arch.tapAsync('vue', function(data, cb) {
setTimeout(() => {
console.log('vue 学习', data);
cb();
}, 1000);
})
}
// 启动钩子的方法
start() {
this.hooks.arch.callAsync('ys', function() { // 这里改成 callAsync 全部执行完毕后 执行该回调
console.log('学习结束啦!');
});
}
}
let l = new Lesson();
l.tap(); // 注册了这两个事件
l.start(); // 执行这两个事件
执行此文件,笔者使用的是 run code 插件
webpack4 学习 ys // 2s later
vue 学习 result // 3s later
学习结束啦 // 3s later
实现一下
// let { AsyncSeriesWaterfallHook } = require('tapable'); // 提供 tapAsync 方法注册监听函数 callAsync 方法执行回调
let { AsyncSeriesWaterfallHook } = require('./ysAsyncSeriesWaterfallHook');
class Lesson {
constructor() {
this.hooks = {
// 定义一些钩子
arch: new AsyncSeriesWaterfallHook(['name']),
}
}
tap() { // 注册监听函数
this.hooks.arch.tapAsync('webpack4', function(name, cb) {
setTimeout(() => {
console.log('webpack4 学习', name);
// cb第一个参数为error 则不继续调用 为null 则往下继续调用 第二个参数为传递的参数
cb(null, 'result');
}, 2000);
})
this.hooks.arch.tapAsync('vue', function(data, cb) {
setTimeout(() => {
console.log('vue 学习', data);
cb();
}, 1000);
})
}
// 启动钩子的方法
start() {
this.hooks.arch.callAsync('ys', function(data) { // 这里改成 callAsync 全部执行完毕后 执行该回调
console.log('学习结束啦!', data);
});
}
}
let l = new Lesson();
l.tap(); // 注册了这两个事件
l.start(); // 执行这两个事件
AsyncSeriesWaterfallHook -- 传递结果的异步串行钩子(tapPromise版)
略..
tapable库用法总结
tapable提供的钩子函数,有三种注册监听函数的方法,分别是
- tap 同步注册
- tapAsync(cb) 异步注册
- tapPromise(异步注册的是promise)
调用的方法也是有三种
- call
- callAsync
- promise
手写 webpack
目录结构
-bin
- ys-pack
- lib
- Compiler.js
- src
- index.js
- webpack.config.js
// index.js
var a = require('./a.js');
console.log('导入模块: ' + a);
// a.js
var a = 1;
module.exports = a;
1) 读取 webpack.config.js 文件
// bin/ys-pack
#! /usr/bin/env node
let path = require('path');
let config = require(path.resolve(process.cwd(), 'webpack.config.js')); // 拿到 webpack 配置文件
let Compiler = require('../lib/Compiler.js'); // 用来编译的类
let compiler = new Compiler(config); // 配置传入
compiler.run(); // 运行编译
2)添加 compiler 编译类 解析模块id 模块内容
// lib/Compiler.js
let fs = require('fs');
let path = require('path');
/**
* @description 用来编译的工具类
* @param { Object } webpack 配置
* @returns void
*/
class Compiler {
constructor(config) {
this.config = config;
// 需要保存入口文件的路径
this.entryId = config.entry; // './src/index.js'
this.root = process.cwd(); // 当前执行命令的文件根目录 用于查找入口文件的真实路径
// 需要保存所有的模块依赖
this.modules = {} // 结构为 路径: 代码 也就是编译后源码的webpack启动函数参数
}
/** 启动 webpack 并且 创建模块依赖关系
* @description
* @param { String } 入口文件真实路径
* @param { Boolean } 是否主模块
*/
buildModule(modulePath, isEntry) {
// 拿到当前模块内容
let source = this.getSource(modulePath);
// 模块id 需要改造下 绝对路径 -> 相对路径 (webpack打包后的模块id是一个相对路径)
let moduleName = './' + path.relative(this.root, modulePath); // 比如 ./src/index.js
console.log(`模块id:${ moduleName}, 模块内容:${ source }`);
}
run() {
this.buildModule(path.resolve(this.root, this.entryId), true);
}
getSource(modulePath) {
let source = fs.readFileSync(modulePath, 'utf8');
return source;
}
}
module.exports = Compiler;
此刻执行 npx-pack
模块id:./src/index.js,
模块内容:import a from './a.js'; console.log("导入模块:" + a);
3) 添加 parse方法 替换require、修复依赖模块id
let fs = require('fs');
let path = require('path');
/**
* @description 用来编译的工具类
* @param { Object } webpack 配置
* @returns void
*/
class Compiler {
...
buildModule(modulePath, isEntry) {
// 拿到当前模块内容
let source = this.getSource(modulePath);
// 模块id 需要改造下 绝对路径 -> 相对路径 (webpack打包后的模块id是一个相对路径)
let moduleName = './' + path.relative(this.root, modulePath); // 比如 ./src/index.js
console.log(`模块id:${ moduleName}, 模块内容:${ source }`);
if (isEntry) this.entryId = moduleName; // 保存主入口
// path.dirname(moduleName) 为 ./src 把它传进去进行路径修复
// sourceCode 目标模块打包结果代码 dependncies为依赖列表
let { sourceCode, dependncies } = this.parse(source, path.dirname(moduleName));
this.modules[moduleName] = sourceCode; // 当前模块的id -> 当前模块打包的源码
}
run() {
this.buildModule(path.resolve(this.root, this.entryId), true);
}
/**
* @description 解析源码 核心步骤 当前模块依赖文件导入模块路径修复 require替换
* @description 生成 AST 解析语法树
* @param { String } source 当前模块内容
* @param { String } parentPath 修复当前模块依赖模块路径需要的父级路径 如'./a.js' -> './src/a.js'
* @returns { Object } sourceCode: 当前模块对应源码(替换了require) dependncies 当前模块依赖列表
*/
parse(source, parentPath) {
console.log(source, parentPath, 'bingo');
}
...
}
module.exports = Compiler;
4) parse 方法实现
这里介绍几个node包
- babylon 将 js 转换成 ast
- @babel/traverse 遍历 ast
- @babel/types 替换 ast 某节点
- @babel/generator 将 ast 解码生 js代码
let fs = require('fs');
let path = require('path');
let babylon = require('babylon'); // js 转 ast
let traverse = require('@babel/traverse').default; // 遍历 ast
let types = require('@babel/types'); // 替换 ast 某节点
let generator = require('@babel/generator').default; // 将 ast 解码生 js代码
/**
* @description 用来编译的工具类
* @param { Object } webpack 配置
* @returns void
*/
class Compiler {
...
/** 启动 webpack 并且 创建模块依赖关系
* @description
* @param { String } 入口文件真实路径
* @param { Boolean } 是否主模块
*/
buildModule(modulePath, isEntry) {
// 拿到当前模块内容
let source = this.getSource(modulePath);
// 模块id 需要改造下 绝对路径 -> 相对路径 (webpack打包后的模块id是一个相对路径)
let moduleName = './' + path.relative(this.root, modulePath); // 比如 ./src/index.js
if (isEntry) this.entryId = moduleName; // 保存主入口
// path.dirname(moduleName) 为 ./src 把它传进去进行路径修复
// sourceCode 目标模块打包结果代码 dependncies为依赖列表
let { sourceCode, dependncies } = this.parse(source, path.dirname(moduleName));
console.log(`修正后的模块源码:${ sourceCode }, 模块依赖包列表:${ JSON.stringify(dependncies) }`);
this.modules[moduleName] = sourceCode; // 当前模块的id -> 当前模块打包的源码
}
run() {
this.buildModule(path.resolve(this.root, this.entryId), true);
}
/**
* @description 解析源码 核心步骤 当前模块依赖文件导入模块路径修复 require替换
* @param { String } source 当前模块内容
* @param { String } parentPath 修复当前模块依赖模块路径需要的父级路径 如'./a.js' -> './src/a.js'
* @returns { Object } sourceCode: 当前模块对应源码(替换了require) dependncies 当前模块依赖列表
*/
parse(source, parentPath) {
let ast = babylon.parse(source, { sourceType: 'module' }); // 源码解析成 ast
let dependncies = []; // 当前模块依赖的模块数组
traverse(ast, {
CallExpression(p) {
// 这里建议在以上贴的网站参考 require('a.js') 转成的 ast结构
let node = p.node; // 对应的节点
// 如果标签为 require
if (node.callee.name == 'require') {
node.callee.name = '__webpack_require__';
let moduleName = node.arguments[0].value; // './a.js'
// 拼上扩展名
moduleName = moduleName + (path.extname(moduleName) ? '' : '.js');
// 拼上要处理的父路径
moduleName = './' + path.join(parentPath, moduleName); // ./src/a.js
node.arguments = [types.stringLiteral(moduleName)]; // 通过 types 替换掉 ast 某节点
dependncies.push(moduleName); // 放入依赖数组
}
}
})
let sourceCode = generator(ast).code; // 重新转成 js 代码
return { sourceCode, dependncies }
}
getSource(modulePath) {
let source = fs.readFileSync(modulePath, 'utf8');
return source;
}
}
module.exports = Compiler;
执行 npx ys-pack
修正后的模块源码:var a = __webpack_require__("./src/a.js"); console.log("导入模块:" + a);;
模块依赖包列表:["./src/a.js"]
5) 递归解析依赖模块
class Compiler {
...
buildModule(modulePath, isEntry) {
let source = this.getSource(modulePath);
let moduleName = './' + path.relative(this.root, modulePath);
if (isEntry) this.entryId = moduleName;
let { sourceCode, dependncies } = this.parse(source, path.dirname(moduleName));
console.log(`修正后的模块源码:${ sourceCode }, 模块依赖包列表:${ JSON.stringify(dependncies) }`);
this.modules[moduleName] = sourceCode; // 递归完毕后 this.modules保存着所有加载的模块
// 递归 解析主模块依赖的模块
dependncies.forEach(dep => {
this.buildModule(path.join(this.root, dep), false);
});
}
...
}
执行 npx ys-pack
修正后的模块源码:var a = __webpack_require__("./src/a.js"); console.log("导入模块:" + a);'
模块依赖包列表:["./src/a.js"]
修正后的模块源码:var a = 1; module.exports = a;
模块依赖包列表:[]
run 方法内增加打印
run() {
this.buildModule(path.resolve(this.root, this.entryId), true);
console.log(this.modules, this.entryId);
}
输出
{
'./src/index.js': 'var a = __webpack_require__("./src/a.js");\n\nconsole.log("导入模块:" + a);',
'./src/a.js': 'var a = 1;\nmodule.exports = a;'
}
./src/index.js
6) 增加 emitFile 方法 使用 modules 组装 ejs模板 并输出
安装 ejs 模块
yarn add ejs -D
新增文件 lib/template.ejs
模板代码
/******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId]) {
/******/ return installedModules[moduleId].exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ i: moduleId,
/******/ l: false,
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.l = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules;
/******/
/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules;
/******/
/******/ // define getter function for harmony exports
/******/ __webpack_require__.d = function(exports, name, getter) {
/******/ if(!__webpack_require__.o(exports, name)) {
/******/ Object.defineProperty(exports, name, { enumerable: true, get: getter });
/******/ }
/******/ };
/******/
/******/ // define __esModule on exports
/******/ __webpack_require__.r = function(exports) {
/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ }
/******/ Object.defineProperty(exports, '__esModule', { value: true });
/******/ };
/******/
/******/ // create a fake namespace object
/******/ // mode & 1: value is a module id, require it
/******/ // mode & 2: merge all properties of value into the ns
/******/ // mode & 4: return value when already ns object
/******/ // mode & 8|1: behave like require
/******/ __webpack_require__.t = function(value, mode) {
/******/ if(mode & 1) value = __webpack_require__(value);
/******/ if(mode & 8) return value;
/******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
/******/ var ns = Object.create(null);
/******/ __webpack_require__.r(ns);
/******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value });
/******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
/******/ return ns;
/******/ };
/******/
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function getDefault() { return module['default']; } :
/******/ function getModuleExports() { return module; };
/******/ __webpack_require__.d(getter, 'a', getter);
/******/ return getter;
/******/ };
/******/
/******/ // Object.prototype.hasOwnProperty.call
/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/ // __webpack_public_path__
/******/ __webpack_require__.p = "";
/******/
/******/
/******/ // Load entry module and return exports
/******/ return __webpack_require__(__webpack_require__.s = "<%-entryId%>");
/******/ })
/************************************************************************/
/******/ ({
<%for(let key in modules){%>
"<%-key%>": (function(module, exports, __webpack_require__) {
eval(`<%-modules[key]%>`); // 可能有换行符 所以为了方便这里我们使用模板字符串
}), // 这里有个逗号哦
<%}%>
/******/ });
修改 Compiler.js 增加 emitFile 方法
let fs = require('fs');
let path = require('path');
let babylon = require('babylon'); // js 转 ast
let traverse = require('@babel/traverse').default; // 遍历 ast
let types = require('@babel/types'); // 替换 ast 某节点
let generator = require('@babel/generator').default; // 将 ast 解码生 js代码
let ejs = require('ejs');
const mkdirp = require('mkdirp')
const getDirName = require('path').dirname;
/**
* @description 用来编译的工具类
* @param { Object } webpack 配置
* @returns void
*/
class Compiler {
...
emitFile() { // 发射文件 用数据渲染我们的 lib/template.ejs 并输出到 this.config.output.path
// 输出路径
let outPath = path.join(this.config.output.path, this.config.output.filename);
let templateString = this.getSource(path.join(__dirname, 'template.ejs')); // 读取 ejs 模板
let outputCode = ejs.render(templateString, { entryId: this.entryId, modules: this.modules }); // 渲染 ejs 模板 得到打包后的源码
this.assets = {}; // 可能会有多个出口文件~
// 资源中 输出路径对应的打包后代码
this.assets[outPath] = outputCode;
for (let key in this.assets) { // 循环输出 output files
this.writeFileSync(key, this.assets[key]);
}
}
run() {
this.buildModule(path.resolve(this.root, this.entryId), true);
this.emitFile();
}
writeFileSync (path, contents) { // 没有文件夹则自动创建的 writeFileSync 方法
mkdirp(getDirName(path)).then(res => fs.writeFileSync(path, contents));
}
}
执行 npx ys-pack
dist/bundle.js
/******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId]) {
/******/ return installedModules[moduleId].exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ i: moduleId,
/******/ l: false,
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.l = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules;
/******/
/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules;
/******/
/******/ // define getter function for harmony exports
/******/ __webpack_require__.d = function(exports, name, getter) {
/******/ if(!__webpack_require__.o(exports, name)) {
/******/ Object.defineProperty(exports, name, { enumerable: true, get: getter });
/******/ }
/******/ };
/******/
/******/ // define __esModule on exports
/******/ __webpack_require__.r = function(exports) {
/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ }
/******/ Object.defineProperty(exports, '__esModule', { value: true });
/******/ };
/******/
/******/ // create a fake namespace object
/******/ // mode & 1: value is a module id, require it
/******/ // mode & 2: merge all properties of value into the ns
/******/ // mode & 4: return value when already ns object
/******/ // mode & 8|1: behave like require
/******/ __webpack_require__.t = function(value, mode) {
/******/ if(mode & 1) value = __webpack_require__(value);
/******/ if(mode & 8) return value;
/******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
/******/ var ns = Object.create(null);
/******/ __webpack_require__.r(ns);
/******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value });
/******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
/******/ return ns;
/******/ };
/******/
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function getDefault() { return module['default']; } :
/******/ function getModuleExports() { return module; };
/******/ __webpack_require__.d(getter, 'a', getter);
/******/ return getter;
/******/ };
/******/
/******/ // Object.prototype.hasOwnProperty.call
/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/ // __webpack_public_path__
/******/ __webpack_require__.p = "";
/******/
/******/
/******/ // Load entry module and return exports
/******/ return __webpack_require__(__webpack_require__.s = "./src/index.js");
/******/ })
/************************************************************************/
/******/ ({
"./src/index.js": (function(module, exports, __webpack_require__) {
eval(`var a = __webpack_require__("./src/a.js");
console.log('导入模块: ' + a);`); // 可能有换行符 所以为了方便这里我们使用模板字符串
}),
"./src/a.js": (function(module, exports, __webpack_require__) {
eval(`// a.js
var a = 1;
module.exports = a;`); // 可能有换行符 所以为了方便这里我们使用模板字符串
}),
/******/ });
导入模块: 1
7) 简易webpack完成,附 Compiler 完整代码
let fs = require('fs');
let path = require('path');
let babylon = require('babylon'); // js 转 ast
let traverse = require('@babel/traverse').default; // 遍历 ast
let types = require('@babel/types'); // 替换 ast 某节点
let generator = require('@babel/generator').default; // 将 ast 解码生 js代码
let ejs = require('ejs');
const mkdirp = require('mkdirp')
const getDirName = require('path').dirname;
/**
* @description 用来编译的工具类
* @param { Object } webpack 配置
* @returns void
*/
class Compiler {
constructor(config) {
this.config = config;
// 需要保存入口文件的路径
this.entryId = config.entry; // './src/index.js'
this.root = process.cwd(); // 当前执行命令的文件根目录 用于查找入口文件的真实路径
// 需要保存所有的模块依赖
this.modules = {} // 结构为 路径: 代码 也就是编译后源码的webpack启动函数参数
}
/** 启动 webpack 并且 创建模块依赖关系
* @description
* @param { String } 入口文件真实路径
* @param { Boolean } 是否主模块
*/
buildModule(modulePath, isEntry) {
// 拿到当前模块内容
let source = this.getSource(modulePath);
// 模块id 需要改造下 绝对路径 -> 相对路径 (webpack打包后的模块id是一个相对路径)
let moduleName = './' + path.relative(this.root, modulePath); // 比如 ./src/index.js
if (isEntry) this.entryId = moduleName; // 保存主入口
// path.dirname(moduleName) 为 ./src 把它传进去进行路径修复
// sourceCode 目标模块打包结果代码 dependncies为依赖列表
let { sourceCode, dependncies } = this.parse(source, path.dirname(moduleName));
console.log(`修正后的模块源码:${ sourceCode }, 模块依赖包列表:${ JSON.stringify(dependncies) }`);
this.modules[moduleName] = sourceCode; // 当前模块的id -> 当前模块打包的源码
// 递归 依赖性也进行解析
dependncies.forEach(dep => {
this.buildModule(path.join(this.root, dep), false);
});
}
emitFile() { // 发射文件 用数据渲染我们的 lib/template.ejs 并输出到 this.config.output.path
// 输出路径
let outPath = path.join(this.config.output.path, this.config.output.filename);
let templateString = this.getSource(path.join(__dirname, 'template.ejs')); // 读取 ejs 模板
let outputCode = ejs.render(templateString, { entryId: this.entryId, modules: this.modules }); // 渲染 ejs 模板 得到打包后的源码
this.assets = {}; // 可能会有多个出口文件~
// 资源中 输出路径对应的打包后代码
this.assets[outPath] = outputCode;
for (let key in this.assets) { // 循环输出 output files
this.writeFileSync(key, this.assets[key]);
}
}
run() {
this.buildModule(path.resolve(this.root, this.entryId), true);
this.emitFile();
}
/**
* @description 解析源码 核心步骤 当前模块依赖文件导入模块路径修复 require替换
* @param { String } source 当前模块内容
* @param { String } parentPath 修复当前模块依赖模块路径需要的父级路径 如'./a.js' -> './src/a.js'
* @returns { Object } sourceCode: 当前模块对应源码(替换了require) dependncies 当前模块依赖列表
*/
parse(source, parentPath) {
let ast = babylon.parse(source, { sourceType: 'module' }); // 源码解析成 ast
let dependncies = []; // 当前模块依赖的模块数组
traverse(ast, {
CallExpression(p) {
// 这里建议在以上贴的网站参考 require('a.js') 转成的 ast结构
let node = p.node; // 对应的节点
// 如果标签为 require
if (node.callee.name == 'require') {
node.callee.name = '__webpack_require__';
let moduleName = node.arguments[0].value; // './a.js'
// 拼上扩展名
moduleName = moduleName + (path.extname(moduleName) ? '' : '.js');
// 拼上要处理的父路径
moduleName = './' + path.join(parentPath, moduleName); // ./src/a.js
node.arguments = [types.stringLiteral(moduleName)]; // 通过 types 替换掉 ast 某节点
dependncies.push(moduleName); // 放入依赖数组
}
}
})
let sourceCode = generator(ast).code; // 重新转成 js 代码
return { sourceCode, dependncies }
}
getSource(modulePath) {
let source = fs.readFileSync(modulePath, 'utf8');
return source;
}
writeFileSync (path, contents) { // 没有文件夹则自动创建的 writeFileSync 方法
mkdirp(getDirName(path)).then(res => fs.writeFileSync(path, contents));
}
}
module.exports = Compiler;
至此 我们自己的打包工具拥有了简单的打包能力,那怎么引用loader跟plugin呢~
8) 增加 loader (less-loader,style-loader)
新增 loader 文件夹,增加 less-loader,style-loader两个文件。
安装 less
yarn add less -D
// less-loader.js
let less = require('less');
function lessLoader(source) {
console.log(source);
let css = '';
less.render(source, function(err, res) {
css = res.css;
})
return css;
}
css = css.replace(/\n/g, '\\n'); // 处理换行符
module.exports = lessLoader;
// style-loader.js
function styleLoader(source) {
console.log(source);
// 接收源码(到这里已经是css啦) 塞进页面 style 标签
let style = `
let style = document.createElement('style');
style.innerHtml = ${ JSON.stringify(source) };
document.head.appendChild(style);
`
return style;
}
module.exports = styleLoader;
// webpack.config.js
let path = require('path');
module.exports = {
mode: 'development',
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
module: {
rules: [
{
test: /\.less$/,
use: [
// 这里是绝对路径 如果不写路径直接写 loader 名称 默认从 node_modules 中引入
path.resolve(__dirname, 'loader', 'style-loader'),
path.resolve(__dirname, 'loader', 'less-loader')
]
}
]
}
}
// Compole.js 增加使用 loader 逻辑
class Compiler {
...
// require 模块走这里 先读取模块内容 再经过 loader 转换
getSource(modulePath) {
let rules = this.config.module.rules; // 所有 rules
let source = fs.readFileSync(modulePath, 'utf8');
// 拿到每个规则来处理
for (var i = 0; i < rules.length; i++) {
let rule = rules[i];
let { test, use: loaderList } = rule;
let loaderListLen = loaderList.length;
console.log(test.test(modulePath));
// test的正则能匹配到的模块路径,则启用 loader 从右向左依次处理源码
if (test.test(modulePath)) {
function loopLoader() {
let loader = require(loaderList[--loaderListLen]); // 取最后一个loader 且每次减 1
source = loader(source);
if (loaderListLen > 0) {
loopLoader();
}
}
loopLoader(); // 递归执行
}
}
return source;
}
...
}
执行编译 npx ys-pack
// build.js
(function() {
...
}) ({
"./src/index.js": (function(module, exports, __webpack_require__) {
eval(`var a = __webpack_require__("./src/a.js");
__webpack_require__("./src/index.less"); // 引入less
console.log('导入模块: ' + a);`); // 可能有换行符 所以为了方便这里我们使用模板字符串
}),
"./src/a.js": (function(module, exports, __webpack_require__) {
eval(`// a.js
var a = 1;
module.exports = a;`); // 可能有换行符 所以为了方便这里我们使用模板字符串
}),
"./src/index.less": (function(module, exports, __webpack_require__) {
eval(`let style = document.createElement('style');
style.innerHtml = "body {\n background: red;\n}\n";
document.head.appendChild(style);`); // 可能有换行符 所以为了方便这里我们使用模板字符串
}),
可以看到,less文件以及正常打包了~ dist 文件新建 index.html,引入 build.js,打开 html,可以看到页面变红哦
总结:
- loader 是一个函数,主要是对输出内容做二次处理。
- loader 在 require 且 正则匹配后缀成功时调用。
- loader 从右向左执行,从下到上
9) 增加 plugins
安装 tapable 到你了~
yarn add tapable
增加插件plugins/emit-plugin.js,entry-options-plugin.js(插件内部提供了一个apply方法去注册监听函数,接收 Compiler实例)
// plugins/emit.plugin.js
class EmitPlugin {
apply(compiler) {
compiler.hooks.emit.tap('emit', function() { // 注册监听函数 但是并没有执行哦
console.log('emit结束,也就是 bundle.js 生成后执行');
})
}
}
module.exports = EmitPlugin;
// plugins/emit.plugin.js
class EmitPlugin {
apply(compiler) {
compiler.hooks.emit.tap('emit', function() { // 注册监听函数 但是并没有执行哦
console.log('emit结束,也就是 bundle.js 生成后执行');
})
}
}
module.exports = EmitPlugin;
// webpack.config.js
let path = require('path');
let EmitPlugin = require('./plugins/emit-plugin');
let EntryOptionsPlugin = require('./plugins/entry-options-pllugin');
module.exports = {
...
plugins: [
new EntryOptionsPlugin(),
new EmitPlugin()
]
}
ys-pack 新增 entryOptions 钩子初始化 钩子调用
#! /usr/bin/env node
let path = require('path');
// 1) 需要找到当前执行的路径 拿到 webpack.config.js
let config = require(path.resolve(process.cwd(), 'webpack.config.js')); // 拿到 webpack 配置文件
let Compiler = require('../lib/Compiler.js'); // 用来编译的类
let compiler = new Compiler(config); // 配置传入
// 这时候调用 entryOptions 钩子
compiler.hooks.entryOption.call();
compiler.run(); // 标识运行编译
Compiler.js 中增加钩子执行调用
// Compiler.js
let { SyncHook } = require('tapable');
class Compiler {
constructor(config) {
...
this.hooks = {
entryOption: new SyncHook(), // 入口钩子 new Compiler 传入 webpack 配置时 调用
afterPlugins: new SyncHook(), // 编译完插件后加载钩子(插件此时并没有执行,只是注册了监听函数)
run: new SyncHook(), // 运行时钩子 this.run方法执行,webpack开始编译前调用
compile: new SyncHook(), // 编译钩子 this.buildModule 执行前调用
afterCompile: new SyncHook(), // 编译后钩子 this.buildModule 执行后调用
emit: new SyncHook(), // 发射文件 // this.emit 执行完毕 生成 bundle.js 时调用
done: new SyncHook(), // 执行完成钩子 全部完成调用
}
// 如果webpack配置传递了 plugins 参数
let plugins = this.config.plugins;
if (Array.isArray(plugins)) {
// 这一步只是在 plugin 内部通过 apply 方法完成监听函数注册,也就是调用了 tap 方法,没有 call 哦
plugins.forEach(plugin => {
plugin.apply(this); // 依次执行 把当前 Compiler 实例放进去
});
}
this.hooks.afterPlugins.call(); // 插件编译完成 监听函数通过 apply 注册上了
}
run() {
// run 方法执行时候
this.hooks.run.call();
// 编译开始钩子
this.hooks.compile.call();
this.buildModule(path.resolve(this.root, this.entryId), true);
// 编译结束
this.hooks.afterCompile.call();
this.emitFile();
this.hooks.emit.call();
// 完事儿啦
this.hooks.done.call();
}
...
}
执行编译 npx ys-pack
入口执行,也就是 new Compiler()就会执行
emit结束,也就是 bundle.js 生成后执行
再探 loader 特性
resolveLoader 别名和模块配置!
function loader(source) { // 参数即为源代码
// do something
return source;
}
return loader;
// webpack.config.js
let path = require('path');
module.exports = {
mode: 'development',
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
module: {
rules: [
{
test: /\.less$/,
use: [
path.resolve(__dirname, 'loader', 'loader')
]
}
]
}
}
每次都写绝对路径是不是太长了呢,但是不写路径又默认从 node_modules 引,怎么办。可以使用 resolveLoader 起一个别名。
module.exports = {
...
resolveLoder: { // 不同于 resolve 解析模块 这个是专门解析 loader的
alias: { // 别名
loader1: path.resolve(__dirname, 'loader', 'loader')
}
},
module: {
rules: [
{
test: /\.less$/,
use: 'loader1'
}
]
}
}
但是这样还是比较麻烦,能不能就不用手动写路径,自动寻找 loader 呢,其实我们可以规定从哪里找 loader
module.exports = {
...
resolveLoader: { // 不同于 resolve 解析模块 这个是专门解析 loader的
modules: ['node_modules', path.resolve(__dirname, 'loader')]
},
module: {
rules: [
{
test: /\.less$/,
use: 'loader1'
}
]
}
}
这样的话,如果 node_modules 中找不到 loader, 就会自动去我们 loader 文件夹下找。
loader 真的是 从右往左,从下往上执行的么?
我们知道,常规的loader是从下到上执行,比如我有3个loader,都是处理 js 的,那么默认是从下到上执行,我们能手动操控执行顺序么(也可以倒序写),这里我们来认识下 loader 的分类。
- pre(前置执行的loader)
- normal(正常的loader)
- inline(行内的loader)
- post(后置执行的loader)
执行顺序是 pre > normal > inline > post 我们可以给每个loader 增加一个 enforce 属性,来改变它的类型来影响执行顺序。
module.exports = {
...
resolveLoder: { // 不同于 resolve 解析模块 这个是专门解析 loader的
modules: ['node_modules', path.resolve(__dirname, 'loaders')]
},
module: {
rules: [
{
test: /\.less$/,
use: 'loader1',
enforce: 'pre'
},
{
test: /\.less$/,
use: 'loader2'
},
{
test: /\.less$/,
use: 'loader3',
enforce: 'post'
}
]
}
}
这样,执行顺序就变成 1, 2, 3了。
注意: inline-loader 和原本的 loader 定义并没有什么不同,除了用法!以下是内联 loader的一些特殊用法
- 加入!前缀, 不使用 config loader 中的normal loader,例如 require('!a-loader!./a.js');
- 加入!!前缀,不执行其他类型 loader,例如 require('!!a-loader!./a.js');
- 加入-!前缀,不使用 config loader 中的 normal loader, pre loader,例如 require('-!a-loader!./a.js');
那相同分类的 loader,难道执行顺序就是从右往左,从上到下么?
loader其实由两部分组成,也可以说是它的两个阶段。
- Pitch loader,它会按正常传递顺序执行
- Normal loader,我们实际运行的执行顺序
比如 ['a', 'b', 'c'],正常调用顺序应该是 c、b、a,但是真正调用顺序是 a.pitch()、b.pitch()、c.pitch()、c、b、a, 如果其中任何一个 pitch loader 返回了值就相当于在它以及它右边的 loader 已经执行完毕。比如如果 b 返回了字符串 "result b",接下来只有 a 会被系统执行,且 a 的loader收到的参数是 result b。
也就是说 Pitch loader 的初衷是为了提升效率,少执行几个 loader。

function loader(source) {
return source + '// 我是loader加上的字符串';
}
loader.pitch = function() {
console.log('pitch-loader,我从左往右执行');
return '我之后的 pitch 不再执行'
}
module.exports = loader;
实现 babel-loader
安装其他 babel 相关
yarn add @babel/core @babel/preset-env -D
yarn add loader-utils -D // loader 工具库 用于帮我们拿到 loader中配置的 options
修改 webpack 配置
let path = require('path');
module.exports = {
mode: 'development',
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
resolveLoader: { // 不同于 resolve 解析模块 这个是专门解析 loader的
modules: ['node_modules', path.resolve(__dirname, 'loaders')]
},
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: { // loader-utils 可以帮我们在 loader 中拿到该配置
presets: ['@babel/preset-env']
}
}
}
]
}
}
babel-loader实现
let babel = require('@babel/core');
let loaderUtils = require('loader-utils');
function loader(source) {
// 拿到 loader 配置的 options
let options = loaderUtils.getOptions(this);
let cb = this.async(); // loader 内部提供的 异步方法,可以帮助我们返回结果
console.log(this.resourcePath); // /Users/ys/study/webpack学习源码/手写各种loader/src/index.js
// 调用 @babel/core 内 transform 方法进行转换
babel.transform(source, {
presets: options.presets,
sourceMap: true,
filename: this.resourcePath.split('/').pop() // 生成的文件名(source查看 webpack:// 源码展示的名字)
}, function(err, result) {
// 拿到参数(错误,编译后源码,souremap) 自动帮我们返回结果
cb(err, result.code, result.map);
})
}
module.exports = loader;
// src/index.js
class Ys {
constructor() {
this.name = 'ys';
}
getName() {
return this.name;
}
}
let ys = new Ys();
console.log(ys.getName());
执行 npx webpack进行打包,发现我们的 es6 语法被转成 es5了,但是没有 sourcemap 怎么办,需要知道,我们想让打包出 sourcemap,webpack 配置必须把 devtool 置为相应模式才行。
devtool: 'source-map'
自创 loader 之 banner-loader
我们实现一个 loader,来根据配置或者读取文件内容来为源码署名,如果配置 options.filename,则使用 options.filename 中的文件进行署名,不然使用 options.text!
let loaderUtils = require('loader-utils'); // loader 工具类 我们需要它拿到 options
let validateOptions = require('schema-utils'); // 校验模块
修改 webpack 配置
let path = require('path');
module.exports = {
mode: 'development',
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
resolveLoader: { // 不同于 resolve 解析模块 这个是专门解析 loader的
modules: ['node_modules', path.resolve(__dirname, 'loaders')]
},
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'banner-loader',
options: {
text: 'amosyang署名',
filename: path.resolve(__dirname, '', 'banner-template.js')
}
}
}
]
}
}
根目录创建 banner-template.js
杨帅署名2
loaders 目录下创建 banner-loader.js
let loaderUtils = require('loader-utils');
let validateOptions = require('schema-utils'); // 校验模块
let fs = require('fs');
function loader(source) {
// 是否启用缓存
// this.cacheable(false); // 每次均重新打包
this.cacheable && this.cacheable(); // 不传参数默认为 true
// 拿到 loader 配置的 options
let options = loaderUtils.getOptions(this);
let cb = this.async(); // loader 内部提供的 异步方法,可以帮助我们返回结果
let schema = { // 创建 options 骨架
type: 'object',
properties: {
text: {
type: 'string'
},
filename: {
type: 'string'
}
}
}
// 参数(传入的骨架 实际接收的参数 参数错误时,抛出错误的文件名)
validateOptions(schema, options, 'banner-loader');
if (options.filename) {
this.addDependency(options.filename); // 当 webpack 配置为 true 时,监听此文件修改
fs.readFile(options.filename, 'utf8', function(err, data) {
cb(err, `/**${ data }**/${ source }`);
})
} else {
cb(null, `/**${ options.text }**/${ source }`);
}
}
module.exports = loader;
执行 npx webpack 可以看到我们的文件都被加上签名啦
...
"use strict";
eval("/**杨帅署名2221**/\n\nfunction _classCallCheck(){...}"
这时候有一个问题,如果我在 webpack 中开启了监听
watch: true
这时候我修改了 banner-template.js 内的模板内容,发现重新 build了,这一切都归功于其中一句代码,我们在 loader 内添加了这个模板为监听文件。
this.addDependency(options.filename); // 当 webpack 配置为 true 时,监听此文件修改
实现 file-loader
src 下 copy 进来一张图片 public.png
修改 src/index.js
// src/index.js
import p from './public.png';
let img = document.createElement('img');
console.log(p);
img.src = p;
document.body.appendChild(img);
修改 webpack 配置
// webpack.config.js
let path = require('path');
module.exports = {
mode: 'development',
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
resolveLoader: { // 不同于 resolve 解析模块 这个是专门解析 loader的
modules: ['node_modules', path.resolve(__dirname, 'loaders')]
},
module: {
rules: [
{
test: /\.png|jpg|gif$/,
// 目的就是根据图片生成一个 md5 戳 发射到 dist目录下,发射完成后,模块本身返回一个带 hash 的图片名
use: 'file-loader'
}
]
}
}
// laoders/file-loader.js
let loaderUtils = require('loader-utils');
function loader(source) {
console.log(source);
// this = loaderContext, 根据传入的格式,二进制内容。产生一个替换后的文件名
let filename = loaderUtils.interpolateName(this, '[hash].[ext]', { content: source });
// 放入到 Complication.assets 上,webpack 最后写入文件的时候带上~
this.emitFile(filename, source);
// 实际导出的是这个处理过的文件名,文件名作为文件模块的返回结果
return `module.exports=${ JSON.stringify(filename) }`;
}
loader.raw = true; // 设置接收的 source 为 Buffer
module.exports = loader;
执行编译 npx build,成功~
Time: 437ms
Built at: 2020-11-30 22:14:32
Asset Size Chunks Chunk Names
bundle.js 4.65 KiB main [emitted] main
c37f803ecaf165e89ca5344e6120b7af.png 693 KiB [emitted]
Entrypoint main = bundle.js
[./src/index.js] 508 bytes {main} [built]
[./src/public.png] 53 bytes {main} [built]
可以看到,file-loader 也就是拷贝了下文件到 dist 中,改个名字~
实现 url-loader
src 下 copy 进来一张图片 public.png
修改 src/index.js
// src/index.js
import p from './public.png';
let img = document.createElement('img');
img.src = p;
document.body.appendChild(img);
修改 webpack 配置
// webpack.config.js
let path = require('path');
module.exports = {
mode: 'development',
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
resolveLoader: { // 不同于 resolve 解析模块 这个是专门解析 loader的
modules: ['node_modules', path.resolve(__dirname, 'loaders')]
},
module: {
rules: [
{
test: /\.png|jpg|gif$/,
use: {
loader: 'url-loader',
options: {
limit: 200*1024 // 大于 200k 产生文件 小于200k 变成 base64
}
}
}
]
}
}
// laoders/file-loader.js
let loaderUtils = require('loader-utils');
let mime = require('mime'); // 获取文件类型
function loader(source) {
let { limit } = loaderUtils.getOptions(this);
if (limit && limit > source.length) { // 注意此处根据 source.length 判断文件大小
// 把文件变成 base64
return `module.exports="data:${mime.getType(this.resourcePath)};base64,${source.toString('base64')}"`;
} else {
// 调用 file-loader 完成此步操作
return require('./file-loader').call(this, source);
}
}
loader.row = true;
module.exports = loader;
执行编译 npx build,成功~
Time: 437ms
Built at: 2020-11-30 22:14:32
Asset Size Chunks Chunk Names
bundle.js 4.65 KiB main [emitted] main
c37f803ecaf165e89ca5344e6120b7af.png 693 KiB [emitted]
Entrypoint main = bundle.js
[./src/index.js] 508 bytes {main} [built]
[./src/public.png] 53 bytes {main} [built]
实现 less-loader
针对 loader 配置
{
test:/\.less$/,
use:[
'style-loader',//生成一段JS脚本,向页面插入style标签,style的内容就是css文本
'less-loader'//把less编译成css
]
}
index.less 内容~
@color:red;
#root{
color:@color;
}
less-loader 实现~
let less = require('less');
/**
* 希望这个loader可以放在最左侧,所以返回一段 js(只不过不使用style-loader,没什么效果)
* @param {*} inputSource
* 传入的参籹
* 如果是最后在的或者说最右边的loader,参数就是模块的内容
* 如果不是最后一个,参数就是上一个loader返回的内容
*/
function loader(inputSource) {
console.log('less-loader');
// 默认情况下loader的执行是同步的,如果调用了async方法,可以把loader的执行变成异步
let callback = this.async();
less.render(inputSource, { filename: this.resource }, (err, output) => {
// less-loader其实返回的是一段JS脚本,也就是说它可以放在最左侧
// 但是没被插入到页面,没用
callback(null, `module.exports = ${JSON.stringify(output.css)}`);
});
}
module.exports = loader;
实现 style-loader
let loaderUtils = require('loader-utils');
/**
* @param {*} inputSource less-loader编译后的CSS内容
* 因为 less-loader 返回一段 js 脚本
* 所以 inputSource 是 `module.exports = "#root{color:red}"`
*/
function loader(inputSource){}
/**
* 如果pitch函数有返回值,不需要于执行后续的loader和读文件了
* request 就是 style-loader!less-loader!index.less
* @param {*} remainingRequest 剩下的请求,为 less-loader!index.less
* @param {*} previousRequest 前面的请求 ""
* @param {*} data 每个 loader 独立的数据,pitch 赋值,loader 内可取值
* @returns
*/
loader.pitch = function(remainingRequest,previousRequest,data){
console.log('remainingRequest',remainingRequest); //less-loader.js!index.less
console.log('previousRequest',previousRequest); // ""
console.log('data',data); // {}
let script = `
let style = document.createElement('style');
style.innerHTML = require(${loaderUtils.stringifyRequest(this,"!!"+remainingRequest)});
document.head.appendChild(style);
`;
// 这个返回的JS脚本给了webpack了
// 把这个JS脚本转成AST抽象语法树,分析里的require依赖
// loaderUtils.stringifyRequest用于绝对路径 -> 相对根模块路径(类似模块ID)
// 分析require('!!./loaders/less-loader!./src/index.less');
// !!的前缀代表只要行内,不要前置后置和普通,
// 如果不加,最后处理 index.less 时候就会出现递归执行(死循环)。
// 会去拿到 less-loader 的返回值,也就是 JSON.stringify(output.css) 塞进 style
// 最后插入到 head 标签中
return script;
}
module.exports = loader;
你看啊,style-loader 的 pitch 不是已经有返回值了么,那它后面的 less-loader 的 pitch 和 normal loader 不是不走了么,那怎么拿到 less-loader 的返回值呢? 是的,是不走了,不过呢,我们 style-loader 的 pitch 方法最后返回了一段 js 内容,在这段内容中做了 require('!!./loaders/less-loader!./src/index.less') 操作,这会引发 less-loader 的执行~
深入 plugin
插件向第三方开发者提供了 webpack 引擎中完整的能力。使用阶段式的构建回调,开发者可以引入它们自己的行为到 webpack 构建流程中。创建插件比创建 loader 更加高级,因为你将需要理解一些 webpack 底层的内部特性来做相应的钩子。
为什么需要一个插件
- webpack 基础配置无法满足需求
- 插件几乎能够任意更改 webpack 编译结果
- ebpack 内部也是通过大量内部插件实现的
可以加载插件的常用对象
| 对象 | 钩子 |
|---|---|
| Compiler | run,compile,compilation,make,emit,done |
| Compilation | buildModule,normalModuleLoader,succeedModule,finishModules,seal,optimize,after-seal |
| Module Factory | beforeResolver,afterResolver,module,parser |
| Module | 无 |
| Parser | program,statement,call,expression |
| Template | hash,bootstrap,localVars,render |
创建插件
- 插件是一个类
- 类上有一个apply的实例方法
- apply的参数是compiler
class DonePlugin {
constructor(options) {
this.options = options;
}
apply(compiler) {
// 也可以 tapAsync 给钩子注册异步回调
compiler.hooks.done.tap('DonePlugin', stats => {
console.log('hello', this.options.name);
});
}
}
module.exports = DonePlugin;
Compiler 和 Compilation
在插件开发中最重要的两个资源就是compiler和compilation对象。理解它们的角色是扩展 webpack 引擎重要的第一步。
- compiler 对象代表了完整的 webpack 环境配置。这个对象在启动 webpack 时被一次性建立,并配置好所有可操作的设置,包括 options,loader 和 plugin。当在 webpack 环境中应用一个插件时,插件将收到此 compiler 对象的引用。可以使用它来访问 webpack 的主环境。
- compilation 对象代表了一次资源版本构建。当运行 webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。一个 compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用。
// compiler 一般来用监听编译的流程 开始 编译结束
// compilation 一般用来监听编译过程中一些资源,每次文件改变都会执行
class AssetPlugin{
constructor(options){
this.options = options;
}
apply(compiler){
// compiler只有一个, 每当监听到文件的变化,就会创建一个新的 compilation
// 每当 compiler 开启一次新的编译,就会创建一个新的 compilation
// 触发一次 compilation 事件
compiler.hooks.compilation.tap('AssetPlugin',(compilation)=>{
compilation.hooks.chunkAsset.tap('AssetPlugin', (chunk,filename)=>{
console.log(chunk.hash, filename, '重新生成 compilation 实例');
});
});
}
}
module.exports = AssetPlugin;
基本插件架构
- 插件是由「具有 apply 方法的 prototype 对象」所实例化出来的
- 这个 apply 方法在安装插件时,会被 webpack compiler 调用一次
- apply 方法可以接收一个 webpack compiler 对象的引用,从而可以在回调函数中访问到 compiler 对象
实现 zipPlugin 插件
我们来实现一个 zipPlugin 插件,它可以把生成的所有文件打一个包~
const path = require('path');
const JSZip = require('jszip');
const { RawSource } = require('webpack-sources');
/* class RawSource{
constructor(content){
this.content = content;
}
//每个Source类都有source方法,返回内容
source(){
return this.content;
}
} */
class ZipPlugin {
constructor(options) {
this.options = options;
}
apply(compiler) {
// 把打包后的文件全部打包在一起生成一个文件包,压缩包
compiler.hooks.emit.tapAsync('ZipPlugin', (compilation, callback) => {
let zip = new JSZip();
for (let filename in compilation.assets) {
// assets 中的每一项通过 source 方法拿源码,这是约定好的~
const source = compilation.assets[filename].source();
zip.file(filename, source);
}
zip.generateAsync({ type: 'nodebuffer' }).then(content => {
// 插入的时候也要构造 source 类
compilation.assets[this.options.filename] = new RawSource(content);
callback();
});
});
}
}
module.exports = ZipPlugin;
[优化] module.noParse 不解析包内的依赖库
如果我们确信,某个包不会依赖其他包「比如 jquery 或 lodash」,那么可以配置 module.noParse 不去解析依赖库,如果依赖库很多的情况下,这种方法能明显提升打包效率。
module.exports = smart(base, {
module: {
noParse: /jquery/, // 不去解析 jquery 中的依赖库
}
});
[优化] webpack.IgnorePlugin 忽略包内引用 手动维护引用
比如我们有一个moment包,使用如下:
import moment from 'moment';
// 设置语言
moment.locale('zh-cn');
let r = moment().endof('day').fromNow();
console.log(r); // "十四天前"
我这里面只用了一个方法,打包却有 1.2M,分析了下,原来在 moment 入口文件 moment.js 中,它帮我们引入了各国的语言包。
aliasedRequire('./locale/' + name);
不过,我们实际需要的只有一个中文的包,这时候我们可以屏蔽掉包内对外部的某一个依赖,手动去维护这个引用。
import webpack from 'webpack';
module.exports = {
plugins: [
new webpack.IgnorPlugin({
contextRegExp: /moment/$, // 匹配 moment 范围
resourceRegExp: /^\.\/locale/ // locale 开头的外部包引用忽略
})
]
}
然后执行我们代码,发现打包文件少了500k,moment设置语言不好使了,打印出去 in 14 hours,这时候我们需要手动引入需要的中文包。
import moment from 'moment';
// 手动引入所需要的语言包
import 'moment/locale/zh-cn';
// 设置语言
moment.locale('zh-cn');
let r = moment().endof('day').fromNow();
console.log(r); // "十四天前"
[优化] dllPlugin 动态链接库 生成manifest.json 构建速度提升10倍
当我们一个项目引入了多个较大的包以后,这些包本身并不会运行,我们也不会修改这些包的代码,但是每当我们修改了业务代码之后,这些包也会被重新打包。极大的浪费了时间,这时我们就需要使用这个工具预先把静态资源提前打包,以后修改源文件再打包时就不会打包这些静态资源文件了。
将静态资源文件(运行依赖包)与源文件分开打包,先使用DllPlugin给静态资源打包,再使用DllReferencePlugin让源文件引用资源文件。
// src/index.js
import React from 'react';
import { render } from 'react-dom';
render(<h1>hello webpack</h1>, window.root);
贴上webpack配置
var path = require('path');
var HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'development',
entry: './src/index',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
devServer: {
port: 3000,
open: true,
contentBase: './dist'
},
module: {
rules: [
{
test: /\.js$/,
use: [{
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-env',
'@babel/preset-react' // 解析webpack语法
]
}
}]
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: './index.html', // 模板路径
filename: 'index.html'
})
]
}
执行 npx webpack
Time: 3110ms
Built at: 2020-11-25 0:27:50
Asset Size Chunks Chunk Names
bundle.js 870 KiB main [emitted] main
index.html 251 bytes [emitted]
可以看到,每次打包耗时3s,打包出来的文件有 870k,可是我们只写了一句代码~ 这其中包含了 react 和 react-dom 包的内容,这两个文件我们不会更改,我们能不能每次打包前,把这两个包抽离出去,引入到页面,打包后直接用呢。
我们新增两个文件
// src/index
module.exports = 123;
// webpack.config.react.js
var path = require('path');
module.exports = {
mode: 'development',
entry: {
test: './src/test.js'
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist')
}
}
分析打包后的 test.js,此处只留下一些关键代码
(function(modules) { // webpackBootstrap
// The module cache
var installedModules = {};
// The require function
function __webpack_require__(moduleId)
return module.exports;
}
return __webpack_require__(__webpack_require__.s = "./src/test.js");
})(
{
"./src/test.js": (function(module, exports) {
eval("module.exports = 123;\n\n//# sourceURL=webpack:///./src/test.js?");
})
}
);
可以看到,打包后的文件把我们的 exports 返回了,却并没有接收。我们可以在外部定义一个变量结收模块返回值。
var a = (function(modules) { // webpackBootstrap
// The module cache
var installedModules = {};
// The require function
function __webpack_require__(moduleId)
return module.exports;
}
return __webpack_require__(__webpack_require__.s = "./src/test.js");
})(
{
"./src/test.js": (function(module, exports) {
eval("module.exports = 123;\n\n//# sourceURL=webpack:///./src/test.js?");
})
}
);
console.log(a); // 123
ok,可以拿到我们模块的输出结果,但是不能每次都这么手动去加啊,其实 output 属性提供了配置项 library: 'a'
module.exports = {
mode: 'development',
entry: {
test: './src/test.js'
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist'),
// 用变量 a 接收模块输出的结果 var a = (fn() { return module.exports; } ())
library: 'a',
// 解析方式
// 不写默认为var 代表 var a = 模块返回结果
// 如果是 commonjs 代表 export['a'] = 模块返回结果
// umd 代表兼容各版本写法
libraryTarget: 'var'
}
}
这时候,a变量就能拿到模块的返回值了,我们需要做一件事儿,就是把入口文件,换成真正需要提取的库名
module.exports = {
mode: 'development',
entry: {
// 把两个包统一作为名为 react 的入口文件
react: ['react', 'react-dom']
},
output: {
// 打包出的应为 _dll_react_vendor.js
filename: '_dll_[name]_vendor.js',
path: path.resolve(__dirname, 'dist'),
// 用变量 _dll_react_vendor 接收模块输出的结果
// var _dll_react_vendor = (fn() { return module.exports; } ())
library: '_dll_[name]_vendor',
libraryTarget: 'var'
},
plugins: [
new webpack.DllPlugin({
name: '_dll_[name]_vendor', // 需要跟上面变量名一致,便于查找
path: path.resolve(__dirname, 'dist', 'manifest.json') // 生成资产清单文件
})
]
}
这时候打包,我们就发现 dist 目录新增文件 _dll_react_vendor.js,manifest.json
// manifest.json
{
"name": "_dll_react_vendor", // 根据这个去找对应 js 所以要跟 js 文件名一样
"content": {
"./node_modules/object-assign/index.js": {
"id": "./node_modules/object-assign/index.js",
"buildMeta": { "providedExports": true }
},
"./node_modules/react-dom/cjs/react-dom.development.js": {
"id": "./node_modules/react-dom/cjs/react-dom.development.js",
"buildMeta": { "providedExports": true }
},
"./node_modules/react-dom/index.js": {
"id": "./node_modules/react-dom/index.js",
"buildMeta": { "providedExports": true }
},
}
}
资产清单上记录着 [name].js 上提供了哪些包。
修改 html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="root"></div>
<!-- 引入dll动态库 -->
<script src="/_dll_react_vendor.js"></script>
</body>
</html>
此时我们引入了 dll 动态库,我们是不是需要告诉 webpack,你引入包的时候,先去我动态库中查找是否有这些包,如果有的话,就别打包了。怎么告诉它呢,webpack 提供了配套使用的 DllReferencePlugin,我们来修改 webpack.config.js 代码,注意,不是 webpack.config.react.js 哦。
plugins: [
// 先去查找 dll 资源清单,清单找不到的时候,再去打包文件
new webpack.DllReferencePlugin({
manifest: path.resolve(__dirname, 'dist', 'manifest.json')
})
]
第一次要先执行dll文件的打包命令(不然因为 manifest.json 未生成,DllReferencePlugin 会报错哦)
npx webpack --config=webpack.config.react.js
然后打包我们的主文件即可
npx webpack
发现主文件大小从 870k 变为 6k,打包时间从 3110ms 变为 409ms
Time: 409ms
Built at: 2020-11-25 1:55:12
Asset Size Chunks Chunk Names
bundle.js 6.27 KiB main [emitted] main
index.html 299 bytes [emitted]
Entrypoint main = bundle.js
需要注意的是,如果配置了 cleanWebpackPlugin,还需要告诉该插件,每次重新打包,不要删除 _dll_react_vendor.js 和 manifest.json
[优化] happypack 多进程打包(不维护了)
由于HappyPack 对file-loader、url-loader 支持的不友好,所以不建议对该loader使用。
yarn add happypack -D
修改 js的loader 为 happypack/loader,并添加 plugin 配置
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: 'Happypack/loader?id=haha' // 代表 js 需要多线程
},
// css 同
]
},
plugins: [
// css 同
new Happypack({
id: 'haha', // id 标识符,要和 rules 中指定的 id 对应起来
use: [
{
loader: 'babel-loader',
options: {
presets: [ '@babel/preset-env', '@babel/preset-react']
}
}
]
})
]
}
打包下,发现启动了三个线程去打包,但是打包比之前还慢了 100ms,这是怎么回事呢?
> npx webpack
Happy[js]: Version: 5.0.1. Threads: 3
Happy[js]: All set; signaling webpack to proceed.
Hash: 5107dc46f2f09ddd86c9
Version: webpack 4.44.2
Time: 989ms
Built at: 2020-11-25 20:55:15
Asset Size Chunks Chunk Names
bundle.js 6.27 KiB main [emitted] main
index.html 299 bytes [emitted]
Entrypoint main = bundle.js
原来,如果代码量不多的话,使用 happypack 反而会拖慢打包速度哦,所以酌情看需不需要开启多线程打包~
[优化] thread-loader 多进程打包
HappyPack 已经不再维护了,现在我们一般使用 thread-loader 来进行多进程打包。
npm i thread-loader -D
修改 js的loader 为 happypack/loader,并添加 plugin 配置
module.exports = {
module: {
rules: [
/*
开启多进程打包。
进程启动大概为600ms,进程通信也有开销。
只有工作消耗时间比较长,才需要多进程打包
*/
{
loader: 'thread-loader',
options: {
workers: 2 // 进程2个
}
}
]
}
}
[自带优化] js 的 tree-shaking (生产环境 && import 语法生效)
修改下代码
// index.js
import calc from './test';
console.log(calc.sum(1, 2));
// test.js
let sum = (a, b) => {
return a + b + 'sum';
}
let minus = (a, b) => {
return a - b + 'minus';
}
export default { sum, minus }
这时候打包,我们发现 sum 和 minus 都在 dist/bundle.js 中,但是我只用到了 sum 呀,能不能把我没用到的方法不打包呢,这时候我们把 mode 改为生成环境再次打包,发现 minus 方法被优化掉了,这就是 webpack4 自带的摇树优化。
需要注意的是,摇树优化只针对 import 语法导入的模块生效,require 无效哦。
[自带优化] scope hoisting 作用域提升 (生效条件如上)
webpack3之后,webpack新增了 scope hoisting 优化,在 webpack 中会自动省略一些可以简化的代码(import语法),并且正常来说 webpack 的引入都是把各个模块分开,通过 webpack_require 导入导出模块,但是使用 scope hoisting 后会把需要导入的文件直接移入导入者顶部,这就是所谓的 hoisting。
记得设置 mode 为 production哦
// inex.js
import calc from './test';
console.log(calc.sum(1, 2));
let a = 1;
let b = 2;
let c = 3;
let d = a + b + c;
console.log(d, '----------------')
// test.js
let sum = (a, b) => {
return a + b + 'sum';
}
let minus = (a, b) => {
return a - b + 'minus';
}
export default { sum, minus }
我们分析打包后的代码, 发现一些重复的代码都被干掉了(a,b,c,d),直接把结果6放上了,而且引用其他模块的方法,也是直接声明在了当前模块的顶端,减少了引用操作,这就是 scope hoisting!!!
...
function (e, t, r) {
"use strict";
r.r(t);
var n = function (e, t) {
return e + t + "sum";
};
console.log(n(1, 2));
console.log(6, "----------------");
},
...
[优化] splitChunks 抽取公共代码 避免多次打包
新增 a.js
// a.js
console.log('aaa', '--------------');
新增 b.js
// b.js
console.log('bbb', '--------------');
新增 other.js
// other.js
import './a';
import './b';
console.log('other.js');
修改 index.js
// index.js
import './a';
import './b';
console.log('index.js');
修改 webpack 入口和出口配置
// webpack.config.js
module.exports = {
entry: {
index: './src/index',
other: './src/other'
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist')
},
}
执行打包,可以看到,dist/index.js 和 dist/other.js 都有公共模块的代码。
能不能把这两个模块抽离出来,变成一个公共模块,然后在 index 和 other分别引入这个模块呢,这样就不会打包两遍 a.js 和 b.js 啦。webpack提供了 splitChunks 来帮助我们抽离公共文件。
// webpack.config.js
module.exports = {
optimization: {
splitChunks: { // 分割代码块
cacheGroups: { // 缓存组
common: { // 我们定义一个公共的模块
chunks: 'initial', // 从入口处开始 就要提取代码
minSize: 0, // 大于0字节就抽离
minChunks: 2, // 引用至少2次 就抽离
}
}
}
}
}
这时候进行打包,我们发现 dist 目录多了一个 common~index~other.js,这里面就是我们要提取的公共模块代码,而 dist/index.js 和 dist/other.js 则没有公共模块的代码。
(window.webpackJsonp = window.webpackJsonp || []).push([
[0],
[
function (o, n) {
console.log("aaa", "--------------");
},
function (o, n) {
console.log("bbb", "--------------");
},
]
]);
这时候我们又有一个想法,如果我们引了两次 jquery,你都给我提到 common 模块中,那得多大呀,我想把第三方包抽成一个 vendor包!
我们来模拟一下,在 index.js 和 other.js 中都加上如下代码
import $ from 'jquery';
console.log($);
这时候打包,一看 common~index~other.js 增加了 80k,这是不能容忍的!
这时我们可以加一个要抽离出的公共包,叫 vendor。
module.exports = {
optimization: {
splitChunks: { // 分割代码块
cacheGroups: { // 缓存组
common: { // 我们定义一个公共的模块
chunks: 'initial', // 从入口处开始 就要提取代码
minSize: 0, // 大于0字节就抽离
minChunks: 2, // 引用至少2次 就抽离
}
},
vendor: {
test: /node_modules/, // 如果引了 node_modules 下的包才考虑抽离
chunks: 'initial', // 从入口处开始 就要提取代码
minSize: 0, // 大于0字节就抽离
minChunks: 2, // 引用至少2次 就抽离
priority: 1 // 权重比上面的高一点 优先检查三方包的抽离以免全部打进 common
}
}
}
}
执行打包命令,我们发现 jquery 已经从 common包中抽离出去了
Time: 3910ms
Built at: 2020-11-26 0:21:28
Asset Size Chunks Chunk Names
common~index~other.js 163 bytes 0 [emitted] common~index~other
index.html 396 bytes [emitted]
index.js 1.58 KiB 2 [emitted] index
other.js 1.58 KiB 3 [emitted] other
vendor~index~other.js 88.5 KiB 1 [emitted] vendor~index~other
[优化工具] speed-measure-webpack-plugin 显示构建速度
npm i const speed-measure-webpack-plugin -D
使用
const ZipPlugin = require("./plugins/ZipPlugin");
const SmwPlugin = require('speed-measure-webpack-plugin');
const smw = new SmwPlugin(); // 创建一个实例
module.exports = smw.wrap({ // 使用 wrap 包裹 webpack 配置
entry: './src/index',
output: {
path: path.resolve('build'),
filename: 'bundle.js'
},
mode: 'development',
plugins: [
new ZipPlugin({ filename: 'assets.zip' })
]
})
编译,可以看到各阶段的执行时间~
[优化工具] webpack-bundle-analyzer 打包文件分析
npm i webpack-bundle-analyzer -D
使用
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
entry: './src/index',
output: {
path: path.resolve('build'),
filename: 'bundle.js'
},
mode: 'development',
plugins: [
new BundleAnalyzerPlugin()
]
}