使用babel对js代码进行tree-shaking

1,343 阅读3分钟

实际的开发过程中,出于各种原因,经常会有些变量在声明后,并不会被真正使用,形成一些既不影响运行、也不容易被发现的坏代码。

同时,一般意义上的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。我们暂且将这个方法叫对声明的引用统计法

babeltraverse过程中,可以借助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的声明,分别是libfn,也就是被注释掉两行。

删除引用数为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集合,我们暂称其为引用的消费者

同时,当一个NodePathremove()之后,NodePath.nodenull

结合这两个特性,需要再对剩余的声明节点的消费者,进行递归查询;当所有消费者节点都是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);

以上。