一、webpack 调试

  1. 1const path = require('path')
  2. module.exports = {
  3. devtool: false,
  4. mode: 'development',
  5. entry: './src/index.js',
  6. output: {
  7. filename: '[name].js',
  8. path: path.resolve(__dirname, 'dist')
  9. },
  10. module: {
  11. rules: [
  12. {
  13. test: /\.js$/,
  14. use: []
  15. }
  16. ]
  17. }
  18. }

1.2 入口调试

1.2.1 确定入口文件

  1. 命令行执行 npm run build 会对应 script当中的 webpack
  2. 查找 node_modules\bin\webpack.cmd 文件执行 shell 命令
  3. shell命令中最终确定 webpack\bin\webpack.js
  4. 执行 webpack.js 最终确定 webpack-cli\bin\cli.js

1.2.2 确定入口调用

  1. //01 引入 webpack
  2. const webpack = require('webpack')
  3. //02 引入配置文件
  4. const config = require('./webpack.config')
  5. //03 创建 compiler
  6. const compiler = webpack(config)
  7. //04 开启打包
  8. compiler.run((err, stats) => {
  9. console.log('打包完成')
  10. })

二、tapable 介绍

类似于 EventEmitter库,更专注于自定义事件的触发处理
使用 tapable可以将 webpack的具体实现与流程拆分,所有的实现都可以通过插件来实现

2.1 使用

  1. const { SyncHook } = require('tapable')
  2. const t = new SyncHook()
  3. t.tap('事件1', () => {
  4. console.log('事件1发生了,进行处理')
  5. })
  6. t.tap('事件2', () => {
  7. console.log('事件2发生了,进行处理')
  8. })
  9. t.call()

2.2 模拟

  1. // const { SyncHook } = require('tapable')
  2. class SyncHook {
  3. constructor() {
  4. this.taps = []
  5. }
  6. tap(name, fn) {
  7. this.taps.push(fn)
  8. }
  9. call() {
  10. this.taps.forEach(tap => tap())
  11. }
  12. }
  13. const t = new SyncHook()
  14. t.tap('事件1', () => {
  15. console.log('事件1发生了,进行处理')
  16. })
  17. t.tap('事件2', () => {
  18. console.log('事件2发生了,进行处理')
  19. })
  20. t.call()

三、webpack 流程

3.1 整体流程

webpack 打包过程中,会在特点时间点广播特定事件
插件在监听到指定的事件之后会触发具体的处理操作,从而改变 webpack 的打包结果

  1. 初始参数
    1. 从配置文件和 shell 语句中读取取与合并参数
  2. 开始编译
    1. 使用配置参数初始化 compiler 对象
    2. 加载配置文件中的插件,调用 run 方法开始执行编译
    3. 依据配置文件找到所有入口文件
  3. 编译模块
    1. 定位配置文件中的 loader,从入口文件开始编译所有模块
    2. 依据入口文件找到所有依赖模块,使用 loader 进行处理
  4. 完成编译
    1. 模块编译动作完成之后得到 loader 处理之后的结果和入口模块及其依赖之间的关系
  5. 输出资源
    1. 依据入口模块及其依赖模块之间的关系,组装包含多个模块的 chunk
    2. 将chunk转换为单独的文件加入到输出列表
  6. 写入文件
    1. 确定输出内容
    2. 确定输出路径及文件名
    3. 将文件内容写入到文件系统

四、webpack 实现

4.1 流程初始化

在初始化中完成了如下几步:
01 初始化配置参数 未完成(还未合并shell中参数)
02 初始化 Compiler 对象
03 挂载所有配置当中的插件
04 调用run方法开始编译

  1. 在配置文件中引入自定义插件(类, 包含apply方法,此方法接收 compiler对象)
  1. const RunPlugin = require('./plugins/run-plugin')
  2. const DonePlugin = require('./plugins/done-plugin')
  3. plugins: [
  4. new RunPlugin,
  5. new DonePlugin
  6. ]
  1. 自定义 webpack 模块,导出函数,接收配置参数,调用之后返回 compiler 对象
  1. const Compiler = require('./Compiler')
  2. function webpack(options) {
  3. let compiler = new Compiler(options)
  4. return compiler
  5. }
  6. module.exports = webpack
  1. 调用 webpack 方法的时候加载配置文件当中的所有配置插件(调用apply方法即可)
  1. const Compiler = require('./Compiler')
  2. function webpack(options) {
  3. let compiler = new Compiler(options)
  4. options.plugins.forEach(plugin => plugin.apply(compiler))
  5. return compiler
  6. }
  7. module.exports = webpack
  1. 自定义 Compiler 类,构造函数接收配置参数,具备 run 方法
  1. class Compiler {
  2. constructor(options) {
  3. this.options = options
  4. }
  5. run() {
  6. console.log('自定义run方法执地了')
  7. }
  8. }
  9. module.exports = Compiler
  1. 调用 run 方法开始编译
  1. compiler.run((err, status) => {
  2. })

