一、webpack 调试
1const path = require('path')module.exports = {devtool: false,mode: 'development',entry: './src/index.js',output: {filename: '[name].js',path: path.resolve(__dirname, 'dist')},module: {rules: [{test: /\.js$/,use: []}]}}
1.2 入口调试
1.2.1 确定入口文件
- 命令行执行
npm run build会对应 script当中的webpack - 查找 node_modules\bin\webpack.cmd 文件执行 shell 命令
- shell命令中最终确定
webpack\bin\webpack.js - 执行 webpack.js 最终确定
webpack-cli\bin\cli.js
1.2.2 确定入口调用
//01 引入 webpackconst webpack = require('webpack')//02 引入配置文件const config = require('./webpack.config')//03 创建 compilerconst compiler = webpack(config)//04 开启打包compiler.run((err, stats) => {console.log('打包完成')})
二、tapable 介绍
类似于 EventEmitter库,更专注于自定义事件的触发处理
使用 tapable可以将 webpack的具体实现与流程拆分,所有的实现都可以通过插件来实现
2.1 使用
const { SyncHook } = require('tapable')const t = new SyncHook()t.tap('事件1', () => {console.log('事件1发生了,进行处理')})t.tap('事件2', () => {console.log('事件2发生了,进行处理')})t.call()
2.2 模拟
// const { SyncHook } = require('tapable')class SyncHook {constructor() {this.taps = []}tap(name, fn) {this.taps.push(fn)}call() {this.taps.forEach(tap => tap())}}const t = new SyncHook()t.tap('事件1', () => {console.log('事件1发生了,进行处理')})t.tap('事件2', () => {console.log('事件2发生了,进行处理')})t.call()
三、webpack 流程
3.1 整体流程
webpack 打包过程中,会在特点时间点广播特定事件
插件在监听到指定的事件之后会触发具体的处理操作,从而改变 webpack 的打包结果
- 初始参数
- 从配置文件和 shell 语句中读取取与合并参数
- 开始编译
- 使用配置参数初始化 compiler 对象
- 加载配置文件中的插件,调用 run 方法开始执行编译
- 依据配置文件找到所有入口文件
- 编译模块
- 定位配置文件中的 loader,从入口文件开始编译所有模块
- 依据入口文件找到所有依赖模块,使用 loader 进行处理
- 完成编译
- 模块编译动作完成之后得到 loader 处理之后的结果和入口模块及其依赖之间的关系
- 输出资源
- 依据入口模块及其依赖模块之间的关系,组装包含多个模块的 chunk
- 将chunk转换为单独的文件加入到输出列表
- 写入文件
- 确定输出内容
- 确定输出路径及文件名
- 将文件内容写入到文件系统
四、webpack 实现
4.1 流程初始化
在初始化中完成了如下几步:
01 初始化配置参数 未完成(还未合并shell中参数)
02 初始化 Compiler 对象
03 挂载所有配置当中的插件
04 调用run方法开始编译
- 在配置文件中引入自定义插件(类, 包含apply方法,此方法接收 compiler对象)
const RunPlugin = require('./plugins/run-plugin')const DonePlugin = require('./plugins/done-plugin')plugins: [new RunPlugin,new DonePlugin]
- 自定义 webpack 模块,导出函数,接收配置参数,调用之后返回 compiler 对象
const Compiler = require('./Compiler')function webpack(options) {let compiler = new Compiler(options)return compiler}module.exports = webpack
- 调用 webpack 方法的时候加载配置文件当中的所有配置插件(调用apply方法即可)
const Compiler = require('./Compiler')function webpack(options) {let compiler = new Compiler(options)options.plugins.forEach(plugin => plugin.apply(compiler))return compiler}module.exports = webpack
- 自定义 Compiler 类,构造函数接收配置参数,具备 run 方法
class Compiler {constructor(options) {this.options = options}run() {console.log('自定义run方法执地了')}}module.exports = Compiler
- 调用 run 方法开始编译
compiler.run((err, status) => {})
4.2 合并参数
从 shell 命令行当中获取参数与用户配置文件中参数合并
function webpack(options) {//* 获取其它参数与配置文件参数进行合并const shellOptions = process.argv.slice(2).reduce((config, args) => {//? 将 args 以 = 做为分割符进行拆分,然后保存在 {} 当中let [key, value] = args.split('=')config[key.slice(2)] = valuereturn config}, {})const finalOptions = { ...options, ...shellOptions }const compiler = new Compiler(options)options.plugins.forEach(plugin => plugin.apply(compiler))return compiler}
4.4 确定入口
调用 run 方法开始编译,在此之前需要确定入口文件
webpack 内部将 entry 做为对象进行处理
class Compiler {constructor(options) {this.options = options}run() {//* 开始执行编译之后,依据配置确定入口文件const entry = {}if (typeof this.options.entry == 'string') {entry.main = this.options.entry} else {entry = this.options.entry}console.log(`以${entry.main}做为入口开始编译`)}}
4.3 添加钩子
创建 Compiler 实例时组合了一个 hooks对象,它内部实现了多个钩子
在插件内部通过这些钩子注册事件,将来用于触发指定业务逻辑
// 01 compiler实例对象身上添加 hooks 属性class Compiler {constructor(options) {this.options = optionsthis.hooks = {run: new SyncHook(), // 开始编译done: new SyncHook(), // 编译工作结束emit: new SyncHook() // 写入文件系统}}run() {}}// 02 插件内部完成插件挂载和钩子事件注册class DonePlugin {// 通过调用 apply 来挂载插件apply(compiler) {compiler.hooks.done.tap('DonePlugin', () => {console.log('done钩子触发后要做的事情')})}}module.exports = DonePlugin
4.5 添加属性
compiler 做为打包的实际操盘手,需要为最终结果负责,因此除了 hooks 与 options 之外还有许多其它属性
- entries:所有入口模块信息
- modules:所有的模块信息
- chunks:所有代码块
- files:一次编译所有产出的文件名
- assets:一次编译所有产出的资源
- context: 当前工作目录
class Compiler {constructor(options) {this.options = optionsthis.hooks = {}this.entries = new Set()this.modules = new Set()this.chunks = new Set()this.files = new Set()this.assets = {}}run() {}}
4.6 使用loader
从入口文件出发,调用所有配置的 loader 对模块进行编译
编译就是使用fs读取到所有文件内容,然后传给loader进行处理
4.6.1 统一路径
// 01 自定义方法实现路径分割符替换操作const toUnixPath = function (path) {return path.replace(/\\/g, '/')}module.exports = toUnixPath// 02 调用演示const entryPath = toUnixPath(path.join(this.context, entry[entryName]))
4.6.2 初始化编译
class Compiler {constructor(options) {this.options = optionsthis.hooks = {}}run() {//* 开始执行编译之后,依据配置确定入口文件let entry = {}if (typeof this.options.entry == 'string') {entry.main = this.options.entry} else {entry = this.options.entry}//* 确定入口及其依赖,调用loader进行编译for (let entryName in entry) {//* 找到入口文件在哪const entryPath = toUnixPath(path.join(this.context, entry[entryName]))//* 自定义方法实现入口文件编译const entryModule = this.buildModule(entryName, entryPath)}}buildModule(moduleName, modulePath){// 1 读取模块内容// 2 调用 loader 进行处理}}
4.6.3 触发 run 钩子
在实例化 compiler 时创建了 hooks 属性用于管理不同的钩子对象
在实例化 compiler 之后依据需要执行流程中的不同阶段,此时可以触发钩子函数
run() {//* 触发 run.tap() 注册的钩子事件处理器this.hooks.run.call()}
4.7 编译模块
4.7.1 读取模块源码
buildModule(moduleName, modulePath) {// 1 读取目标模块的内容const originalSourceCode = fs.readFileSync(modulePath, 'utf-8')const targetSourceCode = originalSourceCode}
4.7.2 添加 loader
- 新建 loaders 目录,创建自定义 loader(本质是函数)
function loader(source) {console.log('loader1执行了')return source + '--loader1--'}module.exports = loader
- 配置文件中匹配指定文件,采用指定的 loader 进行处理
rules: [{test: /\.js$/,use: [path.resolve(__dirname, 'loaders', 'loader1.js'),path.resolve(__dirname, 'loaders', 'loader2.js')]}]
4.7.3 获取 loader
buildModule(moduleName, modulePath) {// 2 调用 loader 编译目标模块(获取配置中的loader--编译)// 2.1 获取配置中的所有 loaderconst rules = this.options.module.ruleslet loaders = []for (let i = 0; i < rules.length; i++) {if (rules[i].test.test(modulePath)) {loaders = [...loaders, ...rules[i].use]}}}
4.7.4 调用 loader
buildModule(moduleName, modulePath) {// 2.2 采用倒序的方式调用 Loaderfor (let i = loaders.length - 1; i >= 0; i--) {targetSourceCode = require(loaders[i])(targetSourceCode)}console.log(targetSourceCode, 1111)}
4.8 递归编译实现
编译的目的之一是为了产出一个键值对,键是模块ID, 值是当前模块ID对应模块的具体内容
4.8.1 获取模块 ID
// 01 使用 path.relative 获取相对路径const moduleId = './' + path.posix.relative(toUnixPath(this.context), modulePath)console.log(moduleId)
4.8.2 定义容器保存module
定义容器保存将来编译后的模块
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: 类似于监听器,遍历过程中监控到指定的节点后触发函数
const types = require('babel-types')const parser = require('@babel/parser')const traverse = require('@babel/traverse').defaultconst generator = require('@babel/generator').defaultlet ast = parser.parse(targetSourceCode)traverse(ast, {CallExpression: ({ node }) => {// 判断当前节点是不是 require,如果则取出当前导入的目标模块if (node.callee.name == 'require') {// 取出当前要导入的目标模块const currentModuleName = node.arguments[0].value}}})
4.8.4 实现单层编译
先找到当前入口依赖的模块
01 目标模块名称
02 目标模块绝对路径
03 处理语文件后缀
再执行递归操作遍历所有模块
// 01 遍历找到的入口模块let ast = parser.parse(targetSourceCode)traverse(ast, {CallExpression: ({ node }) => {// 判断当前节点是不是 require,如果则取出当前导入的目标模块if (node.callee.name == 'require') {// 取出当前要导入的目标模块const currentModuleName = node.arguments[0].value// 获取上述模块所在的目录,用于拼接它的绝对路径const dirName = path.posix.dirname(modulePath)// 获取上述模块的绝对路径let depModulePath = path.posix.join(dirName, currentModuleName)// 处理文件后缀const extensions = this.options.resolve ? this.options.resolve.extensions : ['.js', '.json', '.jsx']depModulePath = addExtensions(depModulePath, extensions)// 修改源代码当中目标模块的 IDconst depModuleId = './' + path.posix.relative(toUnixPath(this.context), depModulePath)node.arguments = [types.stringLiteral(depModuleId)]// 将依赖的模块信息保存起来module.dependencies.push(depModulePath)}}})// 02 实现 addExtension 方法//TODO: 处理被加载模块的后缀function addExtensions(modulePath, extensions) {// 如果用户导模块的时候自己加了则无需要处理if (path.extname(modulePath) == '.js') return// 如果用户没有则尝试添加for (let i = 0; i < extensions.length; i++) {if (fs.existsSync(modulePath + extensions[i])) {return modulePath + extensions[i]}}// 如果循环结束之后事仍然没有找到则说明目标模块不存在,则抛出异常throw new Error(`${modulePath} 对应的模块不存在`)}
4.8.5 实现递归编译
依据入口找到它的依赖模块 ID ,用于修改具体的值
再生成依赖模块的绝对路径,用于下一次编译
调用 generator 方法将 AST 转回为源代码
递归调用 buildModule 实现所有模块的编译
编译结束之更新 this 身上的 entries 及 modules 的值
// 将 ast 重新生成源代码let { code } = generator(ast)module._source = code // 用于将来输出打包结果// 找到它所依赖的模块执行递归编译module.dependencies.forEach(dependency => {let depModule = this.buildModule(moduleName, dependency)// 每编译一个就将它添加到当前入口的 modules 当中this.modules.add(depModule)})
4.8.6 循环引用处理
在导包过程中有可能会出现 A引用B , B引用C,C又引用A的情况
这个时候只需要在加入 dependencies 之前判断当前被引用模块是否已经存在
const alreadyModuleIds = Array.from(this.modules).map(module => module.id)if (!alreadyModuleIds.includes(depModuleId)) {// 将依赖的模块信息保存起来module.dependencies.push(depModulePath)}
4.8.7 完整 buildModule代码
buildModule(moduleName, modulePath) {// 1 读取目标模块的内容const originalSourceCode = fs.readFileSync(modulePath, 'utf-8')let targetSourceCode = originalSourceCode// 2 调用 loader 编译目标模块(获取配置中的loader--编译)// 2.1 获取配置中的所有 loaderconst rules = this.options.module.ruleslet loaders = []for (let i = 0; i < rules.length; i++) {if (rules[i].test.test(modulePath)) {loaders = [...loaders, ...rules[i].use]}}// 2.2 采用倒序的方式调用 Loaderfor (let i = loaders.length - 1; i >= 0; i--) {targetSourceCode = require(loaders[i])(targetSourceCode)}// 3 递归编译// 3.1 获取模块 IDconst moduleId = './' + path.posix.relative(toUnixPath(this.context), modulePath)// 3.2 定义变量保存将来产出的编译后模块let module = { id: moduleId, dependencies: [], name: moduleName }// 3.3 实现编译let ast = parser.parse(targetSourceCode)traverse(ast, {CallExpression: ({ node }) => {// 判断当前节点是不是 require,如果则取出当前导入的目标模块if (node.callee.name == 'require') {// 取出当前要导入的目标模块const currentModuleName = node.arguments[0].value// 获取上述模块所在的目录,用于拼接它的绝对路径const dirName = path.posix.dirname(modulePath)// 获取上述模块的绝对路径let depModulePath = path.posix.join(dirName, currentModuleName)// 处理文件后缀const extensions = this.options.resolve ? this.options.resolve.extensions : ['.js', '.json', '.jsx']depModulePath = addExtensions(depModulePath, extensions)// 修改源代码当中目标模块的 IDconst depModuleId = './' + path.posix.relative(toUnixPath(this.context), depModulePath)node.arguments = [types.stringLiteral(depModuleId)]const alreadyModuleIds = Array.from(this.modules).map(module => module.id)if (!alreadyModuleIds.includes(depModuleId)) {// 将依赖的模块信息保存起来module.dependencies.push(depModulePath)}}}})// 将 ast 重新生成源代码let { code } = generator(ast)module._source = code // 用于将来输出打包结果// 找到它所依赖的模块执行递归编译module.dependencies.forEach(dependency => {let depModule = this.buildModule(moduleName, dependency)// 每编译一个就将它添加到当前入口的 modules 当中this.modules.add(depModule)})return module}
4.9 组装 chunk
依据入口和模块间的依赖关系,组装包含多个模块的chunk
