这是我参与8月更文挑战的第8天,活动详情查看:8月更文挑战
这篇文章断断续续写了几个小时,为了是 bundler 这件事说清楚,主要是因为 bundle 这件事比较琐碎和复杂,打现在为止还不确定我是不是把这件事事情说清楚了,其实对于技术文档因为其内在逻辑性本来就比较严谨,即便这样想要恰当表达还是一件不易的事,想要把一个故事讲清楚清楚想必是一件更难的事情,突然对文学作品的作家产生一种敬畏之心。
首先我们都知道今天对于任何一门语言来说,都少不了包(模块)管理工具,因为对于大型应用,都需要 code 按功能进行拆分来模块化,这种模块化的好处不言而喻,就仅看模块化在开发过程中的好处,模块化可以让大家协同分模块去一起并行开发。
javascript 开始是没有模块的概念,早期 javascript 主要工作给网页添加动态效果和交互的 toy ,可能也没有必要引入模块的概念,不过今天则不同,随着 web 应用变得越来越复杂,更多事情交个前端去完成,而且 nodejs 的出现让 javascript 的领域从前端扩张到后端,这样一来,包管理引入到这门语言也就变得迫在眉睫了。我接触第一个 javascript 包管理工具有 require.js。到了今天 javascript 包管理工具主要有两种概念,大家都去实现这两种概念。
- ES6 module
import _ from 'lodash'
export default someValue;
需要注意的是 ES module 形式需要启动服务,通过 http 请求方式来价值
- Commonjs modules
const _ = require('lodash');
module.exports = someValue
动机
Webpack 是一个很好的 javascript 包管理工具,很多项目都是用 webpack 进行构建,可能最近好像 vue 项目放弃 webpack 而采用 rollup.js 构建工具。接触过早期的 require.js 也用过 webpack 来构建项目,只不过都是停留在使用这个层面上,具体内部是什么机制还不算了解。昨天看到了老外分享关于如何自己实现个 javascript 构建工具的视频,准备和大家一起实现一篇,当然不仅是 copy,对其分享还会就一些关键内容进行展开,并且是当地进行扩展。
思路
要做一件事,就需要先思考怎么做,例如把大象放冰箱里一共分 3 部,那么我们来做项目构建应该如何做,想一想,首先我们需要将 js 文件读入,然后从文件中找到包含依赖的信息,
- 读取 js 将文件进行解析出一个 AST
- 通过 AST 将代码抽象后提取依赖相关信息
- 利用相关信息生成可以遍历依赖关系图
- 利用依赖关系图将代码有效组织在一起
entry.js - message.js - name.js
获取资源对象
我们将每个 javascript 文件看成一个资源,这里 createAsset 方法主要负责读取每一个 javascript 文件(看成资源),然后从资源提取所需信息。
读取 javascript 文件
引入 fs 模块,使用 readFileSync 读取文件内容,同步读取文件。
const fs = require("fs")
function createAsset(filename){
const content = fs.readFileSync(filename,'utf-8');
console.log(content);
}
creatAsset('./example/entry.js');
将文本解析为 AST
这里 createAsset获取资源,将文件以字符串形式读取后赋值给 content 对象。接下来我们需要读取的字符串进行解析,解析成为一个 AST,那么什么是 AST,全称是抽象语法树,也就是 javascript 代码形成一个结构化的语法树,其实 AST 就是普通的 Json 文件,用于描述。
这里我们引入 babylon 这个包,帮助我们将字符串解析成为一个 AST 结构数据。接下来我们需要通过解析文件获取到这个文件依赖了哪些文件,
- File Program body ImportDeclaration
- 每一个结点都有一个 type 类型
const fs = require("fs");
const babylon = require('babylon');
function creatAsset(filename){
const content = fs.readFileSync(filename,'utf-8');
const ast = babylon.parse(content,{
sourceType:'module'
});
console.log(ast);
}
下面是我们解析输出 AST 对象,这就是一个普通 json 对象,我们可以简单阅读一下。每个结点都有一个 type 类型,我们来简单看一下每个结点都包含哪些内容,type 表示结点的类型,start 和 end 表示该结点在 code 中位置,我们来对照 code 来看一下解析出 AST 对象。
import message from './message.js';
console.log(message)
其中有两个集合 comments 和tokens,在 tokens 中存放着一个一个
Node {
type: 'File',
start: 0,
end: 57,
loc: SourceLocation {
start: Position { line: 1, column: 0 },
end: Position { line: 3, column: 20 }
},
program: Node {
type: 'Program',
start: 0,
end: 57,
loc: SourceLocation { start: [Position], end: [Position] },
sourceType: 'module',
body: [ [Node], [Node] ],
directives: []
},
comments: [],
tokens: [
Token {
type: [KeywordTokenType],
value: 'import',
start: 0,
end: 6,
loc: [SourceLocation]
},
Token {
type: [TokenType],
value: 'message',
start: 7,
end: 14,
loc: [SourceLocation]
},
Token {
type: [TokenType],
value: 'from',
start: 15,
end: 19,
loc: [SourceLocation]
},
Token {
type: [TokenType],
value: './message.js',
start: 20,
end: 34,
loc: [SourceLocation]
},
Token {
type: [TokenType],
value: undefined,
start: 34,
end: 35,
loc: [SourceLocation]
},
Token {
type: [TokenType],
value: 'console',
start: 37,
end: 44,
loc: [SourceLocation]
},
Token {
type: [TokenType],
value: undefined,
start: 44,
end: 45,
loc: [SourceLocation]
},
Token {
type: [TokenType],
value: 'log',
start: 45,
end: 48,
loc: [SourceLocation]
},
Token {
type: [TokenType],
value: undefined,
start: 48,
end: 49,
loc: [SourceLocation]
},
Token {
type: [TokenType],
value: 'message',
start: 49,
end: 56,
loc: [SourceLocation]
},
Token {
type: [TokenType],
value: undefined,
start: 56,
end: 57,
loc: [SourceLocation]
},
Token {
type: [TokenType],
value: undefined,
start: 57,
end: 57,
loc: [SourceLocation]
}
]
}
接下里的工作我们来完善一下 createAsset ,我们已经将文本解析为 AST 后,我们再引入 babel-traverse ,目的是在解析好的 AST 上获取到 ImportDeclaration,有关 import 的相关的语句。
/** */
const fs = require("fs");
const babylon = require('babylon');
const traverse = require('babel-traverse').default;
function creatAsset(filename){
const content = fs.readFileSync(filename,'utf-8');
const ast = babylon.parse(content,{
sourceType:'module'
});
traverse(ast,{
ImportDeclaration:({node})=>{
console.log(node);
}
})
// console.log(ast);
}
creatAsset('./example/entry.js');
通过 traverse 提取到了 import 内容,通过这个结点 source.value 可以获取到./message.js 这是我们想要,这里 ImportDeclaration 结点可以获取到文件的依赖关系。
Node {
type: 'ImportDeclaration',
start: 0,
end: 35,
loc: SourceLocation {
start: Position { line: 1, column: 0 },
end: Position { line: 1, column: 35 }
},
specifiers: [
Node {
type: 'ImportDefaultSpecifier',
start: 7,
end: 14,
loc: [SourceLocation],
local: [Node]
}
],
source: Node {
type: 'StringLiteral',
start: 20,
end: 34,
loc: SourceLocation { start: [Position], end: [Position] },
extra: { rawValue: './message.js', raw: "'./message.js'" },
value: './message.js'
}
}
收集到的该文件的依赖信息,都保存在dependencies 数组里,
function creatAsset(filename){
const content = fs.readFileSync(filename,'utf-8');
const ast = babylon.parse(content,{
sourceType:'module'
});
const dependencies = []
traverse(ast,{
ImportDeclaration:({node})=>{
// console.log(node);
dependencies.push(node.source.value);
}
});
console.log(dependencies);
}
[ './message.js' ]
我们 createAsset 返回一个资源对象,这个资源对象包含几个属性,id
、filename 和 dependencies 依赖关系,其中 dependencies 保存该文件所依赖的文件。
let ID = 0;
function createAsset(filename){
const content = fs.readFileSync(filename,'utf-8');
const ast = babylon.parse(content,{
sourceType:'module'
});
const dependencies = []
traverse(ast,{
ImportDeclaration:({node})=>{
// console.log(node);
dependencies.push(node.source.value);
}
});
const id = ID++;
return{
id,
filename,
dependencies,
};
// console.log(dependencies);
}
const mainAsset = creatAsset('./example/entry.js');
console.log(mainAsset)
{
id: 0,
filename: './example/entry.js',
dependencies: [ './message.js' ]
}
创建依赖图
构建文件依赖图,首先将口入资源放到一个队列 (queue),遍历依赖(dependencies) 其中存放着文件依赖的路径,但是这里依赖路径是是相对路径,但是 createAsset 需要接受一个绝对路径作为参数。我们在文件引用的库或者文件时用到路径相对于该文件的路径import message from './message.js';,
function createGraph(entry){
const mainAsset = createAsset(entry);
const queue = [mainAsset];
for(const asset of queue){
const dirname = path.dirname(asset.filename);
asset.mapping = {}
asset.dependencies.forEach(relativePath => {
const absolutePath = path.join(dirname,relativePath);
const child = createAsset(absolutePath);
asset.mapping[relativePath] = child.id
queue.push(child)
});
}
return queue;
}
创建图,通过图形式来表示项目各个文件的依赖关系,这里创建一个方法createGraph(),介绍主入口的文件路径作为参数,然后调用 createAsset 获取包换 id,filename和 dependencies的对象,接下来定义一个 queue,这个数组里存放所有,这里暂且叫资源对象吧,每次获取到资源对象后,我们根据资源对象中依赖数组来获取其依赖文件路径,因为依赖文件路径是相对于引用该文件的路径相对路径,而 creatAsset 读取文件时需要的是绝对路径。所以引入 path 包,通过 const absolutePath = path.join(dirname,relativePath);得到绝对路径。
[
{
id: 0,
filename: './example/entry.js',
dependencies: [ './message.js' ],
mapping: { './message.js': 1 }
},
{
id: 1,
filename: 'example/message.js',
dependencies: [ './name.js' ],
mapping: { './name.js': 2 }
},
{ id: 2, filename: 'example/name.js', dependencies: [], mapping: {} }
]
基于依赖图进行 bundle
所谓 bundle 就是根据依赖图将这些模块文件组织在一起,
function bundle(graph){
let modules = '';
graph.forEach(mod => {
modules + `${mod.i}:[
]`
})
const result = `
(function(){
})({${modules}})
}`
const result = `
(function(){
})({${modules}})
}`
这里返回一个 result 是立即执行函数,传入一个对象作为参数。
因不是所有浏览器对 EMSCscript module 形式,大多数都支持 commonjs 这样包引用的方式,bebel 可以看成代码转换工具,根据你给出presets:['env'] 将代码转换为其他形式。
const {code} = babel.transformFromAst(ast,null,{presets:['env']});
// install babel-core and npm install babel-preset-env --save
return{
id,
filename,
dependencies,
code
};
接下来给每个资源对象添加一个 code 属性和 mapping 属性,这里先给 mapping 埋下一个伏笔,
[
{
id: 0,
filename: './example/entry.js',
dependencies: [ './message.js' ],
code: '"use strict";\n' +
'\n' +
'var _message = require("./message.js");\n' +
'\n' +
'var _message2 = _interopRequireDefault(_message);\n' +
'\n' +
'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n' +
'\n' +
'console.log(_message2.default);',
mapping: { './message.js': 1 }
},
{
id: 1,
filename: 'example/message.js',
dependencies: [ './name.js' ],
code: '"use strict";\n' +
'\n' +
'Object.defineProperty(exports, "__esModule", {\n' +
' value: true\n' +
'});\n' +
'\n' +
'var _name = require("./name.js");\n' +
'\n' +
'exports.default = "hello " + _name.name;',
mapping: { './name.js': 2 }
},
{
id: 2,
filename: 'example/name.js',
dependencies: [],
code: '"use strict";\n' +
'\n' +
'Object.defineProperty(exports, "__esModule", {\n' +
' value: true\n' +
'});\n' +
"var name = exports.name = 'machine learning';",
mapping: {}
}
]
function bundle(graph){
let modules = '';
graph.forEach(mod => {
modules +=`${mod.id}:[
function(require, module, exports){
${mod.code}
}
]`
})
const result = `
(function(){
})({${modules}})
`
return result;
}
const result = `
(function(){
})({${modules}})
`
这里简单介绍一下 result, {${moduels}} ,经过上面对字符串的拼接得到 module 的值类似于 "id:[function],id:[function]... 这种形式,然后我们将解析好字符串放到一个对象。
(function(){
})({0:[
function(require, module, exports){
"use strict";
var _message = require("./message.js");
var _message2 = _interopRequireDefault(_message);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
console.log(_message2.default);
}
],1:[
function(require, module, exports){
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
var _name = require("./name.js");
exports.default = "hello " + _name.name;
}
],2:[
function(require, module, exports){
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
var name = exports.name = 'machine learning';
}
]})
分析构建文件
这里用到递归,对于递归是初学者提升一个标志,
-
首先 module 是一个对象,key 是索引从 0 ,值是一个数组,数组中包含一个函数和对象
-
在 modules 中每一个函数接受三个
require,modules和exportsfunction localRequire(relativePath) { return require(mapping[relativePath]) }
localRequire 会根据输入相对路径,对应到 module 中 id,这里就用到 mapping ,在 mapping 是一个对象,对象键值是模块存放的相对路径 ./messge.js 而值这是该模块对应 id。
这里我们先看定义一个 require 函数接受一个 id 然后调用函数并且传入0 作为参数,我们来看一看 require 里面都做了哪些事情,首先根据 id 从 modules 数组获取 fn 和 mapping,mapping 这是这样一个键值对。在 require内部定义一个localRequire这里根据该文件依赖相对路径所对应 id 来递归调用 require这也是开始我们定义mapping的缘故。定义 module,调用函数并且传入require,module和module.exports。
(function (modules) {
function require(id) {
const [fn, mapping] = modules[id]
function localRequire(relativePath) {
return require(mapping[relativePath])
}
const module = {
exports: {}
};
fn(localRequire, module, module.exports)
return module.exports
}
require(0);
})({
0: [
function (require, module, exports) {
"use strict";
var _message = require("./message.js");
var _message2 = _interopRequireDefault(_message);
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : {
default: obj
};
}
console.log(_message2.default);
},
{
"./message.js": 1
}
],
1: [
function (require, module, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
var _name = require("./name.js");
exports.default = "hello " + _name.name;
},
{
"./name.js": 2
}
],
2: [
function (require, module, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
var name = exports.name = 'machine learning';
},
{}
],
})