babel 能够做静态分析,分析代码然后得出一些信息。我们经常用的打包工具就需要通过静态分析的方式得出模块间的依赖关系,然后构造成依赖图,之后对这个依赖图做各种处理,最后输出成文件。
比如 webpack 的打包过程:从入口模块分析依赖,构造模块依赖图,然后把一些模块合并到同个分组(chunk)里,生成 chunk 依赖图,最后把 chunk 通过模版打印为 assets,输出为文件。
从入口模块开始,对每个模块的依赖关系的分析就是基于 AST,这种就可以用 babel parser (或者直接用 acorn)来处理。
这一节我们就来实现下依赖分析的功能,也就是遍历所有的模块。
写这个的好处一个是能够加深我们对打包工具的认识,二是当做一些独立的工具的时候,可能也需要分析模块依赖关系。
思路分析
模块依赖分析也就是要分析 import 和 export,从入口模块开始,读取文件内容,通过 babel parser 把内容 parse 成 ast,之后通过 babel traverse 来对 AST 进行遍历。分别对 ImportDeclaration、ExportDeclaration 做处理:
ImportDeclaration:收集 import 信息,确定依赖的模块和引入的变量,之后再递归处理该模块 ExportDeclaration:收集 export 信息,确定导出的变量
我们可以设计这样一个结构来表示每个模块的信息:
class DependencyNode {
constructor(path = '', imports = {}, exports = []) {
this.path = path;
this.imports = imports;
this.exports = exports;
this.subModules = {};
}
}
path 表示当前模块路径, imports 表示从什么模块引入了什么变量,exports 表示导出了什么变量。
接下来我们要完成 traverseModule 这个方法,也就是对每个模块的处理
const dependencyGraph = traverseModule(入口模块路径);
具体处理的过程就是:
- 读取文件内容
- 通过 babel parser 把文件内容 parse 成 ast
- 遍历 AST,对 ImportDeclaration、ExportDeclaration 分别做处理
- 对分析出的依赖路径进行处理,变成绝对路径,并尝试补全
- 递归处理分析出来的依赖路径
如果没有后缀名的依赖路径,要分别尝试 .js、.jsx、.ts、.tsx 的路径,如果存在就补全成该路径,并且目录还要补全 index 文件名。
通过递归处理依赖模块,就可以完成依赖图的构建,我们可以保存根节点和所有模块的信息:
const dependencyGraph = {
root: new DependencyNode(),
allModules: {}
};
当处理完所有模块后,就得到了完整的 dependencyGraph。
接下来我们来写下代码。
代码实现
首先我们定义要返回的 dependencyGraph,
class DependencyNode {
constructor(path = '', imports = {}, exports = []) {
this.path = path;
this.imports = imports;
this.exports = exports;
this.subModules = {};
}
}
module.exports = function(curModulePath) {
const dependencyGraph = {
root: new DependencyNode(),
allModules: {}
};
traverseJsModule(curModulePath, dependencyGraph.root, dependencyGraph.allModules);
return dependencyGraph;
}
接下来实现遍历的方法,也就是之前分析的 读取文件内容、parse 成 AST、travese AST 提取模块信息和依赖信息、递归遍历依赖(先把路径处理成绝对路径) 的过程。
要注意的是,ts、jsx、tsx 等用的 babel 插件不同,要根据 extname 来做不同的插件的引入。
function resolveBabelSyntaxtPlugins(modulePath) {
const plugins = [];
if (['.tsx', '.jsx'].some(ext => modulePath.endsWith(ext))) {
plugins.push('jsx');
}
if (['.ts', '.tsx'].some(ext => modulePath.endsWith(ext))) {
plugins.push('typescript');
}
return plugins;
}
function traverseJsModule(curModulePath, dependencyGrapthNode, allModules) {
const moduleFileContent = fs.readFileSync(curModulePath, {
encoding: 'utf-8'
});
dependencyGrapthNode.path = curModulePath;
const ast = parser.parse(moduleFileContent, {
sourceType: 'unambiguous',
plugins: resolveBabelSyntaxtPlugins(curModulePath)
});
traverse(ast, {
ImportDeclaration(path) {
// 收集import 信息
// 递归处理依赖模块
traverseJsModule(subModulePath, subModule, allModules);
dependencyGrapthNode.subModules[subModule.path] = subModule;
},
ExportDeclaration(path) {
//收集 export 信息
}
});
allModules[curModulePath] = dependencyGrapthNode;
}
上面省略了对 ImportDeclaration 和 ExportDeclaration 的处理,接下来我们来分别处理下这两种节点:
ImportDeclaration 分为三种:
// 这种我们叫 deconstruct import(解构引入)
import { a, b as bb} from 'aa';
// 这种我们叫 namespace import(命名空间引入)
import * as c from 'cc';
// 这种我们叫 default import(默认引入)
import b from 'b';
可以用 astexplorer.net 看一下它们的 AST。
我们要根据具体的类型来提取信息,三种不同的 import 的 AST 提取信息的方式不同。
先定义下三种 import 类型:
const IMPORT_TYPE = {
deconstruct: 'deconstruct',
default: 'default',
namespace: 'namespace'
}
然后 visitor 里对不同类型的 AST 做不同的处理:
ImportDeclaration(path) {
const subModulePath = moduleResolver(curModulePath, path.get('source.value').node);
if (!subModulePath) {
return;
}
const specifierPaths = path.get('specifiers');
dependencyGrapthNode.imports[subModulePath] = specifierPaths.map(specifierPath => {
if (specifierPath.isImportSpecifier()) {
return {
type: IMPORT_TYPE.deconstruct,
imported: specifierPath.get('imported').node.name,
local: specifierPath.get('local').node.name
}
} else if (specifierPath.isImportDefaultSpecifier()) {
return {
type: IMPORT_TYPE.default,
local: specifierPath.get('local').node.name
}
} else {
return {
type: IMPORT_TYPE.namespace,
local: specifierPath.get('local').node.name
}
}
});
const subModule = new DependencyNode();
traverseJsModule(subModulePath, subModule, allModules);
dependencyGrapthNode.subModules[subModule.path] = subModule;
}
上面我们通过记录了 import 信息到 dependencyGrapthNode.imports 中,并且递归处理了依赖模块。而且在处理依赖模块之前,我们做了把路径转成绝对路径和路径补全的处理。
平时写 js 依赖是可以忽略后缀的,甚至还可以忽略文件名(比如 index.js),但是我们解析依赖要给它补全后缀名。
路径补全的处理就是分别尝试 .tsx,.ts,.jsx,.js的路径是否存在,如果是目录的话,还要连同 index 一起补全,也就是 index.tsx、index.ts、index.jsx、index.js
function completeModulePath (modulePath) {
const EXTS = ['.tsx','.ts','.jsx','.js'];
if (modulePath.match(/\.[a-zA-Z]+$/)) {
return modulePath;
}
function tryCompletePath (resolvePath) {
for (let i = 0; i < EXTS.length; i ++) {
let tryPath = resolvePath(EXTS[i]);
if (fs.existsSync(tryPath)) {
return tryPath;
}
}
}
function reportModuleNotFoundError (modulePath) {
throw 'module not found: ' + modulePath;
}
if (isDirectory(modulePath)) {//如果是目录
const tryModulePath = tryCompletePath((ext) => path.join(modulePath, 'index' + ext));
if (!tryModulePath) {
reportModuleNotFoundError(modulePath);
} else {
return tryModulePath;
}
} else if (!EXTS.some(ext => modulePath.endsWith(ext))) {//如果补全后的路径存在
const tryModulePath = tryCompletePath((ext) => modulePath + ext);
if (!tryModulePath) {
reportModuleNotFoundError(modulePath);
} else {
return tryModulePath;
}
}
return modulePath;
}
当然,我们还要收集下 export 的信息,也是分为三种类型:
// 全部导出(all export)
export * from 'a';
// 默认导出 (default export)
export default b;
// 命名导出 (named export)
export { c as cc };
然后分别对这三种 AST 做不同的信息收集:
ExportDeclaration(path) {
if(path.isExportNamedDeclaration()) {
const specifiers = path.get('specifiers');
dependencyGrapthNode.exports = specifiers.map(specifierPath => ({
type: EXPORT_TYPE.named,
exported: specifierPath.get('exported').node.name,
local: specifierPath.get('local').node.name
}));
} else if (path.isExportDefaultDeclaration()) {
let exportName;
const declarationPath = path.get('declaration');
if(declarationPath.isAssignmentExpression()) {
exportName = declarationPath.get('left').toString();
} else {
exportName = declarationPath.toString()
}
dependencyGrapthNode.exports.push({
type: EXPORT_TYPE.default,
exported: exportName
});
} else {
dependencyGrapthNode.exports.push({
type: EXPORT_TYPE.all,
exported: path.get('exported').node.name,
source: path.get('source').node.value
});
}
}
递归处理每一个模块就完成了依赖图的构建。
效果演示
首先我们写一个测试项目:
index.js
import { aa1, aa2 } from './a';
console.log(aa1);
a.js
import b from './b';
const aa1 = 1;
const aa2 = 2;
console.log(b);
export {
aa1,
aa2
}
b.js
import { cc as renamedCc } from './c';
export default b = 4;
c/index.js
const cc = 5;
export {
cc
};
然后使用 traverseModule 方法对入口模块 index 进行处理:
const traverseModule = require('./traverseModule');
const path = require('path');
const dependencyGraph = traverseModule(path.resolve(__dirname, '../test-project/index.js'));
console.log(JSON.stringify(dependencyGraph, null, 4));
结果如下,我们成功构建出了整个依赖图:
{
"root": {
"path": "/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/index.js",
"imports": {
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/a.js": [
{
"type": "deconstruct",
"imported": "aa1",
"local": "aa1"
},
{
"type": "deconstruct",
"imported": "aa2",
"local": "aa2"
}
]
},
"exports": [],
"subModules": {
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/a.js": {
"path": "/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/a.js",
"imports": {
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/b.js": [
{
"type": "default",
"local": "b"
}
]
},
"exports": [
{
"type": "named",
"exported": "aa1",
"local": "aa1"
},
{
"type": "named",
"exported": "aa2",
"local": "aa2"
}
],
"subModules": {
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/b.js": {
"path": "/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/b.js",
"imports": {
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/c/index.js": [
{
"type": "deconstruct",
"imported": "cc",
"local": "renamedCc"
}
]
},
"exports": [
{
"type": "default",
"exported": "b"
}
],
"subModules": {
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/c/index.js": {
"path": "/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/c/index.js",
"imports": {},
"exports": [
{
"type": "named",
"exported": "cc",
"local": "cc"
}
],
"subModules": {}
}
}
}
}
}
}
},
"allModules": {
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/c/index.js": {
"path": "/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/c/index.js",
"imports": {},
"exports": [
{
"type": "named",
"exported": "cc",
"local": "cc"
}
],
"subModules": {}
},
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/b.js": {
"path": "/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/b.js",
"imports": {
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/c/index.js": [
{
"type": "deconstruct",
"imported": "cc",
"local": "renamedCc"
}
]
},
"exports": [
{
"type": "default",
"exported": "b"
}
],
"subModules": {
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/c/index.js": {
"path": "/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/c/index.js",
"imports": {},
"exports": [
{
"type": "named",
"exported": "cc",
"local": "cc"
}
],
"subModules": {}
}
}
},
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/a.js": {
"path": "/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/a.js",
"imports": {
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/b.js": [
{
"type": "default",
"local": "b"
}
]
},
"exports": [
{
"type": "named",
"exported": "aa1",
"local": "aa1"
},
{
"type": "named",
"exported": "aa2",
"local": "aa2"
}
],
"subModules": {
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/b.js": {
"path": "/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/b.js",
"imports": {
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/c/index.js": [
{
"type": "deconstruct",
"imported": "cc",
"local": "renamedCc"
}
]
},
"exports": [
{
"type": "default",
"exported": "b"
}
],
"subModules": {
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/c/index.js": {
"path": "/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/c/index.js",
"imports": {},
"exports": [
{
"type": "named",
"exported": "cc",
"local": "cc"
}
],
"subModules": {}
}
}
}
}
},
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/index.js": {
"path": "/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/index.js",
"imports": {
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/a.js": [
{
"type": "deconstruct",
"imported": "aa1",
"local": "aa1"
},
{
"type": "deconstruct",
"imported": "aa2",
"local": "aa2"
}
]
},
"exports": [],
"subModules": {
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/a.js": {
"path": "/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/a.js",
"imports": {
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/b.js": [
{
"type": "default",
"local": "b"
}
]
},
"exports": [
{
"type": "named",
"exported": "aa1",
"local": "aa1"
},
{
"type": "named",
"exported": "aa2",
"local": "aa2"
}
],
"subModules": {
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/b.js": {
"path": "/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/b.js",
"imports": {
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/c/index.js": [
{
"type": "deconstruct",
"imported": "cc",
"local": "renamedCc"
}
]
},
"exports": [
{
"type": "default",
"exported": "b"
}
],
"subModules": {
"/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/c/index.js": {
"path": "/Users/guang/code/babel-plugin-exercize/exercize-module-iterator/test-project/c/index.js",
"imports": {},
"exports": [
{
"type": "named",
"exported": "cc",
"local": "cc"
}
],
"subModules": {}
}
}
}
}
}
}
}
}
}
有了依赖图之后,就可以做进一步的处理,比如:
- 合并一些模块成 chunk graph
- 通过 export 和 import 的关系的分析,实现 treeshking
总结
打包工具 webpack 就是基于 AST 来做的依赖分析,通过构建模块依赖图,之后进一步的处理。这节我们基于 babel parser 和 babel traverse 做了模块的遍历和依赖图的生成。
每个模块的处理都是 读取内容、parse、遍历 AST提取 import 和 export 信息、递归遍历依赖 的过程。
其中要注意的是parse 的插件要根据后缀名来决定,路径要做下补全。
遍历 AST 是要确定什么属性,遍历模块则是要解析 require,然后处理路径。
依赖图分析完之后就可以做进一步的处理,比如合并 chunk、treeshking 等,然后输出成文件,这就是打包工具。
(代码在这里,建议 git clone 下来通过 node 跑一下)