发布于 

AST、Babel、依赖

Babel

Babel 的原理

Babel 转换 JS 代码可以分成以下三个大步骤:

  1. parse: 把代码 code 变成 AST
  2. traverse: 遍历 AST 进行修改
  3. generate: 把 AST 变成代码 code2

即,code --(1)-> ast --(2)-> ast2 --(3)-> code2

一个简单的示例:手动把 let 变成 var

let_to_var.ts 将 code 中的 let 全部变成 var

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { parse } from "@babel/parser"
import traverse from "@babel/traverse"
import generate from "@babel/generator"

const code = `let a = 'let'; let b = 2`
const ast = parse(code, { sourceType: 'module' })
traverse(ast, {
enter: item => {
if(item.node.type === 'VariableDeclaration'){
if(item.node.kind === 'let'){
item.node.kind = 'var'
}
}
}
})
const result = generate(ast, {}, code)
console.log(result.code)

执行命令 node -r ts-node/register let_to_var.ts,结果如下:

运行结果
运行结果

如果你想用 Chrome 查看 AST,可以添加 --inspect-brk 选项,node -r ts-node/register --inspect-brk let_to_var.ts

为什么必须要用 AST

为什么必须要用 AST 来做一层转换,直接将 code 字符串中的 let 替换为 var 不就行了吗?

  • 你很难用正则表达式来替换,正则很容易把let a = 'let'变成var a = 'var'
  • 你需要识别每个单词的意思,才能做到只修改用于声明变量的 let
  • 而 AST 能明确地告诉你每个 let 的意思

自动把代码转为 ES5

上面的例子中,我们是手动的把 code 中的 let 转换为了 var,如何将这一操作自动化呢?

使用 @babel/core@babel/preset-env 即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// to_es5.ts
import { parse } from "@babel/parser"
import * as babel from "@babel/core"

const code = `let a = 'let';let b = 2; const c = 3`
const ast = parse(code, { sourceType: 'module' })
// `babel.transformFromAstSync` 可以把 AST 变成 code2
const result1 = babel.transformFromAstSync(ast, code, {
presets: ['@babel/preset-env']
})
console.log('\nresult1.code\n', result1.code)

// 如果图方便,可以用 babel.transformSync 直接把 code 变成 code2
const result2 = babel.transformSync(code, {
presets: ['@babel/preset-env']
})
console.log('\nresult2.code\n', result2.code)

运行结果如下:

运行结果
运行结果

@babel/preset-env 内置了很多转换规则,比如把所有 ES6+ 代码转换为 ES5 代码的规则

代码不应该是字符串,而是应该放到文件中

上面的例子都是以字符串的形式,而在我们平时的开发中,代码都是分散在一个个的文件中的,此时就需要我们引入 Node 的文件模块。

在根目录下创建 test.js 文件

1
2
3
4
// test.js
let a = 'let'
let b = 2
const c = 3

创建 file_to_es5.ts 文件

1
2
3
4
5
6
7
8
9
10
11
// file_to_es5.ts
import { parse } from "@babel/parser"
import * as babel from "@babel/core"
import * as fs from 'fs'

const code = fs.readFileSync('./test.js').toString()
const ast = parse(code, { sourceType: 'module' })
const result = babel.transformFromAstSync(ast, code, {
presets: ['@babel/preset-env']
})
fs.writeFileSync('./test.es5.js', result.code)

执行 node -r ts-node/register file_to_es5.ts,就会得到 test.es5.js 文件

1
2
3
4
5
6
// test.es5.js
"use strict";

var a = 'let';
var b = 2;
var c = 3;

如何把 ./test.js 改为任意文件?

Babel 除了转换 JS 语法,还能做啥?

还可以用来分析 JS 文件的依赖关系(即答)

创建一个 project_1 目录,project_1 目录中有三个 JS 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// project_1/a.js
const a = {
value: 1,
}
export default a

// project_1/b.js
const b = {
value: 2,
}
export default b

// project_1/index.js
import a from './a.js'
import b from './b.js'
console.log(a.value + b.value)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// deps_1.ts
import { parse } from "@babel/parser"
import traverse from "@babel/traverse"
import { readFileSync } from 'fs'
import { resolve, relative, dirname } from 'path';

// 设置根目录
const projectRoot = resolve(__dirname, 'project_1')
// 类型声明
type DepRelation = { [key: string]: { deps: string[], code: string } }
// 初始化一个空的 depRelation,用于收集依赖
const depRelation: DepRelation = {}

// 将入口文件的绝对路径传入函数,如 D:\demo\fixture_1\index.js
collectCodeAndDeps(resolve(projectRoot, 'index.js'))

console.log(depRelation)
console.log('done')

function collectCodeAndDeps(filepath: string) {
const key = getProjectPath(filepath) // 文件的项目路径,如 index.js
// 获取文件内容,将内容放至 depRelation
const code = readFileSync(filepath).toString()
// 初始化 depRelation[key]
depRelation[key] = { deps: [], code: code }
// 将代码转为 AST
const ast = parse(code, { sourceType: 'module' })
// 分析文件依赖,将内容放至 depRelation
traverse(ast, {
enter: path => {
if (path.node.type === 'ImportDeclaration') {
// path.node.source.value 往往是一个相对路径,如 ./a.js,需要先把它转为一个绝对路径
const depAbsolutePath = resolve(dirname(filepath), path.node.source.value)
// 然后转为项目路径
const depProjectPath = getProjectPath(depAbsolutePath)
// 把依赖写进 depRelation
depRelation[key].deps.push(depProjectPath)
}
}
})
}
// 获取文件相对于根目录的相对路径
function getProjectPath(path: string) {
return relative(projectRoot, path).replace(/\\/g, '/')
}

