TypeScript 3.7

可选链 (Optional Chaining)

演练场

可选链是我们在问题追踪器上的 第 16 号议题。作为背景信息,自那时起,TypeScript 问题追踪器上已经有了超过 23,000 个议题。

其核心在于,可选链使我们能够编写这样的代码:当遇到 nullundefined 时,TypeScript 可以立即停止执行某些表达式。可选链的主角是用于可选属性访问的全新 ?. 运算符。当我们编写如下代码时:

ts
let x = foo?.bar.baz();

这是一种表达方式:当 foo 已定义时,将计算 foo.bar.baz();但当 foonullundefined 时,停止当前操作并直接返回 undefined。”

简单来说,该代码片段等同于编写以下内容。

ts
let x = foo === null || foo === undefined ? undefined : foo.bar.baz();

请注意,如果 barnullundefined,我们的代码在访问 baz 时仍会报错。同样,如果 baznullundefined,我们在调用处也会遇到错误。?. 只会检查其左侧的值是否为 nullundefined,而不会检查后续的任何属性。

你可能会发现自己使用 ?. 替换了大量使用 && 运算符执行重复空值检查的代码。

ts
// Before
if (foo && foo.bar && foo.bar.baz) {
// ...
}
// After-ish
if (foo?.bar?.baz) {
// ...
}

请记住,?. 的行为与那些 && 操作不同,因为 && 会对“假值”(例如空字符串、0NaN 以及 false)进行特殊处理,但这正是该结构的有意设计。它不会像 0 或空字符串这样的有效数据那样进行短路。

可选链还包括另外两个操作。首先是可选元素访问,其作用类似于可选属性访问,但允许我们访问非标识符属性(例如任意字符串、数字和符号)。

ts
/**
* Get the first element of the array if we have an array.
* Otherwise return undefined.
*/
function tryGetFirstElement<T>(arr?: T[]) {
return arr?.[0];
// equivalent to
// return (arr === null || arr === undefined) ?
// undefined :
// arr[0];
}

此外还有可选调用,它允许我们在表达式不为 nullundefined 时有条件地调用它们。

ts
async function makeRequest(url: string, log?: (msg: string) => void) {
log?.(`Request started at ${new Date().toISOString()}`);
// roughly equivalent to
// if (log != null) {
// log(`Request started at ${new Date().toISOString()}`);
// }
const result = (await fetch(url)).json();
log?.(`Request finished at ${new Date().toISOString()}`);
return result;
}

可选链所具有的“短路”行为仅限于属性访问、调用和元素访问——它不会在这些表达式之外进一步扩展。换句话说,

ts
let result = foo?.bar / someComputation();

不会阻止除法运算或 someComputation() 的调用发生。它等同于

ts
let temp = foo === null || foo === undefined ? undefined : foo.bar;
let result = temp / someComputation();

这可能会导致除以 undefined,这就是为什么在 strictNullChecks 下,以下代码会报错。

ts
function barPercentage(foo?: { bar: number }) {
return foo?.bar / 100;
// ~~~~~~~~
// Error: Object is possibly undefined.
}

更多详细信息,你可以阅读提案查看原始拉取请求

空值合并 (Nullish Coalescing)

演练场

空值合并运算符是另一个即将推出的 ECMAScript 特性,它与可选链相辅相成,并且我们的团队一直致力于在 TC39 中推广它。

你可以将此特性(?? 运算符)视为在处理 nullundefined 时“回退”到默认值的一种方式。当我们编写如下代码时:

ts
let x = foo ?? bar();

这是一种新的表达方式:当 foo “存在”时使用该值;但当它为 nullundefined 时,改为计算 bar()

同样,上述代码等同于以下内容。

ts
let x = foo !== null && foo !== undefined ? foo : bar();

当尝试使用默认值时,?? 运算符可以替换 || 的使用。例如,以下代码片段尝试获取最后一次保存在 localStorage 中的音量(如果有的话);然而,它存在一个 bug,因为它使用了 ||

ts
function initializeAudio() {
let volume = localStorage.volume || 0.5;
// ...
}

localStorage.volume 被设置为 0 时,页面会将音量设置为 0.5,这并非预期结果。?? 避免了因 0NaN"" 被视为假值而产生的一些意外行为。

