通过 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 工作流程.svg
webpack 的编译流程可以看作事件驱动型事件流工作机制。核心的两个部分:负责编译的 compiler 和负责创建 bundles 的 compilation。

手写实现打包

基本框架

webpack库文件关系图.svg

SingleEntryPlugin

SingleEntryPlugin 类专门用于处理单入口,作用是给 make 和 compilation 钩子注册事件。

  1. class SingleEntryPlugin {
  2. constructor(context, entry, name) {
  3. // 挂载上下文、入口文件名、入口文件键名
  4. this.context = context
  5. this.entry = entry
  6. this.name = name
  7. }
  8. apply(compiler) {
  9. compiler.hooks.make.tapAsync('SingleEntryPlugin', (compilation, callback) => {
  10. const { context, entry, name } = this
  11. console.log('make 钩子监听执行了')
  12. // compilation.addEntry(context, entry, name, callback)
  13. })
  14. }
  15. }
  16. module.exports = SingleEntryPlugin

EntryOptionPlugin

EntryOptionPlugin 是用于处理 webpack 入口文件的插件。作用是获取入口文件的路径和名称,传给其它的钩子,并在 make 钩子和 compilation 钩子注册事件监听。
手写实现:

  1. const SingleEntryPlugin = require("./SingleEntryPlugin")
  2. const itemToPlugin = function (context, item, name) {
  3. return new SingleEntryPlugin(context, item, name)
  4. }
  5. class EntryOptionPlugin {
  6. apply(compiler) {
  7. compiler.hooks.entryOption.tap('EntryOptionPlugin', (context, entry) => {
  8. itemToPlugin(context, entry, 'main').apply(compiler)
  9. })
  10. }
  11. }
  12. module.exports = EntryOptionPlugin

Compiler 类 的 Run 方法

run 方法是 webpack 的核心,里面定义了 finalCallback 方法(主要作用是对 callback 方法进行调用)和 onCompiled 方法(编译完成后调用的方法,里面会触发 done 钩子事件并调用 finalCallback 方法),然后依次触发了 beforeRun 和 run 钩子,run 钩子的触发回调函数里调用了 compile 方法,往 compile 传入 onCompiled 方法。

compile 方法

compile 方法包含了编译的过程。

  1. 调用 newCompilationParams 方法,返回 params,其中最主要的是 normalModuleFactory 实例。
  2. 触发 beforeCompile 钩子监听,在它的回调函数里又触发了 compile 钩子监听。
  3. 调用 newCompilation 方法,传入 params,返回一个 compilation 实例。
  4. 触发 make 钩子,传入 compilation 实例。

手写实现

Compiler.js:

  1. const {
  2. Tapable,
  3. SyncHook,
  4. SyncBailHook,
  5. AsyncParallelHook,
  6. AsyncSeriesHook
  7. } = require("tapable")
  8. const Stats = require('./Stats')
  9. const Compilation = require("./Compilation")
  10. const NormalModuleFactory = require("./NormalModuleFactory")
  11. class Compiler extends Tapable {
  12. constructor(context) {
  13. super()
  14. this.context = context
  15. this.hooks = {
  16. done: new AsyncSeriesHook(['stats']),
  17. beforeRun: new AsyncSeriesHook(["compiler"]),
  18. run: new AsyncSeriesHook(["compiler"]),
  19. thisCompilation: new SyncHook(["compilation", "params"]),
  20. compilation: new SyncHook(["compilation", "params"]),
  21. beforeCompile: new AsyncSeriesHook(["params"]),
  22. compile: new SyncHook(["params"]),
  23. make: new AsyncParallelHook(["compilation"]),
  24. afterCompile: new AsyncSeriesHook(["compilation"]),
  25. entryOption: new SyncBailHook(["context", "entry"]),
  26. }
  27. }
  28. run(callback) {
  29. console.log('run 方法执行了')
  30. const finalCallback = function (err, stats) {
  31. return callback(err, stats)
  32. }
  33. const onCompiled = function (err, compilation) {
  34. console.log('onCompiled~')
  35. finalCallback(err, new Stats(compilation))
  36. }
  37. this.hooks.beforeRun.callAsync(this, (err) => {
  38. this.hooks.run.callAsync(this, (err) => {
  39. this.compile(onCompiled)
  40. })
  41. })
  42. }
  43. compile(callback) {
  44. const params = this.newCompilationParams()
  45. this.hooks.beforeRun.callAsync(params, (err) => {
  46. this.hooks.compile.call(params)
  47. const compilation = this.newCompilation(params)
  48. this.hooks.make.callAsync(compilation, (err) => {
  49. console.log('make 钩子监听触发了')
  50. callback(err, compilation)
  51. })
  52. })
  53. }
  54. newCompilationParams() {
  55. return {
  56. normalModuleFactory: new NormalModuleFactory()
  57. }
  58. }
  59. newCompilation(params) {
  60. const compilation = this.createCompilation()
  61. this.hooks.thisCompilation.call(compilation, params)
  62. this.hooks.compilation.call(compilation, params)
  63. return compilation
  64. }
  65. createCompilation() {
  66. return new Compilation(this)
  67. }
  68. }
  69. module.exports = Compiler

