Angular路由守卫精讲:用CanActivate实现登录拦截与权限控制
1. 路由守卫到底解决了什么问题1.1 从一个常见需求说起未登录用户不能进入后台做Angular项目做久了你一定会遇到这种需求后台管理页面只能让登录用户访问没登录的一律踢回登录页就算登录了不同角色可见的页面也不一样普通员工不能点开管理员专属的配置页。如果你还是在每个组件的ngOnInit里写一遍登录判断甚至靠后端报401才跳转那说明你还没体会到路由守卫的爽。先说一个我印象很深的项目。那是个企业内部管理系统刚开始图省事所有判断都在组件里做。结果就是每个页面都要先注入AuthService然后在ngOnInit里调一个checkLogin()方法如果没登录就router.navigate去登录页还要把当前url传到query参数里方便登录后回跳。表面看也没什么但等页面多起来就头疼了权限逻辑散落在几十个组件里改一处漏三处。后来痛定思痛把所有访问控制收敛到路由守卫里一个CanActivate搞定所有入口拦截代码量减少是一方面关键是权限逻辑终于有了统一收口的地方。这是路由守卫最核心的价值把能不能进这个路由的判断逻辑从组件里剥离出来放到路由配置层统一处理。Angular的路由守卫不是新东西但很多初学者要么不知道有这东西要么只知道CanActivate这一个名字对它的返回值、执行时机、和依赖注入的关系都没吃透。这篇文章就以CanActivate为主线把路由权限控制和登录拦截这件事从头到尾捋一遍。1.2 路由守卫的六种类型别只盯着CanActivateAngular官方的路由守卫一共有六种我先把它们放一张表里你看完就明白为什么很多项目只需要CanActivate但有些复杂场景必须组合使用。守卫类型接口/函数执行时机典型用途CanActivateCanActivateFn进入路由前登录校验、权限判断CanActivateChildCanActivateChildFn进入子路由前父路由下的子路由批量拦截CanDeactivateCanDeactivateFn离开路由前表单未保存提醒、操作确认CanLoadCanLoadFn懒加载模块加载前未登录不加载模块代码减少流量ResolveResolveFn路由激活前获取数据进入页面之前先把接口数据准备好CanMatchCanMatchFn路由匹配阶段按条件决定是否匹配该路由适合多路由共用一个路径的情况这六种守卫里CanActivate是使用频率最高的它决定了这个路由能不能被激活其兄弟CanActivateChild判断的是子路由CanDeactivate则是反方向的拦截比如你在一个编辑页面改了表单没保存就想跳走它会弹个确认框Resolve更多是数据预取的解法可以在路由进入前把详情页的数据先请求回来页面一加载就有内容不需要再搞loading。2. CanActivate的两种写法class式与函数式2.1 函数式守卫新版本推荐的做法Angular 15之前的写法是class式守卫实现一个继承自CanActivate接口的类在里面写canActivate方法。但Angular 15之后官方推荐用函数式守卫写法更简洁代码量少一半而且不存在class继承和依赖注入的坑。函数式守卫的本质就是一个普通的函数函数签名长这样import { inject } from angular/core; import { CanActivateFn, Router, UrlTree } from angular/router; import { AuthService } from ../services/auth.service; export const authGuard: CanActivateFn (route, state) { const authService inject(AuthService); const router inject(Router); if (authService.isLoggedIn()) { return true; } // 未登录时重定向到登录页并记录来源地址 return router.createUrlTree([/login], { queryParams: { redirect: state.url } }); };这个函数接收两个参数route是即将激活的路由快照ActivatedRouteSnapshotstate是当前路由状态RouterStateSnapshot里面包含目标url。函数里直接通过inject()拿到AuthService和Router比class式守卫在构造函数里注入依赖要直观得多。函数式守卫的好处还有一点它可以像普通函数一样被组合复用。比如你有三个判断条件可以写成一个高阶函数来生成新的守卫函数这在class式里做起来就比较绕。2.2 class式守卫的写法与依赖注入细节虽然函数式现在是官方推荐但老项目里class式守卫依然大量存在你得能看懂也保不齐什么时候要维护老代码。class式守卫长这样import { Injectable } from angular/core; import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router, UrlTree } from angular/router; import { Observable } from rxjs; import { AuthService } from ../services/auth.service; Injectable({ providedIn: root }) export class AuthGuard implements CanActivate { constructor( private authService: AuthService, private router: Router ) {} canActivate( route: ActivatedRouteSnapshot, state: RouterStateSnapshot ): Observableboolean | UrlTree | Promiseboolean | UrlTree | boolean | UrlTree { if (this.authService.isLoggedIn()) { return true; } return this.router.createUrlTree([/login], { queryParams: { redirect: state.url } }); } }这里最容易踩的坑是providedIn和模块注入的配置问题。你如果在AuthGuard里注入了某个服务而这个服务不是providedIn: root而是靠某个模块的providers数组提供的同时这个模块又不是AppModule那路由守卫执行的时候就会报NullInjectorError: No provider for XxxService。原因在于路由守卫的实例化时机和模块加载顺序有关。懒加载模块里的路由守卫只会在这个模块被加载之后才会执行如果守卫依赖的服务恰好只在那个懒加载模块里提供第一次访问该路由时没问题但如果其他地方复用这个守卫就可能拿到null。所以我的经验是能被守卫依赖的服务一律在根级提供要么providedIn: root要么就放在AppModule的providers数组里别放子模块里赌运气。2.3 返回值到底是什么true、false、还是UrlTree很多人对CanActivate的返回值理解停留在返回true就放行返回false就拦下来。这个理解对但不完整。Angular守卫的返回值是有讲究的true放行路由正常激活。false阻止导航页面留在原地。UrlTreeAngular 7.1之后引入的类型。返回UrlTree时Angular会取消当前导航并自动跳转到这个UrlTree对应的地址。这个设计很聪明等于是把重定向到哪的指令直接编码进了返回值里路由系统负责执行跳转。推荐做法是优先返回UrlTree而不是在守卫里手动调router.navigate。为什么因为手动调用navigate会触发一次新的导航和当前正在进行的导航叠加在一起在一些复杂场景下会出现两次导航互相干扰的问题。而返回UrlTreeAngular会在内部处理这次的导航取消和新地址跳转行为更统一。还有一种情况是返回Observable或Promise这种一般用于异步判断。比如你要调后端接口确认用户权限或者等一个token刷新完成再决定是否放行。返回Observable时要注意流是否完结——如果返回的是一个http请求产生的Observable那没问题请求结束自动complete但如果是Subject或BehaviorSubject你必须手动pipe(first())来确保流能结束否则守卫永远不会执行完页面就一直卡在空白。3. 登录拦截与路由权限控制的设计思路3.1 登录状态存哪里localStorage、sessionStorage还是内存做登录拦截核心就是判断当前用户是否已登录。这个状态的存储位置我见过太多种方案有人全塞localStorage有人全放服务内存里各有各的问题。我的建议是分三层内存、持久化存储、服务端会话。内存里放一个当前用户信息对象和一个登录状态标志用BehaviorSubject管理这是整个应用判断登录状态的唯一数据源持久化存储localStorage或sessionStorage里放token和用户基本信息用于刷新页面后恢复状态服务端会话则是真正兜底的后端接口会校验token有效性。这么设计的原因很直白只放内存里一刷新页面就全没了守卫刷新后拿不到状态体验很糟糕只放localStorage里存token没问题但存user信息就有安全风险而且localStorage的读取是同步的稍微大一点的对象每次读取都影响性能。具体实现上AuthService里维护一个BehaviorSubject初始化的时候从localStorage恢复import { Injectable } from angular/core; import { BehaviorSubject } from rxjs; interface UserInfo { id: number; name: string; roles: string[]; } Injectable({ providedIn: root }) export class AuthService { private readonly TOKEN_KEY app_token; private readonly USER_KEY app_user; private userSubject new BehaviorSubjectUserInfo | null(this.loadUser()); user$ this.userSubject.asObservable(); private loadUser(): UserInfo | null { const token localStorage.getItem(this.TOKEN_KEY); const userStr localStorage.getItem(this.USER_KEY); if (!token || !userStr) { return null; } try { return JSON.parse(userStr) as UserInfo; } catch (e) { this.logout(); return null; } } get isLoggedIn(): boolean { return this.userSubject.value ! null; } login(token: string, user: UserInfo): void { localStorage.setItem(this.TOKEN_KEY, token); localStorage.setItem(this.USER_KEY, JSON.stringify(user)); this.userSubject.next(user); } logout(): void { localStorage.removeItem(this.TOKEN_KEY); localStorage.removeItem(this.USER_KEY); this.userSubject.next(null); } }这个小服务是后续所有守卫的基础。你会注意到我在loadUser里加了try...catch因为localStorage里的字符串不一定能正常JSON.parse比如用户手动改过或者存了脏数据这些边界情况都要处理不能让应用直接崩掉。3.2 基于角色的权限判断RBAC模型怎么落进守卫里权限控制只做登录拦截是不够的。现实中大多数后台系统都是RBAC模型用户有角色角色有权限权限对应到具体的路由或操作按钮。前端要实现路由级的RBAC通常的做法是在路由的data字段里配置需要的角色或权限码守卫里取当前用户的角色做匹配。// 路由配置示例 { path: admin, loadChildren: () import(./admin/admin.module).then(m m.AdminModule), canActivate: [authGuard, roleGuard], data: { roles: [admin] // 只有admin角色能访问 } }角色守卫的职责是当前路由的data里如果声明了roles就检查当前用户角色有没有交集没声明就直接放行因为不需要做角色校验。import { CanActivateFn, Router, UrlTree } from angular/router; import { inject } from angular/core; import { AuthService } from ../services/auth.service; export const roleGuard: CanActivateFn (route, state, ) { const authService inject(AuthService); const router inject(Router); const requiredRoles route.data?.[roles] as string[] | undefined; if (!requiredRoles || requiredRoles.length 0) { return true; } const user authService.getCurrentUser(); if (!user) { return router.createUrlTree([/login]); } const hasRole user.roles.some(role requiredRoles.includes(role)); if (hasRole) { return true; } // 没有权限时跳转到403页面而不是返回false return router.createUrlTree([/403]); };这里有个细节值得展开没有权限时很多人习惯返回false然后页面就不动了用户也不知道发生了什么。更好的做法是给它一个urlTree跳转到403页面明确告诉用户你的权限不够。这对产品体验是很大的提升你只需要预先配置一个403路由就能让权限拦截变得可视、可理解。3.3 多守卫的执行顺序与短路机制Angular的路由配置允许一个路由挂多个守卫比如上面例子里的authGuard和roleGuard。很多人没搞明白的是多个守卫是并行执行的还是串行执行的如果一个失败另一个还会不会跑答案在源码里当同一个路由配置了多个守卫时Angular会从第一个开始依次执行如果某个守卫返回false或者UrlTree后面的守卫就不会再执行了整个导航被取消。只有前一个守卫返回true才会轮到下一个守卫。这意味着守卫的排列顺序很重要。我见过有人把authGuard放在roleGuard后面结果是如果用户未登录角色判断先跑了拿不到用户信息就报错。正确顺序是先做登录校验再做角色校验最后才轮到业务相关的判断。另外要注意并行执行的场景是父子路由各自的守卫父路由的canActivate和子路由的canActivate都会执行父路由的守卫先执行子路由守卫后执行任何一个返回false子路由都不会激活。这个机制可以用来做父路由统一拦截子路由精细控制的嵌套权限模型。3.4 和Vue路由守卫对比一下思路有什么异同angular和vue对比开发是个经常被提起的话题就路由守卫这一块两边的设计思路差异其实很有意思。Vue Router的全局前置守卫beforeEach是唯一的闸口你所有的逻辑都写在这一个函数里通过next()或return来控制放行或重定向。Angular则是多个守卫各管一段每个路由配置各自的canActivate数组职责更单一代码复用也更细粒度。各有优劣。Vue那种方式写起来很快适合中小项目但项目一大全局守卫函数会变成又臭又长的if...else堆砌Angular这种多守卫模式每类判断独立一个文件测试也更好写但配置成本略高理解门槛也稍微大一点。我的体会是如果你的Angular项目能用路由的data字段配合多个小守卫比把所有逻辑写在一个大而全的守卫里要清晰得多。守卫应该像乐高积木每一块职责单一组合起来才能应对复杂场景。4. 实操从零实现一个带登录拦截的后台系统4.1 初始化项目与目录规划说再多理论都不如直接跑一个例子。我们用一个Angular 17项目来做演示项目需求如下有一个后台首页和一个用户管理页面未登录用户访问任意页面都要跳转登录页登录成功后默认跳回之前要访问的页面user角色可以访问用户管理页其他角色访问会跳403。先创建项目ng new guard-demo --routing --stylescss创建完进入项目目录规划一下结构src/app/ ├── auth/ │ ├── auth.service.ts # 登录状态管理 │ ├── auth.guard.ts # 登录守卫 │ └── role.guard.ts # 角色守卫 ├── login/ │ ├── login.component.ts │ ├── login.component.html │ └── login.component.scss ├── pages/ │ ├── dashboard/ │ │ ├── dashboard.component.ts │ │ └── dashboard.component.html │ └── user-manage/ │ ├── user-manage.component.ts │ └── user-manage.component.html ├── page-not-found/ │ ├── 403.component.ts │ └── 403.component.html └── app.routes.ts # 路由配置这个结构把auth相关的服务放在独立目录里后续如果要加Interceptors请求拦截器、Token刷新逻辑都可以往这个目录里补。4.2 完整代码实现守卫、服务、路由配置一条龙AuthService直接复用上文那个版本再加一个getCurrentUser方法返回用户信息。然后创建authGuard和roleGuard两个守卫文件代码如下login.component.ts的登录逻辑里模拟一个登录接口调用登录成功后调用authService.login()并读取query参数里的redirect字段跳回原来要去的页面import { Component } from angular/core; import { FormBuilder, Validators, ReactiveFormsModule } from angular/forms; import { Router, ActivatedRoute } from angular/router; import { AuthService } from ../auth/auth.service; interface LoginResponse { token: string; user: { id: number; name: string; roles: string[]; }; } Component({ selector: app-login, standalone: true, imports: [ReactiveFormsModule], templateUrl: ./login.component.html }) export class LoginComponent { loginForm this.fb.group({ username: [admin, Validators.required], password: [123456, Validators.required] }); constructor( private fb: FormBuilder, private authService: AuthService, private router: Router, private route: ActivatedRoute ) {} onSubmit(): void { if (this.loginForm.invalid) { return; } // 模拟登录请求 const response: LoginResponse { token: fake-token, user: { id: 1, name: this.loginForm.value.username || admin, roles: [admin] } }; this.authService.login(response.token, response.user); // 跳转到登录前想去的页面如果没有就回首页 const redirect this.route.snapshot.queryParamMap.get(redirect); this.router.navigateByUrl(redirect || /dashboard); } }路由配置app.routes.ts把守卫挂上去import { Routes } from angular/router; import { authGuard } from ./auth/auth.guard; import { roleGuard } from ./auth/role.guard; export const routes: Routes [ { path: login, loadComponent: () import(./login/login.component).then(m m.LoginComponent) }, { path: dashboard, loadComponent: () import(./pages/dashboard/dashboard.component).then(m m.DashboardComponent), canActivate: [authGuard] }, { path: user-manage, loadComponent: () import(./pages/user-manage/user-manage.component).then(m m.UserManageComponent), canActivate: [authGuard, roleGuard], data: { roles: [admin] } }, { path: 403, loadComponent: () import(./pages/page-not-found/403.component).then(m m.ForbiddenComponent) }, { path: , redirectTo: /dashboard, pathMatch: full }, { path: **, redirectTo: /dashboard } ];这里用了loadComponent懒加载注意懒加载组件和守卫一起使用时守卫的执行一定发生在组件加载之前所以即使模块没加载守卫也能正常工作这一点很多人会混淆。4.3 登录拦截的完整流程演示配置好了之后我们过一遍整个流程方便你验证自己的理解对不对。场景一未登录用户直接在浏览器里访问http://localhost:4200/user-manage。第一步Angular启动路由匹配找到user-manage这个路由发现它的canActivate数组里有authGuard和roleGuard。第二步authGuard先执行AuthService里userSubject的value是nullisLoggedIn返回falseauthGuard直接返回一个指向/login并携带redirectuser-manage的UrlTree。第三步roleGuard不会再执行导航被取消浏览器地址变成http://localhost:4200/login?redirectuser-manage登录页出现。场景二用户输入用户名密码点击登录登录成功后调用router.navigateByUrl(/user-manage)这次AuthService里已经有用户信息了authGuard放行roleGuard再检查用户角色是admin也放行页面正常加载。场景三如果用户是一个只有user角色的人登录成功后访问/user-manageroleGuard发现requiredRoles是[admin]当前用户角色是[user]没有交集返回跳转/403的UrlTree用户看到403页面。这套流程里最核心的一点是所有判断都发生在路由激活之前也就是说页面组件根本不会被创建。这比在组件里写判断要安全得多——未登录用户连组件的初始化代码都不会执行敏感数据也不会被加载到内存里。5. 常见问题与排查技巧实录5.1 守卫不生效先检查这三个地方我在维护别人项目时遇到过几次守卫完全不生效的情况最后定位下来基本都是三个原因。第一个原因是路由配置了但没挂守卫。听起来像是废话但确实有人把canActivate写在了子路由上结果父路由自己没挂守卫用户直接访问父路由的路径就绕过拦截了。尤其是有多个层级的路由时每个层级都得仔细检查。第二个原因是接口实现错误。Angular的CanActivateFn导出没问题但你在实现时没写export关键字或者写成了普通方法而不是箭头函数导致类内部引用时报错。这个在JavaScript编译阶段可能不会立刻暴露等路由执行时才报错。第三个原因是路由懒加载和守卫的加载顺序问题。如果守卫定义在懒加载模块内部而这个模块还没加载Angular是无法执行这个守卫的。正确做法是守卫必须放在被加载的模块之外通常放在AppModule级别的共享目录里。我用这个原则来规避90%以上的懒加载相关守卫问题。5.2 跳转登录页后陷入死循环怎么办这是一个非常典型的问题登录页本身也配置了守卫而这个守卫判断未登录时跳回登录页于是出现去登录页 - 守卫发现未登录 - 又跳登录页 - 无限循环。解决方案就一条登录页永远不要挂authGuard。更准确地说是不要把登录页放进需要登录才能访问的范围里。如果你的项目里把所有路由都包在一个父路由下而这个父路由挂了守卫那登录页就不能放在这个父路由下面需要提到父路由之外。还有一种更隐蔽的循环登录成功后调用navigateByUrl(redirect)而这个redirect如果是登录页自己也会陷入循环。建议在navigateByUrl之前加一层判断const redirect this.route.snapshot.queryParamMap.get(redirect); if (redirect redirect ! /login) { this.router.navigateByUrl(redirect); } else { this.router.navigateByUrl(/dashboard); }5.3 页面刷新后守卫状态丢失怎么恢复登录态刷新页面时整个Angular应用会重新启动内存里所有状态全部清空。如果只依赖内存里的userSubject刷新后守卫必然认为用户未登录直接踢回登录页体验很糟。解决办法是刷新时从localStorage恢复登录状态。也就是AuthService在构造函数里或者应用初始化时执行loadUser()从localStorage读token和user信息重新填充userSubject。我在上文的AuthService代码里已经做了这件事。但还有一个更复杂的场景localStorage里有token但token可能已经过期了。这时候需要调后端接口验证token有效性。这就要用到APP_INITIALIZER或者路由守卫里的异步处理// 在守卫里做token过期校验 export const authGuard: CanActivateFn async (route, state) { const authService inject(AuthService); const router inject(Router); if (authService.isLoggedIn) { return true; } // 如果本地有token尝试静默登录 if (authService.hasToken()) { try { const user await authService.fetchCurrentUser().toPromise(); authService.setUser(user); return true; } catch (e) { authService.logout(); } } return router.createUrlTree([/login], { queryParams: { redirect: state.url } }); };这个方案虽然可行但我个人的建议是token过期校验这种逻辑不要放在路由守卫里做。守卫的职责是能不能进而不是怎么恢复状态。恢复状态应该放在应用的初始化阶段比如用APP_INITIALIZER在应用启动时就完成token校验和用户信息获取守卫只需要从内存里取值就行这样职责清晰也不会出现每个路由都触发一次token校验的性能浪费。5.4 异步守卫的坑Observable不完结导致页面白屏最后聊一个很少被文档提但很容易踩的坑异步守卫的流不结束。你以为CanActivate返回一个Observable 就行但如果这个Observable是来自一个Subject它永远不会发射值守卫就永远在等待状态页面就一直转圈或白屏。此时Angular既不会取消导航也不会报错排查起来非常困难。我当时的场景是从Ngrx Store里select用户状态直接用了一个selector返回的Observable// 错误示范 canActivate(): Observableboolean { return this.store.select(selectIsLoggedIn); }这个Observable只要store不变化它就一直不发射最终值守卫就一直等着。正确做法是加first()让流取第一个值就结束// 正确示范 canActivate(): Observableboolean { return this.store.select(selectIsLoggedIn).pipe(first()); }以后再遇到守卫不生效但页面也不跳转的情况先看你的Observable是不是需要手动完结。这个是排查方向比无头苍蝇一样到处debug强得多。6. 我的一些心得和补充建议回到开头那个项目后来我们把所有路由守卫收敛到auth目录下还额外做了一件很重要的事在守卫之外加了一层HTTP拦截器专门处理接口返回401的情况。两者的分工很明确路由守卫管页面入口拦截器管接口请求。如果你出现了路由进去了但接口全部报401的情况就说明你只做了页面拦截没做接口请求的统一拦截前后端的状态校验是脱节的。拦页面和拦接口缺一不可这才是完整的前端权限控制闭环。另外提一句CanActivate守卫和Angular的依赖注入体系是天然联动的这也意味着你可以在守卫里使用任意服务日志服务、埋点服务、配置服务都可以。比如我常在一个叫auditGuard的守卫里做页面访问埋点用户每次进入敏感页面都会自动打一条日志这个需求用守卫来做非常方便。如果你要把这套方案应用到生产项目里我最后再叮嘱一句不要写一个超大的AuthGuard处理所有逻辑而是拆分成多个单一职责的小守卫组合使用。这样不仅每个守卫可以单独测试权限模型变更的时候你只需要调整路由配置里的canActivate数组顺序而不需要动守卫内部的代码。实际上Angular路由守卫的设计哲学就是小而专每个守卫只回答一个问题组合起来回答复杂问题。用好了这套机制路由权限控制就不再是if...else堆砌的代码而是清晰可控的配置声明。