Webapck5 核心打包原理全流程解析
Webpack 内部插件与钩子关系
一文吃透 Webpack 核心原理 流程图
基础
Tapable
webpack本质上是一种事件流的机制,它的工作流程就是将各个插件串联起来,而实现这一切的核心就是 Tapable。
Webpack 的 Tapable 事件流机制保证了插件的有序性,将各个插件串联起来, Webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条 webapck 机制中,去改变 webapck 的运作,使得整个系统扩展性良好。
Tapable也是一个小型的 library,是Webpack的一个核心工具。类似于node中的events库,核心原理就是一个订阅发布模式。作用是提供类似的插件接口。
SyncHook简单实现
class Hook {
constructor(args) {
this.taps = []
this.interceptors = [] // 这个放在后面用
this._args = args
}
tap(name, fn) {
this.taps.push({ name, fn })
}
}
class SyncHook extends Hook {
call(name, fn) {
try {
this.taps.forEach((tap) => tap.fn(name))
fn(null, name)
} catch (error) {
fn(error)
}
}
}
Loader
loader 让 webpack 能够去处理那些非 JavaScript 文件(webpack 自身只理解 JavaScript)。 loader 可以将所有类型的文件转换为 webpack 能够处理的有效模块,然后你就可以利用 webpack 的打包能力,对它们进行处理。 本质上,webpack loader 将所有类型的文件,转换为应用程序的依赖图(和最终的 bundle)可以直接引用的模块。
Plugin
loader 被用于转换某些类型的模块,而插件则可以用于执行范围更广的任务。 插件的范围包括,从打包优化和压缩,一直到重新定义环境中的变量。插件接口功能极其强大,可以用来处理各种各样的任务。
流程概述
- 收集参数(合并
webpack.config.js和shell) - 通过调用
webpack(options)方法创建compiler对象 - 加载插件到
compiler(hook 到 compiler 的相应生命周期) - 调用
compiler.run(callback)方法启动打包。- 触发
make hook,进入compilation(编译)流程 addEntry方法获得各个入口的对象buildModule编译模块文件- 从入口出发,读取入口文件内容调用匹配
loader处理入口文件。 - 通过
babel分析依赖,并且同时将所有依赖的路径更换为相对于项目启动目录options.context的路径,替换'__webpack_require__'及moduleId。 - 入口文件中如果存在依赖的话,递归上述步骤编译依赖模块。
- 将每个依赖的模块编译后的对象加入
this.modules。 - 将每个入口文件编译后的对象加入
this.entries。
- 从入口出发,读取入口文件内容调用匹配
- 根据
modules和entries的依赖关系,组合最终输出的chunk模块。( this.chunks )注:4.4 - 根据
chunks生成this.assets并输出(触发this.hooks.emit.call()) - 打包结束,触发
done hook,并执行run callback回调
- 触发
webpack源码精简
//build.js
const webpack = require('webpack');
function build() {
const compiler = webpack(config);
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
...
})
})
}
//webpack.js
const Compiler = require('./compiler');
function webpack(options) {
// 合并参数
const options = getNormalizedWebpackOptions(rawOptions);
applyWebpackOptionsBaseDefaults(options);
// 创建compiler对象
const compiler = new Compiler(options.context, options);
//注册内置插件
new NodeEnvironmentPlugin({
infrastructureLogging: options.infrastructureLogging
}).apply(compiler);
// 加载配置插件
if (Array.isArray(options.plugins)) {
for (const plugin of options.plugins) {
if (typeof plugin === "function") {
plugin.call(compiler, compiler);
} else {
plugin.apply(compiler);
}
}
}
applyWebpackOptionsDefaults(options);
compiler.hooks.environment.call();
compiler.hooks.afterEnvironment.call();
//内置插件(EntryOptionPlugin->EntryPlugin->make tap->addEntry)
new WebpackOptionsApply().process(options, compiler);
compiler.hooks.initialize.call();
return compiler;
}
module.exports = webpack;
//compiler.js
const { SyncHook } = require('tapable');
class Compiler {
constructor(context, options = ({})) {
this.hooks = Object.freeze({
...
done: new AsyncSeriesHook(["stats"]),
afterDone: new SyncHook(["stats"]),
beforeRun: new AsyncSeriesHook(["compiler"]),
run: new AsyncSeriesHook(["compiler"]),
emit: new AsyncSeriesHook(["compilation"]),
afterEmit: new AsyncSeriesHook(["compilation"]),
thisCompilation: new SyncHook(["compilation", "params"]),
compilation: new SyncHook(["compilation", "params"]),
beforeCompile: new AsyncSeriesHook(["params"]),
compile: new SyncHook(["params"]),
make: new AsyncParallelHook(["compilation"]),
afterCompile: new AsyncSeriesHook(["compilation"]),
environment: new SyncHook([]),
afterEnvironment: new SyncHook([]),
afterPlugins: new SyncHook(["compiler"]),
afterResolvers: new SyncHook(["compiler"]),
entryOption: new SyncBailHook(["context", "entry"])
...
});
run(callback){
...
}
}
compiler.run(源码精简)
run(callback) {
const finalCallback = (err, stats) => {
this.hooks.afterDone.call(stats);
};
const onCompiled = (err, compilation) => {
if (this.hooks.shouldEmit.call(compilation) === false) {
this.hooks.done.callAsync(stats, err => {
return finalCallback(null, stats);
});
return;
}
process.nextTick(() => {
this.emitAssets(compilation, err => {
if (compilation.hooks.needAdditionalPass.call()) {
this.hooks.done.callAsync(stats, err => {
this.hooks.additionalPass.callAsync(err => {
this.compile(onCompiled);
});
});
return;
}
this.emitRecords(err => {
this.hooks.done.callAsync(stats, err => {
this.cache.storeBuildDependencies(
compilation.buildDependencies,
err => {
return finalCallback(null, stats);
}
);
});
});
});
});
};
const run = () => {
this.hooks.beforeRun.callAsync(this, err => {
this.hooks.run.callAsync(this, err => {
this.readRecords(err => {
this.compile(onCompiled);
});
});
});
};
run();
}
compile(callback) {
this.hooks.beforeCompile.callAsync(params, err => {
this.hooks.compile.call(params);
const compilation = this.newCompilation(params);
// make call,调用addEntry
this.hooks.make.callAsync(compilation, err => {
this.hooks.finishMake.callAsync(compilation, err => {
process.nextTick(() => {
compilation.finish(err => {
compilation.seal(err => {
this.hooks.afterCompile.callAsync(compilation, err => {
return callback(null, compilation);
});
});
});
});
});
});
});
}
Webpack Hooks(生命周期)
compiler 与 compilation
compiler(控制打包流程)
Compiler 对象包含了当前运行Webpack的配置,包括entry、output、loaders等配置,这个对象在启动Webpack时被实例化,而且是全局唯一的。Plugin可以通过该对象获取到 Webpack 的配置信息进行处理。
compilation (负责编译流程)
Compilation对象代表了一次资源版本构建。当运行 webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。一个 Compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用。
为什么需要compilation
在使用watch,源代码发生改变的时候就需要重新编译模块,但是compiler可以继续使用 如果使用compiler则需要初始化注册所有plugin,但是plugin没必要重新注册 这时候就需要创建一个新的compilation对象 而只有修改新的webpack配置才需要重新运行 npm run build 来重新生成 compiler对象
Webpack Plugin
plugin类里面需要实现一个apply方法,webpack打包时候,会调用plugin的aplly方法来执行plugin的逻辑,这个方法接受一个compiler作为参数,这个compiler是webpack实例
学习插件架构,需要理解三个关键问题:
- WHAT: 什么是插件
- WHEN: 什么时间点会有什么钩子被触发
- HOW: 在钩子回调中,如何影响编译状态
Example:
- compiler.hooks.compilation :
- 时机:启动编译创建出 compilation 对象后触发
- 参数:当前编译的 compilation 对象
- 示例:很多插件基于此事件获取 compilation 实例
- compiler.hooks.make:
- 时机:正式开始编译时触发
- 参数:同样是当前编译的 compilation 对象
- 示例:webpack 内置的 EntryPlugin 基于此钩子实现 entry 模块的初始化
- compilation.hooks.optimizeChunks :
- 时机:seal 函数中,chunk 集合构建完毕后触发
- 参数:chunks 集合与 chunkGroups 集合
- 示例:SplitChunksPlugin 插件基于此钩子实现 chunk 拆分优化
- compiler.hooks.done:
- 时机:编译完成后触发
- 参数:stats 对象,包含编译过程中的各类统计信息
- 示例:webpack-bundle-analyzer 插件基于此钩子实现打包分析
Demo
fileList.md
需求:在每次webpack打包之后,自动产生一个打包文件清单,实际上就是一个markdown文件,上面记录了打包之后的文件夹dist里所有的文件的一些信息。
const path = require('path');
const FileListPlugin = require('./fileListPlugin.js');
module.exports = {
mode: 'development',
entry: {
a: './a.js',
b: './b.js',
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].bundle.js',
},
plugins: [
new FileListPlugin({
filename: 'fileList.md',
}),
],
};
参数:生成文件名
hook时机:emit hook
- 类型: AsyncSeriesHook
- 触发的事件:生成资源到 output 目录之前。
- 参数:compilation
class FileListPlugin {
constructor(options) {
this.options = options || {};
this.filename = this.options.filename || 'fileList.md';
}
apply(compiler) {
compiler.hooks.emit.tapAsync('FileListPlugin', (compilation, cb) => {
const fileListName = this.filename;
let len = Object.keys(compilation.assets).length;
let content = `# 一共有${len}个文件\n\n`;
for (let filename in compilation.assets) {
content += `- ${filename}\n`;
}
compilation.assets[fileListName] = {
source: function () {
return content;
},
size: function () {
return content.length;
},
};
cb();
});
}
}
module.exports = FileListPlugin;
cleanPlugin
需求:打包之前清空文件
参数:无
hook时机:
initialize
SyncHook Called when a compiler object is initialized.
const { execSync } = require('child_process');
class CleanPlugin {
apply(compiler) {
compiler.hooks.initialize.tap('CleanPlugin', () => {
const dir = compiler.options.output.path;
execSync(`rm -rf ${dir}`);
});
}
}
module.exports = CleanPlugin;
Babel
Babel 是一个通用的多功能的 JavaScript 编译器。 这个处理过程中的每一步都涉及到创建或是操作抽象语法树,亦称 AST
Babel 的工作流程
Babel 的三个主要处理步骤分别是: 解析(parse)==>转换(transform)==>生成(generate)。
所以要想完成这几个步骤,babel 提供了几个实用工具(@babel/parser,@babel/traverse,@babel/generator)
解析
解析步骤接收代码并输出 AST。 这个步骤分为两个阶段:词法分析(Lexical Analysis)和 语法分析(Syntactic Analysis)。
{
type: "FunctionDeclaration",
id: {
type: "Identifier",
name: "square"
},
params: [{
type: "Identifier",
name: "n"
}],
body: {
type: "BlockStatement",
body: [{
type: "ReturnStatement",
argument: {
type: "BinaryExpression",
operator: "*",
left: {
type: "Identifier",
name: "n"
},
right: {
type: "Identifier",
name: "n"
}
}
}]
}
}
转换
转换步骤接收 AST 并对其进行遍历,在此过程中对节点进行添加、更新及移除等操作。 这是 Babel 或是其他编译器中最复杂的过程 同时也是插件将要介入工作的部分,这将是本手册的主要内容, 因此让我们慢慢来。
生成
代码生成步骤把最终(经过一系列转换之后)的 AST 转换成字符串形式的代码,同时还会创建源码映射(source maps)。 代码生成其实很简单:深度优先遍历整个 AST,然后构建可以表示转换后代码的字符串。
遍历
babel-traverse Babel Traverse(遍历)模块维护了整棵树的状态,并且负责替换、移除和添加节点。
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const code = `function square(n) {
return n * n;
}`;
const ast = parser.parse(code);
traverse(ast, {
FunctionDeclaration(path) {
if (
path.node.type === "Identifier" &&
path.node.name === "n"
) {
path.node.name = "x";
}
}
});
Paths(路径)
AST 通常会有许多节点,那么节点直接如何相互关联呢? 我们可以使用一个可操作和访问的巨大可变对象表示节点之间的关联关系,或者也可以用Paths(路径)来简化这件事情。. Path 是表示两个节点之间连接的对象。
计算机语言,一般都是由Statement(语句)、expression(表达式)和declaration(声明)组成
DEMO
- 箭头函数转普通函数
- 函数添加加try catch
- 编译埋点
- 按需加载
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generator = require('@babel/generator').default;
const template = require('@babel/template').default;
const babel = require('@babel/core');
const t = require('@babel/types');
const source1 = `
const func1 = (a,b) => { a=a+b; b=a-b; a=a-b; }
const func2 = n => n * n;
function func3(){
n*n
}
function func4(){
for(i=0;i<10000;i++){
for(j=0;j<10000;j++){
}
}
}
func4()
`;
//function func2(n){return n*n}
const ast = parser.parse(source1);
const visitor = {
ArrowFunctionExpression(path) {
const { node } = path;
const id = path.parent.id;
const params = path.node.params;
if (id.name === 'func1') {
node.type = 'FunctionExpression';
}
if (id.name === 'func2') {
const body = t.blockStatement([t.returnStatement(node.body)]);
const ast = t.functionExpression(id, params, body);
path.replaceWith(ast);
}
},
FunctionDeclaration(path, state) {
const { node } = path;
let body = node.body;
if (t.isBlockStatement(body) && t.isTryStatement(body.body[0])) return;
// 文件名
// let filename = state.file.opts.filename;
// 获取行列
let line = node.loc.start.line;
let column = node.loc.start.column;
// 获取函数名
let funcName = 'anonymous function';
// 如果是函数申明,则可以直接从id属性获取
if (node.id) {
funcName = node.id.name;
}
// 有父节点,并且父节点是申明,则说明是函数申明的方式创建的函数,需要从父节点获取函数名
if (t.isVariableDeclarator(path.parentPath)) {
funcName = path.parentPath.node.id.name;
} else if (t.isProperty(path.parentPath)) {
// 通过对象属性定义的函数
funcName = path.parentPath.node.key.name;
}
const tryStatement = template.ast(`
try {
} catch (error) {
}`);
tryStatement.block = body;
tryStatement.handler.body.body[0] = template.ast(`
console.log(error,"@ ${'filename'} ${funcName}, line ${line}, column ${column}")
`);
node.body = t.blockStatement([tryStatement]);
},
CallExpression(path) {
if (path.node.callee.name === 'func4') {
path.insertBefore(template.ast(`console.time('debug')`));
path.insertAfter(template.ast(`console.timeEnd('debug')`));
}
},
};
traverse(ast, visitor);
let { code } = generator(ast);
// const babelPlugin = { visitor };
// let { code } = babel.transform(source1, { plugins: [babelPlugin] });
// let { code } = babel.transformFileSync('code.js', {
// plugins: [babelPlugin],
// });
console.log(code);
const source2 = `
import { DatePicker } from 'antd';
`;
//import DatePicker from 'antd/es/date-picker'; // 加载 JS
//import 'antd/es/date-picker/style/css'; // 加载 CSS
const antdVisitor = {
ImportDeclaration(path) {
const { node } = path;
if (!t.isImportSpecifier(node.specifiers[0])) return;
const name = node.specifiers[0].local.name;
const lowerName = name.replace(/(?<!^)([A-Z])/, '-$&').toLowerCase();
const ast = template.ast(`
import ${name} from 'antd/es/${lowerName}';
import 'antd/es/${lowerName}/style/css';
`);
path.replaceWithMultiple(ast);
},
};
const antdPlugin = { visitor: antdVisitor };
code = babel.transform(source2, { plugins: [antdPlugin] }).code;
console.log(code);
AST 延展
javascript 是解释型语言还是编译型语言?
即时编译 JIT(Just-in-time)
整体来说,为了解决解释器的低效问题,后来的浏览器把编译器也引入进来,形成混合模式。最终,结合了解释器和编译器的两者优点。 基本思想: 在 JavaScript 引擎中增加一个监视器(也叫分析器)。监视器监控着代码的运行情况,记录代码一共运行了多少次、如何运行的等信息。如果同一行代码运行了几次,这个代码段就被标记成了 “warm”,如果运行了很多次,则被标记成 “hot”。
webpack模块加载
Demo
源码
//index.js
import { add } from './a.js';
const rst = add(1, 2);
console.log(rst);
//a.js
export const add = (a, b) => {
return a + b;
};
打包
删除注释,格式化代码
webpack-cli: 4.9.1
(() => {
'use strict';
var __webpack_modules__ = {
'./a.js': (
__unused_webpack_module,
__webpack_exports__,
__webpack_require__
) => {
eval(
`__webpack_require__.r(__webpack_exports__);
__webpack_require__.d(__webpack_exports__, {"add": () => add});
const add = (a, b) => { return a + b;};`
); //判断,导出,执行
},
'./index.js': (
__unused_webpack_module,
__webpack_exports__,
__webpack_require__
) => {
eval(
`__webpack_require__.r(__webpack_exports__);
var _a_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./a.js");
const rst = (0,_a_js__WEBPACK_IMPORTED_MODULE_0__.add)(1, 2);
console.log(rst);`
);
},
};
var __webpack_module_cache__ = {};
// require
function __webpack_require__(moduleId) {
// 缓存已经执行的模块导出结果
if (__webpack_module_cache__[moduleId]) {
return __webpack_module_cache__[moduleId].exports;
}
var module = (__webpack_module_cache__[moduleId] = {
exports: {},
});
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
return module.exports;
}
// 导出的方法添加到exports
(() => {
__webpack_require__.d = (exports, definition) => {
for (var key in definition) {
if (
__webpack_require__.o(definition, key) &&
!__webpack_require__.o(exports, key)
) {
Object.defineProperty(exports, key, {
enumerable: true,
get: definition[key],
});
}
}
};
})();
// 判断是否自有属性
(() => {
__webpack_require__.o = (obj, prop) =>
Object.prototype.hasOwnProperty.call(obj, prop);
})();
// 判断module引入类型
(() => {
__webpack_require__.r = (exports) => {
if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
Object.defineProperty(exports, Symbol.toStringTag, {
value: 'Module',
});
}
Object.defineProperty(exports, '__esModule', { value: true });
};
})();
__webpack_require__('./index.js');
})();
概述
根据4.4中 this.module和 this.entry,生成 __webpack_modules__,注入__webpack_require__模板代码,挂载方法,并执行。
__webpack_require__模块时,新建module对象,将module.export通过形参__webpack_exports__引用传递给模块源码执行函数,源码调用__webpack_require__.d( __webpack_exports__ [, ])将需导出的方法挂载到__webpack_exports__执行完毕后,__webpack_require__返回更新后的module.export。如果模块源码内有引入,则通过调用传参__webpack_require__递归执行