查看原文
其他

TypeScript 5.2 发布,支持显式资源管理!

CUGGZ 前端充电宝 2023-10-06

全文约 6400 字,预计阅读需要 20 分钟。

根据 TypeScript 路线图,TypeScript 5.2 计划于 8.22 发布。下面就来看看该版本都带来了哪些新特性!

以下是 TypeScript 5.2 新增的功能:

  • using 声明和显式资源管理

  • 装饰器元数据

  • 命名和匿名元组元素

  • 联合类型数组方法调用

  • 对象成员的逗号自动补全

  • 内联变量重构

  • 重大变更和正确性修复

using 声明和显式资源管理

TypeScript 5.2 添加了对 ECMAScript 中即将推出的显式资源管理功能的支持。

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

假如有一个函数,它创建了一个临时文件,通过各种操作读写该文件,然后关闭并删除它。

import * as fs from "fs";

export function doSomeWork() {
const path = ".some_temp_file";
const file = fs.openSync(path, "w+");

// 操作文件...

// 关闭文件并删除它
fs.closeSync(file);
fs.unlinkSync(path);
}

那如果需要提前退出怎么办?

export function doSomeWork() {
const path = ".some_temp_file";
const file = fs.openSync(path, "w+");

// 操作文件...
if (someCondition()) {
// 其他操作...

// 关闭文件并删除它
fs.closeSync(file);
fs.unlinkSync(path);
return;
}

// 关闭文件并删除它
fs.closeSync(file);
fs.unlinkSync(path);
}

可以看到,这里就出现了重复的清理工作。如果抛出错误,我们也不保证关闭并删除文件。这可以通过将这一切包装在 try/finally 块中来解决。

export function doSomeWork() {
const path = ".some_temp_file";
const file = fs.openSync(path, "w+");

try {
// 操作文件...

if (someCondition()) {
// 其他操作...
return;
}
}
finally {
// 关闭文件并删除它
fs.closeSync(file);
fs.unlinkSync(path);
}
}

这样写虽然没有什么大问题,但是会让代码变得复杂。如果我们向finally块添加更多清理逻辑,还可能遇到其他问题,比如异常阻止了其他资源的释放。 这就是显式资源管理提案旨在解决的问题。该提案的关键思想是将资源释放(要处理的清理工作)作为 JavaScript 中的一等公民来支持。

这一功能的实现方式是引入一个名为 Symbol.dispose 的新内置 symbol,并且可以创建具有以 Symbol.dispose 命名的方法的对象。为了方便起见,TypeScript 定义了一个名为 Disposable 的新全局类型,用于描述这些对象。

