2023-08-18
前端工程化
00
请注意,本文编写于 472 天前,最后修改于 270 天前,其中某些信息可能已经过时。

目录

1. webpack-chain
chainWebpack
2. 自定义loader
加载本地loader的四种方法
loader核心API
md-loader 案例
loader的执行顺序
3. 自定义Plugins
plugin工作原理
tapable
内部的一些钩子
什么是钩子
Tapable
plugin 的基本结构
4. 打包分析
打包时间分析
打包文件分析
5. 打包原理
6. webpack源码学习
webpack的启动流程
webpack源码调试
webpack运行流程
Compiler与Compilation的区别

本文将主要讲述以下内容:

  1. webpack-chain的基本使用;
  2. 自定义webpack loader
  3. plugin的工作原理,tapable的用法及自定义plugin
  4. 手撸一个Demo版本的Webpack
  5. 讲解webpack启动流程、运行流程及源码调试技巧。

1. webpack-chain

为什么要使用chainWebpack的方式修改webpack配置

  • webpack.config.js配置单个项目没有什么问题,
  • 但是对于团队有多个项目,想共享一些配置文件,你会觉得比较难以入手,因为要考虑构建配置的可扩展性。
  • webpack-chain尝试通过提供可链式和流式的API创建和修改webpack配置。
  • webpack-chain 由两部分组成 configureWebpackchainWebpack

chainWebpack

类似于JavaScript Map为链式和生成配置提供一些便利,如果一个属性被标记为ChainedMap,则它将具有如下的API和方法:除非另有说明,否则这些方法将返回ChainedMap,允许链式调用这些方法。

webpack-chain 有一定的认知和学习成本,推荐先跑一跑官网例子, 切记别上来就尝试修改cli配置(知识的掌握效率低)。(中学做实验有个控制变量法,编程有种设计思想叫做职责单一,学习技术同样如此)

webpack-chain修改配置示例

