第 14 章 语法扩展

Flying
2023-02-18 / 0 评论 / 29 阅读 / 正在检测是否收录...

“TypeScript 不会添加到
JavaScript 运行时。”
这都是谎言吗?!

当 TypeScript 于 2012 年首次发布时,Web 应用程序的复杂性增长速度快于普通 JavaScript 添加支持深度复杂性的功能的速度。当时最流行的 JavaScript 语言风格,CoffeeScript,通过引入新的和令人兴奋的语法结构,使它与 JavaScript 不同。

如今,使用特定于超集语言(如 TypeScript)的新运行时特性来扩展 JavaScript 语法被认为是不好的实践,原因如下:

  • 最重要的是,运行时语法扩展可能与较新版本的 JavaScript 中的新语法冲突。
  • 它们使刚接触该语言的程序员更难理解 JavaScript 的结束和其他语言的开始。
  • 它们增加了采用超集语言代码并生成 JavaScript 的转译器的复杂性。

因此,我怀着沉重的心情和深深的遗憾通知您,早期的 TypeScript 设计人员在 TypeScript 语言中为 JavaScript 引入了三个语法扩展:

  • 类,在规范获得批准时与 JavaScript 类保持一致
  • 枚举,一个简单的语法糖,类似于键和值的普通对象
  • 命名空间,一种早于现代模块的解决方案,用于构建和组织代码

幸运的是,TypeScript 对 JavaScript 的运行时语法扩展的“原罪”并不是该语言自早期以来所做的设计决策。TypeScript 不会添加新的运行时语法结构,直到它们通过批准过程取得重大进展,以添加到 JavaScript 本身。

TypeScript 类最终的外观和行为几乎与 JavaScript 类相同(呸!),除了 useDefineForClassFields 的行为(本书中未涵盖的配置选项)和参数属性(此处介绍)。枚举仍然在某些项目中使用,因为它们偶尔有用。实际上,没有新项目再使用命名空间了。

TypeScript 还采用了一个针对 JavaScript “装饰器”的实验性提案,我也将介绍该提案。

类参数属性

我建议避免使用类参数属性,除非您在大量使用类或框架的项目中工作,这些类或框架将从中受益。

在 JavaScript 类中,想要在构造函数中接受参数并立即将其分配给类属性是很常见的。

Engineer 类接受 string 类型的单个 area 参数,并将其分配给类型为 stringarea 属性:

class Engineer {
  readonly area: string;

  constructor(area: string) {
    this.area = area;
    console.log(`I work in the ${area} area.`);
  }
}

// Type: string
new Engineer("mechanical").area;

TypeScript 包含一个简洁语法,用于声明这些类型的“参数属性”:在类构造函数的开头分配给相同类型的成员属性的属性。将 readonly 和或隐私修饰符之一(public protected private)放在构造函数的参数前面,指示 TypeScript 也声明相同名称和类型的属性。

前面的 Engineer 示例可以使用 area 的参数属性在 TypeScript 中重写:

class Engineer {
  constructor(readonly area: string) { 
    console.log(`I work in the ${area} area.`);
  }
}

// Type: string
new Engineer("mechanical").area;

参数属性在类构造函数的最开头分配(如果类派生自基类,则在 super() 调用之后)。它们可以与类上的其他参数和或属性混合使用。

下面的 NamedEngineer 类声明常规属性 fullName、常规参数 name 和参数属性 area

class NamedEngineer { 
  fullName: string;

  constructor(
    name: string,
    public area: string,
  ) {
    this.fullName = `${name}, ${area} engineer`;
  }
}

它和没有参数属性的等效 TypeScript 看起来很相似,但还有几行代码来显式分配 area

class NamedEngineer { 
  fullName: string; 
  area: string;
  
  constructor(
    name: string, 
    area: string,
  ) {
    this.area = area;
    this.fullName = `${name}, ${area} engineer`;
  }
}

参数属性在 TypeScript 社区中是一个有时争论的问题。大多数项目更喜欢绝对避免它们,因为它们是运行时语法扩展,因此具有我前面提到的相同缺点。它们也不能与较新的 # 类私有字段语法一起使用。

另一方面,当它们用于非常有利于创建类的项目时,它们非常好。参数属性解决了需要声明参数属性名称和类型两次的便利问题,这是 TypeScript 而不是 JavaScript 固有的。

