简介
众所周知,webpack 能够进行项目代码的打包,编译等,将各个模块的依赖进行编译为执行文件,因此这个打包的流程也是相当复杂的,文本将通过一个实战一个 Mini Webpack 的方式来理解 Webpack 是如何对项目进行打包的。
初始化
// main.js
import add from './add.js';
import minus from './minus.js';
import { data } from './data.js';
const result_1 = add(1, 2);
const result_2 = minus(2, 1);
console.log('result_1: ', result_1);
console.log('result_2: ', result_2);
console.log('data: ', data);
// add.js
function add(a, b) {
return a + b;
}
export default add;
// minus.js
function minus(a, b) {
return a - b;
}
export default minus;
// data.js
export const data = 'My name is 小滨'
以上的代码中,按照现在游览器,使用 type="module" 也是可以有办法识别 import 语法的,当然需要一个服务支持,例如:Vite
但对于 Webpack 来说,都是进行打包转义,使其能够被游览能够识别和运行的,下面就开始来看看如何将上述的代码进行打包。
读取文件
首先,如果要进行打包,那第一步当然需要能够读取到即将要打包的代码内容,否者无从谈起,那一般来说我们读取可能是有一个入口文件,这里入口文件就是 main.js,通过 Node fs 模块读取文件。
/**
* 分析模块
*/
function getModuleInfo(file) {
// 读取文件, 这里需要加上 uft-8,否者返回 Buffer 类型
const body = fs.readFileSync(file, 'uft-8');
console.log(body);
}
getModuleInfo('./src/main.js')
读取到文件后,接下来就要对内容进行分析了,这里读取到的内容无非就是长长的字符串,对字符串进行分析,想当然肯定要用到正则了,并且最好能够得到一个有一定结构的数据才能方便分析,就像 Vue、React 这些框架一样,将写的伪 Html 代码转换成 AST 树、Virtual 树等。
这里其实就是对读取到的内容,将其转换成 AST 树,下面就来试试转成 AST 树。
转化成 AST 语法树
换成成 AST 树,并不是一个简单的事情,这里就不从新造轮子,而是使用 babel 的库来对代码进行动手术,转换成 AST 树。
function getModuleInfo(file) {
// 读取文件
// ...
// 转换为 AST 树,采用的是 @babel/parser
// 文档:https://babeljs.io/docs/en/babel-parser
const ast = parser.parse(body, {
sourceType: 'module', // 表示我们要解析的是 ES 模块
})
console.log(ast);
}
通过 @babel/parser 库将内容转换成了 AST 树,得到的结果大概长在这样:
Node {
type: 'File',
// ...
program: Node {
// ...
body: [
ImportDeclaration: {
type: 'ImportDeclaration',
// ...
source: {
type: 'Literal',
// ...
value: './add'
}
},
// ...
ExpressionStatement: {}
// ...
],
directives: []
},
comments: []
}
上面 AST 树省略了很多,其中最最重要的就是 body.source.value 的内容,这个内容记载了代码中的依赖关系。
说到依赖关系,接下来就是对 AST 树进行分析,找出入口文件所依赖的其他模块,这样才能相对应的引入入口文件所需要的其他模块的方法或者数据。
从上面的 AST 中,可以获取到当前分析文件所需要的依赖模块入口,但是需要分析 AST 树,肯定是需要遍历递归的,这工作量也是不小的,在这里继续使用 babel 的库 @babel/traverse 来进行依赖收集的工作
依赖收集
const fs = require('fs');
const path = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse')
/**
* 分析模块
*/
function getModuleInfo(file) {
// 读取文件
// ...
// 转换为 AST 树,采用的是 @babel/parser
// 文档:https://babeljs.io/docs/en/babel-parser
// ...
// 收集依赖
const deps = {};
// 文档:https://babeljs.io/docs/en/babel-traverse
traverse.default(ast, {
// visitor 函数,解析 import 语法糖
ImportDeclaration({ node }) {
const dirname = path.dirname(file); // 获取文件目录 ./src
const abspath = './' + path.join(dirname, node.source.value); // 拼接地址 ./src/add
deps[node.source.value] = abspath;
}
})
/**
* 输出:
* {
* './add': './src/add',
* './minus': './src/minus',
* './data': './src/data'
* }
*/
console.log(deps)
}
通过使用 @babel/traverse 对 AST 树进行分析,获取依赖关系。
这里有一个小坑的是 @babel/traverse 按照官方的写法是有一点小问题的,需要使用 traverse.default() 才行
到此,从上面的处理过程中,可以得到了文件的内容和内容里面的模块的依赖关系了。但是还有一个需要处理的是,从哪个上面读取文件的内容中,可以看到里面是 ES6 的写法 import 虽然对于现代游览器来说,可以加上 type="module" 识别这种类型,但是在 webpack 的处理中,为了更好的兼容,是会吧 ES6 写法进行转换的,比如:import 会被转换成 require,
下面,还是采用 babel 进行转换,使用的是:@babel/code
ES6 转换成 ES5
const fs = require('fs');
const path = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse');
const babel = require("@babel/core");
/**
* 分析模块
*/
function getModuleInfo(file) {
// 读取文件
// ...
// 转换为 AST 树,采用的是 @babel/parser
// 文档:https://babeljs.io/docs/en/babel-parser
// ...
// 收集依赖
// ...
// ES6 语法转换 ES5
// https://www.babeljs.cn/docs/babel-core
// https://www.babeljs.cn/docs/babel-preset-env
const code = babel.transformFromAst(ast, null, {
presets: ['@babel/preset-env'] // 根据当前环境使用兼容转换
})
console.log(code);
}
这样就能够将 ES6 进行转换了,转换的结果如下:
{
metadata: {},
options: {
// ...
code: '"use strict";\n' +
'\n' +
'var _add = _interopRequireDefault(require("./add"));\n' +
'\n' +
'var _minus = _interopRequireDefault(require("./minus"));\n' +
'\n' +
'var _data = require("./data");\n' +
'\n' +
'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +
'\n' +
'var result_1 = (0, _add["default"])(1, 2);\n' +
'var result_2 = (0, _minus["default"])(2, 1);\n' +
"console.log('result_1: ', result_1);\n" +
"console.log('result_2: ', result_2);\n" +
"console.log('data: ', _data.data);",
// ...
}
最后 getModuleInfo 就算完成了,将分析后的内容进行返回了:
function getModuleInfo(file) {
// 读取文件
// ...
// 转换为 AST 树,采用的是 @babel/parser
// 文档:https://babeljs.io/docs/en/babel-parser
// ...
// 收集依赖
// ...
// ES6 语法转换 ES5
// ...
const moduleInfo = { file, code, deps };
return moduleInfo;
}
目前,我们只对一个入口文件进行了解析,入口文件汇总所依赖的其他模块,肯定也是要一一进行读取内容,然后进行解析的,其实也就是重新再走一遍入口文件所走过的路,那这里其实就是从入口文件的依赖关系中遍历递归就行了。
递归解析依赖
function getDeps(temp, entry) {
const { deps } = entry;
Object.keys(deps).forEach(key => {
const child = getModuleInfo(deps[key]);
temp.push(child);
getDeps(temp, child);
})
}
function parseModules(file) {
const entry = getModuleInfo(file); // 获取入口文件的模块信息
const temp = [entry]; // 收集所有文件的模块信息集合
const depsGraph = {}; // 收集所有依赖的集合
getDeps(temp, entry); // 递归入口文件的依赖集合,手机所有依赖信息
temp.forEach(item => {
depsGraph[item.file] = {
deps: item.deps,
code: item.code
}
})
return depsGraph;
}
parseModules('./src/main.js')
通过对依赖模块 deps 进行遍历递归,最终得到:
{
'./src/main.js': {
deps: {
'./add.js': './src/add.js',
'./minus.js': './src/minus.js',
'./data.js': './src/data.js'
},
code: '"use strict";\n' +
'\n' +
'var _add = _interopRequireDefault(require("./add.js"));\n' +
'\n' +
'var _minus = _interopRequireDefault(require("./minus.js"));\n' +
'\n' +
'var _data = require("./data.js");\n' +
'\n' +
'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +
'\n' +
'var result_1 = (0, _add["default"])(1, 2);\n' +
'var result_2 = (0, _minus["default"])(2, 1);\n' +
"console.log('result_1: ', result_1);\n" +
"console.log('result_2: ', result_2);\n" +
"console.log('data: ', _data.data);"
},
'./src/add.js': {
deps: {},
code: '"use strict";\n' +
'\n' +
'Object.defineProperty(exports, "__esModule", {\n' +
' value: true\n' +
'});\n' +
'exports["default"] = void 0;\n' +
'\n' +
'function add(a, b) {\n' +
' return a + b;\n' +
'}\n' +
'\n' +
'var _default = add;\n' +
'exports["default"] = _default;'
},
'./src/minus.js': {
deps: {},
code: '"use strict";\n' +
'\n' +
'Object.defineProperty(exports, "__esModule", {\n' +
' value: true\n' +
'});\n' +
'exports["default"] = void 0;\n' +
'\n' +
'function minus(a, b) {\n' +
' return a - b;\n' +
'}\n' +
'\n' +
'var _default = minus;\n' +
'exports["default"] = _default;'
},
'./src/data.js': {
deps: {},
code: '"use strict";\n' +
'\n' +
'Object.defineProperty(exports, "__esModule", {\n' +
' value: true\n' +
'});\n' +
'exports.data = void 0;\n' +
"var data = 'My name is 小滨';\n" +
'exports.data = data;'
}
}
从以上结果可以很好的得到所需要的结果了,每一个文件的依赖关系,内容数据集合,接下来就是对这个数据进行执行了,问题是,如果执行每一个文件的 code 字段的内容。
仔细看看每一个模块的 code 字段,会发现,这字段中存在 require、exports.data、exports["default"],其实这些东西,在原本的 JS 引擎中是没有的东西,那如果没有,那就有我们赋予即可,无非就是一些方法和数据。
其次,我们要执行 code 的代码也是一个问题,因为 code 其实就是一个字符串。如果需要执行一个字符串,可以采用 new Function(code) 来创建一个函数,之后进行调用执行,这也其实就是 Vue 生成 render 方法的手段,另外也可以使用 eval(code) 来做这件事情,文本就采用 eval(code) 的方式。
编写 bundle
在编写 bundle 时,可以先使用上面得到的模块信息集合写一个 bundle 试试,如下:
(function (graph) {
function require(file) {
function absRequire(path) {
const filePath = graph[file].deps[path];
return require(filePath);
}
let exports = {}; // require 就是返回 export,定义一个 export
(function (require, exports, code) {
eval(code)
}(absRequire, exports, graph[file].code))
return exports
}
require('./src/main.js');
}({
'./src/main.js': {
deps: {
'./add.js': './src/add.js',
'./minus.js': './src/minus.js',
'./data.js': './src/data.js'
},
code: '"use strict";\n' +
'\n' +
'var _add = _interopRequireDefault(require("./add.js"));\n' +
'\n' +
'var _minus = _interopRequireDefault(require("./minus.js"));\n' +
'\n' +
'var _data = require("./data.js");\n' +
'\n' +
'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +
'\n' +
'var result_1 = (0, _add["default"])(1, 2);\n' +
'var result_2 = (0, _minus["default"])(2, 1);\n' +
"console.log('result_1: ', result_1);\n" +
"console.log('result_2: ', result_2);\n" +
"console.log('data: ', _data.data);"
},
'./src/add.js': {
deps: {},
code: '"use strict";\n' +
'\n' +
'Object.defineProperty(exports, "__esModule", {\n' +
' value: true\n' +
'});\n' +
'exports["default"] = void 0;\n' +
'\n' +
'function add(a, b) {\n' +
' return a + b;\n' +
'}\n' +
'\n' +
'var _default = add;\n' +
'exports["default"] = _default;'
},
'./src/minus.js': {
deps: {},
code: '"use strict";\n' +
'\n' +
'Object.defineProperty(exports, "__esModule", {\n' +
' value: true\n' +
'});\n' +
'exports["default"] = void 0;\n' +
'\n' +
'function minus(a, b) {\n' +
' return a - b;\n' +
'}\n' +
'\n' +
'var _default = minus;\n' +
'exports["default"] = _default;'
},
'./src/data.js': {
deps: {},
code: '"use strict";\n' +
'\n' +
'Object.defineProperty(exports, "__esModule", {\n' +
' value: true\n' +
'});\n' +
'exports.data = void 0;\n' +
"var data = 'My name is 小滨';\n" +
'exports.data = data;'
}
}))
以上执行后,即可得到:
result_1: 3
result_2: 1
data: My name is 小滨
说明根据解析出来的模块信息,最终得到了正确的输出结果。
下面就来继续编写真正的 bundle 方法
function bundle(file) {
const depsGraph = JSON.stringify(parseModules('./src/main.js'));
return `(function (graph) {
function require(file) {
function absRequire(path) {
const filePath = graph[file].deps[path];
return require(filePath)
}
var exports = {};
(function (require,exports,code) {
eval(code)
})(absRequire,exports,graph[file].code)
return exports
}
require('${file}')
})(${depsGraph})`;
}
const content = bundle("./src/main.js");
这里输出的 content 就是最后打包后的内容了,下面就将打包后的内容,生成 bundle.js 文件吧
创建 bundle 文件
使用 Node 的 fs、path 模块进行创建,其次还可以使用 prettier 库,对输出的内容进行格式化一下~
function bundle(file) {
// ...
}
const content = bundle("./src/main.js");
const distPath = path.resolve(__dirname, './dist');
!fs.existsSync(distPath) && fs.mkdirSync(distPath);
const bundlePath = path.resolve(__dirname, './dist/bundle.js');
fs.writeFileSync(bundlePath, prettier.format(content, { parser: 'babel' }))
到此,就大功告成啦!~
本文通过一个编写一个 Mini Webpack 的方式来理解 Webpack 的打包原理,希望能够帮助到阅读到此的同学,有帮助的话,那就点赞再走吧~