TypeScript 5.2

using 声明与显式资源管理

TypeScript 5.2 增加了对 ECMAScript 中即将推出的显式资源管理 (Explicit Resource Management) 特性的支持。让我们探索一下其背后的动机,并了解该特性为我们带来了什么。

创建对象后,通常需要进行某种“清理”工作。例如,你可能需要关闭网络连接、删除临时文件或仅仅是释放一些内存。

设想一个函数,它创建一个临时文件,对其进行各种读写操作,最后将其关闭并删除。

ts
import * as fs from "fs";
export function doSomeWork() {
const path = ".some_temp_file";
const file = fs.openSync(path, "w+");
// use file...
// Close the file and delete it.
fs.closeSync(file);
fs.unlinkSync(path);
}

这样做没问题,但如果我们由于某种原因需要提前退出函数呢?

ts
export function doSomeWork() {
const path = ".some_temp_file";
const file = fs.openSync(path, "w+");
// use file...
if (someCondition()) {
// do some more work...
// Close the file and delete it.
fs.closeSync(file);
fs.unlinkSync(path);
return;
}
// Close the file and delete it.
fs.closeSync(file);
fs.unlinkSync(path);
}

我们开始发现清理逻辑出现了重复,而且很容易被遗忘。如果抛出错误,我们也无法保证文件一定会被关闭和删除。这可以通过将所有代码包裹在 try/finally 块中来解决。

ts
export function doSomeWork() {
const path = ".some_temp_file";
const file = fs.openSync(path, "w+");
try {
// use file...
if (someCondition()) {
// do some more work...
return;
}
}
finally {
// Close the file and delete it.
fs.closeSync(file);
fs.unlinkSync(path);
}
}

虽然这样更健壮,但它为我们的代码增加了不少“噪音”。如果我们开始在 finally 块中添加更多的清理逻辑,还会遇到其他陷阱——例如,异常阻止了其他资源的释放。这正是显式资源管理提案旨在解决的问题。该提案的核心思想是将资源释放(即我们要处理的这些清理工作)作为 JavaScript 的一等公民来支持。

首先,引入了一个新的内置 symbol,名为 Symbol.dispose。我们可以创建带有以 Symbol.dispose 命名的方法的对象。为了方便起见,TypeScript 定义了一个名为 Disposable 的新全局类型来描述这些对象。