实验性装饰器

我建议尽可能避免使用装饰器,直到使用装饰器语法批准 ECMAScript 版本。如果您正在使用建议使用 TypeScript 装饰器的框架版本(如 Angular 或 NestJS),则框架的文档将指导如何使用它们。

许多其他包含类的语言允许使用某种运行时逻辑来注释或修饰这些类和或其成员来修改它们。_Decorator functions 是 JavaScript 的一个建议,它允许通过将 @ 和函数名称放在首位来注释类和成员。

例如,以下代码片段仅显示了在类 MyClass 上使用修饰器的语法:

@myDecorator
class MyClass { /* ... */ }

装饰器尚未在 ECMAScript 中得到批准,因此 TypeScript 从 4.7.2 版本开始默认不支持它们。但是,TypeScript 确实包含一个 experimentalDecorators 编译器选项,该选项允许在代码中使用它们的旧实验版本。它可以通过 tsc CLI 或在 TSConfig 文件中启用,如下所示,就像其他编译器选项一样:

{
  "compilerOptions": {
    "experimentalDecorators": true
  }
}

装饰器的每次用法将在创建其修饰的实体后立即执行一次。每种修饰器(访问器、类、方法、参数和属性)都会接收一组不同的参数,这些参数描述它正在修饰的实体。
例如,在 Greeter 类方法上使用的此 logOnCall 修饰器接收 Greeter 类本身、属性的键 (“log”) 和描述属性的 descriptor 对象。在对 Greeter 类调用原始 greet 方法之前,将 descriptor.value 修改为日志“装饰”了 greet 方法:

我不会深入研究旧的 experimentalDecorators 如何适用于每种可能的装饰器类型的细微差别和细节。TypeScript 的装饰器支持端口是实验性的,与 ECMAScript 提案的最新草案不一致。特别是在任何 TypeScript 项目中编写自己的装饰器很少是合理的。

枚举

我建议不要使用枚举,除非您有一组经常重复的字面量,都可以用一个通用名称来描述,并且如果切换到枚举,其代码会更容易阅读。
function logOnCall(target: any, key: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;
  console.log("[logOnCall] I am decorating", target.constructor.name);

  descriptor.value = function (...args: unknown[]) { console.log(`[descriptor.value] Calling '${key}' with:`, ...args); return original.call(this, ...args);
                                                   }
}

class Greeter {
  @logOnCall
  greet(message: string) {
    console.log(`[greet] Hello, ${message}!`);
  }
}

new Greeter().greet("you");
// Output log:
// "[logOnCall] I am decorating", "Greeter"
// "[descriptor.value] Calling 'greet' with:", "you"
// "[greet] Hello, you!"

大多数编程语言都包含“枚举”或枚举类型的概念,以表示一组相关值。枚举可以被认为是存储在对象中的一组字面量值,每个值都有一个友好名称。

JavaScript 不包含枚举语法,因为可以使用传统对象来代替它们。例如,虽然 HTTP 状态代码可以存储并用作数字,但许多开发人员发现将它们存储在按友好名称对其进行键的对象中更具可读性:

const StatusCodes = {
  InternalServerError: 500,
  NotFound: 404,
  Ok: 200,
  // ...
} as const;

StatusCodes.InternalServerError; // 500

TypeScript 中类似枚举的对象棘手的事情是,没有一种很好的类型系统方法来表示值必须是它们的值之一。一种常见的方法是使用第 9 章“类型修饰符”中的 keyoftypeof 类型修饰符一起破解一个,但这是相当多的语法。

以下 StatusCodeValue 类型使用前面的 StatusCodes 值创建其可能的状态代码编号值的类型联合:

// Type: 200 | 404 | 500
type StatusCodeValue = (typeof StatusCodes)[keyof typeof StatusCodes];
let statusCodeValue: StatusCodeValue; statusCodeValue = 200; // Ok statusCodeValue = -1;
// Error: Type '-1' is not assignable to type 'StatusCodeValue'.

TypeScript 提供 enum 语法,用于创建字面量值类型为 numberstring 的对象。以 enum 关键字开头——然后是对象的名称(通常用 Pascal式)——然后是枚举中包含逗号分隔键的 {} object。每个键都可以选择在初始值之前使用 =