Stats.js:

  1. class Stats {
  2. constructor(compilation) {
  3. this.entries = compilation.entries
  4. this.modules = compilation.modules
  5. }
  6. toJson() {
  7. return this
  8. }
  9. }
  10. module.exports = Stats

make 钩子执行前的流程

  1. 一、步骤
  2. 01 实例化 compiler 对象( 它会贯穿整个webpack工作的过程
  3. 02 compiler 调用 run 方法
  4. 二、compiler 实例化操作
  5. 01 compiler 继承 tapable,因此它具备钩子的操作能力(监听事件,触发事件,webpack是一个事件流)
  6. 02 在实例化了 compiler 对象之后就往它的身上挂载很多属性,其中 NodeEnvironmentPlugin 这个操作就让它具备了
  7. 文件读写的能力(我们的模拟时采用的是 node 自带的 fs )
  8. 03 具备了 fs 操作能力之后又将 plugins 中的插件都挂载到了 compiler 对象身上
  9. 04 将内部默认的插件与 compiler 建立关系,其中 EntryOptionPlugin 处理了入口模块的 id
  10. 05 在实例化 compiler 的时候只是监听了 make 钩子(SingleEntryPlugin)
  11. 5-1 SingleEntryPlugin 模块的 apply 方法中有二个钩子监听
  12. 5-2 其中 compilation 钩子就是让 compilation 具备了利用 normalModuleFactory 工厂创建一个普通模块的能力
  13. 5-3 因为它就是利用一个自己创建的模块来加载需要被打包的模块
  14. 5-4 其中 make 钩子 compiler.run 的时候会被触发,走到这里就意味着某个模块执行打包之前的所有准备工作就完成了
  15. 5-5 addEntry 方法调用()
  16. 三、run 方法执行( 当前想看的是什么时候触发了 make 钩子
  17. 01 run 方法里就是一堆钩子按着顺序触发(beforeRun run compile
  18. 02 compile 方法执行
  19. 1 准备参数(其中 normalModuleFactory 是我们后续用于创建模块的)
  20. 2 触发beforeCompile
  21. 3 将第一步的参数传给一个函数,开始创建一个 compilation newCompilation
  22. 4 在调用 newCompilation 的内部
  23. - 调用了 createCompilation
  24. - 触发了 this.compilation 钩子 compilation 钩子的监听
  25. 03 当创建了 compilation 对象之后就触发了 make 钩子
  26. 04 当我们触发 make 钩子监听的时候,将 compilation 对象传递了过去
  27. 四、总结
  28. 1 实例化 compiler
  29. 2 调用 compile 方法
  30. 3 newCompilation
  31. 4 实例化了一个compilation 对象(它和 compiler 是有关系)
  32. 5 触发 make 监听
  33. 6 addEntry 方法(这个时候就带着 context name entry 一堆的东西) 就奔着编译去了.....

addEntry

方法流程

  1. 01 make 钩子在被触发的时候,接收到了 compilation 对象实现,它的身上挂载了很多内容
  2. 02 compilation 当中解构了三个值
  3. entry : 当前需要被打包的模块的相对路径(./src/index.js)
  4. name: main
  5. context: 当前项目的根路径
  6. 03 dep 是对当前的入口模块中的依赖关系进行处理
  7. 04 调用了 addEntry 方法
  8. 05 compilation实例的身上有一个 addEntry 方法,然后内部调用了 _addModuleChain 方法,去处理依赖
  9. 06 compilation 当中我们可以通过 NormalModuleFactory 工厂来创建一个普通的模块对象
  10. 07 webpack 内部默认启了一个 100 并发量的打包操作,当前我们看到的是 normalModule.create()
  11. 08 beforeResolve 里面会触发一个 factory 钩子监听【 这个部分的操作其实是处理 loader 当前我们重点去看
  12. 09 上述操作完成之后获取到了一个函数被存在 factory 里,然后对它进行了调用
  13. 10 在这个函数调用里又触发了一个叫 resolver 的钩子( 处理 loader的,拿到了 resolver方法就意味着所有的Loader 处理完毕
  14. 11 调用 resolver() 方法之后,就会进入到 afterResolve 这个钩子里,然后就会触发 new NormalModule
  15. 12 在完成上述操作之后就将module 进行了保存和一些其它属性的添加
  16. 13 调用 buildModule 方法开始编译---》 调用 build ---》doBuild

手写实现

Compilation.js:

  1. const { Tapable, SyncHook } = require('tapable')
  2. const path = require('path')
  3. const NormalModuleFactory = require('./NormalModuleFactory')
  4. const Parser = require('./Parser')
  5. const normalModuleFactory = new NormalModuleFactory()
  6. const parser = new Parser()
  7. class Compilation extends Tapable {
  8. constructor(compiler) {
  9. super()
  10. this.compiler = compiler
  11. this.context = compiler.context
  12. this.options = compiler.options
  13. // 让 compilation 具备文件读写能力
  14. this.inputFileSystem = compiler.inputFileSystem
  15. this.outputFileSystem = compiler.outputFileSystem
  16. this.entries = [] // 存放所有入口模块的数据
  17. this.modules = [] // 存放所有模块的数据
  18. this.hooks = {
  19. succeedModule: new SyncHook(['module'])
  20. }
  21. }
  22. // 01
  23. /**
  24. * 完成模块编译的操作
  25. * @param {*} context 当前项目根目录
  26. * @param {*} entry 当前入口文件的相对路径
  27. * @param {*} name chunkName main
  28. * @param {*} callback 回调
  29. */
  30. addEntry(context, entry, name, callback) {
  31. this._addModuleChain(context, entry, name, (err, module) => {
  32. callback(err, module)
  33. })
  34. }
  35. // 02
  36. _addModuleChain (context, entry, name, callback) {
  37. let entryModule = normalModuleFactory.create({
  38. name,
  39. context,
  40. rawRequest: entry,
  41. resource: path.posix.join(context, entry), // 当前操作的核心作用就是返回 entry 入口的绝对路径
  42. parser,
  43. })
  44. const afterBuild = function (err) {
  45. callback(err, entryModule)
  46. }
  47. this.buildModule(entryModule, afterBuild)
  48. // 当完成了本次的 build 操作后将 module 进行保存
  49. this.entries.push(entryModule)
  50. this.modules.push(entryModule)
  51. }
  52. // 03
  53. /**
  54. * 完成具体的 build 行为
  55. * @param {*} module 当前需要被编译的模块
  56. * @param {*} callback
  57. */
  58. buildModule(module, callback) {
  59. module.build(this, (err) => {
  60. // 如果代码走到这里意味着 当前 module 的编译完成了
  61. this.hooks.succeedModule.call(module)
  62. callback(err)
  63. })
  64. }
  65. }
  66. module.exports = Compilation

Parser.js:

  1. const babylon = require('babylon')
  2. const { Tapable } = require('tapable')
  3. class Parser extends Tapable {
  4. parse(source) {
  5. return babylon.parse(
  6. source,
  7. {
  8. sourceType: 'module',
  9. plugins: ['dynamicImport'] // 当前插件可以支持 import() 动态导入的语法
  10. }
  11. )
  12. }
  13. }
  14. module.exports = Parser

NormalModuleFactory.js:

  1. const NormalModule = require("./NormalModule")
  2. class NormalModuleFactory {
  3. create(data) {
  4. return new NormalModule(data)
  5. }
  6. }
  7. module.exports = NormalModuleFactory

NormalModule.js:

  1. class NormalModule {
  2. constructor(data) {
  3. this.name = data.name
  4. this.entry = data.entry
  5. this.rawRequest = data.rawRequest
  6. this.parser = data.parser
  7. this.resource = data.resource
  8. this._source // 存放某个模块的源代码
  9. this._ast // 存放某个模块源代码的ast
  10. }
  11. build(compilation, callback) {
  12. /**
  13. * 01 从文件中读取到将来需要被加载的 module 的内容
  14. * 02 如果当前不是 js 模块则需要 loader 进行处理,最终返回 js 模块
  15. * 03 上述的操作完成之后就可以将 js 代码转为 ast 语法树
  16. * 04 当前 js 模块内部可能又引用了很多其他的模块,因此我们需要递归完成
  17. * 05 前面的完成后,只需重复执行即可
  18. */
  19. this.doBuild(compilation, (err) => {
  20. this._ast = this.parser.parse(this._source)
  21. callback(err)
  22. })
  23. }
  24. doBuild(compilation, callback) {
  25. this.getSource(compilation, (err, source) => {
  26. this._source = source
  27. callback()
  28. })
  29. }
  30. getSource(compilation, callback) {
  31. compilation.inputFileSystem.readFile(this.resource, 'utf8', callback)
  32. }
  33. }
  34. module.exports = NormalModule

依赖模块处理

  1. 处理依赖模块的相对路径和后缀,把源代码转成语法树进行一些操作。
  2. 需要将 index.js 里的 require 方法替换成 webpack_require 。对语法树进行修改,之后再转回成源代码。
  3. 实现递归的操作 ,所以要将依赖的模块信息保存好,方例交给下一次 create。

NormalModule.js对 doBuild 调用时的回调函数进行改造:

  1. const path = require('path')
  2. const types = require('@babel/types')
  3. const generator = require('@babel/generator').default
  4. const traverse = require('@babel/traverse').default
  5. class NormalModule {
  6. constructor(data) {
  7. this.context = data.context
  8. this.name = data.name
  9. // this.entry = data.entry
  10. this.rawRequest = data.rawRequest
  11. this.parser = data.parser
  12. this.resource = data.resource
  13. this._source // 存放某个模块的源代码
  14. this._ast // 存放某个模块源代码的ast
  15. this.dependencies = [] // 定义一个空数组用于保存被依赖加载的模块信息
  16. }
  17. build(compilation, callback) {
  18. /**
  19. * 01 从文件中读取到将来需要被加载的 module 的内容
  20. * 02 如果当前不是 js 模块则需要 loader 进行处理,最终返回 js 模块
  21. * 03 上述的操作完成之后就可以将 js 代码转为 ast 语法树
  22. * 04 当前 js 模块内部可能又引用了很多其他的模块,因此我们需要递归完成
  23. * 05 前面的完成后,只需重复执行即可
  24. */
  25. this.doBuild(compilation, (err) => {
  26. this._ast = this.parser.parse(this._source)
  27. // 这里的 _ast 就是当前 module 的语法树,我们可以对它进行修改,最后将 ast 转回成 code 代码
  28. traverse(this._ast, {
  29. CallExpression: (nodePath) => {
  30. let node = nodePath.node
  31. // 定位 require 所在的节点
  32. if (node.callee.name === 'require') {
  33. // 获取原始请求路径
  34. let modulePath = node.arguments[0].value // './title'
  35. // 取出当前被加载的模块名称
  36. let moduleName = modulePath.split(path.posix.sep).pop() // title
  37. // [当前打包器只处理 js]
  38. let extName = moduleName.indexOf('.') == -1 ? '.js' : ''
  39. moduleName += extName // title.js
  40. // [最终想要读取当前 js 的内容] 所以需要绝对路径
  41. let depResource = path.posix.join(path.posix.dirname(this.resource), moduleName)
  42. // [将当前模块的 id 定义 OK]
  43. let depModuleId = './' + path.posix.relative(this.context, depResource) // ./src/title.js
  44. // 记录当前被依赖模块的信息,方便后面递归加载
  45. this.dependencies.push({
  46. name: this.name,
  47. context: this.context,
  48. rawRequest: moduleName,
  49. moduleId: depModuleId,
  50. resource: depResource,
  51. })
  52. // 替换内容
  53. node.callee.name = '__webpack_require__'
  54. node.arguments = [types.stringLiteral(depModuleId)]
  55. }
  56. }
  57. })
  58. // 上述操作是利用 ast 按要求做了修改,下面的内容就是利用 .... 将修改后的 ast 转回成 code
  59. let { code } = generator(this._ast)
  60. this._source = code
  61. callback(err)
  62. })
  63. }
  64. doBuild(compilation, callback) {
  65. this.getSource(compilation, (err, source) => {
  66. this._source = source
  67. callback()
  68. })
  69. }
  70. getSource(compilation, callback) {
  71. compilation.inputFileSystem.readFile(this.resource, 'utf8', callback)
  72. }
  73. }
  74. module.exports = NormalModule

方法Compilation.js 改造(_addModuleChain的函数体提取成 createModule ,新增 processDependencies 方法):

  1. const { Tapable, SyncHook } = require('tapable')
  2. const async = require('neo-async')
  3. const path = require('path')
  4. const NormalModuleFactory = require('./NormalModuleFactory')
  5. const Parser = require('./Parser')
  6. const normalModuleFactory = new NormalModuleFactory()
  7. const parser = new Parser()
  8. class Compilation extends Tapable {
  9. constructor(compiler) {
  10. super()
  11. this.compiler = compiler
  12. this.context = compiler.context
  13. this.options = compiler.options
  14. // 让 compilation 具备文件读写能力
  15. this.inputFileSystem = compiler.inputFileSystem
  16. this.outputFileSystem = compiler.outputFileSystem
  17. this.entries = [] // 存放所有入口模块的数据
  18. this.modules = [] // 存放所有模块的数据
  19. this.hooks = {
  20. succeedModule: new SyncHook(['module'])
  21. }
  22. }
  23. // 01
  24. /**
  25. * 完成模块编译的操作
  26. * @param {*} context 当前项目根目录
  27. * @param {*} entry 当前入口文件的相对路径
  28. * @param {*} name chunkName main
  29. * @param {*} callback 回调
  30. */
  31. addEntry(context, entry, name, callback) {
  32. this._addModuleChain(context, entry, name, (err, module) => {
  33. callback(err, module)
  34. })
  35. }
  36. // 02
  37. _addModuleChain(context, entry, name, callback) {
  38. this.createModule(
  39. {
  40. name: name,
  41. context: context,
  42. rawRequest: entry,
  43. resource: path.posix.join(context, entry),
  44. moduleId: './' + path.posix.relative(context, path.posix.join(context, entry)),
  45. parser
  46. }, (entryModule) => {
  47. this.entries.push(entryModule)
  48. },
  49. callback)
  50. }
  51. /**
  52. * 定义一个创建模块的方法,达到复用的目的
  53. * @param {*} data 创建模块时需要的属性
  54. * @param {*} doAddEntry 可选参数 在加载入口模块的时候,将入口模块的 id 写入 this.entries
  55. * @param {*} callback
  56. */
  57. createModule(data, doAddEntry, callback) {
  58. let module = normalModuleFactory.create(data)
  59. const afterBuild = (err, module) => {
  60. // afterBuild 当中需要判断,当前 module 加载完成后是否需要处理依赖模块
  61. if (module.dependencies.length > 0) {
  62. // 当前逻辑表示 module 有需要加载的依赖模块,可以再单独定义一个方法来实现
  63. this.processDependencies(module, (err) => {
  64. callback()
  65. })
  66. } else {
  67. callback(err, module)
  68. }
  69. }
  70. this.buildModule(module, afterBuild)
  71. // 当完成了本次的 build 操作后将 module 进行保存
  72. doAddEntry && doAddEntry(module)
  73. this.modules.push(module)
  74. }
  75. // 03
  76. /**
  77. * 完成具体的 build 行为
  78. * @param {*} module 当前需要被编译的模块
  79. * @param {*} callback
  80. */
  81. buildModule(module, callback) {
  82. module.build(this, (err) => {
  83. // 如果代码走到这里意味着 当前 module 的编译完成了
  84. this.hooks.succeedModule.call(module)
  85. callback(err, module)
  86. })
  87. }
  88. processDependencies(module, callback) {
  89. // 01 当前的函数核心功能就是实现一个被依赖模块的递归加载
  90. // 02 加载模块的思路都是创建一个模块,然后读取被加载模块的内容
  91. // 03 当前不知道 module 需要依赖几个模块,需要让所有被依赖的模块都加载完成载执行 callback [neo-async]
  92. let dependencies = module.dependencies
  93. async.forEach(dependencies, (dependency, done) => {
  94. this.createModule({
  95. name: dependency.name,
  96. context: dependency.context,
  97. rawRequest: dependency.rawRequest,
  98. moduleId: dependency.moduleId,
  99. resource: dependency.resource,
  100. parser,
  101. }, null, done)
  102. }, callback)
  103. }
  104. }
  105. module.exports = Compilation

生成打包文件

生成 chunk 代码

和源代码不同,手写实现是通过模板文件渲染成的,在同一个目录下新建一个 temp 目录,把 webpack 打包的结果作为模板放入一个 ejs 文件,把入口文件模块 Id ,需要加载的模块的 Id 和内容,改成可以动态渲染。

  1. return __webpack_require__(__webpack_require__.s = '<%-entryModuleId%>') // 最后调用 __webpack_require__
  2. // 传入模块数组参数
  3. ({
  4. <%for(let module of modules) {%>
  5. "<%-module.moduleId%>":
  6. (function (module, exports, __webpack_require__) {
  7. <%-module._source%>
  8. })
  9. <%}%>
  10. })

Compilation 新增一个方法:

  1. createChunkAssets() {
  2. for (let i = 0; i < this.chunks.length; i++) {
  3. const chunk = this.chunks[i]
  4. const fileName = chunk.name + '.js'
  5. chunk.files.push(fileName)
  6. // 01 生成具体的 chunk 内容
  7. let tempPath = path.posix.join(__dirname, 'temp/main.ejs')
  8. // 02 读取模块文件中的内容
  9. let tempCode = this.inputFileSystem.readFileSync(tempPath, 'utf8')
  10. // 03 获取渲染函数
  11. let tempRender = ejs.compile(tempCode)
  12. // 按 ejs 的语法渲染数据
  13. let source = tempRender({
  14. entryModuleId: chunk.entryModule.moduleId,
  15. modules: chunk.modules
  16. })
  17. // 输出文件
  18. this.emitAssets(fileName, source)
  19. }
  20. }
  21. emitAssets(fileName, source) {
  22. this.assets[fileName] = source
  23. this.files.push(fileName)
  24. }

生成打包文件

Compile.js 实现 emitAssets 方法。

  1. emitAssets(compilation, callback) {
  2. // 当前要做的核心:创建 dist;执行文件的写入
  3. // 定义一个工具方法用于执行文件的生成操作
  4. const emitFiles = (err) => {
  5. const assets = compilation.assets
  6. let outputPath = this.options.output.path
  7. for (const file in assets) {
  8. let source = assets[file]
  9. let targetPath = path.posix.join(outputPath, file)
  10. this.outputFileSystem.writeFileSync(targetPath, source, 'utf8')
  11. }
  12. callback()
  13. }
  14. // 创建目录,启动文件写入
  15. this.hooks.emit.callAsync(compilation, (err) => {
  16. mkdirp.sync(this.options.output.path)
  17. emitFiles(err)
  18. })
  19. }

Demo

webpack 手写实现