解析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 {
      // 组件恢复时
    }
  }
});
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 143,906评论 1 305
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 61,698评论 1 257
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 95,242评论 0 213
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 41,257评论 0 182
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 49,076评论 1 260
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 38,912评论 1 178
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 30,509评论 2 274
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,255评论 0 168
  • 想象着我的养父在大火中拼命挣扎,窒息,最后皮肤化为焦炭。我心中就已经是抑制不住地欢快,这就叫做以其人之道,还治其人...
    爱写小说的胖达阅读 29,145评论 6 235
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 32,613评论 0 214
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,376评论 2 216
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 30,733评论 1 231
  • 白月光回国,霸总把我这个替身辞退。还一脸阴沉的警告我。[不要出现在思思面前, 不然我有一百种方法让你生不如死。]我...
    爱写小说的胖达阅读 24,287评论 0 32
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,196评论 2 213
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 31,644评论 3 211
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,651评论 0 9
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,059评论 0 169
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 33,644评论 2 233
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 33,789评论 2 237

推荐阅读更多精彩内容

  • 一、对于 MVVM 的理解?# MVVM是 Model-View-Viewmodel的缩写,Model代表数据模型...
    一朵er阅读 248评论 0 0
  • 本文首发于TalkingCoder,一个有逼格的程序员社区。转载请注明出处和作者。 写在前面 本文为系列文章,总共...
    Aresn阅读 9,435评论 0 42
  • 主要还是自己看的,所有内容来自官方文档。 介绍 Vue.js 是什么 Vue (读音 /vjuː/,类似于 vie...
    Leonzai阅读 3,251评论 0 25
  • VUE介绍 Vue的特点构建用户界面,只关注View层简单易学,简洁、轻量、快速渐进式框架 框架VS库库,是一封装...
    多多酱_DuoDuo_阅读 2,595评论 1 17
  • 什么是组件? 组件 (Component) 是 Vue.js 最强大的功能之一。组件可以扩展 HTML 元素,封装...
    youins阅读 9,376评论 0 13