JS静态分析工具ClosureCompiler介绍
2018-06-23 / modified at 2022-04-04 / 1.8k words / 8 mins
️This article has been over 2 years since the last update.

Closure Compiler是谷歌推出的一款Javascript压缩工具,在业界有广泛的使用。与传统压缩工具不同的地方在于,它将对代码的AST进行静态分析,而不是简单的正则表达式压缩。

在实际项目中,为了实现自动化提升代码质量,本文将通过在项目中引入此工具

  • 定制js压缩打包流程
  • 定制静态检测(比如jslint无法发现的业务特有BUG)

本文面向读者: 有一定AST基础,面向后端,希望控制代码质量的读者。由于AST的解析非常复杂,本文将只限制在使用工具上,而不会分析内部源码实现。

Getting Start

引入如下Maven依赖(如果不希望把它记入打包,可以把它配置为test的scope)

1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/com.google.javascript/closure-compiler -->
<dependency>
<groupId>com.google.javascript</groupId>
<artifactId>closure-compiler</artifactId>
<version>r2388</version>
</dependency>

一个AST遍历的例子

1
2
3
4
5
6
7
8
9
10
11
12
Compiler compiler = new Compiler();
Node parse = compiler.parse(SourceFile.fromCode("a.js", "var a = 1;"));
NodeTraversal.traverse(compiler, parse, new NodeTraversal.Callback() {
@Override
public boolean shouldTraverse(NodeTraversal nodeTraversal, Node n, Node parent) {
return true;
}
@Override
public void visit(NodeTraversal t, Node n, Node parent) {
System.out.println("n = " + n);
}
});

一个压缩的例子

1
2
3
4
5
6
7
8
9
10
11
SourceFile sourceFile;
sourceFile=SourceFile.fromCode("a.js", "function sum(left, right){return 2 + 3}");
// 初始化编译器
CompilerOptions opts = new CompilerOptions();
CompilationLevel.SIMPLE_OPTIMIZATIONS.setOptionsForCompilationLevel(opts);
Compiler compiler = new Compiler();
// 执行编译优化操作
compiler.compile(Collections.emptyList(), Collections.singletonList(sourceFile), opts);
String s = compiler.toSource();
System.out.println(s);
// => function sum(a,b){return a+b};

对JS代码进行静态转换

本部分将定制Closure的转换规则,以实现自动化代码变换。

预先准备

在完成new Compiler()的构造后,通过下文的样板代码,我们加入了一个新的优化器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 为DefaultPassConfig加入额外的优化PassFactory
DefaultPassConfig passes = new DefaultPassConfig(opts) {
@Override
protected List<PassFactory> getOptimizations() {
List<PassFactory> optimizations = super.getOptimizations();
// 创建了一个名称为`custom_pass`,实现为`CustomCompilerPass`的优化器
// 并放在了优化器List的最前面
optimizations.add(0, new PassFactory("custom_pass", true) {
@Override
protected CompilerPass createInternal(AbstractCompiler compiler) {
return new CustomCompilerPass(compiler);
}
});
return optimizations;
}
};
compiler.setPassConfig(passes);

接下来只用定制CustomCompilerPass即可实现代码优化了

这里本质上就是IR到IR的三段编译器

函数名变成大写

代码如下

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
class CustomCompilerPass implements CompilerPass {
AbstractCompiler compiler;

public CustomCompilerPass(AbstractCompiler compiler) {
this.compiler = compiler;
}

@Override
public void process(Node externs, Node root) {
NodeTraversal.traverse(compiler, root, new NodeTraversal.AbstractPostOrderCallback(){
@Override
public void visit(NodeTraversal t, Node n, Node parent) {
if (n.isFunction()){
Node firstChild = n.getFirstChild();
String qualifiedName = firstChild.getQualifiedName();
if (qualifiedName != null){
firstChild.setString(qualifiedName.toUpperCase());
compiler.reportCodeChange();
}
}
}
});
}
}
// -> function SUM(a, b){return 5}

注意这里树的遍历是后续(PostOrder)遍历,代码树结构大致如下,注意FUNCTION有三个Child

1
2
3
4
5
6
7
8
9
10
(BLOCK
(SCRIPT
(FUNCTION
"sum"
(PARAM_LIST
("left" "right")
(BLOCK
(RETURN
(ADD
(2 3))))))))

定制JS压缩

假设有如下JS,此JS来自UI-Router的SAP代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$stateProvider.state('home', {
url: "/", // root route
views: {
"lazyLoadView": {
controller: 'AppCtrl',
templateUrl: 'partials/main.html'
}
},
resolve: {
loadMyCtrl: ['$ocLazyLoad', function($ocLazyLoad) {
return $ocLazyLoad.load(['js/a.js','js/b.js','js/c.js']);
}]
}
});