前面的 StatusCodes 对象如下所示 StatusCode 枚举:

enum StatusCode {
  InternalServerError = 500,
  NotFound = 404,
  Ok = 200,
}

StatusCode.InternalServerError; // 500

与类名一样,枚举名称(如 StatusCode)可以用作类型注释中的类型名称。在这里,StatusCode 类型的 statusCode 变量可以给出 StatusCode.Ok 或数字值:

let statusCode: StatusCode;

statusCode = StatusCode.Ok; // Ok
statusCode = 200; // Ok
方便起见,TypeScript 允许将任何数字分配给数字枚举值,但代价是需要稍微保护类型。statusCode = -1 在前面的代码片段中也是允许的。

枚举编译为输出编译的 JavaScript 中的等效对象。它们的每个成员都成为具有相应值的对象成员键,反之亦然。

之前的 enum StatusCode 将创建大致如下 JavaScript:

var StatusCode; (function (StatusCode) {
  StatusCode[StatusCode["InternalServerError"] = 500] = "InternalServerError";
  StatusCode[StatusCode["NotFound"] = 404] = "NotFound";
  StatusCode[StatusCode["Ok"] = 200] = "Ok";文本
})(StatusCode || (StatusCode = {}));

枚举在 TypeScript 社区中是一个稍微有争议的话题。一方面,它们违反了 TypeScript 的通用口号,即从不向 JavaScript 添加新的运行时语法结构。它们提供了一种新的非 JavaScript 语法供开发人员学习,并且围绕 preserveConstEnums 等选项有一些怪癖,本章稍后将介绍。

另一方面,它们对于显式声明已知值集非常有用。枚举在 TypeScript 和 VS Code 源存储库中被广泛使用!

自动数值

枚举成员不需要具有显式初始值。省略值时,TypeScript 将从第一个值开始 0,并将每个后续值递增 1。允许 TypeScript 为枚举成员选择值是一个不错的选择,因为该值除了唯一且与键名称相关联之外无关紧要。

这个 VisualTheme 枚举允许 TypeScript 完全选择值,从而产生三个整数:

enum VisualTheme {
  Dark, // 0
  Light, // 1
  System, // 2
}

生成的 JavaScript 看起来与显式设置的值相同:

var VisualTheme; (function (VisualTheme) {
  VisualTheme[VisualTheme["Dark"] = 0] = "Dark";
  VisualTheme[VisualTheme["Light"] = 1] = "Light";
  VisualTheme[VisualTheme["System"] = 2] = "System";
})(VisualTheme || (VisualTheme = {}));

在具有数值的枚举中,任何缺少显式值的成员都将比前一个值大 1

例如,Direction 枚举可能只关心其 Top 成员的值为 1,其余值也是正整数:

enum Direction {
  Top = 1, 
  Right,
  Bottom, 
  Left,
}

它的输出 JavaScript 看起来也与其余成员具有显式值 234 相同:

var Direction; (function (Direction) {
  Direction[Direction["Top"] = 1] = "Top";
  Direction[Direction["Right"] = 2] = "Right";
  Direction[Direction["Bottom"] = 3] = "Bottom";
  Direction[Direction["Left"] = 4] = "Left";
})(Direction || (Direction = {}));
修改枚举的顺序将导致基础数字更改。如果将这些值保留在某个位置(如数据库),请注意更改枚举顺序或删除条目。您的数据可能突然损坏,因为保存的数字将不再代表您的代码期望的内容。

字符串值枚举

枚举也可以为其成员使用字符串而不是数字。此 LoadStyle 枚举对其成员使用友好的字符串值:

enum LoadStyle {
  AsNeeded = "as-needed",
  Eager = "eager",
}

具有字符串成员值的枚举的输出 JavaScript 在结构上看起来与具有数字成员值的枚举相同:

var LoadStyle; (function (LoadStyle) {
LoadStyle["AsNeeded"] = "as-needed";
  LoadStyle["Eager"] = "eager";
})(LoadStyle || (LoadStyle = {}));

字符串值枚举对于在清晰名称下别名共享常量非常方便。字符串值枚举不是使用字符串字面量的类型联合,而是允许更强大的编辑器自动完成和重命名这些属性 — 如第 12 章 “使用 IDE 功能”中所述。

字符串成员值的一个缺点是 TypeScript 无法自动计算它们。仅允许自动计算具有数值的成员后面的枚举成员。

