一、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 引入 webpack
const webpack = require('webpack')
//02 引入配置文件
const config = require('./webpack.config')
//03 创建 compiler
const 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)] = value
return 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 = options
this.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 = options
this.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 = options
this.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 获取配置中的所有 loader
const rules = this.options.module.rules
let 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 采用倒序的方式调用 Loader
for (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').default
const generator = require('@babel/generator').default
let 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)
// 修改源代码当中目标模块的 ID
const 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 获取配置中的所有 loader
const rules = this.options.module.rules
let loaders = []
for (let i = 0; i < rules.length; i++) {
if (rules[i].test.test(modulePath)) {
loaders = [...loaders, ...rules[i].use]
}
}
// 2.2 采用倒序的方式调用 Loader
for (let i = loaders.length - 1; i >= 0; i--) {
targetSourceCode = require(loaders[i])(targetSourceCode)
}
// 3 递归编译
// 3.1 获取模块 ID
const 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)
// 修改源代码当中目标模块的 ID
const 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