我们衷心感谢社区成员 Wenlu WangTitian Cernicova Dragomir 实现此功能!欲了解更多详情,请查看他们的拉取请求空值合并提案仓库

断言函数 (Assertion Functions)

演练场

有一类特定的函数在发生意外情况时会 throw 错误。它们被称为“断言”函数。例如,Node.js 有一个专门用于此目的的函数,名为 assert

js
assert(someValue === 42);

在此示例中,如果 someValue 不等于 42,那么 assert 将抛出 AssertionError

JavaScript 中的断言通常用于防范传入不正确的类型。例如:

js
function multiply(x, y) {
assert(typeof x === "number");
assert(typeof y === "number");
return x * y;
}

不幸的是,在 TypeScript 中,这些检查无法被正确编码。对于弱类型代码,这意味着 TypeScript 的检查力度变弱;而对于稍微保守的代码,它通常迫使用户使用类型断言。

ts
function yell(str) {
assert(typeof str === "string");
return str.toUppercase();
// Oops! We misspelled 'toUpperCase'.
// Would be great if TypeScript still caught this!
}

另一种替代方案是重写代码以便语言可以对其进行分析,但这并不方便。

ts
function yell(str) {
if (typeof str !== "string") {
throw new TypeError("str should have been a string.");
}
// Error caught!
return str.toUppercase();
}

归根结底,TypeScript 的目标是以最不具破坏性的方式为现有的 JavaScript 结构进行类型定义。出于这个原因,TypeScript 3.7 引入了一个称为“断言签名”的新概念,用于模拟这些断言函数。

第一种断言签名模拟了 Node 的 assert 函数的工作方式。它确保在包含作用域的剩余部分中,被检查的任何条件都必须为真。

ts
function assert(condition: any, msg?: string): asserts condition {
if (!condition) {
throw new AssertionError(msg);
}
}

asserts condition 表示如果 assert 返回(因为它否则会抛出错误),那么传递给 condition 参数的任何内容都必须为真。这意味着在该作用域的其余部分,该条件必须是真值。作为一个例子,使用此断言函数意味着我们确实捕获了最初的 yell 示例。

ts
function yell(str) {
assert(typeof str === "string");
return str.toUppercase();
// ~~~~~~~~~~~
// error: Property 'toUppercase' does not exist on type 'string'.
// Did you mean 'toUpperCase'?
}
function assert(condition: any, msg?: string): asserts condition {
if (!condition) {
throw new AssertionError(msg);
}
}

另一种类型的断言签名不检查条件,而是告诉 TypeScript 某个特定的变量或属性具有不同的类型。

ts
function assertIsString(val: any): asserts val is string {
if (typeof val !== "string") {
throw new AssertionError("Not a string!");
}
}

这里 asserts val is string 确保在任何调用 assertIsString 之后,传入的任何变量都将被识别为 string

ts
function yell(str: any) {
assertIsString(str);
// Now TypeScript knows that 'str' is a 'string'.
return str.toUppercase();
// ~~~~~~~~~~~
// error: Property 'toUppercase' does not exist on type 'string'.
// Did you mean 'toUpperCase'?
}

这些断言签名与编写类型谓词签名非常相似:

ts
function isString(val: any): val is string {
return typeof val === "string";
}
function yell(str: any) {
if (isString(str)) {
return str.toUppercase();
}
throw "Oops!";
}

而且就像类型谓词签名一样,这些断言签名也非常具有表现力。我们可以用它们表达一些相当复杂的想法。

ts
function assertIsDefined<T>(val: T): asserts val is NonNullable<T> {
if (val === undefined || val === null) {
throw new AssertionError(
`Expected 'val' to be defined, but received ${val}`
);
}
}

若要详细了解断言签名,请查看原始拉取请求

对返回 never 的函数提供更好的支持

作为断言签名工作的一部分,TypeScript 需要对函数在何处以及如何被调用进行更多的编码。这给了我们扩展另一类函数支持的机会:返回 never 的函数。

任何返回 never 的函数的意图都是它永远不会返回。它表明抛出了异常、发生了停止错误条件,或者程序已退出。例如,@types/node 中的 process.exit(...) 被指定为返回 never

为了确保函数永远不会潜在地返回 undefined 或在所有代码路径中有效地返回,TypeScript 需要一些语法信号——即在函数末尾使用 returnthrow。因此,用户经常发现自己需要 return 他们的失败处理函数。

