本文主要讲解一些 webpack 打包核心原理的大概步骤,及手写一个简单的 webpack。
从本质上讲,webpack是现代 JavaScript 应用程序的静态模块打包器。当 webpack 处理你的应用程序时,它会在内部从一个或多个入口点构建一个依赖关系图,然后将您项目所需的每个模块组合成一个或多个bundles,这些 bundles 是用于提供内容的静态资源。
在开始手写之前,我们先来看下为什么要使用webpack呢?我们用个例子来演示热身:
一、不使用webpack会有什么问题?
我们首先建立一个空的项目,使用 npm init -y
快速初始化一个 package.json
,然后在根目录下创建 src
目录,src
目录下创建 index.js
,add.js
。根目录下创建 index.html
,其中 index.html
引入 index.js
,在 index.js
引入 add.js
。
使用es5写法导入导出模块,这里是使用commonjs规范
1 2 3 4 5 6
| exports.default = function(a, b) {return a + b;}
var add = require('./add.js') console.log(add(1,5))
|
1 2 3 4 5 6 7 8 9 10 11 12 13
| <!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> </head> <body> <script src="./src/index.js"></script> </body> </html>
|
当我们在浏览器打开 index.html
后,控制台会提示 Uncaught ReferenceError: require is not defined
我们直接使用模块化开发时,可以看到浏览器并不识别commonjs的模块化用法,会提示 require is not defined
,这就不利于我们进行工程化开发了,所以 webpack 最核心解决的问题,就是读取这些文件,按照模块间依赖关系,重新组装成可以运行的脚本。
webpack是怎么解决这个问题的呢?
二、实现原始打包代码
先看下它有几个问题和它们各自的解决方案:
1. 加载子模块
往往模块是别的库(比如nodejs),用的commonjs来写的,那么我们就要处理加载模块的问题:
1.1 读取子模块 add.js
读取文件后的代码字符串是不能直接运行的
1 2
| `exports.default = function(a, b) {return a + b;}`
|
那么,如何使字符串能够变成可执行代码呢?
- 使用
new Function
1 2 3 4 5 6
| new Function(`1+5`)
function (){ 1+5 } (new Function(`1+5`))()
|
- 使用
eval
1
| console.log(eval(`1+5`))
|
可以看出,使用 eval
非常简洁方便,所以这里我们使用 eval
来解决。解决第一步后,我们将其放在html的script脚本运行一下:
1 2 3 4 5 6 7 8 9 10
|
<script> `exports.default = function(a, b) {return a + b;}` eval(`exports.default = function(a, b) {return a + b;}`) </script>
|
这样会提示一个新的错误 Uncaught ReferenceError: exports is not defined
,我们继续往下看
1.2 导出的变量提示不存在
解决:创建一个 exports
对象,这是为了符合 commonjs
的规范的导出写法
1 2 3 4
| var exports = {} eval(`exports.default = function(a, b) {return a + b;}`) console.log(exports.default(1, 5))
|
这时,刷新页面后再看浏览器已经不报错了,继续
1.3 变量全局污染
如果在导出的文件中,还要一些其它的变量,比如 var a = 1;
之类的,就会造成全局污染
解决:为了避免全局污染,我们使用自执行函数包裹起来,它会为其创建一个独立的作用域,这也是很多框架中会使用到的技巧
1 2 3 4 5 6 7 8 9
| var exports = {};
(function(exports, code) { eval(code) })(exports, `exports.default = function(a, b) {return a + b;}`) console.log(exports.default(1, 5))
|
2. 实现加载模块
这一步,是实现 index.js
中,调用子模块中方法,并执行的步骤,我们可以先将 index.js
内容拷贝到脚本,看会提示什么错误,再根据错误,一步步去解决
1 2 3 4 5 6 7 8 9 10 11 12 13
|
<script> var exports = {};
(function(exports, code) { eval(code) })(exports, `exports.default = function(a, b) {return a + b;}`)
var add = require('./add.js') console.log(add(1,5)) </script>
|
此时控制台会提示 Uncaught ReferenceError: require is not defined
解决方法是:自己模拟实现一个 require
方法,在刚刚的立即执行函数外,封装一个 require
方法,并将 exports.default
(也就是 add
方法,这里写成exports.default
也是为了符合cjs规范)返回
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
<script> function require(file) { (function(exports, code) { eval(code) })(exports, `exports.default = function(a, b) {return a + b;}`) return exports.default; } var add = require('./add.js'); console.log(add(1,3)) </script>
|
此时刷新浏览器,控制台已经打印出来了结果 6
。
3. 文件读取
这时的文件是写死的,require('./add.js')
,还不能按照参数形式处理,所以我们可以用对象映射方式创建一个文件列表,再套一个自执行函数,以它的参数形式传入
1 2 3 4 5 6 7 8 9 10
| { "index.js": ` var add = require('./add.js') console.log(add(1,5)) `, "add.js": ` exports.default = function(a, b) {return a + b;} ` }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
<script> var exports = {}; (function(list) { function require(file) { (function(exports, code) { eval(code) })(exports, list[file]) return exports.default; } require('./index.js') })({ "./index.js": ` var add = require('./add.js') console.log(add(1,5)) `, "./add.js": ` exports.default = function(a, b) {return a + b;} ` }) </script>
|
此时刷新浏览器,控制台的打印还是 6
。成功了!!!
上面这些代码就是我们平常用 webpack
打包后看到的那一堆看都不想看的结果了=’=(也就是万恶的 bundle.js
),这就是一个 webpack
最小模块打包的雏形了
好了,经过这一套操作写来,你可能对 webpack
的原理有了基本的认识。
三、手写 webpack 打包工具
webpack 核心打包原理如下:
1.获取主模块内容
2.分析模块
3.对模块内容进行处理
- 遍历
AST
收集依赖,安装 @babel/traverse
包
- ES6转ES5,安装
@babel/core
和 @babel/preset-env
包
4.递归所有模块,获取模块依赖图对象
5.生成最终代码
好了,现在我们开始根据上面核心打包原理的思路来实践一下,首先,我们先将所有的代码都改为es6
1 2 3 4 5 6
| export default function(a, b) {return a + b;}
import add from './add.js' console.log(add(1,5))
|
1. 获取主模块内容
我们在根目录创建一个 bundle.js
文件,既然要读取文件内容,我们需要用到node.js的核心模块 fs
1 2 3 4 5 6 7 8 9
|
const fs = require('fs')
const getModuleInfo = file => { const body = fs.readFileSync(file, 'utf-8') console.log(body) } getModuleInfo('./src/index.js')
|
我们首先来看读到的内容是什么,在根目录执行命令 node bundule.js
,打印了以下信息:
1 2
| import add from './add.js' console.log(add(1,5))
|
我们可以看到,入口文件 index.js 的所有内容都以字符串形式输出了,我们接下来可以借助babel提供的功能,来完成入口文件的分析。
2. 分析模块
我们安装 @babel/parser
,演示时安装的版本号为 ^7.9.6
这个babel模块的作用,就是把我们js文件的代码内容,转换成js对象的形式,这种形式的js对象,称做抽象语法树
(Abstract Syntax Tree, 以下简称AST
)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
const fs = require('fs') const parser = require('@babel/parser')
const getModuleInfo = file => { const body = fs.readFileSync(file, 'utf-8') const ast = parser.parse(body, { sourceType: 'module' }) console.log(ast) console.log(ast.program.body) } getModuleInfo('./src/index.js')
|
我们打印出了ast,注意文件内容是在ast.program.body中,如下图所示:
体验AST树: https://astexplorer.net/
可以将 index.js
中的代码复制到此网站中,查看详细的 AST
树
入口文件内容被放到一个数组中,总共有两个 Node 节点,我们可以看到,每个节点有一个 type 属性,其中第一个 Node 的 type 属性是 ImportDeclaration
,这对应了我们入口文件的import语句,并且,其 source.value 属性是引入这个模块的相对路径,这样我们就得到了入口文件中对打包有用的重要信息了。
接下来要对得到的 AST
做处理,返回一份结构化的数据,方便后续使用。
3. 对模块内容进行处理
3.1 收集依赖
对 ast.program.body
部分数据的获取和处理,本质上就是对这个数组的遍历,在循环中做数据处理,这里同样引入一个babel的模块 @babel/traverse
来完成这项工作。
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
|
const fs = require('fs') const path = require('path') const parser = require('@babel/parser') const traverse = require('@babel/traverse').default
const getModuleInfo = file => { const body = fs.readFileSync(file, 'utf-8')
const ast = parser.parse(body, { sourceType: 'module' })
const deps = {} traverse(ast, { ImportDeclaration({ node }) { const dirname = path.dirname(file); const absPath = '.' + path.sep + path.join(dirname, node.source.value) deps[node.source.value] = absPath } }) console.log("deps:",deps) } getModuleInfo('./src/index.js')
|
创建一个对象 deps
,用来收集模块自身引入的依赖,使用traverse遍历ast,我们只需要对ImportDeclaration的节点做处理,注意我们做的处理实际上就是把相对路径转化为绝对路径,这时候,就可以看到 index.js
的依赖文件为 add.js
了
3.2 ES6 转 ES5
安装 @babel/core
@babel/preset-env
,演示时安装的版本号均为 ^7.9.6
获取依赖之后,我们需要对 ast
做语法转换,把 ES6
的语法转化为 ES5
的语法,使用babel核心模块 @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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
|
const fs = require('fs') const path = require('path') const parser = require('@babel/parser') const traverse = require('@babel/traverse').default const babel = require("@babel/core");
const getModuleInfo = file => { const body = fs.readFileSync(file, 'utf-8')
const ast = parser.parse(body, { sourceType: 'module' })
const deps = {} traverse(ast, { ImportDeclaration({ node }) { const dirname = path.dirname(file); const absPath = '.' + path.sep + path.join(dirname, node.source.value) deps[node.source.value] = absPath } }) console.log("deps:",deps)
const { code } = babel.transformFromAst(ast, null, { presets: ["@babel/preset-env"] }) console.log("code:", code)
const moduleInfo = { file, deps, code } console.log(moduleInfo) return moduleInfo } getModuleInfo('./src/index.js')
|
如下图所示,我们最终把一个模块的代码,转化为一个对象形式的信息,这个对象包含文件的绝对路径,文件所依赖模块的信息,以及模块内部经过babel转化后的代码
4. 递归所有模块,获取依赖对象
这个过程,也就是获取依赖图(dependency graph)
的过程,这个过程就是从入口模块开始,对每个模块以及模块的依赖模块都调用 getModuleInfo
方法就行分析,最终返回一个包含所有模块信息的对象
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
|
const parseModules = file => { const depsGraph = {} const entry = getModuleInfo(file) const temp = [entry] for (let i = 0; i < temp.length; i++) { const item = temp[i] const deps = item.deps if (deps) { for (const key in deps) { if (Object.hasOwnProperty.call(deps, key)) { temp.push(getModuleInfo(deps[key])) } } } } temp.forEach(moduleInfo => { depsGraph[moduleInfo.file] = { deps: moduleInfo.deps, code: moduleInfo.code } }) console.log(depsGraph) return depsGraph } parseModules('./src/index.js')
|
获得的 depsGraph
依赖对象如下图:
有没有发现,这个依赖对象很像我们第二大节 二、实现原始打包代码
中第三小节中,我们自定义的 文件列表
。所以接下来我们将 depsGraph
和我们上面编写的打包代码组合一下。
5. 生成最终代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
|
const bundle = file => { const depsGraph = JSON.stringify(parseModules(file)) return ` (function (graph) { function require(file) { var exports = {}; (function (exports,code) { eval(code) })(exports,graph[file].code) return exports } require('${file}') })(${depsGraph})`; }
const content = bundle('./src/index.js') console.log(content)
|
上面的写法是有问题的,我们需要对file做绝对路径转化,否则 graph[file].code
是获取不到的,定义 adsRequire
方法做相对路径转化为绝对路径
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
|
const bundle = file => { const depsGraph = JSON.stringify(parseModules(file)) return ` (function (graph) { function require(file) { var exports = {}; function absRequire(relPath){ return require(graph[file].deps[relPath]) } (function (require,exports,code) { eval(code) })(absRequire,exports,graph[file].code) return exports } require('${file}') })(${depsGraph})`; }
const content = bundle('./src/index.js') console.log(content)
|
生成的内容如图所示:
接下来,我们只需要把生成的内容写入一个JavaScript文件即可:
1 2 3 4 5 6 7 8
|
const content = bundle('./src/index.js')
!fs.existsSync("./dist") && fs.mkdirSync("./dist");
fs.writeFileSync("./dist/bundle.js", content);
|
生成的结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| (function (graph) { function require(file) { var exports = {}; function absRequire(relPath){ return require(graph[file].deps[relPath]) } (function (require,exports,code) { eval(code) })(absRequire,exports,graph[file].code) return exports } require('./src/index.js') })({"./src/index.js":{"deps":{"./add.js":".\\src\\add.js"},"code":"\"use strict\";\n\nvar _add = _interopRequireDefault(require(\"./add.js\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\nconsole.log((0, _add[\"default\"])(1, 5));"},".\\src\\add.js":{"deps":{},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports[\"default\"] = _default;\n\nfunction _default(a, b) {\n return a + b;\n}"}})
|
最后,我们在index.html引入这个./dist/bundle.js文件,我们可以看到控制台正确输出了我们想要的结果。
6. 完整 bundle.js 代码
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 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97
| const fs = require('fs') const path = require('path') const parser = require('@babel/parser') const traverse = require('@babel/traverse').default const babel = require("@babel/core");
const getModuleInfo = file => { const body = fs.readFileSync(file, 'utf-8')
const ast = parser.parse(body, { sourceType: 'module' })
const deps = {} traverse(ast, { ImportDeclaration({ node }) { const dirname = path.dirname(file); const absPath = '.' + path.sep + path.join(dirname, node.source.value) deps[node.source.value] = absPath } }) console.log("deps:",deps)
const { code } = babel.transformFromAst(ast, null, { presets: ["@babel/preset-env"] }) console.log("code:", code)
const moduleInfo = { file, deps, code } console.log(moduleInfo) return moduleInfo }
const parseModules = file => { const depsGraph = {} const entry = getModuleInfo(file) const temp = [entry] for (let i = 0; i < temp.length; i++) { const item = temp[i] const deps = item.deps if (deps) { for (const key in deps) { if (Object.hasOwnProperty.call(deps, key)) { temp.push(getModuleInfo(deps[key])) } } } } temp.forEach(moduleInfo => { depsGraph[moduleInfo.file] = { deps: moduleInfo.deps, code: moduleInfo.code } }) console.log(depsGraph) return depsGraph }
const bundle = file => { const depsGraph = JSON.stringify(parseModules(file)) return ` (function (graph) { function require(file) { var exports = {}; function absRequire(relPath){ return require(graph[file].deps[relPath]) } (function (require,exports,code) { eval(code) })(absRequire,exports,graph[file].code) return exports } require('${file}') })(${depsGraph})`; }
const content = bundle('./src/index.js') console.log(content)
!fs.existsSync("./dist") && fs.mkdirSync("./dist");
fs.writeFileSync("./dist/bundle.js", content);
|
参考链接: