在 Angular‘ ngOnInit’中异步/等待

我正在评估取代 Angular 的代理人的优缺点。RxJS 的 Observable与简单的 Promise,使我可以使用 asyncawait,并得到一个更直观的代码样式。

我们的典型场景之一: 在 ngOnInit中加载一些数据:

ngOnInit () {
this.service.getData().subscribe(data => {
this.data = this.modifyMyData(data);
});
}

当我从 getData()返回一个 Promise,并使用 asyncawait时,它变成:

async ngOnInit () {
const data = await this.service.getData();
this.data = this.modifyMyData(data);
}

现在,很明显,Angular 不会“知道”ngOnInit已经变成了 async。我觉得这不是问题: 我的应用程序仍然像以前一样工作。但是当我查看 OnInit接口时,函数的声明方式显然不是这样的,这表明它可以被声明为 async:

ngOnInit(): void;

所以——底线: 我在这里做的合理吗? 或者我会遇到任何不可预见的问题?

83329 次浏览

It is no different than what you had before. ngOnInit will return a Promise and the caller will ignore that promise. This means that the caller will not wait for everything in your method to finish before it proceeds. In this specific case it means the view will finish being configured and the view may be launched before this.data is set.

That is the same situation you had before. The caller would not wait for your subscriptions to finish and would possibly launch the app before this.data had been populated. If your view is relying on data then you likely have some kind of ngIf setup to prevent you from accessing it.

I personally don't see it as awkward or a bad practice as long as you're aware of the implications. However, the ngIf can be tedious (they would be needed in either way). I have personally moved to using route resolvers where it makes sense so I can avoid this situation. The data is loaded before the route finishes navigating and I can know the data is available before the view is ever loaded.

You can use rxjs function of.

of(this.service.getData());

Converts the promise to an observable sequence.

ngOnInit does NOT wait for the promise to complete. You can make it an async function if you feel like using await like so:

import { take } from 'rxjs/operators';


async ngOnInit(): Promise<any> {
const data = await firstValueFrom(this.service.getData());
this.data = this.modifyMyData(data);
}

However, if you're using ngOnInit instead of the constructor to wait for a function to complete, you're basically doing the equivalent of this:

import { take } from 'rxjs/operators';


constructor() {
firstValueFrom(this.service.getData())
.then((data => {;
this.data = this.modifyMyData(data);
});
}

It will run the async function, but it WILL NOT wait for it to complete. If you notice sometimes it completes and sometimes it doesn't, it really just depends on the timing of your function.

Using the ideas from this post, you can basically run outside zone.js. NgZone does not include scheduleMacroTask, but zone.js is imported already into angular.

Solution

import { isObservable, Observable } from 'rxjs';
import { take } from 'rxjs/operators';


declare const Zone: any;


async waitFor<T>(prom: Promise<T> | Observable<T>): Promise<T> {
if (isObservable(prom)) {
prom = firstValueFrom(prom);
}
const macroTask = Zone.current
.scheduleMacroTask(
`WAITFOR-${Math.random()}`,
() => { },
{},
() => { }
);
return prom.then((p: T) => {
macroTask.invoke();
return p;
});
}

I personally put this function in my core.module.ts, although you can put it anywhere.

Use it like so:

constructor(private cm: CoreModule) {
const p = this.service.getData();
this.post = this.cm.waitFor(p);
}

You could also check for isBrowser to keep your observable, or wait for results.

Conversely, you could also import angular-zen and use it like in this post, although you will be importing more than you need.

J

UPDATE: 2/26/22 - Now uses firstValueFrom since .toPromise() is depreciated.

Now, obviously, Angular will not “know”, that ngOnInit has become async. I feel that this is not a problem: My app still works as before.

Semantically it will compile fine and run as expected, but the convenience of writing async / wait comes at a cost of error handling, and I think it should be avoid.

Let's look at what happens.

What happens when a promise is rejected:

public ngOnInit() {
const p = new Promise((resolver, reject) => reject(-1));
}

The above generates the following stack trace:

core.js:6014 ERROR Error: Uncaught (in promise): -1
at resolvePromise (zone-evergreen.js:797) [angular]
at :4200/polyfills.js:3942:17 [angular]
at new ZoneAwarePromise (zone-evergreen.js:876) [angular]
at ExampleComponent.ngOnInit (example.component.ts:44) [angular]
.....

We can clearly see that the unhandled error was triggered by a ngOnInit and also see which source code file to find the offending line of code.

What happens when we use async/wait that is reject:

    public async ngOnInit() {
const p = await new Promise((resolver, reject) => reject());
}

The above generates the following stack trace:

core.js:6014 ERROR Error: Uncaught (in promise):
at resolvePromise (zone-evergreen.js:797) [angular]
at :4200/polyfills.js:3942:17 [angular]
at rejected (tslib.es6.js:71) [angular]
at Object.onInvoke (core.js:39699) [angular]
at :4200/polyfills.js:4090:36 [angular]
at Object.onInvokeTask (core.js:39680) [angular]
at drainMicroTaskQueue (zone-evergreen.js:559) [<root>]

What happened? We have no clue, because the stack trace is outside of the component.

Still, you might be tempted to use promises and just avoid using async / wait. So let's see what happens if a promise is rejected after a setTimeout().

    public ngOnInit() {
const p = new Promise((resolver, reject) => {
setTimeout(() => reject(), 1000);
});
}

We will get the following stack trace:

core.js:6014 ERROR Error: Uncaught (in promise): [object Undefined]
at resolvePromise (zone-evergreen.js:797) [angular]
at :4200/polyfills.js:3942:17 [angular]
at :4200/app-module.js:21450:30 [angular]
at Object.onInvokeTask (core.js:39680) [angular]
at timer (zone-evergreen.js:2650) [<root>]

Again, we've lost context here and don't know where to go to fix the bug.

Observables suffer from the same side effects of error handling, but generally the error messages are of better quality. If someone uses throwError(new Error()) the Error object will contain a stack trace, and if you're using the HttpModule the Error object is usually a Http response object that tells you about the request.

So the moral of the story here: Catch your errors, use observables when you can and don't use async ngOnInit(), because it will come back to haunt you as a difficult bug to find and fix.

I used try catch inside the ngOnInit():

async ngOnInit() {
try {
const user = await userService.getUser();
} catch (error) {
console.error(error);
}
}

Then you get a more descriptive error and you can find where the bug is

I wonder what are the downsides of an immediately invoked function expression :

    ngOnInit () {
(async () => {
const data = await this.service.getData();
this.data = this.modifyMyData(data);
})();
}

It is the only way I can imagine to make it work without declaring ngOnInit() as an async function

I would do this a bit differently, you don't show your html template, but I'm assuming you're doing something in there with data, i.e.

<p> \{\{ data.Name }}</p> <!-- or whatever -->

Is there a reason you're not using the async pipe?, i.e.

<p> \{\{ (data$ | async).Name }}</p>

or

<p *ngIf="(data$ | async) as data"> \{\{ data.name }} </p>

and in your ngOnInit:

data$: Observable<any>; //change to your data structure
ngOnInit () {
this.data$ = this.service.getData().pipe(
map(data => this.modifyMyData(data))
);
}

The correct answer to this question is to use Resolver feature of Angular. With Resolve, your component will not be rendered until you tell it to render.

From docs:

Interface that classes can implement to be a data provider. A data provider class can be used with the router to resolve data during navigation. The interface defines a resolve() method that is invoked when the navigation starts. The router waits for the data to be resolved before the route is finally activated.