这篇文章分析了如何自己手写一个简单的打包工具。 我们先查看当前项目结构: word.js:
export const word = 'hello'
message.js:
import {word} from "./word.js";
const message = `say ${word}`
export default message
index.js:
import message from "./message.js";
console.log(message)
我们的预期是写一个bundler.js,它将对index.js进行打包,生成能够在浏览器上运行的代码。
1. 入口文件分析
首先,打包工具是在nodejs环境下运行的(和webpack类似)。 我们如果要对入口文件打包,首先要读入入口文件的代码,并对其进行模块分析。
1.1 读取入口文件
这里我们使用node.js 中fs模块的readFileSync同步读取。
const fs = require('fs')
const moduleAnalyser = (filename) => {
const content = fs.readFileSync(filename,'utf-8')
console.log(content)
}
moduleAnalyser('./src/index.js')
使用node bundler.js 执行,控制台输出:
import message from "./message.js";
console.log(message)
现在我们已经拿到了index.js的文件内容,但是我们可以看到,index.js的执行还需要message.js模块,所以,我们还需要将模块也提取出来。
1.2 模块提取
我们需要记录下运行index.js所需的依赖,那么,如何将message模块提取出来呢?字符串截取可行吗?稍微思考一下,我们就可以否决掉字符串截取这个方案,对大型的js文件使用字符串截取太过复杂并且容易出错。 我们可以引入@babel/parser ,@babel/parser可以帮助我们对源码进行分析。
npm i @babel/parser
const fs = require('fs')
const parser = require('@babel/parser')
const moduleAnalyser = (filename) => {
const content = fs.readFileSync(filename,'utf-8')
const ast = parser.parse(content,{
sourceType: 'module'
})
console.log(ast)
}
moduleAnalyser('./src/index.js')
我们再次执行node bundler.js ,控制台输出以下内容:
Node {
type: 'File',
start: 0,
end: 59,
loc: SourceLocation {
start: Position { line: 1, column: 0 },
end: Position { line: 3, column: 20 }
},
program: Node {
type: 'Program',
start: 0,
end: 59,
loc: SourceLocation { start: [Position], end: [Position] },
sourceType: 'module',
interpreter: null,
body: [ [Node], [Node] ],
directives: []
},
comments: []
}
其实这就是抽象语法树,ast中一个program对象,这就是**@babel/parser解析content(也就是index.js)后得到的代码结构,而program.body就是代码的节点结构。 修改bundler.js**中console.log(ast.program.body) ,控制台输出:
[
Node {
type: 'ImportDeclaration',
start: 0,
end: 35,
loc: SourceLocation { start: [Position], end: [Position] },
specifiers: [ [Node] ],
source: Node {
type: 'StringLiteral',
start: 20,
end: 34,
loc: [SourceLocation],
extra: [Object],
value: './message.js'
}
},
Node {
type: 'ExpressionStatement',
start: 39,
end: 59,
loc: SourceLocation { start: [Position], end: [Position] },
expression: Node {
type: 'CallExpression',
start: 39,
end: 59,
loc: [SourceLocation],
callee: [Node],
arguments: [Array]
}
}
]
控制台输出了一个数组,数组中有两个node对象,第一个node对象就对应了index.js的第一行import message from "./message.js"; ,该node对象中type表明了这一行是引入声明,source.value的值就是引入的模块的值;第二个node对象就对应了index.js的第二行代码console.log(message) ,该对象的type表明了这一行是js表达式。 现在,不管我们在index.js中引入多少个模块,ast.program.body 中就会存在与之对应的ImportDeclaration类型的node节点。这时候,我们的模块提取就很容易了,只需要将每一个node遍历之后就能拿到了。 这里,我们使用**@babel/traverse来提取模板。 @babel/traverse 可以用来遍历更新@babel/parser**生成的AST
- 对语法树中特定的节点进行操作
- 进入节点(enter)
- 退出节点(exit)
关于**@babel/traverse**的详细用法大家可以在网上查找相关资料,这里笔者将仅做简单叙述。
const fs = require('fs')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const moduleAnalyser = (filename) => {
const content = fs.readFileSync(filename,'utf-8')
const ast = parser.parse(content,{
sourceType: 'module'
})
const dependencies = {};
traverse(ast, {
ImportDeclaration: function (Node){
const dirname = path.dirname(filename);
const newDir = '.\\' + path.join(dirname, './message.js');
dependencies[Node.node.source.value] = newDir
}
})
return {
filename,
dependencies
}
}
moduleAnalyser('./src/index.js')
- 在引入
const traverse = require('@babel/traverse').default 时,由于 @babel/traverse 使用的是ES Module语法,我们如果想通过CommenJS语法引入,需要在后面加上.default 。 - 这里的traverse方法第一个参数就是我们使用 @babel/parser 生成的语法树,第二个参数是一个对象,
ImportDeclaration: function (path){ console.log(path.node.source.value) } 这里的意思是,对type为ImportDeclaration的node对象执行后面的操作,传入的path 就是该节点对象。通过path.node.source.value 就可以拿到引入声明的值,即index.js中的 “./message.js”。
这里我们使用了 dependencies 将 ./message.js 和另一个路径对应起来,因为当我们使用bundler.js对index.js打包时,需要使用到index.js依赖的模块,所以应该将相对于index.js的路径转换成相对于bundler.js的文件路径,这里将文件路径保存到了dependencies中,便于我们之后的使用。
**moduleAnalyser **最后返回了一个对象,包括了入口文件,以及入口文件的模块依赖。
虽然我们现在可以拿到index.js及其依赖,但是我们不难发现index.js 和依赖模块使用了es module语法,而浏览器是不支持es module 语法的,所以我们还需要使用**@babel/core**对代码进行转换。
npm i @babel/core -s
@babel/core有一个方法transformFromAst可以将抽象语法树转换成浏览器可以运行的js代码,而在使用**@babel/core对Ast进行转换时,我们通常还需要配置@babel/preset-env**,它是一系列插件的集合,包含了我们在babel6中常用的es2015,es2016, es2017等最新的语法转化插件,允许我们使用最新的js语法。
npm i @babel/preset-env -s
transformFromAst会根据参数中的抽象语法树返回一个对象,该返回对象包括一个code字段,修改**moduleAnalyser **:
const moduleAnalyser = (filename) => {
const content = fs.readFileSync(filename,'utf-8')
const ast = parser.parse(content,{
sourceType: 'module'
})
const dependencies = {};
traverse(ast, {
ImportDeclaration: function (Node){
const dirname = path.dirname(filename);
const newDir = '.\\' + path.join(dirname, './message.js');
dependencies[Node.node.source.value] = newDir
}
})
const {code} = babel.transformFromAst(ast, null, {
presets:["@babel/preset-env"]
})
console.log(code)
return {
filename,
dependencies,
code
}
}
执行node bundler.js ,控制台输出:
var _message = _interopRequireDefault(require("./message.js"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj
}; }
console.log(_message.default);
现在的代码,就是浏览器可以执行的代码了。 但是,如果我们就直接执行这一段代码,肯定是会报错的,因为虽然现在已经把index.js分析完毕,但是 ./message.js还是外部模块,我们还需要对index.js的模块进行分析。
2. 依赖图谱
首先,我们输出moduleAnalyser (’./src/index.js)
{
filename: './src/index.js',
dependencies: { './message.js': '.\\src\\message.js' },
code: '"use strict";\n' +
'\n' +
'var _message = _interopRequireDefault(require("./message.js"));\n' +
'\n' +
'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default:
obj }; }\n' +
'\n' +
'console.log(_message.default);'
}
moduleAnalyser 中包含了入口文件,入口文件的依赖,转换后的人口文件js代码。 既然已经有了入口文件的依赖,那么我们就应该向分析入口文件一样,逐步分析 【运行index.js】所需要的所有依赖(包括依赖的依赖)。 我们新建一个makeDependencisGraph(),它的作用就是得到模块之间的依赖信息(即依赖图谱)。
const makeDependenciesGraph = (entry) => {
const entryModule = moduleAnalyser(entry)
const graphArray = [entryModule]
for(let i = 0; i < graphArray.length; i++) {
console.log(graphArray.length)
const item = graphArray[i];
const { dependencies } = item;
console.log(dependencies)
if(dependencies) {
for(let j in dependencies) {
graphArray.push(
moduleAnalyser(dependencies[j])
);
}
}
}
console.log(graphArray)
}
在makeDependenciesGraph 中,我们使用moduleAnalyser(entry)对入口文件进行初步分析,将分析结果entryModule放入graphArray数组,使用一个循环,在这个循环中,遍历graphArray数组,如果item存在依赖,就再次调用moduleAnalyser分析该依赖,并将分析结果存入graphArray数组。最后,就可以把所有的模块依赖都分析到。 **makeDependenciesGraph(’./src/index.js)**的输出结果:
[
{
filename: './src/index.js',
dependencies: { './message.js': '.\\src\\message.js' },
code: '"use strict";\n' +
'\n' +
'var _message = _interopRequireDefault(require("./message.js"));\n' +
'\n' +
'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { defaul
t: obj }; }\n' +
'\n' +
'console.log(_message.default);'
},
{
filename: '.\\src\\message.js',
dependencies: { './word.js': '.\\src\\word.js' },
code: '"use strict";\n' +
'\n' +
'Object.defineProperty(exports, "__esModule", {\n' +
' value: true\n' +
'});\n' +
'exports.default = void 0;\n' +
'\n' +
'var _word = require("./word.js");\n' +
'\n' +
'var message = "say ".concat(_word.word);\n' +
'var _default = message;\n' +
'exports.default = _default;'
},
{
filename: '.\\src\\word.js',
dependencies: {},
code: '"use strict";\n' +
'\n' +
'Object.defineProperty(exports, "__esModule", {\n' +
' value: true\n' +
'});\n' +
'exports.word = void 0;\n' +
"var word = 'hello';\n" +
'exports.word = word;'
}
]
我们是以数组的形式来存储的,这不方便我们以后的使用,所以我们再改变一下我们的数据结构:
const makeDependenciesGraph = () => {
...
const graph = {}
for(let i in graphArray){
const item = graphArray[i]
graph[item.filename] = {
dependencies: item.dependencies,
code: item.code
}
}
console.log(graph)
}
输出:
{
'./src/index.js': {
dependencies: { './message.js': '.\\src\\message.js' },
code: '"use strict";\n' +
'\n' +
'var _message = _interopRequireDefault(require("./message.js"));\n' +
'\n' +
'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n' +
'\n' +
'console.log(_message.default);'
},
'.\\src\\message.js': {
dependencies: { './word.js': '.\\src\\word.js' },
code: '"use strict";\n' +
'\n' +
'Object.defineProperty(exports, "__esModule", {\n' +
' value: true\n' +
'});\n' +
'exports.default = void 0;\n' +
'\n' +
'var _word = require("./word.js");\n' +
'\n' +
'var message = "say ".concat(_word.word);\n' +
'var _default = message;\n' +
'exports.default = _default;'
},
'.\\src\\word.js': {
dependencies: {},
code: '"use strict";\n' +
'\n' +
'Object.defineProperty(exports, "__esModule", {\n' +
' value: true\n' +
'});\n' +
'exports.word = void 0;\n' +
"var word = 'hello';\n" +
'exports.word = word;'
}
}
现在,我们就可以根据文件路径拿到文件的依赖和可执行代码了。
3. 生成可运行代码
const generateCode = (entry) => {
const graph = JSON.stringify(makeDependenciesGraph(entry))
return`
(function(graph){
function run(module){
const code = graph[module].code
function myRequire(path){
return run(graph[module].dependencies[path])
}
var myExports = {};
(function(require, exports, code){
eval(code)
})(myRequire, myExports , code)
return exports;
};
run('${entry}')
})(${graph});
`
}
generateCode的返回值是一个模板字符串,注意,graph需要通过JSON.stringify预处理,否则传入的${graph} 实际上是graph.toString() ,也就是[object Object] 。 为了防止模块之间的数据污染,我们使用了闭包。 最外层传入的是graph,graph包含了运行entry的所有代码和依赖信息。 run是运行传入参数module 文件的方法,我们在闭包中执行了run(entry) ,也就是执行index.js。 这里希望读者仔细阅读run函数,run函数涉及到了闭包,参数传递,递归等用法,需要读者具有一定的js基础。 我们此时的demo是run('./src/index.js') 首先我们从graph中拿到index.js的code,但是我们可以观察code,其中有_interopRequireDefault(require("./message.js")) 这样的代码,也就是说,执行该段code还需要一个require 函数,所以我们在一个闭包中执行该段code,同时向其中传入一个函数,闭包使用require对该函数进行接收,而require(module) 的效果和run类似,都是执行该模块,但是,_interopRequireDefault(require("./message.js")) ,如果要执行**./message.js**,我们需要在graph中获取到对应的code,而graph中并不存在 ./message.js 字段,我们需要graph[module].dependencies[path] 来获取其在graph的键值,这里稍作解释: 当我们执行run('./src/index') 时,index.js需要**./message.js**,而graph['./src/index.js'].dependencies['./message.js'] 就是 ‘.\src\message.js’,之后,我们就可以执行run('.\\src\\message.js') ,这里有递归操作。 创建一个myExports 空对象的原因和创建myRequire类似,因为有code使用了exports对象,我们将myExports作为exports参数传递给执行code的闭包中,使执行code时能够使用到exports。
这段代码晦涩难懂,希望大家多多思考,如有不懂,欢迎讨论,共同进步。
|