类
背景阅读:
类 (MDN)
TypeScript 完全支持 ES2015 引入的 class 关键字。
与其它 JavaScript 语言特性一样,TypeScript 添加了类型注解和其它语法,允许你表达类与其他类型之间的关系。
类成员
这是最基本的类——一个空类:
这个类目前还不太有用,所以我们开始添加一些成员。
字段
字段声明会在类上创建一个公共可写属性:
与其他位置一样,类型注解是可选的,但如果未指定,则会被隐式视为 any。
字段也可以有初始化器;这些会在类实例化时自动运行:
class Point {
x = 0;
y = 0;
}
const pt = new Point();
// 打印 0, 0
console.log(`${pt.x}, ${pt.y}`);Try与 const、let 和 var 一样,类属性的初始化器将用于推断其类型:
--strictPropertyInitialization
strictPropertyInitialization 设置控制类字段是否需要在构造函数中初始化。
注意,字段需要在构造函数本身中初始化。 TypeScript 不会分析你从构造函数中调用的方法来检测初始化,因为派生类可能会覆盖这些方法而未能初始化成员。
如果你打算通过构造函数以外的方式明确初始化一个字段(例如,可能某个外部库正在为你填充类的一部分),你可以使用明确赋值断言操作符 !:
readonly
字段可以加上 readonly 修饰符。 这可以防止在构造函数之外对该字段进行赋值。
class Greeter {
readonly name: string = "world";
constructor(otherName?: string) {
if (otherName !== undefined) {
this.name = otherName;
}
}
err() {
this.name = "not ok"; }
}
const g = new Greeter();
g.name = "also not ok";Try构造函数
背景阅读:
构造函数 (MDN)
类的构造函数与函数非常相似。 你可以添加带有类型注解的参数、默认值和重载:
class Point {
x: number;
y: number;
// 带默认值的普通签名
constructor(x = 0, y = 0) {
this.x = x;
this.y = y;
}
}Tryclass Point {
x: number = 0;
y: number = 0;
// 构造函数重载
constructor(x: number, y: number);
constructor(xy: string);
constructor(x: string | number, y: number = 0) {
// 代码逻辑
}
}Try类构造函数签名与函数签名之间只有少数几个区别:
- 构造函数不能有类型参数——这些属于外部类声明,我们稍后会学习
- 构造函数不能有返回类型注解——始终返回类实例类型
超类调用
与 JavaScript 一样,如果你有一个基类,则需要在构造函数体中在使用任何 this. 成员之前调用 super();:
class Base {
k = 4;
}
class Derived extends Base {
constructor() {
// 在 ES5 中打印错误的值;在 ES6 中抛出异常
console.log(this.k); super();
}
}Try在 JavaScript 中忘记调用 super 是一个很容易犯的错误,但 TypeScript 会在必要时告诉你。
方法
背景阅读:
方法定义
类上的函数属性称为方法。 方法可以使用与函数和构造函数相同的所有类型注解:
除了标准类型注解之外,TypeScript 没有为方法添加任何新内容。
注意,在方法体内,仍然必须通过 this. 来访问字段和其他方法。 方法体中的非限定名称将始终引用封闭作用域中的某个东西:
let x: number = 0;
class C {
x: string = "hello";
m() {
// 这里试图修改第 1 行的 'x',而不是类的属性
x = "world"; }
}TryGetters / Setters
类也可以有访问器:
class C {
_length = 0;
get length() {
return this._length;
}
set length(value) {
this._length = value;
}
}Try注意,如果没有额外逻辑,由字段支持的 get/set 对在 JavaScript 中很少有用。 如果你不需要在 get/set 操作期间添加额外逻辑,直接公开公共字段就可以了。
TypeScript 对访问器有一些特殊的推断规则:
- 如果存在
get但没有set,则该属性自动为readonly - 如果未指定 setter 参数的类型,则从 getter 的返回类型推断
从 TypeScript 4.3 开始,访问器的 get 和 set 可以具有不同的类型。
class Thing {
_size = 0;
get size(): number {
return this._size;
}
set size(value: string | number | boolean) {
let num = Number(value);
// 不允许 NaN、Infinity 等
if (!Number.isFinite(num)) {
this._size = 0;
return;
}
this._size = num;
}
}Try索引签名
类可以声明索引签名;它们的作用与其他对象类型的索引签名相同:
class MyClass {
[s: string]: boolean | ((s: string) => boolean);
check(s: string) {
return this[s] as boolean;
}
}Try因为索引签名类型还需要捕获方法的类型,所以有效地使用这些类型并不容易。 通常,最好将索引数据存储在另一个地方,而不是类实例本身。
类的继承
与其他具有面向对象特性的语言一样,JavaScript 中的类可以从基类继承。
implements 子句
你可以使用 implements 子句来检查一个类是否满足特定的 interface。 如果一个类未能正确实现它,则会报错:
interface Pingable {
ping(): void;
}
class Sonar implements Pingable {
ping() {
console.log("ping!");
}
}
class Ball implements Pingable { pong() {
console.log("pong!");
}
}Try类也可以实现多个接口,例如 class C implements A, B {。
注意事项
重要的是要理解 implements 子句仅仅是一个检查,确保类可以被当作接口类型来使用。 它根本不会改变类的类型或其方法。 一个常见的错误来源是认为 implements 子句会改变类的类型——它不会!
interface Checkable {
check(name: string): boolean;
}
class NameChecker implements Checkable {
check(s) { // 注意这里没有错误
return s.toLowerCase() === "ok";
}
}Try在这个例子中,我们可能期望 s 的类型会受到 check 的 name: string 参数的影响。 但事实并非如此——implements 子句不会改变类主体的检查方式或类型推断方式。
类似地,实现具有可选属性的接口并不会创建该属性:
interface A {
x: number;
y?: number;
}
class C implements A {
x = 0;
}
const c = new C();
c.y = 10;Tryextends 子句
背景阅读:
extends 关键字 (MDN)
类可以 extend 一个基类。 派生类拥有其基类的所有属性和方法,并且可以定义额外的成员。
class Animal {
move() {
console.log("Moving along!");
}
}
class Dog extends Animal {
woof(times: number) {
for (let i = 0; i < times; i++) {
console.log("woof!");
}
}
}
const d = new Dog();
// 基类方法
d.move();
// 派生类方法
d.woof(3);Try重写方法
背景阅读:
super 关键字 (MDN)
派生类也可以重写基类的字段或属性。 你可以使用 super. 语法访问基类方法。 注意,因为 JavaScript 类是一个简单的查找对象,所以不存在“超类字段”的概念。
TypeScript 强制要求派生类始终是其基类的子类型。
例如,以下是一种合法的重写方法的方式:
class Base {
greet() {
console.log("Hello, world!");
}
}
class Derived extends Base {
greet(name?: string) {
if (name === undefined) {
super.greet();
} else {
console.log(`Hello, ${name.toUpperCase()}`);
}
}
}
const d = new Derived();
d.greet();
d.greet("reader");Try派生类遵循其基类的契约非常重要。 记住,通过基类引用来引用派生类实例是非常常见的(并且始终是合法的!):
如果 Derived 没有遵循 Base 的契约会怎样?
class Base {
greet() {
console.log("Hello, world!");
}
}
class Derived extends Base {
// 使此参数变为必需的
greet(name: string) { console.log(`Hello, ${name.toUpperCase()}`);
}
}Try如果我们忽略错误编译这段代码,这个示例将崩溃:
仅类型字段声明
当 target >= ES2022 或 useDefineForClassFields 为 true 时,类字段在父类构造函数完成后初始化,会覆盖父类设置的任何值。当你只想为继承的字段重新声明更准确的类型时,这可能是一个问题。为了处理这些情况,你可以编写 declare 来向 TypeScript 指示此字段声明不应有运行时效果。
interface Animal {
dateOfBirth: any;
}
interface Dog extends Animal {
breed: any;
}
class AnimalHouse {
resident: Animal;
constructor(animal: Animal) {
this.resident = animal;
}
}
class DogHouse extends AnimalHouse {
// 不生成 JavaScript 代码,
// 仅确保类型正确
declare resident: Dog;
constructor(dog: Dog) {
super(dog);
}
}Try初始化顺序
在某些情况下,JavaScript 类的初始化顺序可能会令人惊讶。 考虑以下代码:
class Base {
name = "base";
constructor() {
console.log("My name is " + this.name);
}
}
class Derived extends Base {
name = "derived";
}
// 打印 "base",而不是 "derived"
const d = new Derived();Try发生了什么?
根据 JavaScript 定义的类初始化顺序是:
- 基类字段被初始化
- 基类构造函数运行
- 派生类字段被初始化
- 派生类构造函数运行
这意味着基类构造函数在其自己的构造函数中看到的是其自身的 name 值,因为派生类字段初始化尚未运行。
继承内置类型
注意:如果你不打算继承像
Array、Error、Map等内置类型,或者你的编译目标明确设置为ES6/ES2015或更高,你可以跳过本节
在 ES2015 中,返回对象的构造函数会隐式地用 this 替换任何 super(...) 调用者的值。 生成的构造函数代码需要捕获 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;
}
}Try你可能会发现:
- 通过构造这些子类返回的对象上的方法可能是
undefined,因此调用sayHello会导致错误。 - 子类实例与其实例之间的
instanceof将失效,因此(new MsgError()) instanceof MsgError将返回false。
作为建议,你可以在任何 super(...) 调用之后立即手动调整原型。
class MsgError extends Error {
constructor(m: string) {
super(m);
// 显式设置原型。
Object.setPrototypeOf(this, MsgError.prototype);
}
sayHello() {
return "hello " + this.message;
}
}Try但是,任何 MsgError 的子类也必须手动设置原型。 对于不支持 Object.setPrototypeOf 的运行时,你可以改用 __proto__。
不幸的是,这些解决方法在 Internet Explorer 10 及更早版本上不起作用。 你可以手动将方法从原型复制到实例本身(例如将 MsgError.prototype 复制到 this),但原型链本身无法修复。
成员可见性
你可以使用 TypeScript 来控制某些方法或属性是否对类外部的代码可见。
public
类成员的默认可见性是 public。 public 成员可以在任何地方访问:
因为 public 已经是默认的可见性修饰符,所以你永远不需要在类成员上写它,但出于风格/可读性的原因,你可以选择这样做。
protected
protected 成员仅对声明它们的类的子类可见。
class Greeter {
public greet() {
console.log("Hello, " + this.getName());
}
protected getName() {
return "hi";
}
}
class SpecialGreeter extends Greeter {
public howdy() {
// 此处可以访问 protected 成员
console.log("Howdy, " + this.getName());
}
}
const g = new SpecialGreeter();
g.greet(); // OK
g.getName();Tryprotected 成员的暴露
派生类需要遵循其基类的契约,但可以选择暴露基类具有更多功能的子类型。 这包括将 protected 成员变为 public:
class Base {
protected m = 10;
}
class Derived extends Base {
// 没有修饰符,因此默认为 'public'
m = 15;
}
const d = new Derived();
console.log(d.m); // OKTry注意,Derived 已经可以自由地读写 m,因此这并没有实质性地改变这种情况的“安全性”。 这里需要注意的主要一点是,在派生类中,如果这种暴露不是有意的,我们需要小心地重复 protected 修饰符。
跨层级 protected 访问
TypeScript 不允许在类层级中访问兄弟类的 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: Derived1) {
other.x = 10; }
}Try这是因为在 Derived2 中访问 x 应该只允许从 Derived2 的子类中进行,而 Derived1 不是其中之一。 此外,如果通过 Derived1 引用访问 x 是非法的(它确实应该是非法的!),那么通过基类引用访问它永远不应改善这种情况。
另请参阅 为什么不能从派生类访问受保护成员?,其中更详细地解释了 C# 关于同一主题的推理。
private
private 类似于 protected,但即使从子类中也不允许访问该成员:
因为 private 成员对派生类不可见,所以派生类无法增加其可见性:
跨实例 private 访问
不同的面向对象语言对于同一类的不同实例是否可以访问彼此的 private 成员有不同的看法。 虽然 Java、C#、C++、Swift 和 PHP 等语言允许这样做,但 Ruby 不允许。
TypeScript 允许跨实例 private 访问:
注意事项
与 TypeScript 类型系统的其他方面一样,private 和 protected 仅在类型检查期间强制执行。
这意味着像 in 或简单的属性查找这样的 JavaScript 运行时结构仍然可以访问 private 或 protected 成员:
// 在 JavaScript 文件中...
const s = new MySafe();
// 会打印 12345
console.log(s.secretKey);private 还允许在类型检查期间使用方括号表示法进行访问。这使得 private 声明的字段在单元测试等场景中可能更容易访问,但缺点在于这些字段是软私有的,并不严格强制私有性。
class MySafe {
private secretKey = 12345;
}
const s = new MySafe();
// 类型检查期间不允许
console.log(s.secretKey);
// OK
console.log(s["secretKey"]);Try与 TypeScript 的 private 不同,JavaScript 的私有字段(#)在编译后保持私有,并且不提供前面提到的如方括号访问等逃生口,因此它们是硬私有的。
当编译到 ES2021 或更低版本时,TypeScript 将使用 WeakMap 替代 #。
"use strict";
var _Dog_barkAmount;
class Dog {
constructor() {
_Dog_barkAmount.set(this, 0);
this.personality = "happy";
}
}
_Dog_barkAmount = new WeakMap();
Try如果你需要保护类中的值免受恶意行为者的攻击,你应该使用提供硬运行时隐私的机制,例如闭包、WeakMap 或私有字段。请注意,这些额外的运行时隐私检查可能会影响性能。
静态成员
背景阅读:
静态成员 (MDN)
类可以有 static 成员。 这些成员不与类的特定实例相关联。 它们可以通过类构造函数对象本身访问:
class MyClass {
static x = 0;
static printX() {
console.log(MyClass.x);
}
}
console.log(MyClass.x);
MyClass.printX();Try静态成员也可以使用相同的 public、protected 和 private 可见性修饰符:
静态成员也是被继承的:
class Base {
static getGreeting() {
return "Hello world";
}
}
class Derived extends Base {
myGreeting = Derived.getGreeting();
}Try特殊的静态名称
通常覆盖 Function 原型的属性是不安全/不可能的。 因为类本身就是可以用 new 调用的函数,所以某些 static 名称不能使用。 像 name、length 和 call 这样的函数属性不能定义为 static 成员:
为什么没有静态类?
TypeScript(和 JavaScript)没有像 C# 那样的 static class 构造。
这些构造之所以存在,是因为那些语言强制所有数据和函数都必须放在类中;由于 TypeScript 中没有这种限制,因此不需要它们。 只有一个实例的类通常只需在 JavaScript/TypeScript 中表示为普通对象。
例如,我们不需要在 TypeScript 中使用“静态类”语法,因为一个普通对象(甚至顶层函数)也能很好地完成工作:
// 不必要的 "static" 类
class MyStaticClass {
static doSomething() {}
}
// 推荐(替代方案 1)
function doSomething() {}
// 推荐(替代方案 2)
const MyHelperObject = {
dosomething() {},
};Try类中的 static 块
静态块允许你编写一系列具有自己作用域的语句,这些语句可以访问包含类中的私有字段。这意味着我们可以编写具有所有语句能力的初始化代码,不会泄露变量,并且可以完全访问类的内部。
class Foo {
static #count = 0;
get count() {
return Foo.#count;
}
static {
try {
const lastInstances = loadLastInstances();
Foo.#count += lastInstances.length;
}
catch {}
}
}Try泛型类
类,与接口一样,可以是泛型的。 当使用 new 实例化泛型类时,其类型参数的推断方式与函数调用相同:
class Box<Type> {
contents: Type;
constructor(value: Type) {
this.contents = value;
}
}
const b = new Box("hello!");
Try类可以像接口一样使用泛型约束和默认值。
静态成员中的类型参数
这段代码是不合法的,原因可能不明显:
记住,类型总是被完全擦除的! 在运行时,只有一个 Box.defaultValue 属性槽。 这意味着设置 Box<string>.defaultValue(如果可能的话)也会改变 Box<number>.defaultValue——这不好。 泛型类的 static 成员永远不能引用类的类型参数。
类中的 this 运行时
背景阅读:
this 关键字 (MDN)
重要的是要记住 TypeScript 不会改变 JavaScript 的运行时行为,而 JavaScript 因具有一些特殊的运行时行为而闻名。
JavaScript 对 this 的处理确实不寻常:
class MyClass {
name = "MyClass";
getName() {
return this.name;
}
}
const c = new MyClass();
const obj = {
name: "obj",
getName: c.getName,
};
// 打印 "obj",而不是 "MyClass"
console.log(obj.getName());Try长话短说,默认情况下,函数内部的 this 值取决于函数的调用方式。 在这个例子中,因为函数是通过 obj 引用调用的,所以它的 this 值是 obj 而不是类实例。
这很少是你想要的结果! TypeScript 提供了几种缓解或防止此类错误的方法。
箭头函数
背景阅读:
箭头函数 (MDN)
如果你有一个函数经常以丢失其 this 上下文的方式被调用,那么使用箭头函数属性而不是方法定义可能是有意义的:
class MyClass {
name = "MyClass";
getName = () => {
return this.name;
};
}
const c = new MyClass();
const g = c.getName;
// 打印 "MyClass" 而不是崩溃
console.log(g());Try这有一些权衡:
- 即使在未使用 TypeScript 检查的代码中,运行时
this值也能保证正确 - 这将使用更多内存,因为每个类实例都会有自己以这种方式定义的每个函数副本
- 你不能在派生类中使用
super.getName,因为原型链中没有条目可以获取基类方法
this 参数
在方法或函数定义中,名为 this 的初始参数在 TypeScript 中具有特殊含义。 这些参数在编译期间会被擦除:
// JavaScript 输出
function fn(x) {
/* ... */
}TypeScript 会检查使用 this 参数调用函数时是否使用了正确的上下文。 我们可以不使用箭头函数,而是在方法定义中添加 this 参数,以静态强制方法被正确调用:
class MyClass {
name = "MyClass";
getName(this: MyClass) {
return this.name;
}
}
const c = new MyClass();
// OK
c.getName();
// 错误,会崩溃
const g = c.getName;
console.log(g());Try这种方法与箭头函数方法相比有相反的权衡:
- JavaScript 调用者可能仍然错误地使用类方法而不知道
- 每个类定义只分配一个函数,而不是每个类实例一个
- 仍然可以通过
super调用基类方法定义
this 类型
在类中,一种称为 this 的特殊类型动态地引用当前类的类型。 让我们看看这有什么用处:
class Box {
contents: string = "";
set(value: string) {
this.contents = value;
return this;
}
}Try在这里,TypeScript 推断 set 的返回类型是 this,而不是 Box。 现在让我们创建一个 Box 的子类:
class ClearableBox extends Box {
clear() {
this.contents = "";
}
}
const a = new ClearableBox();
const b = a.set("hello");
Try你也可以在参数类型注解中使用 this:
class Box {
content: string = "";
sameAs(other: this) {
return other.content === this.content;
}
}Try这与写 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);Try基于 this 的类型守卫
你可以在类和接口的方法返回位置使用 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;
} else if (fso.isDirectory()) {
fso.children;
} else if (fso.isNetworked()) {
fso.host;
}Try基于 this 的类型守卫的一个常见用例是允许对特定字段进行惰性验证。例如,当 hasValue 已被验证为 true 时,此案例会从 box 内部持有的值中移除 undefined:
class Box<T> {
value?: T;
hasValue(): this is { value: T } {
return this.value !== undefined;
}
}
const box = new Box<string>();
box.value = "Gameboy";
box.value;
if (box.hasValue()) {
box.value;
}Try参数属性
TypeScript 提供了一种特殊语法,可以将构造函数参数转换为具有相同名称和值的类属性。 这些被称为参数属性,通过在构造函数参数前加上可见性修饰符 public、private、protected 或 readonly 之一来创建。 生成的字段将获得这些修饰符:
class Params {
constructor(
public readonly x: number,
protected y: number,
private z: number
) {
// 不需要主体
}
}
const a = new Params(1, 2, 3);
console.log(a.x);
console.log(a.z);Try类表达式
背景阅读:
类表达式 (MDN)
类表达式与类声明非常相似。 唯一的区别是类表达式不需要名称,尽管我们可以通过它们最终绑定到的标识符来引用它们:
const someClass = class<Type> {
content: Type;
constructor(value: Type) {
this.content = value;
}
};
const m = new someClass("Hello, world");
Try构造函数签名
JavaScript 类是使用 new 操作符实例化的。给定类本身的类型,InstanceType 工具类型模拟了此操作。
class Point {
createdAt: number;
x: number;
y: number
constructor(x: number, y: number) {
this.createdAt = Date.now()
this.x = x;
this.y = y;
}
}
type PointInstance = InstanceType<typeof Point>
function moveRight(point: PointInstance) {
point.x += 5;
}
const point = new Point(3, 4);
moveRight(point);
point.x; // => 8Tryabstract 类与成员
TypeScript 中的类、方法和字段可以是抽象的。
抽象方法或抽象字段是尚未提供实现的方法或字段。 这些成员必须存在于抽象类中,该类不能直接实例化。
抽象类的作用是作为子类的基类,这些子类需要实现所有抽象成员。 当一个类没有任何抽象成员时,它被称为具体的。
让我们看一个例子:
abstract class Base {
abstract getName(): string;
printName() {
console.log("Hello, " + this.getName());
}
}
const b = new Base();Try我们不能用 new 实例化 Base,因为它是抽象的。 相反,我们需要创建一个派生类并实现抽象成员:
class Derived extends Base {
getName() {
return "world";
}
}
const d = new Derived();
d.printName();Try请注意,如果我们忘记实现基类的抽象成员,将会得到一个错误:
抽象构造签名
有时你想接受某个类的构造函数,该函数产生派生自某个抽象类的实例。
例如,你可能想编写以下代码:
TypeScript 正确地告诉你,你正在尝试实例化一个抽象类。 毕竟,根据 greet 的定义,编写这段代码是完全合法的,最终会构造一个抽象类:
相反,你想编写一个接受具有构造签名的东西的函数:
function greet(ctor: new () => Base) {
const instance = new ctor();
instance.printName();
}
greet(Derived);
greet(Base);Try现在 TypeScript 正确地告诉你可以调用哪些类构造函数函数——Derived 可以,因为它是具体的,但 Base 不行。
类之间的关系
在大多数情况下,TypeScript 中的类在结构上进行比较,与其他类型相同。
例如,这两个类可以相互替换,因为它们是相同的:
class Point1 {
x = 0;
y = 0;
}
class Point2 {
x = 0;
y = 0;
}
// OK
const p: Point1 = new Point2();Try类似地,即使没有显式继承,类之间也存在子类型关系:
class Person {
name: string;
age: number;
}
class Employee {
name: string;
age: number;
salary: number;
}
// OK
const p: Person = new Employee();Try这听起来很简单,但有一些情况看起来比其他情况更奇怪。
空类没有成员。 在结构类型系统中,没有成员的类型通常是其他任何类型的超类型。 因此,如果你编写一个空类(不要这样做!),任何东西都可以用来替代它:
class Empty {}
function fn(x: Empty) {
// 不能对 'x' 做任何事,所以我不会做
}
// 全部 OK!
fn(window);
fn({});
fn(fn);Try