实现mini-webpack
最近阳的人好多呀,记得以前阳过的人比较稀奇,现在别人听见我还没阳过都很诧异哈哈。我也不知道是我抵抗力强还是防护得比较好,本来以为我可能是无症状,但是刚刚用了抗原测,只有一道杠。身体是本钱呐,要照顾好呀。
好多人都在催我阳,我偏不!!!妹的
好了就扯到这里,进入正题!
为了尽量表达清楚代码量还有注释比较多,很快就能看完的,要坚持!!! 源码链接在文章末尾哈!
食用过程中如果有问题或者表达不清楚的地方,欢迎指出,十分感谢!
要实现mini-webpack,肯定需要先知道webpack是什么。
引用官方文档的话。webpack 是一个用于现代 JavaScript 应用程序的 静态模块打包工具。
当 webpack 处理应用程序时,它会在内部从一个或多个入口点构建一个 依赖图(dependency graph),然后将你项目中所需的每一个模块组合成一个或多个
bundles,它们均为静态资源,用于展示你的内容。
简单来说,webpack会通过程序的入口文件,将程序的所有资源整合起来,输出一个整合后的资源。
webpack基础打包功能
打包主要分成两大步骤:1.构建依赖关系图 2.根据依赖关系图生成代码
构建依赖关系图流程
- 读取入口文件并获取其内容
- 解析文件内容,得到一个抽象语法树
- 从抽象语法树上获取入口文件与其他文件的依赖关系(将这个依赖关系的信息称为asset)
- asset中包含该文件的 路径-filePath、源码-source、依赖的文件列表-deps(入口文件所依赖的文件的相对路径组成的数组)
- 创建依赖关系表(获取所有模块之间的依赖关系)
- 创建一个队列,队列中保存所有模块的依赖关系(asset组成的一个数组),开始只有入口文件的asset
- 遍历这个队列,逐个处理asset(目前只有一个入口文件的asset,没关系,遍历的过程中还会添加其他文件的asset)
- 每个asset中都有一个deps,保存这个asset对应的模块依赖的其他文件,遍历这个deps,可以拿到依赖的每个文件的相对路径, 使用 path.resolve() 和 依赖文件的相对路径 获取到这个文件的位置进行下一步操作
- 现在已经可以拿到入口文件所依赖文件的相对路径,使用这个(些)相对路径做与入口文件相同的操作,可以得到依赖文件的asset
- 将依赖文件的asset添加到队列中(对依赖的所有文件都进行上一步的操作,实际上是一个递归处理,直到处理完所有用到的文件)
- 到这里可以拿到所有模块的依赖关系表了,先实现构建依赖关系图的功能
实现构建依赖关系图
实现之前呢,先做好准备工作,上面流程中的操作需要安装一些包。
- 读取文件:fs模块-node内置不用安装
- 拼接路径:path模块-node内置不用安装
- 根据源码生成抽象语法树:@babel/parser
- 从抽象语法树中获取依赖模块的相对路径:@babel/traverse
需要安装
@babel/parser和@babel/transver,yarn add @babel/parser @babel/transver,接下来可以实现了。
需要打包的文件目录结构
|- dist dist目录必须创建,否则生成文件的时候可能会因为找不到dist目录而写入失败
|- bundle.ejs
|- index.js
|- example
|- index.html
|- main.js
|- foo.js
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>mini-webpack</title>
</head>
<body>
<script src="main.js"></script>
<!--<script src="dist/bundle.js"></script>-->
</body>
</html>
main.js
import { foo } from './foo.js'
console.log(data)
foo()
console.log('main.js')
foo.js
export function foo () {
console.log('foo')
}
根据模块相对路径创建模块的asset index.js:
function createAsset(filePath) {
// 获取源码
const source = fs.readFileSync(filePath, { encoding: 'utf-8' })
// 根据源码生成抽象语法树
const ast = parser.parse(source, { sourceType: 'module' })
const deps = [] // 保存该模块的依赖
// 从树上获取文件的依赖信息并保存起来
traverse.default(ast, {
// 遍历抽象语法树上所有的导入语句
ImportDeclaration(path) {
// path.node.source.value就是导入语句中的路径,例如:'./foo.js'
deps.push(path.node.source.value)
}
})
// 返回该模块的asset
return {
filePath,
source,
deps
}
}
根据入口文件asset创建整个系统的依赖关系表 index.js:
function createGraph() {
// 创建入口文件的asset
const mainAsset = createAsset('./example/main.js')
/*
* 接下来基于上一步获取到的依赖信息,继续查找依赖关系
* 上一步获取到了main.js依赖的信息-foo.js
* 接下来遍历mainAsset.deps,获取里面的依赖信息,即foo.js的依赖信息
* */
// 开始处理模块的依赖关系,从入口文件开始
const queue = [mainAsset] // 用来保存所有模块,在下边遍历的时候会给队列中添加数据
// 遍历一个队列,在遍历时队列长度会增长
for (const asset of queue) {
// 遍历每个模块的依赖项,处理所有模块的依赖关系
asset.deps.forEach(relativePath => { // 遍历每个模块的依赖项
// 根据依赖项的绝对路径创建该模块的信息
const childAsset = createAsset(path.resolve('./example', relativePath))
// 将创建的依赖模块信息添加到队列中(因为依赖的模块中还可能依赖其他模块)
queue.push(childAsset)
})
}
// 返回处理后的所有模块组成的关系图
return queue
}
这里已经可以根据入口文件获取整个系统的所有依赖关系了。获取的依赖关系数据:
[
{
"filePath": "./example/main.js",
"source": "import { foo } from './foo.js'\n\nconsole.log(data)\n\nfoo()\n\nconsole.log('main.js')\n",
"code": "\"use strict\";\n\nvar _foo = require(\"./foo.js\");\n\nconsole.log(data);\n(0, _foo.foo)();\nconsole.log('main.js');",
"deps": [
"./foo.js"
],
"mapping": {
"./foo.js": 1
},
"id": 0
},
{
"filePath": "D:\\relax\\powerful\\example\\foo.js",
"source": "export function foo () {\n console.log('foo')\n}\n",
"code": "\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports.foo = foo;\n\nfunction foo() {\n console.log('foo');\n}",
"deps": [],
"mapping": {},
"id": 1
}
]
生成代码的准备工作
在生成代码之前应该先确定好如何组织所有的代码,以及如何生成文件。
这一步先自己手动将所有文件组织起来。
1.将所有代码组织起来实际上就是将所有文件合并到一个文件中
bundle.js:
// main.js的内容
import { foo } from './foo.js'
foo()
console.log('main.js')
// foo.js的内容
export function foo () {
console.log('foo')
}
2.要避免同名的变量发生冲突,因此将每个文件中的代码单入放入一个函数中
bundle.js:
function mainJs() {
import { foo } from './foo.js'
foo()
console.log('main.js')
}
function fooJs() {
export function foo () {
console.log('foo')
}
}
3.ESM模块化只能写在文件开头,但是CJS的可以在函数内部,所以把ESM改成CJS(不是使用CJS,而是模拟CJS)
bundle.js:
function mainJs() {
const { foo } = require('./foo.js')
foo()
console.log('main.js')
}
function fooJs() {
function foo () {
console.log('foo')
}
module.exports = {
foo
}
}
4.这一步需要实现自己的require,用来根据每个模块的相对路径来获取这个模块的内容(即该模块对应的函数)
bundle.js:
function require (filePath) {
const map = {
'./foo.js': fooJs,
'./main.js': mainJs
}
// 根据文件路径获取对应的函数
const fn = map[filePath]
// 执行
fn()
}
// 这里就相当于整个文件的执行入口
require('./main.js')
function mainJs() {
const { foo } = require('./foo.js')
foo()
console.log('main.js')
}
function fooJs() {
function foo () {
console.log('foo')
}
module.exports = {
foo
}
}
5.上面代码整个流程是看起来是通了,但是前面说过只是模拟CJS,所有需要为每个模块的函数提供 require、module
bundle.js:
function require (filePath) {
const map = {
'./foo.js': fooJs,
'./main.js': mainJs
}
// 根据文件路径获取对应的函数
const fn = map[filePath]
const module = {
exports: {}
}
// 执行
fn(require, module, module.exports)
// require作为导出模块的入口,所以应该返回module.exports
return module.exports
}
// 这里就相当于整个文件的执行入口
require('./main.js')
// exports 就是 module.exports 的简写
function mainJs(require, module, exports) {
const { foo } = require('./foo.js')
foo()
console.log('main.js')
}
function fooJs(require, module, exports) {
function foo () {
console.log('foo')
}
module.exports = {
foo
}
}
6.重构,将上面的代码改为立即执行函数
bundle.js:
(
function (map) {
function require (filePath) {
// 根据文件路径获取对应的函数
const fn = map[filePath]
const module = {
exports: {}
}
// 执行
fn(require, module, module.exports)
// require作为导出模块的入口,所以应该返回module.exports
return module.exports
}
// 这里就相当于整个文件的执行入口
require('./main.js')
}
)({
'./foo.js': function (require, module, exports) {
function foo () {
console.log('foo')
}
module.exports = {
foo
}
},
'./main.js': function (require, module, exports) {
const { foo } = require('./foo.js')
foo()
console.log('main.js')
}
})
到这一步 代码组合完毕,可以看到需要动态改变的只是这个立即执行函数的参数对象,对象中的key和value中的函数体部分需要改变。
举个例子:
const params = {
'./main.js': // 这一行的key
function (require, module, exports) {
// 这行以下
const { foo } = require('./foo.js')
foo()
console.log('main.js')
// 这行以上
}
}
执行上面代码输出:
foo
main.js
根据依赖关系图生成代码
如何生成代码
这一步已经知道应该怎么组织将要生成的代码,接下来就是如何生成代码
因为组织代码的时候已经将ESM模块化的语法改成了CJS,之前创建模块asset代码的功能需要调整,因为生成的asset中包含代码内容source。
将ESM转为CJS需要使用
babel-core中的 transformFromAst 方法,转CJS时需要传入一个配置对象,对象中的presets设置为['env'], presets的值为['env']时需要额外安装一个包,babel-preset-env。
安装yarn add babel-core babel-preset-env
index.js:
function createAsset(filePath) {
// 获取源码
const source = fs.readFileSync(filePath, { encoding: 'utf-8' })
// 根据源码生成抽象语法树
const ast = parser.parse(source, { sourceType: 'module' })
const deps = [] // 保存该模块的依赖
// 从树上获取文件的依赖信息并保存起来
traverse.default(ast, {
// 遍历抽象语法树上所有的导入语句
ImportDeclaration(path) {
// path.node.source.value就是导入语句中的路径,例如:'./foo.js'
deps.push(path.node.source.value)
}
})
// TODO 将 esm 改为 cjs,加入ESM转CJS的逻辑
const { code } = transformFromAst(ast, null, {
presets: ['env']
})
// 返回该模块的asset
return {
filePath,
source, // ESM的源码
code, // CJS的源码
deps
}
}
上面不是已经有了手写的模板文件了嘛,最后可以通过这个模板文件和 ejs 来生成打包结果。
安装ejs yarn add ejs
处理模板中的动态内容, bundle.ejs:
(map => {
function require(filePath) {
const fn = map[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"] %>
}
<% }) %>
}
)
根据模板之前生成的依赖关系图和模板使用 ejs 生成打包结果 index.js:
// 根据所有模块的依赖信息生成打包后的代码
function build (graph) {
// 读取模板
const template = fs.readFileSync('./bundle.ejs', { encoding: 'utf-8' })
// 处理成模板渲染需要的数据 { filePath: string, code: string }[]
const data = graph.map(asset => {
const { code, filePath } = asset
return {
code, filePath
}
})
// 根据模板和处理后的数据渲染代码
const code = ejs.render(template, { data })
// 写入
fs.writeFileSync('./dist/bundle.js', code)
}
到这里,打包功能就已经ok啦! 这是自动打包生成的文件。 dist/bundle.js:
(map => {
function require(filePath) {
const fn = map[filePath]
const module = {
exports: {}
}
fn(require, module, module.exports)
return module.exports
}
require('./main.js')
})(
{
"./example/main.js": function (require, module, exports) {
"use strict";
var _foo = require("./foo.js");
console.log(data);
(0, _foo.foo)();
console.log('main.js');
},
"D:\relax\powerful\example\foo.js": function (require, module, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.foo = foo;
function foo() {
console.log('foo');
}
},
}
)
修改打包策略
不对,好像还有一个问题,就是传入立即执行函数的参数对象中的key,它不一定是相对路径。因此需要改变一下传参的这个策略。 还存在一个问题就是可能存在同名的文件夹(比如foo.js),其他文件引入的时候相对路径可能也是 './foo.js' ,因此需要改变 当前的策略,给每个模块一个id(作为map的key),值是一个数组,第一项是该模块的函数,第二项是该模块依赖的信息。
原来的参数案例
map = {
'./foo.js': function foojs(require, module, exports) {
function foo() {
console.log('foo')
}
module.exports = {
foo
}
},
'./main.js': function mainjs(require, module, exports) {
const {foo} = require('./foo.js')
foo()
console.log('main.js')
}
}
修改后的参数案例
map = {
1: [function foojs(require, module, exports) {
function foo() {
console.log('foo')
}
module.exports = {
foo
}
}, {}], // foo.js 没有依赖
2: [function mainjs(require, module, exports) {
const {foo} = require('./foo.js')
foo()
console.log('main.js')
}, { './foo.js': 1 }] // main.js依赖了foo.js
}
// 修改后的参数
const params = {
"模块ID": [
"模块对应的代码,一个函数,就是原来的value",
"该模块的依赖信息,一个对象,对象的key是依赖模块的相对路径,对象的value是依赖模块的ID"
]
}
修改打包策略,自然也需要修改打包的模板文件
新增了一个localRequire函数,有必要说一下:
// 每次调用require的时候,比如 var _foo = require("./foo.js")
// 先根据mapping基于模块路径查找到模块id
// 在根据模块id找到 fn 和 该模块 的mapping
function localRequire (filePath) {
const id = mapping[filePath]
return require(id)
}
根据上面的params看这行代码,将fn和mapping结构出来 const [fn, mapping] = map[id],只是
取数据的方式发生一些小变化,整体逻辑并没有太大变化
bundle.ejs:
(map => {
function require(id) {
const [fn, mapping] = map[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"])%>],
<% }) %>
}
)
实现新的打包策略
index.js:
// 声明一个全局变量,作为模块id
let id = 0
// 更新创建模块依赖信息的函数
function createAsset(filePath) {
// 获取文件内容
let source = fs.readFileSync(filePath, { encoding: 'utf-8' })
// 获取依赖关系
// 获取抽象语法树
const ast = parser.parse(source, { sourceType: 'module' })
const deps = [] // 保存模块的依赖
// 2-2.从树上获取文件的依赖信息并保存起来
traverse.default(ast, {
// 回调
ImportDeclaration(path) {
deps.push(path.node.source.value)
}
})
// 将 esm 改为 cjs
const { code } = transformFromAst(ast, null, {
presets: ['env'] // 需要安装babel-preset-env
})
return {
filePath,
source, // ESM 转换前
code, // ESM 转 CJS 之后
deps, // 依赖
mapping: {}, // 用来保存当前模块依赖的其他模块和依赖模块的id
id: id++ // 模块id
}
}
// 更新创建所有依赖项关系图的函数
function createGraph() {
const mainAsset = createAsset('./example/main.js')
/*
* 接下来基于上一步获取到的依赖信息,继续查找依赖关系
* 上一步获取到了main.js依赖的信息-foo.js
* 接下来遍历mainAsset.deps,获取里面的依赖信息,即foo.js的依赖信息
* */
// 开始处理模块的依赖关系,从入口文件开始
const queue = [mainAsset] // 用来保存所有模块,在下边遍历的时候会给队列中添加数据
// TODO 遍历一个队列,在遍历时队列长度发生变化
for (const asset of queue) {
// 遍历每个模块的依赖项,处理所有模块的依赖关系
asset.deps.forEach(relativePath => { // 遍历每个模块的依赖项
// 根据依赖项的绝对路径创建该模块的信息
const child = createAsset(path.resolve('./example', relativePath))
// 将当前模块(asset)的依赖信息保存到mapping中
// 当前模块依赖作为key,依赖的模块的id作为值
asset.mapping[relativePath] = child.id
// 将创建的依赖模块信息添加到队列中(因为依赖的模块中还可能依赖其他模块)
queue.push(child)
})
}
// 返回处理后的所有模块
return queue
}
// 更新生成代码的函数
function build (graph) {
// 读取模板
const template = fs.readFileSync('./bundle.ejs', { encoding: 'utf-8' })
// 处理成模板渲染需要的数据 { filePath: string, code: string }[]
// 处理成模板渲染需要的数据 { id, code, mapping }[]
const data = graph.map(asset => {
const { id, code, mapping } = asset
return {
id, code, mapping
}
})
// 根据模板和处理后的数据渲染代码
const code = ejs.render(template, { data })
// 5-4写入
fs.writeFileSync('./dist/bundle.js', code)
}
更新完成!!!
最后执行打包功能:node index.js
生成代码:
dist/bundle.js
(map => {
function require(id) {
const [fn, mapping] = map[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");
console.log(data);
(0, _foo.foo)();
console.log('main.js');
}, {"./foo.js":1}],
1: [function (require, module, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.foo = foo;
function foo() {
console.log('foo');
}
}, {}],
}
)
源码:传送门
过几天分享loader和plugin的逻辑,这一篇看明白了的话,loader和plugins很简单的喔。
源码目录目前有一丢丢乱,明天上班就整理好并且加上一个索引说明。(可以不好好工作,但是必须好好学习!)