异步/等待类构造函数

404

目前,我正在尝试在类构造函数中使用async/await。这是为了让我能够为我正在开发的Electron项目获取自定义的电子邮件标签。

customElements.define('e-mail', class extends HTMLElement {
  async constructor() {
    super()

    let uid = this.getAttribute('data-uid')
    let message = await grabUID(uid)

    const shadowRoot = this.attachShadow({mode: 'open'})
    shadowRoot.innerHTML = `
      <div id="email">A random email message has appeared. ${message}</div>
    `
  }
})

目前该项目无法运行,出现以下错误:

Class constructor may not be an async method

有没有办法规避这个问题,让我能在其中使用async/await?而不是需要回调函数或.then()?


12
构造函数的目的是为您分配一个对象并立即返回。您能否更加具体地说明一下为什么您认为您的构造函数应该是异步的?因为我们几乎可以肯定正在处理一个 XY 问题 - Mike 'Pomax' Kamermans
12
很有可能。基本上,我需要查询数据库以获取加载此元素所需的元数据。查询数据库是一个异步操作,因此在构建元素之前,我需要等待它完成的某种方法。我不想使用回调函数,因为我已经在项目的其余部分中使用了await/async,并希望保持连续性。 - Alexander Craggs
4
最好不要将构造函数设置为异步。创建一个同步的构造函数来返回对象,然后使用像.init()这样的方法来执行异步操作。另外,由于你正在子类化HTMLElement,很可能使用此类的代码并不知道它是异步的,因此你可能需要寻找完全不同的解决方案。 - jfriend00
@PopeyGilbert,请在您的帖子中添加这些详细信息(不要将它们作为“编辑”添加,而是自然地融入问题中)。同时,回答即将到来。 - Mike 'Pomax' Kamermans
显示剩余3条评论
22个回答

535

这永远行不通。

async关键字允许在被标记为async的函数中使用await,但它也将该函数转换成一个Promise生成器。因此,标记为async的函数将返回一个Promise。而构造函数则返回正在构造的对象。因此我们有了一种既想返回对象又想返回Promise的情况:这是不可能的。

只能在可以使用promise的地方使用async/await,因为它们本质上是promise的语法糖。在构造函数中无法使用Promise,因为构造函数必须返回要构造的对象,而不是Promise。

有两种设计模式可以克服这个问题,它们都是在Promise出现之前发明的。

  1. 使用init()函数,这与jQuery的.ready()函数有些相似。您创建的对象只能在其自己的initready函数中使用:

用法:

    var myObj = new myClass();
    myObj.init(function() {
        // inside here you can use myObj
    });

实现:

    class myClass {
        constructor () {

        }

        init (callback) {
            // do something async and call the callback:
            callback.bind(this)();
        }
    }
  1. 使用构造器。我在javascript中很少看到这种用法,但在Java中,当需要异步构建对象时,这是更常见的解决方案之一。当然,构建器模式用于构建需要大量复杂参数的对象。这正是异步构建器的用例。区别在于异步构建器不返回一个对象,而是该对象的promise:

用法:

    myClass.build().then(function(myObj) {
        // myObj is returned by the promise, 
        // not by the constructor
        // or builder
    });

    // with async/await:

    async function foo () {
        var myObj = await myClass.build();
    }

实现:

    class myClass {
        constructor (async_param) {
            if (typeof async_param === 'undefined') {
                throw new Error('Cannot be called directly');
            }
        }

        static build () {
            return doSomeAsyncStuff()
               .then(function(async_result){
                   return new myClass(async_result);
               });
        }
    }

使用 async/await 实现:

    class myClass {
        constructor (async_param) {
            if (typeof async_param === 'undefined') {
                throw new Error('Cannot be called directly');
            }
        }

        static async build () {
            var async_result = await doSomeAsyncStuff();
            return new myClass(async_result);
        }
    }

注意:尽管在上面的示例中我们使用了Promise作为异步构建器,但它们并不是严格必要的。您也可以轻松编写一个接受回调函数的构建器。


关于在静态函数中调用函数的说明。

这与异步构造函数没有任何关系,而是与关键字 this 的实际含义有关(对于来自可以自动解析方法名称的语言的人来说,即不需要 this 关键字的语言,可能会感到有些惊讶)。

关键字 this 指的是被实例化的对象,而不是类。因此,在静态函数中通常无法使用 this ,因为静态函数未绑定到任何对象,而是直接绑定到类。

也就是说,在下面的代码中:

class A {
    static foo () {}
}

你不能做以下事情:

var a = new A();
a.foo() // NOPE!!

相反,您需要将其称为:

A.foo();

因此,以下代码将导致错误:

class A {
    static foo () {
        this.bar(); // you are calling this as static
                    // so bar is undefinned
    }
    bar () {}
}
为了解决这个问题,您可以将bar改为普通函数或静态方法:
function bar1 () {}

class A {
    static foo () {
        bar1();   // this is OK
        A.bar2(); // this is OK
    }

    static bar2 () {}
}

1
请注意,根据注释,这是一个HTML元素,通常没有手动的init()函数,但具有与某些特定属性(如srchref,在这种情况下为data-uid)相关联的功能,这意味着使用一个setter来绑定并在每次绑定新值时启动init(可能在构造过程中也会启动,但当然不会等待结果代码路径)。 - Mike 'Pomax' Kamermans
2
如果下面的答案不够充分,你应该评论一下原因(如果有的话)。或者以其他方式解决它。 - Augie Gardner
1
@AlexanderCraggs 这只是为了方便,在回调函数中使用this指向myClass。如果你总是使用myObj而不是this,那么你就不需要它了。 - slebetman
8
目前语言存在限制,但我认为在未来我们可以像普通函数和异步函数一样使用 const a = await new A() - 7ynk3r
5
我不认为这是严格不可能的。异步函数最终仍会返回结果,只是延迟了。异步函数可以像普通函数一样愉快地返回,只需要等待即可。没有根本的不匹配。正如下面所示,有人已经解决了这个问题。 - jgmjgm
显示剩余18条评论

319

肯定可以做到这一点,方法是在构造函数中返回一个立即调用的异步函数表达式(IIAFE)。在顶层 await 可用之前,IIAFE 是一种非常常见的模式,以便在异步函数之外使用await

(async () => {
  await someFunction();
})();
我们将使用这种模式在构造函数中立即执行异步函数,并将其结果作为this返回:

// Sample async function to be used in the async constructor
async function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}


class AsyncConstructor {
  constructor(value) {
    return (async () => {

      // Call async functions here
      await sleep(500);
      
      this.value = value;

      // Constructors return `this` implicitly, but this is an IIFE, so
      // return `this` explicitly (else we'd return an empty object).
      return this;
    })();
  }
}

(async () => {
  console.log('Constructing...');
  const obj = await new AsyncConstructor(123);
  console.log('Done:', obj);
})();

要实例化这个类,请使用:

const instance = await new AsyncConstructor(...);

对于TypeScript,您需要断言构造函数的类型是类类型,而不是返回类类型的Promise:

assert

class AsyncConstructor {
  constructor(value) {
    return (async (): Promise<AsyncConstructor> => {
      // ...
      return this;
    })() as unknown as AsyncConstructor;  // <-- type assertion
  }
}

缺点

  1. 使用异步构造函数扩展类会有一个限制。如果您需要在派生类的构造函数中调用super,则必须在不带await的情况下调用它。如果您需要使用await调用super构造函数,则会遇到TypeScript错误2337:超级调用不允许在构造函数外部或构造函数内部的嵌套函数中。
  2. 有人认为,将构造函数返回Promise是一种“不良实践”

在使用此解决方案之前,请确定是否需要扩展该类,并记录必须使用await调用构造函数。


@Downgoat:嗨,Vihan,我根据你的答案编写了一个测试用例,并在此线程中发布了它 https://dev59.com/61cQ5IYBdhLWcg3wCfWS - Juan Lanus
在具有异步构造函数的子类中,如何使用super()?是否有任何解决方案? 更具体地说:父类也具有异步构造函数。 - user2912903
@Vipulw await new ConnectwiseRestApi(options).ServiceDeskAPI => (await new ConnectwiseRestApi(options)).ServiceDeskAPI - Downgoat
@Downgoat 非常感谢,这个想法很有效。我还有一个方法调用附加在它上面,所以必须在括号外面再添加一个await。谢谢。 - Vipulw
这与将构造函数设置为异步相同,因为在两种情况下您都从构造函数返回一个承诺。因此,解决方案在异步函数中返回“this”。所以基本上,返回promise of this就像返回undefined或this一样。但是返回其他任何东西的promise将从构造函数返回一个promise,您将永远无法获取实际实例。 - jcubic
显示剩余8条评论

17

由于异步函数是 Promise,因此您可以在类上创建一个静态函数,该函数执行返回该类实例的异步函数:

class Yql {
  constructor () {
    // Set up your class
  }

  static init () {
    return (async function () {
      let yql = new Yql()
      // Do async stuff
      await yql.build()
      // Return instance
      return yql
    }())
  }  

  async build () {
    // Do stuff with await if needed
  }
}

async function yql () {
  // Do this instead of "new Yql()"
  let yql = await Yql.init()
  // Do stuff with yql instance
}

yql()

在异步函数中调用 let yql = await Yql.init()


2
这不是与slebetman的回答中描述的2017年相同的build模式吗? - Dan Dascalescu

17

与其他人说的不同,你可以让它运作。

JavaScript class可以从它们的constructor中返回几乎任何内容,甚至是另一个类的实例。 因此,您可以从类的构造函数中返回一个解析为其实际实例的Promise

以下是一个示例:

export class Foo {

    constructor() {

        return (async () => {

            // await anything you want

            return this; // Return the newly-created instance
        })();
    }
}

然后,你可以通过以下方式创建Foo的实例:

const foo = await new Foo();

1
call 的参数被忽略了,因为它是一个箭头函数。 - Robert
你说得对,@Robert。是我的错。我会在一会儿更新我的答案 - 用普通函数调用替换.call(this)调用应该没问题。谢谢你指出这个问题。 - Davide Cannizzo
1
工作得很顺利,显然这将使构造函数返回一个承诺,因此您总是需要等待。 - Daniel Black

11

权宜之计

你可以创建一个 async init() {... return this;} 方法,然后在通常情况下只使用 new MyClass() 的地方改为使用 new MyClass().init()

这种方式并不完美,因为它依赖于每个使用你的代码的人和你自己总是像这样实例化对象。但是,如果你只在代码中的特定位置或两个位置使用此对象,那么可能还可以接受。

然而,ES没有类型系统,因此如果你忘记调用它,构造函数将返回undefined。最好的做法是:

class AsyncOnlyObject {
    constructor() {
    }
    async init() {
        this.someField = await this.calculateStuff();
    }

    async calculateStuff() {
        return 5;
    }
}

async function newAsync_AsyncOnlyObject() {
    return await new AsyncOnlyObject().init();
}

newAsync_AsyncOnlyObject().then(console.log);
// output: AsyncOnlyObject {someField: 5}

工厂方法解决方案(稍微好一些)

但是,如果您意外地执行了 new AsyncOnlyObject,则应该只创建使用Object.create(AsyncOnlyObject.prototype)的工厂函数:

async function newAsync_AsyncOnlyObject() {
    return await Object.create(AsyncOnlyObject.prototype).init();
}

newAsync_AsyncOnlyObject().then(console.log);
// output: AsyncOnlyObject {someField: 5}

