本文主要讲解一些 webpack 打包核心原理的大概步骤,及手写一个简单的 webpack。

从本质上讲,webpack是现代 JavaScript 应用程序的静态模块打包器。当 webpack 处理你的应用程序时,它会在内部从一个或多个入口点构建一个依赖关系图,然后将您项目所需的每个模块组合成一个或多个bundles,这些 bundles 是用于提供内容的静态资源。

在开始手写之前,我们先来看下为什么要使用webpack呢?我们用个例子来演示热身:

一、不使用webpack会有什么问题?

我们首先建立一个空的项目,使用 npm init -y 快速初始化一个 package.json,然后在根目录下创建 src 目录,src 目录下创建 index.jsadd.js。根目录下创建 index.html,其中 index.html 引入 index.js,在 index.js 引入 add.js

使用es5写法导入导出模块,这里是使用commonjs规范

1
2
3
4
5
6
// src/add.js
exports.default = function(a, b) {return a + b;}

// src/index.js
var add = require('./add.js')
console.log(add(1,5))
1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- index.html -->
<!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`))() // 6
  • 使用 eval
    1
    console.log(eval(`1+5`)) //6

可以看出,使用 eval 非常简洁方便,所以这里我们使用 eval 来解决。解决第一步后,我们将其放在html的script脚本运行一下:

1
2
3
4
5
6
7
8
9
10
<!-- index.html -->

<script>
// 读取到的文件内容
`exports.default = function(a, b) {return a + b;}`
// 第一种运行方式:使用new Function
// (new Function(`exports.default = function(a, b) {return a + b;}`))()
// 第二种运行方式:eval
eval(`exports.default = function(a, b) {return a + b;}`)
</script>

这样会提示一个新的错误 Uncaught ReferenceError: exports is not defined,我们继续往下看

1.2 导出的变量提示不存在

解决:创建一个 exports 对象,这是为了符合 commonjs 的规范的导出写法

1
2
3
4
// 创建一个exports对象,为了使其符合cjs规范
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
// 2. 创建一个exports对象,为了使其符合cjs规范
var exports = {}; // 注意要加分号,否则会提示{} is not a function,它会默认跟下面语句整合
// 1. 使用eval将字符串转化为可执行脚本
// eval(`exports.default = function(a, b) {return a + b;}`)
// 3. 为了避免全局污染
(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
<!-- index.html -->

<script>
var exports = {}; // 注意要加分号,否则会提示{} is not a function,它会默认跟下面语句整合

(function(exports, code) {
eval(code)
})(exports, `exports.default = function(a, b) {return a + b;}`)

// index.js的内容
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
<!-- index.html -->

<script>
// 4. 实现require方法
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
<!-- index.html -->

<script>
var exports = {}; // 注意要加分号,否则会提示{} is not a function,它会默认跟下面语句整合

(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.分析模块

    • 安装 @babel/parser 包(转AST
  • 3.对模块内容进行处理

    • 遍历 AST 收集依赖,安装 @babel/traverse
    • ES6转ES5,安装 @babel/core@babel/preset-env
  • 4.递归所有模块,获取模块依赖图对象

  • 5.生成最终代码

好了,现在我们开始根据上面核心打包原理的思路来实践一下,首先,我们先将所有的代码都改为es6

1
2
3
4
5
6
// src/add.js
export default function(a, b) {return a + b;}

// src/index.js
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
// bundle.js

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
// bundle.js

const fs = require('fs')
const parser = require('@babel/parser')

const getModuleInfo = file => {
const body = fs.readFileSync(file, 'utf-8')
const ast = parser.parse(body, {
// 表示我们要解析的是es6模块
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
// bundle.js

const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default

const getModuleInfo = file => {
// 1. 读取文件
const body = fs.readFileSync(file, 'utf-8')

// 2. 转换AST语法树
const ast = parser.parse(body, {
sourceType: 'module'
})

// 3. 收集依赖
const deps = {}
traverse(ast, {
ImportDeclaration({ node }) {
// 获取当前目录名
const dirname = path.dirname(file);
// 设置绝对路径
// path.sep 自动识别window或linux、mac等系统并转换响应的 斜杠 "/"
const absPath = '.' + path.sep + path.join(dirname, node.source.value)
deps[node.source.value] = absPath
}
})
console.log("deps:",deps) // deps: { './add.js': './src\\add.js' }
}
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
// bundle.js

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 => {
// 1. 读取文件
const body = fs.readFileSync(file, 'utf-8')

// 2. 转换AST语法树
const ast = parser.parse(body, {
sourceType: 'module'
})

// 3. 收集依赖
const deps = {}
traverse(ast, {
ImportDeclaration({ node }) {
// 获取当前目录名
const dirname = path.dirname(file);
// 设置绝对路径
// path.sep 自动识别window或linux、mac等系统并转换响应的 斜杠 "/"
const absPath = '.' + path.sep + path.join(dirname, node.source.value)
deps[node.source.value] = absPath
}
})
console.log("deps:",deps) // deps: { './add.js': './src\\add.js' }

// 4. ES6转换ES5
const { code } = babel.transformFromAst(ast, null, {
presets: ["@babel/preset-env"]
})
console.log("code:", code)

// 5. 输出模块信息
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
// bundle.js

const parseModules = file => {
// 5. 定义依赖图
const depsGraph = {}
// 6. 首先获取入口模块的信息
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) {
// 7. 遍历模块的依赖,递归获取模块信息
for (const key in deps) {
if (Object.hasOwnProperty.call(deps, key)) {
temp.push(getModuleInfo(deps[key]))
}
}
}
}
// 8. 生成最终的依赖对象
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
// bundle.js

// 9. 打包
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
// bundle.js

// 9. 打包
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
// bundle.js

const content = bundle('./src/index.js')
// 判断有没dist目录,没有就创建
!fs.existsSync("./dist") && fs.mkdirSync("./dist");

// 将打包后的文件写入./dist/bundle.js中
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 => {
// 1. 读取文件
const body = fs.readFileSync(file, 'utf-8')

// 2. 转换AST语法树
const ast = parser.parse(body, {
sourceType: 'module'
})

// 3. 收集依赖
const deps = {}
traverse(ast, {
ImportDeclaration({ node }) {
// 获取当前目录名
const dirname = path.dirname(file);
// 设置绝对路径
// path.sep 自动识别window或linux、mac等系统并转换响应的 斜杠 "/"
const absPath = '.' + path.sep + path.join(dirname, node.source.value)
deps[node.source.value] = absPath
}
})
console.log("deps:",deps) // deps: { './add.js': './src\\add.js' }

// 4. ES6转换ES5
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 => {
// 5. 定义依赖图
const depsGraph = {}
// 6. 首先获取入口的信息
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) {
// 7. 遍历模块的依赖,递归获取模块信息
for (const key in deps) {
if (Object.hasOwnProperty.call(deps, key)) {
temp.push(getModuleInfo(deps[key]))
}
}
}
}
// 8. 生成最终的依赖对象
temp.forEach(moduleInfo => {
depsGraph[moduleInfo.file] = {
deps: moduleInfo.deps,
code: moduleInfo.code
}
})
console.log(depsGraph)
return depsGraph
}

// 9. 打包
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)

// 判断有没dist目录,没有就创建
!fs.existsSync("./dist") && fs.mkdirSync("./dist");

// 将打包后的文件写入./dist/bundle.js中
fs.writeFileSync("./dist/bundle.js", content);

参考链接: