Question 2:
webpack
的编译流程是啥?
应该会有面试官这样问过你:
- webpack了解多少?
- 对webpack的编译原理了解吗?
- 写过webpack插件吗?
- 列举webpack编译流程中的hook节点
这些问题其实都可以被看作是同一个问题,那就是面试官在问你:你对webpack
的编译流程了解多少?
来总结一下我听到过的答案,尽量完全复原候选人面试的时候说的原话。
答案1:
webpack就是通过loader来加载资源,通过插件进行修改,最后打包生成bundles.js
答案2:
Webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:
1. 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数;
2. 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译;
3. 确定入口: 根据配置中的 entry 找出所有的入口文件;
4. 编译模块: 从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模
块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理;
5. 完成模块编译: 在经过第 4 步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内
容以及它们之间的依赖关系;
6. 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个
Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会;
7. 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系
统。
复制代码
哇哦! 这说起来一套一套的,有备而来啊! 这是从哪背的书?这时候,敢这么回答,那一定是作死现场。因为面试官一定会衍生出一系列问题,把你问哭。例如:
- 你刚说的输出列表是什么?
- 其实是在问chunks是挂在哪个对象上。
- 入口模块是怎么处理的?
- 其实在问如何生成入口模块的
- 最后是怎么把文件内容写入到文件系统的?
- 其实是在问compiler是如何具备文件读写能力的
- 什么时候构建阶段开始?
- 其实是在问webpack开始构建之前做了哪些构建准备
- 你说的调用Loader对模块进行翻译是如何做到的?
- 其实是在问在哪个阶段处理loader的
- ……
所以,你感觉快哭了没?我给你讲一个我的答案可好?
面试其实是门很纠结的艺术。webpack
东西那么多,不是三两句就能讲明白的。 所以如何花最少的时间尽量多的讲清楚内容,就是需要你认真琢磨的事情了。
答案3:
先把关键点说清楚,带源码那种,而且是越直观越好。以下1-4点花30秒说完吧,毕竟是说给面试官听。
- 初始化参数,
webpack.config.js
的module.export
,结合默认参数,merge
出最终的参数。 - 开始编译,通过初始化参数来实例化
Compiler
对象,加载所有配置的插件,执行对象的run方法。 - 确认入口文件。
- 编译模块:从入口文件出发,调用所有配置的Loader对模块进行加载,再找出该模块依赖的模块。通过递归这个过程直到所有入口文件都经过处理,得到一条依赖线。
webpack.js
中核心的操作就是require
了node_modules/webpack-cli/bin/cli.js
cli.js
- 01 当前文件一般有二个操作,处理参数,将参数交给不同的逻辑(分发业务)
- 02
options
(初始化参数) - 03
complier
(实例化Compiler
对象) - 04
complier.run
( 那run
里面做了什么,后续再看 )
- 实例化complier对象,complier会贯穿整个webpack工作流的过程。
complier
继承Tabable
,所以complier
具有操作钩子的能力。例如监听、触发事件,而webpack
是个事件流。- 实例话
complier
对象时,会把很多属性挂载上去。其中NodeEnvironmentPlugin让complier
具备文件读写的能力。 - 将
plugins
中的插件都挂载到了cpmplier
身上 - 将内部默认的
plugins
与complier
建立联系,其中有个EntryOptionsPlugin
处理了入口模块的id
- 在
webpack/lib/SingleEntryPlugin.js
里,compiler
监听了make
钩子- 在
singleEntryPlugin.js
模块的apply方法中有两个钩子监听 dep = SingleEntryPlugin.createDependency(entry,name)
- 其中
compilation
钩子就是让compilation
具备了利用normalModuleFactory
工厂创建一个普通模块的能力。因为compilation
就是利用自己创建出来的模块来加载需要被打包的模块。 - 其中
make
钩子在Compiler.run
的时候会被调用,到这里就意味着某个模块执行打包之前的所有准备工作就做完了。 - 然后
Compilation
调用addEntry
就标志着make
构建阶段开始了。
- 在
run
方法的执行- 刚说了Compiler.run方法执行会调用make钩子,那run方法里就是有一堆钩子按着顺序触发,例如
beforeRun
、run
、compile
- compile 方法的执行
- 先准备些参数,例如刚才提到的
normalModuleFactory
,用于后续创建模块。 - 触发
beforeCompile
- 将准备参数传入一个方法(
newCompilation
),用于创建一个compilation。在newCompilation
内部,先调用createCompilation
,然后触发this.compilation
钩子和compilation
钩子的监听
- 先准备些参数,例如刚才提到的
- 创建了
compilation
对象之后就触发了make
钩子。当触发make
钩子监听的时候,会将compilation
对象传入。compilation.addEntry
就意味着make构建阶段开始。 make
钩子被触发,接收到compilation
对象,那么从compilation
可以解构出三个值。entry
:当前被打包模块的相对路径,name
,context
:当前项目的跟路径processDependencies
处理模块间的依赖关系。函数内部通过async.forEach
来递归创建每个被加载进来的模块。compilation
调用addEntry
方法,内部调用_addModuleChain方法去处理依赖。compilation
当中可以通过normalModuleFactory
工厂来创建一个普通的模块对象。webpack
内部默认开启来一个100
并发量的打包操作. 源码里看到的是normalModuleFactory.create
这样一个方法。- 然后在
beforeResolve
方法里会触发一个factory
钩子监听。上述操作完成后,factory
获取到一个函数并对其进行调用。函数中又有一个resolver
钩子被触发,resolver
其实是处理loader
。当触发resolver
钩子,就意味着所有的Loader
处理完毕。 - 接下里就会触发
afterResolve
这个钩子,调用new NormalModule
- 最后就是调用
buildModule
方法开始编译 -> 调用build
-> 调用doBuild。bulid
过程中会将js
代码转化成ast
语法树,如果当前js
模块引用了其它模块,那就需要递归重复bulid
。当前所有入口模块都被存放在compilation
对象的entries
数组里。 - 那我还需要对当前模块的
ast
语法树进行一些修改,再转化回js
代码。例如将require
转化成__webpack_require__
- 最后
compile
方法最后调用compilation.seal
方法去处理chunk
。 生成代码内容,最终输出文件到指定打包路径下。
- 刚说了Compiler.run方法执行会调用make钩子,那run方法里就是有一堆钩子按着顺序触发,例如
唉。 哭了没。 面试的时候千万别讲这么细,因为面试官也细节不到这个程度。所以,你要讲的话,就把你认为重要话术整理出来吧。
接下来直接上代码,我们自己来实现webpack编译流程
- run.js
let webpack = require('./youWebpack') let options = require('./webpack.config') let compiler = webpack(options); // webpack 初始化 webpack.config.js 的 module.exports // 执行run方法 compiler.run((err, stats) => { console.log(err) console.log(stats) }) 复制代码
- 既然run方法require了
youWebpack
,那就得写出来不是。
/* * youWebpack.js */ const Compiler = require('./Compiler'); const NodeEnvironmentPlugin = require('./NodeEnvironmentPlugin') const WebpackOptionsApply = require('./WebpackOptionsApply') const webpack = function (options) { // 01 实例化 compiler 对象 let compiler = new Compiler(options.context) compiler.options = options // 02 初始化 NodeEnvironmentPlugin(让compiler具体文件读写能力) new NodeEnvironmentPlugin().apply(compiler) // 03 挂载所有 plugins 插件至 compiler 对象身上 if (options.plugins && Array.isArray(options.plugins)) { for (const plugin of options.plugins) { plugin.apply(compiler) } } // 04 挂载所有 webpack 内置的插件(入口) new WebpackOptionsApply().process(options, compiler); // 05 返回 compiler 对象即可 return compiler } module.exports = webpack 复制代码
- 你细品,
youWebpack
里有require了 Compiler、NodeEnvironmentPlugin、WebpackOptionsApply。 没办法,继续得写出来。
/* * WebpackOptionsApply.js */ const EntryOptionPlugin = require("./EntryOptionPlugin") class WebpackOptionsApply { process(options, compiler) { new EntryOptionPlugin().apply(compiler) compiler.hooks.entryOption.call(options.context, options.entry) } } module.exports = WebpackOptionsApply 复制代码
/* * NodeEnvironmentPlugin.js */ const fs = require('fs'); // webpack为提升文件读写性能, 源码里是对 node 的 fs 模块进行了二次封装的。我们这勉强够用,就不封装了。 /捂脸 class NodeEnvironmentPlugin { constructor(options) { this.options = options || {} } apply(complier) { complier.inputFileSystem = fs complier.outputFileSystem = fs } } module.exports = NodeEnvironmentPlugin 复制代码
Compiler,webpack 核心之一的Compiler来了。
/* * Compiler */ const { Tapable, SyncHook, SyncBailHook, AsyncSeriesHook, AsyncParallelHook } = require('tapable') const path = require('path') const mkdirp = require('mkdirp') const Stats = require('./Stats') const NormalModuleFactory = require('./NormalModuleFactory') const Compilation = require('./Compilation') const { emit } = require('process') class Compiler extends Tapable { constructor(context) { super() this.context = context this.hooks = { done: new AsyncSeriesHook(["stats"]), entryOption: new SyncBailHook(["context", "entry"]), beforeRun: new AsyncSeriesHook(["compiler"]), run: new AsyncSeriesHook(["compiler"]), 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"]), emit: new AsyncSeriesHook(['compilation']) } } emitAssets(compilation, callback) { // 当前需要做的核心: 01 创建dist 02 在目录创建完成之后执行文件的写操作 // 01 定义一个工具方法用于执行文件的生成操作 const emitFlies = (err) => { const assets = compilation.assets let outputPath = this.options.output.path for (let file in assets) { let source = assets[file] let targetPath = path.posix.join(outputPath, file) this.outputFileSystem.writeFileSync(targetPath, source, 'utf8') } callback(err) } // 创建目录之后启动文件写入 this.hooks.emit.callAsync(compilation, (err) => { mkdirp.sync(this.options.output.path) emitFlies() }) } run(callback) { console.log('run 方法执行了~~~~') const finalCallback = function (err, stats) { callback(err, stats) } const onCompiled = (err, compilation) => { // 最终在这里将处理好的 chunk 写入到指定的文件然后输出至 dist this.emitAssets(compilation, (err) => { let stats = new Stats(compilation) finalCallback(err, stats) }) } this.hooks.beforeRun.callAsync(this, (err) => { this.hooks.run.callAsync(this, (err) => { this.compile(onCompiled) }) }) } compile(callback) { const params = this.newCompilationParams() this.hooks.beforeRun.callAsync(params, (err) => { this.hooks.compile.call(params) const compilation = this.newCompilation(params) this.hooks.make.callAsync(compilation, (err) => { // console.log('make钩子监听触发了~~~~~') // callback(err, compilation) // 在这里我们开始处理 chunk compilation.seal((err) => { this.hooks.afterCompile.callAsync(compilation, (err) => { callback(err, compilation) }) }) }) }) } newCompilationParams() { const params = { normalModuleFactory: new NormalModuleFactory() } return params } newCompilation(params) { const compilation = this.createCompilation() this.hooks.thisCompilation.call(compilation, params) this.hooks.compilation.call(compilation, params) return compilation } createCompilation() { return new Compilation(this) } } module.exports = Compiler 复制代码
- You look. 你还得自己实现
NormalModuleFactory
、Compilation
、Stats
/* * Stats 其实看代码就能明白,Stats只是将compilation身上挂载的 入口模块、模块内容、chunks、文件目录等拿了出来。 这里可以回头看看run 方法 */ class Stats { constructor(compilation) { this.entries = compilation.entries this.modules = compilation.modules this.chunks = compilation.chunks this.files = compilation.files } toJson() { return this } } module.exports = Stats 复制代码
/* * NormalModuleFactory */ const NormalModule = require("./NormalModule"); class NormalModuleFactory { create(data) { return new NormalModule(data) } // 源码里头还实现了其它方法,所以这里不要嫌弃为什么又要单独require一个 NormalModule } module.exports = NormalModuleFactory 复制代码
webpack 核心之一 Compilation
,至于它干嘛的?请你回头看看Stats就明白了。
const ejs = require('ejs') const Chunk = require('./Chunk') const path = require('path') const async = require('neo-async') const Parser = require('./Parser') const NormalModuleFactory = require('./NormalModuleFactory') const { Tapable, SyncHook } = require('tapable') // 实例化一个 normalModuleFactory parser const normalModuleFactory = new NormalModuleFactory() const parser = new Parser() class Compilation extends Tapable { constructor(compiler) { super() this.compiler = compiler this.context = compiler.context this.options = compiler.options // 让 compilation 具备文件的读写能力 this.inputFileSystem = compiler.inputFileSystem this.outputFileSystem = compiler.outputFileSystem this.entries = [] // 存入所有入口模块的数组 this.modules = [] // 存放所有模块的数据 this.chunks = [] // 存放当前次打包过程中所产出的 chunk this.assets = [] this.files = [] this.hooks = { succeedModule: new SyncHook(['module']), seal: new SyncHook(), beforeChunks: new SyncHook(), afterChunks: new SyncHook() } } /** * 完成模块编译操作 * @param {*} context 当前项目的根 * @param {*} entry 当前的入口的相对路径 * @param {*} name chunkName main * @param {*} callback 回调 */ addEntry(context, entry, name, callback) { this._addModuleChain(context, entry, name, (err, module) => { callback(err, module) }) } _addModuleChain(context, entry, name, callback) { this.createModule({ parser, name: name, context: context, rawRequest: entry, resource: path.posix.join(context, entry), moduleId: './' + path.posix.relative(context, path.posix.join(context, entry)) }, (entryModule) => { this.entries.push(entryModule) }, callback) } /** * 定义一个创建模块的方法,达到复用的目的 * @param {*} data 创建模块时所需要的一些属性值 * @param {*} doAddEntry 可选参数,在加载入口模块的时候,将入口模块的id 写入 this.entries * @param {*} callback */ createModule(data, doAddEntry, callback) { let module = normalModuleFactory.create(data) const afterBuild = (err, module) => { // 在 afterBuild 当中我们就需要判断一下,当前次module 加载完成之后是否需要处理依赖加载 if (module.dependencies.length > 0) { // 当前逻辑就表示module 有需要依赖加载的模块,因此我们可以再单独定义一个方法来实现 this.processDependencies(module, (err) => { callback(err, module) }) } else { callback(err, module) } } this.buildModule(module, afterBuild) // 当我们完成了本次的 build 操作之后将 module 进行保存 doAddEntry && doAddEntry(module) this.modules.push(module) } /** * 完成具体的 build 行为 * @param {*} module 当前需要被编译的模块 * @param {*} callback */ buildModule(module, callback) { module.build(this, (err) => { // 如果代码走到这里就意味着当前 Module 的编译完成了 this.hooks.succeedModule.call(module) callback(err, module) }) } processDependencies(module, callback) { // 1 当前的函数核心功能就是实现一个被依赖模块的递归加载 // 2 加载模块的思想都是创建一个模块,然后想办法将被加载模块的内容拿进来? // 3 当前我们不知道 module 需要依赖几个模块, 此时我们需要想办法让所有的被依赖的模块都加载完成之后再执行 callback?【 neo-async 】 let dependencies = module.dependencies async.forEach(dependencies, (dependency, done) => { this.createModule({ parser, name: dependency.name, context: dependency.context, rawRequest: dependency.rawRequest, moduleId: dependency.moduleId, resource: dependency.resource }, null, done) }, callback) } seal(callback) { this.hooks.seal.call() this.hooks.beforeChunks.call() // 1 当前所有的入口模块都被存放在了 compilation 对象的 entries 数组里 // 2 所谓封装 chunk 指的就是依据某个入口,然后找到它的所有依赖,将它们的源代码放在一起,之后再做合并 for (const entryModule of this.) { // 核心: 创建模块加载已有模块的内容,同时记录模块信息 const chunk = new Chunk(entryModule) // 保存 chunk 信息 this.chunks.push(chunk) // 给 chunk 属性赋值 chunk.modules = this.modules.filter(module => module.name === chunk.name) } // chunk 流程梳理之后就进入到 chunk 代码处理环节(模板文件 + 模块中的源代码==》chunk.js) this.hooks.afterChunks.call(this.chunks) // 生成代码内容 this.createChunkAssets() callback() } createChunkAssets() { for (let i = 0; i < this.chunks.length; i++) { const chunk = this.chunks[i] const fileName = chunk.name + '.js' chunk.files.push(fileName) // 1 获取模板文件的路径 let tempPath = path.posix.join(__dirname, 'temp/main.ejs') // 2 读取模块文件中的内容 let tempCode = this.inputFileSystem.readFileSync(tempPath, 'utf8') // 3 获取渲染函数 let tempRender = ejs.compile(tempCode) // 4 按ejs的语法渲染数据 let source = tempRender({ entryModuleId: chunk.entryModule.moduleId, modules: chunk.modules }) // 输出文件 this.emitAssets(fileName, source) } } emitAssets(fileName, source) { this.assets[fileName] = source this.files.push(fileName) } } module.exports = Compilation 复制代码
还有部分就不写了。 像 Parser、 Chunk。
其实,主要是为懒,写得太累了, 我得去觅食了。好了,开玩笑。主要webpack整个编译过程到这应该就完全明白了。。
小结一下
webpack编译过程是啥? 代码里应该体现得非常清楚了。
step1: 实例化compiler
- 实例化 compiler 对象
- 初始化 NodeEnvironmentPlugin(让compiler具体文件读写能力)
- 挂载所有 plugins 插件至 compiler 对象身上
- 挂载所有 webpack 内置的插件(入口)
step2: compiler.run
- this.hooks.beforeRun.callAsync -> this.hooks.run.callAsync -> this.compile
-
this.compile 接收 onCompiled
-
onCompiled 内容是: 最终在这里将处理好的 chunk 写入到指定的文件然后输出至 dist (文件输出路径,不一定是dist)
-
step3: compile方法做的事情
- newCompilationParams,实例化Compilation对象之前先初始化其所需参数
- 调用this.hooks.beforeRun.callAsync
- this.newCompilation(params) 实例化Compilation对象
- this.hooks.make.callAsync 触发make钩子监听
- compilation.seal 开始处理 chunk
- this.hooks.afterCompile.callAsync(compilation,...)
- 流程进入compilation了。。。
step4: 完成模块编译操作
- addEntry
- _addModuleChain
- createModule:定义一个创建模块的方法,达到复用的目的
- module = normalModuleFactory.create(data) : 创建普通模块,目的是用来加载js模块
- afterBuild
- this.processDependencies : 找到模块与模块之间的依赖关系
- this.buildModule(module, afterBuild)
- module.build : 到这里就意味着当前 Module 的编译完成了
- createModule:定义一个创建模块的方法,达到复用的目的
- _addModuleChain
- seal: 生成代码内容,输出文件
Over.
作者:在剥我的壳
链接:https://juejin.cn/post/6972378623281987621
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。