随着前端工程化,各种自动化构建工具也越来越多,像早期的 gulp,再到后来的 webpack,以及 Vue 新的 vite 等等,虽然 webpack 用了很长时间,但是还没研究过内部原理,今天就来尝试研究一下原理并且手动实现一个简易的 webpack 并且对代码进行打包。

首先来看一个最基本的代码,我们在项目中,一般最常使用的就是 es6 的 import 语法,比如现在有一个 add.js,还有一个 types.js,以及入口文件 main.js 我们一步一步来拆解分析

add.js

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

types.js

1
2
3
4
// types.js
export const PARAMS_ONE = 1
export const PARAMS_TWO = 2

c.js

1
2
let c = 20
export default c

main.js

1
2
3
4
import { PARAMS_ONE, PARAMS_TWO } from './types'
import add from './add'
console.log(add(PARAMS_ONE, PARAMS_TWO))

webpack 配置如下,这里需要注意,需要 mode 为 development,才能看到打包之后的 bundle 源码,这里的 webpack 版本为 5.24.4

webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
var webpack = require('webpack')
var path = require('path')

module.exports = {
// dirname是当前webpack.config.js的文件路径
entry: path.join(__dirname, 'src/main.js'),
mode: 'development',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
},
}

在 package.json 的 scripts 添加命令

package.json

1
"build": "webpack --config webpack.config.js"

当我们执行npm run build命令时候,在 dist 目录下会生成一个 bundle.js 的文件,里面的内容大概如下
image

what?这里面得一大坨都是什么?不用着急,当我们要去实现一个 webpack 时候,我们自然要先去分析 webpack 打包出来 bundle.js 的代码是什么,是如何运行的,理解原理,我们才能有思路去实现一个 webpack

bundle.js

当我们把打包出来的 bundle.js 代码折叠之后,就会发现,发现其实就是一个匿名自执行函数

1
2
3
4
/******/ ;(() => {
// code....
})()

可以看到,在匿名自执行函数内部,首先申明了一个__webpack_modules__的对象字面量,里面的key是以我们文件的路径作为字段,值是一个箭头函数,里面有三个参数,函数内部是一个 eval 用来处理 webpack 编译之后的代码

1
2
3
4
5
6
7
var __webpack_modules__ = ({

"./src/add.js":((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (/* binding */ add)\n/* harmony export */ });\n/* harmony import */ var _c__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./c */ \"./src/c.js\");\n\nfunction add(a, b) {\n return a + b + _c__WEBPACK_IMPORTED_MODULE_0__.default\n}\n\n\n//# sourceURL=webpack://webpack-demo/./src/add.js?");}),
})

下面还声明了一个函数__webpack_require__,以及三个自执行函数,这三个自执行的匿名函数,执行完之后会在__webpack_require__函数身上添加三个静态方法,分别是d,o以及r

1
2
3
4
5
6
7
8
9
10
11
var __webpack_module_cache__ = {};
function __webpack_require__(moduleId) {
if(__webpack_module_cache__[moduleId]) {
return __webpack_module_cache__[moduleId].exports;
}
var module = __webpack_module_cache__[moduleId] = {
exports: {}
};
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
return module.exports;
}

__webpack_require__挂载静态方法的自执行函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(() => {
__webpack_require__.d = (exports, definition) => {
for(var key in definition) {
...
}
};
})();

(() => {
__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
})();

(() => {
// define __esModule on exports
__webpack_require__.r = (exports) => {
...
};
})();

在 bundle.js 最下方,就是我们的启动函数__webpack_require__,这里调用了__webpack_require__,并且传入了./src/main.js作为函数的参数,也就是在 webpack.config.js 中指定的 entry

1
2
3
4
// startup
// Load entry module and return exports
// This entry module can't be inlined because the eval devtool is used.
var __webpack_exports__ = __webpack_require__("./src/main.js");

现在让我们来尝试分析一下调用这里的函数之后发生的事情。

当我们调用__webpack_require__函数之后,__webpack_require__函数接受一个参数moduleId,第一次调用时候为"./src/main.js",首先对模块做了缓存判断,如果缓存存在,则立即返回当前的exports,否则,把当前模块添加到__webpack_module_cache__中,并且添加一个属性exports,值为空对象,在exports中,会把我们在各个 js 文件中导出的内容挂载在exports上。再往下则通过__webpack_modules__,也就是上面以文件路径作为 key 值的对象字面量,找到对应的moduleId的函数执行,将当前modulemodule.exports就是导出的内容,以及__webpack_require__函数自身作为参数传入,在这里第一次对应执行的函数是main.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var __webpack_module_cache__ = {};
function (moduleId) {
<!--缓存判断-->
if(__webpack_module_cache__[moduleId]) {
return __webpack_module_cache__[moduleId].exports;
}
<!--创建cache缓存-->
var module = __webpack_module_cache__[moduleId] = {

exports: {}
};
<!--找到对应的模块函数执行-->
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
// Return the exports of the module
return module.exports;
}

来看一下__webpack_modules__对应的main.js函数,可以看到,这里是通过eval函数包裹了一个字符串代码来执行的,为什么会通过eval函数包裹来执行呢?这就涉及到了webpack中的devTool,在默认情况下,如果不配置devTool选项,就是默认的eval来执行代码,具体信息可以参见理解 webpack 中的 devTool 的配置项

