1. 代码结构以及相关依赖
webpack 的核心功能就是模块打包,从入口文件开始分析依赖关系,将相关的模块打包在一起,
本文实现了一个简易的打包器
本文对应的代码仓库 simple-bundler
1.1 文件结构
入口文件index.js分别引入了模块a.js和b.js,其中a, b两个模块之间又互相引用
// index.jsimport a from './a.js'import b from './b.js'console.log(a.getB())console.log(b.getA())// a.jsimport b from './b.js'const a = {value: 'a',getB: () => b.value + ' from a.js'}export default a// b.jsimport a from './a.js'const b = {value: 'b',getA: () => a.value + ' from b.js'}export default b
显而易见,以上三个文件在浏览器中无法直接运行,因为浏览器不支持 ES Module 的 import/export 方法,一些浏览器比如 chrome 可以设置 <script type="module" src="index.js"></script>支持,但这样每次 import 就要请求一次,请求文件过多,非常影响性能。
正确的处理方法是通过 babel 把 import/export 转换为普通代码,并且所有文件打包成一个文件,接下来的打包器就是要实现这个功能
1.2 相关依赖
实现打包器需要用到一些 babel依赖:
- @babel/parser :解析代码生成 AST
- @babel/traverse:遍历 AST,通常用来修改 AST
- @babel/core:babel 用来转译代码
- @babel/preset-env:babel 转译的规则
2. 打包流程
2.1 生成依赖图谱
初步得到hash依赖图谱
指定打包的入口文件为index.js,项目中的deps_4.ts实现了生成依赖图谱的初步功能,
生成依赖图谱的功能函数collectCodeAndDeps如下,分析入口文件的AST,找到入口文件的依赖并进行递归分析 ```javascript import { parse } from “@babel/parser” import traverse from “@babel/traverse” import { readFileSync } from ‘fs’ import { resolve, relative, dirname } from ‘path’;
// 设置根目录 const projectRoot = resolve(__dirname, ‘project_1’) // 类型声明 type DepRelation = { [key: string]: { deps: string[], code: string } } // 初始化一个空的 depRelation,用于收集依赖 const depRelation: DepRelation = {}
// 将入口文件的绝对路径传入函数, collectCodeAndDeps(resolve(projectRoot, ‘index.js’))
console.log(depRelation) console.log(‘done’)
function collectCodeAndDeps(filepath: string) { const key = getProjectPath(filepath) // 文件的项目路径,如 index.js if (Object.keys(depRelation).includes(key)) { // 注意,重复依赖不一定是循环依赖 return } // 获取文件内容,将内容放至 depRelation const code = readFileSync(filepath).toString() // 初始化 depRelation[key] depRelation[key] = { deps: [], code: code } // 将代码转为 AST const ast = parse(code, { sourceType: ‘module’ }) // 分析文件依赖,将内容放至 depRelation traverse(ast, { enter: path => { if (path.node.type === ‘ImportDeclaration’) { // path.node.source.value 往往是一个相对路径,如 ./a.js,需要先把它转为一个绝对路径 const depAbsolutePath = resolve(dirname(filepath), path.node.source.value) // 然后转为项目路径 const depProjectPath = getProjectPath(depAbsolutePath) // 把依赖写进 depRelation depRelation[key].deps.push(depProjectPath) collectCodeAndDeps(depAbsolutePath) } } }) } // 获取文件相对于根目录的相对路径 function getProjectPath(path: string) { return relative(projectRoot, path).replace(/\/g, ‘/‘) }
函数用一个hash `depRelation`来维护依赖图谱,具体结构如下,记录了文件名,依赖数组和本身的代码```javascript{'index.js': {deps: [ 'a.js', 'b.js' ],code: "import a from './a.js'\n" +"import b from './b.js'\n" +'console.log(a.getB())\n' +'console.log(b.getA())\n'},'a.js': {deps: [ 'b.js' ],code: "import b from './b.js'\n" +'const a = {\n' +" value: 'a',\n" +" getB: () => b.value + ' from a.js'\n" +'}\n' +'export default a\n'},'b.js': {deps: [ 'a.js' ],code: "import a from './a.js'\n" +'const b = {\n' +" value: 'b',\n" +" getA: () => a.value + ' from b.js'\n" +'}\n' +'export default b\n'}}
对 code 进行es5转译
在初步得到依赖图谱后我们发现一个问题:那就是 import/export 还是存在
这时候我们需要使用 babel 实现代码转译,在函数collectCodeAndDeps中增加以下部分将写入依赖图谱的 code 转译成 ES5,完整代码见项目中的 bundler_1.ts
const code = readFileSync(filepath).toString()const { code: es5Code } = babel.transform(code, {presets: ['@babel/preset-env']})
此时,依赖图谱depRelation的结构如下:
{'index.js': {deps: [ 'a.js', 'b.js' ],code: '"use strict";\n' +'\n' +'var _a = _interopRequireDefault(require("./a.js"));\n' +'\n' +'var _b = _interopRequireDefault(require("./b.js"));\n' +'\n' +'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +'\n' +'console.log(_a["default"].getB());\n' +'console.log(_b["default"].getA());'},'a.js': {deps: [ 'b.js' ],code: '"use strict";\n' +'\n' +'Object.defineProperty(exports, "__esModule", {\n' +' value: true\n' +'});\n' +'exports["default"] = void 0;\n' +'\n' +'var _b = _interopRequireDefault(require("./b.js"));\n' +'\n' +'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +'\n' +'var a = {\n' +" value: 'a',\n" +' getB: function getB() {\n' +` return _b["default"].value + ' from a.js';\n` +' }\n' +'};\n' +'var _default = a;\n' +'exports["default"] = _default;'},'b.js': {deps: [ 'a.js' ],code: '"use strict";\n' +'\n' +'Object.defineProperty(exports, "__esModule", {\n' +' value: true\n' +'});\n' +'exports["default"] = void 0;\n' +'\n' +'var _a = _interopRequireDefault(require("./a.js"));\n' +'\n' +'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +'\n' +'var b = {\n' +" value: 'b',\n" +' getA: function getA() {\n' +` return _a["default"].value + ' from b.js';\n` +' }\n' +'};\n' +'var _default = b;\n' +'exports["default"] = _default;'}}
这时候depRelation中每个模块对应的code都是字符串的形式,我们以 a.js 的 code 为例,把它转换为js分析一下
"use strict";
Object.defineProperty(exports, "__esModule", {value: true}); // 疑惑 1
exports["default"] = void 0; // 疑惑 2
var _b = _interopRequireDefault(require("./b.js")); // 细节 1
function _interopRequireDefault(obj) { // 细节 1
return obj && obj.__esModule ? obj : { "default": obj }; // 细节 1
}
var a = {
value: 'a',
getB: function getB() {
return _b["default"].value + ' from a.js'; // 细节 1
}
};
var _default = a; // 细节 2
exports["default"] = _default; // 细节 2
疑惑1
该部分等同于exports.__esModule = true,表式原来是 ES Module 模块,方便与 Common JS 模块区分
疑惑2
该部分等同于exports.default = undefined,表示导出的初始化
细节1
import b from './b.js'转译为了var _b = _interopRequireDefault(require("./b.js"))
其中这个 require 函数还没有定义,注意这个不是Common JS中的那个 require,我们这里就先假设它已经被定义,而且作用就是引入模块 b
函数_interopRequireDefault的作用是给引入的模块添加 default 属性,特别是 Common JS 模块没有默认导出,然后访问引入模块时,要通过 default 属性访问
细节2
该部分等同于exports.default = a,此时 exports 对象的结构为
{
__esModule: true,
default: a
}
总结
进行转译后,import 变为了 require 函数,export 变为了 exports 对象
2.2 根据打包文件倒推打包器
既然我们的最终目标是打包出一个文件 dist.js,那这个文件应该包含所有的模块,并且能够执行所有的模块,我们可以根据dist.js 倒推 bundelr.js
打包文件分析
所以这个打包得到的 dist.js 应该包含什么内容呢:
- 首先应该有依赖图谱相关的内容
- 其次应该有一个执行函数可以执行入口
dist.js 内容如下,详见项目 dist.js
var depRelation = [{
key: "index.js",
deps: ["a.js", "b.js"],
code: function (require, module, exports) {
"use strict";
var _a = _interopRequireDefault(require("./a.js"));
var _b = _interopRequireDefault(require("./b.js"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
console.log(_a["default"].getB());
console.log(_b["default"].getA());
}
}, {
key: "a.js",
deps: ["b.js"],
code: function (require, module, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
var _b = _interopRequireDefault(require("./b.js"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
var a = {
value: 'a',
getB: function getB() {
return _b["default"].value + ' from a.js';
}
};
var _default = a;
exports["default"] = _default;
}
}, {
key: "b.js",
deps: ["a.js"],
code: function (require, module, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
var _a = _interopRequireDefault(require("./a.js"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
var b = {
value: 'b',
getA: function getA() {
return _a["default"].value + ' from b.js';
}
};
var _default = b;
exports["default"] = _default;
}
}]
// 核心代码
var modules = {}
execute(depRelation[0].key)
function execute(key) {
// 如果已经 require 过,就直接返回上次的结果
if (modules[key]) { return modules[key] }
// 找到要执行的项目
var item = depRelation.find(i => i.key === key)
// 找不到就报错,中断执行
if (!item) { throw new Error(`${item} is not found`) }
// 把相对路径变成项目路径
var pathToKey = (path) => {
var dirname = key.substring(0, key.lastIndexOf('/') + 1)
var projectPath = (dirname + path).replace(/\.\//g, '').replace(/\/\//, '/')
return projectPath
}
// 创建 require 函数
var require = (path) => {
return execute(pathToKey(path))
}
// 初始化当前模块
modules[key] = { __esModule: true }
// 初始化 module 方便 code 往 module.exports 上添加属性
var module = { exports: modules[key] }
// 调用 code 函数,往 module.exports 上添加导出属性
// 第二个参数 module 大部分时候是无用的,主要用于兼容旧代码
item.code(require, module, module.exports)
// 返回当前模块
return modules[key]
}
console.log(modules)
数组形式的依赖图谱
打包文件中的依赖图谱没有采用2.1中打包器中初步生成的hash形式,而是采用了数组形式,这样可以通过索引0开始执行入口文件,
数组形式的依赖图谱可以由打包器中的hash依赖图谱转换和写入打包文件生成
具体代码见项目中 bundler_2.ts
依赖图谱中的code为函数
依赖图谱中的code不再是字符串了,而变为了函数,只要打包器依赖图谱的code字符串外面包裹上对应字符串再写入文件就可以得到
生成的函数 function(require, module, exports){...},里面的三个参数是 Common JS2 规范规定的,这样我们也能够确定 code 中的require函数和exports对象是外面传进来的
execute 执行函数
主体思路:
const modules = {} // modules 用于缓存所有模块
function execute(key) {
if (modules[key]) { return modules[key] }
var item = depRelation.find(i => i.key === key)
var require = (path) => { // 定义 require 函数
return execute(pathToKey(path))
}
modules[key] = { __esModule: true }
var module = { exports: modules[key] } // 定义 module 对象
item.code(require, module, module.exports)
return modules.exports
}
其中对象 modules 用于缓存所有的模块,执行dist.js 后打印 modules
{
'index.js': { __esModule: true },
'a.js': { __esModule: true, default: { value: 'a', getB: [Function: getB] } },
'b.js': { __esModule: true, default: { value: 'b', getA: [Function: getA] } }
}
倒推打包器
有了打包文件,倒推打包器也就很简单了,通过拼接对应的字符串即可,代码见项目 bundler_3.ts
import { parse } from "@babel/parser"
import traverse from "@babel/traverse"
import { writeFileSync, readFileSync } from 'fs'
import { resolve, relative, dirname } from 'path';
import * as babel from '@babel/core'
// 设置根目录
const projectRoot = resolve(__dirname, 'project_1')
// 类型声明
type DepRelation = { key: string, deps: string[], code: string }[]
// 初始化一个空的 depRelation,用于收集依赖
const depRelation: DepRelation = [] // 数组!
// 将入口文件的绝对路径传入函数
collectCodeAndDeps(resolve(projectRoot, 'index.js'))
writeFileSync('dist_2.js', generateCode())
console.log('done')
function generateCode() {
let code = ''
code += 'var depRelation = [' + depRelation.map(item => {
const { key, deps, code } = item
return `{
key: ${JSON.stringify(key)},
deps: ${JSON.stringify(deps)},
code: function(require, module, exports){
${code}
}
}`
}).join(',') + '];\n'
code += 'var modules = {};\n'
code += `execute(depRelation[0].key)\n`
code += `
function execute(key) {
if (modules[key]) { return modules[key] }
var item = depRelation.find(i => i.key === key)
if (!item) { throw new Error(\`\${item} is not found\`) }
var pathToKey = (path) => {
var dirname = key.substring(0, key.lastIndexOf('/') + 1)
var projectPath = (dirname + path).replace(\/\\.\\\/\/g, '').replace(\/\\\/\\\/\/, '/')
return projectPath
}
var require = (path) => {
return execute(pathToKey(path))
}
modules[key] = { __esModule: true }
var module = { exports: modules[key] }
item.code(require, module, module.exports)
return modules[key]
}
`
return code
}
function collectCodeAndDeps(filepath: string) {
const key = getProjectPath(filepath) // 文件的项目路径,如 index.js
if (depRelation.find(i => i.key === key)) {
// 注意,重复依赖不一定是循环依赖
return
}
// 获取文件内容,将内容放至 depRelation
const code = readFileSync(filepath).toString()
const { code: es5Code } = babel.transform(code, {
presets: ['@babel/preset-env']
})
// 初始化 depRelation[key]
const item = { key, deps: [], code: es5Code }
depRelation.push(item)
// 将代码转为 AST
const ast = parse(code, { sourceType: 'module' })
// 分析文件依赖,将内容放至 depRelation
traverse(ast, {
enter: path => {
if (path.node.type === 'ImportDeclaration') {
// path.node.source.value 往往是一个相对路径,如 ./a.js,需要先把它转为一个绝对路径
const depAbsolutePath = resolve(dirname(filepath), path.node.source.value)
// 然后转为项目路径
const depProjectPath = getProjectPath(depAbsolutePath)
// 把依赖写进 depRelation
item.deps.push(depProjectPath)
collectCodeAndDeps(depAbsolutePath)
}
}
})
}
// 获取文件相对于根目录的相对路径
function getProjectPath(path: string) {
return relative(projectRoot, path).replace(/\\/g, '/')
}
3. 总结
综上,我们实现了一个简易的打包器将三个js文件打包成了一个,但功能还比较简陋,有以下不足:
- 生成的代码有重复部分,比如_interopRequireDefault函数
- 只能打包 js
- 支持ES Module,不支持 Common JS
- 不支持插件
- 不支持配置入口出口等
功能虽然简陋,但是实现了 webpack 的核心原理,有助于深入学习
