我的 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 的争议点,那些部分可能会更主观一些。

相关推荐
前端百草阁2 小时前
JavaScript 模块系统:CJS/AMD/UMD/ESM
javascript·ecmascript
打小就很皮...2 小时前
简单实现Ajax基础应用
前端·javascript·ajax
wanhengidc3 小时前
服务器租用:高防CDN和加速CDN的区别
运维·服务器·前端
哆啦刘小洋3 小时前
HTML Day04
前端·html
再学一点就睡4 小时前
JSON Schema:禁锢的枷锁还是突破的阶梯?
前端·json
保持学习ing4 小时前
帝可得 - 设备管理
javascript·vue.js·elementui
从零开始学习人工智能5 小时前
FastMCP:构建 MCP 服务器和客户端的高效 Python 框架
服务器·前端·网络
烛阴5 小时前
自动化测试、前后端mock数据量产利器:Chance.js深度教程
前端·javascript·后端
好好学习O(∩_∩)O5 小时前
QT6引入QMediaPlaylist类
前端·c++·ffmpeg·前端框架
敲代码的小吉米5 小时前
前端HTML contenteditable 属性使用指南
前端·html