我们先看一下 webpack 官方对 webpack 的定义:
本质上,webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler) 。
当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph) ,
其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle。
webpack 作为一个打包器,相信大部分前端开发者是有使用过的,我也不例外。我所在的项目组是开发 H5 游戏的,随着所使用的引擎(egret)增加了对 webpack 的支持,我们项目也从原来官方的打包方式升级为用 webpack 打包。
之前项目从头开始编译得三十秒左右,后面每次改动后再编译也得十几秒左右。升级为 webpack 之后,除了第一次编译比较久,大概得一分钟左右,后面每次修改代码后只需要两三秒就完成热更新了,和之前的对比起来体验好了很多。
改用 webpack 打包对项目编译速度提升这么大,这也让我好奇它具体的一个执行流程是如何的,接下来便开始本篇文章的正题,通过阅读 webpack 的源码来简单地了解一下打包的过程。
注:webpack 版本为 v5.10.1,webpack-cli 版本为 4.2.0
一 入口
当我们使用 webpack-cli 对项目进行打包的时候,实际上调用的就是当前项目中的 node_modules 中的 webpack-cli 中的 /bin/cli.js,我们将 webpack-cli 的代码下载到本地上,打开 cli.js 可以看到它走了 runCLI
的逻辑。
if (packageExists('webpack')) {
runCLI(rawArgs);
} else {...
}
跳转之后我们可以发现 runCLI
中执行了 const cli = new WebpackCLI()
并调用了 cli.run
, 然后执行了 createCompiler
创建 compiler
对象,然后就进入到 webpack 的打包流程中了。
createCompiler(options, callback) {
let compiler;
try {
compiler = webpack(options, callback);
} catch (error) {...
}
return compiler;
}
二 打包流程的前置知识
在开始打包流程之前,我们需要了解一个叫 Tapable 的库,这个库是 webpack 团队为了 webpack 而写的一个事件库。因为 webpack 本质上是一个分发各种事件的架子,然后通过不同的 plugin 监听这些事件再分别执行对应的操作。
Tapable 用法
定义一个事件:this.hooks.eventName = new SyncHook([arg]);
监听一个事件:this.hooks.eventName.tap(reason, fn);
分发一个事件:this.hooks.eventName.call(arg);
三 打包流程
webpack 源码中整个打包流程如下,具体的过程下面进行介绍。
打开 webpack 的源码,进入到 package.json 中我们可以发现 main 指向的是 lib/index.js
,那么我们的入口就是 lib/index.js
了。
进入到 index.js 中,将代码折叠起来后我们可以发现最重要的函数应该是 fn 函数。fn 函数的作用是去请求当前目录下 webpack.js。
const fn = lazyFunction(() => require("./webpack"));
module.exports = mergeExports(fn, {...
});
webpack.js
进入到 webpack.js
中,根据上面入口的代码 compiler = webpack(options, callback);
可知,在有无 callback
的判断中,会进入有的判断中,然后执行 create
函数,create
函数中我们的 options 是对象,所以是走 else 的逻辑,执行了 createCompiler
函数。
const webpack = /** @type {WebpackFunctionSingle & WebpackFunctionMulti} */ ((
options,
callback
) => {
const create = () => {
...
if (Array.isArray(options)) {...
} else {
/** @type {Compiler} */
compiler = createCompiler(options);
watch = options.watch;
watchOptions = options.watchOptions || {};
}
return { compiler, watch, watchOptions };
};
if (callback) {
try {
const { compiler, watch, watchOptions } = create();
if (watch) {
compiler.watch(watchOptions, callback);
} else {
compiler.run((err, stats) => {
compiler.close(err2 => {
callback(err || err2, stats);
});
});
}
return compiler;
} catch (err) {...
}
} else {...
}
});
createCompiler
函数这里就开始我们上面那张图的内容了,new Compiler(options.context);
中进行了一系列参数的初始化,有兴趣的可以自己去看一下。然后就开始进行事件的挂载了,事件都是挂载在 Compiler
上的,Compiler
的代码在 compiler.js
中。
可以看到按照 enviroment -> afterEnviroment -> initialize
这个顺序进行事件的分发,然后再在对应的地方进行监听和响应,要了解具体的响应的话可以在代码中直接全局进行搜索 事件名.tap
例如 enviroment.tap
。这些不是特别关键的步骤就不进行细看了。
const createCompiler = rawOptions => {
...
compiler.hooks.environment.call();
compiler.hooks.afterEnvironment.call();
compiler.hooks.initialize.call();
return compiler;
};
createCompiler
函数执行完后回到 create
函数中,判断当前有无 watch
,再决定执行 compiler.watch
还是 compiler.run
。因为这是第一次编译,所以肯定不是处于 watch
状态,就进入到 compiler.run
也即 compiler.js
中了。
compiler.js
compiler.js
中的 run
函数如下所示,执行了函数里面自己定义的 run
箭头函数,又开始了各种事件的分发,同事调用了 readRecords
进行文件的读取,然后调用了 this.compile
并把 onCompiled
做为回调函数传了进去。
run(callback) {
const onCompiled = (err, compilation) => {...}
const run = () => {
this.hooks.beforeRun.callAsync(this, err => {
...
this.hooks.run.callAsync(this, err => {
...
this.readRecords(err => {
...
this.compile(onCompiled);
});
});
});
};
if (this.idle) {
...
run();
});
} else {
run();
}
}
compile
函数分发了 beforeCompile
和 compile
事件,然后就执行了 his.newCompilation
函数,创建了一个 compilation 对象,接着继续分发 make
和 finishMake
事件,make
是编译的第一个过程,这个需要留意一下。
然后调用了 compilation.finish
并在其回调里面调用了 compilation.seal
,这两个我们后面再看里面的具体逻辑。接着分发了一个 afterCompile
事件表明编译阶段结束了,然后调用了 callback 即上面 run
函数中的 onCompiled
函数。
compile(callback) {
const params = this.newCompilationParams();
this.hooks.beforeCompile.callAsync(params, err => {
...
this.hooks.compile.call(params);
const compilation = this.newCompilation(params);
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 => {...
});
});
});
});
});
});
});
}
onCompiled
函数里面分发了 shouldEmit
事件,然后进入 nextTick
函数中,执行了 this.emitAssets
函数,this.emitAssets
函数里面分发了一个 emit
事件然后读取了要打包的文件并定义了一个输出的文件。
接着执行 this.emitRecords
然后分发 done
事件,表示打包结束。
const onCompiled = (err, compilation) => {
if (err) return finalCallback(err);
if (this.hooks.shouldEmit.call(compilation) === false) {...
}
process.nextTick(() => {
this.emitAssets(compilation, err => {
if (compilation.hooks.needAdditionalPass.call()) {...
}
this.emitRecords(err => {
...
this.hooks.done.callAsync(stats, err => {...
}
});
});
});
};
然后我们回到 compilation.finsih
函数中,它分发了 finishModules
事件对模块进行了一些操作,然后对每个模块都执行了 callback 函数。
finish(callback) {
const { modules } = this;
this.hooks.finishModules.callAsync(modules, err => {...
for (const module of modules) {...
callback();
}
});
}
再看看 compilation.seal
函数,在这里创建了 chunkGraph
对象,这个对象就是用来收集所有模块的关系的,接着就是分发各种事件。
seal(callback) {
const chunkGraph = new ChunkGraph(this.moduleGraph);
this.chunkGraph = chunkGraph;
for (const module of this.modules) {
ChunkGraph.setChunkGraphForModule(module, chunkGraph);
}
this.hooks.seal.call();
this.hooks.afterOptimizeDependencies.call(this.modules);
this.hooks.beforeChunks.call();
for (const [name, { dependencies, includeDependencies, options }] of this.entries) {
const chunk = this.addChunk(name);
...
}
buildChunkGraph(this, chunkGraphInit);
this.hooks.afterChunks.call(this.chunks);
this.hooks.optimize.call();
小结
到这里打包流程我们就大概的都看了一遍,整个流程比较重要的内容就是上面那张包含事件和函数的图,主要的流程包括 env -> init -> run -> beforeCompile -> compile -> compilation -> make -> finishMake -> afterCompile -> emit -> afterEmit -> done
这些事件。
看完上面的内容你可能还是一脸迷茫,不知道到底 webpack 打包过程中做了哪些内容,下面我们通过回答另外一个问题来加深对源码的理解。
webpack 是如何收集和分析依赖的?
首先我们需要知道 webpack 是在哪个阶段进行依赖的分析和收集的,根据上面的流程,通过排除法可以知道最有可能的就是在 make
到 finishMake
阶段。
翻到上面看下 compile
函数的代码,你会发现 make
和 finishMake
中间并没有做什么操作,根据 Table
库的知识点可知,我们需要找到监听 make
事件的地方即 make.tap
。
搜索之后你会发现有九个结果,一个个看了之后我们可以发现 EntryPlugin.js
中的监听函数是最符合我们的预期的,EntryPlugin.createDependency
顾名思义就是创建依赖的地方,然后执行 compilation.addEntry
将依赖添加到编译中。
compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {
const { entry, options, context } = this;
const dep = EntryPlugin.createDependency(entry, options);
compilation.addEntry(context, dep, options, err => {
callback(err);
});
});
接下来我们继续往里面看,经过 compilation.addEntry -> this._addEntryItem -> this.addModuleChain -> this.handleModuleCreation -> this.factorizeModule
的跳转之后,我们可以发现最后就是往 factorizeQueue
中添加了一个任务,到这里就没了。
我们需要了解一下 factorizeQueue
是什么,这里才知道它里面是如何处理的。根据下面的代码我们可以看到它的处理函数即 processor
为 this._factorizeModule
。处理函数里面主要就是执行了 factory.create
,到这里发现前面又没有路了。那么我们需要找到这个 factory
是什么,这个寻找的过程比较麻烦,这里就省略不讲了,最后寻找的结果是 factory
对应 NormalModuleFactory
。
this.factorizeQueue = new AsyncQueue({
name: "factorize",
parallelism: options.parallelism || 100,
processor: this._factorizeModule.bind(this)
});
在 NormalModuleFactory
中找到 create
函数,可以看到 factorize
进行了事件监听,搜索 factorize.tap
可以看到里面进行了一系列事件的监听: reslove -> afterResolve -> createModule
,由此可知 factory.create
最终创建了一个 module 对象。
this.factorizeModule
执行完后就到它的回调函数里面,回调函数中调用了 this.addModule
函数,这个函数里面和 this.factorizeModule
了类似,也是往一个队列里面添加了一个任务之类,进入到它的 processor
中,可以知道它的作用就是把 module
添加到 compilation.modules
中,同时会检查 id 防止重复添加。
接着到 this.addModule
的回调函数中,它在里面调用了 this.buildModule
,从名称我们可以推测依赖很大可能就是在这个函数里面进行收集的。this.buildModule
中也是和上面类似的操作,它的 processor
就在下面的 _buildModule
函数中。_buildModule
中调用了 module.needBuild
,而 module.needBuild
里面调用了 module.build
,这个 module
由上面的 NormalModuleFactory
可知它对应的代码应该在 NormalModule.js
中。
在 NormalModule.js
中搜索 build
函数,我们可以注意到在这里有对 source
和 ast
进行初始化。接着我们进入到 doBuild
函数中。
build(options, compilation, resolver, fs, callback) {
...
this._source = null;
this._ast = null;
...
return this.doBuild(options, compilation, resolver, fs, err => {...})
可以看到 doBuild
函数执行了 runLoaders
,顾名思义这个就是执行所有的 loader,读取所有文件的源代码。runLoaders
执行完后回调里面调用了 processResult
函数,在 processResult
里面对 _source
进行了赋值,但是 _ast
此时还是空的,因为之前没有对它进行过处理,它的赋值应该是后面才进行的。
doBuild(options, compilation, resolver, fs, callback) {
const loaderContext = this.createLoaderContext(...);
const processResult = (err, result) => {
...
this._source = this.createSource(...);
this._ast =
typeof extraInfo === "object" &&
extraInfo !== null &&
extraInfo.webpackAST !== undefined ?
extraInfo.webpackAST : null;
...
}
runLoaders({...}, (err, result) => {
...
processResult(err, result.result);
});
执行完 doBuild
函数我们再回到上面它的回调里面,可以看到在 try
中执行了 parse
的操作,对源码(this._source.source()
)进行了解析,这个 parser
是根据源码的不同去调用不同的 parser
,例如解析 JavaScript
代码的话就去调用了 JavascriptParser.js
中的 parse
函数。
this.doBuild(options, compilation, resolver, fs, err => {
...
let result;
try {
result = this.parser.parse(this._ast || this._source.source(), {...});
} catch (e) {
...
}
})
JavascriptParser.js
中的 parse
函数中调用了 this.blockPreWalkStatements(ast.body);
函数,这个函数就是进行依赖分析的地方。分析的过程就是将源码的每行代码都进行分析,如果发现当前的代码是引用相关的,就根据 statement.type
分别进行处理。
blockPreWalkStatements(statements) {
for (let index = 0, len = statements.length; index < len; index++) {
const statement = statements[index];
this.blockPreWalkStatement(statement);
}
}
blockPreWalkStatement(statement) {
...
switch (statement.type) {
case "ImportDeclaration":
this.blockPreWalkImportDeclaration(statement);
break;
case "ExportAllDeclaration":
this.blockPreWalkExportAllDeclaration(statement);
break;
case "ExportDefaultDeclaration":
this.blockPreWalkExportDefaultDeclaration(statement);
break;
case "ExportNamedDeclaration":
this.blockPreWalkExportNamedDeclaration(statement);
break;
case "VariableDeclaration":
this.blockPreWalkVariableDeclaration(statement);
break;
case "ClassDeclaration":
this.blockPreWalkClassDeclaration(statement);
break;
}
}
到这里我们可以回答上面提出的那个问题了,webpack 是如何收集和分析依赖的?
在 doBuild
中执行 runLoaders
将所有的文件转化为源码,然后在 doBuild
的回调中将源码进行 parse
转化为 AST,然后再根据 AST 对每一行代码进行分析,发现是引用相关的就将其进行处理。
后面的内容我们在这里就不具体分析了,大概描述下就是上面的引用相关会被添加到 module 的 dependencies 或 blocks 中,然后再 seal
阶段 webpack 将 module 转化为 chunk,并且可能会把多个 module 通过 codeGeneration
合并为一个 chunk,seal
结束之后为每个 chunk 创建文件,并写到硬盘上。