前言
webpack
在前端工程畛域起到了中流砥柱的作用,了解它的外部实现机制会对你的工程建设提供很大的帮忙(不论是定制性能还是优化打包)。
上面咱们基于 webpack5 源码构造,对整个打包流程进行简略梳理并进行实现,便与思考和了解每个阶段所做的事件,为今后扩大和定制工程化能力打下基础。
一、筹备工作
在流程剖析过程中咱们会简略实现 webpack
的一些性能,局部性能的实现会借助第三方工具:
tapable
提供 Hooks 机制来接入插件进行工作;babel
相干依赖可用于将源代码解析为 AST,进行模块依赖收集和代码改写。
// 创立仓库
mkdir webpack-demo && cd webpack-demo && npm init -y
// 装置 babel 相干依赖
npm install @babel/parser @babel/traverse @babel/types @babel/generator -D
// 装置 tapable(注册 / 触发事件流)和 fs-extra 文件操作依赖
npm install tapable fs-extra -D
接下来咱们在 src
目录下新建两个入口文件和一个公共模块文件:
mkdir src && cd src && touch entry1.js && touch entry2.js && touch module.js
并别离为文件增加一些内容:
// src/entry1.js
const module = require('./module');
const start = () => 'start';
start();
console.log('entry1 module:', module);
// src/entry2.js
const module = require('./module');
const end = () => 'end';
end();
console.log('entry2 module:', module);
// src/module.js
const name = 'cegz';
module.exports = {name,};
有了打包入口,咱们再来创立一个 webpack.config.js
配置文件做一些根底配置:
// ./webpack.config.js
const path = require('path');
const CustomWebpackPlugin = require('./plugins/custom-webpack-plugin.js');
module.exports = {
entry: {entry1: path.resolve(__dirname, './src/entry1.js'),
entry2: path.resolve(__dirname, './src/entry2.js'),
},
context: process.cwd(),
output: {path: path.resolve(__dirname, './build'),
filename: '[name].js',
},
plugins: [new CustomWebpackPlugin()],
resolve: {extensions: ['.js', '.ts'],
},
module: {
rules: [
{
test: /\.js/,
use: [path.resolve(__dirname, './loaders/transformArrowFnLoader.js'), // 转换箭头函数
],
},
],
},
};
以上配置,指定了两个入口文件,以及一个 output.build
输入目录,同时还指定了一个 plugin
和一个 loader
。
接下来咱们编写 webpack
的外围入口文件,来实现打包逻辑。这里咱们创立 webpack 外围实现所需的文件:
// cd webpack-demo
mkdir lib && cd lib
touch webpack.js // webpack 入口文件
touch compiler.js // webpack 外围编译器
touch compilation.js // webpack 外围编译对象
touch utils.js // 工具函数
这里咱们创立了两个比拟类似的文件:compiler
和 compilation
,在这里做下简要阐明:
compiler
:webpack 的编译器,它提供的run
办法可用于创立compilation
编译对象来解决代码构建工作;compilation
:由compiler.run
创立生成,打包编译的工作都由它来实现,并将打包产物移交给compiler
做输入写入操作。
对于入口文件 lib/webpack.js
,你会看到大抵如下构造:
// lib/webpack.js
function webpack(options) {...}
module.exports = webpack;
对于执行入口文件的测试用例,代码如下:
// 测试用例 webpack-demo/build.js
const webpack = require('./lib/webpack');
const config = require('./webpack.config');
const compiler = webpack(config);
// 调用 run 办法进行打包
compiler.run((err, stats) => {if (err) {console.log(err, 'err');
}
// console.log('构建实现!', stats.toJSON());
});
接下来,咱们从 lib/webpack.js
入口文件,依照以下步骤开始剖析打包流程。
1、初始化阶段 – webpack
- 合并配置项
- 创立 compiler
- 注册插件
2、编译阶段 – build
- 读取入口文件
- 从入口文件开始进行编译
- 调用 loader 对源代码进行转换
- 借助 babel 解析为 AST 收集依赖模块
- 递归对依赖模块进行编译操作
3、生成阶段 – seal
- 创立 chunk 对象
- 生成 assets 对象
4、写入阶段 – emit
二、初始化阶段
初始化阶段的逻辑集中在调用 webpack(config)
时候,上面咱们来看看 webpack()
函数体内做了哪些事项。
2.1、读取与合并配置信息
通常,在咱们的工程的根目录下,会有一个 webpack.config.js
作为 webpack
的配置起源;
除此之外,还有一种是通过 webpak bin cli 命令进行打包时,命令行上携带的参数也会作为 webpack 的配置。
在配置文件中蕴含了咱们要让 webpack 打包解决的入口模块、输入地位、以及各种 loader、plugin 等;
在命令行上也同样能够指定相干的配置,且 权重高于
配置文件。(上面将模仿 webpack cli 参数合并解决)
所以,咱们在 webpack 入口文件这里将先做一件事件:合并配置文件与命令行的配置。
// lib/webpack.js
function webpack(options) {
// 1、合并配置项
const mergeOptions = _mergeOptions(options);
...
}
function _mergeOptions(options) {const shellOptions = process.argv.slice(2).reduce((option, argv) => {
// argv -> --mode=production
const [key, value] = argv.split('=');
if (key && value) {const parseKey = key.slice(2);
option[parseKey] = value;
}
return option;
}, {});
return {...options, ...shellOptions};
}
module.exports = webpack;
2.2、创立编译器(compiler)对象
好的程序结构离不开一个实例对象,webpack 同样也不甘示弱,其编译运行是由一个叫做 compiler
的实例对象来驱动运行。
在 compiler
实例对象上会记录咱们传入的配置参数,以及一些串联插件进行工作的 hooks
API。
同时,还提供了 run
办法启动打包构建,emitAssets
对打包产物进行输入磁盘写入。这部分内容前面介绍。参考 webpack 视频解说:进入学习
// lib/webpack.js
const Compiler = require('./compiler');
function webpack(options) {
// 1、合并配置项
const mergeOptions = _mergeOptions(options);
// 2、创立 compiler
const compiler = new Compiler(mergeOptions);
...
return compiler;
}
module.exports = webpack;
Compiler
构造函数根底构造如下:
// core/compiler.js
const fs = require('fs');
const path = require('path');
const {SyncHook} = require('tapable'); // 串联 compiler 打包流程的订阅与告诉钩子
const Compilation = require('./compilation'); // 编译构造函数
class Compiler {constructor(options) {
this.options = options;
this.context = this.options.context || process.cwd().replace(/\\/g, '/');
this.hooks = {
// 开始编译时的钩子
run: new SyncHook(),
// 模块解析实现,在向磁盘写入输入文件时执行
emit: new SyncHook(),
// 在输入文件写入实现后执行
done: new SyncHook(),};
}
run(callback) {...}
emitAssets(compilation, callback) {...}
}
module.exports = Compiler;
当须要进行编译时,调用 compiler.run
办法即可:
compiler.run((err, stats) => {...});
2.3、插件注册
有 compiler 实例对象后,就能够注册配置文件中的一个个插件,在适合的机会来干涉打包构建。
插件须要接管 compiler
对象作为参数,以此来对打包过程及产物产生 side effect
。
插件的格局能够是函数或对象,如果为对象,须要自定义提供一个 apply
办法。常见的插件构造如下:
class WebpackPlugin {apply(compiler) {...}
}
注册插件逻辑如下:
// lib/webpack.js
function webpack(options) {
// 1、合并配置项
const mergeOptions = _mergeOptions(options);
// 2、创立 compiler
const compiler = new Compiler(mergeOptions);
// 3、注册插件,让插件去影响打包后果
if (Array.isArray(options.plugins)) {for (const plugin of options.plugins) {if (typeof plugin === "function") {plugin.call(compiler, compiler); // 当插件为函数时
} else {plugin.apply(compiler); // 如果插件是一个对象,须要提供 apply 办法。}
}
}
return compiler;
}
到这里,webpack 的初始工作曾经实现,接下来是调用 compiler.run()
进入编译构建阶段。
三、编译阶段
编译工作的终点是在 compiler.run
,它会:
- 发动构建告诉,触发
hooks.run
告诉相干插件; - 创立
compilation
编译对象; - 读取 entry 入口文件;
- 编译 entry 入口文件;
3.1、创立 compilation 编译对象
模块的打包(build
)和 代码生成(seal
)都是由 compilation
来实现。
// lib/compiler.js
class Compiler {
...
run(callback) {
// 触发 run hook
this.hooks.run.call();
// 创立 compilation 编译对象
const compilation = new Compilation(this);
...
}
}
compilation
实例上记录了构建过程中的 entries
、module
、chunks
、assets
等编译信息,同时提供 build
和 seal
办法进行代码构建和代码生成。
// lib/compilation.js
const fs = require('fs');
const path = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generator = require('@babel/generator').default;
const t = require('@babel/types');
const {tryExtensions, getSourceCode} = require('./utils');
class Compilation {constructor(compiler) {
this.compiler = compiler;
this.context = compiler.context;
this.options = compiler.options;
// 记录以后 module code
this.moduleCode = null;
// 保留所有依赖模块对象
this.modules = new Set();
// 保留所有入口模块对象
this.entries = new Map();
// 所有的代码块对象
this.chunks = new Set();
// 寄存本次产出的文件对象(与 chunks 一一对应)this.assets = {};}
build() {}
seal() {}
}
有了 compilation
对象后,通过执行 compilation.build
开始模块构建。
// lib/compiler.js
class Compiler {
...
run(callback) {
// 触发 run hook
this.hooks.run.call();
// 创立 compilation 编译对象
const compilation = new Compilation(this);
// 编译模块
compilation.build();}
}
3.2、读取 entry 入口文件
构建模块首先从 entry 入口模块开始,此时首要工作是依据配置文件拿到入口模块信息。
entry 配置的形式多样化,如:能够不传(有默认值)、能够传入 string,也能够传入对象指定多个入口。
所以读取入口文件须要思考并兼容这几种灵便配置形式。
// lib/compilation.js
class Compilation {
...
build() {
// 1、读取配置入口
const entry = this.getEntry();
...
}
getEntry() {let entry = Object.create(null);
const {entry: optionsEntry} = this.options;
if (!optionsEntry) {entry['main'] = 'src/index.js'; // 默认找寻 src 目录进行打包
} else if (typeof optionsEntry === 'string') {entry['main'] = optionsEntry;
} else {entry = optionsEntry; // 视为对象,比方多入口配置}
// 绝对于我的项目启动根目录计算出相对路径
Object.keys(entry).forEach((key) => {entry[key] = './' + path.posix.relative(this.context, entry[key]);
});
return entry;
}
}
3.3、编译 entry 入口文件
拿到入口文件后,顺次对每个入口进行构建。
// lib/compilation.js
class Compilation {
...
build() {
// 1、读取配置入口
const entry = this.getEntry();
// 2、构建入口模块
Object.keys(entry).forEach((entryName) => {const entryPath = entry[entryName];
const entryData = this.buildModule(entryName, entryPath);
this.entries.set(entryName, entryData);
});
}
}
构建阶段执行如下操作:
- 通过
fs
模块读取 entry 入口文件内容; - 调用
loader
来转换(更改)文件内容; - 为模块创立
module
对象,通过 AST 解析源代码收集依赖模块,并改写依赖模块的门路; - 如果存在依赖模块,递归进行上述三步操作;
读取文件内容:
// lib/compilation.js
class Compilation {
...
buildModule(moduleName, modulePath) {
// 1. 读取文件原始代码
const originSourceCode = fs.readFileSync(modulePath, 'utf-8');
this.moduleCode = originSourceCode;
...
}
}
调用 loader 转换源代码:
// lib/compilation.js
class Compilation {
...
buildModule(moduleName, modulePath) {
// 1. 读取文件原始代码
const originSourceCode = fs.readFileSync(modulePath, 'utf-8');
this.moduleCode = originSourceCode;
// 2. 调用 loader 进行解决
this.runLoaders(modulePath);
...
}
}
loader
自身是一个 JS 函数,接管模块文件的源代码作为参数,通过加工革新后返回新的代码。
// lib/compilation.js
class Compilation {
...
runLoaders(modulePath) {const matchLoaders = [];
// 1、找到与模块相匹配的 loader
const rules = this.options.module.rules;
rules.forEach((loader) => {
const testRule = loader.test;
if (testRule.test(modulePath)) {// 如:{ test:/\.js$/g, use:['babel-loader'] }, {test:/\.js$/, loader:'babel-loader'}
loader.loader ? matchLoaders.push(loader.loader) : matchLoaders.push(...loader.use);
}
});
// 2. 倒序执行 loader
for (let i = matchLoaders.length - 1; i >= 0; i--) {const loaderFn = require(matchLoaders[i]);
// 调用 loader 解决源代码
this.moduleCode = loaderFn(this.moduleCode);
}
}
}
执行 webpack 模块编译逻辑:
// lib/compilation.js
class Compilation {
...
buildModule(moduleName, modulePath) {
// 1. 读取文件原始代码
const originSourceCode = fs.readFileSync(modulePath, 'utf-8');
this.moduleCode = originSourceCode;
// 2. 调用 loader 进行解决
this.runLoaders(modulePath);
// 3. 调用 webpack 进行模块编译 为模块创立 module 对象
const module = this.handleWebpackCompiler(moduleName, modulePath);
return module; // 返回模块
}
}
- 创立
module
对象; - 对 module code 解析为
AST
语法树; - 遍历 AST 去辨认
require
模块语法,将模块收集在module.dependencies
之中,并改写require
语法为__webpack_require__
; - 将批改后的 AST 转换为源代码;
- 若存在依赖模块,深度递归构建依赖模块。
// lib/compilation.js
class Compilation {
...
handleWebpackCompiler(moduleName, modulePath) {
// 1、创立 module
const moduleId = './' + path.posix.relative(this.context, modulePath);
const module = {
id: moduleId, // 将以后模块绝对于我的项目启动根目录计算出相对路径 作为模块 ID
dependencies: new Set(), // 存储该模块所依赖的子模块
entryPoint: [moduleName], // 该模块所属的入口文件
};
// 2、对模块内容解析为 AST,收集依赖模块,并改写模块导入语法为 __webpack_require__
const ast = parser.parse(this.moduleCode, {sourceType: 'module',});
// 遍历 ast,辨认 require 语法
traverse(ast, {CallExpression: (nodePath) => {
const node = nodePath.node;
if (node.callee.name === 'require') {const requirePath = node.arguments[0].value;
// 寻找模块绝对路径
const moduleDirName = path.posix.dirname(modulePath);
const absolutePath = tryExtensions(path.posix.join(moduleDirName, requirePath),
this.options.resolve.extensions,
requirePath,
moduleDirName
);
// 创立 moduleId
const moduleId = './' + path.posix.relative(this.context, absolutePath);
// 将 require 变成 __webpack_require__ 语句
node.callee = t.identifier('__webpack_require__');
// 批改模块门路(参考 this.context 的相对路径)node.arguments = [t.stringLiteral(moduleId)];
if (!Array.from(this.modules).find(module => module.id === moduleId)) {
// 在模块的依赖汇合中记录子依赖
module.dependencies.add(moduleId);
} else {
// 曾经存在模块汇合中。尽管不增加进入模块编译 然而仍要在这个模块上记录被依赖的入口模块
this.modules.forEach((module) => {if (module.id === moduleId) {module.entryPoint.push(moduleName);
}
});
}
}
},
});
// 3、将 ast 生成新代码
const {code} = generator(ast);
module._source = code;
// 4、深度递归构建依赖模块
module.dependencies.forEach((dependency) => {const depModule = this.buildModule(moduleName, dependency);
// 将编译后的任何依赖模块对象退出到 modules 对象中去
this.modules.add(depModule);
});
return module;
}
}
通常咱们 require 一个模块文件时习惯不去指定文件后缀,默认会查找 .js 文件。
这跟咱们在配置文件中指定的 resolve.extensions
配置无关,在 tryExtensions
办法中会尝试为每个未填写后缀的 Path 利用 resolve.extensions
:
// lib/utils.js
const fs = require('fs');
function tryExtensions(modulePath, extensions, originModulePath, moduleContext) {
// 优先尝试不须要扩展名选项(用户如果曾经传入了后缀,那就应用用户填入的,无需再利用 extensions)extensions.unshift('');
for (let extension of extensions) {if (fs.existsSync(modulePath + extension)) {return modulePath + extension;}
}
// 未匹配对应文件
throw new Error(`No module, Error: Can't resolve ${originModulePath} in ${moduleContext}`
);
}
module.exports = {
tryExtensions,
...
}
至此,「编译阶段」到此结束,接下来是「生成阶段」seal
。
四、生成阶段
在「编译阶段」会将一个个文件构建成 module
存储在 this.modules
之中。
在「生成阶段」,会依据 entry
创立对应 chunk
并从 this.modules
中查找被 entry
所依赖的 module
汇合。
最初,联合 runtime
webpack 模块机制运行代码,通过拼接生成最终的 assets
产物。
// lib/compiler.js
class Compiler {
...
run(callback) {
// 触发 run hook
this.hooks.run.call();
// 创立 compilation 编译对象
const compilation = new Compilation(this);
// 编译模块
compilation.build();
// 生成产物
compilation.seal();
...
}
}
entry + module
–> chunk
–> assets
过程如下:
// lib/compilation.js
class Compilation {
...
seal() {
// 1、依据 entry 创立 chunk
this.entries.forEach((entryData, entryName) => {
// 依据以后入口文件和模块的相互依赖关系,组装成为一个个蕴含以后入口所有依赖模块的 chunk
this.createChunk(entryName, entryData);
});
// 2、依据 chunk 创立 assets
this.createAssets();}
// 依据入口文件和依赖模块组装 chunks
createChunk(entryName, entryData) {
const chunk = {
// 每一个入口文件作为一个 chunk
name: entryName,
// entry build 后的数据信息
entryModule: entryData,
// entry 的所依赖模块
modules: Array.from(this.modules).filter((i) =>
i.entryPoint.includes(entryName)
),
};
// add chunk
this.chunks.add(chunk);
}
createAssets() {
const output = this.options.output;
// 依据 chunks 生成 assets
this.chunks.forEach((chunk) => {const parseFileName = output.filename.replace('[name]', chunk.name);
// 为每一个 chunk 文件代码拼接 runtime 运行时语法
this.assets[parseFileName] = getSourceCode(chunk);
});
}
}
getSourceCode
是将 entry
和 modules
组合而成的 chunk
,接入到 runtime
代码模板之中。
// lib/utils.js
function getSourceCode(chunk) {const { entryModule, modules} = chunk;
return ` (() => { var __webpack_modules__ = { ${modules .map((module) => {return ` '${module.id}': (module) => {${module._source}
} `; }) .join(',')}
}; 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; } (() => { ${entryModule._source}
})();})(); `;}
到这里,「生成阶段」解决实现,这也意味着 compilation
编译工作的实现,接下来咱们回到 compiler
进行最初的「产物输入」。
五、写入阶段
「写入阶段」比拟容易了解,assets
上曾经领有了最终打包后的代码内容,最初要做的就是将代码内容写入到本地磁盘之中。
// lib/compiler.js
class Compiler {
...
run(callback) {
// 触发 run hook
this.hooks.run.call();
// 创立 compilation 编译对象
const compilation = new Compilation(this);
// 编译模块
compilation.build();
// 生成产物
compilation.seal();
// 输入产物
this.emitAssets(compilation, callback);
}
emitAssets(compilation, callback) {const { entries, modules, chunks, assets} = compilation;
const output = this.options.output;
// 调用 Plugin emit 钩子
this.hooks.emit.call();
// 若 output.path 不存在,进行创立
if (!fs.existsSync(output.path)) {fs.mkdirSync(output.path);
}
// 将 assets 中的内容写入文件系统中
Object.keys(assets).forEach((fileName) => {const filePath = path.join(output.path, fileName);
fs.writeFileSync(filePath, assets[fileName]);
});
// 完结之后触发钩子
this.hooks.done.call();
callback(null, {toJSON: () => {
return {
entries,
modules,
chunks,
assets,
};
},
});
}
}
至此,webpack 的打包流程就以实现。
接下来咱们欠缺配置文件中未实现的 loader
和 plugin
,而后调用测试用例,测试一下下面的实现。
六、编写 loader
在 webpack.config.js
中咱们为 .js
文件类型配置了一个自定义 loader 来转换文件内容:
// webpack.config.js
module: {
rules: [
{
test: /\.js/,
use: [path.resolve(__dirname, './loaders/transformArrowFnLoader.js'),
],
},
],
},
loader 自身是一个函数,接管文件模块内容作为参数,通过革新解决返回新的文件内容。
上面咱们在 loaders/transformArrowFnLoader.js
中,对文件中应用到的箭头函数,转换为一般函数,来了解 webpack loader
的作用。
// loaders/transformArrowFnLoader.js
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generator = require('@babel/generator').default;
const t = require('@babel/types');
function transformArrowLoader(sourceCode) {
const ast = parser.parse(sourceCode, {sourceType: 'module'});
traverse(ast, {ArrowFunctionExpression(path, state) {
const node = path.node;
const body = path.get('body');
const bodyNode = body.node;
if (bodyNode.type !== 'BlockStatement') {const statements = [];
statements.push(t.returnStatement(bodyNode));
node.body = t.blockStatement(statements);
}
node.type = "FunctionExpression";
}
});
const {code} = generator(ast);
return code;
}
module.exports = transformArrowLoader;
最终,箭头函数通过解决后变成如下构造:
const start = () => 'start';
||
||
const start = function () {return 'start';};
七、编写插件
从下面介绍咱们理解到,每个插件都须要提供一个 apply
办法,此办法接管 compiler
作为参数。
通过 compiler
能够去订阅 webpack
工作期间不同阶段的 hooks
,以此来影响打包后果或者做一些定制操作。
上面咱们编写自定义插件,绑定两个不同机会的 compiler.hooks
来扩大 webpack 打包性能:
hooks.emit.tap
绑定一个函数,在webpack
编译资源实现,输入写入磁盘前执行(能够做革除output.path
目录操作);hooks.done.tap
绑定一个函数,在webpack
写入磁盘实现之后执行(能够做一些动态资源copy
操作)。
// plugins/custom-webpack-plugins
const fs = require('fs-extra');
const path = require('path');
class CustomWebpackPlugin {apply(compiler) {
const outputPath = compiler.options.output.path;
const hooks = compiler.hooks;
// 革除 build 目录
hooks.emit.tap('custom-webpack-plugin', (compilation) => {fs.removeSync(outputPath);
});
// copy 动态资源
const otherFilesPath = path.resolve(__dirname, '../src/otherfiles');
hooks.done.tap('custom-webpack-plugin', (compilation) => {fs.copySync(otherFilesPath, path.resolve(outputPath, 'otherfiles'));
});
}
}
module.exports = CustomWebpackPlugin;
当初,咱们通过 node build.js
运行文件,最终会在 webpack-demo
下生成 build
目录以及入口打包资源。
文末
置信读完本篇文章,你对 webpack 的打包思路有了清晰的意识。