ts
class TempFile implements Disposable {
#path: string;
#handle: number;
constructor(path: string) {
this.#path = path;
this.#handle = fs.openSync(path, "w+");
}
// other methods
[Symbol.dispose]() {
// Close the file and delete it.
fs.closeSync(this.#handle);
fs.unlinkSync(this.#path);
}
}

稍后我们可以调用这些方法。

ts
export function doSomeWork() {
const file = new TempFile(".some_temp_file");
try {
// ...
}
finally {
file[Symbol.dispose]();
}
}

将清理逻辑移动到 TempFile 本身并不能给我们带来太多好处;我们基本上只是把所有的清理工作从 finally 块移到了一个方法中,这在以前一直都是可行的。但是,为该方法提供一个众所周知的“名称”意味着 JavaScript 可以基于它构建其他特性。

这就引出了该特性的第一个主角:using 声明!using 是一个新关键字,它允许我们声明新的固定绑定,类似于 const。关键区别在于,使用 using 声明的变量会在作用域结束时调用它们的 Symbol.dispose 方法!

所以,我们完全可以将代码写成这样:

ts
export function doSomeWork() {
using file = new TempFile(".some_temp_file");
// use file...
if (someCondition()) {
// do some more work...
return;
}
}

看吧——没有 try/finally 块!至少,我们没看到。从功能上讲,这正是 using 声明为我们所做的,但我们不必去处理那些复杂的逻辑。

你可能熟悉 C# 中的 using 声明Python 中的 with 语句,或者 Java 中的 try-with-resource 声明。它们都与 JavaScript 的新 using 关键字类似,提供了一种相似的显式方式,在作用域结束时执行对象的“拆卸”工作。

using 声明会在其所在作用域的最末端,或者在“提前返回”(如 return)或抛出错误之前执行清理。它们还会像栈一样,以后进先出的顺序进行释放。

ts
function loggy(id: string): Disposable {
console.log(`Creating ${id}`);
return {
[Symbol.dispose]() {
console.log(`Disposing ${id}`);
}
}
}
function func() {
using a = loggy("a");
using b = loggy("b");
{
using c = loggy("c");
using d = loggy("d");
}
using e = loggy("e");
return;
// Unreachable.
// Never created, never disposed.
using f = loggy("f");
}
func();
// Creating a
// Creating b
// Creating c
// Creating d
// Disposing d
// Disposing c
// Creating e
// Disposing e
// Disposing b
// Disposing a

using 声明旨在应对异常;如果抛出了错误,它会在释放后重新抛出。另一方面,如果你的函数体执行如预期,但 Symbol.dispose 抛出了错误,那么该异常也会被重新抛出。

但是,如果清理前的逻辑和清理过程本身都抛出了错误会怎样?针对这种情况,引入了 SuppressedError 作为 Error 的一个新子类型。它具有一个 suppressed 属性,保存了最后抛出的错误,以及一个 error 属性,保存了最近抛出的错误。

ts
class ErrorA extends Error {
name = "ErrorA";
}
class ErrorB extends Error {
name = "ErrorB";
}
function throwy(id: string) {
return {
[Symbol.dispose]() {
throw new ErrorA(`Error from ${id}`);
}
};
}
function func() {
using a = throwy("a");
throw new ErrorB("oops!")
}
try {
func();
}
catch (e: any) {
console.log(e.name); // SuppressedError
console.log(e.message); // An error was suppressed during disposal.
console.log(e.error.name); // ErrorA
console.log(e.error.message); // Error from a
console.log(e.suppressed.name); // ErrorB
console.log(e.suppressed.message); // oops!
}

你可能已经注意到我们在这些示例中使用了同步方法。然而,许多资源释放涉及异步操作,我们需要在继续执行任何其他代码之前等待这些操作完成。

这就是为什么还有一个新的 Symbol.asyncDispose,它引出了该特性的下一个主角 —— await using 声明。它们类似于 using 声明,但关键在于它们会查找哪些释放操作必须被 await。它们使用以 Symbol.asyncDispose 命名的方法,不过它们也可以操作任何具有 Symbol.dispose 的对象。为了方便,TypeScript 还引入了一个名为 AsyncDisposable 的全局类型,用于描述任何具有异步释放方法的对象。

ts
async function doWork() {
// Do fake work for half a second.
await new Promise(resolve => setTimeout(resolve, 500));
}
function loggy(id: string): AsyncDisposable {
console.log(`Constructing ${id}`);
return {
async [Symbol.asyncDispose]() {
console.log(`Disposing (async) ${id}`);
await doWork();
},
}
}
async function func() {
await using a = loggy("a");
await using b = loggy("b");
{
await using c = loggy("c");
await using d = loggy("d");
}
await using e = loggy("e");
return;
// Unreachable.
// Never created, never disposed.
await using f = loggy("f");
}
func();
// Constructing a
// Constructing b
// Constructing c
// Constructing d
// Disposing (async) d
// Disposing (async) c
// Constructing e
// Disposing (async) e
// Disposing (async) b
// Disposing (async) a

如果你希望其他人始终如一地执行拆卸逻辑,根据 DisposableAsyncDisposable 定义类型可以让你的代码更容易协作。实际上,现有的许多类型都已经拥有 dispose()close() 方法。例如,Visual Studio Code API 甚至定义了它们自己的 Disposable 接口。浏览器以及 Node.js、Deno 和 Bun 等运行时的 API 也可能会选择为已具备清理方法(如文件句柄、连接等)的对象使用 Symbol.disposeSymbol.asyncDispose

现在,这对于库来说听起来很棒,但对于你的应用场景可能显得有点沉重。如果你正在进行大量的临时清理工作,创建一个新类型可能会引入过多的抽象和关于最佳实践的疑问。例如,再次以我们的 TempFile 为例。

ts
class TempFile implements Disposable {
#path: string;
#handle: number;
constructor(path: string) {
this.#path = path;
this.#handle = fs.openSync(path, "w+");
}
// other methods
[Symbol.dispose]() {
// Close the file and delete it.
fs.closeSync(this.#handle);
fs.unlinkSync(this.#path);
}
}
export function doSomeWork() {
using file = new TempFile(".some_temp_file");
// use file...
if (someCondition()) {
// do some more work...
return;
}
}

我们想要的只是记得调用两个函数——但这真的是编写它的最佳方式吗?我们应该在构造函数中调用 openSync,创建一个 open() 方法,还是自己传入句柄?我们应该为每一个可能的操作公开一个方法,还是直接将属性公开?

这就引出了该特性的最后主角:DisposableStackAsyncDisposableStack。这些对象对于处理一次性清理以及任意数量的清理都非常有用。DisposableStack 是一个对象,它拥有多种方法来跟踪 Disposable 对象,并可以被赋予执行任意清理工作的函数。我们还可以将它们赋值给 using 变量,因为——注意——它们本身也是 Disposable!所以,这就是我们如何重写最初的例子。

ts
function doSomeWork() {
const path = ".some_temp_file";
const file = fs.openSync(path, "w+");
using cleanup = new DisposableStack();
cleanup.defer(() => {
fs.closeSync(file);
fs.unlinkSync(path);
});
// use file...
if (someCondition()) {
// do some more work...
return;
}
// ...
}

在这里,defer() 方法仅接受一个回调函数,一旦 cleanup 被释放,该回调就会运行。通常,defer(以及其他 DisposableStack 方法,如 useadopt)应该在创建资源后立即调用。顾名思义,DisposableStack 会像栈一样以后进先出的顺序释放它跟踪的所有内容,因此在创建值之后立即 defer 有助于避免奇怪的依赖问题。AsyncDisposableStack 的工作方式类似,但可以跟踪 async 函数和 AsyncDisposable,它本身也是一个 AsyncDisposable

defer 方法在许多方面类似于 GoSwiftZigOdin 等语言中的 defer 关键字,它们的惯例应该是相似的。

由于此特性非常新,大多数运行时将不会原生支持它。要使用它,你需要为以下内容提供运行时 polyfill:

  • Symbol.dispose
  • Symbol.asyncDispose
  • DisposableStack
  • AsyncDisposableStack
  • SuppressedError

然而,如果你只对 usingawait using 感兴趣,你应该只需要对内置的 symbol 进行 polyfill。在大多数情况下,像下面这样简单的代码就可以工作:

ts
Symbol.dispose ??= Symbol("Symbol.dispose");
Symbol.asyncDispose ??= Symbol("Symbol.asyncDispose");

你还需要将编译器的 target 设置为 es2022 或更低版本,并将 lib 设置配置为包含 "esnext""esnext.disposable"

json
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022", "esnext.disposable", "dom"]
}
}

有关此特性的更多信息,请查看 GitHub 上的工作进展

装饰器元数据 (Decorator Metadata)

TypeScript 5.2 实现了一个名为装饰器元数据的 ECMAScript 新特性

该特性的核心思想是让装饰器能够轻松地在其所应用的类上创建和使用元数据。

每当使用装饰器函数时,它们现在都可以访问其上下文对象上的一个新 metadata 属性。metadata 属性仅仅持有一个简单的对象。由于 JavaScript 允许我们随意添加属性,它可以作为一个由每个装饰器更新的字典来使用。或者,由于类中每个被装饰部分的 metadata 对象都是相同的,它可以用作 Map 的键。当类上的所有装饰器运行完毕后,该对象可以通过 Symbol.metadata 在类上访问。

ts
interface Context {
name: string;
metadata: Record<PropertyKey, unknown>;
}
function setMetadata(_target: any, context: Context) {
context.metadata[context.name] = true;
}
class SomeClass {
@setMetadata
foo = 123;
@setMetadata
accessor bar = "hello!";
@setMetadata
baz() { }
}
const ourMetadata = SomeClass[Symbol.metadata];
console.log(JSON.stringify(ourMetadata));
// { "bar": true, "baz": true, "foo": true }

这在许多不同的场景中非常有用。元数据可以附加到许多用途上,例如调试、序列化或使用装饰器执行依赖注入。由于元数据对象是按每个被装饰的类创建的,框架既可以将它们私下用作 MapWeakMap 的键,也可以根据需要附加属性。

例如,假设我们想使用装饰器来跟踪在使用 JSON.stringify 时哪些属性和访问器是可序列化的,如下所示:

ts
import { serialize, jsonify } from "./serializer";
class Person {
firstName: string;
lastName: string;
@serialize
age: number
@serialize
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
toJSON() {
return jsonify(this)
}
constructor(firstName: string, lastName: string, age: number) {
// ...
}
}

这里,意图是只有 agefullName 应该被序列化,因为它们被标记了 @serialize 装饰器。我们为此定义了一个 toJSON 方法,但它只是调用了 jsonify,而 jsonify 使用了 @serialize 创建的元数据。

这是一个模块 ./serialize.ts 可能如何定义的示例:

ts
const serializables = Symbol();
type Context =
| ClassAccessorDecoratorContext
| ClassGetterDecoratorContext
| ClassFieldDecoratorContext
;
export function serialize(_target: any, context: Context): void {
if (context.static || context.private) {
throw new Error("Can only serialize public instance members.")
}
if (typeof context.name === "symbol") {
throw new Error("Cannot serialize symbol-named properties.");
}
const propNames =
(context.metadata[serializables] as string[] | undefined) ??= [];
propNames.push(context.name);
}
export function jsonify(instance: object): string {
const metadata = instance.constructor[Symbol.metadata];
const propNames = metadata?.[serializables] as string[] | undefined;
if (!propNames) {
throw new Error("No members marked with @serialize.");
}
const pairStrings = propNames.map(key => {
const strKey = JSON.stringify(key);
const strValue = JSON.stringify((instance as any)[key]);
return `${strKey}: ${strValue}`;
});
return `{ ${pairStrings.join(", ")} }`;
}

该模块有一个名为 serializables 的本地 symbol,用于存储和检索被标记为 @serializable 的属性名称。它在每次调用 @serializable 时将这些属性名称的列表存储在元数据中。当调用 jsonify 时,属性列表会从元数据中获取,并用于从实例中检索实际值,最终序列化这些名称和值。

使用 symbol 从技术上讲使这些数据对其他人可见。另一种替代方案是使用以元数据对象为键的 WeakMap。这可以保持数据私有,并且在这种情况下使用较少的类型断言,但在其他方面是类似的。

ts
const serializables = new WeakMap<object, string[]>();
type Context =
| ClassAccessorDecoratorContext
| ClassGetterDecoratorContext
| ClassFieldDecoratorContext
;
export function serialize(_target: any, context: Context): void {
if (context.static || context.private) {
throw new Error("Can only serialize public instance members.")
}
if (typeof context.name !== "string") {
throw new Error("Can only serialize string properties.");
}
let propNames = serializables.get(context.metadata);
if (propNames === undefined) {
serializables.set(context.metadata, propNames = []);
}
propNames.push(context.name);
}
export function jsonify(instance: object): string {
const metadata = instance.constructor[Symbol.metadata];
const propNames = metadata && serializables.get(metadata);
if (!propNames) {
throw new Error("No members marked with @serialize.");
}
const pairStrings = propNames.map(key => {
const strKey = JSON.stringify(key);
const strValue = JSON.stringify((instance as any)[key]);
return `${strKey}: ${strValue}`;
});
return `{ ${pairStrings.join(", ")} }`;
}

需要注意的是,这些实现不处理子类化和继承。这留给你作为练习(你可能会发现它在文件的一个版本中比在另一个版本中更容易!)。

由于此特性仍然较新,大多数运行时将不会原生支持它。要使用它,你需要为 Symbol.metadata 提供一个 polyfill。在大多数情况下,像下面这样简单的代码就可以工作:

ts
Symbol.metadata ??= Symbol("Symbol.metadata");

你还需要将编译器的 target 设置为 es2022 或更低版本,并将 lib 设置配置为包含 "esnext""esnext.decorators"

json
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022", "esnext.decorators", "dom"]
}
}