TypeScript 将能够在此枚举的 ImplicitNumber 中提供隐式值 9001,因为前一个成员值是数字 9000,但其 NotAllowed 成员将抛出一个错误,因为它跟在字符串成员值后面:

enum Wat {
  FirstString = "first",
  SomeNumber = 9000,
  ImplicitNumber, // Ok (value 9001)
  AnotherString = "another",

  NotAllowed,
  // Error: Enum member must have initializer.
}

理论上,您可以同时使用数字和字符串成员值创建枚举。在实践中,该枚举可能会造成不必要的混淆,因此您可能不应该这样做。

Const 枚举

由于枚举创建运行时对象,因此使用枚举会比字面量值联合的常见替代策略生成更多的代码。TypeScript 允许在枚举前面声明带有 const 修饰符的枚举,以告诉 TypeScript 从编译的 JavaScript 代码中省略其对象定义和属性查找。

DisplayHint 枚举用作 displayHint 变量的值:

const enum DisplayHint {
  Opaque = 0,
  Semitransparent,
  Transparent,
}

let displayHint = DisplayHint.Transparent;

输出编译的 JavaScript 代码将完全缺少枚举声明,并将使用注释作为枚举的值:

let displayHint = 2 /* DisplayHint.Transparent */;

对于仍需要创建枚举对象定义的项目,确实存在 preserveConstEnums 编译器选项,该选项将保持枚举声明本身的存在。值仍将直接使用字面量,而不是在枚举对象上访问它们。

前面的代码片段仍将在其编译的 JavaScript 输出中省略属性查找:

var DisplayHint; (function (DisplayHint) {
  DisplayHint[DisplayHint["Opaque"] = 0] = "Opaque";
  DisplayHint[DisplayHint["Semitransparent"] = 1] = "Semitransparent";
  DisplayHint[DisplayHint["Transparent"] = 2] = "Transparent";
})(DisplayHint || (DisplayHint = {}));

let displayHint = 2 /* Transparent */;

preserveConstEnums 可以帮助减小生成的 JavaScript 代码的大小,尽管并非所有转译 TypeScript 代码的方法都支持它。有关 isolatedModules 编译器选项以及何时不支持 const 枚举的更多信息,请参见第 13 章 “配置选项”。

命名空间

除非为现有包创作 DefinitelyTyped 类型定义,否则不要使用命名空间。命名空间与现代 JavaScript 模块语义不匹配。它们的自动成员分配可能会使代码难以阅读。我只提到它们,因为您可能会在 .d.ts 文件中遇到它们。

早在 ECMAScript 模块被批准之前,Web 应用程序将其大部分输出代码捆绑到浏览器加载的单个文件中的情况并不少见。这些巨大的单个文件通常会创建全局变量来保存对项目不同区域的重要值的引用。对于页面来说,包含该文件比设置旧的模块加载器(如 RequireJS)更简单,而且加载性能通常更高,因为许多服务器尚不支持 HTTP/2 下载流。为单文件输出而创建的项目需要一种方法来组织代码部分和这些全局变量。

TypeScript 语言提供了一个具有“内部模块”概念的解决方案,现在称为命名空间。命名空间是全局可用的对象,其中“导出”的内容可作为该对象的成员进行调用。命名空间使用 namespace 关键字后跟 {} 代码块进行定义。该命名空间块中的所有内容都在函数闭包中求值。

Randomized 命名空间创建一个 value 变量并在内部使用它:

namespace Randomized {
  const value = Math.random(); 
  console.log(`My value is ${value}`);
}

它的输出 JavaScript 创建一个 Randomized 对象并计算函数内块的内容,因此 value 变量在命名空间之外不可用:

var Randomized; 
(function (Randomized) {
  const value = Math.random(); 
  console.log(`My value is ${value}`);
})(Randomized || (Randomized = {}));
警告:命名空间和命名空间关键字最初在 TypeScript 中分别称为“modules”和“module”。事后看来,这是一个令人遗憾的选择,因为现代模块加载器和 ECMAScript 模块的兴起。模块关键字仍然偶尔出现在非常旧的项目中,但可以并且应该安全地替换为命名空间。

命名空间导出