javascript
module.exports = { chainWebpack: (config) => { // 一般是新增规则写在这里 config.module .rule('cssOptimize') .test(/\.(sc|c)ss$/i) .include .add(path.resolve(__dirname, './src')) .end() .use('removeCssComments') .loader(path.resolve(__dirname, './loaders/removeCssCommets.js')) // 2.1 修改loader示例 config.module .rule('eslint') .set('exclude', /node_module/) .use('eslint-loader') .loader('eslint-loader') .tap((options) => { options.fix = true; options.formatter = require('eslint-friendly-formatter'); return options; }); // 3.1 修改plugin示例 if (process.env.NODE_ENV !== 'development') { config.plugin('BannerPlugin') .use(require('webpack').BannerPlugin, [{ banner: `${getVersionInfo()}`, include: /manifest/, }]).end(); } // 3.2 修改plugin示例2 修改 HtmlWebpackPlugin config.plugin('html').tap(args => { // 官网没说那么细,一开始我以为args是一个对象,其实它的配置是数组的第一个元素 Object.assign(args[0], { title: '标题', versionInfo: new Date().toLocalString(), /*。。。。*/ }); return args; }).end(); }, // configureWebpack: {}, configureWebpack: (config) => { const htmlPlugin = config.plugins.find((item) => (item.options || {}).filename === 'index.html'); htmlPlugin.options.title = 'custome title'; return config } }

2. 自定义loader

加载本地loader的四种方法

js
/* 1.指定loader的绝对路径 */ module.exports = { module: { rules: [ { test: /\.js$/, // 在这里配置绝对路径 use: path.resolve(__dirname, 'loaders/myLoader.js') } ] } } /* 2.resolveLoader里配置alias别名 */ module.exports = { resolveLoader: { alias: { myLoader: path.resolve(__dirname, 'loaders/myLoader.js') } }, module: { rules: [ { test: /\.js$/, use: 'myLoader' } ] } } /* 3.resolveLoader 里配置 modules 属性 */ module.exports = { // xxx resolveLoader: { // 配置 resolveLoader.modules modules: ['node_modules', path.resolve(__dirname, 'loaders'] }, module: { rules: [ { test: /\.js$/, use: 'myLoader' } ] } } /* 4. npm link */

loader核心API

官方文档如何写一个loader

1.首先看一个极简的loader

js
module.exports = function (source) { // const result = source.replace("XXX", "xxx"); // console.log(result); return source; // 下面是完整写法, 如果有错误传入第一个参数, // return this.callback(null, source); };
  • loader的返回值必须是String或者Buffer类型
  • 如果是字符串则必须符合JS语法的字符串

2.处理一个异步的loader

  • less-loader举例
js
const less = require("less"); module.exports = function (source) { less.render(source, (e, output) => { // console.log(output.css); this.callback(e, output.css); }); };
  • 再举个例子style-loader
js
module.exports = function (source) { return `const ele = document.createElement('style'); ele.innerHTML = ${JSON.stringify(source)}; document.head.appendChild(ele) `; };
  1. loader的参数
  • webpack.config.js module.rules配置
js
{ test: /\.js$/i, use: [ { test: /\.js$/i, loader: "mybabel-loader", options: { presets: [ "@babel/preset-env" ] } } ] }
  • mybabel-loader.js
js
const babel = require("@babel/core"); const { getOptions } = require("loader-utils"); module.exports = function(content) { // 设置为异步的loader const callback = this.async(); // 获取传入的参数 const options = getOptions(this); // 补充知识点: 如果需要校验参数可以使用 schema-utils // 对源代码进行转换 babel.transform( content, options, /* todo把这里参数与配置文件merge合并 */ (err, result) => { if (err) { callback(err); } else { callback(null, result.code) } }) }

md-loader 案例

  1. 配置webpack module.rules
js
{ test: /\.md$/i, use: [ // "html-loader", "mymd-loader" ] },
  1. 示例代码
  • mymd-loader.js
js
const marked = require('marked'); const hljs = require('highlight.js'); module.exports = function(content) { marked.setOptions({ highlight: function(code, lang) { return hljs.highlight(lang, code).value; } }) const htmlContent = marked(content); const innerContent = "`" + htmlContent + "`"; const moduleCode = `var code=${innerContent}; export default code;` return moduleCode; }
  • src/index.js
js
import code from "./doc.md"; import "highlight.js/styles/default.css"; document.body.innerHTML = code;
  1. 新建doc.md文件,增加一些内容打包试试看

loader的执行顺序

  1. loader的执行循序是从后往前
  • webpack.config.js module.rules配置
js
{ test: /\.js$/i, use: [ 'second_exec-loader', 'first_exec_loader', ] }
  1. pitch loadernormal loader
  • webpack.config.js loader配置如下
js
{ test: /\.js$/i, use: [ 'second_exec-loader', 'first_exec_loader', ] }
  • first_exec_loader.js
js
// normal loader module.exports = function(content) { console.log("first_exec-loader"); return content; } // pitch loader module.exports.pitch = function() { console.log("first_exec-loader pitch"); }
  • second_exec-loader.js
js
module.exports = function(content) { console.log("second_exec-loader"); return content; } module.exports.pitch = function() { console.log("second_exec-loader pitch"); }

输出顺序为

image.png

  1. enforce改变loader顺序
  • webpack.config.js loader配置如下
js
{ test: /\.js$/i, use: 'second_exec-loader', enforce: "pre", }, { test: /\.js$/i, use: 'first_exec_loader' }
  • 如果忽略enforce字段,上述配置与案例1的执行顺序一致
  • 增加enforce的执行循序为

image.png

总结: loader配置enforce后执行顺序为

  • pitch loader post, inline, normal, pre
  • normal loader pre, normal, inline, post

inline 是什么?
import "style-loader!css-loader!./css/style.css";

3. 自定义Plugins

plugin工作原理

  • webpack像是一条生产线,要经过一系列的处理流程才能将源代码转换成输出结果
  • 这条生产线上的处理流程都是职责单一的,多个流程之间有存在依赖关系,只有完成当前处理后才能交给下一个流程去处理。
  • 插件就像插入生产线的一个功能,在特定的时机对生产线上的资源做处理。
  • webpack通过tabable来组织这条复杂的生产线,webpack在运行过程中会广播事件,插件只需要监听它所需要的事件,就能加入到这条生产线中,去改变生产线的运作。
  • webpack通过事件机制保证了插件的有序性,使得整个系统扩展性很好

——「深入浅出 Webpack」

Plugin作用

  • 作用于webpack的生命周期,执行更广泛的任务。
  • 扩展了webpack,拥有更强的构建能力

tapable

在学习webpack plugin之前要先了解下tapable,然后再学习webpack plugin就是水到渠成的事了。
tapable本质是一个高级版本的事件机制,我们先来看一下EventEmmitter

javascript
class EventEmmitter { events = {} on(name, fn) { const events = this.events[name] ??= []; events.push(fn); } emit(name, data) { const events = this.events[name] ??= []; events.forEach(fn => fn(data)); } off(name, fn) { const events = this.events[name] ??= []; if(!fn) { this.events[name].length = 0; } else { this.events[name] = events.filter( item => ![item, item.cb].includes(fn) ) } } once(name, fn) { const once = (arg) => { fn(arg); this.off(name, once); } once.cb = fn; this.on(name, once) } }

补充说明: EventEmmitter仅仅短短几十行代码,却在前端领域的应用非常广泛, 比如nodejsvuereact都有它的身影。

因为eventEmmitter的应用如此广泛,我这里也不在啰嗦eventEmitter的使用了,接下来我们来看一下tapable的使用。

javascript
const { SyncHook } = require('tapable'); const syncHook = new SyncHook(['name', 'age']); syncHook.tap('event1', function(name, age) { console.log('event1 of syncHook execed', name, age); }); syncHook.tap('event2', function(name, age) { console.log('event2 of syncHook execed', name, age); }); syncHook.call('张三', 25); /** * 输出 * - event1 of syncHook execed 张三 25 * - event2 of syncHook execed 张三 25 * * 与 eventEmmitter 的区别 * - 创建实例的时候需要声明参数个数, * - 声明几个参数 .tap事件的回调函数就能收到几个参数 **/ const { SyncBailHook } = require('tapable'); const bailHook = new SyncBailHook(['name', 'age']); bailHook.tap('event1', function(name, age) { console.log('event1 of SyncBailHook execed', name, age); // return false; }); bailHook.tap('event2', function(name, age) { console.log('event2 of SyncBailHook execed', name, age); }); bailHook.call('李四', 32); /** * SyncBailHook与 SyncHook的区别 * - 如果前一个事件有返回值(返回值不为undefined)则后续事件不再执行 * - 类似 DOM事件的 stopImmediatePropagation() */ const {SyncWaterfallHook} = require('tapable'); const waterfallHook = new SyncWaterfallHook(['name', 'age']); waterfallHook.tap('event1', function(name, age) { console.log('event1 of SyncWaterfallHook execed', name, age); return '王小二'; }); waterfallHook.tap('event2', function(name, age) { console.log('event2 of SyncWaterfallHook execed', name, age); }); waterfallHook.call('王五', 32); /** * 输出 * - event1 of SyncWaterfallHook execed 王五 32 * - event2 of SyncWaterfallHook execed 王小二 32 * * SyncWaterfallHook 与 SyncHook 的 区别 * - 如果前一个事件有返回值(返回值不为undefined),则传给下一个事件 */ const {SyncLoopHook} = require('tapable'); const loopHook = new SyncLoopHook(); let count = 0; loopHook.tap('event1', function() { count++; console.log('event1 of SyncLoopHook execed', count); if(count < 2) { return true; } // return '王小二'; }); loopHook.tap('event2', function() { count++; console.log('event2 of SyncLoopHook execed', count); if(count < 4) { return true; } }); loopHook.call(); /** * 输出 * - event1 of SyncLoopHook execed 1 * - event1 of SyncLoopHook execed 2 * - event2 of SyncLoopHook execed 3 * - event1 of SyncLoopHook execed 4 * - event2 of SyncLoopHook execed 5 * * 与 SyncHook 的区别 * - 如果某个事件有返回值(返回值不为undefined)则循环执行包含该事件的之前的所有事件 */ const { AsyncSeriesHook } = require('tapable'); const seriesHook = new AsyncSeriesHook(['name', 'age']); seriesHook.tapAsync('event1', (name, age, cb) => { console.log('event1 of AsyncSeriesHook execed', name, age); setTimeout(() => { cb() }, 1000) }); seriesHook.tapPromise('event1', (name, age) => new Promise( (resolve) => { console.log('event2 of AsyncSeriesHook execed', name, age) setTimeout(resolve, 2000) } )); seriesHook.callAsync('王二小', 12, () => { console.log('all events execed done') }) /** * 输出 * - event1 of AsyncSeriesHook execed 王二小 12 * 间隔1s * - event2 of AsyncSeriesHook execed 王二小 12 * 间隔2s * - all events execed done * * AsyncSeriesHook 与 SyncHook 的区别 * - AsyncSeriesHook是异步串行 * - 使用 tapAsync 和 tapPromise 代替 tap 这样也是更好的语义化 * - 使用 callAsync 代替 call */ const { AsyncParallelHook } = require('tapable'); const parallelHook = new AsyncParallelHook(['name', 'age']); parallelHook.tapAsync('event1', (name, age, cb) => { setTimeout(() => { console.log('event1 of AsyncParallelHook execed done', name, age); cb(); }, 2000) }); parallelHook.tapPromise('event2', (name, age) => new Promise( (resolve) => { setTimeout(() => { console.log('event2 of AsyncParallelHook execed done', name, age) resolve(); }, 1000); } )); parallelHook.callAsync('王二小', 12, () => { console.log('all events execed done') }) /** * 输出 * 间隔1s * - event2 of AsyncParallelHook execed done 王二小 12 * 在间隔1s * - event1 of AsyncParallelHook execed done 王二小 12 * - all events execed done * * AsyncParallelHook 与 SyncHook 的区别 * - AsyncParallelHook 是异步并行 */ /** * 理解了 sync 与 async, * bail 与 waterfall、loop, * parallel 与 Series 基本上就掌握了hook * 还剩一些组合情况,不需要解释了 */ const { AsyncParallelBailHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook, AsyncSeriesLoopHook, } = require('tapable')

内部的一些钩子

什么是钩子

钩子的本质就是:事件。为了方便我们直接介入和控制编译过程,webpack 把编译过程中触发的各类关键事件封装成事件接口暴露了出来,这些接口被很形象地称做:hooks(钩子)。开发插件,离不开这些钩子。

Tapable

Tapable 为 webpack 提供了统一的插件接口(钩子)类型定义,它是 webpack 的核心功能库。webpack 中目前有十种 hooks,在 Tapable 源码中可以看到,他们是:

javascript
// https://github.com/webpack/tapable/blob/master/lib/index.js exports.SyncHook = require("./SyncHook"); exports.SyncBailHook = require("./SyncBailHook"); exports.SyncWaterfallHook = require("./SyncWaterfallHook"); exports.SyncLoopHook = require("./SyncLoopHook"); exports.AsyncParallelHook = require("./AsyncParallelHook"); exports.AsyncParallelBailHook = require("./AsyncParallelBailHook"); exports.AsyncSeriesHook = require("./AsyncSeriesHook"); exports.AsyncSeriesBailHook = require("./AsyncSeriesBailHook"); exports.AsyncSeriesLoopHook = require("./AsyncSeriesLoopHook"); exports.AsyncSeriesWaterfallHook = require("./AsyncSeriesWaterfallHook");

Tapable 还统一暴露了三个方法给插件,用于注入不同类型的自定义构建行为:

  • tap:可以注册同步钩子和异步钩子。
  • tapAsync:回调方式注册异步钩子。
  • tapPromise:Promise方式注册异步钩子。

plugin 的基本结构

一个 webpack plugin 由如下部分组成:

  1. 一个命名的 Javascript 方法或者 JavaScript 类。
  2. 它的原型上需要定义一个叫做 apply 的方法。
  3. 注册一个事件钩子。
  4. 操作webpack内部实例特定数据。
  5. 功能完成后,调用webpack提供的回调

一个基本的 plugin 代码结构大致长这个样子:

javascript
// plugins/MyPlugin.js class MyPlugin { apply(compiler) { compiler.hooks.done.tap('My Plugin', (stats) => { console.log('Bravo!'); }); } } module.exports = MyPlugin;

开发一个文件清单插件

4. 打包分析

打包时间分析

speed-measure-webpack-plugin

打包文件分析

5. 打包原理

尝试写一个简单webpck构建过程

  1. 业务代码如下
javascript
// src/index.js import a from "./a.js"; import b from "./b.js"; console.log(a + "hello,webpack-bundle!"); // src/a.js import c from "./c.js"; export default "haha" + c; // src/b.js export default "xixi"; // src/c.js export default "c";
  1. 编写配置文件
javascript
const path = require("path"); module.exports = { entry: "./src/index.js", mode: "development", output: { path: path.resolve(__dirname, "./dist"), filename: "main.js", }, };

3.编写demo版webpack.js

javascript
const fs = require("fs"); const path = require("path"); const parser = require("@babel/parser"); const traverse = require("@babel/traverse").default; const { transformFromAst } = require("@babel/core"); module.exports = class webpack { constructor(options) { const { entry, output } = options; this.entry = entry; this.output = output; this.modules = []; } run() { //开始分析入口模块的内容 const info = this.parse(this.entry); //递归分析其他的模块 // console.log(info); this.modules.push(info); for (let i = 0; i < this.modules.length; i++) { const item = this.modules[i]; const { dependencies } = item; if (dependencies) { for (let j in dependencies) { this.modules.push(this.parse(dependencies[j])); } } } const obj = {}; this.modules.forEach((item) => { obj[item.entryFile] = { dependencies: item.dependencies, code: item.code, }; }); // console.log(obj); this.file(obj); } parse(entryFile) { const content = fs.readFileSync(entryFile, "utf-8"); const ast = parser.parse(content, { sourceType: "module", }); const dependencies = {}; traverse(ast, { ImportDeclaration({ node }) { // "./a.js" => "./src/a.js" const newPathName = "./" + path.join(path.dirname(entryFile), node.source.value); // console.log(newPathName); dependencies[node.source.value] = newPathName; }, }); const { code } = transformFromAst(ast, null, { presets: ["@babel/preset-env"], }); return { entryFile, dependencies, code, }; } file(code) { //创建自运行函数,处理require,module,exports //生成main.js = >dist/main.js const filePath = path.join(this.output.path, this.output.filename); console.log(filePath); //require("./a.js") // this.entry = "./src/index.js" const newCode = JSON.stringify(code); const bundle = `(function(graph){ function require(module){ function reRequire(relativePath){ return require(graph[module].dependencies[relativePath]) } var exports = {}; (function(require,exports,code){ eval(code) })(reRequire,exports,graph[module].code) return exports; } require('${this.entry}') })(${newCode})`; fs.writeFileSync(filePath, bundle, "utf-8"); } };
  1. 编写执行脚本 node bundle.js
javascript
const options = require("./webpack.config.js"); const webpack = require("./lib/webpack.js"); new webpack(options).run();
  1. 将生成后代码放在浏览器控制台运行下
javascript
(function (graph) { function require(module) { function reRequire(relativePath) { return require(graph[module].dependencies[relativePath]) } var exports = {}; (function (require, exports, code) { eval(code) })(reRequire, exports, graph[module].code) return exports; } require('./src/index.js') })({ "./src/index.js": { "dependencies": {"./a.js": "./src/a.js", "./b.js": "./src/b.js"}, "code": "\"use strict\";\n\nvar _a = _interopRequireDefault(require(\"./a.js\"));\n\nvar _b = _interopRequireDefault(require(\"./b.js\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\nconsole.log(_a[\"default\"] + \"hello,webpack-bundle!\");" }, "./src/a.js": { "dependencies": {"./c.js": "./src/c.js"}, "code": "\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports[\"default\"] = void 0;\n\nvar _c = _interopRequireDefault(require(\"./c.js\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\nvar _default = \"haha\" + _c[\"default\"];\n\nexports[\"default\"] = _default;" }, "./src/b.js": { "dependencies": {}, "code": "\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports[\"default\"] = void 0;\nvar _default = \"xixi\";\nexports[\"default\"] = _default;" }, "./src/c.js": { "dependencies": {}, "code": "\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports[\"default\"] = void 0;\nvar _default = \"c\";\nexports[\"default\"] = _default;" } })

6. webpack源码学习

Q:webpackwebpack-cli之间的关系
A:webpack是核心通过nodejs代码进行webpack打包,webpack-cli提供命令行参数合并及打包的功能,它能解析命令行参数merge到配置文件中,最终调用webpack进行打包

webpack的启动流程

  1. npx webpack
  2. node ./node_modules/bin/webpack
    1. 判断是否安装webpack-cli,没有就安装,安装后继续执行
  3. runCli 执行webpack-cli bin.webpack-cli字段 最终相当于执行npx webpack-cli
  4. 接受参数, 执行 lib/bootstrap.js --> new WebpackCli().run()
  5. 执行 webpack-cli.js 构造函数 和run方法
  6. run方法做了些命令行参数文档,然后检查webpack是否安装,没安装就执行安装,然后this.webpack = require('webpack')
  7. --> this.makeCommand --> this.makeOption解析命令行参数merge到配置文件
  8. --> buildCommand --> this.runWebpack (watch实现) --> this.createCompiler
    • this.createCompiler() 有一行关键代码 compiler = this.webpack(config.options, callback)
    • 等价于 (new Webpack(config.options)).run(callback)
    • config.options 即合并命令行参数后的完整webpack配置
    • 到此为止与webpack-cli 就没有关系了

webpack源码调试

前面分析了webpack启动流程,简单概括下

cli做了一些列的检查工作,最后使用执行Webpack构造函数传入参数对象,生成compiler对象,再调用compiler.run()方法。

js
const compiler = new Webpack(config.options) compiler.run(callback); // 返回this 即compiler对象

综上我可以手动创建一个配置文件webpack.config.js和一个脚本build.js,脚本代码如下

js
const webpack = require("../lib/webpack"); const config = require("./webpack.config"); const compiler = webpack(config); compiler.run((err, stats) => { if (err) { console.error(err); } else { console.log(stats); } });

../lib/webpack文件哪里来的?
这个就是github webpack源码包的一个文件。可以把仓库down下来本地调试
控制台执行 node build.js 即可对webpack源码进行断点调试了

webpack-5.24.3.tar.gz 这里是一个加了注释的源码包,有兴趣的同学可以下载下来调试下

webpack运行流程

一次完整的 webpack 打包大致是这样的过程:

  • 将命令行参数与 webpack 配置文件 合并、解析得到参数对象。
  • 参数对象传给 webpack 执行得到 Compiler 对象。
  • 执行 Compilerrun方法开始编译。每次执行 run 编译都会生成一个 Compilation 对象。
  • 触发 Compilermake方法分析入口文件,调用 compilationbuildModule 方法创建主模块对象。
  • 生成入口文件 AST(抽象语法树),通过 AST 分析和递归加载依赖模块。
  • 所有模块分析完成后,执行 compilationseal 方法对每个 chunk 进行整理、优化、封装。
  • 最后执行 CompileremitAssets 方法把生成的文件输出到 output 的目录中。

image.png

注: 创建compiler对象后 立即开始注册所有插件plugin.call(compiler, compiler) or plugin.apply(compiler);除了配置文件中的plugin所有配置项都会转换为Plugin

Compiler与Compilation的区别

  • 在webpack构建的之初就会创建Compiler对象, 并且在webpack的整个生命周期都会存在,除非重启,否则该对象一致保持不变。

webpack的整个生命周期
before - run - beforeCompiler - compile - make - finishMake - afterCompiler - done

  • 当要编译具体某个文件时就是创建Compilation对象, 它主要是存在于 compile(之后) - make(之前) 阶段主要使用的对象,
    • watch -> 源代码发生改变就需要重新编译模块,创建一个新的Compilation对象

本文作者:郭敬文

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!