解析Angular路由组件缓存复用和实现问题
很多接触过vuejs的同学对keep-alive这个指令是印象比较深刻的,它可以指定vue组件缓存之前的状态,不论是路由组件还是动态切换组件,不像正常的组件一样进行销毁和重新实例化,这在有些功能的实现显得十分重要。而且,这在其他两个框架并没有官方提供指令,从这点可以看出vue设计的巧妙,以及对需求的考虑细致。
在angular中,虽然不能像vue一样自由缓存组件示例状态,却提供了一个路由复用策略来实现对路由组件实例的缓存和复用,我们平时使用的多级嵌套路由在切换时上层路由出口的实例不会重现实例化,就是angular内部使用默认的路由复用策略实现的,这点在看完下面的流程分析就明白了。
一、概念
路由树
我们知道,在配置了路由导航的angular应用会形成一棵应用的路由树,像下面这样
route-reuse-tree.png
应用会从根开始逐级去匹配每一级的路由节点和routeConfig,并检测实例化路由组件,其中routeConfig涵盖树里的每一个节点,包括懒加载路由
路由复用策略
RouteReuseStrategy是angular提供的一个路由复用策略,暴露了简单的接口
abstract class RouteReuseStrategy {
// 判断是否复用路由
abstract shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean
// 存储路由快照&组件当前实例对象
abstract store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void
// 判断是否允许还原路由对象及其子对象
abstract shouldAttach(route: ActivatedRouteSnapshot): boolean
// 获取实例对象,决定是否实例化还是使用缓存
abstract retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null
// 判断路由是否允许复用
abstract shouldDetach(route: ActivatedRouteSnapshot): boolean
}
二、方法解析
1) shouldReuseRoute
检测是否复用路由,该方法根据返回值来决定是否继续调用,如果返回值为true则表示当前节点层级路由复用,将继续下一路由节点调用,入参为的future和curr不确定,每次都交叉传入;否则,则停止调用,表示从这个节点开始将不再复用。
两个路由路径切换的时候是从“路由树”的根开始从上往下层级依次比较和调用的,并且两边每次比较的都是同一层级的路由节点配置。root路由节点调用一次,非root路由节点调用两次这个方法,第一次比较父级节点,第二次比较当前节点。
还是以上面的路由树为例,它的检测层级是这样的:
route-reuse-tree-2.png
对比图示,方法的每一次调用时比较的都是同一层级的路由配置节点,就是像图中被横线穿在一起的那些一样,即入参的future和curr是同级的。
举个例子,shouldReuseRoute方法的常见实现为:
shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
return future.routeConfig === curr.routeConfig;
}
这时当路由从“main/cop/web/pc”切换到“main/cop/fan/list/group”的调用顺序是这样的:
root --> main --> web / fan (返回false)
即到第3层的时候routeConfig不一样,返回false,调用结束,得到不复用的“分叉路由点”
这个方法得到的结果很重要,将作为其他好几个方法的基础
2) retrieve
紧接着shouldReuseRoute方法返回false的节点调用,入参route即是当前层级路由不需要复用。以上个例子说明,此时的route是main/cop/fan/
的路由节点。
retrieve调用根据返回结果来决定是否继续调用:如果返回的是null,当前路由对应的组件会实例化,并继续对其子级路由调用retrieve方法,直到遇到缓存路由或到末级路由。
在本次路由还原时也会调用,用来获取缓存示例
3) shouldDetach
用来判断刚刚离开的上一个路由是否复用,其调用的时机也是当前层级路由不需要复用,shouldReuseRoute方法返回false的时候。以上个例子说明,首次调用的入参route是main/cop/web/
的路由节点。
shouldDetach方法根据返回结果来决定是否继续调用:如果返回的是false,则继续下一层级调用该方法,当前路由对应的组件会实例化,并继续对其子级路由调用retrieve方法,直到返回true或者是最末级路由后才结束。
4) store
紧接着shouldDetach方法返回true的时候调用,存储需要被缓存的那一级路由的DetachedRouteHandle;若没有返回true的则不调用。
以上个例子说明,若我们设置了main/cop/web/pc
的keep=true,此时的入参route是main/cop/web/pc
节点,存储的是它的实例对象。
注意:
- 无论路径上有几个可以被缓存的路由节点,被存储的只有有一个,就是Detach第一次返回true的那次
- 在本次路由还原后也会调用一次此方法存储实例
5) shouldAttach
判断是否允许还原路由对象及其子对象,调用时机是当前层级路由不需要复用的时候,即shouldReuseRoute()返回false的时候,而且,并不是所有的路由层级都是有组件实例的,只有包含component的route才会触发shouldAttach。
如果反回false,将继续到当前路由的下一带有component的路由层级调用shouldAttach,直到返回true或者是最末级路由后才结束。
当shouldAttach返回true时就调用一次retrieve方法和store方法
6)调用顺序
shouldReuseRoute -> retrieve -> shouldDetach -> store -> shouldAttach -
-> retrieve(若shouldAttach返回true) -> store(若shouldAttach返回true)
下面是典型的调用顺序链截图:
屏幕快照 2019-10-20 下午12.16.57.png
三、使用问题
这个路由复用策略的使用限制比较大 ,一般需要路由组织层级标准化,且无法缓存多级路由出口嵌套的场景。
常用配置
import { RouteReuseStrategy, ActivatedRouteSnapshot, DetachedRouteHandle } from '@angular/router';
export class AppReuseStrategy implements RouteReuseStrategy {
public static handlers: { [key: string]: DetachedRouteHandle } = {};
shouldDetach(route: ActivatedRouteSnapshot): boolean {
// 若是全缓存可去掉此分支
if (!route.data.keep) {
return false;
}
return true;
}
store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
AppReuseStrategy.handlers[this.getRouteUrl(route)] = handle;
}
shouldAttach(route: ActivatedRouteSnapshot): boolean {
return !!AppReuseStrategy.handlers[this.getRouteUrl(route)];
}
retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {
if (!AppReuseStrategy.handlers[this.getRouteUrl(route)]) {
return null;
}
return AppReuseStrategy.handlers[this.getRouteUrl(route)];
}
shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
return future.routeConfig === curr.routeConfig && JSON.stringify(future.params) === JSON.stringify(curr.params);
}
/** 使用route的path作为快照的key */
getRouteUrl(route: ActivatedRouteSnapshot) {
const path = route['_routerState'].url.replace(/\//g, '_');
return path;
}
}
这个配置的使用限制很大,通常需要路由有严格的层级配置,一般在同一module下的同级路由组件之间的缓存和切换时很好用的,但是在不同module之间切换或者时缓存路由不同级时就会出现恢复的不是你想要的组件实例,或者经常遇到下面这种错误:
屏幕快照 2019-10-20 下午12.22.55.png
这种错误可以通过修改缓存的匹配逻辑来避免,我们也可以根据我们的使用业务来修改各个方法的逻辑条件来满足使用场景。
下面是时间总结的几种使用和避免错误的方法:
1、清除缓存实例
由于策略的使用限制,我们可以提供两个清除缓存的接口
// 清除单个路由缓存
public static deleteRouteSnapshot(path: string): void {
const name = path.replace(/\//g, '_');
if (AppReuseStrategy.handlers[name]) {
delete AppReuseStrategy.handlers[name];
}
}
// 清除全部路由缓存
public static clear(): void {
for (let key in AppReuseStrategy.handlers) {
delete AppReuseStrategy.handlers[key];
}
}
根据需要可以在其他组件的初始化调用这个接口做清除工作,更好的方法是利用路由守卫,在模块的共同父路由守卫里调用clear接口
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate, CanActivateChild, CanLoad {
...
canActivate(next: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {
...
AppReuseStrategy.clear();
...
}
这样可以避免不同模块之间切换的错误,在同一模块内的缓存和切换依然生效。
2、重组url
上面的方式虽然可行,但把策略的修改波及到其他地方,不内聚,可以通过修改缓存匹配URL的方式让策略自己实现而不上报reattach不匹配的错误:有缓存实例,复用;否则,实例化。修改上面的方案:
export class AppReuseStrategy implements RouteReuseStrategy {
public static handlers: { [key: string]: DetachedRouteHandle } = {};
public static currRouteConfig: any;
...
shouldAttach(route: ActivatedRouteSnapshot): boolean {
const diffUrl = this.getDiffRouteUrl(this.getRouteUrl(route));
return !!AppReuseStrategy.handlers[diffUrl];
}
retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {
const diffUrl = this.getDiffRouteUrl(this.getRouteUrl(route));
if (!AppReuseStrategy.handlers[diffUrl]) {
return null;
}
return AppReuseStrategy.handlers[diffUrl];
}
shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
if (future.routeConfig === curr.routeConfig &&
JSON.stringify(future.params) === JSON.stringify(curr.params)) {
return true;
} else {
AppReuseStrategy.currRouteConfig =curr.routeConfig;
return false;
}
}
getRouteUrl(route: ActivatedRouteSnapshot) {
const path = route['_routerState'].url.replace(/\//g, '_');
return path;
}
getDiffRouteUrl(path: any) {
if (AppReuseStrategy.currRouteConfig && AppReuseStrategy.currRouteConfig.children) {
for (let child of AppReuseStrategy.currRouteConfig.children) {
if (path.lastIndexOf(child.path) !== -1) {
return path.slice(0, path.lastIndexOf(`_${child.path}`));
}
}
return path;
} else {
return path;
}
}
}
3、只缓存叶子组件
事实上在我们路由树里,通常是有叶子路由节点需要被缓存和复用,依赖整个“树枝”一起存储占内存也没有必要,二来由于策略局限性也容易出现问题。存储叶子即可缓存指定的叶子节点,也可以在不同模块间自由切换,还是修改上面的例子:
export class AppReuseStrategy implements RouteReuseStrategy {
public static handlers: { [key: string]: DetachedRouteHandle } = {};
shouldDetach(route: ActivatedRouteSnapshot): boolean {
console.debug('shouldDetach======>', route);
if (!route.data.keep) {
return false;
}
return true;
}
store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
console.debug('store======>', route, handle);
AppReuseStrategy.handlers[this.getRouteUrl(route)] = handle;
}
shouldAttach(route: ActivatedRouteSnapshot): boolean {
console.debug('shouldAttach======>', route);
return !route.routeConfig.children && !route.routeConfig.loadChildren &&
!!AppReuseStrategy.handlers[this.getRouteUrl(route)];
}
retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {
console.debug('retrieve======>', route);
if (route.routeConfig.children || route.routeConfig.loadChildren || !AppReuseStrategy.handlers[this.getRouteUrl(route)]) {
return null;
}
return AppReuseStrategy.handlers[this.getRouteUrl(route)];
}
shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
console.debug('shouldReuseRoute======>');
return future.routeConfig === curr.routeConfig && JSON.stringify(future.params) === JSON.stringify(curr.params);
}
getRouteUrl(route: ActivatedRouteSnapshot) {
const path = route['_routerState'].url.replace(/\//g, '_');
return path;
}
}
若要支持非叶子节点的缓存,可以增加次标志符,比如perantKeep,如下:
...
path: 'cop-project',
canActivate: [AuthGuard],
data: {perantKeep: true},
children: [
...
]
修改策略方法:
shouldDetach(route: ActivatedRouteSnapshot): boolean {
console.debug('shouldDetach======>', route);
if (!route.data.keep && !route.data.perantKeep) {
return false;
}
return true;
}
store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
AppReuseStrategy.handlers[this.getRouteUrl(route)] = handle;
}
shouldAttach(route: ActivatedRouteSnapshot): boolean {
return (route.data.keepParent || !route.routeConfig.children && !route.routeConfig.loadChildren) &&
!!AppReuseStrategy.handlers[this.getRouteUrl(route)];
}
retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {
if ((!route.data.keepParent && (route.routeConfig.children || route.routeConfig.loadChildren)) ||
!AppReuseStrategy.handlers[this.getRouteUrl(route)]) {
return null;
}
return AppReuseStrategy.handlers[this.getRouteUrl(route)];
}
shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
return (!curr.data.keepParent || !future.data.keepParent) &&
(future.routeConfig === curr.routeConfig && JSON.stringify(future.params) === JSON.stringify(curr.params));
}
有时候我们想在缓存页面的切出和切入时干点事情,因为此时组件不再重新初始化,以前放在Init和Destroy钩子里做的事情可能需要考虑找个时机来做,可以使rxjs订阅来做,修改策略代码,增加subject,
import { RouteReuseStrategy, ActivatedRouteSnapshot, DetachedRouteHandle } from '@angular/router';
import { Observable, Subject } from 'rxjs';
export class RouteMsg {
url: string = '';
type: string = '';
constructor(type: string, url: string) {
this.type = type;
this.url = url;
}
}
export class AppReuseStrategy implements RouteReuseStrategy {
public static handlers: { [key: string]: DetachedRouteHandle } = {};
public static routeText$ = new Subject<RouteMsg>();
public static getRouteText(): Observable<RouteMsg> {
return AppReuseStrategy.routeText$.asObservable();
}
shouldDetach(route: ActivatedRouteSnapshot): boolean {
if (!route.data.keep) {
return false;
}
AppReuseStrategy.routeText$.next(new RouteMsg('detach', route['_routerState'].url));
return true;
}
retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {
if ((!route.data.keepParent && (route.routeConfig.children || route.routeConfig.loadChildren)) || !AppReuseStrategy.handlers[this.getRouteUrl(route)]) {
return null;
}
AppReuseStrategy.routeText$.next(new RouteMsg('attach', route['_routerState'].url));
return AppReuseStrategy.handlers[this.getRouteUrl(route)];
}
...
}
在对应组件订阅该对象
AppReuseStrategy. getRouteText().subscrib(res => {
if(res.res === this.url) {
if(res.type === 'detach') {
// 组件切换出
} else {
// 组件恢复时
}
}
});