总所周知,现代前端,基本都离不开前端工程化,多多少少都要用上打包工具进行处理,例如webpack、rollup。不过写得再多,也只是针对webpack/rollup的配置工程师,具体打包过程,对我们来说却是个黑盒。
不如,我们来搞点事情?

梳理流程
在构建之前我们要梳理一下打包的流程。
-
我们的打包工具要有一个打包入口
[entry]
-
我们的
[entry]
文件会引入依赖,因此需要一个{graph}
来存储模块图谱,这个模块图谱有3个核心内容:filepath
:模块路径,例如./src/message.js'
dependencies
:模块里用了哪些依赖code
:这个模块中具体的代码
-
{graph}
会存储所有的模块,因此需要递归遍历每一个被依赖了的模块 -
根据
{graph}
的依赖路径,用{graph.code}
构建可运行的代码 -
将代码输出到出口文件,打包完成
搭建架构
首先写好我们需要打包的代码
// ./src/index.js
import message from './message.js';
import {word} from './word.js';
console.log(message);
console.log(word);
// ./src/message.js
import {word} from './word.js';
const message = `hello ${word}`;
export default message;
// ./src/word.js
export const word = 'paraslee';
然后在根目录创建bundler.js,设计好具体要用上的功能,
// ./bundler.js
// 模块分析能力:分析单个模块,返回分析结果
function moduleAnalyser(filepath) {
return {};
}
// 图谱构建能力:递归模块,调用moduleAnalyser获取每一个模块的信息,综合存储成一个模块图谱
function moduleGraph(entry) {
const moduleMuster = moduleAnalyser(entry);
const graph = {};
return graph;
}
// 生成代码能力:通过得到的模块图谱生成可执行代码
function generateCode(entry) {
const graph = moduleGraph(entry);
const code = '';
return code;
}
// 调用bundleCode执行打包操作,获取到可执行代码后,将代码输出到文件里
function bundleCode() {
const code = generateCode('./src/index.js');
}
bundleCode();
自底向上,编码开始!
模块分析
首先是最底层的功能:moduleAnalyser(模块分析)
因为第一个分析的模块一定是入口文件,所以我们在写 moduleAnalyser 的具体内容时可以把其他代码先注释掉,单独编写这一块。
function moduleAnalyser(filepath) {}
moduleAnalyser('./src/index.js');
首先,我们需要读取这个文件的信息,通过node提供的 fs
API,我们可以读取到文件的具体内容
const fs = require('fs');
function moduleAnalyser(filepath) {
const content = fs.readFileSync(filePath, 'utf-8');
}
打印content能得到下图的结果

第二步,我们需要获取这个模块的所有依赖,即 ./message.js
和 ./word.js
。有两种方法可以拿到依赖:1. 手写规则进行字符串匹配;2. 使用babel工具操作
第一种方法实在吃力不讨好,而且容错性低,效率也不高,因此我采用第二种方法