我们要感谢 Oleksandr Tarasiuk 为 TypeScript 5.2 贡献了装饰器元数据的实现

具名与匿名元组元素

元组类型一直支持为每个元素使用可选的标签或名称。

ts
type Pair<T> = [first: T, second: T];

这些标签不会改变你可以对它们进行的操作——它们仅仅是为了帮助提高可读性和辅助工具。

然而,TypeScript 之前有一条规则,即元组不能混合使用带标签和不带标签的元素。换句话说,要么元组中的任何元素都不能有标签,要么所有元素都需要有标签。

ts
// ✅ fine - no labels
type Pair1<T> = [T, T];
// ✅ fine - all fully labeled
type Pair2<T> = [first: T, second: T];
// ❌ previously an error
type Pair3<T> = [first: T, T];
// ~
// Tuple members must all have names
// or all not have names.

对于剩余元素(rest elements),这可能会很烦人,因为我们被迫不得不加上 resttail 这样的标签。

ts
// ❌ previously an error
type TwoOrMore_A<T> = [first: T, second: T, ...T[]];
// ~~~~~~
// Tuple members must all have names
// or all not have names.
// ✅
type TwoOrMore_B<T> = [first: T, second: T, rest: ...T[]];

这也意味着该限制必须在类型系统内部强制执行,这意味着 TypeScript 会丢失标签。

