TypeScript编译器增强版

Posted on October 14, 2016

在 Egret 引擎的命令行工具里一直都有一个自动排序的功能,能够自动分析出ts文件的依赖关系并给出正确的加载顺序,不用开发者手动去排列加载顺序。但是跟引擎工具耦合在一起了,没法独立使用。并且因为不是基于语法树分析的,准确率虽然还不错,但是没有达到100%完全可靠。正好最近TypeScript 2.0也发布了,借着升级引擎内置编译器的机会,我把这个功能从Egret 命令行里抽离出来做成了独立版。这次是基于语法树分析的,实现了100%的可靠性。除了自动排序,还加上其他几项附加功能,相当于原版 TypeScript 编译器的增强版:https://github.com/domchen/typescript-plus。 使用上完全兼容原版编译器,也是按照 tsc 项目的 API 风格去扩展的,额外增加的几个功能,参数开关默认都是关闭。

主要提供了四个额外功能:getter/setter优化,类反射信息,自动排序,以及条件编译。其中自动排序和条件编译在TypeScript项目的官方issue一年前就有很多人提过,实际项目中确实也有强烈的需求,不过可能目前还不是TypeScript团队的核心目标,并且实现条件编译等功能会违反了他们现在的设计原则:不在输出JS过程中做类型检查操作。因此目前想等官方加上的可能性不太高。我会持续维护这个项目,尽量保持跟原版编译器每个版本的同步。

下面详细说一下 typescript-plus 的使用:

安装

首先确保你已经安装了最近版本的node.js。然后执行如下命令就可以安装了:

npm install -g typescript-plus

如果需要编程方式调用它,可以参考这个项目的代码:tspack

运行

直接在命令行调用 tsc-plus 即可;

tsc-plus [input files] [options]

命令行参数格式与原版tsc完全一致,关于原版编译器的使用方法,可以参考这个教程

额外参数

参数 类型 默认值 描述
accessorOptimization boolean false 如果get/set方法只含有一行对其他方法的调用,直接用那个目标方法作为get/set方法体
emitReflection boolean false 输出类的反射信息
reorderFiles boolean false 根据依赖关系自动排序源文件列表
defines Object   将代码中出现的全局变量替换为对应的常量

这些参数除了 defines 只能在tsconfig.json中使用外,其他参数还可以通过命令行传入,例如 --reorderFiles 的形式。

tsconfig.json 文件示例:

{
    "compilerOptions": {
        "module": "commonjs",
        "accessorOptimization": true,
        "emitReflection": true,
        "reorderFiles": true
        "defines": {
            "DEBUG": false,
            "RELEASE": true
        }
    },  
    "files": [
        "core.ts",
        "sys.ts"
    ]  
}

Getter/Setter 优化

在TypeScript中,子类是没法覆盖父类的 get/set 方法的。为了解决这个问题,我们通常会转发一下 get/set 方法到本类的另一个方法,然后那个方法就可以被覆盖了,例如下面的例子:

TypeScript:

class Student {

    public _name:string;

    protected setName(value:string):void {
        this._name = value;
    }

    public get name():string {
        return this._name;
    }

    public set name(value:string) {
        this.setName(value);
    }
}

这个方法确实能解决问题,但是也明显带来了性能问题,因为直接访问 get/set 属性时必须要额外转发了一次。现在我们只要开启 accessorOptimization 选项,编译器就会优化掉只有一行代码调用的转发,直接用转发的目标方法作为 get/set 的方法体,上面的 TypeScript 文件开启这个参数后输出的 JavaScript 如下:

Javascript:

var Student = (function () {
    function Student() {
    }
    Student.prototype.setName = function (value) {
        this._name = value;
    };
    Object.defineProperty(Student.prototype, "name", {
        get: function () {
            return this._name;
        },
        set: Student.prototype.setName,
        enumerable: true,
        configurable: true
    });
    return Student;
}());

如果你把 setName() 定义在了 set name()之后,情况略复杂一些,但是一样可以正确输出,结果如下:

var Student = (function () {
    function Student() {
    }
    Object.defineProperty(Student.prototype, "name", {
        get: function () {
            return this._name;
        },
        set: setName,
        enumerable: true,
        configurable: true
    });
    Student.prototype.setName = setName;
    function setName(value) {
        this._name = value;
    };
    return Student;
}());

不管定义在什么位置,都可以正常运行。有了这个优化之后,就可以大胆地使用转发了,又解决了子类的覆盖问题,又完全不影响执行性能。

