早期JavaScript只需要实现简单的页面交互,几行代码即可搞定。随着浏览器性能的提升以及前端技术的不断发展,JavaScript代码日益膨胀,此时就需要一个完善的模块化机制来解决这个问题。因此诞生了CommonJS(NodeJS), AMD(sea.js), ES6 Module(ES6, Webpack), CMD(require.js)等模块化规范。
什么是模块化?
模块化是一种处理复杂系统分解为更好的可管理模块的方式,用来分割,组织和打包软件。每一个模块完成一个特定的子功能,所有的模块按照某种方式组装起来,成为一个整体,完成整个系统的所有要求功能。
模块化的好处是什么?
随着Webpack的盛行,理解Webpack是如何将模块打包的也是前端人的基本素养,因此本文将从Webpack角度去实现一个类似于Webpack的JS模块打包器。
说到模块化,就不得不提一下JavaScript模块化的发展进程了。早期JavaScript模块化模式比较简单粗暴,将一个模块定义为一个全局函数
function module1() {
// code
}
function module2() {
// code
}
这种方案非常简单,但问题也很明显:污染全局命名空间,引起命名冲突或数据不安全,而且模块间的依赖关系并不明显。
在此基础上,又有了namespace模式,利用一个对象来对模块进行包装
var module1 = {
data: { }, // 数据区域
func1: function() {}
func2: function() {}
}
这种方案的问题依然是数据不安全,外面能直接修改module1
的data
因此又有了IIFE模式,利用自执行函数(闭包)
!function(window) {
var data = {};
function func1() {
data.hello = "hello";
}
function func2() {
data.world = "world";
}
window.module1 = { func1, func2 };
} (window)
数据定义为私有,外部只能通过模块暴露的方法来对data
进行操作,但这依然没有解决模块依赖的问题
基于IIFE,又提出了一种新的模块化方案,即在IIFE的基础上引入了依赖(现代模块化的基石,Webpack、NodeJS等模块化都是基于此实现的)
!function (window, module2) {
var data = {};
function func1() {
data.world = "world";
module2.hello();
}
window.module1 = { func1 };
} (window, { hello: function() {}, });
这样使IIFE模块化的依赖关系变得更明显,又保证了IIFE模块化独有的特性。这种模块化方案也是本文JS模块打包器的模块化思路。
使用引用依赖的IIFE模块化方案,首先将每一个模块都封装成一个闭包函数,并传入require
,module
,exports
参数
function (require, module, exports) {
// 模块化代码
var module1 = require("module2");
console.log(module1.add(1, 2));
exports.sub = function (a, b) { return a - b }
}
并且使用一个modules
对象来管理每个模块
var modules = {
"module1": [
["module2"], // dependencies,模块依赖数组
function (require, module, exports) {
// 模块化代码
var module1 = require("module2");
console.log(module1.add(1, 2));
exports.sub = function (a, b) { return a - b }
}
],
"module2": [
[],
function (require, module, exports) {
exports.add = function (a, b) {
return a + b
}
}
],
}
这里的重点在于实现require函数来加载每一个模块
function require(moduleId) {
var deps = modules[moduleId][0]; // modules就是上面的modules,而moduleId就是上面的"module2"
var fn = modules[moduleId][1]; // 封装的闭包函数
var module = { exports: {} };
fn(require, module, module.exports); // 核心在这,将函数执行,并将require传进去
return module.exports;
}
稍微组织一下代码
!function (modules) {
function require (moduleId) {
var deps = modules[moduleId][0];
var fn = modules[moduleId][1];
var module = { exports: {} };
fn(require, module, module.exports);
return module.exports;
}
// 从根模块依次加载
require("module1"); // 假设module1是根模块
}({
"module1": [
["module2"], // dependencies,依赖模块数组
function (require, module, exports) {
var module2 = require("module2");
console.log("module2: ", module2.add(1, 2));
exports.sub = function (a, b) {
return a - b
}
}
],
"module2": [
[],
function (require, module, exports) {
exports.add = function (a, b) {
return a + b;
}
}
],
});
运行如下
这就是本文的目标,将多个js文件打包成类似上面这样的模块化代码。
目录结构如下
mypack
I__ src // 打包目录
|__ tools
|__ a.js
|__ b.js
|__ func
|__ func.js
|__ add.js
|__ index.js // 模块打包入口
|__ index.js // 打包器代码
|__ config.js // 打包配置
在config.js中可以先写上打包配置
const path = require("path");
module.exports = {
entry: "src/index.js",
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "./dist")
}
};
每个文件使用ES6语法,并使用ES6模块化规范(import/exports),引入和导出模块使用
import { add } from "../add";
export default function func() {
console.log("func");
}
那么首先需要将ES6代码转码成ES5,并将"…/add" import的相对路径解析出来。读者可能已经想到了,使用babel就能做到这件事,因此安装babel依赖
npm install --save @babel/core @babel/traverse @babel/preset-env
babel core用于解析出抽象语法树ast并重新生成新的code,traverse用于遍历抽象语法树
使用transformFileAsync
来生成抽象语法树,其语法格式为
transformFileAsync(filename: string, options?: Object)
传入js文件和options异步生成ast
const { transformFileAsync } = require("@babel/core");
transformFileAsync("./src/index.js", { sourceType: "module", ast: true }).then((result) => {
console.log(result.ast);
});
ast: true
开启ast支持,默认为false,返回的结果中ast为null,sourceType: "module"
表示使用ES6 module
sourceType可以是 “script” | “module” | “unambiguous”,默认其实是"module"
console.log打印的不完全,建议去astexplorer网站上去看
抽象语法树ast有了,那么只需要遍历这颗语法树,然后找到import语句的位置,并将import的文件找出来,细心的读者可能发现了,上图下面的箭头就指着不就是嘛?没错,这里可以使用babel traverse 去遍历ast,traverse其实是根据对应的type来遍历ast的,type就是上图第一个箭头指着的,import表达式的type就是ImportDeclaration
,在上面代码的基础上,将
const { transformFileAsync } = require("@babel/core");
const traverser = require("@babel/traverse");
transformFileAsync("./src/index.js", { sourceType: "module", ast: true }).then((result) => {
traverser.default(result.ast, {
ImportDeclaration({ node }) { // import
console.log(node.source.value); // 打印出import的文件
}
});
});
结果如下
我们可以正常的从文件中解析出使用了某个模块(文件),现在只需要将ES6的代码转回ES5就可以了
const { transformFileAsync, transformFromAstAsync } = require("@babel/core");
transformFileAsync("./src/index.js", { sourceType: "module", ast: true }).then(({ast}) => {
// 使用@babel/preset-env插件来转化代码
transformFromAstAsync(ast, null, { presets: ["@babel/preset-env"], }).then(({code}) => {
console.log(code);
});
});
编译后的代码正好有个require
函数,这也是上面实现的require
函数。
整理了一下思路,现在稍微组织一下代码,将上面的内容封装成一个解析js文件的依赖和编译回ES5代码的函数,
const { transformFileAsync, transformFromAstAsync } = require("@babel/core");
const traverser = require("@babel/traverse");
async function getDepsAndCode(filename) {
const { ast } = await transformFileAsync(filename, { sourceType: "module", ast: true });
const { code } = await transformFromAstAsync(ast, null, { presets: ["@babel/preset-env"] });
const deps = [];
traverser.default(ast, {
ImportDeclaration({node}) {
deps.push(node.source.value);
}
});
return { code, deps }
}
async function main () {
const { code, deps } = await getDepsAndCode("./src/index.js");
console.log(code);
console.log();
console.log(deps);
}
main().catch(console.error);
读取一个文件,可以解析出它的依赖,接下来就是从入口依次构建依赖图,其实就是类似于上文中的modules的结构
const graph = {
"./src/index.js": { // 以文件绝对路径当作moduleId可以避免出现同名模块
code,
mapping: { // 相对路径到绝对路径的一个映射,因为使用的绝对路径当moduleId,但require时还是用相对路径
"./add": "src/add"
}
},
"src/add": {
code,
mapping: {}
}
};
从入口文件递归(深搜)构建graph,在搜索时有个要点,就是要把当前的目录给记录下来,保证求绝对路径时能正确
const path = require("path");
const config = require("./config");
function resolveJsFile(filename) {
if (fs.existsSync(filename)) return filename;
if (fs.existsSync(filename + ".js")) return filename + ".js";
return filename;
}
async function makeDepsGraph(entry) {
const graph = {};
async function makeDepsGraph (filename) {
if (graph[filename]) return; // 防止重复加载模块
const mapping = {}; // 定义相对路径到绝对路径的一个映射
const dirname = path.dirname(filename); // 注意保存上一个目录名,这样能找到模块的绝对路径
const { code, deps } = await getDepsAndCode(resolveJsFile(filename));
graph[filename] = { code }; // 解决循环依赖
for (let dep of deps) {
mapping[dep] = path.join(dirname, dep); // dep是相对路径,path.join(dirname, dep)是绝对路径
await makeDepsGraph(mapping[dep]); // 深搜,不使用广搜
}
graph[filename].mapping = mapping;
}
await makeDepsGraph(entry);
return graph;
}
async function main () {
const graph = await makeDepsGraph(config.entry);
console.log(graph);
}
main().catch(console.error);
上一步构建出依赖图graph,接下来就是生成bundle代码,按照上面介绍的原理,我们可以根据graph生成modules
let modules = "";
for (let filename of Object.keys(graph)) {
modules += `'${ filename }': {
mapping: ${ JSON.stringify(graph[filename].mapping) },
fn: function (require, module, exports) {
${ graph[filename].code }
}
},`
}
有了modules,在包裹上一个自执行函数即可生成bundle
还记得require函数嘛
function require(moduleId) {
var deps = modules[moduleId][0]; // modules就是上面的modules,而moduleId就是上面的"module2"
var fn = modules[moduleId][1]; // 封装的闭包函数
var module = { exports: {} };
fn(require, module, module.exports); // 核心在这,将函数执行,并将require传进去
return module.exports;
}
这里的require函数没有对模块进行缓存并且没有对循环依赖进行处理
循环依赖:即A依赖B,而B又依赖了A,举个例子
a.js
import { b } from "./b"; b(); export function a() { console.log("a") }
b.js
import { a } from "./a"; a(); export function b() { console.log("b") }
如果直接使用上面的require函数的话,会一直在这两个模块中来回require,直到栈溢出
因此,对require函数进行改进
var cache = {}; // 缓存模块
var count = {}; // 模块计数,大于2表示存在循环引用
function require(moduleId) {
if (cache[moduleId]) return cache[moduleId];
count[moduleId] || (count[moduleId] = 0);
count[moduleId] ++;
var mapping = modules[moduleId].mapping;
var fn = modules[moduleId].fn;
function _require(id) { // id是相对路径
var mId = mapping[id]; // 使用mapping映射为绝对路径
if (count[mId] >= 2) return {}; // 循环引用返回空对象
return require(mId);
}
var module = { exports: {} };
fn(_require, module, module.exports);
return module.exports;
}
因此bundle也变成了
const bundle = `
!function (modules) {
var cache = {};
var count = {};
function require(moduleId) {
if (cache[moduleId]) return cache[moduleId];
count[moduleId] || (count[moduleId] = 0);
count[moduleId] ++;
var mapping = modules[moduleId].mapping;
var fn = modules[moduleId].fn;
function _require(id) {
var mId = mapping[id];
if (count[mId] >= 2) return {};
return require(mId);
}
var module = { exports: {} };
fn(_require, module, module.exports);
return module.exports;
}
require('${entry}');
} ({${modules}})`;
整理一下代码,将bundle写入到相应的文件中,代码如下
async function writeJsBundle (entry) {
const graph = await makeDepsGraph(entry);
let modules = "";
for (let filename of Object.keys(graph)) {
modules += `'${ filename }': {
mapping: ${ JSON.stringify(graph[filename].mapping) },
fn: function (require, module, exports) {
${ graph[filename].code }
}
},`
}
const bundle = `
!function (modules) {
var cache = {};
var count = {};
function require(moduleId) {
if (cache[moduleId]) return cache[moduleId];
count[moduleId] || (count[moduleId] = 0);
count[moduleId] ++;
var mapping = modules[moduleId].mapping;
var fn = modules[moduleId].fn;
function _require(id) {
var mId = mapping[id];
if (count[mId] >= 2) return {};
return require(mId);
}
var module = { exports: {} };
fn(_require, module, module.exports);
return module.exports;
}
require('${ entry }');
} ({${ modules }})`;
await mkdir(config.output.path);
await writeFile(`${ config.output.path }/${ config.output.filename }`, bundle);
}
完整的代码如下,本人从不骗人,说好100行就100行
const fs = require("fs");
const path = require("path");
const { promisify } = require("util");
const { transformFileAsync, transformFromAstAsync } = require("@babel/core");
const traverser = require("@babel/traverse");
const config = require("./config");
const mkOneDir = promisify(fs.mkdir);
const writeFile = promisify(fs.writeFile);
async function mkdir (dir) {
const dirs = dir.split("/").filter(Boolean);
let cur = "";
for (let d of dirs) {
cur += d;
if (!fs.existsSync(cur)) await mkOneDir(cur);
cur += "/"
}
}
async function getDepsAndCode (filename) {
const { ast } = await transformFileAsync(filename, { sourceType: "module", ast: true });
const { code } = await transformFromAstAsync(ast, null, { presets: ["@babel/preset-env"] });
const deps = [];
traverser.default(ast, {
ImportDeclaration ({ node }) { deps.push(node.source.value); }
});
return { code, deps }
}
function resolveJsFile (filename) {
if (fs.existsSync(filename)) return filename;
if (fs.existsSync(filename + ".js")) return filename + ".js";
return filename;
}
async function makeDepsGraph (entry) {
const graph = {};
async function makeDepsGraph (filename) {
if (graph[filename]) return;
const mapping = {};
const dirname = path.dirname(filename);
const { code, deps } = await getDepsAndCode(resolveJsFile(filename));
graph[filename] = { code };
for (let dep of deps) {
mapping[dep] = path.join(dirname, dep);
await makeDepsGraph(mapping[dep]);
}
graph[filename].mapping = mapping;
}
await makeDepsGraph(entry);
return graph;
}
async function writeJsBundle (entry) {
const graph = await makeDepsGraph(entry);
let modules = "";
for (let filename of Object.keys(graph)) {
modules += `'${ filename }': {
mapping: ${ JSON.stringify(graph[filename].mapping) },
fn: function (require, module, exports) {
${ graph[filename].code }
}
},`
}
const bundle = `
!function (modules) {
var cache = {};
var count = {};
function require(moduleId) {
if (cache[moduleId]) return cache[moduleId];
count[moduleId] || (count[moduleId] = 0);
count[moduleId] ++;
var mapping = modules[moduleId].mapping;
var fn = modules[moduleId].fn;
function _require(id) {
var mId = mapping[id];
if (count[mId] >= 2) return {};
return require(mId);
}
var module = { exports: {} };
fn(_require, module, module.exports);
return module.exports;
}
require('${ entry }');
} ({${ modules }})`;
await mkdir(config.output.path);
await writeFile(`${ config.output.path }/${ config.output.filename }`, bundle);
}
async function main () {
await writeJsBundle(config.entry);
}
main().catch(console.error);
https://github.com/sundial-dreams/mypack
babel: https://babeljs.io/
前端模块化: https://juejin.im/post/5c17ad756fb9a049ff4e0a62
手写一个js打包器: https://juejin.im/post/5e04c935e51d4557ea02c097