类是组织和规划代码的方式,是封装的基本单位。 typescript类大量借用了C#的相关理论,支持可见性修饰符,属性初始化语句,多态,装饰器和接口。
不过,由于Typescript将类编译成常规的JavaScript类,所以我们也能使用一些JavaScript管用法,例如兼顾类型安全的混入(mixin)。
Typescript的某些类特性,例如属性初始化语句和装饰器,JavaScript类也支持,因此能生成运行时代码。其他特性(例如可见性修饰符,接口和泛型)是Typescript专属的特性,只存在于编译时,把应用编译成JavaScript后不生成任何代码。
本章学习Typescript类的用法。掌握类的使用方法和缘由。
我们将制作一个国际象棋引擎。提供一个API供两个玩家交替走棋。 首先草拟类型:
// 表示以此国际象棋游戏
class Game { }
// 表示一个国际象棋棋子
class Piece { }
// 一个棋子的一组坐标
class Position { }
// 将棋子分类
class King extends Piece {
}
class Queen extends Piece {
}
// 主教
class Bishop extends Piece {
}
// 骑士
class Knight extends Piece {
}
// 城堡
class Rook extends Piece {
}
// 兵
class Pawn extends Piece {
}
每个棋子都有颜色和当前位置。
下面为Piece类添加颜色和位置
type Color = "Black" | "White";
// 竖线
type File = "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H";
// 横线
type Rank = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;// 表示以此国际象棋游戏
class Game { }
// 一个棋子的一组坐标
class Position {
constructor(
private file: File,
private rank: Rank
) { }
}
// 表示一个国际象棋棋子
class Piece {
protected position: Position
constructor(
private readonly color: Color,
file: File,
rank: Rank
) {
this.position = new Position(file, rank)
}
}
Typescript类中的属性和方法支持三个访问修饰符
访问修饰符的作用是不让类暴露过多实现细节,而是只开放规范的API,供外部使用。
我们定义了一个Piece类,但是并不希望用户直接实例化Piece,而是在此基础上扩展,定义Queen,Bishop等类,实例化这些子类。为此,我们可以做出限制,具体方式是使用abstract:
abstract class Piece {
protected position: Position
constructor(
private readonly color: Color,
file: File,
rank: Rank
) {
this.position = new Position(file, rank)
}
}
现在实例化Piece将报错
abstract关键字表示,我们不能直接初始化该类,但是并不阻止我们在类中定义方法:
abstract class Piece {
protected position: Position
constructor(
private readonly color: Color,
file: File,
rank: Rank
) {
this.position = new Position(file, rank)
}
moveTo(position:Position){
this.position = position
}
abstract canMoveTo(position:Position):boolean
}
// 将棋子分类
class King extends Piece {
canMoveTo(position: Position): boolean {
return true
}
}
现在,Piece类包含以下信息
下面更新King类的定义
class Position {
constructor(
private file: File,
private rank: Rank
) { }
distanceFrom(position:Position){
return {
rank:Math.abs(position.rank - this.rank),
file:Math.abs(position.file.charCodeAt(0)-this.file.charCodeAt(0))
}
}
}
// 将棋子分类
class King extends Piece {
canMoveTo(position: Position): boolean {
let distance = this.position.distanceFrom(position)
return distance.rank<2 && distance.file < 2
}
}
开始新游戏时,我们想自动创建一个棋盘和一些棋子:
class Game {
private pieces = Game.makePieces()
private static makePieces() {
return [
new King("White", "E", 1),
new King("White", "E", 8),
new Queen("White", "D", 1),
new Queen("Black", "D", 8)
]
}
}
游戏其他功能自行实现
总结一下:
与JavaScript一样,Typescript也支持super调用。如果子类覆盖父类中定义的方法,在子类中可以通过super调用父类中的同名方法。super有两种调用方式:
// 将棋子分类
class King extends Piece {
constructor(
color: Color,
file: File,
rank: Rank
){
super(color,file,rank)
}
canMoveTo(position: Position): boolean {
let distance = this.position.distanceFrom(position)
return distance.rank < 2 && distance.file < 2
}
}
this可以用作值,此外还能用作类型。对类来说,this类型还可以用于注解方法的返回类型。
例如:实现ES6中set数据结构的简化版
class Set{
has(value:string):boolean{
return true
}
add(value:string):Set{
return this
}
}
定义Set的子类型
class Set{
has(value:string):boolean{
return true
}
add(value:string):this{
return this
}
}
class MutableSet extends Set {
delete(value:number):boolean{
return true
}
// 不用覆盖了
// add(value:number)
}
扩展其他类时,要把返回this的每个方法的签名覆盖掉,就显得比较麻烦。如果只是为了让类型检查器满意,这样做就失去了继承基类的意义。
如此一来,哦我们可以把MutableSet中覆盖的add方法省略,因为在Set中this指向一个Set实例,而在MutableSet中,this指向一个MutableSet实例。
类经常当做接口使用 与类型别名相似,接口是一种命名类型的方式,这样就不用再行内定义了。类型别名和接口算是同一种概念的两种句法(就像函数表达式和函数声明之间的关系)
type Sushi = {
calories:number
salty:boolean
tasty:boolean
}
// 重写为
interface Sushi{
calories:number
salty:boolean
tasty:boolean
}
在使用Sushi类型别名的地方都能使用Sushi接口。两个声明都定义结构,而且二者可以相互赋值(其实,二者完全一样)
把类型组合在一起时,更为有趣。
type Food = {
calories:number
tasty:boolean
}
type Sushi = Food&{
salty:boolean
}
interface Cake extends Food{
sweet:boolean
}
接口不一定扩展其他接口,其实,接口可以扩展任何结构:对象类型,类或其他接口。
类型和接口之间有什么区别呢?有三个细微的差别。
type A = number;
type B = A | string
interface A {
good(x:number):string
bad(x:number):string
}
interface B extends A {
good(x:string|number):string
bad(x:string):string// 报错
}
而使用别名没事
type A = {
good(x:number):string
bad(x:number):string
}
type B = A & {
good(x:string|number):string
bad(x:string):string// 报错
}
let c:B = {
good(x:number){
return "s"
},
bad(x:string|number){
return ""
}
}
建模对象类型的继承时,Typescript对接口所做的可赋值检查时捕获错误的有力工具
声明合并指的是Typescript自动把多个同名声明组合在一起。介绍枚举时讲过这个特性(3.2.12节),讨论命名空间声明(10.3节)还会说到
倘若生命了两个名为User的接口,Typescript将自动把二者组合成一个接口:
interface User {
name:string
}
interface User {
age:number
}
let a:User = {
name:"111",
age:12
}
而使用类型别名重写的话,将报错
注意:两个接口不能冲突,如果在一个接口中某个属性的类型为T,而在另一个接口中该属性的类型为U,由于T和U不是同一种类型,Typescript将报错
interface User{
age:string
}
interface User{
age:number// 报错
}
如果接口中声明了泛型(5.7节),那么两个接口中要完全相同的方式声明泛型(名称一样还不行),这样才能合并接口。
interface User{
age:Age
}
interface User{
age:Age
}// 报错
Typescript很少会这么做,但是在这里,Typescript不仅检查了两个类型满不满足可赋值性,还会确认二者是否完全一致。
声明类时,可以使用implement关键字指明该类满足某个接口。与其他显示类型注解一样,这是为类添加类型层面约束的一种便利方式。这么做能尽量保证类在实现上的准确性,防止错误出现在下游,不知具体原因。这也是实现常用的设计模式(例如适配器,工厂和策略)的一种常见方式。后后面分享
interface Animal{
eat(food:string):void
sleep(hour:string):void
}
class Cat implements Animal {
eat(food: string): void {
console.info("Ate some",food,".MM");
}
sleep(hour: string): void {
console.info("Slept for",hour,"hours");
}
}
new Cat().sleep("10")
new Cat().eat("fish")
cat必须实现Animal声明的每个方法。如果需要,在此基础上还可以实现其他方法和属性。
接口可以声明实例属性,但是不能带有可见性修饰符(private,protected,public),也不能使用static关键字。另外,像对象类型一样(第三章),可以使用readonly把实例属性标记为只读:
interface Animal {
readonly name: string
eat(food: string): void
sleep(hour: string): void
}
一个类不限于只能实现一个接口,而是想实现多少个都可以:
interface A {}
interface B {}
class C implements A,B{
}
实现接口其实于扩展抽象类差不多,区别是,接口更通用,更轻量,而抽象类的作用更具体,功能更丰富。
接口是对结构建模的方式。在值层面可以表示对象,数组,函数,类或类的实例。接口不生成JavaScript代码,只存在于编译时。
抽象类只能对类建模,而且生成运行时代码,即JavaScript类.抽象类可以有构造方法,可以提供默认实现,还能为属性和方法设置访问修饰符。这些在接口中都做不到。
具体使用哪个,取决于实现用途。如果多个类共用同一个实现,使用抽象类。如果需要一种轻量的方式表示“这个类是T型”,使用接口。
与Typescript中的其他类型一样,Typescript根据结构比较类,与类的名称无关。类与其他类型是否兼容,要看结构;如果常规的对象定义了同样的属性或方法,也与类兼容。从C#,Scala和其他多数名义类型编程语言转过来的程序员来说,一定要记住这点。 这意味着,如果一个函数接受Zebra实例,而我们传入一个Poodle实例,Typescript并不介意:
class Zebra{
// 小跑
trot(){
}
}
// 贵宾犬
class Poodle {
trot(){
}
}
// 漫步
function ambleAround(animal:Zebra){
animal.trot()
}
let zebra = new Zebra
let poodle = new Poodle
ambleAround(zebra)
ambleAround(poodle)
Typescript是彻底的结构化类型语言,因此这段代码完全有效。
然后,如果类中使用private或proteceted修饰的字段,情况就不一样了。检查一个结构是否可赋值给一个类时,如果类中油private或protected字段,而且结构不是类或其子类的实例,那么结构就不可赋值给类:
class A {
private x = 1
}
class B extends A {}
function f(a:A){}
f(new A)
f(new B)
f({x:1})// 错误
在Typescript中,多数时候,表达的要么是值要么时类型:
let a = 1999
function b(){
}
// 类型
type a = number
interface b {
():void
}
let c:b=function(){
}
在Typescript中,类型和值位于不同的命名空间中。根据场合,Typescript知道你要使用的是类型还是值(上面的a或b):
if(a+1>3){}// 推导为值
let x:a = 3// 推导为类型a
这种根据上下文进行解析的特性十分有用,可以做一些很酷的事情,例如实现伴生类型(companion type 6.3.4节)
类和枚举比较特殊,他们既在类型命名空间中生成类型,也在值命名空间中生成值。
class C {}
let c:C // 类型
= new C// 值
enum E {F,G}
let e:E// 类型
= E.F// 值
使用类时,我们需要一种方式表达“这个变量应是这个类的实例”,枚举同样如此(“这个变量应是这个枚举的一个成员”)。由于类和枚举在类型层面生成类型,所以我们可以轻易表达这种”是什么“关系。
此外,我们还需要一种在运行时表示类的方式,这样才能使用new实例化类,在类上调用静态方法,做元编程,使用instanceof操作,因此类还需要生成值。
在上述示例中,C指C类的一个实例。那要怎么表示C类自身的类型呢?使用typeof关键字(Typescript提供的类型运算符,作用类似于JavaScript中值层面的typeof,不过操作的是类型)。
下面声明一个StringDatabase类,实现一个简单的数据库:
type State = {
// 索引签名
[key: string]: string
}
class StringDatabase {
state:State = {}
get(key:string):string|null{
return key in this.state ? this.state[key] : null
}
set(key:string,value:string):void{
this.state[key] = value
}
static from(state:State):StringDatabase{
let db = new StringDatabase
for(let key in state){
db.set(key,state[key])
}
return db
}
}
let db = StringDatabase.from({name:"red",age:"16"})
console.log(db.get("name"));
这个类声明生成的类型是什么呢?是实例类型StringDatabase:
interface StringDatabase{
state:State
get(key:string):string|null
set(key:string,value:string):void
}
以及 构造方法类typeof StringDatabase:
interface StringDatabaseConstructor{
new():StringDatabase
from(state:State):StringDatabase
}
即,StringDatabaseConstructor只有一个方法.from,使用new运算符操作这个构造方法得到一个StringDatabase实例。这两个接口组合在一起对类的构造方法和实例进行建模。
new()那一行称为构造方法签名,Typescript通过这种方式表示指定的类型可以使用new运算符实例化。鉴于Typescript采用的是结构化类型,这是描述类的最佳方式,即可以通过new运算符实例化的是类。
类声明不仅在值层面和类型层面生成相关内容,而且在类型层面生成两部分内容:一部分表示类的实例,另一部分表示类的构造方法(通过类型运算符typeof获取)
与函数和类型一样,类和接口对泛型参数也有深层次支持,包括默认类型和限制。泛型的作用域可以放在整个类或接口中,也可放在特定的方法中:
class MyMap{1.
constructor(initialKey:K,initialValue:V){2。
}
get(key:K):V{3.
}
set(key:K,value:V):void{
}
merge(map:MyMap):MyMap{4.
//
}
static of(k:K,v:V):MyMap{5.
}
}
1.
中声明的K和V,不过该方法自己声明了泛型K和V接口也可以绑定泛型
interface MyMap{
get(key:K):V
set(key:K,value:V):void
}
与函数一样,我们可以显式为泛型绑定具体类型,也可以让Typescript自动推导:
let a = new MyMap("k",1)// MyMap
let b = new MyMap("k",true)//MyMap
a.get("k")
a.set("k",false)
JavaScript和Typescript都没有trait或mixin关键字,不过自己实现起来也不难。这两个特性都用于模拟多重继承(一个类扩展两个以上的类),可做面向角色编程。
这是一种编程风格,在这种风格中,我们不表述“这是一个Shape”,而是描述事物的属性,表述”这个东西可以度量“或者”这个东西有四条边“;我们不再关心”是什么“关系,转而描述”能做什么“和”有什么“关系。
下面我们自己手动实现混入。
混入这种模式把行为和属性混合到类中。按照惯例,混入有以下特性:
Typescript没有内置混入的概念,不过我们可以自己手动轻易实现。下面我们设计一个调试Typescript类的库,以此为例进行说明。作用是输出关于类的一些信息。
class User{
//
}
User.debug() // 求值结果为'User({"id":3,"name":"Emma Gluzman"})'
通过这个标准的.debug接口,用户便可以调试任何类。下面开始实现。我们将通过一个混入实现这个接口,将其命名为withEZDbug。混入其实就是一个函数,只不过这个函数接受一个类构造方法,而且返回一个类构造方法。这个混入的声明如下:
type ClassConstructor = new(...args:any[])=>{} // 1.
function withEZDebug(Class:C){// 2.
return class extends Class {//3.
constructor(...args:any[]){// 4.
super(...args) //5.
}
}
}
与常规的JavaScript一样,如果构造方法中没有什么逻辑,可以省略4.
和5.
在这个withEZDebug示例中,我们不打算在构造方法中放任何逻辑,因此可以省略那两行。
准备工作后,下面开始实现调试功能。调用.debug时,我们想输出类的构造方法名称和实例的值:
type ClassConstructor = new(...args:any[])=>{} // 1.
function withEZDebug(Class:C){// 2.
return class extends Class {//3.
constructor(...args:any[]){// 4.
super(...args) //5.
}
debug(){
let Name = Class.constructor.name;
let value = this.getDebugValue()
return Name+'('+JSON.stringify(value)+')'
}
}
}
!!!???这里要调用.getDebugValue方法,可以我们怎么样确保类实现了这个方法呢?
答案是,不接受常规的类,而是使用泛型确保传给withEZDebug的类定义了.getDebugValue方法:
type ClassConstructor = new(...args:any[])=>{} // 1.
function withEZDebug>(Class:C){// 2.
return class extends Class {//3.
constructor(...args:any[]){// 4.
super(...args) //5.
}
debug(){
let Name = Class.constructor.name;
let value = this.getDebugValue()
return Name+'('+JSON.stringify(value)+')'
}
}
}
1.
为ClassConstructor添加了一个泛型参数。1.2
为ClassConstructor绑定一个结构类型,C,规定传给withEZDebug的构造方法至少定义了.getDebugValue方法最终代码
type ClassConstructor = new(...args:any[])=>{}
function withEZDebug>(Class:C){
return class extends Class {
getDebugValue: any;
constructor(...args:any[]){
super(...args)
}
debug(){
let Name = Class.constructor.name;
// 当前作用域中查找,没有就去父类中找
let value = this.getDebugValue()
return Name+'('+JSON.stringify(value)+')'
}
}
}
class HardToDebugUser{
constructor(
private id:number,
private firstName:string,
private lastName:string
){}
getDebugValue(){
console.log("running");
return{
id:this.id,
name:this.firstName+" "+this.lastName
}
}
}
let User = withEZDebug(HardToDebugUser)
let user = new User(3,"red","润")
console.log("debug中",user.debug());
我们可以把任意多个混入混合到类中,为类增添更丰富的行为,而且这一切在类型上都是安全的。混入有助于封装行为,是描述可重用行为的一种重要方式。
很多语言,比如Scala,PHP,Kotlin和Rust,实现了精简版混入,称为性状(trait)。性状与混入类似,但是没有构造方法,也不支持实例属性。 因此性状更容易使用,而且不会在多个性状访问性状与基类共用的状态时产生冲突。
装饰器是Typescript的一个实验特性,为类,类方法,属性和方法参数的元编程提供简介的句法。其实,装饰器就是子啊装饰目标上调用函数的一种句法。
tsconfig.json添加
"experimentalDecorators": true
开启装饰器,这是一个实验特性,目前Typescript5.x版支持装饰器第三阶段了,本文暂时没涉及,学好本节后可以快速过渡。
使用装饰器
@serializeable
class APIPayload{
getValue():Payload{
//
}
}
不使用装饰器
let APIPayload = serializeable(class APIPayload{
getValue():Payload{
//.
}
})
对不同种类的装饰器,Typescript要求作用域中有那种装饰器指定名称的函数,而且该函数还要具有相应的签名(见下表5-1)
表5-1:不同种类装饰器函数要具有的类型签名
装饰目标 |
具有的类型签名 |
---|---|
类 |
(Constructor:{new(...any[])=>any})=>any |
方法 |
(classPrototype:{},methodName:string,descriptor:PropertyDescriptor)=>any |
静态方法 |
(Constructor:{new(...any[])=>any}),methodName:string,descriptor:PropertyDescriptor)=>any |
方法的参数 |
(classPrototype:{},paramName:string,index:number)=>void |
静态方法的参数 |
(Constructor:{new(...any[])=>any}),paramName:string,index:number)=>any |
属性 |
(classPrototype:{},propertyName:string)=>any |
静态属性 |
(Constructor:{new(...any[])=>any},propertyName:string)=>any |
属性设值方法/读值方法 |
(classPrototype:{},propertyName:string,descriptor:PropertyDescriptor)=>any |
静态属性设置方法/读值方法 |
(Constructor:{new(...any[])=>any},propertyName:string,descriptor:PropertyDescriptor)=>any |
Typescript没有内置任何装饰器,如果你要使用,只能自己实现,或者从npm中安装。不同种类的装饰器(包括类装饰器,方法装饰器,属性装饰器和函数参数装饰器)都是常规函数,只不过要满足相应的特定签名。例如,前面使用的@serializable装饰器可以像下面这样实现:
type ClassConstructor = new(...args:any[])=>T//1.
function serializeable>(Constructor:T){// 3.
return class extends Constructor{// 4.
serialize(){
return this.getValue()
}
}
}
@serializeable
class Payload{
name="redrun"
serialize:any
getValue(): Payload {
console.log(this);
return this
}
}
let p = new Payload()
p.serialize()
Typescript假定装饰器不改变装饰器目标的结构,意即不增加或删除方法和属性。Typescript在编译时检查返回的类是否可以复制给传入的类。
在Typescript的装饰器称为稳定特性前,不建议使用。
使用常规用法
let DecroatedAPIPayload = serialized(APIPayload)
let payload = new DecoratedAPIPayload
payload.serialize() // string
更多装饰器信息,官方文档
final关键字的作用:某些语言使用这个关键字把类标记为不可拓展,或者把方法标记为不可覆盖。
Typescript的类和方法不支持final关键字,但是我们可以轻易模拟
可以使用私有的构造方法模拟final类:
class MessageQueue{
private constructor(private message:string[]){}
}
class BadQueue extends MessageQueue{}//报错
new MessageQueue()// 报错
除了禁止扩展类以外,私有的构造方法还禁止直接实例化类。但是,我们喜欢final类能够实例化,禁止拓展就好,那么,怎样保留第一个限制,而避免第二个限制呢;
class MessageQueue{
private constructor(private message:string[]){}
static create(message:string[]){
return new MessageQueue(message)
}
}
// class BadQueue extends MessageQueue{}//报错
// new MessageQueue()
MessageQueue.create([])// 创建一个类
下面动手实现一两个设计模式
工厂模式(factory pattern)是创建某种类型的对象的一种方式,这种方式把创建哪种具体对象留给创建该对象的工厂决定。
type Shoe = {
purpose:string
}
class BalletFlat implements Shoe {
purpose = "dancing"
}
class Boot implements Shoe{
purpose = "woodcutting"
}
这里使用type,此外也可以使用interface
下面创建工厂
type Shoe = {
purpose:string
}
class BalletFlat implements Shoe {
purpose = "dancing"
}
class Boot implements Shoe{
purpose = "woodcutting"
}
let Shoe = {
create(type:"balletFlat"|"boot"):Shoe{
switch(type){
case "balletFlat":return new BalletFlat
case "boot":return new Boot
}
}
}
这个实例使用伴生对象模式(6.3.4节)声明类型Shoe和同名值Shoe,以此表明值提供了操作类型的方法。若想使用这个工厂,只需要调用.create:
Shoe.ceate("boot")
建造者模式(builder pattern)把对象的建造方式与具体的实现方式区分开。如果你用过jquery,或者ES6的Map和Set等数据结构,对这种API风格不陌生。
class RequestBuilder {
private url:string|null = null
private method:'get'|'post'|null = null
private data:object|null = null
setURL(url:string):this{
this.url = url
return this
}
setMethod(method:'get'|'post'):this {
this.method = method
return this
}
setData(data:object):this{
this.data = data
return this
}
send(){
//
}
}
new RequestBuilder()
.setURL("/user")
.setMethod('get')
.setData({firstName:'Anna'})
.send()
按顺序调用
class RequestBuilder {
protected data: object | null = null
protected method: 'get' | 'post' | null = null
protected url: string | null = null
setMethod(method: 'get' | 'post'): RequestBuilderWithMethod {
return new RequestBuilderWithMethod().setMethod(method).setData(this.data)
}
setData(data: object | null): this {
this.data = data
return this
}
}
class RequestBuilderWithMethod extends RequestBuilder {
setMethod(method: 'get' | 'post' | null): this {
this.method = method
return this
}
setURL(url: string): RequestBuilderWithMethodAndURL {
return new RequestBuilderWithMethodAndURL()
.setMethod(this.method)
.setURL(url)
.setData(this.data)
}
}
class RequestBuilderWithMethodAndURL extends RequestBuilderWithMethod {
setURL(url: string): this {
this.url = url
return this
}
send() {
// ...
}
}
new RequestBuilder()
.setMethod('get')
.setData({})
.setURL('foo.com')
.send()