1 什么是bundler
diff
- 打包器
2 打包器有什么用?
现在的前端开发,文件多,多人开发。如何避免命名冲突和提高代码的可读性和可维护性就很重要,不是像以前script src 指定src值的方式引入不同的js代码了。就需要一个人能统一这个乱世, 打包器就横空出世了。解决了什么问题?
如图所示: 解决了 让我们开发的时候,可以写模块,可以拆分文件(esm commongjs 一个文件就是一个模块 私有的 同名变量在多个文件里面不冲突,其他模块想要使用属性或者方法 需要模块内通过当前是什么模块标准,按照对应的模块标准导出即可 ,其他使用的地方,按照模块导入语法导入使用即可,其他语言里面默认支持的功能, js es6才有),并且可以在浏览器里面运行。 就是这个目的。
3 bundler有哪些?
- 这里我们只谈webpack
4 webpack里面怎么实现的?
- 解析一个文件,找出它的依赖文件
- 递归构建依赖图
- 根据依赖图谱,将所有的最终打包成一个文件
依赖图谱一般长这样:
对应的数据结构就是 图
这就是实现思路
根据实现思路,建好对应的文件
markdown
/example
- entry.js 入口文件 里面引入message.js
- message.js 里面引入name.js
- name.js 里面导出name 变量
bundle.js 实现具体逻辑的地方
entry.js
js
import message from "./message.js";
console.log(message);
message.js
message.js
js
import { name } from "./name.js";
export default `hello ${name}`;
name.js
js
export const name = "前端关宇";
bundle.js
- fs 有读取文件的能力, 调用方法读取entry.js里面的内容, utf-8 的形式展示
- 将读取的内容(就是字符串)转成 ast (抽象语法树) 本质就是一个js对象或者叫树
- 拿到ast 构建依赖图
1. 里面的实现
js
const fs = require("fs");
function createAsset(filename) {
const fileContent = fs.readFileSync(filename, "utf-8");
console.log(fileContent);
console.log(typeof fileContent); // string
}
createAsset("./example/entry.js");
根目录下执行 node bundle.js
查看结果 发现读取的内容是字符串
将读取的到的内容 复制到astexplorer.net/ 左侧是源码 右侧是解析好的ast 可以看到 引入的语句 对应的部分 顶部中间位置 显示@babel/parser 意思就是通过这个库,解析成的ast , 点击展开也可以选择不同的其他的库,解析出来的格式不太一样。无论用哪一个,目的就是解析字符串成ast对象。
里面可以找到从哪个文件引入的信息 这里就是 ./message.js 对应右侧 source 对象里面的value属性
左右是对应高亮的 , 当你hover对应部分的时候, 左右会对应起来。方便你看结果。
当我写两个引入语句的时候, 右侧同样会在ast里面解析出来有两个引入。
2. 里面的实现,code string to ast
很多库可以实现, 比如 代码字符串转ast 使用babel parser npm i @babel/parser
很多依赖大佬的 acorn 这个大佬的 作品 ProseMirror codemirror acorn js parser 还有看过他的书的话一定不陌生的:
遍历ast 使用 babel traverse npm i @babel/traverse
目前函数长这样, 大概就是拿到字符串 输入字符串输出ast 输入ast得到deps数组 里面是依赖的文件路径 函数放回一个对象, 有三个属性 id , filename ,deps
输入字符串输出ast 的处理
输入ast得到deps数组 的处理 这里面接口提供了hooks ,使我们传递对应的 hooks name 和 callback 就可以得到符合条件的node 节点。 我们需要的是导入申明 callback里面可以解构拿到node节点, 节点里面有属性可以拿到 依赖的文件的路径的值
整体代码
js
const fs = require("fs");
const { parse } = require("@babel/parser");
const traverse = require("@babel/traverse").default;
function createAst(filename) {
const fileContent = fs.readFileSync(filename, "utf-8");
console.log(fileContent);
return parse(
fileContent,
//https://babel.dev/docs/babel-parser search sourceType
{ sourceType: "module" }
);
}
function genDeps(ast) {
//遍历ast 收集依赖
let deps = [];
traverse(ast, {
ImportDeclaration: ({ node }) => {
let depFileName = node.source.value;
deps.push(depFileName);
},
});
return deps;
}
let ID = 0;
function createAsset(filename) {
const ast = createAst(filename);
let deps = genDeps(ast);
const id = ID++;
return {
id,
filename,
deps,
};
}
const mainAsset = createAsset("./example/entry.js");
console.log(mainAsset); //{ id: 0, filename: './example/entry.js', deps: [ './message.js' ] }
构建队列 里面每一项是一个对象 包含一些属性
目前返回的队列长这样
数组对象里面 对象每一个里面有id 属性 filename 属性 和 deps 依赖 还多一个属性 mapping 记录 当前 文件名和依赖的id 上面createGraph 里面 给asset 添加的属性 mapping 在遍历的时候,填充的这个asset.mapping 里面的key 和value
下一步 实现bundle方法 里面输入是graph 输出是一个js
- 实现之前有一个细节要处理, 我们写的代码 有的使用import 语法 有的使用require , 不统一, 我们需要实现统一,比如最后都使用require语法来引入 代码, 这里就需要使用babel 将语法统一转换成require使用
- 需要安装 npm i @babel/core @babel/preset-env
里面加入 getCode 并且返回code
js
function createAsset(filename) {
const ast = createAst(filename);
let deps = genDeps(ast);
const id = ID++;
let code = getCode(ast);
console.log("code", code);
return {
id,
filename,
deps,
code
};
}
安装 @babel/core @babel/preset-env 以后 顶部引入babel
调用babel.transformFromAst方法 传入ast 和对应的参数 指定预设 presets 为 @babel/preset-env
调用后 输出code ,得到的code 就是都是require语法的了。
输出graph 可以看到 返回的队列是长这样: 每一个对象里面多了一个code属性,并且里面的模块导入和导出都是使用的require module.exports 语法了
为什么要统一成 require module.exports 语法?
一个是为了统一。 嗯 。好像没说一样
一个是为了 统一以后,使用commonjs 模块规范, 里面有require module module.exports . 方便对require函数进行重写, webpack内部也是自己实现了一个require函数 来负责模块的导入,现在就是达到了,不管用户用什么模块标准写, 来到我这里打包的时候,我都给你统一处理成require , 然后再用我自己写的require 巧妙吧!
完成bundle函数
因为浏览器里面只能识别es5 的js , es5里面实现模块化 可以通过自执行函数,利用函数作用域的方式, 这里也这么实现。
入参是graph 里面是一个数组对象
目前就是在构造 modules 字符串 , 从graph 里面取出需要的数据 , 构造完应该是一个对象, 作为入参, 传递给自执行函数里面
输出的结果是这样的
发现code里面的 require('./message.js') 这里面的参数'./message.js' 不能写死,要传入用户动态写的, 比如下次用户写 .a/b.js 这里就要变化, 其实我们之前在构建graph里面 有对每一个asset 添加了mapping 这里面记录了这个变化的值, 所以拿过来构造一下返回即可。
将asset里面的mapping对象,字符串化, 并且作为value数组的第二个参数返回即可
这是现在构建出来的结果:
modules实参就是刚才构建好的 入参对象 key是id value是一个数组,数组第一项是function(require){}那个函数, 第二项是变化的值 mapping
接下来就要实现require函数了,接收的参数是模块id, 然后调用require函数, 因为模块id是从0开始, 所以传入0
这就是webpack的精华了, 自己实现了require函数 里面将传入的fn调用, 并且在module.exports对象上添加了各个模块里面写的属性和方法。 最后导出module.exports
最后! 将构建好的结果(下面这段js) 放在浏览器控制台里面运行 , 你就可以看到结果了!
js
(function (modules) {
function require(id){
//获取传入对象里面的 一个模块 , 从模块数组里面取出fn 和 mapping
const [fn, mapping] = modules[id]
//定义自己的导入函数
function localRequire(relativePath){
let nextId = mapping[relativePath]
return require(nextId)
}
//自己构造一个module 并且返回
const module = {exports: {} };
//调用传入的参数 并且
//注入自己实现的localRequire module
fn(localRequire, module, module.exports)
//返回对象
return module.exports
}
require(0)
})({
0 :
[
function(require,module, exports){"use strict";
var _message = _interopRequireDefault(require("./message.js"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
console.log(_message["default"]);},
{"./message.js":1}
],
1 :
[
function(require,module, exports){"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
var _name = require("./name.js");
var _default = exports["default"] = "hello ".concat(_name.name);},
{"./name.js":2}
],
2 :
[
function(require,module, exports){"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.name = void 0;
var name = exports.name = "前端关宇";},
{}
],
})
结果是:
总结
这里就实现了一个简单的webpack
核心就三步:
- 1 将用户写的代码字符串 读取成ast, 一维的代码变成内存里面嵌套的树
- 2 根据ast 递归构建依赖图,这里使用了队列, 嵌套的树变成一维的数组对象
- 3 将数组对象整合成一个大的js输出
涉及到的知识点有:
- 转译器的使用 babel-parser
- 如何遍历ast
- 如何将代码转换成commonjs语法 使用babel/preset-env
- nodejs基础api fs path 的使用
- package.json scripts的使用
- webpack本质是一个nodejs的包,解决了多模块打包成一个浏览器可以识别的大的模块,并且通过构建依赖图,解决了不同js模块之间的手动维护依赖关系的难题,为什么要打包成一个模块?从网络的角度,只需要建立一次连接,避免频繁的请求多个文件的时候,tcp 建立和断开连接
- es6 语法的使用 包括模版字符串的使用 函数 iife自执行函数可以充当作用域等
- 数组的使用, 解构赋值
- 数据结构队列的使用
- 递归的使用
- 其实写了一遍这个,es6语法就掌握大部分了。
- 推荐你自己实现一遍,知其然知其所以然。
happy coding!
完整代码
js
const fs = require("fs");
const path = require("path");
const { parse } = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const babel = require("@babel/core");
function createAst(filename) {
const fileContent = fs.readFileSync(filename, "utf-8");
console.log(fileContent);
return parse(
fileContent,
//https://babel.dev/docs/babel-parser search sourceType
{ sourceType: "module" }
);
}
function genDeps(ast) {
//遍历ast 收集依赖
let deps = [];
traverse(ast, {
ImportDeclaration: ({ node }) => {
let depFileName = node.source.value;
deps.push(depFileName);
},
});
return deps;
}
function getCode(ast) {
const { code } = babel.transformFromAst(ast, null, {
presets: ["@babel/preset-env"],
});
return code;
}
let ID = 0;
function createAsset(filename) {
const ast = createAst(filename);
let deps = genDeps(ast);
const id = ID++;
let code = getCode(ast);
return {
id,
filename,
deps,
code,
};
}
function handledeps(deps, asset, dirname, queue) {
//asset 的数据结构 是一个对象 里面有{id, filename, deps} 三个属性
deps.forEach((relativePath) => {
//获取绝对路径
const absolutePath = path.join(dirname, relativePath);
// console.log(absolutePath, relativePath); //example/message.js ./message.js
//创建子内容 返回一个对象 里面有{id, filename, deps} 三个属性
const childAsset = createAsset(absolutePath);
//构建映射
asset.mapping[relativePath] = childAsset.id;
//向队列里面添加子内容 继续处理 因为子内容里面 可能还有依赖其他模块
queue.push(childAsset);
});
}
//entry 就是上面的filename 值就是这样的 ./example/entry.js
function createGraph(entry) {
const mainAsset = createAsset(entry);
const queue = [mainAsset]; //声明一个队列
//构建队列
for (let asset of queue) {
let deps = asset.deps;
const dirname = path.dirname(asset.filename);
//添加一个mapping属性 用于做映射 数据结构是 key: relativePath , value : childAsset.id
asset.mapping = {};
//deps的数据结构 string[] ['./message.js'] 这种
handledeps(deps, asset, dirname, queue);
}
//返回队列
return queue;
}
const entry = "./example/entry.js";
const graph = createGraph(entry);
console.log(graph.length);
const result = bundle(graph);
function bundle(graph) {
let modules = "";
//moduleObject : {id,filename, code, deps, mapping }
graph.forEach(({ id, code, mapping }) => {
let key = `${id}`;
let value = `
[
function(require,module, exports){${code}},
${JSON.stringify(mapping)}
],
`;
modules += `${key} : ${value}`;
});
const result = `
(function (modules) {
function require(id){
//获取传入对象里面的 一个模块 , 从模块数组里面取出fn 和 mapping
const [fn, mapping] = modules[id]
//定义自己的导入函数
function localRequire(relativePath){
let nextId = mapping[relativePath]
return require(nextId)
}
//自己构造一个module 并且返回
const module = {exports: {} };
//调用传入的参数 并且
//注入自己实现的localRequire module
fn(localRequire, module, module.exports)
//返回对象
return module.exports
}
require(0)
})({
${modules}
})
`;
return result;
}
console.log("result", result);