class TempFile implements Disposable {
#path: string;
#handle: number;

constructor(path: string) {
this.#path = path;
this.#handle = fs.openSync(path, "w+");
}

// 其他操作

[Symbol.dispose]() {
// 关闭文件并删除它
fs.closeSync(this.#handle);
fs.unlinkSync(this.#path);
}
}

然后,可以调用这些方法:

export function doSomeWork() {
const file = new TempFile(".some_temp_file");

try {
// ...
}
finally {
file[Symbol.dispose]();
}
}

将清理逻辑移到 TempFile 本身并不能带来很大的好处;基本上只是将所有的清理工作从 finally 块中移到一个方法中,而这之前这就是可以实现的。但是,有一个众所周知的方法名称意味着 JavaScript 可以在其之上构建其他功能。

这就引出了该特性的第一个亮点:使用 using 声明!using 是一个新的关键字,可以声明新的固定绑定,有点类似于 const。关键的区别在于,使用 using 声明的变量在作用域结束时会调用其 Symbol.dispose 方法!

所以我们可以简单地编写这段代码:

export function doSomeWork() {
using file = new TempFile(".some_temp_file");

// 操作文件...

if (someCondition()) {
// 其他操作...
return;
}
}

可以看到,已经没有了  try/finally 块,从功能上来说,这就是 using 声明为我们做的事情,但我们不必处理其中的细节。

如果你对 C# 的 using 声明、Python 的 with 语句或者 Java 的 try-with-resource 声明比较熟悉。就会发现,JavaScript 的新关键字 using 和它们类似,并提供了一种显式的在作用域结束时执行对象的 "清理" 操作的方式。

**using**** 声明会在其所属的作用域的最后,或者在出现 "早期返回"(如 return 或抛出错误)之前进行清理操作。它们还按照先进后出的顺序(类似于栈)进行释放。**

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;

// 不会执行
// 不会创建,不会处置
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 方法本身也可能会抛出错误。如果在资源释放期间发生了错误,那么这个错误也会被重新抛出。

但是,如果在清理之前和清理期间的逻辑都抛出错误会怎样呢?为了处理这些情况,引入了一个新的 Error 子类型,名为 SuppressedError。它具有一个 suppressed 属性,用于保存最后抛出的错误,以及一个 error 属性,用于保存最近抛出的错误。

当在处理资源时发生多个错误时,SuppressedError 类型的错误对象可以保留最新的错误,并将之前发生的错误标记为被压制的错误。这种机制允许我们更好地跟踪和处理多个错误的情况。

假设在进行资源清理时,首先发生了一个错误A,然后又发生了一个错误B。在使用 SuppressedError 错误对象时,它会将错误B作为最新错误记录,并将错误A标记为被压制的错误。这样一来,我们可以通过 SuppressedError 对象获取到两个错误的相关信息,从而更全面地了解发生的错误情况。

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 的新 symbol,并且带来了一个新特性:await using 声明。它们与 using 声明类似,但区别在于它们会查找需要等待其释放的对象。它们使用由 Symbol.asyncDispose 命名的不同方法,尽管也可以操作具有 Symbol.dispose 的对象。为了方便起见,TypeScript 还引入了一个全局类型 AsyncDisposable,用于描述具有异步释放方法的对象。

async function doWork() {
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;

// 不会执行
// 不会创建,不会处置
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 的例子:

class TempFile implements Disposable {
#path: string;
#handle: number;

constructor(path: string) {
this.#path = path;
this.#handle = fs.openSync(path, "w+");
}

// 其他操作

[Symbol.dispose]() {
// 关闭文件并清理它
fs.closeSync(this.#handle);
fs.unlinkSync(this.#path);
}
}

export function doSomeWork() {
using file = new TempFile(".some_temp_file");

// 操作文件...

if (someCondition()) {
// 其他操作...
return;
}
}

我们只是想记住调用两个函数,但是这种写法是最好的吗?我们应该在构造函数中调用 openSync、创建一个 open() 方法,还是自己传入处理方法?应该为每个可能的操作都暴露一个方法,还是将属性设为 public

这就引出了新特性的主角:DisposableStackAsyncDisposableStack。这些对象非常适用于一次性的清理操作,以及任意数量的清理工作。DisposableStack 是一个对象,它具有多个用于跟踪 Disposable 对象的方法,并且可以接收执行任意清理工作的函数。同时,我们也可以将 DisposableStack 分配给 using 变量,这意味着我们可以将其用于资源管理,并在使用完成后自动释放资源。这是因为 DisposableStack 本身也实现了 Disposable 接口,所以可以像使用其他 Disposable 对象一样使用它。

下面是改写原例子的方式:

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);
});

// 操作文件...

if (someCondition()) {
// 其他操作...
return;
}

// ...
}

这里的defer() 方法接受一个回调函数,该回调函数将在清理被释放时运行。通常情况下,defer()(以及其他类似的 DisposableStack 方法,如 useadopt)应该在创建资源后立即调用。正如其名称所示,DisposableStack 以栈的方式处理它所跟踪的所有内容,按照先进后出的顺序进行清理,因此立即在创建值后延迟执行可帮助避免奇怪的依赖问题。AsyncDisposable 的工作原理类似,但可以跟踪异步函数和 AsyncDisposable,并且本身也是一个 AsyncDisposable

由于这个特性非常新,大多数运行时环境不会原生支持它。要使用它,需要为以下内容提供运行时的 polyfills:

