我的 Angular 总结:一些让我惊讶的 NGRX 行为

虽然我个人并不是 NGRX 的粉丝,但确实有很多 Angular 开发者在使用它,我也有一些使用经验。因此我想分享一下我所遇到的一些令人惊讶的行为。


ROUTER_NAVIGATION 默认会在任何路由守卫(guard)或解析器(resolver)执行之前被触发

假设你在 app.component.ts 中使用了来自 @ngrx/router-storeselectUrl,并且你正在访问 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 被提供给了 LoginModuleUserEffects.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_INITprovideAppInitializer 之前被触发

这个问题可能与上面提到的行为有关。我们知道 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 的争议点,那些部分可能会更主观一些。

相关推荐
AlexMaybeBot4 小时前
躺在沙发上开发 Openclaw 的移动端APP
前端·flutter
用户2181697049304 小时前
Flutter(十三)Text Image TextField
前端
程序员黑豆4 小时前
深入解析Java数据类型:基本类型与引用类型的本质区别与实战选择
前端·ai编程·全栈
你挚爱的强哥4 小时前
【exportExcel】单纯靠js不用任何其他第三方插件导出xls格式文件
开发语言·javascript·ecmascript
东方小月5 小时前
从零开发一个 Coding Agent(七):实现纯文本 Agent Loop
前端·人工智能
大家的林语冰5 小时前
👉 尤雨溪再次成立新公司,同时官宣 Pinia 4 正式发布!
前端·javascript·vue.js
浮生望5 小时前
React useRef 深度解析:从DOM操作到Web Worker多线程协作
前端
浮生望5 小时前
React Router v7 实战全景:路由配置、嵌套路由、鉴权守卫与懒加载完整指南
前端
用户938515635075 小时前
从零在浏览器里跑 DeepSeek-R1:WebGPU + Transformer.js 全链路实战(二)
前端·javascript·typescript
雪隐6 小时前
个人电脑玩AI-16让5060 Ti给你打工——MiniMax H3 提速实录:从"泡杯茶等视频"到"视频等你",一张 5060 Ti 的自我修养
前端·人工智能·后端