4.2 合并参数

从 shell 命令行当中获取参数与用户配置文件中参数合并

  1. function webpack(options) {
  2. //* 获取其它参数与配置文件参数进行合并
  3. const shellOptions = process.argv.slice(2).reduce((config, args) => {
  4. //? 将 args 以 = 做为分割符进行拆分,然后保存在 {} 当中
  5. let [key, value] = args.split('=')
  6. config[key.slice(2)] = value
  7. return config
  8. }, {})
  9. const finalOptions = { ...options, ...shellOptions }
  10. const compiler = new Compiler(options)
  11. options.plugins.forEach(plugin => plugin.apply(compiler))
  12. return compiler
  13. }

4.4 确定入口

调用 run 方法开始编译,在此之前需要确定入口文件
webpack 内部将 entry 做为对象进行处理

  1. class Compiler {
  2. constructor(options) {
  3. this.options = options
  4. }
  5. run() {
  6. //* 开始执行编译之后,依据配置确定入口文件
  7. const entry = {}
  8. if (typeof this.options.entry == 'string') {
  9. entry.main = this.options.entry
  10. } else {
  11. entry = this.options.entry
  12. }
  13. console.log(`以${entry.main}做为入口开始编译`)
  14. }
  15. }

4.3 添加钩子

创建 Compiler 实例时组合了一个 hooks对象,它内部实现了多个钩子
在插件内部通过这些钩子注册事件,将来用于触发指定业务逻辑

  1. // 01 compiler实例对象身上添加 hooks 属性
  2. class Compiler {
  3. constructor(options) {
  4. this.options = options
  5. this.hooks = {
  6. run: new SyncHook(), // 开始编译
  7. done: new SyncHook(), // 编译工作结束
  8. emit: new SyncHook() // 写入文件系统
  9. }
  10. }
  11. run() {}
  12. }
  13. // 02 插件内部完成插件挂载和钩子事件注册
  14. class DonePlugin {
  15. // 通过调用 apply 来挂载插件
  16. apply(compiler) {
  17. compiler.hooks.done.tap('DonePlugin', () => {
  18. console.log('done钩子触发后要做的事情')
  19. })
  20. }
  21. }
  22. module.exports = DonePlugin

4.5 添加属性

compiler 做为打包的实际操盘手,需要为最终结果负责,因此除了 hooks 与 options 之外还有许多其它属性

  • entries:所有入口模块信息
  • modules:所有的模块信息
  • chunks:所有代码块
  • files:一次编译所有产出的文件名
  • assets:一次编译所有产出的资源
  • context: 当前工作目录
  1. class Compiler {
  2. constructor(options) {
  3. this.options = options
  4. this.hooks = {}
  5. this.entries = new Set()
  6. this.modules = new Set()
  7. this.chunks = new Set()
  8. this.files = new Set()
  9. this.assets = {}
  10. }
  11. run() {}
  12. }

4.6 使用loader

从入口文件出发,调用所有配置的 loader 对模块进行编译
编译就是使用fs读取到所有文件内容,然后传给loader进行处理

4.6.1 统一路径

  1. // 01 自定义方法实现路径分割符替换操作
  2. const toUnixPath = function (path) {
  3. return path.replace(/\\/g, '/')
  4. }
  5. module.exports = toUnixPath
  6. // 02 调用演示
  7. const entryPath = toUnixPath(path.join(this.context, entry[entryName]))

4.6.2 初始化编译

  1. class Compiler {
  2. constructor(options) {
  3. this.options = options
  4. this.hooks = {}
  5. }
  6. run() {
  7. //* 开始执行编译之后,依据配置确定入口文件
  8. let entry = {}
  9. if (typeof this.options.entry == 'string') {
  10. entry.main = this.options.entry
  11. } else {
  12. entry = this.options.entry
  13. }
  14. //* 确定入口及其依赖,调用loader进行编译
  15. for (let entryName in entry) {
  16. //* 找到入口文件在哪
  17. const entryPath = toUnixPath(path.join(this.context, entry[entryName]))
  18. //* 自定义方法实现入口文件编译
  19. const entryModule = this.buildModule(entryName, entryPath)
  20. }
  21. }
  22. buildModule(moduleName, modulePath){
  23. // 1 读取模块内容
  24. // 2 调用 loader 进行处理
  25. }
  26. }

4.6.3 触发 run 钩子

在实例化 compiler 时创建了 hooks 属性用于管理不同的钩子对象
在实例化 compiler 之后依据需要执行流程中的不同阶段,此时可以触发钩子函数

  1. run() {
  2. //* 触发 run.tap() 注册的钩子事件处理器
  3. this.hooks.run.call()
  4. }