然而,假设您想在许多对象上使用此模式... 您可以将其抽象为装饰器或某些东西,您需要在定义后(啰嗦,呃)调用,例如 postProcess_makeAsyncInit(AsyncOnlyObject),但是在这里我将使用extends,因为它似乎符合子类语义 (子类是父类+额外内容,因此它们应遵守父类的设计契约,并可能执行其他操作;如果父类不是异步的,那么异步子类就很奇怪,因为它无法以相同的方式初始化):


抽象解决方案(扩展/子类版本)

class AsyncObject {
    constructor() {
        throw new Error('classes descended from AsyncObject must be initialized as (await) TheClassName.anew(), rather than new TheClassName()');
    }

    static async anew(...args) {
        var R = Object.create(this.prototype);
        R.init(...args);
        return R;
    }
}

class MyObject extends AsyncObject {
    async init(x, y=5) {
        this.x = x;
        this.y = y;
        // bonus: we need not return 'this'
    }
}

MyObject.anew('x').then(console.log);
// output: MyObject {x: "x", y: 5}

(不要在生产环境中使用:我没有考虑复杂的情况,比如这是否是编写关键字参数包装器的正确方法。)


5

根据您的评论,您可能应该像其他具有资产加载功能的HTMLElement一样做:使构造函数启动侧载操作,根据结果生成load或error事件。

是的,这意味着使用promises,但也意味着“以与每个其他HTML元素相同的方式进行操作”,因此您处于良好的公司。例如:

var img = new Image();
img.onload = function(evt) { ... }
img.addEventListener("load", evt => ... );
img.onerror = function(evt) { ... }
img.addEventListener("error", evt => ... );
img.src = "some url";

它会启动异步加载源资产,当成功时以 onload 结束,当发生错误时以 onerror 结束。因此,请确保你的自定义类也这样做:

class EMailElement extends HTMLElement {
  connectedCallback() {
    this.uid = this.getAttribute('data-uid');
  }

  setAttribute(name, value) {
    super.setAttribute(name, value);
    if (name === 'data-uid') {
      this.uid = value;
    }
  }

  set uid(input) {
    if (!input) return;
    const uid = parseInt(input);
    // don't fight the river, go with the flow, use a promise:
    new Promise((resolve, reject) => {
      yourDataBase.getByUID(uid, (err, result) => {
        if (err) return reject(err);
        resolve(result);
      });
    })
    .then(result => {
      this.renderLoaded(result.message);
    })
    .catch(error => {
      this.renderError(error);
    });
  }
};

customElements.define('e-mail', EmailElement);

然后您需要使渲染加载(renderLoaded)/渲染错误(renderError)函数处理事件调用和影子DOM:
  renderLoaded(message) {
    const shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.innerHTML = `
      <div class="email">A random email message has appeared. ${message}</div>
    `;
    // is there an ancient event listener?
    if (this.onload) {
      this.onload(...);
    }
    // there might be modern event listeners. dispatch an event.
    this.dispatchEvent(new Event('load'));
  }

  renderFailed() {
    const shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.innerHTML = `
      <div class="email">No email messages.</div>
    `;
    // is there an ancient event listener?
    if (this.onload) {
      this.onerror(...);
    }
    // there might be modern event listeners. dispatch an event.
    this.dispatchEvent(new Event('error'));
  }

还要注意我将您的id更改为class,因为除非您编写一些奇怪的代码仅允许页面上的单个<e-mail>元素实例,否则您不能使用唯一标识符并将其分配给一堆元素。


5
这篇文章是和编程有关的,以下是翻译的文本:

这篇文章中有很多好的(和一些不好的)笔记...但没有一个真正涵盖了整个故事和 TypeScript。这就是我的见解。

有两种解决这个问题的方法。

1. 使用闭包而不是类:

async function makeAPI() {
  await youCanGoAsyncHere()

  async function fetchFirst() {}
  async function fetchSecond() {}

  return {
    fetchFirst,
    fetchSecond,
  }
}

