CSS調整

2018年8月15日

AngularのRouteReuseStrategyを実装する

AngularのRouteReuseStrategyを実装してみます。

RouteReuseStrategyとは

その名の通り、RouteをReuseする為のStrategyになります。(おい)
例えば、/hogeでHogeComponentが描画されたとして、そこから、/fugaへ画面遷移してFugaComponentが描画され、その後、ブラウザバック等で/hogeに戻りました、というときに、HogeComponentを画面遷移前のまま表示したりできます。
要するにキャッシュのような仕組みということですね。
(実際はAndroidのFragmentManagerのaddに近いのかな?)

パッと思いつくユースケースは、一覧 → 詳細 → 一覧へ戻る、とかでページング状態等を保持するとかでしょうか。

RouteReuseStrategyの実装


まずは、RouteReuseStrategyをimplします。

export class MyRouteReuseStrategy implements RouteReuseStrategy {}

Moduleにprovideも必要です。

providers:[
    { provide: RouteReuseStrategy, useClass: MyRouteReuseStrategy }
]

共通処理の実装


先に共通処理を実装します。

private storedHandles: { [path: string]: DetachedRouteHandle } = {};

private getPath(route: ActivatedRouteSnapshot) {
    let url = route.pathFromRoot.filter(r => r.url.length > 0).map(r => r.url.map(u => u.path).join("/")).join("/");
    return url.length > 0? url: null;
}

private isReuse(route: ActivatedRouteSnapshot) {
    return route.routeConfig && !route.routeConfig.loadChildren &&
           route.routeConfig.data && route.routeConfig.data.reuse;
}

それぞれ、

storedHandlesが、Componentのキャッシュに相当するものを保持しておく入れ物になります。

getPathが、routeからpathの文字列を取得する処理で、取得したpathはstoredHandlesの配列キーになります。

isReuseが、reuse対象かどうかの判定処理で、

・loadChildrenなrouteでないこと=そのrouteがmoduleを跨がないこと
・後述のroute.dataにreuseが指定されていること

としました。

RouteReuseStrategyで実装が必要なメソッドは5つ

shouldDetach、storeの実装


shouldDetach(route: ActivatedRouteSnapshot): boolean {
    return this.isReuse(route);
}

store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
    if (this.isReuse(route)) {
        let path = this.getPath(route);
        if (path) this.storedHandles[path] = handle;
    }
}

shouldDetachは、そのrouteを残すかどうかを判定します。trueを返した場合、storeが呼ばれます。
storeは、実際に保持する処理になります。渡されてくるhandleをpath単位で保持する形としました。

shouldAttach、retrieveの実装


shouldAttach(route: ActivatedRouteSnapshot): boolean {
    if (this.isReuse(route)) {
        let path = this.getPath(route);
        if (path) return !!this.storedHandles[path];
    }
    return false;
}
retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {
    if (this.isReuse(route)) {
        let path = this.getPath(route);
        if (path) return this.storedHandles[path] || null;
    }
    return null;
}

shouldAttachは、保持されているものを使うかどうか判定します。trueを返した場合、retrieveが呼ばれます。
retrieveは、実際に保持されているものを渡す処理になります。pathを利用して取得できたら返す形としました。ちなみに、nullを返した場合は通常どおりに新しいComponentが作成されます。

shouldReuseRouteの実装


shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
    return future.routeConfig === curr.routeConfig;
}

shouldReuseRouteはreuseする対象かどうかの判定に使います。
futureとcurrはそれぞれ今と次のRouteで、同じrouteであることを条件とすることにしました。

これで一応の機構はできました。
Routesの定義時に上のisReuseで取得しているdata.reuseを設定したrouteが、対象になります。

Routesの定義


const HogeRoutes: Routes = [
  { path: '', component: HogeList, data: { reuse: true } },
  { path: ':id', component: HogeEdit }
];

この場合、HogeList(一覧)とHogeEdit(編集)画面のうち、一覧画面のみ保持され、編集画面から戻ってもHogeListのローカル変数等はそのままになります。
当然ですが、編集画面は:idで都度表示内容が変更される画面を想定しているので、reuseするのは望ましくないです。

さて、これでHogeListは使い回されることになるわけですが、この状態では、例えば、編集画面で項目が削除されたり作成されたとしても、一覧へ反映されないことになります。
もちろんお互いのComponentで同じデータの変更をsubscribeしているような造りであれば問題は無いのですが、それでなくとも、任意にキャッシュしているComponentを再生成したいときも出てきます。

retrieveがnullを返せばいいので、storedHandlesから消す処理があれば、実現できそうです。

refreshの実装


refresh(route: ActivatedRouteSnapshot) {
    let path = this.getPath(route);
    if (path && this.storedHandles[path]) this.storedHandles[path] = null;
}

これをHogeEditでSaveが呼ばれた場合等に以下のように呼び出します。

class HogeEdit {
    constructor(route: ActivatedRoute, router: Router) {}
    
    afterSave() {
        (this.router.routeReuseStrategy as MyRouteReuseStrategy).refresh(this.route.parent.snapshot);
    }
}

RouteReuseStrategyはRouterが持っているので、そのままアクセスできます。
refreshに渡すのは保持されているかもしれないrouteのsnapshotなので、今回はparentから取っています。
これで、afterSaveを通った後にHogeListへ戻った場合に限り、Componentが使い回されなくなりました。

以下、クラス全体です。
最低限は満たせましたが、実際はもう少し調整が必要になるケースが多いかと思います。

export class MyRouteReuseStrategy implements RouteReuseStrategy {
    private storedHandles: { [path: string]: DetachedRouteHandle } = {};

    private getPath(route: ActivatedRouteSnapshot) {
        let url = RouteUtil.getPathFromRoot(route);
        return url.length > 0? url: null;
    }

    private isReuse(route: ActivatedRouteSnapshot) {
        return route.routeConfig && !route.routeConfig.loadChildren &&
               route.routeConfig.data && route.routeConfig.data.reuse;
    }

    shouldDetach(route: ActivatedRouteSnapshot): boolean {
        return this.isReuse(route);
    }
    store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
        if (this.isReuse(route)) {
            let path = this.getPath(route);
            if (path) this.storedHandles[path] = handle;
        }
    }

    shouldAttach(route: ActivatedRouteSnapshot): boolean {
        if (this.isReuse(route)) {
            let path = this.getPath(route);
            if (path) return !!this.storedHandles[path];
        }
        return false;
    }
    retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {
        if (this.isReuse(route)) {
            let path = this.getPath(route);
            if (path) return this.storedHandles[path] || null;
        }
        return null;
    }

    shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
        return future.routeConfig === curr.routeConfig;
    }

    refresh(route: ActivatedRouteSnapshot) {
        let path = this.getPath(route);
        if (path && this.storedHandles[path]) this.storedHandles[path] = null;
    }
}

いじょ