4.7 编译模块

4.7.1 读取模块源码

  1. buildModule(moduleName, modulePath) {
  2. // 1 读取目标模块的内容
  3. const originalSourceCode = fs.readFileSync(modulePath, 'utf-8')
  4. const targetSourceCode = originalSourceCode
  5. }

4.7.2 添加 loader

  1. 新建 loaders 目录,创建自定义 loader(本质是函数)
  1. function loader(source) {
  2. console.log('loader1执行了')
  3. return source + '--loader1--'
  4. }
  5. module.exports = loader
  1. 配置文件中匹配指定文件,采用指定的 loader 进行处理
  1. rules: [
  2. {
  3. test: /\.js$/,
  4. use: [
  5. path.resolve(__dirname, 'loaders', 'loader1.js'),
  6. path.resolve(__dirname, 'loaders', 'loader2.js')
  7. ]
  8. }
  9. ]

4.7.3 获取 loader

  1. buildModule(moduleName, modulePath) {
  2. // 2 调用 loader 编译目标模块(获取配置中的loader--编译)
  3. // 2.1 获取配置中的所有 loader
  4. const rules = this.options.module.rules
  5. let loaders = []
  6. for (let i = 0; i < rules.length; i++) {
  7. if (rules[i].test.test(modulePath)) {
  8. loaders = [...loaders, ...rules[i].use]
  9. }
  10. }
  11. }

4.7.4 调用 loader

  1. buildModule(moduleName, modulePath) {
  2. // 2.2 采用倒序的方式调用 Loader
  3. for (let i = loaders.length - 1; i >= 0; i--) {
  4. targetSourceCode = require(loaders[i])(targetSourceCode)
  5. }
  6. console.log(targetSourceCode, 1111)
  7. }

4.8 递归编译实现

编译的目的之一是为了产出一个键值对,键是模块ID, 值是当前模块ID对应模块的具体内容

4.8.1 获取模块 ID

  1. // 01 使用 path.relative 获取相对路径
  2. const moduleId = './' + path.posix.relative(toUnixPath(this.context), modulePath)
  3. console.log(moduleId)

4.8.2 定义容器保存module

定义容器保存将来编译后的模块

  1. let module = { id: moduleId, dependencies: [], name: moduleName }

4.8.3 实现 ast 遍历

核心就是利用 babel 提供的相关工具库,实现对源代码内容的替换

  • @babel/parser: 解析器,将源码代码转为 AST
  • @babel/traverse: 遍历器,循环 AST 上的每一个节点(ESM)
  • @babel/generator: 生成器,将 AST 转为源代码(ESM)
  • babel-types: 类似于监听器,遍历过程中监控到指定的节点后触发函数
  1. const types = require('babel-types')
  2. const parser = require('@babel/parser')
  3. const traverse = require('@babel/traverse').default
  4. const generator = require('@babel/generator').default
  5. let ast = parser.parse(targetSourceCode)
  6. traverse(ast, {
  7. CallExpression: ({ node }) => {
  8. // 判断当前节点是不是 require,如果则取出当前导入的目标模块
  9. if (node.callee.name == 'require') {
  10. // 取出当前要导入的目标模块
  11. const currentModuleName = node.arguments[0].value
  12. }
  13. }
  14. })

4.8.4 实现单层编译

先找到当前入口依赖的模块
01 目标模块名称
02 目标模块绝对路径
03 处理语文件后缀
再执行递归操作遍历所有模块

  1. // 01 遍历找到的入口模块
  2. let ast = parser.parse(targetSourceCode)
  3. traverse(ast, {
  4. CallExpression: ({ node }) => {
  5. // 判断当前节点是不是 require,如果则取出当前导入的目标模块
  6. if (node.callee.name == 'require') {
  7. // 取出当前要导入的目标模块
  8. const currentModuleName = node.arguments[0].value
  9. // 获取上述模块所在的目录,用于拼接它的绝对路径
  10. const dirName = path.posix.dirname(modulePath)
  11. // 获取上述模块的绝对路径
  12. let depModulePath = path.posix.join(dirName, currentModuleName)
  13. // 处理文件后缀
  14. const extensions = this.options.resolve ? this.options.resolve.extensions : ['.js', '.json', '.jsx']
  15. depModulePath = addExtensions(depModulePath, extensions)
  16. // 修改源代码当中目标模块的 ID
  17. const depModuleId = './' + path.posix.relative(toUnixPath(this.context), depModulePath)
  18. node.arguments = [types.stringLiteral(depModuleId)]
  19. // 将依赖的模块信息保存起来
  20. module.dependencies.push(depModulePath)
  21. }
  22. }
  23. })
  24. // 02 实现 addExtension 方法
  25. //TODO: 处理被加载模块的后缀
  26. function addExtensions(modulePath, extensions) {
  27. // 如果用户导模块的时候自己加了则无需要处理
  28. if (path.extname(modulePath) == '.js') return
  29. // 如果用户没有则尝试添加
  30. for (let i = 0; i < extensions.length; i++) {
  31. if (fs.existsSync(modulePath + extensions[i])) {
  32. return modulePath + extensions[i]
  33. }
  34. }
  35. // 如果循环结束之后事仍然没有找到则说明目标模块不存在,则抛出异常
  36. throw new Error(`${modulePath} 对应的模块不存在`)
  37. }

