实际的开发过程中,出于各种原因,经常会有些变量在声明后,并不会被真正使用,形成一些既不影响运行、也不容易被发现的坏代码。
同时,一般意义上的tree-shaking
都是基于依赖分析;在编译过程中,一些并没有被使用到的引用,仍然会打包到最终的输出里。
本文是基于上面两个出发点,尝试通过babel
对代码进行扫描,提供一种发现和删除这类id
的思路。
本文默认读者已对babel的 parse
traverse
transform
等方法有了基本的认识。
初始化环境
const { traverse, parseSync, transformFromAstSync, types } = require('@babel/core')
Scope
当我们在代码中声明了一个变量,实际上是使用了一个id
映射了一个值或指针,而后的使用都是对该id
的调用。比如:
import lib from 'some-lib'
let a = 1
function fn() {
var aa = 2
console.log(a) // 调用外部a
console.log(aa) // 调用内部aa
}
fn() // 调用 fn
lib.cal(a) // 调用 lib a
每一次id
调用,runtime会首先在当前的scope
查找这个id
,不存在的话继而向父级scope
查找。
只要我们能在语法分析过程中,找到这种引用关系,将未被引用的id
声明删掉,就可以实现代码级的tree-shaking
。我们暂且将这个方法叫对声明的引用统计法。
在babel
的traverse
过程中,可以借助NodePath.scope
,对id
的声明调用做引用统计。
Scope.bindings
Scope.bindings
里,以{ key: value }
结构保存了所有当前作用域所有的变量。我们对上面的代码进行traverse
,查看每次id
调用时的bindings
情况:
// demo.js
const demo = (code) => {
const ast = parseSync(code)
traverse(ast, {
Identifier(path) {
const { node, scope } = path
console.log(node.name, Object.keys(scope.bindings))
}
})
}
demo(`
import lib from 'some-lib'
let a = 1
function fn() {
var aa = 2
console.log(a) // 调用外部a
console.log(aa) // 调用内部aa
}
fn() // 调用 fn
lib.cal(a) // 调用 lib a
`)
看下输出:
% node demo
lib [ 'lib', 'a', 'fn' ]
a [ 'lib', 'a', 'fn' ]
fn [ 'aa' ] // <---
aa [ 'aa' ]
console [ 'aa' ]
log [ 'aa' ]
a [ 'aa' ]
console [ 'aa' ]
log [ 'aa' ]
aa [ 'aa' ]
fn [ 'lib', 'a', 'fn' ] // <---
lib [ 'lib', 'a', 'fn' ]
cal [ 'lib', 'a', 'fn' ]
a [ 'lib', 'a', 'fn' ]
上面的输出情况应该不需要过多解释,但建议对fn
的的声明和调用过程的bindings
不同做一下思考。
声明的引用数统计
Binding.references 和 Binding.path
在每个Binding对象中,都存储了该声明被引用的次数references
。
Binding.path
则是存储了这个声明节点所在的NodePath
,可以借助这个对象,对AST进行修改。
查看引用数为0的NodePath类型
直接上代码:
const demo = (code) => {
const ast = parseSync(code)
const set = new Set()
traverse(ast, {
Identifier(path) {
const { scope } = path
Object.values(scope.bindings).forEach(binding => {
if (binding.references < 1 && !set.has(binding.path)) {
set.add(binding.path)
}
})
}
})
set.forEach(it => {
console.log(it.type)
})
}
demo(`
import lib from 'some-lib'
let a = 1
function fn() {
var aa = 2
console.log(a) // 调用外部a
console.log(aa) // 调用内部aa
}
// fn() // 调用 fn
// lib.cal(a) // 调用 lib a
`)
注意代码参数尾部两个调用被注释掉了:
% node demo
ImportDefaultSpecifier
FunctionDeclaration
显示的引用数为0的声明,分别是lib
和fn
,也就是被注释掉两行。
删除引用数为0的NodePath,并输出新代码
const demo = (code) => {
const ast = parseSync(code)
traverse(ast, {
Identifier(path) {
const { scope } = path
Object.values(scope.bindings).forEach(binding => {
if (binding.references < 1) {
binding.path.remove()
}
})
}
})
const { code: newCode } = transformFromAstSync(ast, '', {
comments: false
})
console.log(newCode)
}
demo(`
import lib from 'some-lib'
let a = 1
function fn() {
var aa = 2
console.log(a) // 调用外部a
console.log(aa) // 调用内部aa
}
// fn() // 调用 fn
// lib.cal(a) // 调用 lib a
`)
输出
% node demo
import 'some-lib';
let a = 1;
还残存了一些声明:
- 变量
a
残存是因为在其他声明内部被使用了,即便那个声明已经被删掉,但是仍然对引用计数起作用,最简单的例子比如let a = 1, b = a
,这里的b
将被删掉,但a
引用计数为1,也删不干净。 - 即便
import
声明内部的ImportSpecifier
为0,在输出的时候,这个声明本身并不会被忽略,因为import
支持这种写法,比如import './index.css'
。所以对import
需要做特殊处理。这个跟VariableDeclaration
声明的行为是不一样的:如果一个变量声明不再包含VariableDeclarator
,输出时这个节点会被忽略。
清理import
空节点
写一个插件处理这个问题:
const plugin_remove_empty_import = () => ({
visitor: {
ImportDeclaration(path) {
const { node } = path
if(node.specifiers.length < 1) {
path.remove()
}
}
}
})
在demo
中更改newCode
生成部分的代码:
const { code: newCode } = transformFromAstSync(ast, '', {
comments: false,
plugins: [
plugin_remove_empty_import,
]
})
输出:
% node demo
let a = 1;
下面处理循环引用的问题
删除循环引用
Binding.referencePaths 与节点递归
Binding.referencePaths
存储了所有对当前节点引用的NodePath
集合,我们暂称其为引用的消费者。
同时,当一个NodePath
被remove()
之后,NodePath.node
为null
。
结合这两个特性,需要再对剩余的声明节点的消费者,进行递归查询;当所有消费者节点都是null
的时候,删除这个声明节点。
新增两个方法
判断节点是否已被删除
/**
* 判断节点是否已被删除
* @param {NodePath} p
* @returns
*/
const isRemoved = (p) => {
while(p) {
if(!p.node) {
return true
}
p = p.parentPath
}
return false
}
对Bindings集合递归删除
/
/**
* 对Bindings集合递归删除
* @param {Set} bindings
* @returns
*/
const shakeBindings = (bindings) => {
const tmp = []
bindings.forEach(binding => {
if(binding.referencePaths.every(p => isRemoved(p))) {
binding.path.remove()
tmp.push(binding)
}
})
if(tmp.length > 0) {
tmp.forEach(binding => bindings.delete(binding))
return shakeBindings(bindings)
}
return bindings
}
完整代码
const { traverse, parseSync, transformFromAstSync, types } = require('@babel/core')
/**
* [PLUGIN] 移除空`import`节点
* @returns
*/
const plugin_remove_empty_import = () => ({
visitor: {
ImportDeclaration(path) {
const { node } = path
if (node.specifiers.length < 1) {
path.remove()
}
},
VariableDeclaration(path) {
}
}
})
/**
* 判断节点是否已被删除
* @param {NodePath} p
* @returns
*/
const isRemoved = (p) => {
while(p) {
if(!p.node) {
return true
}
p = p.parentPath
}
return false
}
/**
* 对Bindings集合递归删除
* @param {Set} bindings
* @returns
*/
const shakeBindings = (bindings) => {
const tmp = []
bindings.forEach(binding => {
if(binding.referencePaths.every(p => isRemoved(p))) {
binding.path.remove()
tmp.push(binding)
}
})
if(tmp.length > 0) {
tmp.forEach(binding => bindings.delete(binding))
return shakeBindings(bindings)
}
return bindings
}
const demo = (code) => {
const ast = parseSync(code)
const bindings = new Set()
traverse(ast, {
Identifier(path) {
const { scope } = path
Object.values(scope.bindings).forEach(binding => {
if (binding.references < 1) {
binding.path.remove()
} else {
bindings.add(binding)
}
})
},
})
shakeBindings(bindings)
const { code: newCode } = transformFromAstSync(ast, '', {
comments: false,
plugins: [
plugin_remove_empty_import,
]
})
console.log(newCode || '<empty>')
return newCode
}
console.log('-'.repeat(16))
demo(`var a = 1, b = a, c = b`)
console.log('-'.repeat(16))
demo(`
import lib from 'some-lib'
let a = 1
function fn() {
var aa = 2
console.log(a) // 调用外部a
console.log(aa) // 调用内部aa
}
// fn() // 调用 fn
// lib.cal(a) // 调用 lib a
`)
console.log('-'.repeat(16))
demo(`
import lib from 'some-lib'
let a = 1
function fn() {
var aa = 2
console.log(a) // 调用外部a
console.log(aa) // 调用内部aa
}
fn() // 调用 fn
// lib.cal(a) // 调用 lib a
`)
console.log('-'.repeat(16))
demo(`
import lib from 'some-lib'
let a = 1
function fn() {
var aa = 2
console.log(a) // 调用外部a
console.log(aa) // 调用内部aa
}
// fn() // 调用 fn
lib.cal(a) // 调用 lib a
`)
console.log('-'.repeat(16))
demo(`
import lib from 'some-lib'
let a = 1
function fn() {
var aa = 2
console.log(a) // 调用外部a
console.log(aa) // 调用内部aa
}
fn() // 调用 fn
lib.cal(a) // 调用 lib a
`)
输出
% node demo
----------------
<empty>
----------------
<empty>
----------------
let a = 1;
function fn() {
var aa = 2;
console.log(a);
console.log(aa);
}
fn();
----------------
import lib from 'some-lib';
let a = 1;
lib.cal(a);
----------------
import lib from 'some-lib';
let a = 1;
function fn() {
var aa = 2;
console.log(a);
console.log(aa);
}
fn();
lib.cal(a);
以上。