[Angular 基础] - routing 路由(下)
之前部分 Angular 笔记:
-
[Angular 基础] - 自定义指令,深入学习 directive
-
[Angular 基础] - service 服务
-
[Angular 基础] - routing 路由(上)
使用 route
书接上回,继续折腾 routing
按照最初的 wireframe,它的实现是这样的:
之前为了简化一些实现,就直接采取了在 routes
下面声明一个新的路径,去采用重新渲染子组件的方式去进行重定向。这也会有几个比较麻烦的点:
- 数据渲染不完全
- 从
servers/:id
返回到servers
很麻烦
如果想要解决这个问题,将子组件重新渲染:
<div class="col-xs-12 col-sm-4"><app-edit-server></app-edit-server><hr /><app-server></app-server>
</div>
又会遇到下面这些问题:
-
在主界面时代码抛出异常
有些情况
Cannot read properties of undefined
是会 break 页面,从而导致内容无法渲染的情况会有这个问题也是因为子页面尚未被选中,路径还是
http://localhost:4200/servers
,自然无法获取对应的 server 数据 -
渲染了额外数据
理想条件下,当在
/servers
这个路径下,代表着没有任何的服务器被选中,那么也不应该渲染对应的组件
为了更加优雅的解决这个问题,而不是使用大量的 ngIf
去进行条件控制,Angular 提供了 child/nested routes 的解决方案
child route
突然想到, react-router v6 也实现了这个功能……果然这日子还是到了前端框架互相抄的日子了……
这里具体修改的配置如下:
-
routing module
const appRoutes: Routes = [{path: 'servers',component: ServersComponent,children: [{path: ':id',component: ServerComponent,},{path: ':id/edit',component: EditServerComponent,},],}, ];
-
servers V 层
<div class="row"><div class="col-xs-12 col-sm-4"><div class="list-group"><a[routerLink]="['/servers', server.id]"[queryParams]="{ allowEdit: server.id === 3 ? '1' : '0' }"fragment="loading"class="list-group-item"*ngFor="let server of servers">{{ server.name }}</a></div></div><div class="col-xs-12 col-sm-4"><router-outlet></router-outlet></div> </div>
实现后的效果:
就像是在 appRoutes
中定义的一样,一旦路径为 servers/:id
,那么就会渲染单独的 server component,如果路径是 servers/:id/edit
,就会渲染 edit-server component
我的理解是,一旦将对应的组件放到了 children
中,就会形成以父组件为基础的一个单独模块。Angular 就像处理其他模块一样,都需要通过一个 router-outlet
去进行监听,才能做出合适的反应
如果 V 层没有添加 router-outlet
的话,那么 children
中的模块就不会被渲染
更多 query param 的部分
上面的动图其实还有一个问题,那就是当从 servers/:id
定向到 servers/:id/edit
时,后置的 query parameters 全都丢了,这也是 Angular 的默认行为,想要改变这个行为,需要在 click handler 中进行处理,下面是对于 edit server
按钮的事件处理:
export class ServerComponent implements OnInit {onEdit() {this.router.navigate(['edit'], {relativeTo: this.route,queryParamsHandling: 'preserve',});}
}
这里的 queryParamsHandling
有三个值:
export declare type QueryParamsHandling = 'merge' | 'preserve' | '';
-
merge
会将当前组件有的 query parameters 与已经存在的 query parameter 进行合并
-
preserve
会保留已经存在的 query parameter
这时候的效果如下:
可以看到,allowEdit
这个参数被保留下来了,对应的 EditServerComponent
也可以通过当前的 query parameter 进行正常的逻辑处理
⚠️:EditServerComponent
有两个 subscription,一个 subscribe queryParams
,另一个则是 subscribe 当前传来的 server id:
export class EditServerComponent implements OnInit {ngOnInit() {this.route.queryParams.subscribe((queryParams: Params) => {this.allowEdit = queryParams.allowEdit === '1';});this.route.fragment.subscribe();const id = parseInt(this.route.snapshot.params.id);// subscribe route params to update the id if params changethis.server = this.serversService.getServer(id);this.serverName = this.server.name;this.serverStatus = this.server.status;this.route.params.subscribe((params: Params) => {this.server = this.serversService.getServer(parseInt(params.id));this.serverName = this.server.name;this.serverStatus = this.server.status;});}
}
添加 not found 页面
根据当前的实现,如果用户意外输入了一些错误的路径,那么就会重新导航到首页,并且在 console 中输出报错信息:
这也是一个相对而言比较粗暴的处理方式,大多数的应用都会渲染一个 not found 页面,而不是直接显示一个空白屏幕。接下来就更新 app routing module 进行实现:
const appRoutes: Routes = [// 其余处理不变{ path: 'not-found', component: PageNotFoundComponent },{ path: '**', redirectTo: '/not-found' },
];
⚠️:这里假设 Not Found 页面已经通过 ng generate component
被创建了,也实现了对应的 V 层
显示效果如下:
这里业务实现的逻辑也比较简单,首先是确定一个为 not-found
的路径,将对因渲染的组件设置为 PageNotFoundComponent
。接下来就是终点的实现了,也就是一个 wildcard match,这个 match 必须 放在最下面,这样无法被之前路由 capture 的变化会落到这里,否则所有的页面都会渲染为 PageNotFoundComponent
组件。
当当前路由找不到当前提供的路径时,它就会被重新定向到 /not-found
页面,从而渲染 PageNotFoundComponent
组件
另一个稍微简单一点的处理方式就是直接将 **
和 component: PageNotFoundComponent
进行绑定:{ path: '**', component: PageNotFoundComponent
,这样就可以保留当前的路径,并渲染 PageNotFoundComponent
组件
这里有一点像是 React Router Dom v5 里的 Switch
,不过 React Router Dom 里面的 wildcard 时 *
,这里是 **
⚠️:Agular 的路径 match 是通过前缀实现的,在 not found page 这个案例的情况是 wildcard,不会造成任何的问题。
👀:一个使用情况可以从 ''
导航到 '/home
,这时候配置可以这么写:{ path: '', redirectTo: 'home', patchMatch: 'full' },
Guards
guards 是用来控制路由的,博啊哭哦权限控制、是否能被访问等一些行为,每个 guard 都有不同的功能
canActive
这个 guard 用来控制当前路由是否可以被访问
这里以 servers 为例,条件控制为只有登录的用户才可以访问 /servers
,变动如下:
-
routing module
const appRoutes: Routes = [{path: 'servers',canActivate: [AuthGuardService],component: ServersComponent,}, ];
-
新增
AuthGuardService
@Injectable({ providedIn: 'root', }) export class AuthGuardService implements CanActivate, CanActivateChild { constructor(private authService: AuthService, private router: Router) {}canActivate(route: ActivatedRouteSnapshot,state: RouterStateSnapshot ): Observable<boolean> | Promise<boolean> | boolean {return this.authService.isAuthenticated().then((authenticated: boolean) => {if (authenticated) {console.log('authenthenciated');return true;} else {console.log('not authenthenciated');this.router.navigate(['/']);return false;}}); }
其中 isAuthenticated
的实现如下:
isAuthenticated() {const promise = new Promise((resolve, reject) => {setTimeout(() => {resolve(this.login);}, 1000);});return promise;}
展示效果如下:
这里 isAuthenticated
返回的永远是一个 promise
,并且会在一秒钟后完成登陆,所以永远不会被重定向到首页。稍微代码,将 resolve 中改成 false
,并修改一下冲定向的路径也可以实现一下效果:
这里都有一个 1s 的等待时间,这个是 setTimeout
决定的
需要注意的是 CanActivate
已经 deprecated 了,原因似乎也是 Angular 要走走 functional guard,而 functional guard 的实现如下:
这里的实现稍微复杂一些,
-
首先将
then
那一块代码抽出来,放到一个新的 service 中:@Injectable({providedIn: 'root', }) export class PermissionsService {constructor(private authService: AuthService, private router: Router) {}canActivate(route: ActivatedRouteSnapshot,state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {return this.authService.isAuthenticated().then((authenticated: boolean) => {if (authenticated) {console.log('authenthenciated');return true;} else {console.log('not authenthenciated');this.router.navigate(['/forbidden']);return false;}});} }
⚠️:这里使用
canActivate
只是一个约定俗成的规范,它可以改成任何一个名称,不是一定要用canActivate
-
使用 functional guard
export const authGuardFunc: CanActivateFn = (route, state) => {return inject(PermissionsService).canActivate(route, state); };
⚠️:直接实现下面的代码,ide 不会报错,但是浏览器会报错:
export const authGuardFunc: CanActivateFn = (route, state) => {return inject(AuthService).isAuthenticated().then((authenticated: boolean) => {if (authenticated) {console.log('authenthenciated');return true;} else {console.log('not authenthenciated');inject(PermissionsService).canActivate(route, state);return false;}}); };
canActiveChild
有一个情况就是,用户可以访问 servers
,但是不能访问 servers
的 children。一个解决方法是将 canActivate: [AuthGuardService],
cv 到每一个 child component,不过当 child component 变多的时候,管理就会变得非常的麻烦。
如果当前逻辑应用于所有的 child component,就可以使用 canActiveChild
去实现:
@Injectable({providedIn: 'root',
})
export class AuthGuardService implements CanActivate, CanActivateChild {constructor(private authService: AuthService, private router: Router) {}// 新增部分,可以直接调用之前实现的 canActivatecanActivateChild(childRoute: ActivatedRouteSnapshot,state: RouterStateSnapshot): boolean | Observable<boolean> | Promise<boolean> {return this.canActivate(childRoute, state);}
}
并在 routing module 中实现对应的修改:
const appRoutes: Routes = [{path: 'servers',canActivateChild: [AuthGuardService],// 其余部分省略},
];
效果如下:
同样,对应的 functional guard 的修改如下:
-
permissions service 的修改:
export class PermissionsService {constructor(private authService: AuthService, private router: Router) {}canActiveChild(route: ActivatedRouteSnapshot,state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {return this.canActivate(route, state);} }
-
func guard 的修改:
export const authGuardFunc: CanActivateFn = (route, state) => {return inject(PermissionsService).canActivate(route, state); };export const authGuardChildFunc: CanActivateChildFn = (route, state) => {return inject(PermissionsService).canActiveChild(route, state); };
调用的时候则是直接在 canActivateChild
中放入 authGuardChildFunc
即可
鉴于代码完全一致,放入 authGuardFunc
也没问题啦……
canDeactivate
canDeactivate
是一个在离开当前页面时会触发的 guard,一般可以用来检查未保存的内容,防止用户提前离开
具体实现方式如下:
-
创造一个 service,具体实现如下:
export interface CanComponentDeactivate {canDeactivate: () => Observable<boolean> | Promise<boolean> | boolean; }@Injectable({providedIn: 'root', }) export class CanDeactivateGuardimplements CanDeactivate<CanComponentDeactivate> {canDeactivate(component: CanComponentDeactivate,currentRoute: ActivatedRouteSnapshot,currentState: RouterStateSnapshot,nextState: RouterStateSnapshot): boolean | Observable<boolean> | Promise<boolean> {return component.canDeactivate();} }
-
在 routing module 中添加 guard:
const appRoutes: Routes = [{path: 'servers',children: [{path: ':id/edit',component: EditServerComponent,canDeactivate: [CanDeactivateGuard],},],}, ];
-
EditServerComponent
实现canDeactivate
函数如果不实现的话,在离开当前页面会报错,也就 break project 了:
这也是为什么 Angular 的实现这么复杂……主要还是为了类型检查,以及 添加的功能都必须实现 这样的检查
具体实现如下:
@Component({selector: 'app-edit-server',templateUrl: './edit-server.component.html',styleUrls: ['./edit-server.component.css'], }) export class EditServerComponent implements CanComponentDeactivate {serverName = '';serverStatus = '';allowEdit = false;changesSaved = false;canDeactivate(): boolean | Promise<boolean> | Observable<boolean> {if (!this.allowEdit) {return true;}if ((this.serverName !== this.server.name ||this.serverStatus !== this.server.status) &&!this.changesSaved) {return confirm('Do you want to discard the changes?');} else {return true;}} }
实现完成后的效果:
这里有 3 种情况:
-
当用户没有编辑权限
✅ 直接允许重定向
-
当用户有编辑权限,但是用户 没有 编辑内容
✅ 直接允许重定向
-
当用户有编辑权限,并且用户 已经 编辑内容
❌ 不允许直接冲定向
这里的具体操作是跳出一个
confirm
,当用户确认后,即可重新定向
这里也提出 functional guard 的实现方式,鉴于其他的变量名不变,所以这里只需要修改 CanDeactivateGuard
service 即可:
export interface CanComponentDeactivate {canDeactivate: () => Observable<boolean> | Promise<boolean> | boolean;
}export const CanDeactivateGuard: CanDeactivateFn<CanComponentDeactivate> = (component: CanComponentDeactivate
): Observable<boolean> | boolean => {if (component.canDeactivate && component.canDeactivate()) return true;
};// @Injectable({
// providedIn: 'root',
// })
// export class CanDeactivateGuard
// implements CanDeactivate<CanComponentDeactivate>
// {
// canDeactivate(
// component: CanComponentDeactivate,
// currentRoute: ActivatedRouteSnapshot,
// currentState: RouterStateSnapshot,
// nextState: RouterStateSnapshot
// ): boolean | Observable<boolean> | Promise<boolean> {
// return component.canDeactivate();
// }
// }
resolve
canActivate
是控制用户允许访问当前页面, canDeactivate
是控制用户不允许访问当前页面,resolve
则是允许等待一段时间(如获取数据的异步操作),在完成操作后渲染组件
实现如下:
-
创建一个新的 resolver service
interface Server {id: number;name: string;status: string; }@Injectable({providedIn: 'root', }) export class ServerResolverService implements Resolve<Server> {constructor(private serversService: ServersService) {}resolve(route: ActivatedRouteSnapshot,state: RouterStateSnapshot): Server | Observable<Server> | Promise<Server> {const promise: Promise<Server> = new Promise((resolve) => {setTimeout(() => {console.log('resolving');resolve(this.serversService.getServer(parseInt(route.params.id)));}, 1000);});return promise;} }
这个 service 就是实现一个 resolver,即在组件渲染之前获取对应的 server。我这里用
setTimeout
模拟了一个异步操作 -
更新 routing module
这里制定要使用 resolver 的组件,即
servers/:id
const routes = (Routes = [{path: 'servers',children: [{path: ':id',component: ServerComponent,resolve: { server: ServerResolverService },},],}, ]);
这一步操作会将获取的
server
——resolve 的数据——存储到server
这个变量名中 -
更新 server component
export class ServerComponent implements OnInit {server: { id: number; name: string; status: string };ngOnInit() {this.route.data.subscribe((data: Data) => {console.log(data);this.server = data.server;});} }
这里主要更新
ngOnInit
中的内容,最终的效果与实现的效果是一致的
最终效果:
可以看到渲染被延迟了大概一秒钟,然后输出了对应的 server
同样增添一下 functional guard 的实现:
export const serverResolver: ResolveFn<Server> = (route) => {const serverId = parseInt(route.params.id);return inject(ServersService).getServer(serverId);
};
⚠️:getServer
返回的是一个 Server
传输数据
之前在 canActivate
guard 中创建了一个 forbidden
页面,这样每次页面报错都会重新导航到 /forbidden
上去。但是这样做的一个问题就在于,如果想要做更多的报错处理,那么可能需要创造更多的报错页面。
下面提供一个可以复用
下面是步骤:
-
创建一个新的 generic error 页面
- V 层
<h4>{{ errorMessage }}</h4>
-
VM 层
@Component({selector: 'app-error-page',templateUrl: './error-page.component.html',styleUrl: './error-page.component.css', }) export class ErrorPageComponent implements OnInit {errorMessage: string;constructor(private route: ActivatedRoute) {}ngOnInit() {this.errorMessage = this.route.snapshot.data['message'];this.route.data.subscribe((data: Data) => {this.errorMessage = data.message;});} }
-
修改 app routing
const appRoutes: Routes = [{path: 'not-found',component: ErrorPageComponent,data: { message: 'Page not found!' },},{path: '**',redirectTo: '/not-found',}, ];
效果如下:
这样可以创建多个不同的路径,并传输不同的信息,实现使用一个 ErrorPageComponent
渲染不同的报错信息
如果搭配其他的 Observable,这里应该也是可以实现避开重复声明路由,而是直接用 **
wildcard 去渲染 ErrorPageComponent
,随后使用 Observable 获取报错信息