4.8.5 实现递归编译

依据入口找到它的依赖模块 ID ,用于修改具体的值
再生成依赖模块的绝对路径,用于下一次编译
调用 generator 方法将 AST 转回为源代码
递归调用 buildModule 实现所有模块的编译
编译结束之更新 this 身上的 entries 及 modules 的值

  1. // 将 ast 重新生成源代码
  2. let { code } = generator(ast)
  3. module._source = code // 用于将来输出打包结果
  4. // 找到它所依赖的模块执行递归编译
  5. module.dependencies.forEach(dependency => {
  6. let depModule = this.buildModule(moduleName, dependency)
  7. // 每编译一个就将它添加到当前入口的 modules 当中
  8. this.modules.add(depModule)
  9. })

4.8.6 循环引用处理

在导包过程中有可能会出现 A引用B , B引用C,C又引用A的情况
这个时候只需要在加入 dependencies 之前判断当前被引用模块是否已经存在

  1. const alreadyModuleIds = Array.from(this.modules).map(module => module.id)
  2. if (!alreadyModuleIds.includes(depModuleId)) {
  3. // 将依赖的模块信息保存起来
  4. module.dependencies.push(depModulePath)
  5. }

4.8.7 完整 buildModule代码

  1. buildModule(moduleName, modulePath) {
  2. // 1 读取目标模块的内容
  3. const originalSourceCode = fs.readFileSync(modulePath, 'utf-8')
  4. let targetSourceCode = originalSourceCode
  5. // 2 调用 loader 编译目标模块(获取配置中的loader--编译)
  6. // 2.1 获取配置中的所有 loader
  7. const rules = this.options.module.rules
  8. let loaders = []
  9. for (let i = 0; i < rules.length; i++) {
  10. if (rules[i].test.test(modulePath)) {
  11. loaders = [...loaders, ...rules[i].use]
  12. }
  13. }
  14. // 2.2 采用倒序的方式调用 Loader
  15. for (let i = loaders.length - 1; i >= 0; i--) {
  16. targetSourceCode = require(loaders[i])(targetSourceCode)
  17. }
  18. // 3 递归编译
  19. // 3.1 获取模块 ID
  20. const moduleId = './' + path.posix.relative(toUnixPath(this.context), modulePath)
  21. // 3.2 定义变量保存将来产出的编译后模块
  22. let module = { id: moduleId, dependencies: [], name: moduleName }
  23. // 3.3 实现编译
  24. let ast = parser.parse(targetSourceCode)
  25. traverse(ast, {
  26. CallExpression: ({ node }) => {
  27. // 判断当前节点是不是 require,如果则取出当前导入的目标模块
  28. if (node.callee.name == 'require') {
  29. // 取出当前要导入的目标模块
  30. const currentModuleName = node.arguments[0].value
  31. // 获取上述模块所在的目录,用于拼接它的绝对路径
  32. const dirName = path.posix.dirname(modulePath)
  33. // 获取上述模块的绝对路径
  34. let depModulePath = path.posix.join(dirName, currentModuleName)
  35. // 处理文件后缀
  36. const extensions = this.options.resolve ? this.options.resolve.extensions : ['.js', '.json', '.jsx']
  37. depModulePath = addExtensions(depModulePath, extensions)
  38. // 修改源代码当中目标模块的 ID
  39. const depModuleId = './' + path.posix.relative(toUnixPath(this.context), depModulePath)
  40. node.arguments = [types.stringLiteral(depModuleId)]
  41. const alreadyModuleIds = Array.from(this.modules).map(module => module.id)
  42. if (!alreadyModuleIds.includes(depModuleId)) {
  43. // 将依赖的模块信息保存起来
  44. module.dependencies.push(depModulePath)
  45. }
  46. }
  47. }
  48. })
  49. // 将 ast 重新生成源代码
  50. let { code } = generator(ast)
  51. module._source = code // 用于将来输出打包结果
  52. // 找到它所依赖的模块执行递归编译
  53. module.dependencies.forEach(dependency => {
  54. let depModule = this.buildModule(moduleName, dependency)
  55. // 每编译一个就将它添加到当前入口的 modules 当中
  56. this.modules.add(depModule)
  57. })
  58. return module
  59. }

4.9 组装 chunk

依据入口和模块间的依赖关系,组装包含多个模块的chunk