类反射信息

直接上例子:

TypeScript:

namespace ts {
    export interface IPerson {
        name:string;
    }
    
    export class Student implements IPerson {
        public name:string = "";
    }
}

JavaScript:

var ts;
(function (ts) {
    var Student = (function () {
        function Student() {
            this.name = "";
        }
        return Student;
    }());
    ts.Student = Student;
    __reflect(Student.prototype, "ts.Student", ["ts.IPerson"]);
})(ts || (ts = {}));

这个 __reflect 函数跟 tsc 原版生成的 __extends 辅助函数一样,一个文件都只会在文件顶部生成一次。它负责注册类的反射信息到类定义上。然后你就可以在运行时获取一个实例的完整类名或判断是否实现了某个接口。具体可以参考 Egret 引擎里的 getQualifiedClassName()is() 函数。

自动排序

通常情况下,当我们传递了--outFile参数后,编译器会把所有输入的ts文件列表编译到一个js文件中。然而这个输出的顺序是按照你输入文件列表的顺序来的,原版的ts编译器并不会去调整它,JS是一边加载一边执行的,如果有立即执行的代码,或者有类的继承,顺序不对加载过程就会报空引用错误。这导致我们需要手动去排列这个先后顺序。文件少的时候还无所谓,文件特别多的时候就很费劲了。

现在只要开启 reorderFiles 选项,编译器在输出JS文件前会自动分析ts代码的依赖关系,然后调整为一个正确的顺序输出结果。你不再需要手动调整输入文件的顺序,也不需要在ts文件里加 ///<reference/> 标签。我在常用的项目里都测试过了这个功能,目前还没发现任何问题。如果遇到非常特殊的写法是我没覆盖到的,可以到 https://github.com/domchen/typescript-plus/issues 去开一个issue,并贴一下能重现排序错误的源文件,我会尽快修复。

条件编译

你可以使用 defines 参数来定义一系列的键值对。键表示代码中的全局变量,值表示对应的常量(目前可以是布尔值,数字,或字符串)。在输出JS文件时,所有定义过的全局变量都会被替换为对应的常量。举个例子:

tsconfig.json:

{
    "compilerOptions": {
        "defines": {
            "DEBUG": false,
            "LANGUAGE": "en_US"
        }
    }
}

TypeScript:

declare var DEBUG:boolean;
declare var LANGUAGE:string;

if (DEBUG) {
    console.log("DEBUG is true");
}

console.log("The language is : " + LANGUAGE);

function someFunction():void {
    let DEBUG = true;
    if (DEBUG) {
        console.log("DEBUG is true");
    }
}

JavaScript:

if (false) {
    console.log("DEBUG is true");
}

console.log("The language is : " + "en_US");

function someFunction() {
    var DEBUG = true;
    if (DEBUG) {
        console.log("DEBUG is true");
    }
}

大家应该会注意到,第二个在 someFunction 函数里的 if(DEBUG) 并没有替换,因为这个是局部变量,并不是全局变量。

注意一下,编译器实际上只做了常量替换,并没有直接去掉 if(false){....} 这些无法执行到的代码块。可能有人会有疑问,这哪能算条件编译啊?实际上因为编译后你还会用到其他的JS代码压缩工具,例如UglifyJSGoogle Closure Compiler. 这些代码块用随便一个JS压缩工具都能很容易去掉,所以这里做到常量替换就已经足够了,不应该做编译器不需要做的额外工作。

其他

实际上我还写了点代码压缩的优化,比如把 Object.defineProperty(...) 这段固定的代码封装为短名函数,把XXXX.prototype 缓存为短名局部变量。最终测试的效果减小的不是很明显,虽然压缩后的JS是小了一点,但是gzip之后是完全一样的。我们实际上网络传送的都是gzip之后的大小,所以意义并不大。最终还是去掉这个优化,保留原版TSC的输出格式。

真正能显著压缩大小的一个思路其实是把private属性都重命名,这个也只有ts编译阶段可以做,因为private信息只有ts存在,到JS里都是public的。Uglifyjs这些工具遇到成员属性都当成public的,所以都跳过不压缩。还有一个思路就是基于tsc的语法树去实现混淆,把public,project以及类名,所有有效的字符串信息都重命名为随机段字符串。这样不存在可阅读的信息,项目规模足够大的情况,效果就跟编译成汇编一样了。以后有时间再研究吧。

最后,你如果觉得这个项目还不错,给它加个star吧。 :D