  • Symbol.dispose

  • Symbol.asyncDispose

  • DisposableStack

  • AsyncDisposableStack

  • SuppressedError

然而,如果你只关注使用 usingawait using,只需要提供内置 symbol 的 polyfill 就足够了。对于大多数情况,下面这样简单的实现应该可以工作:

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

此外,还需要将编译目标设置为 es2022 或更低,并在 lib 设置中配置 "esnext""esnext.disposable"

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

装饰器元数据

TypeScript 5.2 实现了一个即将推出的 ECMAScript 功能,称为装饰器元数据。这个功能的关键思想是让装饰器能够在它们所用于或内嵌的任何类上轻松创建和使用元数据。

无论何时使用装饰器函数,它们都可以在上下文对象的新metadata属性上进行访问。metadata属性仅包含一个简单的对象。由于JavaScript允许我们任意添加属性,它可以被用作一个由每个装饰器更新的字典。另外,由于每个装饰部分的元数据对象将对于类的每个装饰部分都是相同的,它可以作为一个Map的键。当类上的所有装饰器都执行完毕后,可以通过Symbol.metadata从类上访问该对象。

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 }

这在许多不同的场景中都非常有用。元数据可以用于许多用途,例如调试、序列化或者在使用装饰器进行依赖注入时。由于每个被装饰的类都会创建相应的元数据对象,框架可以将它们作为私有的键入到 Map 或 WeakMap 中,或者根据需要添加属性。

举个例子,假设想要使用装饰器来跟踪哪些属性和访问器在使用JSON.stringify进行序列化时是可序列化的,代码示例如下:

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,后者使用了 @serialize 创建的元数据。

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 来存储元数据。WeakMap 是一种特殊的 Map 数据结构,它的键只能是对象,并且不会阻止对象被垃圾回收。在这种情况下,我们可以使用每个对象的原型作为键,将元数据存储在对应的 WeakMap 实例中。这样,只有持有 WeakMap 实例的代码才能够访问和操作元数据,确保了数据的私密性,也减少了在代码中进行类型断言的次数。

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。下面这个简单的示例应该适用于大多数情况:

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

此外,还需要将编译目标设置为 es2022 或更低,并在 lib 设置中配置 "esnext""esnext.disposable"

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

命名和匿名元组元素

元组类型支持为每个元素提供可选的标记或名称。

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

这些标记并不会改变对元组的操作能力,它们仅仅是为了增加可读性和工具支持。

然而,在以前的 TypeScript 版本中,有一个规则是元组不能在标记和非标记元素之间混合使用。换句话说,要么所有的元素都不带标记,要么所有的元素都需要带标记。

// ✅ 没有标记
type Pair1<T> = [T, T];

// ✅ 都有标记
type Pair2<T> = [first: T, second: T];

// ❌
type Pair3<T> = [first: T, T];
// ~
// Tuple members must all have names or all not have names.

对于剩余元素,这可能会变得有些麻烦,因为只能强制添加一个标签,比如"rest"或"tail"。

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 将丢失标记。

正如之前提到的,为了确保在元组类型中所有元素要么都带有标签,要么都不带标签的规则,TypeScript 在类型系统内部进行了相应的限制。这意味着 TypeScript 在类型检查过程中会忽略元组元素的标签信息,对于类型系统而言,元组中的元素只被视为按照它们的顺序排列的一组类型。

因此,尽管您在定义元组类型时可以使用标签,但在类型检查和类型推断过程中,TypeScript 不会考虑这些标签。只有元组元素的顺序和类型才会被 TypeScript 确认和验证。

这样做是为了确保遵守 TypeScript 的语法规则并维持类型系统的一致性。尽管在类型定义中可能会丢失标签信息,但这不会影响元组的使用和功能。

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 中,对元组标记的全有或全无限制已经被解除,可以更好地处理带有标签的元组和展开操作。现在,可以在定义元组时为每个元素指定一个标记,并且在展开操作中保留这些标记。