babel有工具能帮我们把JS代码转换成AST,通过对AST进行遍历,可以直接获取到使用了 import 语句的内容
npm i @babel/parser @babel/traverse
...
const parser = require('@babel/parser');
const traverse = require("@babel/traverse").default;
const moduleAnalyser = (filePath) => {
const content = fs.readFileSync(filePath, 'utf-8');
// 通过 [@babel/parser的parse方法] 能将js代码转换成ast
const AST = parser.parse(content, {
sourceType: 'module' // 如果代码使用了esModule,需要配置这一项
});
// [@babel/traverse] 能遍历AST树
traverse(AST, {
// 匹配 ImportDeclaration 类型节点 (import语法)
ImportDeclaration: function(nodePath) {
// 获取模块路径
const relativePath = nodePath.node.source.value;
}
});
}
如果我们在控制台中打印 relativePath
就能输出 ./message.js
和 ./word.js
AST实在是太长♂了,感兴趣的小伙伴可以自己输出AST看看长啥样,我就不放出来了
第三步,获取到依赖信息后,连同代码内容一起存储下来
下面是 moduleAnalyser的完整代码,看完后可能会有几个疑惑,我会针对标注处挨个进行解释
npm i @babel/core
...
const babel = require('@babel/core');
const moduleAnalyser = (filePath) => {
const content = fs.readFileSync(filePath, 'utf-8');
const AST = parser.parse(content, {
sourceType: 'module'
});
// 存放文件路径 #1
const dirname = path.dirname(filePath);
// 存放依赖信息
const dependencies = {};
traverse(AST, {
ImportDeclaration: function(nodePath) {
const relativePath = nodePath.node.source.value;
// 将相对模块的路径 改为 相对根目录的路径 #2
const absolutePath = path.join(dirname, relativePath);
// replace是为了解决windows系统下的路径问题 #3
dependencies[relativePath] = './' + absolutePath.replace(/\\/g, '/');
}
});
// 用babel将AST编译成可运行的代码 #4
const {code} = babel.transformFromAst(AST, null, {
presets: ["@babel/preset-env"]
})
return {
filePath,
dependencies,
code
}
}
#1 为什么要获取dirname?
首先入口文件为 ./src/index.js,默认 ./src 为代码的根目录,所有依赖,所有模块文件都在 ./src 下面 (暂时先不考虑node_modules),因此我们要获取这个根目录信息, dirname === 'src'
#2 为什么要把相对模块的路径 改为 相对根目录的路径
在 ./src/index.js 中是这样引入模块的 import message from './message.js'
,relativePath 变量存储的值为 ./message.js
,这对于分析 message.js 文件非常不便,转换成 ./src/message.js
后就能直接通过fs读取这个文件,方便了很多
#3 为什么要这样存储依赖信息
通过键值对的存储,既可以保留 将相对模块的路径 ,又可以存放 相对根目录的路径
#4 为什么要做代码编译
代码编译可以将esModule转换成commonjs,之后构建代码时,我们可以编写自己的 require()
方法进行模块化处理.
OK,现在已经理解了 moduleAnalyser 方法,让我们看看输出结果长啥样

图谱构建
现在已经实现了 模块分析能力,接下来我们需要递归所有被导入了的模块,将每个模块的分析结果存储下来作为 grapth
...
const moduleAnalyser = (filePath) => {...}
const moduleGraph = (entry) => {
// moduleMuster 存放已经分析过的模块集合, 默认直接加入入口文件的分析结果
const moduleMuster = [moduleAnalyser(entry)];
// cache记录已经被分析过的模块,减少模块的重复分析
const cache = {
[moduleMuster[0].filePath]: 1
};
// 存放真正的graph信息
const graph = {};
// 递归遍历所有的模块
for (let i = 0; i < moduleMuster.length; i++) {
const {filePath} = moduleMuster[i];
if (!graph[filePath]) {
const {dependencies, code} = moduleMuster[i];
graph[filePath] = {
dependencies,
code
};
for (let key in dependencies) {
if (!cache[dependencies[key]]) {
moduleMuster.push(moduleAnalyser(dependencies[key]));
cache[dependencies[key]] = 1;
}
}
}
}
return graph;
}
// 先直接传入enrty信息,获取图谱信息
moduleGraph('./src/index.js');
moduleGraph 方法并不难理解,主要内容在递归层面。
输出看看最终生成的图谱 graph

构建代码
接下来就是重点中的重点,核心中的核心:根据graph生成可执行代码
...
const moduleAnalyser = (filePath) => {...}
const moduleGraph = (entry) => {...}
const generateCode = (entry) => {
// 代码在文件里其实是一串字符串,浏览器是把字符串转换成AST后再操作执行的,因此这里需要把图谱转换成字符串来使用
const graph = JSON.stringify(moduleGraph(entry));
return `
(function(graph) {
// 浏览器没有require方法,需要自行创建
function require(module) {
// 代码中引入模块时是使用的相对模块的路径 ex. var _word = require('./word.js')
// 但我们在引入依赖时需要转换成相对根路径的路径 ex. require('./src/word.js')
// 将requireInEval传递到闭包中供转换使用
function requireInEval(relativePath) {
return require(graph[module].dependencies[relativePath]);
}
// 子模块的内容存放在exports中,需要创建空对象以便使用。
var exports = {};
// 使用闭包避免模块之间的变量污染
(function(code, require, exports) {
// 通过eval执行代码
eval(code);
})(graph[module].code, requireInEval, exports)
// 将模块所依赖的内容返回给模块
return exports;
}
// 入口模块需主动引入
require('${entry}');
})(${graph})`;
}
generateCode('./src/index.js');
此刻一万匹草泥马在心中狂奔:这破函数到底写的是个啥玩意儿? 给爷看晕了