ts
function dispatch(x: string | number): SomeType {
if (typeof x === "string") {
return doThingWithString(x);
} else if (typeof x === "number") {
return doThingWithNumber(x);
}
return process.exit(1);
}

现在,当调用这些返回 never 的函数时,TypeScript 会识别出它们影响控制流图并将其纳入考量。

ts
function dispatch(x: string | number): SomeType {
if (typeof x === "string") {
return doThingWithString(x);
} else if (typeof x === "number") {
return doThingWithNumber(x);
}
process.exit(1);
}

与断言函数一样,你可以在同一个拉取请求中了解更多信息

(更多)递归类型别名

演练场

类型别名在“递归”引用自身的方式上一直存在限制。原因是任何使用类型别名的地方都需要能够用它所别名的内容进行替换。在某些情况下,这是不可能的,因此编译器会拒绝某些递归别名,例如以下内容:

ts
type Foo = Foo;

这是一个合理的限制,因为 Foo 的任何使用都需要被替换为 Foo,进而需要被替换为 Foo,再被替换为 Foo,等等……好吧,希望你明白了!最终,没有一种类型可以在 Foo 的位置上有意义。

这与其他语言处理类型别名的方式相当一致,但它确实导致了一些用户利用该特性时略显惊奇的场景。例如,在 TypeScript 3.6 及之前版本中,以下内容会导致错误。

ts
type ValueOrArray<T> = T | Array<ValueOrArray<T>>;
// ~~~~~~~~~~~~
// error: Type alias 'ValueOrArray' circularly references itself.

这很奇怪,因为严格来说,用户始终可以通过引入接口来编写本质上相同的代码,并没有任何问题。

ts
type ValueOrArray<T> = T | ArrayOfValueOrArray<T>;
interface ArrayOfValueOrArray<T> extends Array<ValueOrArray<T>> {}

因为接口(和其他对象类型)引入了一层间接性,并且不需要立即完整构建其结构,TypeScript 在处理这种结构时没有问题。

但是,引入接口的变通方法对用户来说并不直观。原则上,直接使用 Array 的原始版 ValueOrArray 确实没有任何问题。如果编译器稍微“懒”一点,只在必要时计算 Array 的类型参数,那么 TypeScript 就能正确表达这些内容。

这正是 TypeScript 3.7 所引入的。在类型别名的“顶层”,TypeScript 将推迟解析类型参数以允许这些模式。

这意味着如下尝试表示 JSON 的代码……

ts
type Json = string | number | boolean | null | JsonObject | JsonArray;
interface JsonObject {
[property: string]: Json;
}
interface JsonArray extends Array<Json> {}

最终无需辅助接口即可重写。

ts
type Json =
| string
| number
| boolean
| null
| { [property: string]: Json }
| Json[];

这种新的放宽限制还让我们能够在元组中递归引用类型别名。以下过去会报错的代码现在是有效的 TypeScript 代码。

ts
type VirtualNode = string | [string, { [key: string]: any }, ...VirtualNode[]];
const myNode: VirtualNode = [
"div",
{ id: "parent" },
["div", { id: "first-child" }, "I'm the first child"],
["div", { id: "second-child" }, "I'm the second child"],
];

有关更多信息,你可以阅读原始拉取请求

--declaration--allowJs

TypeScript 中的 declaration 标志允许我们从 TypeScript 源文件(即 .ts.tsx 文件)生成 .d.ts 文件(声明文件)。这些 .d.ts 文件在几个方面都很重要。

首先,它们之所以重要,是因为它们允许 TypeScript 在不重新检查原始源代码的情况下针对其他项目进行类型检查。它们也很重要,因为它们允许 TypeScript 与现有的、在构建时未考虑 TypeScript 的 JavaScript 库进行互操作。最后,还有一个通常被低估的好处:当使用由 TypeScript 驱动的编辑器时,TypeScript JavaScript 用户都能从这些文件中受益,从而获得更好的自动补全等功能。

不幸的是,declaration 并不支持 allowJs 标志,后者允许混合使用 TypeScript 和 JavaScript 输入文件。这是一个令人沮丧的限制,因为这意味着用户在迁移代码库时无法使用 declaration 标志,即使他们使用了 JSDoc 注释。TypeScript 3.7 改变了这一点,允许这两个选项同时使用!

