类的类型标注(Class Types) - TypeScript 类的类型
类的类型标注(Class Types)
TypeScript 完全支持 ES2015 引入的class
关键字。和其他 JavaScript 语言特性一样,TypeScript 提供了类型注解和其他语法,允许你表达类与其他类型之间的关系。
类成员
这是一个最基本的类,一个空类:
class Point {}
这个类并没有什么用,所以让我们添加一些成员。
字段
一个字段声明会创建一个公共(public)可写入(writeable)的属性:
class Point { x: number; y: number; } const pt = new Point(); pt.x = 0; pt.y = 0;
注意:类型注解是可选的,如果没有指定,会隐式的设置为any
。
字段可以设置初始值:
class Point { x = 0; y = 0; } const pt = new Point(); console.log(`${pt.x}, ${pt.y}`); // 输出 0, 0
就像const
、let
和var
,一个类属性的初始值会被用于推断它的类型。
const pt = new Point(); pt.x = "0"; // Type 'string' is not assignable to type 'number'.
strictPropertyInitialization 选项
strictPropertyInitialization
选项,是控制严格性的标记,控制了类的字段是否需要在构造器(构造函数)中初始化。如果开启strictPropertyInitialization
,我们必须要确保每个实例的属性都会初始化,可以在构造函数里初始化或者属性定义时赋值。
class BadGreeter { name: string; // Property 'name' has no initializer and is not definitely assigned in the constructor.}
class GoodGreeter { name: string; // 构造器 constructor() { this.name = "hello"; } }
注意,字段需要在构造函数自身进行初始化。TypeScript 不会分析从构造函数调用的方法以检测初始化,因为派生类也许会覆盖这些方法并且无法初始化成员:
class BadGreeter { name: string; // Property 'name' has no initializer and is not definitely assigned in the constructor. setName(): void { this.name = '123' } constructor() { this.setName(); } }
如果你执意通过构造函数以外的方法来初始化字段(例如,可能有一个外部库正在为您填充类的一部分),你可以使用赋值断言操作符!
class OKGreeter { // Not initialized, but no error name!: string; }
readonly
字段可以添加一个readonly
前缀修饰符,这将防止对构造函数之外的字段进行赋值。
class Greeter { readonly name: string = "world"; constructor(otherName?: string) { if (otherName !== undefined) { this.name = otherName; } } err() { this.name = "not ok"; // Cannot assign to 'name' because it is a read-only property. } } const g = new Greeter(); g.name = "also not ok"; // Cannot assign to 'name' because it is a read-only property.
构造函数
类的构造函数跟函数非常类似,你可以使用带类型注解的参数、默认值、重载等。
class Point { x: number; y: number; // Normal signature with defaults constructor(x = 0, y = 0) { this.x = x; this.y = y; } }
class Point { // Overloads constructor(x: number, y: string); constructor(s: string); constructor(xs: any, y?: any) { // TBD } }
但类构造函数签名与函数签名之间也有一些区别:
- 构造函数不能有类型参数(关于类型参数,回想下泛型里的内容),这些属于外层的类声明,我们稍后就会学习到。
- 构造函数不能有返回类型注解,因为总是返回类实例类型
Super 调用
在 JavaScript 中,super
关键字用于访问对象字面量或类的原型([[Prototype]])上的属性,或调用父类的构造函数。
ES6 类(class)可以通过extends
关键字实现继承,而同时子类必须在constructor
方法中调用super()
方法,否则新建实例时会报错。这是因为子类自己的this
对象,必须先通过父类的构造函数完成塑造,得到与父类同样的实例属性和方法,然后再对其进行加工,加上子类自己的实例属性和方法。如果不调用super()
方法,子类就得不到this
对象。
ES5 类的继承的实质是先创造子类的实例对象this
,然后再将父类的方法添加到this
上面(Parent.apply(this))。ES6 的继承机制完全不同,实质是先将父类实例对象的属性和方法,加到this
上面(所以必须先调用super()
方法),然后再用子类的构造函数修改this
。
因此 ES6 中,子类的构造函数中,只有调用super()
之后,才可以使用this
关键字,否则会报错。这是因为子类实例的构建,基于父类实例,只有super()
方法才能调用父类实例。同时子类没有定义constructor
方法,super()
方法会被默认添加。
即便我们忽视错误编译代码,这个例子也会运行错误:
const b: Base = new Derived(); // Crashes because "name" will be undefined b.greet();
初始化顺序
有些情况下,JavaScript 类初始化的顺序会让你感到很奇怪,让我们看这个例子:
class Base { name = "base"; constructor() { console.log("My name is " + this.name); } } class Derived extends Base { name = "derived"; } // Prints "base", not "derived" const d = new Derived();
到底发生了什么呢?
类初始化的顺序,就像在 JavaScript 中定义的那样:
- 基类字段初始化
- 基类构造函数运行
- 派生类字段初始化
- 派生类构造函数运行
这意味着基类构造函数只能看到它自己的name
的值,因为此时派生类字段初始化还没有运行。
继承内置类型
注意:如果你不打算继承内置的类型比如Array
、Error
、Map
等或者你的编译目标是 ES6/ES2015 或者更新的版本,你可以跳过这个章节。在 ES2015 中,当调用super(...)
的时候,如果构造函数返回了一个对象,会隐式替换this
的值。所以捕获super()
可能的返回值并用this
替换它是非常有必要的。
这就导致,像Error
、Array
等子类,也许不会再如你期望的那样运行。这是因为Error
、Array
等类似内置对象的构造函数,会使用 ECMAScript 6 的new.target
调整原型链。然而,在 ECMAScript 5 中,当调用一个构造函数的时候,并没有方法可以确保new.target
的值。其他的降级编译器默认也会有同样的限制。
对于一个像下面这样的子类:
class MsgError extends Error { constructor(m: string) { super(m); } sayHello() { return "hello " + this.message; } }
你也许可以发现:
- 对象的方法可能是
undefined
,所以调用sayHello
会导致错误 instanceof
失效,(new MsgError())instanceof MsgError
会返回false
。
我们推荐,手动的在super(...)
调用后调整原型:
class MsgError extends Error { constructor(m: string) { super(m); // Set the prototype explicitly. Object.setPrototypeOf(this, MsgError.prototype); } sayHello() { return "hello " + this.message; } }
不过,任何MsgError
的子类也不得不手动设置原型。如果运行时不支持Object.setPrototypeOf
,你也许可以使用__proto__
。
不幸的是,这些方案并不会能在 IE 10 或者之前的版本正常运行。解决的一个方法是手动拷贝原型中的方法到实例中(就比如MsgError.prototype
到this
),但是它自己的原型链依然没有被修复。
成员可见性(Member Visibility)
你可以使用 TypeScript 控制某个方法或者属性是否对类以外的代码可见。
public
类成员默认的可见性为public
,一个public
的成员可以在任何地方被获取:
class Greeter { public greet() { console.log("hi!"); } } const g = new Greeter(); g.greet();
因为public
是默认的可见性修饰符,所以你不需要写它,除非处于格式或者可读性的原因。
protected
protected
成员仅仅对子类可见:
class Greeter { public greet() { console.log("Hello, " + this.getName()); } protected getName() { return "hi"; } } class SpecialGreeter extends Greeter { public howdy() { // OK to access protected member here console.log("Howdy, " + this.getName()); } } const g = new SpecialGreeter(); g.greet(); // OK g.getName(); // Property 'getName' is protected and only accessible within class 'Greeter' and its subclasses.
受保护成员的公开
派生类需要遵循基类的实现,但是依然可以选择公开拥有更多能力的基类子类型,这就包括让一个protected
成员变成public
:
class Base { protected m = 10; } class Derived extends Base { // No modifier, so default is 'public' m = 15; } const d = new Derived(); console.log(d.m); // OK
这里需要注意的是,如果公开不是故意的,在这个派生类中,我们需要小心的拷贝protected
修饰符。
交叉等级受保护成员访问
不同的 OOP 语言在通过一个基类引用是否可以合法的获取一个protected
成员是有争议的。
class Base { protected x: number = 1; } class Derived1 extends Base { protected x: number = 5; } class Derived2 extends Base { f1(other: Derived2) { other.x = 10; } f2(other: Base) { other.x = 10; // Property 'x' is protected and only accessible through an instance of class 'Derived2'. This is an instance of class 'Base'. } }
在 Java 中,这是合法的,而 C#和 C++认为这段代码是不合法的。
TypeScript 站在 C#和 C++这边。因为Derived2
的x
应该只有从Derived2
的子类访问才是合法的,而Derived1
并不是它们中的一个。此外,如果通过Derived1
访问x
是不合法的,通过一个基类引用访问也应该是不合法的。
private
private
有点像protected
,但是不允许访问成员,即便是子类。
class Base { private x = 0; } const b = new Base(); // Can't access from outside the class console.log(b.x); // Property 'x' is private and only accessible within class 'Base'.
class Derived extends Base { showX() { // Can't access in subclasses console.log(this.x); // Property 'x' is private and only accessible within class 'Base'. } }
因为private
成员对派生类并不可见,所以一个派生类也不能增加它的可见性:
class Base { private x = 0; } class Derived extends Base { // Class 'Derived' incorrectly extends base class 'Base'. Property 'x' is private in type 'Base' but not in type 'Derived'. x = 1; }
交叉实例私有成员访问
不同的 OOP 语言在关于一个类的不同实例是否可以获取彼此的private
成员上,也是不一致的。像 Java、C#、C++、Swift 和 PHP 都是允许的,Ruby 是不允许。
TypeScript 允许交叉实例私有成员的获取:
class A { private x = 10; public sameAs(other: A) { // No error return other.x === this.x; } }
警告(Caveats)
private
和protected
仅仅在类型检查的时候才会强制生效。
这意味着在 JavaScript 运行时,像in
或者简单的属性查找,依然可以获取private
或者protected
成员。
class MySafe { private secretKey = 12345; }
// In a JavaScript file... const s = new MySafe(); // Will print 12345 console.log(s.secretKey);
private
允许在类型检查的时候,通过方括号语法进行访问。这让比如单元测试的时候,会更容易访问private
字段,这也让这些字段是弱私有(soft private)而不是严格的强制私有。
class MySafe { private secretKey = 12345; } const s = new MySafe(); // Not allowed during type checking console.log(s.secretKey); Property 'secretKey' is private and only accessible within class 'MySafe'. // OK console.log(s["secretKey"]);
不像 TypeScript 的private
,JavaScript 的私有字段(#
)即便是编译后依然保留私有性,并且不会提供像上面这种方括号获取的方法,这让它们变得强私有(hard private)。
class Dog { #barkAmount = 0; personality = "happy"; constructor() {} }
"use strict"; class Dog { #barkAmount = 0; personality = "happy"; constructor() { } }
当被编译成 ES2021 或者之前的版本,TypeScript 会使用 WeakMaps 替代#
:
"use strict"; var _Dog_barkAmount; class Dog { constructor() { _Dog_barkAmount.set(this, 0); this.personality = "happy"; } } _Dog_barkAmount = new WeakMap();
如果你需要防止恶意攻击,保护类中的值,你应该使用强私有的机制比如闭包,WeakMaps
,或者私有字段。但是注意,这也会在运行时影响性能。
TypeScript 的官方文档早已更新,但我能找到的中文文档都还停留在比较老的版本。所以对其中新增以及修订较多的一些章节进行了翻译整理。
本篇翻译整理自 TypeScript Handbook 中「Classes 」章节。
本文并不严格按照原文翻译,对部分内容也做了解释补充。
静态成员
类可以有静态成员,静态成员跟类实例没有关系,可以通过类本身访问到:
class MyClass { static x = 0; static printX() { console.log(MyClass.x); } } console.log(MyClass.x); MyClass.printX();
静态成员同样可以使用public
protected
和private
这些可见性修饰符:
class MyClass { private static x = 0; } console.log(MyClass.x); // Property 'x' is private and only accessible within class 'MyClass'.
静态成员也可以被继承:
class Base { static getGreeting() { return "Hello world"; } } class Derived extends Base { myGreeting = Derived.getGreeting(); }
特殊静态名称
类本身是函数,而覆写Function
原型上的属性通常认为是不安全的,因此不能使用一些固定的静态名称,函数属性像name
、length
、call
不能被用来定义static
成员:
class S { static name = "S!"; // Static property 'name' conflicts with built-in property 'Function.name' of constructor function 'S'.}
为什么没有静态类?
TypeScript(和 JavaScript)并没有名为静态类(static class)的结构,但是像 C#和 Java 有。
所谓静态类,指的是作为类的静态成员存在于某个类的内部的类。比如这种:
// java public class OuterClass { private static String a = "1"; static class InnerClass { private int b = 2; } }
静态类之所以存在是因为这些语言强迫所有的数据和函数都要在一个类内部,但这个限制在 TypeScript 中并不存在,所以也没有静态类的需要。一个只有一个单独实例的类,在 JavaScript/TypeScript 中,完全可以使用普通的对象替代。
举个例子,我们不需要一个static class
语法,因为 TypeScript 中一个常规对象(或者顶级函数)可以实现一样的功能:
// Unnecessary "static" class class MyStaticClass { static doSomething() {} } // Preferred (alternative 1) function doSomething() {} // Preferred (alternative 2) const MyHelperObject = { dosomething() {}, };
类静态块
静态块允许你写一系列有自己作用域的语句,也可以获取类里的私有字段。这意味着我们可以安心的写初始化代码:正常书写语句,无变量泄漏,还可以完全获取类中的属性和方法。
class Foo { static #count = 0; get count() { return Foo.#count; } static { try { const lastInstances = loadLastInstances(); Foo.#count += lastInstances.length; } catch {} } }
泛型类
类跟接口一样,也可以写泛型。当使用new
实例化一个泛型类,它的类型参数的推断跟函数调用是同样的方式:
class Box { contents: Type; constructor(value: Type) { this.contents = value; } } const b = new Box("hello!"); // const b: Box
类跟接口一样也可以使用泛型约束以及默认值。
静态成员中的类型参数
这代码并不合法,但是原因可能并没有那么明显:
class Box { static defaultValue: Type; // Static members cannot reference class type parameters.}
记住类型会被完全抹除,运行时,只有一个Box.defaultValue
属性槽。这也意味着如果设置Box.defaultValue
是可以的话,这也会改变Box.defaultValue
,而这样是不好的。
所以泛型类的静态成员不应该引用类的类型参数。
类运行时的this
TypeScript 并不会更改 JavaScript 运行时的行为,并且 JavaScript 有时会出现一些奇怪的运行时行为。
就比如 JavaScript 处理this
就很奇怪:
class MyClass { name = "MyClass"; getName() { return this.name; } } const c = new MyClass(); const obj = { name: "obj", getName: c.getName, }; // Prints "obj", not "MyClass" console.log(obj.getName());
默认情况下,函数中this
的值取决于函数是如何被调用的。在这个例子中,因为函数通过obj
被调用,所以this
的值是obj
而不是类实例。
这显然不是你所希望的。TypeScript 提供了一些方式缓解或者阻止这种错误。
箭头函数
如果你有一个函数,经常在被调用的时候丢失this
上下文,使用一个箭头函数或许更好些。
class MyClass { name = "MyClass"; getName = () => { return this.name; }; } const c = new MyClass(); const g = c.getName; // Prints "MyClass" instead of crashing console.log(g());
这里有几点需要注意下:
this
的值在运行时是正确的,即使 TypeScript 不检查代码- 这会使用更多的内存,因为每一个类实例都会拷贝一遍这个函数。
- 你不能在派生类使用
super.getName
,因为在原型链中并没有入口可以获取基类方法。
this
参数
在 TypeScript 方法或者函数的定义中,第一个参数且名字为this
有特殊的含义。该参数会在编译的时候被抹除:
// TypeScript input with 'this' parameter function fn(this: SomeType, x: number) { /* ... */ }
// JavaScript output function fn(x) { /* ... */ }
TypeScript 会检查一个有this
参数的函数在调用时是否有一个正确的上下文。不像上个例子使用箭头函数,我们可以给方法定义添加一个this
参数,静态强制方法被正确调用:
class MyClass { name = "MyClass"; getName(this: MyClass) { return this.name; } } const c = new MyClass(); // OK c.getName(); // Error, would crash const g = c.getName; console.log(g()); // The 'this' context of type 'void' is not assignable to method's 'this' of type 'MyClass'.
这个方法也有一些注意点,正好跟箭头函数相反:
- JavaScript 调用者依然可能在没有意识到它的时候错误使用类方法
- 每个类一个函数,而不是每一个类实例一个函数
- 基类方法定义依然可以通过
super
调用
this
类型
在类中,有一个特殊的名为this
的类型,会动态的引用当前类的类型,让我们看下它的用法:
class Box { contents: string = ""; set(value: string) { // (method) Box.set(value: string): this this.contents = value; return this; } }
这里,TypeScript 推断set
的返回类型为this
而不是Box
。让我们写一个Box
的子类:
class ClearableBox extends Box { clear() { this.contents = ""; } } const a = new ClearableBox(); const b = a.set("hello"); // const b: ClearableBox
你也可以在参数类型注解中使用this
:
class Box { content: string = ""; sameAs(other: this) { return other.content === this.content; } }
不同于写other: Box
,如果你有一个派生类,它的sameAs
方法只接受来自同一个派生类的实例。
class Box { content: string = ""; sameAs(other: this) { return other.content === this.content; } } class DerivedBox extends Box { otherContent: string = "?"; } const base = new Box(); const derived = new DerivedBox(); derived.sameAs(base); Argument of type 'Box' is not assignable to parameter of type 'DerivedBox'. Property 'otherContent' is missing in type 'Box' but required in type 'DerivedBox'.
基于this
的类型保护(this-based type guards)
你可以在类和接口的方法返回的位置,使用this is Type
。当搭配使用类型收窄(举个例子,if
语句),目标对象的类型会被收窄为更具体的Type
。
class FileSystemObject { isFile(): this is FileRep { return this instanceof FileRep; } isDirectory(): this is Directory { return this instanceof Directory; } isNetworked(): this is Networked & this { return this.networked; } constructor(public path: string, private networked: boolean) {} } class FileRep extends FileSystemObject { constructor(path: string, public content: string) { super(path, false); } } class Directory extends FileSystemObject { children: FileSystemObject[]; } interface Networked { host: string; } const fso: FileSystemObject = new FileRep("foo/bar.txt", "foo"); if (fso.isFile()) { fso.content; // const fso: FileRep } else if (fso.isDirectory()) { fso.children; // const fso: Directory } else if (fso.isNetworked()) { fso.host; // const fso: Networked & FileSystemObject }
一个常见的基于 this 的类型保护的使用例子,会对一个特定的字段进行懒校验(lazy validation)。举个例子,在这个例子中,当hasValue
被验证为 true 时,会从类型中移除undefined
:
class Box { value?: T; hasValue(): this is { value: T } { return this.value !== undefined; } } const box = new Box(); box.value = "Gameboy"; box.value; //(property) Box.value?: unknown if (box.hasValue()) { box.value; //(property) value: unknown }
参数属性
TypeScript 提供了特殊的语法,可以把一个构造函数参数转成一个同名同值的类属性。这些就被称为参数属性(parameter properties)。你可以通过在构造函数参数前添加一个可见性修饰符public
private
protected
或者readonly
来创建参数属性,最后这些类属性字段也会得到这些修饰符:
class Params { constructor( public readonly x: number, protected y: number, private z: number ) { // No body necessary } } const a = new Params(1, 2, 3); console.log(a.x); // (property) Params.x: number console.log(a.z); // Property 'z' is private and only accessible within class 'Params'.
类表达式(Class Expressions)
类表达式跟类声明非常类似,唯一不同的是类表达式不需要一个名字,尽管我们可以通过绑定的标识符进行引用:
const someClass = class { content: Type; constructor(value: Type) { this.content = value; } }; const m = new someClass("Hello, world"); // const m: someClass
抽象类和成员(abstract Classes and Members)
TypeScript 中,类、方法、字段都可以是抽象的(abstract)。
抽象方法或者抽象字段是不提供实现的。这些成员必须存在在一个抽象类中,这个抽象类也不能直接被实例化。
抽象类的作用是作为子类的基类,让子类实现所有的抽象成员。当一个类没有任何抽象成员,他就会被认为是具体的(concrete)。
让我们看个例子:
abstract class Base { abstract getName(): string; printName() { console.log("Hello, " + this.getName()); } } const b = new Base(); // Cannot create an instance of an abstract class.
我们不能使用new
实例Base
因为它是抽象类。我们需要写一个派生类,并且实现抽象成员。
class Derived extends Base { getName() { return "world"; } } const d = new Derived(); d.printName();
注意,如果我们忘记实现基类的抽象成员,我们会得到一个报错:
class Derived extends Base { Non-abstract class 'Derived' does not implement inherited abstract member 'getName' from class 'Base'. // forgot to do anything }
抽象构造签名
有的时候,你希望接受传入可以继承一些抽象类产生一个类的实例的类构造函数。
举个例子,你也许会写这样的代码:
function greet(ctor: typeof Base) { const instance = new ctor(); // Cannot create an instance of an abstract class. instance.printName(); }
TypeScript 会报错,告诉你正在尝试实例化一个抽象类。毕竟,根据greet
的定义,这段代码应该是合法的:
// Bad! greet(Base);
但如果你写一个函数接受传入一个构造签名:
function greet(ctor: new () => Base) { const instance = new ctor(); instance.printName(); } greet(Derived); greet(Base); // Argument of type 'typeof Base' is not assignable to parameter of type 'new () => Base'. // Cannot assign an abstract constructor type to a non-abstract constructor type.
现在 TypeScript 会正确的告诉你,哪一个类构造函数可以被调用,Derived
可以,因为它是具体的,而Base
是不能的。
类之间的关系
大部分时候,TypeScript 的类跟其他类型一样,会被结构性比较。
举个例子,这两个类可以用于替代彼此,因为它们结构是相等的:
class Point1 { x = 0; y = 0; } class Point2 { x = 0; y = 0; } // OK const p: Point1 = new Point2();
类似的还有,类的子类型之间可以建立关系,即使没有明显的继承:
class Person { name: string; age: number; } class Employee { name: string; age: number; salary: number; } // OK const p: Person = new Employee();
这听起来有些简单,但还有一些例子可以看出奇怪的地方。
空类没有任何成员。在一个结构化类型系统中,没有成员的类型通常是任何其他类型的父类型。所以如果你写一个空类(只是举例,你可不要这样做),任何东西都可以用来替换它:
class Empty {} function fn(x: Empty) { // can't do anything with 'x', so I won't } // All OK! fn(window); fn({}); fn(fn);
鹏仔微信 15129739599 鹏仔QQ344225443 鹏仔前端 pjxi.com 共享博客 sharedbk.com
图片声明:本站部分配图来自网络。本站只作为美观性配图使用,无任何非法侵犯第三方意图,一切解释权归图片著作权方,本站不承担任何责任。如有恶意碰瓷者,必当奉陪到底严惩不贷!