GGMM不着急,我来一步步说明
首先把字符串化的graph传入闭包函数中以供使用。
然后需要手动导入入口文件模块,即 require('${entry}')
,注意这里要使用引号包裹,确保为字符串
因此我们的 require 函数此时为
function require(module = './src/index.js') {}
根据 graph['./src/index.js']
能获取到 入口文件的分析结果,

function require(module = './src/index.js') {
(function(code) {
// 通过eval执行代码
eval(code);
})(graph[module].code)
}
然后我们看一下此时eval会执行的代码,即入口文件编译后的代码
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
var _word = require("./word.js");
var message = "hello ".concat(_word.word);
var _default = message;
exports["default"] = _default;
在第六行里有一句 var _word = require("./word.js");
,因为作用域链的存在,这里的require会调用最外层的require方法, 但是我们自己写的require方法接受的是相对根目录的路径,因此需要有一个方法进行转换。
// 此时的require
function require(module = './src/index.js') {
function requireInEval(relativePath = './word.js') {
return require(graph[module].dependencies[relativePath]);
}
var exports = {};
(function(code, require, exports) {
eval(code);
})(graph[module].code, requireInEval, exports)
return exports;
}
通过 requireInEval 进行路径转换,并传入到闭包当做,根据作用域的特性,eval中执行的 require 为传入的requireInEval 方法。
eval执行时,会把依赖里的数据存放到exports对象中,因此在外面我们也需要创建一个exports对象接受数据。
最后将exports返回出去
之后就是重复以上步骤的循环调用
生成文件
现在,打包流程基本已经完成了,generateCode 方法返回的code代码,可以直接放到浏览器中运行。
不过好歹是个打包工具,肯定要把打包结果输出出来。
const os = require('os'); // 用来读取系统信息
...
const moduleAnalyser = (filePath) => {...}
const moduleGraph = (entry) => {...}
const generateCode = (entry) => {...}
function bundleCode(entry, output) {
// 获取输出文件的绝对路径
const outPath = path.resolve(__dirname, output);
const iswin = os.platform() === 'win32'; // 是否是windows
const isMac = os.platform() === 'darwin'; // 是否是mac
const code = generateCode(entry);
// 读取输出文件夹
fs.readdir(outPath, function(err, files) {
// 如果没有文件夹就创建文件夹
let hasDir = true;
if (err) {
if (
(iswin && err.errno === -4058)
|| (isMac && err.errno === -2)
) {
fs.mkdir(outPath, {recursive: true}, err => {
if (err) {
throw err;
}
});
hasDir = false;
} else {
throw err;
}
}
// 清空文件夹里的内容
if (hasDir) {
files = fs.readdirSync(outPath);
files.forEach((file) => {
let curPath = outPath + (iswin ? '\\' :"/") + file;
fs.unlinkSync(curPath);
});
}
// 将代码写入文件,并输出
fs.writeFile(`${outPath}/main.js`, code, 'utf8', function(error){
if (error) {
throw error;
}
console.log('打包完成!');
})
})
}
bundleCode('./scr/index.js', 'dist');
执行 node bundler.js
看看最终结果吧!
尾声
到这里,一个基础的打包工具就完成了!
你可以自己添加 bundler.config.js ,把配置信息添加进去,传入bundler.js,这样看上去更像个完整的打包工具了。
这个打包工具非常简单,非常基础,webpack/rollup因为涉及了海量的功能和优化,其内部实现远比这个复杂N倍,但打包的核心思路大体相同。
这里放上完整的代码: github
如果文中有错误/不足/需要改进/可以优化的地方,希望能在评论里提出,作者看到后会在第一时间里处理
既然你都看到这里了,为何不点个赞👍再走,github的星星⭐是对我持续创作最大的支持❤️️
拜托啦,这对我真的很重要