此特性最显著的影响可能比较微妙:通过 TypeScript 3.7,用户可以使用带有 JSDoc 注释的 JavaScript 编写库,并为 TypeScript 用户提供支持。

其工作原理是,在使用 allowJs 时,TypeScript 会进行一些尽力而为的分析来理解常见的 JavaScript 模式;然而,某些模式在 JavaScript 中的表达方式并不一定看起来像它们在 TypeScript 中的对应形式。当开启 declaration 发出时,TypeScript 会找出最佳方式,将 JSDoc 注释和 CommonJS 导出转换为输出 .d.ts 文件中的有效类型声明等。

例如,以下代码片段:

js
const assert = require("assert");
module.exports.blurImage = blurImage;
/**
* Produces a blurred image from an input buffer.
*
* @param input {Uint8Array}
* @param width {number}
* @param height {number}
*/
function blurImage(input, width, height) {
const numPixels = width * height * 4;
assert(input.length === numPixels);
const result = new Uint8Array(numPixels);
// TODO
return result;
}

将生成如下的 .d.ts 文件:

ts
/**
* Produces a blurred image from an input buffer.
*
* @param input {Uint8Array}
* @param width {number}
* @param height {number}
*/
export function blurImage(
input: Uint8Array,
width: number,
height: number
): Uint8Array;

这也可以超越带有 @param 标签的基础函数,如下示例:

js
/**
* @callback Job
* @returns {void}
*/
/** Queues work */
export class Worker {
constructor(maxDepth = 10) {
this.started = false;
this.depthLimit = maxDepth;
/**
* NOTE: queued jobs may add more items to queue
* @type {Job[]}
*/
this.queue = [];
}
/**
* Adds a work item to the queue
* @param {Job} work
*/
push(work) {
if (this.queue.length + 1 > this.depthLimit) throw new Error("Queue full!");
this.queue.push(work);
}
/**
* Starts the queue if it has not yet started
*/
start() {
if (this.started) return false;
this.started = true;
while (this.queue.length) {
/** @type {Job} */ (this.queue.shift())();
}
return true;
}
}

将被转换为以下 .d.ts 文件:

ts
/**
* @callback Job
* @returns {void}
*/
/** Queues work */
export class Worker {
constructor(maxDepth?: number);
started: boolean;
depthLimit: number;
/**
* NOTE: queued jobs may add more items to queue
* @type {Job[]}
*/
queue: Job[];
/**
* Adds a work item to the queue
* @param {Job} work
*/
push(work: Job): void;
/**
* Starts the queue if it has not yet started
*/
start(): boolean;
}
export type Job = () => void;

请注意,当同时使用这些标志时,TypeScript 不一定非要对 .js 文件进行降级处理。如果你只是想让 TypeScript 创建 .d.ts 文件,可以使用 emitDeclarationOnly 编译器选项。

欲了解更多详情,请查看原始拉取请求

useDefineForClassFields 标志和 declare 属性修饰符

早在 TypeScript 实现公共类字段时,我们尽最大努力假设以下代码:

ts
class C {
foo = 100;
bar: string;
}

等同于构造函数体内类似的赋值。

ts
class C {
constructor() {
this.foo = 100;
}
}

遗憾的是,虽然这似乎是该提案早期阶段发展的方向,但公共类字段以不同方式标准化的可能性非常大。相反,原始代码示例可能需要脱糖为更接近以下内容的形式:

ts
class C {
constructor() {
Object.defineProperty(this, "foo", {
enumerable: true,
configurable: true,
writable: true,
value: 100,
});
Object.defineProperty(this, "bar", {
enumerable: true,
configurable: true,
writable: true,
value: void 0,
});
}
}

虽然 TypeScript 3.7 默认不会改变任何现有的输出,但我们一直在逐步推出变更以帮助用户减轻未来潜在的中断。我们提供了一个名为 useDefineForClassFields 的新标志来启用此输出模式以及一些新的检查逻辑。

两个最大的变化如下:

  • 声明使用 Object.defineProperty 进行初始化。
  • 声明始终初始化为 undefined,即使它们没有初始化表达式。

这可能会对现有使用继承的代码造成相当大的影响。首先,基类中的 set 访问器将不会被触发——它们将被完全覆盖。

