虽然我个人并不是 NGRX 的粉丝,但确实有很多 Angular 开发者在使用它,我也有一些使用经验。因此我想分享一下我所遇到的一些令人惊讶的行为。
ROUTER_NAVIGATION
默认会在任何路由守卫(guard)或解析器(resolver)执行之前被触发
假设你在 app.component.ts 中使用了来自 @ngrx/router-store
的 selectUrl
,并且你正在访问 http://localhost:4200/#/login
。
ts
export class AppComponent {
private store = inject(Store);
private router = inject(Router);
ngOnInit(): void {
this.router.events
.pipe(
filter((event) => event instanceof NavigationEnd),
map((event) => event.url),
startWith(this.router.url)
)
.subscribe((url) => {
console.log("router events url ", url);
});
this.store.select(selectUrl).subscribe((url) => {
console.log("selectUrl ", url);
});
}
}
我们还有一个用于 login
路由的 canActivate
守卫:
ts
export const routes: Routes = [
//....
{
path: "login",
loadComponent: () =>
import("./features/login/login.component").then(
(module) => module.LoginComponent
),
canActivate: [canActivateLogin],
},
];
ts
export const canActivateLogin: CanActivateFn = (route, state) => {
console.log("canActivateLogin start");
return inject(AuthService)
.login()
.then((isLogin) => {
console.log("canActivateLogin end");
return true;
});
};
那你会在控制台看到什么日志?
你会看到如下输出:
router events url /
selectUrl undefined
selectUrl /login
canActivateLogin start
canActivateLogin end
router events url /login
从日志中可以看出,当导航发生时,selectUrl
给出的是一个"未来"的 URL。
如果你在守卫中取消了导航:
ts
export const canActivateLogin: CanActivateFn = (route, state) => {
console.log("canActivateLogin start");
return inject(AuthService)
.login()
.then((isLogin) => {
console.log("canActivateLogin end");
return false;
});
};
那么你会看到如下输出:
router events url /
selectUrl undefined
selectUrl /login
canActivateLogin start
canActivateLogin end
selectUrl
正如你所见,UI 页面仍然停留在 /
,而 selectUrl
却在期间给出了错误的 URL。此外,selectUrl
返回 undefined
或空字符串也显得有些奇怪。
如果你有一些逻辑依赖于 selectUrl
,这可能会导致 bug,因为 ROUTER_NAVIGATION
是在 guard 和 resolver 执行之前就被 dispatch 的。
不过幸运的是,我们可以通过配置来改变这种行为:
ts
export const appConfig: ApplicationConfig = {
providers: [
// ...
provideRouterStore({
navigationActionTiming: NavigationActionTiming.PostActivation,
}),
],
};
配置完成后,成功进入 /login
的日志会变成:
router events url /
selectUrl undefined
canActivateLogin start
canActivateLogin end
selectUrl /login
router events url /login
如果导航被取消,则日志为:
router events url /
selectUrl undefined
canActivateLogin start
canActivateLogin end
selectUrl
如果我们把 selectUrl
返回的 undefined
或 ''
视作 /
,现在就能得到正确的 URL 了,因为此时 ROUTER_NAVIGATION
会在 guard 和 resolver 之后才执行。
模块预加载(Preloading)会导致 Effects 提前初始化
假设我们希望懒加载 LoginModule
来优化性能:
ts
import { Routes } from "@angular/router";
export const routes: Routes = [
//...
{
path: "login",
loadChildren: () =>
import("./features/login/login.module").then(
(module) => module.LoginModule
),
},
];
ts
const loginRoutes: Routes = [
{
path: "",
loadComponent: () =>
import("./login.component").then((module) => module.LoginComponent),
canActivate: [canActivateLogin],
providers: [],
},
];
@NgModule({
declarations: [],
imports: [RouterModule.forChild(loginRoutes)],
providers: [
provideEffects(UserEffects),
provideState(userFeatureKey, userReducer),
],
})
export class LoginModule {}
ts
@Injectable()
export class UserEffects {
private actions$ = inject(Actions);
private userService = inject(UserService);
getUserInfo$ = createEffect(() => {
return this.actions$.pipe(
ofType(UserActions.loadUsers, PageHeaderActions.refreshButtonClicked),
switchMap(() => {
return this.userService
.getUserInfo()
.pipe(map((user) => UserActions.loadUsersSuccess({ user })));
})
);
});
}
UserEffects
被提供给了 LoginModule
。UserEffects.getUserInfo$
可以通过 PageHeaderActions.refreshButtonClicked
action 被触发。
如果你还没有访问过 /login
路由,LoginModule
就不会被加载,此时 dispatch PageHeaderActions.refreshButtonClicked
action 不会有任何效果。
然而,如果你启用了模块预加载(例如使用 PreloadAllModules
):
ts
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withHashLocation(),
withPreloading(PreloadAllModules)
),
],
};
即使你还没有访问 /login
页面,UserEffects
也会被初始化!
如果你 dispatch 了 PageHeaderActions.refreshButtonClicked
action,userService.getUserInfo()
方法就会被调用,即便你还没有访问过 /login
页面! 这可能导致 bug,因为在路由尚未激活的情况下,某些前提条件可能还未满足,它的后续调用可能会出问题,比如给个错误弹窗之类。
这个行为对我来说非常意外。我仅仅是开启了预加载,怎么就产生了新 bug 呢!
ROOT_EFFECTS_INIT
在 provideAppInitializer
之前被触发
这个问题可能与上面提到的行为有关。我们知道 Angular 提供了 provideAppInitializer
,
这个函数在应用启动过程中执行,并且所需的数据在启动时就已可用。
如果该函数返回一个 Promise 或 Observable,则初始化过程会在 Promise 解决或 Observable 完成后才会结束。
如果你有依赖于 ROOT_EFFECTS_INIT
的代码,比如:
ts
@Injectable()
export class UserEffects {
private actions$ = inject(Actions);
private userService = inject(UserService);
getUserInfo$ = createEffect(() => {
return this.actions$.pipe(
ofType(ROOT_EFFECTS_INIT),
switchMap(() => {
console.log("effects getUserInfo");
return this.userService
.getUserInfo()
.pipe(map((user) => UserActions.loadUsersSuccess({ user })));
})
);
});
}
ts
function initializeApp() {
const authService = inject(AuthService);
const router = inject(Router);
console.log("initializeApp");
}
export const appConfig: ApplicationConfig = {
providers: [
provideAppInitializer(initializeApp),
provideStore({
router: routerReducer,
[userFeatureKey]: userReducer,
}),
provideEffects(UserEffects),
],
};
Effect 会被触发并先于 provideAppInitializer
执行。基于上面的代码,你会看到如下日志:
effects getUserInfo
initializeApp
这对我来说也是一个惊喜。
如果你想要改变这一行为,你可以创建一个新的 action,例如 INIT_ROOT_EFFECTS_AFTER_APP_INITIALIZED
来替代 ROOT_EFFECTS_INIT
,并在 provideAppInitializer
函数结束时 dispatch 这个新的 action。
最后的话
关于 NgRx 还有很多值得讨论的内容,我知道它是有争议的。但基本上这就是我在有限经验中遇到的一些与 NgRx API 设计相关的"陷阱"。
以后我可能会总结一些关于如何在项目中使用 NgRx 的争议点,那些部分可能会更主观一些。