通过 npx 或者 yarn 执行 webpack 命令,实际上是调用了 node_modules 文件下的 webpack cmd 文件,这个 cmd 文件里又组装了用 node 运行 /node_modules/webpack/bin/webpack.js 的命令。webpack.js 中又加载了 /node_modules/webpack/bin/webpack-cli/cli.js。cli.js 定义了一个自调用函数,接收并处理命令行参数,将参数分发给不同的业务。加载 webpack 打包配置 options,创建一个 compiler,执行 compiler 的 run 方法。
webpack 的编译流程可以看作事件驱动型事件流工作机制。核心的两个部分:负责编译的 compiler 和负责创建 bundles 的 compilation。
手写实现打包
基本框架
SingleEntryPlugin
SingleEntryPlugin 类专门用于处理单入口,作用是给 make 和 compilation 钩子注册事件。
class SingleEntryPlugin {
constructor(context, entry, name) {
// 挂载上下文、入口文件名、入口文件键名
this.context = context
this.entry = entry
this.name = name
}
apply(compiler) {
compiler.hooks.make.tapAsync('SingleEntryPlugin', (compilation, callback) => {
const { context, entry, name } = this
console.log('make 钩子监听执行了')
// compilation.addEntry(context, entry, name, callback)
})
}
}
module.exports = SingleEntryPlugin
EntryOptionPlugin
EntryOptionPlugin 是用于处理 webpack 入口文件的插件。作用是获取入口文件的路径和名称,传给其它的钩子,并在 make 钩子和 compilation 钩子注册事件监听。
手写实现:
const SingleEntryPlugin = require("./SingleEntryPlugin")
const itemToPlugin = function (context, item, name) {
return new SingleEntryPlugin(context, item, name)
}
class EntryOptionPlugin {
apply(compiler) {
compiler.hooks.entryOption.tap('EntryOptionPlugin', (context, entry) => {
itemToPlugin(context, entry, 'main').apply(compiler)
})
}
}
module.exports = EntryOptionPlugin
Compiler 类 的 Run 方法
run 方法是 webpack 的核心,里面定义了 finalCallback 方法(主要作用是对 callback 方法进行调用)和 onCompiled 方法(编译完成后调用的方法,里面会触发 done 钩子事件并调用 finalCallback 方法),然后依次触发了 beforeRun 和 run 钩子,run 钩子的触发回调函数里调用了 compile 方法,往 compile 传入 onCompiled 方法。
compile 方法
compile 方法包含了编译的过程。
- 调用 newCompilationParams 方法,返回 params,其中最主要的是 normalModuleFactory 实例。
- 触发 beforeCompile 钩子监听,在它的回调函数里又触发了 compile 钩子监听。
- 调用 newCompilation 方法,传入 params,返回一个 compilation 实例。
- 触发 make 钩子,传入 compilation 实例。
手写实现
Compiler.js:
const {
Tapable,
SyncHook,
SyncBailHook,
AsyncParallelHook,
AsyncSeriesHook
} = require("tapable")
const Stats = require('./Stats')
const Compilation = require("./Compilation")
const NormalModuleFactory = require("./NormalModuleFactory")
class Compiler extends Tapable {
constructor(context) {
super()
this.context = context
this.hooks = {
done: new AsyncSeriesHook(['stats']),
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"]),
entryOption: new SyncBailHook(["context", "entry"]),
}
}
run(callback) {
console.log('run 方法执行了')
const finalCallback = function (err, stats) {
return callback(err, stats)
}
const onCompiled = function (err, compilation) {
console.log('onCompiled~')
finalCallback(err, new Stats(compilation))
}
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)
})
})
}
newCompilationParams() {
return {
normalModuleFactory: new NormalModuleFactory()
}
}
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
Stats.js:
class Stats {
constructor(compilation) {
this.entries = compilation.entries
this.modules = compilation.modules
}
toJson() {
return this
}
}
module.exports = Stats
make 钩子执行前的流程
一、步骤
01 实例化 compiler 对象( 它会贯穿整个webpack工作的过程 )
02 由 compiler 调用 run 方法
二、compiler 实例化操作
01 compiler 继承 tapable,因此它具备钩子的操作能力(监听事件,触发事件,webpack是一个事件流)
02 在实例化了 compiler 对象之后就往它的身上挂载很多属性,其中 NodeEnvironmentPlugin 这个操作就让它具备了
文件读写的能力(我们的模拟时采用的是 node 自带的 fs )
03 具备了 fs 操作能力之后又将 plugins 中的插件都挂载到了 compiler 对象身上
04 将内部默认的插件与 compiler 建立关系,其中 EntryOptionPlugin 处理了入口模块的 id
05 在实例化 compiler 的时候只是监听了 make 钩子(SingleEntryPlugin)
5-1 在 SingleEntryPlugin 模块的 apply 方法中有二个钩子监听
5-2 其中 compilation 钩子就是让 compilation 具备了利用 normalModuleFactory 工厂创建一个普通模块的能力
5-3 因为它就是利用一个自己创建的模块来加载需要被打包的模块
5-4 其中 make 钩子 在 compiler.run 的时候会被触发,走到这里就意味着某个模块执行打包之前的所有准备工作就完成了
5-5 addEntry 方法调用()
三、run 方法执行( 当前想看的是什么时候触发了 make 钩子 )
01 run 方法里就是一堆钩子按着顺序触发(beforeRun run compile)
02 compile 方法执行
1 准备参数(其中 normalModuleFactory 是我们后续用于创建模块的)
2 触发beforeCompile
3 将第一步的参数传给一个函数,开始创建一个 compilation (newCompilation)
4 在调用 newCompilation 的内部
- 调用了 createCompilation
- 触发了 this.compilation 钩子 和 compilation 钩子的监听
03 当创建了 compilation 对象之后就触发了 make 钩子
04 当我们触发 make 钩子监听的时候,将 compilation 对象传递了过去
四、总结
1 实例化 compiler
2 调用 compile 方法
3 newCompilation
4 实例化了一个compilation 对象(它和 compiler 是有关系)
5 触发 make 监听
6 addEntry 方法(这个时候就带着 context name entry 一堆的东西) 就奔着编译去了.....
addEntry
方法流程
01 make 钩子在被触发的时候,接收到了 compilation 对象实现,它的身上挂载了很多内容
02 从 compilation 当中解构了三个值
entry : 当前需要被打包的模块的相对路径(./src/index.js)
name: main
context: 当前项目的根路径
03 dep 是对当前的入口模块中的依赖关系进行处理
04 调用了 addEntry 方法
05 在 compilation实例的身上有一个 addEntry 方法,然后内部调用了 _addModuleChain 方法,去处理依赖
06 在 compilation 当中我们可以通过 NormalModuleFactory 工厂来创建一个普通的模块对象
07 在 webpack 内部默认启了一个 100 并发量的打包操作,当前我们看到的是 normalModule.create()
08 在 beforeResolve 里面会触发一个 factory 钩子监听【 这个部分的操作其实是处理 loader, 当前我们重点去看 】
09 上述操作完成之后获取到了一个函数被存在 factory 里,然后对它进行了调用
10 在这个函数调用里又触发了一个叫 resolver 的钩子( 处理 loader的,拿到了 resolver方法就意味着所有的Loader 处理完毕 )
11 调用 resolver() 方法之后,就会进入到 afterResolve 这个钩子里,然后就会触发 new NormalModule
12 在完成上述操作之后就将module 进行了保存和一些其它属性的添加
13 调用 buildModule 方法开始编译---》 调用 build ---》doBuild
手写实现
Compilation.js:
const { Tapable, SyncHook } = require('tapable')
const path = require('path')
const NormalModuleFactory = require('./NormalModuleFactory')
const Parser = require('./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.hooks = {
succeedModule: new SyncHook(['module'])
}
}
// 01
/**
* 完成模块编译的操作
* @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)
})
}
// 02
_addModuleChain (context, entry, name, callback) {
let entryModule = normalModuleFactory.create({
name,
context,
rawRequest: entry,
resource: path.posix.join(context, entry), // 当前操作的核心作用就是返回 entry 入口的绝对路径
parser,
})
const afterBuild = function (err) {
callback(err, entryModule)
}
this.buildModule(entryModule, afterBuild)
// 当完成了本次的 build 操作后将 module 进行保存
this.entries.push(entryModule)
this.modules.push(entryModule)
}
// 03
/**
* 完成具体的 build 行为
* @param {*} module 当前需要被编译的模块
* @param {*} callback
*/
buildModule(module, callback) {
module.build(this, (err) => {
// 如果代码走到这里意味着 当前 module 的编译完成了
this.hooks.succeedModule.call(module)
callback(err)
})
}
}
module.exports = Compilation
Parser.js:
const babylon = require('babylon')
const { Tapable } = require('tapable')
class Parser extends Tapable {
parse(source) {
return babylon.parse(
source,
{
sourceType: 'module',
plugins: ['dynamicImport'] // 当前插件可以支持 import() 动态导入的语法
}
)
}
}
module.exports = Parser
NormalModuleFactory.js:
const NormalModule = require("./NormalModule")
class NormalModuleFactory {
create(data) {
return new NormalModule(data)
}
}
module.exports = NormalModuleFactory
NormalModule.js:
class NormalModule {
constructor(data) {
this.name = data.name
this.entry = data.entry
this.rawRequest = data.rawRequest
this.parser = data.parser
this.resource = data.resource
this._source // 存放某个模块的源代码
this._ast // 存放某个模块源代码的ast
}
build(compilation, callback) {
/**
* 01 从文件中读取到将来需要被加载的 module 的内容
* 02 如果当前不是 js 模块则需要 loader 进行处理,最终返回 js 模块
* 03 上述的操作完成之后就可以将 js 代码转为 ast 语法树
* 04 当前 js 模块内部可能又引用了很多其他的模块,因此我们需要递归完成
* 05 前面的完成后,只需重复执行即可
*/
this.doBuild(compilation, (err) => {
this._ast = this.parser.parse(this._source)
callback(err)
})
}
doBuild(compilation, callback) {
this.getSource(compilation, (err, source) => {
this._source = source
callback()
})
}
getSource(compilation, callback) {
compilation.inputFileSystem.readFile(this.resource, 'utf8', callback)
}
}
module.exports = NormalModule
依赖模块处理
- 处理依赖模块的相对路径和后缀,把源代码转成语法树进行一些操作。
- 需要将 index.js 里的 require 方法替换成 webpack_require 。对语法树进行修改,之后再转回成源代码。
- 实现递归的操作 ,所以要将依赖的模块信息保存好,方例交给下一次 create。
NormalModule.js对 doBuild 调用时的回调函数进行改造:
const path = require('path')
const types = require('@babel/types')
const generator = require('@babel/generator').default
const traverse = require('@babel/traverse').default
class NormalModule {
constructor(data) {
this.context = data.context
this.name = data.name
// this.entry = data.entry
this.rawRequest = data.rawRequest
this.parser = data.parser
this.resource = data.resource
this._source // 存放某个模块的源代码
this._ast // 存放某个模块源代码的ast
this.dependencies = [] // 定义一个空数组用于保存被依赖加载的模块信息
}
build(compilation, callback) {
/**
* 01 从文件中读取到将来需要被加载的 module 的内容
* 02 如果当前不是 js 模块则需要 loader 进行处理,最终返回 js 模块
* 03 上述的操作完成之后就可以将 js 代码转为 ast 语法树
* 04 当前 js 模块内部可能又引用了很多其他的模块,因此我们需要递归完成
* 05 前面的完成后,只需重复执行即可
*/
this.doBuild(compilation, (err) => {
this._ast = this.parser.parse(this._source)
// 这里的 _ast 就是当前 module 的语法树,我们可以对它进行修改,最后将 ast 转回成 code 代码
traverse(this._ast, {
CallExpression: (nodePath) => {
let node = nodePath.node
// 定位 require 所在的节点
if (node.callee.name === 'require') {
// 获取原始请求路径
let modulePath = node.arguments[0].value // './title'
// 取出当前被加载的模块名称
let moduleName = modulePath.split(path.posix.sep).pop() // title
// [当前打包器只处理 js]
let extName = moduleName.indexOf('.') == -1 ? '.js' : ''
moduleName += extName // title.js
// [最终想要读取当前 js 的内容] 所以需要绝对路径
let depResource = path.posix.join(path.posix.dirname(this.resource), moduleName)
// [将当前模块的 id 定义 OK]
let depModuleId = './' + path.posix.relative(this.context, depResource) // ./src/title.js
// 记录当前被依赖模块的信息,方便后面递归加载
this.dependencies.push({
name: this.name,
context: this.context,
rawRequest: moduleName,
moduleId: depModuleId,
resource: depResource,
})
// 替换内容
node.callee.name = '__webpack_require__'
node.arguments = [types.stringLiteral(depModuleId)]
}
}
})
// 上述操作是利用 ast 按要求做了修改,下面的内容就是利用 .... 将修改后的 ast 转回成 code
let { code } = generator(this._ast)
this._source = code
callback(err)
})
}
doBuild(compilation, callback) {
this.getSource(compilation, (err, source) => {
this._source = source
callback()
})
}
getSource(compilation, callback) {
compilation.inputFileSystem.readFile(this.resource, 'utf8', callback)
}
}
module.exports = NormalModule
方法Compilation.js 改造(_addModuleChain的函数体提取成 createModule ,新增 processDependencies 方法):
const { Tapable, SyncHook } = require('tapable')
const async = require('neo-async')
const path = require('path')
const NormalModuleFactory = require('./NormalModuleFactory')
const Parser = require('./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.hooks = {
succeedModule: new SyncHook(['module'])
}
}
// 01
/**
* 完成模块编译的操作
* @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)
})
}
// 02
_addModuleChain(context, entry, name, callback) {
this.createModule(
{
name: name,
context: context,
rawRequest: entry,
resource: path.posix.join(context, entry),
moduleId: './' + path.posix.relative(context, path.posix.join(context, entry)),
parser
}, (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()
})
} else {
callback(err, module)
}
}
this.buildModule(module, afterBuild)
// 当完成了本次的 build 操作后将 module 进行保存
doAddEntry && doAddEntry(module)
this.modules.push(module)
}
// 03
/**
* 完成具体的 build 行为
* @param {*} module 当前需要被编译的模块
* @param {*} callback
*/
buildModule(module, callback) {
module.build(this, (err) => {
// 如果代码走到这里意味着 当前 module 的编译完成了
this.hooks.succeedModule.call(module)
callback(err, module)
})
}
processDependencies(module, callback) {
// 01 当前的函数核心功能就是实现一个被依赖模块的递归加载
// 02 加载模块的思路都是创建一个模块,然后读取被加载模块的内容
// 03 当前不知道 module 需要依赖几个模块,需要让所有被依赖的模块都加载完成载执行 callback [neo-async]
let dependencies = module.dependencies
async.forEach(dependencies, (dependency, done) => {
this.createModule({
name: dependency.name,
context: dependency.context,
rawRequest: dependency.rawRequest,
moduleId: dependency.moduleId,
resource: dependency.resource,
parser,
}, null, done)
}, callback)
}
}
module.exports = Compilation
生成打包文件
生成 chunk 代码
和源代码不同,手写实现是通过模板文件渲染成的,在同一个目录下新建一个 temp 目录,把 webpack 打包的结果作为模板放入一个 ejs 文件,把入口文件模块 Id ,需要加载的模块的 Id 和内容,改成可以动态渲染。
return __webpack_require__(__webpack_require__.s = '<%-entryModuleId%>') // 最后调用 __webpack_require__
// 传入模块数组参数
({
<%for(let module of modules) {%>
"<%-module.moduleId%>":
(function (module, exports, __webpack_require__) {
<%-module._source%>
})
<%}%>
})
Compilation 新增一个方法:
createChunkAssets() {
for (let i = 0; i < this.chunks.length; i++) {
const chunk = this.chunks[i]
const fileName = chunk.name + '.js'
chunk.files.push(fileName)
// 01 生成具体的 chunk 内容
let tempPath = path.posix.join(__dirname, 'temp/main.ejs')
// 02 读取模块文件中的内容
let tempCode = this.inputFileSystem.readFileSync(tempPath, 'utf8')
// 03 获取渲染函数
let tempRender = ejs.compile(tempCode)
// 按 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)
}
生成打包文件
Compile.js 实现 emitAssets 方法。
emitAssets(compilation, callback) {
// 当前要做的核心:创建 dist;执行文件的写入
// 定义一个工具方法用于执行文件的生成操作
const emitFiles = (err) => {
const assets = compilation.assets
let outputPath = this.options.output.path
for (const file in assets) {
let source = assets[file]
let targetPath = path.posix.join(outputPath, file)
this.outputFileSystem.writeFileSync(targetPath, source, 'utf8')
}
callback()
}
// 创建目录,启动文件写入
this.hooks.emit.callAsync(compilation, (err) => {
mkdirp.sync(this.options.output.path)
emitFiles(err)
})
}