前言
由于现在社区有太多的零配置脚手架,导致日常业务开发中基本不会关注webpack
的原理,甚至一些具体配置都不会去看。
由于疫情严重被困在家,无聊中透露着寂寞,我就按照着官方40分钟教你写webpack,学着实现一个小型的webpack
, 通过此次实践简单了解webpack
的打包原理。
准备
因为涉及到 ES6 转 ES5,所以我们首先需要安装一些 Babel 相关的工具
yarn add babylon babel-core babel-traverse babel-preset-env
为了方便调试查看, 另外安装一些辅助包(高亮代码/格式代码)
yarn add cli-highlight js-beautify
package.json
文件中添加script
命令
"bundle": "node bundler.js | js-beautify | highlight"
待打包文件
我们按照官方操作,创建三个待打包文件(entry.js
-> message.js
-> name.js
)
// entry.js 入口文件
import message from './message.js';
console.log(message);
// message.js
import {name} from './name.js';
export default `hello ${name}!`;
// name.js
export const name = 'world';
源码文件
在根目录创建bundler.js
, 编写打包代码, 这里面主要包括三个函数
// 据入口文件获取文件信息, 获取当前js文件的依赖信息
function createAsset(filename) {//代码略}
// 从入口开始分析所有依赖项,形成依赖图,采用广度遍历
function createGraph(entry) {//代码略}
// 根据生成的依赖关系图,生成浏览器可执行文件
function bundle(graph) {//代码略}
目录结构
- example
- entry.js
- message.js
- name.js
- bundler.js
实现
获取依赖关系(createAsset)
如何获取依赖呢,其实思路很简单:
- 根据
webpack
的入口配置,指向一个文件, 通过这个文件的路径读取文件的信息 - 把读取的文件代码(字符串),转化成
AST
(抽象语法树) - 从
AST
中找到它所依赖的模块, 获取其相对路径,组成json
格式数据
const fs = require('fs')
const path = require('path')
const babylon = require('babylon')
const traverse = require('babel-traverse').default
const babel = require('babel-core')
let ID = 0
// 根据入口文件获取文件信息, 获取当前js文件的依赖信息
function createAsset(filename) {
//获取文件,返回值是字符串
const content = fs.readFileSync(filename, 'utf-8')
// babylon 转换成 AST
const ast = babylon.parse(content, {
sourceType: 'module'
})
// 用来存储当前文件所依赖的文件路径
const dependencies = []
// 遍历 ast
traverse(ast, {
ImportDeclaration: ({ node }) => {
// 把当前依赖的模块加入到数组中,其实这存的是字符串,
dependencies.push(node.source.value)
}
})
// 创建id, 方便之后找到依赖关系,下面会讲
const id = ID++
// 这边主要把ES6 的代码转成 ES5
const { code } = babel.transformFromAst(ast, null, {
presets: ['env']
});
return {
id,
filename,
dependencies,
code
}
}
接下来在末行添加代码
console.log(createAsset('./example/entry.js'))
运行yarn bundle
命令,查看生成的数据:
{
id: 0,
filename: './example/entry.js',
dependencies: ['./message.js'],
code: '"use strict";\n\nvar _message = require("./message.js");\n\nvar _message2 = _interopRequireDefault(_message);\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nconsole.log(_message2.default);'
}
生成依赖关系图(createGraph)
从上面的代码可以看出,以入口文件为引查询到它的依赖关系(entry.js 依赖 message.js
), 那之后就可以通过遍历的方式,查询message.js
之后的依赖关系,形成数组数据
// 从入口开始分析所有依赖项,形成依赖图,采用广度遍历
function createGraph(entry) {
// 如上所示,表示入口文件的依赖
const mainAsset = createAsset(entry)
// 既然要广度遍历肯定要有一个队列,第一个元素肯定是 从 "./example/entry.js" 返回的信息
const queue = [mainAsset]
for (const asset of queue) {
// 获取相对路径
const dirname = path.dirname(asset.filename)
// 新增一个属性来保存子依赖项的数据
// 保存类似 这样的数据结构 ---> {"./message.js" : 1}
// 对应上面的 id, 方便找到依赖关系
asset.mapping = {}
// 根据依赖添加数组元素
asset.dependencies.forEach(relativePath => {
const absolutePath = path.join(dirname, relativePath)
// 获得子依赖(子模块)的依赖项、代码、模块id,文件名
const child = createAsset(absolutePath)
asset.mapping[relativePath] = child.id
queue.push(child)
})
}
return queue
}
接下来跟同样进行测试
const graph = createGraph('./example/entry.js')
console.log(graph)
得到如下数据:
[
{
id: 0,
filename: './example/entry.js',
dependencies: ['./message.js'],
code: '"use strict";\n\nvar _message = require("./message.js");\n\nvar _message2 = _interopRequireDefault(_message);\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nconsole.log(_message2.default);',
mapping: {
'./message.js': 1
}
},
{
id: 1,
filename: 'example/message.js',
dependencies: ['./name.js'],
code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\n\nvar _name = require("./name.js");\n\nexports.default = "hello " + _name.name + "!";',
mapping: {
'./name.js': 2
}
},
{
id: 2,
filename: 'example/name.js',
dependencies: [],
code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\nvar name = exports.name = \'world\';',
mapping: {}
}
]
打包代码(bundle)
接下来就是最重要的一步,以上已经生成的依赖关系数据,并且都有对应的code
, 那现在要做的就是把这些code
整合起来,让它可以在浏览器端运行。
首先看一下代码的大致结构:
function bundle(graph) {
let modules = "";
//循环依赖关系,并把每个模块中的代码存在function作用域里
graph.forEach(mod => {
modules += `${mod.id}:[
function (require, module, exports){
${mod.code}
},
${JSON.stringify(mod.mapping)},
],`;
});
const result = `
(function(modules) {
// 代码略
})({${modules}})
`;
return result;
}
这块稍微有些复杂,一步一步来。
首先我们把所有的代码都转换成了ES5
,并且生成了依赖关系图,现在要做的第一步就是把代码整合在一起,而做到这一步的整体思路就是:创建匿名函数,遍历整个依赖关系数组,生成一个函数对象当作参数传进去
生成的结构如图所示
(function(modules) {
})({
0: [
function(require, module, exports) {
"use strict";
var _message = require("./message.js");
var _message2 = _interopRequireDefault(_message);
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : {
default: obj
};
}
console.log(_message2.default);
},
// 导入模块对应的 id
{
"./message.js": 1
},
],
1: [
function(require, module, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
var _name = require("./name.js");
exports.default = "hello " + _name.name + "!";
},
{
"./name.js": 2
},
],
2: [
function(require, module, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
var name = exports.name = 'world';
},
{},
],
})
由上图可以看到entry.js
代码经过Babel
转码后是什么样子
// 原代码
import message from './message.js';
console.log(message)
// 转换成 ES5
"use strict";
var _message = require("./message.js");
var _message2 = _interopRequireDefault(_message);
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : { default: obj };
}
console.log(_message2.default);
这代码放在浏览器中肯定是无法运行的
VM689:1 Uncaught ReferenceError: require is not defined
at <anonymous>:1:16
Babel
将我们 ES6
的模块化代码转换为了 CommonJS
, 而现在需要在浏览器端运行CommonJS
代码就需要一些操作,这也就是匿名函数内部所要做的事情, 具体实现就是模拟创建require
方法,返回值是导入模块的export
值
例如entry.js
中var _message = require("./message.js");
, 如何获取message.js
的内容获取它的返回值?
(function(modules) {
function require(id) {
// 根据id获取 function 和 mapping
const [fn, mapping] = modules[id];
function localRequire(relativePath){
//根据模块的路径在mapping中找到对应的模块id
return require(mapping[relativePath]);
}
const module = {exports:{}};
//执行每个模块的代码。
//对应上方的 function(require, module, exports)
fn(localRequire,module,module.exports);
return module.exports;
}
// 导入entry.js 代码
require(0)
})(modules)
乍一看可能有点蒙圈,请对照上方每块转换成ES5
的代码,我简单解释一下具体的流程:
require(0)
执行entry.js
代码,也就是执行fn(localRequire,module,module.exports);
- 走到
require("./message.js")
这里,这里的require
就是上方传入的localRequire
,传入了"./message.js"
,根据mapping
获取ID
也就是1
, 相当于执行了一次require(1)
,但此时还没有获取到message
的值 - 走到了
name.js
中,又执行了require("./name.js");
,如出一辙,也就是require(2)
- 此时执行了
var name = exports.name = 'world';
, 此时传入的exports
终于有了值,require
方法返回了module.exports
的值,也就是说var _name = require("./name.js");
,name
的值是'world'
- 之后执行
exports.default = "hello " + _name.name + "!";
也获取了require("./message.js")
的返回值
就我浅薄的理解而言,就是模拟创建并递归调用require
方法,外部暴露module.export
并作为返回值
完整的bundle
函数如下所示:
function bundle(graph) {
let modules = "";
//循环依赖关系,并把每个模块中的代码存在function作用域里
graph.forEach(mod => {
modules += `${mod.id}:[
function (require, module, exports){
${mod.code}
},
${JSON.stringify(mod.mapping)},
],`;
});
//require, module, exports 是 cjs的标准不能再浏览器中直接使用,所以这里模拟cjs模块加载,执行,导出操作。
const result = `
(function(modules){
//创建require函数, 它接受一个模块ID(这个模块id是数字0,1,2) ,它会在我们上面定义 modules 中找到对应是模块.
function require(id){
const [fn, mapping] = modules[id];
function localRequire(relativePath){
//根据模块的路径在mapping中找到对应的模块id
return require(mapping[relativePath]);
}
const module = {exports:{}};
//执行每个模块的代码。
fn(localRequire,module,module.exports);
return module.exports;
}
//执行入口文件,
require(0);
})({${modules}})
`;
return result;
}
打包
const graph = createGraph("./example/entry.js");
const result = bundle(graph);
// 打包生成文件
fs.writeFileSync("./bundle.js", result);
浏览器端完美运行