
🤖 作者简介:水煮白菜王,一位前端劝退师 👻
👀 文章专栏: 前端专栏 ,记录一下平时在博客写作中,总结出的一些开发技巧和知识归纳总结✍。
感谢支持💕💕💕
目录
- Webpack核心机制
- Webpack打包机制
- 实现一个丐版Webpack
-
- 开始
- [接下来我们再来逐行解析 bundle 函数](#接下来我们再来逐行解析 bundle 函数)
- [如果你觉得这篇文章对你有帮助,请点赞 👍、收藏 👏 并关注我!👀](#如果你觉得这篇文章对你有帮助,请点赞 👍、收藏 👏 并关注我!👀)

Webpack核心机制
Webpack 本质上是一个高度可配置且可扩展的模块捆绑器,它采用了一种基于事件流的编程范例。Webpack 的运作依赖于一系列插件来完成各种任务,从简单的文件转换到复杂的构建优化。
Webpack 主要使用 Compiler 和 Compilation 两个类来控制整个生命周期。它们都继承自 Tapable 并利用它注册构建过程中的各个阶段所需触发的事件。
Tapable:Webpack 插件系统的"心脏"
Tapable 是一个类似于 Node.js 的 EventEmitter 的库,主要用于管理钩子函数的发布与订阅。在 Webpack 的插件系统中,Tapable 扮演着核心调度者的角色。
Tapabel提供的钩子及示例
Tapable 提供了多种类型的钩子(Hook)以便挂载,适用于不同的执行场景(同步 / 异步、串行 / 并发、是否支持熔断等):
javascript
const {
SyncHook, // 同步钩子:依次执行所有订阅者
SyncBailHook, // 同步熔断钩子:一旦某个订阅者返回非 undefined 值则停止执行
SyncWaterfallHook, // 同步流水钩子:前一个订阅者的返回值作为参数传给下一个
SyncLoopHook, // 同步循环钩子:重复执行订阅者直到返回 undefined
AsyncParallelHook, // 异步并发钩子:并行执行所有订阅者(不关心顺序)
AsyncParallelBailHook, // 异步并发熔断钩子:任意一个订阅者返回非 undefined 则立即结束
AsyncSeriesHook, // 异步串行钩子:按顺序依次执行每个订阅者
AsyncSeriesBailHook, // 异步串行熔断钩子:同 SyncBailHook,但为异步模式
AsyncSeriesWaterfallHook // 异步串行流水钩子:同 SyncWaterfallHook,但为异步模式
} = require("tapable");
Tabpack 提供了同步&异步绑定钩子的方法对比如下:
类型 | 绑定方法 | 执行方法 |
---|---|---|
同步 (Sync) | .tap(name, fn) |
.call(args...) |
异步 (Async) | .apAsync(name, fn) /.tapPromise(name, fn) |
.callAsync(args..., cb) / .promise(args...) |
Tabpack 同步简单示例:
javascript
const { SyncHook } = require("tapable");
// 创建一个带有三个参数的同步钩子
const demohook = new SyncHook(["arg1", "arg2", "arg3"]);
// 注册监听函数 绑定事件到webpack事件流
demohook.tap("hook1", (arg1, arg2, arg3) => {
console.log("接收到参数:", arg1, arg2, arg3);
});
// 触发钩子 执行绑定的事件
demohook.call(1, 2, 3);
// 输出: 接收到参数:1 2 3
源码解读
- 初始化启动之Webpack的入口文件
● 追本溯源,第一步我们要找到Webpack的入口文件。
● 当通过命令行启动Webpack后,npm会让命令行工具进入node_modules.bin 目录。
● 然后查找是否存在 webpack.sh 或者 webpack.cmd 文件,如果存在,就执行它们,不存在就会抛出错误。
● 实际的入口文件是:node_modules/webpack/bin/webpack.js
,让我们来看一下里面的核心函数。
javascript
// node_modules/webpack/bin/webpack.js
// 正常执行返回
process.exitCode = 0;
// 运行某个命令
const runCommand = (command, args) => {...}
// 判断某个包是否安装
const isInstalled = packageName => {...}
// webpack可用的CLI:webpacl-cli和webpack-command
const CLIs = {...}
// 判断是否两个CLI是否安装了
const installedClis = CLIs.filter(cli=>cli.installed);
// 根据安装数量进行处理
if (installedClis.length === 0) {...} else if
(installedClis.length === 1) {...} else {...}
启动后,Webpack最终会找到 webpack-cli /webpack-command的 npm 包,并且 执行 CLI。
- webpack-cli
搞清楚了Webpack启动的入口文件后,接下来让我们把目光转移到webpack-cli,看看它做了哪些动作。
● 引入 yargs,对命令行进行定制分析命令行参数,对各个参数进行转换,组成编译配置项引用webpack,根据配置项进行编译和构建
● webpack-cli 会处理不需要经过编译的命令。
javascript
// node_modules/webpack-cli/bin/cli.js
const {NON_COMPILATION_ARGS} = require("./utils/constants");
const NON_COMPILATION_CMD = process.argv.find(arg => {
if (arg === "serve") {
global.process.argv = global.process.argv.filter(a => a !== "serve");
process.argv = global.process.argv;
}
return NON_COMPILATION_ARGS.find(a => a === arg);
});
if (NON_COMPILATION_CMD) {
return require("./utils/prompt-command")(NON_COMPILATION_CMD,...process.argv);
}
webpack-cli提供的不需要编译的命令如下
javascript
// node_modules/webpack-cli/bin/untils/constants.js
const NON_COMPILATION_ARGS = [
"init", // 创建一份webpack配置文件
"migrate", // 进行webpack版本迁移
"add", // 往webpack配置文件中增加属性
"remove", // 往webpack配置文件中删除属性
"serve", // 运行webpack-serve
"generate-loader", // 生成webpack loader代码
"generate-plugin", // 生成webpack plugin代码
"info" // 返回与本地环境相关的一些信息
];
webpack-cli 使用命令行工具包yargs
javascript
// node_modules/webpack-cli/bin/config/config-yargs.js
const {
CONFIG_GROUP,
BASIC_GROUP,
MODULE_GROUP,
OUTPUT_GROUP,
ADVANCED_GROUP,
RESOLVE_GROUP,
OPTIMIZE_GROUP,
DISPLAY_GROUP
} = GROUPS;
● webpack-cli对配置文件和命令行参数进行转换最终生成配置选项参数 options,最终会根据配置参数实例化webpack对象,然后执行构建流程。
● 除此之外,让我们回到node_modules/webpack/lib/webpack.js里来看一下Webpack还做了哪些准备工作。
javascript
// node_modules/webpack/lib/webpack.js
const webpack = (options, callback) => {
...
options = new WebpackOptionsDefaulter().process(options);
compiler = new Compiler(options.context);
new NodeEnvironmentPlugin().apply(compiler);
...
compiler.options = new WebpackOptionsApply().process(options, compiler);
...
webpack.WebpackOptionsDefaulter = WebpackOptionsDefaulter;
webpack.WebpackOptionsApply = WebpackOptionsApply;
...
webpack.NodeEnvironmentPlugin = NodeEnvironmentPlugin;
}
WebpackOptionsDefaulter的功能是设置一些默认的Options(代码比较多,可自行查看node_modules/webpack/lib/WebpackOptionsDefaulter.js)
javascript
// node_modules/webpack/lib/node/NodeEnvironmentPlugin.js
class NodeEnvironmentPlugin {
apply(compiler) {
...
compiler.hooks.beforeRun.tap("NodeEnvironmentPlugin", compiler => {
if (compiler.inputFileSystem === inputFileSystem) inputFileSystem.purge();
});
}
}
从上面的代码我们可以知道,NodeEnvironmentPlugin插件监听了beforeRun钩子,它的作用是清除缓存。
- WebpackOptionsApply
WebpackOptionsApply会将所有的配置options参数转换成webpack内部插件。
使用默认插件列表:
● output.library -> LibraryTemplatePlugin
● externals -> ExternalsPlugin
● devtool -> EvalDevtoolModulePlugin, SourceMapDevToolPlugin
● AMDPlugin, CommonJsPlugin
● RemoveEmptyChunksPlugin
javascript
// node_modules/webpack/lib/WebpackOptionsApply.js
new EntryOptionPlugin().apply(compiler);
compiler.hooks.entryOption.call(options.context, options.entry);
- EntryOptionPlugin
下来让我们进入EntryOptionPlugin插件,看看它做了哪些动作。
javascript
// node_modules/webpack/lib/EntryOptionPlugin.js
module.exports = class EntryOptionPlugin {
apply(compiler) {
compiler.hooks.entryOption.tap("EntryOptionPlugin", (context, entry) => {
if (typeof entry === "string" || Array.isArray(entry)) {
itemToPlugin(context, entry, "main").apply(compiler);
} else if (typeof entry === "object") {
for (const name of Object.keys(entry)) {
itemToPlugin(context, entry[name], name).apply(compiler);
}
} else if (typeof entry === "function") {
new DynamicEntryPlugin(context, entry).apply(compiler);
}
return true;
});
}
};
● 如果是数组,则转换成多个entry来处理,如果是对象则转换成一个个entry来处理。
● compiler实例化是在node_modules/webpack/lib/webpack.js里完成的。通过EntryOptionPlugin插件进行参数校验。通过WebpackOptionsDefaulter将传入的参数和默认参数进行合并成为新的options,创建compiler,以及相关plugin,最后通过
● WebpackOptionsApply将所有的配置options参数转换成Webpack内部插件。
● 再次来到我们的node_modules/webpack/lib/webpack.js中
javascript
if (options.watch === true || (Array.isArray(options) && options.some(o => o.watch))) {
const watchOptions = Array.isArray(options)
? options.map(o => o.watchOptions || {})
: options.watchOptions || {};
return compiler.watch(watchOptions, callback);
}
compiler.run(callback);
实例compiler后会根据options的watch判断是否启动了watch,如果启动watch了就调用compiler.watch来监控构建文件,否则启动compiler.run来构建文件。
编译构建
compile
首先会实例化NormalModuleFactory和ContextModuleFactory。然后进入到run方法。
javascript
// node_modules/webpack/lib/Compiler.js
run(callback) {
...
// beforeRun 如上文NodeEnvironmentPlugin插件清除缓存
this.hooks.beforeRun.callAsync(this, err => {
if (err) return finalCallback(err);
// 执行run Hook开始编译
this.hooks.run.callAsync(this, err => {
if (err) return finalCallback(err);
this.readRecords(err => {
if (err) return finalCallback(err);
// 执行compile
this.compile(onCompiled);
});
});
});
}
在执行this.hooks.compile之前会执行this.hooks.beforeCompile,来对编译之前需要处理的插件进行执行。紧接着this.hooks.compile执行后会实例化Compilation对象
javascript
// node_modules/webpack/lib/compiler.js
compile(callback) {
const params = this.newCompilationParams();
this.hooks.beforeCompile.callAsync(params, err => {
if (err) return callback(err);
// 进入compile阶段
this.hooks.compile.call(params);
const compilation = this.newCompilation(params);
// 进入make阶段
this.hooks.make.callAsync(compilation, err => {
if (err) return callback(err);
compilation.finish(err => {
if (err) return callback(err);
// 进入seal阶段
compilation.seal(err => {
if (err) return callback(err);
this.hooks.afterCompile.callAsync(compilation, err => {
if (err) return callback(err);
return callback(null, compilation);
})
})
})
})
})
}
make
● 一个新的Compilation创建完毕,将从Entry开始读取文件,根据文件类型和配置的Loader对文件进行编译,编译完成后再找出该文件依赖的文件,递归的编译和解析。
● 我们来看一下make钩子被监听的地方。
● 如代码中注释所示,addEntry是make构建阶段真正开始的标志
javascript
// node_modules/webpack/lib/SingleEntryPlugin.js
compiler.hooks.make.tapAsync(
"SingleEntryPlugin",
(compilation, callback) => {
const { entry, name, context } = this;
cosnt dep = SingleEntryPlugin.createDependency(entry, name);
// make构建阶段开始标志
compilation.addEntry(context, dep, name, callback);
}
)
addEntry实际上调用了_addModuleChain方法,_addModuleChain方法将模块添加到依赖列表中去,同时进行模块构建。构建时会执行如下函数:
javascript
// node_modules/webpack/lib/Compilation.js
// addEntry -> addModuleChain
_addModuleChain(context, dependency, onModule, callback) {
...
this.buildModule(module, false, null, null, err => {
...
})
...
}
如果模块构建完成,会触发finishModules。
javascript
// node_modules/webpack/lib/Compilation.js
finish(callback) {
const modules = this.modules;
this.hooks.finishModules.callAsync(modules, err => {
if (err) return callback(err);
for (let index = 0; index < modules.length; index++) {
const module = modules[index];
this.reportDependencyErrorsAndWarnings(module, [module]);
}
callback();
})
}
1. Module
● Module包括NormalModule(普通模块)、ContextModule(./src/a ./src/b)、ExternalModule(module.exports=jQuery)、DelegatedModule(manifest)以及MultiModule(entry:['a', 'b'])。
● 本文以NormalModule(普通模块)为例子,看一下构建(Compilation)的过程。
使用 loader-runner 运行 loadersLoader转换完后,使用 acorn 解析生成AST使用 ParserPlugins 添加依赖
2. loader-runner
javascript
// node_modules/webpack/lib/NormalModule.js
const { getContext, runLoaders } = require("loader-runner");
doBuild(){
...
runLoaders(
...
)
...
}
...
try {
const result = this.parser.parse()
}
doBuild会去加载资源,doBuild中会传入资源路径和插件资源去调用loader-runner插件的runLoaders方法去加载和执行loader
3. acorn
javascript
// node_modules/webpack/lib/Parser.jsconst acorn = require("acorn");
使用acorn解析转换后的内容,输出对应的抽象语法树(AST)。
javascript
// node_modules/webpack/lib/Compilation.js
this.hooks.buildModule.call(module);
...
if (error) {
this.hooks.failedModule.call(module, error);
return callback(error);
}
this.hooks.succeedModule.call(module);
return callback();
● 成功就触发succeedModule,失败就触发failedModule。
● 最终将上述阶段生成的产物存放到Compilation.js的this.modules = [];上。
完成后就到了seal阶段。
这里补充介绍一下Chunk生成的算法
4. Chunk生成算法
● webpack首先会将entry中对应的module都生成一个新的chunk。
● 遍历module的依赖列表,将依赖的module也加入到chunk中。
● 如果一个依赖module是动态引入的模块,会根据这个module创建一个新的chunk,继续遍历依赖。
● 重复上面的过程,直至得到所有的chunk。
eal
● 所有模块及其依赖的模块都通过Loader转换完成,根据依赖关系开始生成Chunk。
● seal阶段也做了大量的的优化工作,进行了hash的创建以及对内容进行生成(createModuleAssets)。
javascript
// node_modules/webpack/lib/Compilation.jsthis.createHash();
this.modifyHash();
this.createModuleAssets();
javascript
// node_modules/webpack/lib/Compilation.js
createModuleAssets(){
for (let i = 0; i < this.modules.length; i++) {
const module = this.modules[i];
if (module.buildInfo.assets) {
for (const assetName of Object.keys(module.buildInfo.assets)) {
const fileName = this.getPath(assetName);
this.assets[fileName] = module.buildInfo.assets[assetName];
this.hooks.moduleAsset.call(module, fileName);
}
}
}
}
seal阶段经历了很多的优化,比如tree shaking就是在这个阶段执行。最终生成的代码会存放在Compilation的assets属性上
emit
将输出的内容输出到磁盘,创建目录生成文件,文件生成阶段结束。
javascript
// node_modules/webpack/lib/compiler.js
this.hooks.emit.callAsync(compilation, err => {
if (err) return callback(err);
outputPath = compilation.getPath(this.outputPath);
this.outputFileSystem.mkdirp(outputPath, emitFiles);
})
总结
Webpack在启动阶段对配置参数和命令行参数以及默认参数进行了合并,并进行了插件的初始化工作。完成初始化的工作后调用Compiler的run开启Webpack编译构建过程,构建主要流程包括compile、make、build、seal、emit等阶段。
Webpack打包机制
webpack是一个打包模块化 JavaScript 的工具,在 webpack里一切文件皆模块,通过 Loader 转换文件,通过 Plugin 注入钩子,最后输出由多个模块组合成的文件。webpack专注于构建模块化项目。
简单版打包模型步骤
从简单的入手看,当 webpack 的配置只有一个出口时,不考虑分包的情况,其实我们只得到了一个bundle.js的文件,这个文件里包含了我们所有用到的js模块,可以直接被加载执行。那么,我可以分析一下它的打包思路,大概有以下4步:
- 利用
babel
完成代码转换及解析,并生成单个文件的依赖模块Map
- 从入口开始递归分析,并生成整个项目的依赖图谱
- 将各个引用模块打包为一个立即执行函数
- 将最终的
bundle
文件写入bundle.js
中
单个文件的依赖模块Map
我们会可以使用这几个包:
@babel/parser
:负责将代码解析为抽象语法树@babel/traverse
:遍历抽象语法树的工具,我们可以在语法树中解析特定的节点,然后做一些操作,如ImportDeclaration
获取通过import
引入的模块,FunctionDeclaration
获取函数@babel/core
:代码转换,如ES6的代码转为ES5的模式
由这几个模块的作用,其实已经可以推断出应该怎样获取单个文件的依赖模块了,转为
Ast->遍历Ast->调用ImportDeclaration
。代码如下:
javascript
// exportDependencies.js
const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const babel = require('@babel/core')
const exportDependencies = (filename)=>{
const content = fs.readFileSync(filename,'utf-8')
// 转为Ast
const ast = parser.parse(content, {
sourceType : 'module'//babel官方规定必须加这个参数,不然无法识别ES Module
})
const dependencies = {}
//遍历AST抽象语法树
traverse(ast, {
//调用ImportDeclaration获取通过import引入的模块
ImportDeclaration({node}){
const dirname = path.dirname(filename)
const newFile = './' + path.join(dirname, node.source.value)
//保存所依赖的模块
dependencies[node.source.value] = newFile
}
})
//通过@babel/core和@babel/preset-env进行代码的转换
const {code} = babel.transformFromAst(ast, null, {
presets: ["@babel/preset-env"]
})
return{
filename,//该文件名
dependencies,//该文件所依赖的模块集合(键值对存储)
code//转换后的代码
}
}
module.exports = exportDependencies
试跑工作:
javascript
//info.js
const a = 1
export a
// index.js
import info from'./info.js'
console.log(info)
//testExport.js
const exportDependencies = require('./exportDependencies')
console.log(exportDependencies('./src/index.js'))
单个文件的依赖模块Map
有了获取单个文件依赖的基础,我们就可以在这基础上,进一步得出整个项目的模块依赖图谱了。首先,从入口开始计算,得到entryMap
,然后遍历entryMap.dependencies
,取出其value(即依赖的模块的路径),然后再获取这个依赖模块的依赖图谱,以此类推递归下去即可,代码如下:
javascript
const exportDependencies = require('./exportDependencies')
//entry为入口文件路径
const exportGraph = (entry)=>{
const entryModule = exportDependencies(entry)
const graphArray = [entryModule]
for(let i = 0; i < graphArray.length; i++){
const item = graphArray[i];
//拿到文件所依赖的模块集合,dependencies的值参考exportDependencies
const { dependencies } = item;
for(let j in dependencies){
graphArray.push(
exportDependencies(dependencies[j])
)//关键代码,目的是将入口模块及其所有相关的模块放入数组
}
}
//接下来生成图谱
const graph = {}
graphArray.forEach(item => {
graph[item.filename] = {
dependencies: item.dependencies,
code: item.code
}
})
//可以看出,graph其实是 文件路径名:文件内容 的集合
return graph
}
module.exports = exportGraph
输出立即执行函数
首先,我们的代码被加载到页面中的时候,是需要立即执行的。所以输出的bundle.js
实质上要是一个立即执行函数。我们主要注意以下几点:
- 我们写模块的时候,用的是
import/export.
经转换后,变成了require/exports
- 我们要让
require/exports
能正常运行,那么我们得定义这两个东西,并加到bundle.js里 - 在依赖图谱里,代码都成了字符串。要执行,可以使用
eval
因此,我们要做这些工作:
- 定义一个
require
函数,require
函数的本质是执行一个模块的代码,然后将相应变量挂载到exports
对象上 - 获取整个项目的依赖图谱,从入口开始,调用
require
方法。完整代码如下:
javascript
const exportGraph = require('./exportGraph')
// 写入文件,可以用fs.writeFileSync等方法,写入到output.path中
const exportBundle = require('./exportBundle')
const exportCode = (entry)=>{
//要先把对象转换为字符串,不然在下面的模板字符串中会默认调取对象的toString方法,参数变成[Object object]
const graph = JSON.stringify(exportGraph(entry))
exportBundle(`
(function(graph) {
//require函数的本质是执行一个模块的代码,然后将相应变量挂载到exports对象上
function require(module) {
//localRequire的本质是拿到依赖包的exports变量
function localRequire(relativePath) {
return require(graph[module].dependencies[relativePath]);
}
var exports = {};
(function(require, exports, code) {
eval(code);
})(localRequire, exports, graph[module].code);
return exports;
//函数返回指向局部变量,形成闭包,exports变量在函数执行后不会被摧毁
}
require('${entry}')
})(${graph})`)
}
module.exports = exportCode
至此,简单打包完成,跑出结果。bundle.js的文件内容为:
javascript
(function(graph) {
//require函数的本质是执行一个模块的代码,然后将相应变量挂载到exports对象上
function require(module) {
//localRequire的本质是拿到依赖包的exports变量
function localRequire(relativePath) {
returnrequire(graph[module].dependencies[relativePath]);
}
var exports = {};
(function(require, exports, code) {
eval(code);
})(localRequire, exports, graph[module].code);
return exports;//函数返回指向局部变量,形成闭包,exports变量在函数执行后不会被摧毁
}
require('./src/index.js')
})({"./src/index.js":{"dependencies":{"./info.js":"./src/info.js"},"code":"\"use strict\";\n\nvar _info = _interopRequireDefault(require(\"./info.js\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\nconsole.log(_info[\"default\"]);"},"./src/info.js":{"dependencies":{"./name.js":"./src/name.js"},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports[\"default\"] = void 0;\n\nvar _name = require(\"./name.js\");\n\nvar info = \"\".concat(_name.name, \" is beautiful\");\nvar _default = info;\nexports[\"default\"] = _default;"},"./src/name.js":{"dependencies":{},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports.name = void 0;\nvar name = 'winty';\nexports.name = name;"}})
webpack打包流程概括
输出 编译 初始化 输出资源 输出完成 开始编译 确定入口 编译模块 完成模块编译 初始化参数
webpack的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:
- 初始化参数:Webpack启动时,依据命令行参数和配置文件设置编译所需的各项基本参数,确保准备好开始构建。
- 开始编译: 用上一步得到的参数初始Compiler对象,加载所有配置的插件,通 过执行对象的run方法开始执行编译
- 确定入口: 根据配置中的 Entry 找出所有入口文件
- 编译模块: 从入口文件出发,调用所有配置的 Loader 对模块进行编译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
- 完成模块编译: 在经过第4步使用 Loader 翻译完所有模块后, 得到了每个模块被编译后的最终内容及它们之间的依赖关系
- 输出资源 :根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再将每个 Chunk 转换成一个单独的文件加入输出列表中,这是可以修改输出内容的最后机会
- 输出完成: 在确定好输出内容后,根据配置确定输出的路径和文件名,将文件的内容写入文件系统中。
在以上过程中, Webpack 会在特定的时间点广播特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,井且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。其实以上7个步骤,可以简单归纳为初始化、编译、输出,三个过程,而这个过程其实就是前面说的基本模型的扩展。
实现一个丐版Webpack
该工具可以实现以下两个功能
● 将 ES6 转换为 ES5
● 支持在 JS 文件中 import CSS 文件
通过这个工具的实现,可以更好地理解打包工具背后的运行原理。
开始
由于需要将 ES6 转换为 ES5,我们首先需要安装一些 Babel 相关的依赖包:
javascript
yarn add babylon babel-traverse babel-core babel-preset-env
接下来我们将这些工具引入文件中
javascript
const fs = require('fs')
const path = require('path')
const babylon = require('babylon')
const traverse = require('babel-traverse').default
const { transformFromAst } = require('babel-core')
第一步:首先,实现如何使用 Babel 解析并转换代码
javascript
function readCode(filePath) {
// 读取文件内容
const content = fs.readFileSync(filePath, 'utf-8')
// 生成 AST
const ast = babylon.parse(content, {
sourceType: 'module'
})
// 寻找当前文件的依赖关系
const dependencies = []
traverse(ast, {
ImportDeclaration: ({ node }) => {
dependencies.push(node.source.value)
}
})
// 通过 AST 将代码转为 ES5
const { code } = transformFromAst(ast, null, {
presets: ['env']
})
return {
filePath,
dependencies,
code
}
}
● 首先我们传入一个文件路径参数,通过 fs 模块读取其内容。
● 接下来我们通过 babylon 解析代码生成抽象语法树(AST),用于分析是否存在其他导入文件。
● 通过 babel-traverse 遍历 AST,提取出所有依赖路径。
● 通过 dependencies 来存储文件中的依赖,最终调用 transformFromAst 将 AST 转换为 ES5 代码。
● 最后函数返回了一个对象,对象中包含了当前文件路径、当前文件依赖和当前文件转换后的代码
接下来我们需要构建一个函数来处理整个依赖图谱,这个函数的功能有以下几点
● 调用 readCode 函数,传入入口文件
● 分析入口文件的依赖
● 识别 JS 和 CSS 文件
javascript
function getDependencies(entry) {
// 读取入口文件
const entryObject = readCode(entry)
const dependencies = [entryObject]
// 遍历所有文件依赖关系
for (const asset of dependencies) {
// 获得文件目录
const dirname = path.dirname(asset.filePath)
// 遍历当前文件依赖关系
asset.dependencies.forEach(relativePath => {
// 获得绝对路径
const absolutePath = path.join(dirname, relativePath)
// CSS 文件逻辑就是将代码插入到 `style` 标签中
if (/\.css$/.test(absolutePath)) {
const content = fs.readFileSync(absolutePath, 'utf-8')
const code = `
const style = document.createElement('style')
style.innerText = ${JSON.stringify(content).replace(/\\r\\n/g, '')}
document.head.appendChild(style)
`
dependencies.push({
filePath: absolutePath,
relativePath,
dependencies: [],
code
})
} else {
// JS 代码需要继续查找是否有依赖关系
const child = readCode(absolutePath)
child.relativePath = relativePath
dependencies.push(child)
}
})
}
return dependencies
}
● 首先我们读取入口文件,然后创建一个数组,该数组的目的是存储代码中涉及到的所有文件
● 接下来我们遍历这个数组,一开始这个数组中只有入口文件,在遍历的过程中,如果入口文件有依赖其他的文件,那么就会被 push 到这个数组中
● 在遍历的过程中,我们先获得该文件对应的目录,然后遍历当前文件的依赖关系
● 在遍历当前文件依赖关系的过程中,首先生成依赖文件的绝对路径,然后判断当前文件是 CSS 文件还是 JS 文件
● 如果是 CSS 文件的话,我们就不能用 Babel 去编译了,只需要读取 CSS 文件中的代码,然后创建一个 <style>
标签,将代码插入进标签并且放入 head 中即可
● 如果是 JS 文件的话,我们还需要分析 JS 文件是否还有别的依赖关系
● 最后将读取文件后的对象 push 进数组中,此时已经获取一个包含所有依赖项的对象数组。
● 现在我们已经获取到了所有的依赖文件,接下来就是实现打包的功能了
第三步:打包依赖,模拟 CommonJS 运行环境
现在我们已经收集了完整的依赖图,下一步是将这些模块打包成一个可以在浏览器中运行的单文件。
javascript
function bundle(dependencies, entry) {
let modules = ''
// 构建函数参数,生成的结构为
// { './entry.js': function(module, exports, require) { 代码 } }
dependencies.forEach(dep => {
const filePath = dep.relativePath || entry
modules += `'${filePath}': (
function (module, exports, require) { ${dep.code} }
),`
})
// 构建 require 函数,目的是为了获取模块暴露出来的内容
const result = `
(function(modules) {
function require(id) {
const module = { exports : {} }
modules[id](module, module.exports, require)
return module.exports
}
require('${entry}')
})({${modules}})
`
// 当生成的内容写入到文件中
fs.writeFileSync('./bundle.js', result)
}
这段代码需要结合着 Babel 转换后的代码来看,这样大家就能理解为什么需要这样写了,
代码结构与 Babel 编译后的 CommonJS 代码相对应,目的是在浏览器端模拟模块化运行环境。
示例:Babel 转换后的代码如下
javascript
// entry.js
var _a = require('./a.js')
var _a2 = _interopRequireDefault(_a)
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : { default: obj }
}
console.log(_a2.default)
// a.js
Object.defineProperty(exports, '__esModule', {
value: true
})
var a = 1
exports.default = a
Babel 将我们 ES6的模块化代码转换为了 CommonJS的代码,但是浏览器是不支持 CommonJS 的,所以如果这段代码需要在浏览器环境下运行的话,我们需要手动实现 CommonJS 相关的类似机制,这就是 bundle 函数做的大部分事情。bundle 函数正是为此而设计。
接下来我们再来逐行解析 bundle 函数
● 首先遍历所有依赖文件,构建出一个函数参数对象
● 对象的属性就是当前文件的相对路径,属性值是一个函数,函数体是当前文件下的代码,函数接受三个参数 module、exports、 require
○ module 参数对应 CommonJS 中的 module
○ exports 参数对应 CommonJS 中的 module.export
○ require 参数对应我们自己创建的 require 函数
● 接下来就是构造一个使用参数的函数了,函数做的事情很简单,就是内部创建一个 require函数,然后调用 require(entry),也就是 require('./entry.js'),这样就会从函数参数中找到 ./entry.js 对应的函数并执行,最后将导出的内容通过 module.export 的方式让外部获取到
● 最后再将打包出来的内容写入到单独的文件中
如果你对于上面的实现还有疑惑的话,可以阅读下打包后的部分简化代码
javascript
;(function(modules) {
function require(id) {
// 构造一个 CommonJS 导出代码
const module = { exports: {} }
// 去参数中获取文件对应的函数并执行
modules[id](module, module.exports, require)
return module.exports
}
require('./entry.js')
})({
'./entry.js': function(module, exports, require) {
// 这里继续通过构造的 require 去找到 a.js 文件对应的函数
var _a = require('./a.js')
console.log(_a2.default)
},
'./a.js': function(module, exports, require) {
var a = 1
// 将 require 函数中的变量 module 变成了这样的结构
// module.exports = 1
// 这样就能在外部取到导出的内容了
exports.default = a
}
// 省略
})
尽管这个"丐版 Webpack"仅用了不到百行代码实现,但它涵盖了现代打包工具的核心思想:
● 找出入口文件所有的依赖关系。
● 将不同类型的资源统一处理
● 然后通过构建 CommonJS 代码来获取 exports 导出的内容。
这为我们理解打包工具的工作原理提供了很好的入门视角。
如果你觉得这篇文章对你有帮助,请点赞 👍、收藏 👏 并关注我!👀