ts
type HasLabels = [a: string, b: string];
type HasNoLabels = [number, number];
type Merged = [...HasNoLabels, ...HasLabels];
// ^ [number, number, string, string]
//
// 'a' and 'b' were lost in 'Merged'

在 TypeScript 5.2 中,元组标签的全有或全无限制已被取消。该语言现在还可以在展开到未标记的元组时保留标签。

我们要向 Josh GoldbergMateusz Burzyński 表示感谢,他们合作取消了这一限制

数组联合类型的更简便方法使用

在以前的 TypeScript 版本中,对数组的联合类型调用方法可能会令人头疼。

ts
declare let array: string[] | number[];
array.filter(x => !!x);
// ~~~~~~ error!
// This expression is not callable.
// Each member of the union type '...' has signatures,
// but none of those signatures are compatible
// with each other.

在这个例子中,TypeScript 会尝试查看 filter 的每个版本是否在 string[]number[] 之间兼容。由于没有连贯的策略,TypeScript 只能摊手说“我无法让它工作”。

在 TypeScript 5.2 中,在放弃这些情况之前,数组的联合类型被作为特殊情况处理。根据每个成员的元素类型构建一个新的数组类型,然后在该类型上调用该方法。

以前面的例子为例,string[] | number[] 被转换为 (string | number)[](或 Array<string | number>),然后在该类型上调用 filter。有一个细微的注意事项是,filter 会产生一个 Array<string | number> 而不是 string[] | number[];但对于一个新产生的值,出现“错误”的风险较小。