我希望转换为支持在生产环境访问min.js的文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$stateProvider.state('home', {
url: "/", // root route
views: {
"lazyLoadView": {
controller: 'AppCtrl',
templateUrl: 'partials/main.html'
}
},
resolve: {
loadMyCtrl: ['$ocLazyLoad', function($ocLazyLoad) {
return $ocLazyLoad.load(function(){
if (window.env == 'dev'){
return ['js/a.js','js/b.js','js/c.js'];
} else {
return ['js/state/home.min.js'];
}
}());
}]
}
});

我们需要首先匹配

1
$ocLazyLoad.load(['js/a.js','js/b.js','js/c.js'])

它的AST如下

1
2
3
4
5
6
7
8
(CALL
(GETPROP
"$ocLazyLoad"
"load"
(ARRAYLIT
"js/a.js"
"js/b.js"
" js/c.js")

将要替换为

1
2
3
4
5
6
7
$ocLazyLoad.load(function(){
if (window.env == 'dev'){
return ['js/a.js','js/b.js','js/c.js'];
} else {
return ['js/state/home.min.js'];
}
}()

需要转换的AST如下

1
2
3
4
5
6
7
8
9
10
11
12
13
(CALL
(GETPROP
"$ocLazyLoad"
"load"
(CALL
(FUNCTION
""
(PARAM_LIST)
(BLOCK
(IF
(EQ)
(BLOCK)
(BLOCK)))))

参考代码如下

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
@Override
public void process(Node externs, Node root) {
NodeTraversal.traverse(compiler, root, new NodeTraversal.AbstractPostOrderCallback() {
@Override
public void visit(NodeTraversal t, Node n, Node parent) {
String state = "";
// 获取state
if (n.isGetProp()
&& n.getFirstChild().getString().equals("$stateProvider")
&& n.getLastChild().getString().equals("state")) {
Node next = n.getNext();
state = next.getString();
}
// 通过AST变换,生成一个匿名函数
if (n.isGetProp()
&& n.getFirstChild().getString().equals("$ocLazyLoad")
&& n.getLastChild().getString().equals("load")) {
Node next = n.getNext();
if (next.getChildCount() == 0){
return;
}
Node call = IR.call(IR.function(IR.name(""), IR.paramList(),
IR.block(
IR.ifNode(
IR.sheq(
IR.getprop(IR.name("window"), IR.string("env")),
IR.string("dev")),
IR.block(
IR.returnNode(next.cloneTree())
),
IR.block(
IR.returnNode(IR.arraylit(IR.string("js/state" + state + "/home.min.js")))
))
)));
next.getParent().replaceChild(next, call);
compiler.reportCodeChange();
}
}
});
}

注意这里使用了一个外部变量window.env,因此需要配置一个extern.js

1
2
3
4
/**
* @type {String}
*/
window.prototype.env;

最终生成的压缩后的js如下

1
$stateProvider.state("index",{url:"/",views:{lazyLoadView:{controller:"AppCtrl",templateUrl:"partials/main.html"}},resolve:{loadMyCtrl:["$ocLazyLoad",function(a){return a.load("dev"===window.env?["js/a.js","js/b.js","js/c.js"]:["js/state/home.min.js"])}]}});

自动加入依赖注入

很多人在使用依赖注入时总是忘记写成数组的形式,导致压缩后无法注入

使用前

1
2
3
4
angular.module('test').controller(function ($http, $scope) {
$http.post();
$scope.req = 1;
});

使用后

1
angular.module("test").controller(["$http","$scope",function(a,b){a.post();b.req=1}]);

实现方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override
public void process(Node externs, Node root) {
NodeTraversal.traverse(compiler, root, new NodeTraversal.AbstractPostOrderCallback() {
@Override
public void visit(NodeTraversal t, Node n, Node parent) {
// 此处自行添加定制过滤条件
if (n.isFunction() && n.getChildAtIndex(1).getChildCount()>0){
Node cloneTree = n.cloneTree();
Node paramNode = cloneTree.getChildAtIndex(1);
Iterable<Node> children = paramNode.children();
List<Node> list = new ArrayList<>();
for (Node child : children) {
list.add(IR.string(child.getString()));
}
list.add(cloneTree);
n.getParent().replaceChild(n, IR.arraylit(list.toArray(new Node[]{})));
}
}
});
}

Eval问题

在项目中发现一处冲突,在某遗留代码的AjaxFileUpload.js(大概是2012年的框架吧)中

1
2
//将data从json转为对象
data = eval("data="+data);

它被编译器优化后,变成了

1
data=eval("data\x3d"+data);

这类优化可能是为了编码更加完善,但是的确产生了很多未知BUG。正确的解决方法是任何时候都不要使用eval,可以用自带的JSON进行解析。

总结

通过操作AST,可以实现

  • 远超正则表达式的高度定制
  • 不需要Runtime

不足

  • 工具开发成本相当高,投入产出比很低,适合有空闲的人来搞,后面的人不一定看的懂
  • 此技术过于冷门,几乎没有Java的文档,但是对AST有兴趣的可以学习一个

参考

  • 后续(PostOrder)遍历图解

Pre Vs. In Vs. Post