本文主要讲解一些 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规范
// src/add.js
exports.default = function(a, b) {return a + b;}
// src/index.js
var add = require('./add.js')
console.log(add(1,5))
复制代码
<!-- 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
读取文件后的代码字符串是不能直接运行的
// 读取到的文件内容,它返回的是一个字符串,并不是一个可执行的语句,比如下面这样:
`exports.default = function(a, b) {return a + b;}`
复制代码
那么,如何使字符串能够变成可执行代码呢?
- 使用
new Function
new Function(`1+5`)
// 等同于
function (){
1+5
}
(new Function(`1+5`))() // 6
复制代码
- 使用
eval
console.log(eval(`1+5`)) //6
复制代码
可以看出,使用 eval
非常简洁方便,所以这里我们使用 eval
来解决。解决第一步后,我们将其放在html的script脚本运行一下:
<!-- 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
的规范的导出写法
// 创建一个exports对象,为了使其符合cjs规范
var exports = {}
eval(`exports.default = function(a, b) {return a + b;}`)
console.log(exports.default(1, 5))
复制代码
这时,刷新页面后再看浏览器已经不报错了,继续
1.3 变量全局污染
如果在导出的文件中,还要一些其它的变量,比如 var a = 1;
之类的,就会造成全局污染 解决:为了避免全局污染,我们使用自执行函数包裹起来,它会为其创建一个独立的作用域,这也是很多框架中会使用到的技巧
// 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
内容拷贝到脚本,看会提示什么错误,再根据错误,一步步去解决
<!-- 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规范)返回
<!-- 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')
,还不能按照参数形式处理,所以我们可以用对象映射方式创建一个文件列表,再套一个自执行函数,以它的参数形式传入
// 文件列表对象大概长这样
{
"index.js": `
var add = require('./add.js')
console.log(add(1,5))
`,
"add.js": `
exports.default = function(a, b) {return a + b;}
`
}
复制代码
<!-- 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
// 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
// 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
,打印了以下信息:
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
)
// 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树: astexplorer.net/ 可以将
index.js
中的代码复制到此网站中,查看详细的AST
树
入口文件内容被放到一个数组中,总共有两个 Node 节点,我们可以看到,每个节点有一个 type 属性,其中第一个 Node 的 type 属性是 ImportDeclaration
,这对应了我们入口文件的import语句,并且,其 source.value 属性是引入这个模块的相对路径,这样我们就得到了入口文件中对打包有用的重要信息了。
接下来要对得到的 AST
做处理,返回一份结构化的数据,方便后续使用。
3. 对模块内容进行处理
3.1 收集依赖
对 ast.program.body
部分数据的获取和处理,本质上就是对这个数组的遍历,在循环中做数据处理,这里同样引入一个babel的模块 @babel/traverse
来完成这项工作。
// 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
完成
// 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
方法就行分析,最终返回一个包含所有模块信息的对象
// 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. 生成最终代码
// 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
方法做相对路径转化为绝对路径
// 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文件即可:
// bundle.js
const content = bundle('./src/index.js')
// 判断有没dist目录,没有就创建
!fs.existsSync("./dist") && fs.mkdirSync("./dist");
// 将打包后的文件写入./dist/bundle.js中
fs.writeFileSync("./dist/bundle.js", content);
复制代码
生成的结果如下:
(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 代码
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);
复制代码
参考链接: