Typescript 的 Open Class 和 Mixin

Open Class 和 Mixin(有的语言里面叫 Trait),是 Ruby 的语言特性中我最喜欢的两个。其实几乎所有的动态语言都能支持这两种特性,只不过是否是语法层面上支持的区别而已。对于 JavaScript 这样的语言来说,直接操作 prototype 就完事了,想怎么搞就怎么搞。但这样的问题是语法过于粗糙,不利于语言的静态分析。我们来看看 Ruby 是怎么做的:

Ruby Open Class

class String  
    def is_start_with_foo?
        self.start_with?("foo")
    end
end

"fooaaa".is_start_with_foo? # return true

这里,看似是声明了一个新的 String 类,实际上是打开了一个已经存在的 String 类,并为它添加新的方法

Ruby Mixin

module Debug  
    def whoAmI?
        "#{self.type.name} (\##{self.id}): #{self.to_s}"
    end
end  
class Phonograph  
    include Debug
    # ...
end  
class EightTrack  
    include Debug
    # ...
end  
ph = Phonograph.new("West End Blues")  
et = EightTrack.new("Surrealistic Pillow")  
ph.whoAmI?    »   "Phonograph (#537766170): West End Blues"  
et.whoAmI?    »   "EightTrack (#537765860): Surrealistic Pillow"  

我们可以看到,ruby 中 mixin 和 open class 的写法,不管是代码的优雅性,可读性以及对静态分析的支持都是非常友好的。想想如果用 javascript 的 prototype 写上面的代码会如何?

在使用 typescript 的过程中,我一直在思考 typescript 能否也能具备 ruby 这样 open class / mixin 的能力。首先在实现上肯定是跑不了使用 prototype 的,但只要能支持静态类型,这些问题都不大。所以使用 typescript 搞 open class / mixin 的首要问题就是解决类型支持,然后再尽量简化语法。

Typescript Open Class

typescript 有个比较牛逼的特性叫做合并声明 (declaration merging),意思就是多个 interface 可以合并成一个,例如:

// in a.ts
interface Fucker {  
    goToHell()
}

// in b.ts
interface Fucker {  
    shit()
}

// will produce a new Fucker interface
interface {  
    goToHell()
    shit()
}

但只是 interface 的声明合并用处不是很大,有趣的是,interface 还能和 class 的声明合并,也就是说 interface 能和 class 同名。聪明的同学看到这里基本已经猜到怎样实现类型安全的 open class 了

class Fucker {  
    constructor(public target: string) {}
}

interface Fucker {  
    goToHell()
}

Fucker.prototype.goToHell = function(this: Fucker) {  
    console.log(`You ${this.target} go to hell`)
}

new Fucker('bitch').goToHell() // output: You bitch go to hell  

看到了吗,我们只需要声明一个跟原 class 同名的 interface,然后在 prototype 里面写实现,就实现了 open class,是不是很简单,还有静态类型,而且语法上问题也不大?不过,把原 class 和 open class 的实现写在同一个文件里是可以的,就像上面的代码一样,但没意义。通常情况下 open class 都是和原 class 的代码分布在不同的文件里实现。很遗憾的是,如果分开在不同的文件,上面这种写法在 ES 的 module 系统下无法实现。这是因为 typescript 文件里的代码默认是在全局的作用域:

// a.ts
const foo = 'foo'

// b.ts
console.log(foo) // output foo  

但只要你在一个文件里使用了export, 或者在 top level 下使用了 import,都会使这个文件里的东西处于局部作用域下。两个局部作用域下的同名 interface 与 class 的声明是不可能合并的,这就是上面的 open class 写法在 module 系统下无法实现的原因。不过有个补救方案是可以用 namespace 来实现,具体怎么写读者可以自行实验。

综上所述,typescript 的 open class 在 module 系统下是无法优雅地实现的。但如果你选择完全不用 module 系统,而只用 namespace,未尝不是一个方案。

Typescript Mixin

官方文档里就有介绍如何实现 mixin,但我感觉上面的写法还是过于繁琐了些,可能是太久没有更新的缘故?这里我们可以用decoratordeclaration merge来优化一下:

// mixin.ts
export interface Ctor {  
    new(...args: any[]): {}
}

// 我们把官方文档上 applyMixin 改装成了装饰器
export function applyMixins(...baseCtors: Ctor[]) {  
    return function (derivedCtor: Ctor) {
        baseCtors.forEach(baseCtor => {
            Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => {
                derivedCtor.prototype[name] = baseCtor.prototype[name];
            });
        });
    }
}

// disposable.ts
export class Disposable {  
    isDisposed: boolean;
    dispose() {
        this.isDisposed = true;
    }
}

// activatable.ts
export class Activatable {  
    isActive: boolean;
    activate() {
        this.isActive = true;
    }

    deactivate() {
        this.isActive = false;
    }
}

// smartObject.ts
@applyMixins(Disposable, Activatable)
export class SmartObject {  
    interact() {
        this.activate();
    }
}

export interface SmartObject extends Disposable, Activatable { }

let smartObj = new SmartObject();

smartObj.interact()

这里我们用 decorator 修改了原 class 的 prototype,为其注入了 mixin 的 class,虽然这时 SmartObject 在运行时确实拥有了 mixin class 的实例变量和方法,但是在编译期它是没有那些变量和方法的。然后我们用一个同名的 interface 把声明合并,这就完成了一个编译期和运行时都良好的 mixin