运行结果如下:

运行结果
运行结果

deps_1.ts 的思路

  1. 调用 collectCodeAndDeps('index.js'),代码中会有更多细节
  2. 先把 depRelation['index.js'] 初始化为 { deps: [], code: 'index.js的源码' }
  3. 然后把 index.js 源码 code 变成 AST
  4. 遍历 AST,看看 import 了哪些依赖,假设依赖了 a.js 和 b.js
  5. 把 a.js 和 b.js 写到 depRelation['index.js'].deps
  6. 最终得到的 depRelation 就收集了 index.js 的依赖

启发:用哈希表来存储文件依赖;哈希表是数据结构中的术语,在 JS 中一个对象就可以看作一个哈希表;这是计数排序的基本操作

升级:依赖的依赖

如何处理更深层的嵌套关系?

三层依赖关系

  • index -> a -> dir/a2 -> dir/dir_in_dir/a3
  • index -> b -> dir/b2 -> dir/dir_in_dir/b3
  • 文件我已经创建好了,放在 project_2 目录里了

思路

  • collectCodeAndDeps 太长了,缩写为 collect
  • 调用 collect(‘index.js’)
  • 发现依赖 ‘./a.js’ 于是调用 collect(‘a.js’)
  • 发现依赖 ‘./dir/a2.js’ 于是调用 collect(‘dir/a2.js’)
  • 发现依赖 ‘./dir_in_dir/a3.js’ 于是调用 collect(‘dir/dir_in_dir/a3.js’)
  • 没有更多依赖了,a.js 这条线结束,发现下一个依赖 ‘./b.js’
  • 以此类推,其实就是递归

给 collect 加递归

执行结果
执行结果

用递归来获取嵌套依赖

递归存在 call stack 溢出的风险,比如嵌套层数超过 20000 时,程序直接报错

再复杂一点:循环依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// project_3/a.js
import b from './b.js'
const a = {
value: b.value + 1,
}
export default a

// project_3/b.js
import a from './a.js'
const b = {
value: a.value + 1,
}
export default b

// project_3/index.js
import a from './a.js'
import b from './b.js'
console.log(a.value + b.value)

依赖关系(project_3)

  • index -> a -> b
  • index -> b -> a

求值

  • a.value = b.value + 1
  • b.value = a.value + 1

运行代码

  • node -r ts-node/register deps_3.ts
  • 报错:调用栈 溢出了
  • 为什么:分析过程 a -> b -> a -> b -> a -> b -> … 把调用栈撑满了

不能循环依赖吗?

并不,我们需要一些小技巧

避免重复进入同一个文件

思路:

  • 一旦发现这个 key 已经在 keys 里了,就 return
  • 这样分析过程就不是 a -> b -> a -> b -> … 而是 a -> b -> return
  • 注意我们只需要分析依赖,不需要执行代码,所以这样是可行的
  • 由于我们的分析不需要执行代码,所以叫做静态分析
  • 但如果我们执行代码,就会发现还是出现了循环

执行 index.js

  • 发现报错:不能在 ‘a’ 初始化之前访问 a
  • 原因:执行过程 a -> b -> a 此处报错,因为 node 发现计算 a 的时候又要计算 a

结论

模块间可以循环依赖

  • a 依赖 b,b 依赖 a
  • a 依赖 b,b 依赖 c,c 依赖 a

但不能有逻辑漏洞

  • a.value = b.value + 1
  • b.value = a.value + 1

那能不能写出一个没有逻辑漏洞的循环依赖?当然可以。

合法的循环依赖(没有逻辑漏洞)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// project_5/a.js
import b from './b.js'
const a = {
value: 'a',
getB: () => b.value + ' from a.js'
}
export default a

// project_5/b.js
import a from './a.js'
const b = {
value: 'b',
getA: () => a.value + ' from b.js'
}
export default b

// project_5/index.js
import a from './a.js'
import b from './b.js'
console.log(a.getB())
console.log(b.getA())
运行 index.js
运行 index.js

有的循环依赖有问题,有的循环依赖没有问题;所以最好别用循环依赖,以防万一

总结

AST 相关

  • parse: 把代码 code 变成 AST
  • traverse: 遍历 AST 进行修改
  • generate: 把 AST 变成代码 code2

工具

  • babel 可以把高级代码翻译为 ES5
  • @babel/parser
  • @babel/traverse
  • @babel/generator
  • @babel/core 包含前三者
  • @babel/preset-env 内置很多规则

代码技巧

  • 使用哈希表来存储数据
  • 通过检测 key 来避免重复

循环依赖

  • 有的循环依赖可以正常执行
  • 有的循环依赖不可以
  • 但都可以做静态分析

(●'◡'●)ノ♥