在 JavaScript 中编写异步构造函数的正确方法
异步构造函数???
关于constructor
解决方法#1:延迟初始化
解决方法2:防御性编程
解决方案:静态异步工厂函数!
实践
结论
异步构造函数???
在有人冲进评论区之前,我必须先强调一下,JavaScript 中目前还没有编写异步构造函数的标准化方法。不过,目前还是有一些变通方法的。有些方法还不错……但大多数都不太符合语法习惯(至少可以这么说)。
在本文中,我们将讨论各种模拟构造函数的方法的局限性。在明确了这些缺点之后,我将演示我所发现的JavaScript 中async
正确的async
构造函数模式。
关于constructor
在 ES6 之前,语言规范中没有类的概念。JavaScript 的“构造函数”只是一些与this
和密切相关的普通函数prototype
。当类最终出现时,constructor
或多或少地成为了普通构造函数的语法糖。
然而,这样做的后果是constructor
继承了旧构造函数的一些古怪行为和语义。最明显的是,从构造函数返回非原始值时,返回的是该值,而不是构造的this
对象。
假设我们有一个Person
带有私有字符串字段的类name
:
class Person {
#name: string;
constructor(name: string) {
this.#name = name;
}
}
由于constructor
隐式返回undefined
(原始值),因此new Person
返回的是新构造的this
对象。但是,如果我们返回的是对象字面量,那么除非我们以某种方式将其包含在对象字面量中,否则我们将无法再访问该this
对象。
class Person {
#name: string;
constructor(name: string) {
this.#name = name;
// This discards the `this` object!
return { hello: 'world' };
}
}
// This leads to a rather silly effect...
const maybePerson = new Person('Some Dood');
console.log(maybePerson instanceof Person); // false
如果我们想保存该this
物体,我们可以这样做:
class Person {
#name: string;
constructor(name: string) {
this.#name = name;
// This preserves the `this` object.
return { hello: 'world', inner: this };
}
get name() { return this.#name; }
}
// This leads to another funny effect...
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'
解决方法#1:延迟初始化
那么...如果可以覆盖 a 的返回类型constructor
,那么是否可以Promise
从 内部返回 a constructor
?
事实上,是的!Promise
毕竟,实例确实是非原始值。因此,constructor
会返回 ,而不是this
。
class Person {
#name: string;
constructor() {
// Here, we simulate an asynchronous task
// that eventually resolves to a name...
return Promise.resolve('Some Dood')
.then(name => {
// NOTE: It is crucial that we use arrow
// functions here so that we may preserve
// the `this` context.
this.#name = name;
return this;
});
}
}
// We overrode the `constructor` to return a `Promise`!
const pending = new Person;
console.log(pending instanceof Promise); // true
console.log(pending instanceof Person); // false
// We then `await` the result...
const person = await pending;
console.log(person instanceof Promise); // false
console.log(person instanceof Person); // true
// Alternatively, we may directly `await`...
const anotherPerson = await new Person;
console.log(anotherPerson instanceof Promise); // false
console.log(anotherPerson instanceof Person); // true
我们基本上实现了延迟初始化!虽然这个解决方法模拟了async constructor
,但它确实存在明显的缺点:
解决方法2:防御性编程
由于覆盖constructor
语义上存在问题,或许我们应该使用一些“状态机式”的包装器,其中constructor
仅仅是状态机的“入口点”。然后,我们需要用户调用其他“生命周期方法”来完全初始化该类。
class Person {
/**
* Observe that the field may now be `undefined`.
* This encodes the "pending" state at the type-level.
*/
this.#name: string | null;
/** Here, we cache the ID for later usage. */
this.#id: number;
/**
* The `constructor` merely constructs the initial state
* of the state machine. The lifecycle methods below will
* drive the state transitions forward until the class is
* fully initialized.
*/
constructor(id: number) {
this.#name = null;
this.#id = id;
}
/**
* Observe that this extra step allows us to drive the
* state machine forward. In doing so, we overwrite the
* temporary state.
*
* Do note, however, that nothing prevents the caller from
* violating the lifecycle interface. That is, the caller
* may invoke `Person#initialize` as many times as they please.
* For this class, the consequences are trivial, but this is not
* always true for most cases.
*/
async initialize() {
const db = await initializeDatabase();
const data = await db.fetchUser(this.#id);
const result = await doSomeMoreWork(data);
this.#name = await result.text();
}
/**
* Also note that since the `name` field may be `undefined`
* at certain points of the program, the type system cannot
* guarantee its existence. Thus, we must employ some defensive
* programming techniques and assertions to uphold invariants.
*/
doSomethingWithName() {
if (!this.#name) throw new Error('not yet initialized');
// ...
}
/**
* Note that the getter may return `undefined` with respect
* to pending initialization. Alternatively, we may `throw`
* an exception when the `Person` is not yet initialized,
* but this is a heavy-handed approach.
*/
get name() { return this.#name; }
}
// From the caller's perspective, we just have to remember
// to invoke the `initialize` lifecycle method after construction.
const person = new Person(1234567890);
await person.initialize();
console.assert(person.name);
与之前的解决方法一样,这也有一些明显的缺点:
- 在调用站点产生详细的初始化。
- 要求调用者熟悉类的生命周期语义和内部结构。
- 需要大量关于如何正确初始化和使用该类的文档。
- 涉及生命周期不变量的运行时验证。
- 使得界面更难维护、更不符合人体工程学,并且更容易被误用。
解决方案:静态异步工厂函数!
有趣的是,最好的根本就async
constructor
不是constructor
!
在第一个解决方法中,我暗示了 可以constructor
返回任意非原始对象。这允许我们将this
对象包装在 中,Promise
以适应延迟初始化。
然而,一切都崩溃了,因为这样做,我们违反了 a 的典型语义constructor
(即使标准允许)。
那么...我们为什么不直接使用常规函数呢?
确实,这就是解决方案!我们只需坚持 JavaScript 的函数式本质。我们不再将工作委托给,而是async
通过某个工厂函数间接调用。3实践中:constructor
constructor
async
static
class Person {
#name: string;
/**
* NOTE: The constructor is now `private`.
* This is totally optional if we intend
* to prevent outsiders from invoking the
* constructor directly.
*
* It must be noted that as of writing, private
* constructors are a TypeScript-exclusive feature.
* For the meantime, the JavaScript-compatible equivalent
* is the @private annotation from JSDoc, which should
* be enforced by most language servers. See the annotation
* below for example:
*
* @private
*/
private constructor(name: string) {
this.#name = name;
}
/**
* This static factory function now serves as
* the user-facing constructor for this class.
* It indirectly invokes the `constructor` in
* the end, which allows us to leverage the
* `async`-`await` syntax before finally passing
* in the "ready" data to the `constructor`.
*/
static async fetchUser(id: number) {
// Perform `async` stuff here...
const db = await initializeDatabase();
const data = await db.fetchUser(id);
const result = await doSomeMoreWork(data);
const name = await result.text();
// Invoke the private constructor...
return new Person(name);
}
}
// From the caller's perspective...
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;
/**
* Once again, we set the `constructor` to be private.
* This ensures that all consumers of this class will use
* the factory function as the entry point.
*/
private constructor(accessToken: string, refreshToken: string) {
this.#access = accessToken;
this.#refresh = refreshToken;
}
/**
* Exchanges the authorization code for an access token.
* @param code - The authorization code from 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);
}
}
// From the caller's perspective...
const client = await Spotify.initialize('authorization-code-here');
console.assert(client instanceof Spotify);
请注意,与第二种解决方法不同,访问令牌的存在是在类型级别强制执行的。无需进行类似状态机的验证和断言。我们可以放心,当我们实现类的方法时Spotify
,访问令牌字段的构造是正确的——没有任何附加条件!
结论
工厂函数模式static
async
允许我们在 JavaScript 中模拟异步构造函数。该模式的核心是间接调用constructor
。这种间接性强制要求传递给 的任何参数在类型级别上constructor
都已准备就绪且正确。它实际上是延迟初始化加上一层间接调用。
该模式还解决了以前解决方法的所有缺陷。
- 允许
async
-await
语法。 - 提供符合人体工程学的界面入口点。
- 通过构造(通过类型推断)强制执行正确性。
- 不需要了解生命周期和类内部知识。
不过,这种模式确实有一个小缺点。典型的做法constructor
是为对象初始化提供一个标准接口。也就是说,我们只需调用new
操作符即可构造一个新对象。然而,使用工厂函数时,调用者必须熟悉类的正确入口点。
坦白说,这不是什么问题。快速浏览一下文档就足以引导用户找到正确的方向。4需要特别注意的是,调用构造函数时应该会抛出一个编译器/运行时错误,提示用户使用提供的private
静态工厂函数来初始化类。
总而言之,在所有变通方案中,工厂函数是最符合语言习惯、最灵活、最不具侵入性的。我们应该避免将async
工作委托给 ,constructor
因为它的设计初衷就不是针对这种用例。此外,我们也应该避免使用状态机和复杂的生命周期,因为它们处理起来太过繁琐。相反,我们应该拥抱 JavaScript 的函数式本质,并使用工厂函数。
-
在代码示例中,这是通过箭头函数实现的。由于箭头函数没有绑定
this
,因此它们会继承this
其封闭作用域的绑定 。↩ -
也就是说,TypeScript 语言服务器错误地推断
new Person
为 类型Person
,而不是 类型Promise<Person>
。当然,这不完全是一个 bug,因为 本来constructor
就不是用来这样使用的 。↩ -
粗略地说,工厂函数是一个返回新对象的函数。在引入类之前,工厂函数通常返回对象字面量。除了传统的构造函数之外,这是参数化对象字面量的一种无附加条件的方式 。↩
-
事实上,Rust 生态系统就是这样做的。Rust 中没有构造函数。初始化对象的实际方式要么直接通过
struct
表达式(即对象字面量),要么间接通过工厂函数。没错,就是工厂函数! ↩