这意味着许多方法,如 filterfindsomeeveryreduce,现在应该都可以在之前无法调用的数组联合类型上进行调用了。

你可以阅读实现该功能的 pull request 了解更多详情

支持 TypeScript 实现文件扩展名的纯类型导入路径

无论是否启用了 allowImportingTsExtensions,TypeScript 现在都允许在纯类型导入路径中包含声明文件和实现文件的扩展名。

这意味着你现在可以编写使用 .ts.mts.cts.tsx 文件扩展名的 import type 语句。

ts
import type { JustAType } from "./justTypes.ts";
export function f(param: JustAType) {
// ...
}

这也意味着可以在 TypeScript 和带有 JSDoc 的 JavaScript 中使用的 import() 类型可以使用这些文件扩展名。

js
/**
* @param {import("./justTypes.ts").JustAType} param
*/
export function f(param) {
// ...
}

更多信息,请参阅此处的更改

对象成员的逗号补全

向对象添加新属性时,很容易忘记添加逗号。以前,如果你忘记了逗号并请求自动补全,TypeScript 会令人困惑地给出一些不相关的补全结果。

TypeScript 5.2 现在可以在你缺少逗号时优雅地提供对象成员补全。而且为了跳过语法错误,它还会自动插入缺少的逗号。

Properties in an object literal are completed despite missing a comma after a prior property. When the property name is completed, the missing comma is automatically inserted.

更多信息,请参阅此处的实现

内联变量重构

TypeScript 5.2 现在拥有一种将变量内容内联到所有使用位置的重构功能。

A variable called 'path' initialized to a string, having both of its usages replaced.

使用“内联变量”重构将消除该变量,并用其初始化表达式替换变量的所有使用处。注意,这可能会导致初始化表达式的副作用在不同时间运行,并且次数与变量被使用的次数相同。

更多详情,请参阅实现该功能的 pull request

针对持续类型兼容性的优化检查

由于 TypeScript 是一个结构化类型系统,有时需要按成员逐一比较类型;然而,递归类型在这里增加了一些问题。例如:

ts
interface A {
value: A;
other: string;
}
interface B {
value: B;
other: number;
}

在检查类型 A 是否与类型 B 兼容时,TypeScript 最终会检查 ABvalue 的类型是否分别兼容。此时,类型系统需要停止继续检查并转而检查其他成员。为此,类型系统必须跟踪任意两个类型是否已经在关联中。

之前,TypeScript 已经保留了一个类型对栈,并遍历它以确定这些类型是否正在关联。当此栈较浅时,这不成问题;但当栈不浅时,那确实是个问题

在 TypeScript 5.3 中,使用简单的 Set 有助于跟踪此信息。这使得使用 drizzle 库的某个报告测试用例的耗时减少了超过 33%!

Benchmark 1: old
Time (mean ± σ): 3.115 s ± 0.067 s [User: 4.403 s, System: 0.124 s]
Range (min … max): 3.018 s … 3.196 s 10 runs
Benchmark 2: new
Time (mean ± σ): 2.072 s ± 0.050 s [User: 3.355 s, System: 0.135 s]
Range (min … max): 1.985 s … 2.150 s 10 runs
Summary
'new' ran
1.50 ± 0.05 times faster than 'old'

在此处阅读有关该更改的更多信息。.

破坏性变更与正确性修复

TypeScript 努力不引入不必要的破坏性变更;然而,我们有时必须进行修正和改进,以便代码能更好地被分析。

lib.d.ts 变更

为 DOM 生成的类型可能会对你的代码库产生影响。更多信息,请参阅 TypeScript 5.2 的 DOM 更新

labeledElementDeclarations 可能持有 undefined 元素

为了支持标签元素与未标签元素的混合,TypeScript 的 API 发生了细微的变化。TupleTypelabeledElementDeclarations 属性现在可能在每个未标记元素的位置持有 undefined

diff
interface TupleType {
- labeledElementDeclarations?: readonly (NamedTupleMember | ParameterDeclaration)[];
+ labeledElementDeclarations?: readonly (NamedTupleMember | ParameterDeclaration | undefined)[];
}

在较新的 Node.js 设置下 modulemoduleResolution 必须匹配

--module--moduleResolution 选项都支持 node16nodenext 设置。这些实际上是“现代 Node.js”设置,应该在任何现代 Node.js 项目中使用。我们发现,当这两个选项在是否使用 Node.js 相关设置上不一致时,项目实际上是被错误配置了。

在 TypeScript 5.2 中,当 --module--moduleResolution 选项中任何一个使用 node16nodenext 时,TypeScript 现在要求另一个也具有类似的 Node.js 相关设置。如果设置不匹配,你可能会收到如下错误消息:

Option 'moduleResolution' must be set to 'NodeNext' (or left unspecified) when option 'module' is set to 'NodeNext'.

或者

Option 'module' must be set to 'Node16' when option 'moduleResolution' is set to 'Node16'.

因此,例如 --module esnext --moduleResolution node16 将被拒绝——但你可能最好直接使用 --module nodenext,或者 --module esnext --moduleResolution bundler

更多信息,请参阅此处的更改

合并符号的一致导出检查

当两个声明合并时,它们必须在是否同时导出上保持一致。由于一个错误,TypeScript 在环境上下文(如声明文件或 declare module 块中)错过了特定情况。例如,它不会对以下情况发出错误,其中 replaceInFile 被声明了一次为导出函数,一次为未导出命名空间。

ts
declare module 'replace-in-file' {
export function replaceInFile(config: unknown): Promise<unknown[]>;
export {};
namespace replaceInFile {
export function sync(config: unknown): unknown[];
}
}

在环境模块中,添加 export { ... } 或类似 export default ... 的结构会隐式改变所有声明是否自动导出。TypeScript 现在能更一致地识别这些令人困惑的语义,并对 replaceInFile 的所有声明需要在修饰符上保持一致这一事实发出错误。

Individual declarations in merged declaration 'replaceInFile' must be all exported or all local.

更多信息,请参阅此处的更改

TypeScript 文档是一个开源项目。通过发送 Pull Request 帮助我们改进这些页面 ❤

此页面的贡献者
ABAndrew Branch (6)
EIEugene Ilyin (1)

最后更新:2026 年 3 月 27 日