命名空间使其有用的关键功能是,命名空间可以通过使内容成为命名空间对象的成员来“导出”内容。然后,其他代码区域可以按名称引用该成员。

在这里,Settings 命名空间将内部和外部使用的 describename版本值导出到命名空间:

namespace Settings {
  export const name = "My Application";
  export const version = "1.2.3";

  export function describe() {
    return `${Settings.name} at version ${Settings.version}`;
  }

  console.log("Initializing", describe());
}

console.log("Initialized", Settings.describe());

输出 JavaScript 显示,在内部和外部使用中,这些值始终作为 Settings 的成员(例如 Settings.name)引用:

var Settings; (function (Settings) {
  Settings.name = "My Application"; 
  Settings.version = "1.2.3"; 
  function describe() {
    return `${Settings.name} at version ${Settings.version}`;
  }
  Settings.describe = describe; 
  console.log("Initializing", describe());
})(Settings || (Settings = {})); 
console.log("Initialized", Settings.describe());

通过对输出对象使用 var 并将导出的内容引用为这些对象的成员,在拆分到多个文件时命名空间设计模式可以很好地工作。以前的 Settings 命名空间可以跨多个文件重写:

// settings/constants.ts
namespace Settings {
  export const name = "My Application";
  export const version = "1.2.3";
}
// settings/describe.ts
namespace Settings {
  export function describe() {
    return `${Settings.name} at version ${Settings.version}`;
  }

  console.log("Initializing", describe());
}
// index.ts
console.log("Initialized", Settings.describe());

连接在一起的输出 JavaScript 大致如下所示:

// settings/constants.ts
var Settings; 
(function (Settings) {
  Settings.name = "My Application"; 
  Settings.version = "1.2.3";
})(Settings || (Settings = {}));
// settings/describe.ts
(function (Settings) {
  function describe() {
    return `${Settings.name} at version ${Settings.version}`;
  }
  Settings.describe = describe;

  console.log("Initialized", describe());
})(Settings || (Settings = {})); 
console.log("Initialized", Settings.describe());

在单文件和多文件声明形式中,运行时的输出对象是一个具有三个键的对象。大致如下:

const Settings = {
  describe: function describe() {
    return `${Settings.name} at version ${Settings.version}`;
  },
  name: "My Application", 
  version: "1.2.3",
};

使用命名空间的主要区别在于,它可以拆分到不同的文件中,并且成员仍然可以在命名空间的名称下相互引用。

嵌套命名空间

命名空间可以通过从另一个命名空间中导出命名空间或在名称中放置一个或多个 . 句点来“嵌套”到无限级别。

以下两个命名空间声明的行为相同:

namespace Root.Nested {
  export const value1 = true;
}

namespace Root {
  export namespace Nested {
    export const value2 = true;
  }
}

它们都编译为结构相同的代码:

(function (Root) {
  let Nested;
  (function (Nested) {
    Nested.value2 = true;
  })(Nested || (Nested = {}));
})(Root || (Root = {}));

在使用命名空间组织的大型项目中,嵌套命名空间是一种方便的方法,可以强制在各部分之间进行更多的划分。许多开发人员选择按项目名称使用根命名空间(可能在其公司和或组织的命名空间内),并为项目的每个主要领域使用子命名空间。

类型定义中的命名空间

今天命名空间的唯一可救赎性(也是我选择将它们包含在本书中的唯一原因)是它们对于 DefinitelyTyped 类型定义很有用。许多 JavaScript 库——尤其是较旧的 Web 应用程序主打库,如 jQuery——被设置为包含在带有传统非模块 <script> 标签的 Web 浏览器中。它们的类型需要表明它们创建了一个可用于所有代码的全局变量——由命名空间完美捕获的结构。

此外,许多支持浏览器的 JavaScript 库被设置为既可以导入到更现代的模块系统中,也可以创建全局命名空间。TypeScript 允许模块类型定义包含 export as namespace,后跟全局名称,以表明模块在该名称下也全局可用。

例如,模块的此声明文件导出 value,并且全局可用:

// nodemodules/@types/my-example-lib/index.d.ts
export const value: number;
export as namespace libExample;

类型系统将知道 import("my-example-lib")window.libExample 将返回模块,具有 number 类型的 value 属性:

// src/index.ts
import * as libExample from "my-example-lib"; // Ok
const value = window.libExample.value; // Ok

首选模块而不是命名空间

