这次来实现webpack,webpack的基本功能是从main.js出发,收集依赖,把所有js文件合并到bundle.js中。假设有main.js和foo.js两个文件:
main.js:
js
// 使用esm模块化,需要在package.json设置"type": "module",
// 需要带.js,否则解析出的相对路径不带.js,找不到文件
import { foo } from './foo.js';
foo();
console.log('main');
foo.js:
js
export function foo() {
console.log('foo');
}
目录结构:
markdown
- dist
- bundle.js
- example
- foo.js
- main.js
index.js
bundle.ejs
依赖收集
因为bundle.js中包含了所有用到的js文件的代码,所以必须通过import语句,收集到该文件依赖了哪些其他的文件,构建出Graph。
首先实现函数createAsset,每个文件都创建一个asset对象。
先调用fs.readFileSync
读取main.js文件,把文件内容传入parser.parse
(使用@babel/parser),构建出AST,然后遍历AST(使用@babel/traverse),当遇到类型为"ImportDeclaration"的结点时,其node.source.value属性保存了相对路径,把它添加到deps数组中。最后返回构建好的asset。
js
function createAsset(filePath) {
const source = fs.readFileSync(filePath, {
encoding: 'utf-8'
});
const ast = parser.parse(source, {
// 因为用到了import,必须设置此选项,否则报错
sourceType: 'module'
});
const deps = [];
// 遍历ast的结点
traverse.default(ast, {
// 处理到该类型结点时,执行此回调
ImportDeclaration({ node }) {
deps.push(node.source.value);
}
});
return {
filePath,
source,
deps,
};
}
运行,打印出main.js的deps为['./foo.js']。
遍历图
收集每个asset的deps后,图实际上已经建立了。我们使用BFS遍历这个图,把所有的asset收集到数组中,用于bundle.js中代码的生成。
js
function createGrapth() {
const mainAsset = createAsset('./example/main.js');
const queue = [mainAsset];
for (const asset of queue) {
asset.deps.forEach((relativePath) => {
// path.resolve得到的是绝对路径
// 此处 './example/' + relativePath 会更简单
const child = createAsset(path.resolve('./example', relativePath));
queue.push(child);
});
}
// queue中收集了所有的asset
return queue;
}
const graph = createGrapth();
现在graph中就包含了"main.js"和"foo.js"对应的两个asset。
合并代码
先让我们手动把两个文件的代码合并起来,能够正常运行。
直接把代码放在一起肯定是不行的,可能产生命名冲突。
js
// 直接合并
import { foo } from './foo.js';
foo();
console.log('main');
export function foo() {
console.log('foo');
}
可以在原先的文件内容外面裹一层函数,靠函数作用域规避命名冲突。但是import和export语句只能在最外层,因此要转换成cjs的写法。
再实现require:
js
// 这是我们自己实现的bundle.js
function require(filePath) {
const map = {
'./main.js': mainjs,
'./foo.js': foojs
}
const fn = map[filePath];
const module = {
exports: {}
}
fn(require, module, module.exports);
return module.exports;
}
require('./main.js');
function mainjs(require, module, exports) {
const { foo } = require('./foo.js');
foo();
console.log('main');
}
function foojs(require, module, exports) {
function foo() {
console.log('foo');
}
module.exports = {
foo
};
}
直接在index.html中引入该bundle.js,可以正常运行,控制台依次打印foo和main。
运行流程:require中传入'./main.js',拿到mainjs并执行,遇到require,传入了'./foo.js',拿到foojs并执行,设置module.exports,require将exports返回,赋值给mainjs中的foo,然后执行foo函数,输出'foo',最后输出'main'。
稍微修改写法,现在上边是固定的,只有最下方提供的modules参数需要动态修改。
js
(function (modules) {
function require(filePath) {
const fn = modules[filePath];
const module = {
exports: {}
};
fn(require, module, module.exports);
return module.exports;
}
require('./main.js');
})({
'./main.js': function (require, module, exports) {
const { foo } = require('./foo.js');
foo();
console.log('main');
},
'./foo.js': function (require, module, exports) {
function foo() {
console.log('foo');
}
module.exports = {
foo
};
}
});
实现build函数,graph稍加转换生成data,把模板和data传给ejs(需要安装ejs),生成代码,并保存到dist/bundle.js中。
js
function build(graph) {
const data = graph.map(({ id, code, mapping }) => {
return {
filePath,
code,
mapping
};
});
const template = fs.readFileSync('./bundle.ejs', { encoding: 'utf-8' });
// 根据template生成代码,data必须包裹在对象中
const code = ejs.render(template, { data });
fs.writeFileSync('./dist/bundle.js', code);
}
build(graph);
此时有一个注意点,我们在自己实现的bundle.js中,原先函数中使用的import/export都被替换成了cjs的语法,但是在代码中,如何让每个asset的code自动作这种转换呢?还是使用babel。需要安装babel-core和babel-preset-env。
js
function createAsset(filePath) {
// ...
// import -> require
const { code } = transformFromAst(ast, null, { presets: ['env'] });
return {
filePath,
code, // 现在code就符合cjs规范了
deps,
};
}
构建ejs模板:
ejs
(function (modules) {
function require(filePath) {
const fn = modules[filePath];
const module = {
exports: {}
};
fn(require, module, module.exports);
return module.exports;
}
require('./main.js');
})({
<% data.forEach(info => { %>
<%- 'info['filePath']' %>: function (require, module, exports) {
<%- info['code'] %>
},
<%});%>
});
在终端输入"node index.js"运行,发现确实生成了dist/bundle.js,但是此时并不能在浏览器中运行,因为filePath使用了本机的绝对路径,在live-server上找不到。解决方案有二:1. 前面解析路径时,不使用path.resolve。2. 添加一层映射,给每个asset添加id,modules对象的key用id表示。
使用方法二,需要给asset添加id和mapping属性,id设置全局变量,不断自增。
js
let id = 0;
function createAsset(filePath) {
// ...
return {
filePath,
code,
deps,
mapping: {},
id: id++
};
}
还需要给mapping添加映射关系,最后mapping的结构类似于是:{'./foo.js': 1 }
,key是asset的相对路径,值是其id。
js
function createGrapth() {
const mainAsset = createAsset('./example/main.js');
const queue = [mainAsset];
for (const asset of queue) {
asset.deps.forEach((relativePath) => {
const child = createAsset(path.resolve('./example', relativePath));
// 添加映射关系
asset.mapping[relativePath] = child.id;
queue.push(child);
});
}
return queue;
}
然后require需要传入id,modules中的每个成员变为数组。还要提供接收filePath的localRequire函数,作一层中间转换。
js
(function (modules) {
function require(id) {
const [fn, mapping] = modules[id];
const module = {
exports: {}
};
function localRequire(filePath) {
const id = mapping[filePath];
return require(id);
}
fn(localRequire, module, module.exports);
return module.exports;
}
require(0);
})({
1: [
function (require, module, exports) {
const { foo } = require('./foo.js');
foo();
console.log('main');
},
{ './foo.js': 2 }
],
2: [
function (require, module, exports) {
function foo() {
console.log('foo');
}
module.exports = {
foo
};
},
{}
]
});
新的ejs模板:
js
(function (modules) {
function require(id) {
const [fn, mapping] = modules[id];
const module = {
exports: {}
};
function localRequire(filePath) {
const id = mapping[filePath];
return require(id);
}
fn(localRequire, module, module.exports);
return module.exports;
}
require(0);
})({
<% data.forEach(info => { %>
<%- info['id'] %>: [function (require, module, exports) {
<%- info['code'] %>
}, <%- JSON.stringify(info['mapping']) %>],
<%});%>
});
生成的bundle.js:
js
(function (modules) {
function require(id) {
const [fn, mapping] = modules[id];
const module = {
exports: {}
};
function localRequire(filePath) {
const id = mapping[filePath];
return require(id);
}
fn(localRequire, module, module.exports);
return module.exports;
}
require(0);
})({
0: [function (require, module, exports) {
"use strict";
var _foo = require("./foo.js");
(0, _foo.foo)(); // 需要带.js,否则解析出的相对路径不带.js,找不到文件
console.log('main');
}, {"./foo.js":1}],
1: [function (require, module, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.foo = foo;
var _bar = require("./bar.js");
function foo() {
console.log('foo');
}
(0, _bar.bar)();
}, {"./bar.js":2}],
2: [function (require, module, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.bar = bar;
function bar() {
console.log('bar');
}
}, {}],
});
现在就能在浏览器正常运行了。
即使修改文件内容,或者在foo.js中添加对bar的依赖(如上所示),都能动态的生成新的bundle.js,把所有文件打包到一起。
总结
打包的核心流程:
- 从main.js出发。
- 读取文件内容,解析代码得到ast,遍历ast,遇到
ImportDeclaration
结点就添加依赖。需要使用babel转换成符合cjs的代码。 - 构建图,图中每个结点都是asset,拥有filePath、code(该文件内的代码)、deps等属性。
- 图结合模板,生成bundle.js。