使用闭包复制一些继承模式可能会很混乱,但对于更简单的情况通常已经足够了。

2. 使用带有protected构造函数和init的工厂:

import * as U from "lib/utils"

class API {
  data!: number // the use of ! here is fine 
  // we marked the constructor "protected" + we call `init` in `make` 
  // assuming we don't like multiple `data?.something` checks

  protected constructor() {
    ...
  }

  protected async init() {
    await youCanGoAsyncHere()
    this.data = 123 // assume other methods depend on this data
  }

  fetchFirst() {}
  fetchSecond() {}

  static async make() {
    const api = new API()
    await api.init()
    return api
  }
}

const t = await Test.make()
console.log(t.data)

这里的主要缺点是在JS/TS中使用泛型继承静态方法有些受限。


4

通常我更喜欢返回一个新实例的静态异步方法,但这里有另一种方法。它更接近于等待构造函数完成。它可以与TypeScript配合使用。

class Foo {
  #promiseReady;

  constructor() {
    this.#promiseReady = this.#init();
  }

  async #init() {
    await someAsyncStuff();
    return this;

  }

  ready() {
    return this.promiseReady;
  }
}

let foo = await new Foo().ready();

3
您可以使用Proxy的construct句柄来实现此操作,代码如下:
const SomeClass = new Proxy(class A {
  constructor(user) {
    this.user = user;
  }
}, {
  async construct(target, args, newTarget) {
    const [name] = args;
    // you can use await in here
    const user = await fetch(name);
    // invoke new A here
    return new target(user);
  }
});

const a = await new SomeClass('cage');
console.log(a.user); // user info

2
这里有很多优秀的知识,以及一些超级周到的回答。简而言之,下面概述的技术相当简单、非递归、支持异步,并符合规则。更重要的是,我认为它还没有得到适当的涵盖 - 不过如果我错了,请纠正我!

我们不再使用方法调用,而是将 II(A)FE 分配给实例属性:

// it's async-lite!
class AsyncLiteComponent {
  constructor() {
    // our instance includes a 'ready' property: an IIAFE promise
    // that auto-runs our async needs and then resolves to the instance
    // ...
    // this is the primary difference to other answers, in that we defer
    // from a property, not a method, and the async functionality both
    // auto-runs and the promise/prop resolves to the instance
    this.ready = (async () => {
      // in this example we're auto-fetching something
      this.msg = await AsyncLiteComponent.msg;
      // we return our instance to allow nifty one-liners (see below)
      return this;
    })();
  }

  // we keep our async functionality in a static async getter
  // ... technically (with some minor tweaks), we could prefetch
  // or cache this response (but that isn't really our goal here)
  static get msg() {
    // yes I know - this example returns almost immediately (imagination people!)
    return fetch('data:,Hello%20World%21').then((e) => e.text());
  }
}

“似乎很简单,它如何使用?”
// Ok, so you *could* instantiate it the normal, excessively boring way
const iwillnotwait = new AsyncLiteComponent();
// and defer your waiting for later
await iwillnotwait.ready
console.log(iwillnotwait.msg)

// OR OR OR you can get all async/awaity about it!
const onlywhenimready = await new AsyncLiteComponent().ready;
console.log(onlywhenimready.msg)

// ... if you're really antsy you could even "pre-wait" using the static method,
// but you'd probably want some caching / update logic in the class first
const prefetched = await AsyncLiteComponent.msg;

// ... and I haven't fully tested this but it should also be open for extension
class Extensior extends AsyncLiteComponent {
  constructor() {
    super();
    this.ready.then(() => console.log(this.msg))
  } 
}
const extendedwaittime = await new Extensior().ready;


在发布之前,我在@slebetman全面回答的评论中对这种技术的可行性进行了简要讨论。我并没有完全被直接否定所说服,因此想进一步展开辩论/批评。请尽情发挥 :)

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接