1. 路由守卫到底在守什么
Angular 的路由系统里有一类东西,平时写业务不太会主动碰,但一旦项目开始变复杂,你就绕不开它——路由守卫。CanActivate是其中出场率最高的一个。简单说,它决定“这个路由能不能进”。用户在地址栏敲了一个 URL,或者点了某个链接要跳到某个页面,Angular 在真正激活这个路由之前,会先问一句:CanActivate,你同意吗?同意就放行,不同意就拦下来,可能跳去登录页,可能原地不动,可能弹个提示。
这个机制解决的核心问题是访问控制。比如一个后台管理系统,订单列表页只有登录用户能看,管理员配置页只有管理员能看,未保存的表单页在离开前要确认。这些判断如果全塞在组件里,组件会变得又臭又长,而且时机也不对——组件都渲染出来了再判断,用户已经看到内容了,体验很差。CanActivate把判断提前到路由激活之前,干净利落。
适合谁来读这篇?如果你已经写过几个 Angular 页面,知道RouterModule怎么配,但对守卫的印象还停留在“抄一段canActivate返回true就完事”,那这篇就是给你准备的。我会从设计思路讲到实操细节,再到踩过的坑,尽量把每个“为什么这么写”都讲透。全文基于 Angular 15+ 的写法,同时会说明旧版本class-based guard的差异,因为很多老项目还在用。
先给一个最直观的类比。路由守卫就像小区门口的保安。CanActivate是进门保安,问你“你住哪栋,有没有门禁卡”;CanDeactivate是出门保安,问你“家里煤气关了没”;CanLoad是更早的一道岗,连小区大门都不让你进,直接省掉加载那栋楼的资源。今天主角是进门保安CanActivate。
2. 为什么是 CanActivate,而不是在组件里判断
2.1 时机差异决定了体验差异
很多人第一反应是:我在组件的ngOnInit里判断不就行了?不行,或者说不够好。组件初始化的时候,路由已经激活了,组件模板已经开始渲染了。你在这个阶段判断未登录,然后router.navigate跳走,用户会看到页面闪一下,甚至能看到不该看的内容骨架。更糟的是,如果组件里有数据请求,请求可能已经发出去了,白白浪费一次网络往返。
CanActivate的执行时机在路由激活之前。Angular 的路由导航是一个有明确阶段的过程:识别 URL、匹配路由配置、执行守卫、解析数据、激活组件。守卫在“解析数据”和“激活组件”之前跑,所以它能真正拦住整个流程。这就是它存在的根本理由——把判断放在副作用发生之前。
2.2 职责分离带来的可维护性
把访问控制写在组件里,还有个问题:同一个判断逻辑会在多个组件里重复。订单列表要判断登录,用户列表要判断登录,报表页要判断登录,每个组件都写一遍if (!isLoggedIn()) { ... },改需求的时候要改 N 个地方。守卫是路由级别的,一个守卫可以挂在多个路由上,逻辑只有一份。
我见过一个项目,权限判断散落在十几个组件里,后来要加“登录过期自动跳转并带上原地址”的需求,改得那叫一个痛苦。如果一开始就用守卫,这个需求只需要改一个文件。这就是架构选择带来的长期收益。
2.3 和 CanLoad、CanDeactivate 的分工
顺便把几个守卫的边界理清楚,避免用错。CanActivate管的是“能不能激活这个路由”,它在模块已经加载的前提下工作。CanLoad管的是“能不能加载这个懒加载模块”,它比CanActivate更早,能阻止整个模块的下载。如果你做的是基于角色的懒加载,比如管理员模块普通用户根本不该下载,那应该用CanLoad,省流量也省时间。
CanDeactivate管的是“能不能离开当前路由”,典型场景是表单未保存提示。CanActivateChild管的是子路由的激活,适合在父路由上统一控制一批子路由的权限。这几个守卫经常配合使用,但职责不能混。我见过有人用CanActivate去做离开确认,结果怎么都不生效,因为方向反了。
3. 函数式守卫:现在的主流写法
3.1 从 class 到 function 的演进
Angular 14 之前,守卫必须是一个实现了CanActivate接口的类,用@Injectable装饰,然后在路由配置里写canActivate: [MyGuard]。Angular 14 引入了函数式守卫,Angular 15 进一步把它变成推荐写法。现在你可以直接写一个函数,返回boolean、Promise<boolean>或者Observable<boolean>。
为什么官方要推函数式?因为守卫本质上就是“一个接收路由信息、返回是否放行的判断”,它不需要是类,不需要依赖注入容器里挂一个实例。函数更轻,更容易测试,也更容易组合。类式守卫在依赖注入上更重,而且写起来样板代码多。新项目我建议直接用函数式,老项目迁移也不难。
3.2 一个最小可用的登录守卫
先看最基础的版本,判断用户有没有登录:
// auth.guard.ts import { inject } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; import { AuthService } from './auth.service'; export const authGuard: CanActivateFn = (route, state) => { const auth = inject(AuthService); const router = inject(Router); if (auth.isLoggedIn()) { return true; } router.navigate(['/login'], { queryParams: { redirect: state.url } }); return false; };这里有几个点值得说。inject()是函数式守卫获取依赖的方式,它必须在注入上下文里调用,也就是守卫函数同步执行的部分,不能放在setTimeout或者异步回调里。state.url是用户原本想去的地址,把它作为redirect参数传给登录页,登录成功后就能跳回去,这个细节体验提升很大,但很多人会漏掉。
返回false之后,导航被取消,当前页面保持不变。如果你已经调用了router.navigate,那用户会被带到登录页。注意顺序:先navigate再return false,不要反过来,虽然反过来也能跑,但逻辑上先决定去哪再拒绝更清晰。
3.3 在路由配置里挂载
// app-routing.module.ts const routes: Routes = [ { path: 'login', component: LoginComponent }, { path: 'orders', component: OrderListComponent, canActivate: [authGuard] }, { path: 'admin', loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule), canActivate: [authGuard, roleGuard] } ];canActivate接收一个数组,数组里的守卫按顺序执行,全部返回 true 才放行,任何一个返回 false 就拦截。这个顺序很重要,通常把开销小、拦截率高的守卫放前面。比如先判断登录,再判断角色,未登录的用户根本不需要走角色判断。
注意:数组里的守卫是串行执行的,前一个返回 false,后面的就不会执行了。所以不要把有副作用的逻辑放在后面的守卫里,指望它一定执行。
4. 异步判断:等接口返回再决定放行
4.1 为什么守卫经常需要异步
真实项目里,登录状态往往不是本地一个布尔值那么简单。用户刷新页面,内存里的状态没了,你得拿 token 去后端换用户信息,这个请求是异步的。或者权限列表存在服务端,需要拉取后才能判断。这时候守卫必须支持异步返回。
函数式守卫天然支持返回Observable<boolean>或Promise<boolean>。Angular 会等这个异步结果出来再决定导航是否继续。这是它比在组件里判断更优雅的地方——整个导航流程会挂起等待,不会出现“先放行再补救”的尴尬。
4.2 用 Observable 处理异步鉴权
export const authGuard: CanActivateFn = (route, state) => { const auth = inject(AuthService); const router = inject(Router); return auth.checkSession().pipe( map(user => { if (user) { return true; } router.navigate(['/login'], { queryParams: { redirect: state.url } }); return false; }), catchError(() => { router.navigate(['/login']); return of(false); }) ); };checkSession()返回一个Observable<User | null>,可能是发一个 HTTP 请求。map里判断结果,catchError兜底网络异常。这里有个关键点:catchError必须返回一个Observable,用of(false)包一下,否则整个流会断掉,导航会卡住。
4.3 用 Promise 的写法
如果你更习惯async/await,也可以返回Promise:
export const authGuard: CanActivateFn = async (route, state) => { const auth = inject(AuthService); const router = inject(Router); try { const user = await auth.checkSessionPromise(); if (user) return true; router.navigate(['/login'], { queryParams: { redirect: state.url } }); return false; } catch { router.navigate(['/login']); return false; } };两种写法都行,看团队习惯。Observable在 Angular 生态里更自然,尤其是你后面要组合多个异步源的时候,RxJS的操作符会方便很多。Promise对新手更友好,可读性强。我个人的选择是:单一请求用Promise,需要组合、取消、重试的场景用Observable。
实操心得:异步守卫里千万不要忘记处理错误分支。我踩过一次坑,接口 500 的时候守卫既没返回 true 也没返回 false,导航就永远挂在那里,页面白屏,控制台也没明显报错,排查了半天才发现是
catchError漏了。
5. 多守卫协作与依赖注入的坑
5.1 守卫的执行顺序与短路
前面提过数组里的守卫串行执行,这里展开说。假设你配了canActivate: [authGuard, roleGuard, subscriptionGuard],执行顺序就是从左到右。authGuard返回 false,后面两个不执行。这个特性可以用来做分层拦截:第一层判断登录,第二层判断角色,第三层判断订阅是否有效。每一层只关心自己的事,逻辑清晰。
但要注意,守卫之间不应该有隐式的依赖。比如roleGuard假设authGuard已经把用户信息加载好了,这种假设很脆弱。如果哪天有人调整了数组顺序,或者单独用了roleGuard,就会出问题。正确做法是每个守卫自己保证前置条件,或者把共享的加载逻辑抽到一个服务里,用缓存避免重复请求。
5.2 inject() 的使用边界
函数式守卫里用inject()拿依赖,这个调用必须发生在注入上下文中。守卫函数被 Angular 调用时是处于注入上下文的,所以函数体同步部分调用inject()没问题。但如果你在setTimeout、Promise.then、subscribe回调里调用inject(),就会报错。
// 错误写法 export const badGuard: CanActivateFn = () => { return timer(1000).pipe( map(() => { const auth = inject(AuthService); // 报错!不在注入上下文 return auth.isLoggedIn(); }) ); }; // 正确写法 export const goodGuard: CanActivateFn = () => { const auth = inject(AuthService); // 同步获取 return timer(1000).pipe( map(() => auth.isLoggedIn()) ); };这个坑很隐蔽,因为报错信息不一定直观。记住原则:依赖在守卫函数体最外层同步拿好,异步逻辑里只用拿到的引用。
5.3 类式守卫的写法(老项目参考)
如果你维护的是 Angular 14 之前的项目,守卫是类:
@Injectable({ providedIn: 'root' }) export class AuthGuard implements CanActivate { constructor(private auth: AuthService, private router: Router) {} canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean { if (this.auth.isLoggedIn()) return true; this.router.navigate(['/login']); return false; } }路由配置里写canActivate: [AuthGuard],注意是类引用不是实例。类式守卫通过构造函数注入依赖,没有inject()的上下文问题,但样板代码多。两种写法可以共存,迁移时逐个替换即可,不用一次性全改。
6. 常见问题与排查技巧实录
6.1 守卫返回 false 但页面还是跳了
这是新手最常遇到的问题。原因通常有两个。第一,你在守卫里调用了router.navigate,然后又返回了true,那当然会跳。第二,你返回的是false的“假值”,比如返回了undefined或者一个空字符串。Angular 判断的是严格的布尔语义,undefined会被当成 false 处理,但如果你返回的是Promise<undefined>,行为可能不符合预期。养成习惯:明确返回true或false。
还有一种情况是守卫根本没执行。检查路由配置里canActivate拼写是否正确,检查守卫是否挂在了正确的路由层级上。如果路由是懒加载的,父路由和子路由的守卫是分开的,挂在父路由上的守卫对子路由的激活不一定生效,需要用CanActivateChild。
6.2 刷新页面后守卫判断失效
SPA 应用刷新后内存状态清空,如果守卫只依赖内存里的登录标志,刷新后就会误判为未登录。解决办法是把登录凭证持久化,比如存在localStorage或cookie里,守卫启动时先读取。但更稳妥的做法是守卫里发起一次会话校验请求,以服务端返回为准。本地存储可以被篡改,不能作为唯一依据。
这里有个性能考量:每次路由切换都发一次校验请求太重。通常的做法是在应用启动时校验一次,把结果缓存在服务里,守卫读缓存。缓存要有过期策略,比如 5 分钟,或者配合 token 的过期时间。
6.3 守卫里的导航导致无限循环
如果你在守卫里navigate到的目标路由,恰好也挂了这个守卫,而目标路由又判断失败再次navigate,就会死循环。典型场景是登录页也挂了authGuard。解决办法是登录页、注册页这类公开页面不要挂鉴权守卫,或者守卫里判断“如果目标就是登录页,直接放行”。
if (state.url === '/login') { return true; }这个判断要放在最前面,避免任何其他逻辑干扰。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 守卫不执行 | 路由配置未挂载或拼写错误 | 检查canActivate数组 |
| 返回 false 仍跳转 | 守卫内调用了 navigate 且返回 true | 检查返回值与导航逻辑 |
| 刷新后误判未登录 | 状态未持久化 | 检查本地存储或会话校验 |
| 导航卡住白屏 | 异步守卫未返回或未处理错误 | 检查 catchError 分支 |
| 无限循环跳转 | 目标路由也挂了同一守卫 | 公开路由排除守卫 |
| inject 报错 | 在异步回调里调用 inject | 依赖提前同步获取 |
6.5 几个我踩过的坑
第一个坑是守卫里做了重定向但没返回 false。我写过一段代码,判断未登录后router.navigate(['/login']),然后忘了return false,函数默认返回undefined,Angular 当成 false 处理,导航被取消,但navigate又触发了新导航,结果两个导航打架,页面表现很诡异。后来养成习惯,navigate后面紧跟return false。
第二个坑是异步守卫里用了first()但流是空的。如果checkSession()在某些情况下不发射任何值就完成了,first()会抛错,守卫就挂了。用take(1)更安全,或者确保你的服务一定会发射值。
第三个坑是守卫的测试。函数式守卫很好测,因为它就是个普通函数,你可以直接调用它,传入 mock 的route和state,配合TestBed提供依赖。类式守卫稍微麻烦点,需要实例化。我建议给每个守卫都写单元测试,尤其是边界情况:未登录、登录但角色不对、接口异常。这些分支在手动测试时很容易漏。
7. 把守卫用出花:几个进阶场景
7.1 基于角色的权限守卫
登录只是第一层,真实系统往往还要判断角色。角色守卫可以这样写:
export const roleGuard = (allowedRoles: string[]): CanActivateFn => { return (route, state) => { const auth = inject(AuthService); const router = inject(Router); const user = auth.currentUser(); if (user && allowedRoles.includes(user.role)) { return true; } router.navigate(['/forbidden']); return false; }; };注意这里用了工厂函数,roleGuard接收允许的角色列表,返回一个CanActivateFn。这样同一个守卫逻辑可以复用于不同路由,每个路由传不同的角色要求。路由配置里写canActivate: [authGuard, roleGuard(['admin', 'editor'])]。这种模式在函数式守卫里非常自然,类式守卫要实现类似效果得写一堆子类或者用配置注入。
7.2 守卫与数据预加载的配合
有时候你希望守卫不只是判断,还顺便把数据准备好,这样组件激活时数据已经在了,不会闪一下空白。Angular 提供了Resolve守卫专门做这件事,但CanActivate里也可以做,只是职责上不太纯粹。我的建议是:判断归CanActivate,取数归Resolve。两者配合,守卫判断能不能进,Resolve负责进之前把数据备好。混在一起会让守卫变得臃肿,测试也麻烦。
7.3 守卫的调试技巧
守卫出问题时,最直接的调试手段是在里面打日志,把route、state.url、判断结果都打出来。Angular 的路由事件流也很有用,订阅Router.events能看到NavigationStart、GuardsCheckStart、GuardsCheckEnd、NavigationEnd这些事件,守卫卡在哪个阶段一目了然。
router.events.subscribe(e => { if (e instanceof GuardsCheckEnd) { console.log('守卫检查结束', e); } });GuardsCheckEnd事件里有个shouldActivate字段,告诉你最终是否放行。这个在排查“为什么没进去”或者“为什么进去了”的时候特别有用。
8. 写在最后的一点个人体会
路由守卫这个东西,刚接触的时候觉得就是个if-else,用久了才发现它是 Angular 路由体系里设计得相当精巧的一环。它把访问控制从组件里抽出来,放到导航流程的正确时机上,既保证了安全,又提升了体验。函数式守卫的引入让它变得更轻、更好测、更好组合,我现在写新项目基本不会再考虑类式写法。
如果你正在维护一个守卫逻辑散乱的项目,我的建议是先从最核心的鉴权守卫开始重构,把它抽成一个函数式守卫,挂到所有需要保护的路由上。然后逐步把角色判断、订阅判断这些拆成独立的守卫,用数组组合。这个过程不用一次性完成,每改一个路由就顺手改一个,慢慢就理顺了。
最后分享一个小技巧:给守卫写测试的时候,不要只测“返回 true”和“返回 false”两个分支,一定要测导航目标是否正确。我见过守卫判断逻辑没问题,但重定向地址写错了,用户被带到 404 页面的情况。测试里 mock 一个Router,断言navigate被调用的参数,这个习惯能帮你省下不少线上排查的时间。