原始地址:https://dev.to/somedood/the-proper-way-to-write-async-constructors-in-javascript-1o8c
‘’'#
异步构造函数???
在任何人迅速进入评论区之前,我必须强调这篇文章是为了强调目前 JavaScript 中没有标准化的编写异步构造函数的方法。然而,在目前情况下,有一些解决方法。其中一些方法不错…但大多数方法都相当非常规的(最不济)。
在本文中,我们将讨论我们尝试模拟异步构造函数的不同方式的限制。一旦我们确定了这些缺点,我将演示我在 JavaScript 中找到的恰当的异步构造函数模式。
关于
构造函数的快速入门
在 ES6 之前,语言规范中没有类的概念。相反,JavaScript "构造函数"只是一些与
this 和
prototype 密切相关的普通函数。当类最终出现时,构造函数实际上是对普通构造函数的语法糖。
然而,这会导致构造函数继承了一些旧构造函数的奇怪行为和语义。最值得注意的是,从构造函数返回一个
而不是构造函数返回相应的新构建的值。
this 对象
假设我们有一个带有私有字符串字段
name 的
Person 类:
class Person {
#name: string;
constructor(name: string) {
this.#name = name;
}
}
由于构造函数隐式地返回
undefined(即原始值),因此
new Person 返回新构造的
this 对象。然而,如果我们返回一个对象字面量,那么除非我们将它包含在对象字面量中,否则我们将无法再访问到
this 对象。
class Person {
#name: string;
constructor(name: string) {
this.#name = name;
// 这将丢弃 this
对象!
return { hello: ‘world’ };
}
}
// 这会导致一个非常愚蠢的效果…
const maybePerson = new Person(‘Some Dood’);
console.log(maybePerson instanceof Person); // false
如果我们打算保留
this 对象,可以这样做:
class Person {
#name: string;
constructor(name: string) {
this.#name = name;
// 这将保留 this
对象。
return { hello: ‘world’, inner: this };
}
get name() { return this.#name; }
}
// 这会导致另一种有趣的效果…
const maybePerson = new Person(‘Some Dood’);
console.log(maybePerson instanceof Person); // false
console.log(maybePerson.inner instanceof Person); // true
console.log(maybePerson.name); // undefined
console.log(maybePerson.inner.name); // ‘Some Dood’
解决方法一:延迟初始化
那么…如果可以覆盖
构造函数的返回类型,那么是否可以在
构造函数内部返回一个
Promise 呢?
事实上,是的!
Promise 实例确实是一个非基本值。因此,构造函数将返回
Promise 对象而不是
this 对象。
class Person {
#name: string;
constructor() {
// 这里我们模拟了一个最终会解析为名字的异步任务…
return Promise.resolve(‘Some Dood’)
.then(name => {
// 注意:这里必须使用箭头函数以便保留
// this
上下文。
this.#name = name;
return this;
});
}
}
// 我们重写了 constructor
来返回一个 Promise
!
const pending = new Person;
console.log(pending instanceof Promise); // true
console.log(pending instanceof Person); // false
// 然后我们await
结果…
const person = await pending;
console.log(person instanceof Promise); // false
console.log(person instanceof Person); // true
// 或者,我们可以直接await
…
const anotherPerson = await new Person;
console.log(anotherPerson instanceof Promise); // false
console.log(anotherPerson instanceof Person); // true
我们实际上实现了延迟初始化!尽管此解决方法模拟了一个
异步构造函数,但它的缺点相当明显:
- 不支持
async-
await 语法。 - 需要手动
chaining promises。 - 需要仔细保留
this 上下文。
1 - 违反了类型推断提供者的许多假设。
2 - 覆盖了(出乎意料的)
构造函数的默认行为,非常不符合
预期,也非常不符合惯例。
解决方法二:防御性编程
由于覆盖
构造函数在语义上有问题,也许我们应该使用一些 "状态机式 "的包装器,其中
构造函数只是一个"入口点"进入状态机。然后,我们需要用户调用其他 "生命周期方法"来完全初始化类。
class Person {
/**
- 注意字段现在可能为
undefined
。 - 这在类型级别上编码了 “待定” 状态。
/
this.#name: string | null;
/* 这里我们缓存 ID 以供稍后使用。*/
this.#id: number;
/** - 这个
constructor
只是构造状态机的初始状态。 - 下面的生命周期方法将推动状态转换直到类完全初始化。
/
constructor(id: number) {
this.#name = null;
this.#id = id;
}
/* - 注意,此额外的步骤允许我们推动状态机向前。这样做会覆盖临时状态。
- 请注意,没有什么可以阻止调用者违反生命周期接口。也就是说,调用者可以随意调用
Person#initialize
。对于这个类来说,后果微乎其微,但对于大多数情况来说,并非总是如此。
/
async initialize() {
const db = await initializeDatabase();
const data = await db.fetchUser(this.#id);
const result = await doSomeMoreWork(data);
this.#name = await result.text();
}
/* - 还要注意,由于
name
字段在程序的某些点可能为undefined
,类型系统无法保证其存在。因此,我们必须采用一些防守性编程技术和断言来维护不变量。
/
doSomethingWithName() {
if (!this.#name) throw new Error(‘not yet initialized’);
// …
}
/* - 注意,getter 可能与挂起的初始化相关联返回
undefined
。或者,我们可以在未初始化Person
时throw
一个异常,但这是一种重型方法。
*/
get name() { return this.#name; }
}
// 从调用者的角度来看,我们只需要记住在构造后调用initialize
生命周期方法。
const person = new Person(1234567890);
await person.initialize();
console.assert(person.name);
与第二种解决方法一样,这种方法也有一些明显的缺点:
- 在调用点产生了冗长的初始化代码。
- 要求调用者熟悉类的生命周期语义和内部。
- 需要详细的文档说明如何正确初始化和使用类。
- 需要运行时验证生命周期不变量。
- 使接口不易维护、不符合人体工程学,并容易误用。
解决方法:静态异步工厂函数!
相当有趣的是,最好的
异步
构造函数实际上不是
构造函数!
在第一个解决方法中,我暗示了如何使
构造函数返回任意的非基元对象。这使得我们能够在一个
Promise 中包装
this 对象,以适应延迟初始化。
然而,一切都崩溃了,因为这样做违反了
构造函数的典型语义(即使标准允许这样做)。
所以…为什么我们不只是使用一个普通的函数呢?
确实,这就是解决方案!我们只需坚持 JavaScript 的函数式根源。我们不再将
异步工作委托给
构造函数,而是通过一些
异步
静态
工厂函数间接调用
构造函数。在实践中:3
class Person {
#name: string;
/**
- 注意:构造函数现在是
private
的。 - 如果目标是防止外部调用构造函数,
- 这是完全可选的。
- 应当注意的是,截至撰写本文时,私有构造函数是 TypeScript 的专属特性。
- 目前,与 JavaScript 兼容的等效方法是使用 JSDoc 的 @private 注释,大多数语言服务器都会强制执行该注释。例如,请参考以下注释示例:
- @private
/
private constructor(name: string) {
this.#name = name;
}
/* - 此静态工厂函数现在作为该类的用户接口构造函数。
- 它在最后间接调用了
constructor
,这样我们就可以在最后使用async
-await
语法,并将“准备好”的数据传递给constructor
。
*/
static async fetchUser(id: number) {
// 在这里执行async
的工作…
const db = await initializeDatabase();
const data = await db.fetchUser(id);
const result = await doSomeMoreWork(data);
const name = await result.text();
// 调用私有构造函数…
return new Person(name);
}
}
// 从调用者的角度来看…
const person = await Person.fetchUser(1234567890);
console.log(person instanceof Person); // true
考虑到我的矫揉造作的例子,这种模式一开始可能不那么强大。但是,当应用到实际的构造中,例如数据库连接、用户会话、API 客户端、协议握手和其他异步工作负载时,很快就会发现这种模式比之前讨论的解决方法更加具有可扩展性和惯用性。
实践中
假设我们想要编写一个
Spotify Web API 客户端,它需要一个访问令牌。根据OAuth 2.0 协议,我们必须首先获取一个授权代码,然后将其交换为访问令牌。
让我们假设我们已经拥有授权代码。使用工厂函数,可以使用授权代码作为参数初始化客户端。
const TOKEN_ENDPOINT = ‘https://accounts.spotify.com/api/token’;
class Spotify {
#access: string;
#refresh: string;
/**
- 再次,我们将
constructor
设为私有。 - 这确保此类的所有使用者都将使用
- 工厂函数作为入口点。
/
private constructor(accessToken: string, refreshToken: string) {
this.#access = accessToken;
this.#refresh = refreshToken;
}
/* - 用授权代码交换访问令牌。
- @param code - 来自 Spotify 的授权代码。
*/
static async initialize(code: string) {
const response = await fetch(TOKEN_ENDPOINT, {
method: ‘POST’,
body: new URLSearchParams({
code,
grant_type: ‘authorization_code’,
client_id: env.SPOTIFY_ID,
client_secret: env.SPOTIFY_SECRET,
redirect_uri: env.OAUTH_REDIRECT,
}),
});
const { access_token, refresh_token } = await response.json();
return new Spotify(access_token, refresh_token);
}
}
// 从调用者的角度来看…
const client = await Spotify.initialize(‘authorization-code-here’);
console.assert(client instanceof Spotify);
请注意,与第二种解决方法不同,访问令牌的存在在类型级别上是强制的。无需状态机式的验证和断言。我们可以放心地在实现
Spotify 类的方法时,访问令牌字段是
通过构造正确的—没有任何附加条件的!
结论
静态异步工厂函数允许我们在 JavaScript 中模拟异步构造函数。这种模式的核心是对
constructor 的间接调用。间接性质使得构造函数中传入的任何参数在类型级别上都是
“准备好”且“正确”。这实际上是延迟初始化再加上一级间接调用。
此模式还解决了以前的解决方法的所有缺点。
- 支持
async-
await 语法。 - 为接口提供了一种符合人体工程学的入口点。
- 通过构造正确保证正确性(通过类型推断)。
- 不需要了解生命周期和类的内部。
当然,这种模式也有一个小缺点。典型的
构造函数提供了一个标准的对象初始化接口。也就是说,我们只需使用
new 运算符调用
构造函数来构造一个新对象。然而,使用工厂函数时,调用者必须熟悉类的正确入口点。
坦率地说,这不是一个问题。稍微浏览一下文档应该足以将用户引导到正确的方向。
为了更加小心,调用一个 4
private 构造函数应该会发出一个编译器/运行时错误,告知用户使用提供的静态工厂函数初始化类。
总之,在所有的解决方法中,工厂函数是最符合惯用法、灵活性最高、最不侵入性的。我们应该避免将
异步工作委托给
构造函数,因为它从来没有为那个用例设计过。此外,我们应该避免状态机和复杂的生命周期,因为它们太复杂了。相反,我们应该拥抱 JavaScript 的函数式根源,使用工厂函数。
在代码示例中,通过箭头函数实现了这一点。
因为箭头函数没有的概念,它们继承了父作用域的
this 绑定
↩
即 TypeScript 语言服务器错误地认为
new Person 是类型
Person 而不是类型
Promise。当然,这不算是一个错误,因为
构造函数本来就不应该被用在这种情况下。
↩
粗略地说,工厂函数是返回一个新对象的函数。在类引入之前,工厂函数通常返回对象字面量。除了传统的构造函数以外,这是一种无需任何条件地参数化对象字面量的方法。
↩
实际上,在 Rust 生态系统中就是这样做的。在 Rust 中,没有构造函数这样的东西。初始化对象的事实上的方式是通过结构体表达式的直接方式实现
(即对象字面量)或通过间接方式实现,即通过工厂函数。是的,工厂函数!
↩
‘’’