前面的示例的 settings/constants.ts 文件和 settings/describe.ts 文件可以使用 ECMAScript 模块重写为现代标准,而不是使用命名空间:

// settings/constants.ts
export const name = "My Application";
export const version = "1.2.3";
// settings/describe.ts
import { name, version } from "./constants";

export function describe() {
  return `${Settings.name} at version ${Settings.version}`;
}

console.log("Initializing", describe());
// index.ts
import { describe } from "./settings/describe";

console.log("Initialized", describe());

在现代构建器(如 Webpack)中,使用命名空间构建的 TypeScript 代码不容易被 tree-shaken(删除未使用的文件),因为命名空间在文件之间创建隐式的,而不是显式声明的,就像 ECMAScript 模块所做的那样。通常强烈建议使用 ECMAScript 模块而不是 TypeScript 命名空间编写运行时代码。

截至 2022 年,TypeScript 本身是用命名空间编写的,但 TypeScript 团队正在努力迁移到模块。谁知道呢,也许当您读到这篇文章时,他们已经完成了转换!祈祷。

仅类型导入和导出

我想以积极的语气结束这一章。最后一组语法扩展,仅类型导入和导出,可能非常有用,并且不会增加输出生成的 JavaScript 的任何复杂性。

TypeScript 的转译器将从文件的导入和导出中删除仅在类型系统中使用的值,因为它们不在运行时 JavaScript 中使用。

例如,以下 index.ts 文件创建 action 变量和 ActivistArea 类型,然后使用独立导出声明导出它们。当将其编译为 index.js 时,TypeScript 的转译器会知道从该独立导出声明中删除 ActivistArea

// index.ts
const action = { area: "people", name: "Bella Abzug", role: "politician" };

type ActivistArea = "nature" | "people";

export { action, ActivistArea };
// index.js
const action = { area: "people", name: "Bella Abzug", role: "politician" };

export { action };

知道移除像ActivistArea这样重新导出的类型需要了解 TypeScript 的类型系统。像 Babel 这样一次作用于单个文件的转译器无法访问 TypeScript 类型系统,以知道每个名称是否只在类型系统中使用。第 13 章将介绍 TypeScript 的isolatedModules编译器选项,它有助于确保代码可以在 TypeScript 以外的工具中转译。

TypeScript 允许在单个导入的名称或在exportimport 声明中的整个 {…} 对象前面添加 type 修饰符。这样做表明它们只能在类型系统中使用。还允许将包的默认导入标记为 type

在以下代码片段中,当 index.ts 编译成 index.js 时,仅保留 value 的导入和导出:

// index.ts
import { type TypeOne, value } from "my-example-types";
import type { TypeTwo } from "my-example-types";
import type DefaultType from "my-example-types";

export { type TypeOne, value };
export type { DefaultType, TypeTwo };
// index.js
import { value } from "my-example-types";

export { value };

一些 TypeScript 开发人员甚至更喜欢选择使用只类型导入,以便更清楚地了解哪些导入仅用作类型。如果导入标记为仅类型,则尝试将其用作运行时值将触发 TypeScript 错误。

以下 ClassOne 是正常导入的,可以在运行时使用,但 ClassTwo 不能,因为它是作为类型导入的:

import { ClassOne, type ClassTwo } from "my-example-types";

new ClassOne(); // Ok

new ClassTwo();
// ~~~~~~~~
// Error: 'ClassTwo' cannot be used as a value
// because it was imported using 'import type'.

仅类型导入和导出不会增加生成的 JavaScript 的复杂性,而是让 TypeScript 外部的转译器清楚地知道何时可以删除代码片段。因此,大多数 TypeScript 开发人员不会像本章中前面介绍的语法扩展那样厌恶它们。

总结

在本章中,您使用了 TypeScript 中包含的一些 JavaScript 语法扩展:

  • 在类构造函数中声明类参数属性
  • 使用修饰器来扩充类及其字段
  • 用枚举表示一组值
  • 使用命名空间跨文件或在类型定义中创建分组
  • 仅类型导入和导出
现在您已经读完了这一章,您最好练习一下学到的东西 https://learningtypescript.com/syntax-extensions

您怎么称呼在 TypeScript 中支持传统 JavaScript 扩展的成本?
“罪恶税。”

0

评论 (0)

取消