1
2
3
4
5
"./src/main.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _types__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./types */ \"./src/types.js\");\n/* harmony import */ var _add__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./add */ \"./src/add.js\");\n\n\nconsole.log((0,_add__WEBPACK_IMPORTED_MODULE_1__.default)(_types__WEBPACK_IMPORTED_MODULE_0__.PARAMS_ONE, _types__WEBPACK_IMPORTED_MODULE_0__.PARAMS_TWO))\n\n\n//# sourceURL=webpack://webpack-demo/./src/main.js?");
}),

我们把重要的代码提炼出来,可以看到,开始执行的代码是__webpack_require__.r函数,传入了__webpack_exports__,对应就是上面的module.exports

1
2
3
4
5
6
7
__webpack_require__.r(__webpack_exports__)

var _types__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/types.js")

var _add__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__( "./src/add.js")

console.log((0,_add__WEBPACK_IMPORTED_MODULE_1__.default)(_types__WEBPACK_IMPORTED_MODULE_0__.PARAMS_ONE, _types__WEBPACK_IMPORTED_MODULE_0__.PARAMS_TWO))

__webpack_require__.r函数作用是什么呢?可以看到,最主要的作用其实就是在当前的 exports 中添加了一个__esModule值为true的属性,而这个属性就表示,标识modulees模块

1
2
3
4
5
6
7
8

// define __esModule on exports
__webpack_require__.r = (exports) => {
if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
}
Object.defineProperty(exports, '__esModule', { value: true });
};

之后继续执行下面的代码,继续通过__webpack_require__函数引入了types.jsadd.js,此时再次执行上面创建module,添加缓存的逻辑,执行types.js对应的eval函数,标记为es模块的逻辑

1
2
3
4
var _types__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/types.js")

var _add__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__( "./src/add.js")

在执行完标记es模块之后,这里原来的types.js导出了两个常量,此时被打包为如下

1
2
3
4
5
6
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ "PARAMS_ONE": () => (/* binding */ PARAMS_ONE),
/* harmony export */ "PARAMS_TWO": () => (/* binding */ PARAMS_TWO)
/* harmony export */ });
const PARAMS_ONE = 1
const PARAMS_TWO = 2

通过__webpack_require__.d函数,将上方标记的 exports 模块,以及一个对象作为参数传入,此时的第一个参数就是我们的标记过的es模块

1
2
3
4
5
//__webpack_exports__
{
__esModule: true
}

来看一下__webpack_require__.d函数

1
2
3
4
5
6
7
__webpack_require__.d = (exports, definition) => {
for(var key in definition) {
if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
}
}

首先对我们传入的对象遍历,通过__webpack_require__.o函数判断,属性只能来源与其自身而不是来自于exports,就会通过Object.defineProperty将当前exports中的key设置为我们导出的常量名,值为上面传入的函数

1
2
3
4
5
6
7
8
9
10
__webpack_require__.d = (exports, definition) => {
for(var key in definition) {
if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
}
}
};

// __webpack_require__.o
__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))

遍历完成types.jsexports,最后入口函数返回这个epxorts_types__WEBPACK_IMPORTED_MODULE_0__变量

1
2
3
4
5
6
7
 var _types__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./types */ "./src/types.js");
// 此时的_types__WEBPACK_IMPORTED_MODULE_0__就是
{
__esModule: true,
PARAMS_ONE:1,
PARAMS_TWO:2,
}

同样,在add.js执行完上述逻辑之后,因为是使用的export default,所以执行完成是

1
2
3
4
5
6
7
8
9
var _add__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./add */ "./src/add.js");
// 此时的_add__WEBPACK_IMPORTED_MODULE_1__就是
{
__esModule: true,
default:function(a,b){
<!--这里的_c__WEBPACK_IMPORTED_MODULE_0__.default就是引入的c.js-->
return a+b+_c__WEBPACK_IMPORTED_MODULE_0__.default
}
}

执行完上面的逻辑之后,最后一步就是执行main.js的最后一个函数

1
console.log((0,_add__WEBPACK_IMPORTED_MODULE_1__.default)(_types__WEBPACK_IMPORTED_MODULE_0__.PARAMS_ONE, _types__WEBPACK_IMPORTED_MODULE_0__.PARAMS_TWO))

这里是通过(0,function(a,b){a+b+c})(1,2)这种方法执行的,这里通过逗号操作符来执行函数,最后就会执行出打印结果。

这里最后来梳理一下:

  1. 通过__webpack_require__ 加载入口文件,有缓存使用缓存,没有创建缓存模块,并且执行对应的eval函数
  2. 发现里面有依赖其他模块,继续通过 __webpack_require__引用,回到第一步,直到没有下一级
  3. 将处理好的exports返回,里面是被标记过的es模块和我们在文件中导出的属性
  4. 执行模块函数,运行结束

image

深度优先遍历(Depth-First Search)

可以看到,模块文件形成了一颗树状依赖,并且按照优先纵深查找,这个就叫做(DFS),其实我们可以把文件结构更改一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[{
name: 'main.js',
code: 'eval(xx)',
children: [{
name: 'add.js',
code: 'eval(xx)',
children: [{
name: 'c.js',
code: 'eval(xx)'
}]
},{
name:'types.js',
code:'eval(xx)'
}]
}]

然后通过递归实现深度遍历

1
2
3
4
5
6
7
8
9
10
let nodeLists = []
function depth1(node) {
if (!node) return
nodeLists.push(node)
node.children && node.children.forEach((v) => {
depth1(v)
})
return nodeLists
}