webpack 打包流程:
1.读取入口文件内容
2.分析入口文件,递归读取模块所依赖的文件内容,生成 AST 语法树
3.根据 AST 语法树,生成浏览器能够运行的代码 具体细节: 先配置好 webpack.config.js 文件,创建 add.js 和 minus.js 在 index.js 中引入:
add.js
1 2 3
| export default (a, b) => { return a + b; };
|
minus.js:
1 2 3
| export const minus = (a, b) => { return a - b; };
|
index.js:
1 2 3 4 5 6
| import add from "./add.js"; import { minus } from "./minus.js"; const sum = add(1, 2); const division = minus(2, 1); console.log(sum); console.log(division);
|
index.html:
1 2 3 4 5 6 7 8 9 10 11
| <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Document</title> <script src="../dist/main.js"></script> </head> <body></body> </html>
|
核心类是 WebpackCompiler.js,在构造函数中先获取 entryPath,初始化钩子,plugins 可以设置在不同的编译阶段,先给 webpack 定义五个生命周期,并在 run 方法适当的时机嵌入,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| class WebpackCompiler { constructor(config) { this.config = config; this.depsGraph = {}; this.root = process.cwd(); this.entryPath = this.config.entry; this.hooks = { run: new tapable.SyncHook(), beforeCompile: new tapable.SyncHook(), afterCompile: new tapable.SyncHook(), afterPlugins: new tapable.SyncHook(), afterEmit: new tapable.SyncWaterfallHook(["hash"]), }; const plugins = this.config.plugins; if (Array.isArray(plugins)) { plugins.forEach((plugin) => { plugin.apply.call(plugin, this); }); } run() { this.hooks.run.call(); this.hooks.beforeCompile.call(); this.buildModule(this.entryPath); this.hooks.afterCompile.call(); this.outputFile(); this.hooks.afterPlugins.call(); this.hooks.afterEmit.call(); } }
|
关于 tapable
可以参考这篇文章:https://juejin.cn/post/6955421936373465118 webpack 插件是一种基于 Tapable 的钩子类型,它在特定时机触发钩子时会带上足够的上下文信息,插件定义的钩子回调中,与这些上下文背后的数据结构,接口产生 sideEeffect,进而影响到编译状态和后续流程 自定义一个插件:
1 2 3 4 5 6 7 8
| class MyPlugin { apply(compiler) { compiler.hooks.run.tap("myPlugin", (compilation) => { console.log("my plugin"); }); } } module.exports = MyPlugin;
|
compiler.hooks.run.tap,其中 run 为 tapable 仓库提供的钩子对象,为订阅函数,tap 用于注册回调,关于 tapable 钩子: SyncHook:同步执行,无需返回值 SyncBailHook:同步执行,无需返回值,返回 undefined 终止 SyncWaterfallHook,同步执行,上一个处理函数的返回值时下一个的输入,返回 undefined 终止 SyncLoopHook:同步执行,订阅的处理函数有一个的返回值不是 undefined 就一直循环它 异步钩子: AsyncSeriesHook:异步执行,无需返回值 AsyncParallelHook:异步并行钩子 AsyncSeriesBailHook:异步执行,无需返回值,返回 undefined 终止 …
获取模块内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| getSourceByPath(modulePath) { let content = fs.readFileSync(modulePath, "utf8"); const rules = this.config.module.rules; for (let i = 0; i < rules.length; i++) { let { test, use } = rules[i]; let len = use.length; if (test.test(modulePath)) { function changeLoader() { let loader = require(use[--len]); content = loader(content); if (len > 0) { changeLoader(); } } changeLoader(); } } return content; }
|
分析模块和收集依赖
根据模块被 loader 编译后的内容和路径解析生成 ast 树,这里用@babel/paser 引入模块内容,用到一个选项 sourceType,设置为 module,表示我们要解析的是 ES 模块
遍历 ast 收集依赖,就是用 import 语句引入的文件路径收集起来,将收集起来的路径转换为绝对路径放到 deps 里,遍历 AST 用@babel/traverse 依赖包,第一个参数是 AST,第二个参数是配置对象,最后 ES6 转为 ES5 用@babel/core 和@babel/preset-env
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| parse(source, file) { let ast = parser.parse(source, { sourceType: "module", }); let dependencies = {}; traverse(ast, { ImportDeclaration({ node }) { const dirname = path.dirname(file); const abspath = "./" + path.join(dirname, node.source.value); dependencies[node.source.value] = abspath; }, }); const { code } = babel.transformFromAst(ast, null, { presets: ["@babel/preset-env"], }); const moduleInfo = { file, code, dependencies }; return moduleInfo; }
|
构建模块
1.先传入主模块路径和内容,获得模块信息放到 temp 数组
2.循环里面获得主模块的依赖 deps
3.遍历主模块的依赖 deps,调用 parse 获得依赖模块信息,继续放到 temps 数组中 实际就是将层层依赖进行收集打平
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| buildModule(modulePath) { const source = this.getSourceByPath(modulePath); const entry = this.parse(source, modulePath); const temp = [entry]; for (let i = 0; i < temp.length; i++) { const deps = temp[i].dependencies; if (deps) { for (const key in deps) { if (deps.hasOwnProperty(key)) { let content = this.getSourceByPath(deps[key]); temp.push(this.parse(content, deps[key])); } } } } temp.forEach((moduleInfo) => { this.depsGraph[moduleInfo.file] = { deps: moduleInfo.dependencies, code: moduleInfo.code, }; }); this.depsGraph = JSON.stringify(this.depsGraph); }
|
此时生成的 depsGraph:
1 2 3 4 5 6 7 8 9 10 11 12 13
| { file: './src/index.js', code: '"use strict";\n' + '\n' + 'var _add = _interopRequireDefault(require("./add.js"));\n' + 'var _minus = require("./minus.js");\n' + 'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' + 'var sum = (0, _add["default"])(1, 2);\n' + 'var division = (0, _minus.minus)(2, 1);\n' + 'console.log(sum);\n' + 'console.log(division);', dependencies: { './add.js': './src\\add.js', './minus.js': './src\\minus.js' } }
|
解决浏览器无法识别部分es5代码
但是还不能执行 code 中 index.js 这段代码,因为浏览器不会识别 require 和 exports,因为没有定义这些 require 和 exports 对象,因此要自己定义require函数,由于打包后的代码require的是相对路径,因此之前保存在graph里的dependency派上用场,里面对应的就是哥哥相对路径结点对应的绝对路径,所以执行absReuire去递归调用require,拿到的就是绝对路径,并且这个absRequire作为参数传给立即执行函数,立即执行函数执行eval代码,会去读取定义的absrequire和exports,实际就是闭包
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| bundler(file) { return ` (function (graph) { function require(file) { function absRequire(relPath) { return require(graph[file].deps[relPath]); } var exports = {}; (function (require, exports, code) { eval(code); })(absRequire, exports, graph[file]?.code); return exports; } require('${file}'); })(${this.depsGraph});`; }
|
实际上执行 require(‘./src/index.js’)后,执行
1 2 3
| (function (require, code) { eval(code); })(absRequire, graph[file].code);
|
执行 eval,也就执行 打包后的代码,但是又会调用 require 函数,也就是我们传递的 absRequire,而执行 absRequire 就执行 return require(graph[file].deps[relPath]),前面已经将import的文件路径转为绝对路径将执行外面这个 require,继续周而复始执行立即执行函数,调用 require,路径已经转化为绝对路径,成功执行相应的 eval(code) 但是在执行 add.js 的 code,会遇到 exports 还没定义的问题,
1 2 3 4 5 6 7 8
| "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports["default"] = void 0; var _default = function _default(a, b) { return a + b; }; exports["default"] = _default;
|
定义一个 exports 使用,执行 add.js 代码时会在这个空对象上增加属性并返回 outPutFile():
1 2 3 4 5 6 7 8 9 10 11
| outputFile() { const code = this.bundler(this.entryPath); let outPath = path.join( this.config.output.path, this.config.output.filename ); console.log(code); fs.writeFileSync(outPath, code); }
|
解决重复import
在webpack的源码中,重复import不会被重复打包是通过模块的缓存机制实现的。当模块被导入时,webpack会将模块的路径作为键,模块的内容作为值存储在一个缓存对象中。如果下次再次导入该模块时,webpack会先检查缓存对象中是否已经存在该模块的缓存,如果有就直接使用缓存中的模块内容,避免重复打包。
具体来说,当webpack遇到重复import时,会先解析对应的模块路径,然后从缓存对象中查找是否已经有该模块的缓存。如果有,就直接使用缓存中的模块内容,否则就重新构建该模块,然后将该模块的路径和内容加入缓存对象中。
此外,webpack还提供了一些优化选项,如splitChunks和DllPlugin,可以进一步提高打包性能和效率,避免重复打包。