手写一个简单的类似webpack的打包器
打包流程说明:
- 定义依赖分析函数,通过读取文件内容,分析得到该文件导入的依赖项
- code => AST => 得到导入声明,记录导入声明中的依赖项路径 => AST->code => 返回记录当前文件filename、依赖项dependencies和转译后的code的对象
- 定义分析依赖图列表的函数,传入项目的入口文件,递归调用依赖分析函数,得到所有文件的依赖关系图列表,返回该列表。
- 核心在于如何递归调用依赖分析函数,这里使用广度优先的算法,通过对根节点的分析开始,依次构建得到下一层级的节点,对这一层级的节点按顺序分析,得到下一层级节点再次按顺序分析,直到无法再得到下一层级节点为止。
- 每一轮的依赖分析,都将依赖项push到列表中。这样保证了按顺序的广度优先分析。
- 根据已经生成的依赖图列表,生成可在浏览器端运行的代码,这里如果使用了@babel/core将AST转换为代码,则需要定义require函数和exports对象。
- 整个代码都需要放在一个IIFE中执行,IIFE传入依赖图列表
- 定义require函数,用来加载模块(依赖的文件代码)并执行,将结果挂载到exports对象上。
- 依赖图列表的每个元素都包含有自身的代码以及依赖列表,自身的代码需要放在IIFE中使用eval()执行
使用的npm包说明
- cli-highlight包:用于在终端高亮显示信息
- @babel/parser:分析JavaScript文件,解析为AST(JavaScript对象)
- @babel/traverse: 与@babel/parser一起使用,遍历AST,对其中的节点进行操作,如更新、删除等等
- traverse(ast, options): 其中,options是一个选项对象,包含了一系列Hooks函数
- 对特定类型的节点可以使用特定的函数,节点类型参考@babel/types
- 对ESModule的导入节点使用options.ImportDeclaration(path) {},path是参数,其中path.node是指向导入声明的节点
- 对函数声明的节点使用options.FunctionDeclaration(path) {}
- 进入节点使用options.enter(path) {}
- 退出节点使用options.exit(path) {}
- @babel/core: 将AST转换为JavaScript代码,需要配合@babel/preset-env
代码如下
项目根目录下bundle.js文件
const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const babel = require('@babel/core')
// 判断是否为{}对象的方法
const isEmptyObject = (obj) => {
for(key in obj) {
return false
}
return true
}
// 读取文件内容,分析依赖
const moduleAnalyzer = (filename) => {
// 读取入口文件内容
const content = fs.readFileSync(filename, 'utf-8')
// 解析文件内容,转换为AST
const ast = parser.parse(content, {
sourceType: 'module'
})
// 声明一个用来存储依赖模块的对象,键为导入模块的相对路径,值为导入模块的绝对路径(相对于项目根目录)
const dependencies = Object.create(null)
// 遍历AST节点,获取导入声明的节点,将导入声明的节点的source的value属性值存储到依赖对象中
traverse(ast, {
ImportDeclaration({ node }) {
// 获取入口文件的所在目录
const dirname = path.dirname(filename)
// 拼接路径, node.source.value是导入语句中的路径部分
const newFile = './' + path.join(dirname, node.source.value)
// 相对路径和绝对路径作为键值对一起存储
dependencies[node.source.value] = newFile
}
})
// 分析更新AST后,使用babel.transformFromAstSync将AST转换为代码code
// 将ES6语法转为浏览器能运行的语法
const { code } = babel.transformFromAstSync(ast, null, {
presets: ['@babel/preset-env']
})
const res = {
filename,
code
}
// 返回分析结果,包含了入口文件、依赖对象和入口文件经过转译后的代码
// {
// filename,
// code,
// dependencies: {
// '相对路径': '绝对路径(以项目根目录为起点)'
// }
// }
if(isEmptyObject(dependencies)) {
return res
} else {
return Object.assign(res, { dependencies })
}
}
// 构建依赖关系图谱列表
const makeDependenciesGraph = (entry) => {
const entryModule = moduleAnalyzer(entry)
//
const graphList = [ entryModule ]
for(let i = 0; i < graphList.length; i ++) {
const item = graphList[i]
const { dependencies } = item
if(dependencies) {
for(dependency in dependencies) {
const res = moduleAnalyzer(dependencies[dependency])
graphList.push(res)
}
}
}
const graph = {}
graphList.forEach(item => {
graph[item.filename] = {
dependencies: item.dependencies,
code: item.code
}
})
return graph
// graph形如
// {
// filename1: {
// dependencies: {},
// code: ''
// },
// filename2: {
// dependencies: {},
// code: ''
// },
// ...
// }
}
// 从依赖图谱列表生成浏览器可用代码
const generateCode = (entry) => {
const graph = makeDependenciesGraph(entry)
// 使用JSON.stringify为了避免下面用${graph}时变为'[object Object]'
// 实际这段字符串在浏览器中作为JavaScript代码运行时,graphCode实际上就是一个对象
const graphCode = JSON.stringify(graph)
// 返回的代码要包含在IIFE中
return `
(function (graph) {
function require(module) {
// localRequire函数用来将加载相对路径转换为加载绝对路径后返回结果
// 主要是由于这里存储的键为绝对路径,在依赖图中只能利用绝对路径来加载模块
function localRequire(relativePath) {
return require(graph[module].dependencies[relativePath])
}
// 在定义exports时,由于是在IIFE之前,所以赋值语句必须要有分号作为结尾,否则要出错
var exports = {};
(function(require, exports, code) {
eval(code)
})(localRequire, exports, graph[module].code)
return exports
}
require('${entry}')
})(${graphCode})
`
}
const code = generateCode('./src/index.js')
// 将code写到'./dist/bundle.js'文件中
fs.writeFile('./dist/bundle.js', code, (err) => {
if(err) {
fs.mkdir('./dist', (err) => {
if(err) {
console.log('fail')
}
fs.writeFileSync('./dist/bundle.js', code)
})
}
})
更多资料见sharejs