联合类型数组方法调用

在之前的 TypeScript 版本中,数组联合类型调用方法可能会导致一些问题。

declare let array: string[] | number[];

array.filter(x => !!x);
// ~~~~~~ error!

报错如下:

此表达式不可调用。
联合类型 "{ <S extends string>(predicate: (value: string, index: number, array: string[]) => value is S, thisArg?: any): S[]; (predicate: (value: string, index: number, array: string[]) => unknown, thisArg?: any): string[]; } | { ...; }" 的每个成员都有签名,但这些签名都不能互相兼容。ts(2349)

在 TypeScript 5.2 中,对于数组联合类型的方法调用进行了特殊处理。在之前的版本中,TypeScript 会尝试确定每个数组类型的方法是否在整个联合类型上都兼容。然而,由于缺乏一种一致的策略,TypeScript 在这些情况下往往束手无策。

在 TypeScript 5.2 中,对于数组的联合类型,会采取一种特殊的处理方式。首先,根据每个成员的元素类型构造一个新的数组类型,然后在该类型上调用方法。

以上面的例子为例,string[] | number[] 被转换为 (string | number)[](或者 Array<string | number>),然后在该类型上调用 filter 方法。需要注意的是,filter 方法将产生一个 Array<string | number> 而不是 string[] | number[],但是针对一个全新生成的值,出错的风险较小。

这意味着在 TypeScript 5.2 中,许多像 filterfindsomeeveryreduce 这样的方法都可以在数组联合类型上调用,而在之前的版本中则无法实现。

对象成员的逗号自动补全

在向对象添加新属性时,很容易忘记添加逗号。以前,如果忘记了逗号并请求自动补全,TypeScript 会给出与此无关的糟糕的补全结果,容易令人困惑。

在 TypeScript 5.2 中,当忘记逗号时,它会优雅地提供对象成员的补全建议。为了避免直接抛出语法错误,它还会自动插入缺失的逗号。

内联变量重构

TypeScript 5.2 现在提供了一种重构功能,可以将一个变量的内容复制并内联到该变量在代码中的所有使用位置。

通常情况下,如果要替换一个变量的使用点,我们需要手动复制变量的内容并替换每个使用点。而通过这个重构功能,TypeScript 可以自动完成这个过程,将变量的值内联到其所有使用位置,从而简化了代码的修改过程。


"内联变量"重构操作会将变量的值直接替换到变量的所有使用位置,从而消除了中间的变量。然而,这种操作可能会改变代码的执行顺序和逻辑,因为原本是通过变量来保存并复用值的地方,现在变成了每次都重新计算并使用初始化器的值。

这可能会导致一些意想不到的问题,特别是如果初始化器有副作用(例如修改其他变量或调用函数)时。因此,使用"内联变量"重构时需要谨慎,并确保了解代码中可能发生的变化和影响。

重大变更和正确性修复

lib.d.ts 更改

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

#labeledElementDeclarations 可能包含未定义的元素

为了支持混合使用带标签和未带标签的元素,TypeScript 的 API 发生了细微的变化。 TupleTypelabelsElementDeclarations 属性可能在元素未标记的每个位置保持未定义。

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

#module#moduleResolution 必须在最近的 Node.js 设置下匹配

-module--moduleResolution 选项均支持 node16 和 nodenext 设置。 这些实际上是“现代 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 一次被声明为导出函数,另一次被声明为未导出的命名空间,TypeScript 将不会发出错误。

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.

参考:https://devblogs.microsoft.com/typescript/announcing-typescript-5-2-rc/

往期推荐

Hello Monorepo

5个前端开源项目带你在Web上体验Windows

程序员必读:提问的智慧,告诉你应如何提问!

前端整洁架构

前端如何安全的渲染HTML字符串?

如何在页面上优雅的展示代码?

JavaScript日期时间操作完整指南!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存