ts
class Base {
set data(value: string) {
console.log("data changed to " + value);
}
}
class Derived extends Base {
// No longer triggers a 'console.log'
// when using 'useDefineForClassFields'.
data = 10;
}

其次,使用类字段来专门化基类属性也将无法工作。

ts
interface Animal {
animalStuff: any;
}
interface Dog extends Animal {
dogStuff: any;
}
class AnimalHouse {
resident: Animal;
constructor(animal: Animal) {
this.resident = animal;
}
}
class DogHouse extends AnimalHouse {
// Initializes 'resident' to 'undefined'
// after the call to 'super()' when
// using 'useDefineForClassFields'!
resident: Dog;
constructor(dog: Dog) {
super(dog);
}
}

这两点归结起来就是,混合属性和访问器会导致问题,重新声明没有初始化表达式的属性也会导致问题。

为了检测围绕访问器的问题,TypeScript 3.7 现在会在 .d.ts 文件中发出 get/set 访问器,以便 TypeScript 可以检查被覆盖的访问器。

受类字段变更影响的代码可以通过将字段初始化表达式转换为构造函数体内的赋值来规避该问题。

ts
class Base {
set data(value: string) {
console.log("data changed to " + value);
}
}
class Derived extends Base {
constructor() {
this.data = 10;
}
}

为了帮助减轻第二个问题,你可以添加显式初始化表达式,或者添加 declare 修饰符来表明该属性不应有任何输出。

ts
interface Animal {
animalStuff: any;
}
interface Dog extends Animal {
dogStuff: any;
}
class AnimalHouse {
resident: Animal;
constructor(animal: Animal) {
this.resident = animal;
}
}
class DogHouse extends AnimalHouse {
declare resident: Dog;
// ^^^^^^^
// 'resident' now has a 'declare' modifier,
// and won't produce any output code.
constructor(dog: Dog) {
super(dog);
}
}

目前 useDefineForClassFields 仅在以 ES5 及更高版本为目标时可用,因为 Object.defineProperty 在 ES3 中不存在。为了实现类似的检查,你可以创建一个单独的项目,以 ES5 为目标并使用 noEmit 来避免完整构建。

有关更多信息,你可以查看这些变更的原始拉取请求

我们强烈建议用户尝试 useDefineForClassFields 标志并向我们的问题追踪器或下方的评论中反馈。这包括关于采用该标志的难易程度的反馈,以便我们了解如何让迁移变得更容易。

使用项目引用的无构建编辑

TypeScript 的项目引用为我们提供了一种简单的方法来拆分代码库以实现更快的编译。遗憾的是,编辑一个其依赖项尚未构建(或其输出已过时)的项目意味着编辑体验效果不佳。

在 TypeScript 3.7 中,当打开一个带有依赖项的项目时,TypeScript 将自动改用源代码 .ts/.tsx 文件。这意味着使用项目引用的项目现在将获得更好的编辑体验,语义操作是最新的并且能够“直接工作”。你可以使用编译器选项 disableSourceOfProjectReferenceRedirect 禁用此行为,在处理此变更可能影响编辑性能的超大型项目时,这可能很有用。

你可以通过阅读其拉取请求来了解有关此变更的更多信息

未调用函数检查

一个常见且危险的错误是忘记调用函数,特别是如果该函数有零个参数或以暗示它可能是属性而不是函数的方式命名时。

ts
interface User {
isAdministrator(): boolean;
notify(): void;
doNotDisturb?(): boolean;
}
// later...
// Broken code, do not use!
function doAdminThing(user: User) {
// oops!
if (user.isAdministrator) {
sudo();
editTheConfiguration();
} else {
throw new AccessDeniedError("User is not an admin");
}
}

在这里,我们忘记了调用 isAdministrator,而代码错误地允许非管理员用户编辑配置!

在 TypeScript 3.7 中,这被识别为一个可能的错误。

ts
function doAdminThing(user: User) {
if (user.isAdministrator) {
// ~~~~~~~~~~~~~~~~~~~~
// error! This condition will always return true since the function is always defined.
// Did you mean to call it instead?

此检查是一个重大变更,但正因如此,检查非常保守。此错误仅在 if 条件中发出,并且如果 strictNullChecks 已关闭,或者函数稍后在 if 的主体中被调用,则不会发出此错误。

ts
interface User {
isAdministrator(): boolean;
notify(): void;
doNotDisturb?(): boolean;
}
function issueNotification(user: User) {
if (user.doNotDisturb) {
// OK, property is optional
}
if (user.notify) {
// OK, called the function
user.notify();
}
}

如果你打算在不调用函数的情况下测试它,你可以更正它的定义以包含 undefined/null,或者使用 !! 编写类似 if (!!user.isAdministrator) 的代码以表明这种强制转换是有意的。

我们衷心感谢 GitHub 用户 @jwbay,他主动创建了一个 概念验证并进行迭代,为我们提供了当前版本

TypeScript 文件中的 // @ts-nocheck

TypeScript 3.7 允许我们在 TypeScript 文件顶部添加 // @ts-nocheck 注释以禁用语义检查。过去,此注释仅在开启 checkJs 的 JavaScript 源文件中受支持,但我们已将支持范围扩大到 TypeScript 文件,以便让所有用户的迁移更加容易。

分号格式化程序选项

TypeScript 的内置格式化程序现在支持在由于 JavaScript 的自动分号插入 (ASI) 规则而使尾随分号可选的位置进行分号插入和移除。此设置现已在 Visual Studio Code Insiders 中提供,并将于 Visual Studio 16.4 Preview 2 的工具选项菜单中提供。

New semicolon formatter option in VS Code

选择“insert”或“remove”值也会影响自动导入、提取类型以及由 TypeScript 服务提供的其他生成代码的格式。将设置保留为默认值“ignore”,会使生成的代码与当前文件中检测到的分号偏好相匹配。

3.7 重大变更

DOM 变更

lib.dom.d.ts 中的类型已更新。这些变更主要是与空值性相关的正确性修复,但具体影响最终取决于你的代码库。

类字段缓解措施

如上所述,TypeScript 3.7 在 .d.ts 文件中发出 get/set 访问器,这可能会对使用较旧版本 TypeScript(如 3.5 及之前版本)的消费者造成重大变更。TypeScript 3.6 的用户将不会受到影响,因为该版本已为该功能进行了面向未来的准备。

虽然这本身不是一种破坏,但选择使用 useDefineForClassFields 标志可能会在以下情况下造成破坏:

  • 在派生类中使用属性声明覆盖访问器
  • 重新声明没有初始化表达式的属性声明

要了解全部影响,请阅读关于 useDefineForClassFields 标志的章节

函数真值检查

如上所述,TypeScript 现在会在 if 语句条件中出现未调用的函数时报错。当在 if 条件中检查函数类型时,除非应用以下任一情况,否则会发出错误:

  • 被检查的值来自可选属性
  • strictNullChecks 已禁用
  • 该函数稍后在 if 的主体中被调用

本地和导入的类型声明现在冲突

由于一个 bug,以下构造以前在 TypeScript 中是允许的:

ts
// ./someOtherModule.ts
interface SomeType {
y: string;
}
// ./myModule.ts
import { SomeType } from "./someOtherModule";
export interface SomeType {
x: number;
}
function fn(arg: SomeType) {
console.log(arg.x); // Error! 'x' doesn't exist on 'SomeType'
}

在这里,SomeType 似乎既源自 import 声明,也源自本地 interface 声明。或许令人惊讶的是,在模块内部,SomeType 仅指代 import 导入的定义,而本地声明的 SomeType 仅在从另一个文件导入时才可用。这非常令人困惑,我们对在实际代码中发现的极少数此类案例进行了审查,结果显示开发者通常认为发生了其他事情。

在 TypeScript 3.7 中,这现在被正确识别为重复标识符错误。正确的修复取决于作者的初衷,应根据具体情况处理。通常,命名冲突是无意的,最好的修复方法是重命名导入的类型。如果意图是增强导入的类型,则应编写适当的模块增强。

3.7 API 变更

为了启用上述递归类型别名模式,typeArguments 属性已从 TypeReference 接口中移除。用户应改为使用 TypeChecker 实例上的 getTypeArguments 函数。

TypeScript 文档是一个开源项目。请通过提交拉取请求来帮助我们改进这些页面 ❤

此页面的贡献者
OTOrta Therox (67)
JJake (3)
NSNick Schonning (1)
JBJack Bates (1)
GLGiuliano Lauro (1)
12+

最后更新:2026 年 3 月 27 日