Một số kiến thức về Angular 2 router - reuse component với RouteReuseStrategy

Trong quá trình xây dựng single-app, đôi khi ta gặp phải tình huống sau: Ta cần giữ lại trạng thái của trang trước sau khi quay về từ một trang nào đó. Ví dụ:

  • Khi từ trang tìm kiếm, ta mong muốn sau khi xem một kết quả và quay lại, trang đó vẫn giữ lại các kết quả tìm kiếm.
  • Khi từ trang index, sau khi navigate tới trang khác để update một item, ta mong muốn khi quay lại, trang index vẫn lưu giữ trạng thái cũ (danh sách item đã load về, vị trí scroll hiện tại ...)

Đầu tiên, ta có thể nghĩ tới việc cho view cha "ẩn" đi, đồng thời cho view con "đè" lên trên. Tuy nhiên, thực hiện theo cách này về lâu dài sẽ phức tạp trong việc kiểm soát trạng thái của các view con này.

Angular cung cấp sẵn cho ta một giải pháp để giải quyết tình huống này - đó là sử dụng RouteReuseStrategy

Ôn lại chút kiến thức về routing trong Angular

Một app Angular - về cơ bản - là một cây các component. Bắt đầu từ nút cha lớn nhất là AppComponent, các component con dần được sắp xếp để thể hiện nội dung của app. Trong quá trình sử dụng, các component được thêm vào, bớt đi theo thời gian. Router trong angular quan tâm tới các component này - hay cụ thể hơn - đó là quan tâm tới cách sắp xếp các component của app. Khái niệm này trong Angular 2 được gọi là router state : router state thể hiện cách sắp xếp các component hiện có trong app nhằm xác định xem nội dung gì được hiển thị ra màn hình.

RouterState và RouterStateSnapshot khác gì nhau ?

Như đã nói ở trên, RouterState thể hiện cho trạng thái của app route thay đổi theo thời gian. Trong quá trình navigate, mỗi khi app được chuyển tới một trang mới, router sẽ tạo một RouterStateSnapshot. Nói cách khác, RouterStateSnapshot là một cấu trúc data lưu lại trạng thái của app router tại một thời điểm. Mỗi khi có một component được tạo mới hoặc xóa đi, hoặc khi một parameter thay đổi, một snapshot mới được tạo.

RouterStateSnapshot và ActivatedRouteSnapshot

Nhìn vào mã nguồn của RouterStateSnapshot

export class RouterStateSnapshot extends Tree<ActivatedRouteSnapshot> {  
  constructor(public url: string, root: TreeNode<ActivatedRouteSnapshot>) {
    super(root);
    setRouterState(<RouterStateSnapshot>this, root);
  }

  toString(): string { return serializeNode(this._root); }
}

ta có thể thấy nó thực chất là một cây chứa các *ActivatedRouteSnapshot *

interface ActivatedRouteSnapshot { 
  url: UrlSegment[]
  params: Params
  queryParams: Params
  fragment: string
  data: Data
  outlet: string
  component: Type<any>|string|null

Mỗi nốt trong "cây" này sẽ được biết về một phần của URL, các parameter của URL, cũng như data trong đó.

Ví dụ, tương ứng với router được định nghĩa như sau:

[
 {
   path: ':folder',
   children: [
     {
       path: '',
       component: FoldersListComponent
     },
     {
       path: ':id',
       component: FolderComponent,
       children: [
         {
           path: 'files',
           component: FilesListComponent
         },
         {
           path: 'files/:id',
           component: FileComponent,
           resolve: {
             file: FileResolver
           }
         }
       ]
     }
   ]
 }
]

Nếu ta navigate tới url '/trash/12/files/34', Router sẽ nhìn vào url và sinh ra cho ta RouteStateSnapshot như dưới đây

Lúc này, router sẽ init cho ta 2 component FolderComponent và FileComponent

Nếu lúc này ta navigate tới một URL tương tự, với chỉ có params là thay đổi : 'trash/12/files/45', snapshot tương ứng dưới đây sẽ được tạo ra:

Ta có thể thấy: để hạn chế việc cập nhật DOM, router sẽ sử dụng lại các component khi mà chỉ có parameter thay đổi mà thôi. Lúc này, nếu như ta đứng bên trong FileComponent và kiểm tra snapshot, params id sẽ luôn mang giá trị là 34 thay vì 45. Vì thế lúc này ta cần một cấu trúc khác cho phép ta đọc được thay đổi của state theo thời gian.

RouterState và ActivatedRoute

export class RouterState extends Tree<ActivatedRoute> {
  constructor( root: TreeNode<ActivatedRoute>, public snapshot: RouterStateSnapshot) {
    super(root);
    setRouterState(<RouterState>this, root);
  }

  toString(): string { return this.snapshot.toString(); }
}
interface ActivatedRoute { 
  snapshot: ActivatedRouteSnapshot
  url: Observable<UrlSegment[]>
  params: Observable<Params>
  queryParams: Observable<Params>
  fragment: Observable<string>
  data: Observable<Data>
  outlet: string
  component: Type<any>|string|null 
}

RouterState tương tự như với RouterStateSnapshot ( cũng như ActivatedRoute tương tự với ActivatedRouteSnapshot), tuy nhiên nó trả về các giá trị dưới dạng Observable, điều đó có nghĩa ta có thể sử dụng RouterState để theo dõi sự thay đổi của router theo thời gian.

ActivatedRouteSnapshot đại diện cho trạng thái của route (bao gồm tất cả các coponent tại thời điểm đó) => ta cần tìm cách để sử dụng lại snapshot này !

Sử dụng lại snapshot (và component) với RouteReuseStrategy

Angular đã cung cấp sẵn cho ta công cụ để giải quyết bài toán. Bằng cách kế thừa lại RouteReuseStrategy, ta có thể tùy chỉnh cách mà Angular vẫn dùng để reuse component.

RouteReuseStrategy là gì ?

Nhìn vào mã nguồn, RouteReuseStrategy là một abstract class gồm 5 function:

class RouteReuseStrategy {
  shouldDetach(route: ActivatedRouteSnapshot): boolean
  store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle|null): void
  shouldAttach(route: ActivatedRouteSnapshot): boolean
  retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle|null
  shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean
}

5 hàm này sẽ quyết định 1 component có nên (và khi nào) được lưu lại, lấy ra và dùng lại. Mỗi một lần khi ta navigate trong app, 5 hàm trên (có thể) được gọi theo thứ tự:

  1. Đầu tiên, shouldReuseRoute được gọi. Nhớ lại ví dụ về url 'folders/12/files/34' ở trên, ta biết rằng Angular sẽ sử dụng lại component nếu như chỉ có các params thay đổi. Ở đây, hàm shouldReuseRoute chính là hàm để kiểm tra điều kiện đó : một khi hàm này trả về true, 4 hàm còn lại sẽ bị bỏ qua. Với hàm này, tốt nhất ta nên tuân theo mặc định của Angular, đó là sử dụng lại component nếu có thể.
shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
    return future.routeConfig === curr.routeConfig;
  }
  1. Nếu shouldReuseRoute trả về false, shouldDetach sẽ được gọi. Hàm này trả về biến boolean xác định xem liệu ta có muốn lưu lại route này không ? . Ở đây, ta có thể định nghĩa trước một tập hợp các URL mà ta muốn app sẽ lưu lại.
    private acceptedRoutes: Array<string> = ['search', 'files-list'];
    
      shouldDetach(route: ActivatedRouteSnapshot): boolean {
        return (this.acceptedRoutes.indexOf(route.url.join('/')) > -1);          // Kiểm tra xem url hiện tại có nằm trong danh sách các url cần lưu lại hay không ?
    }

  1. Nếu shouldDetach trả về true, store được gọi. Đây là chỗ mà ta lưu lại những thứ cần biết để khôi phục lại 1 route => Đây là chỗ ta lưu lại component để dùng sau này. Hàm này có 1 params thuộc type DetachedRouteHandle, ta có thể lưu lại biến này vào 1 mảng để dùng lại sau này (biến này chứa component mà ta cần lưu lại) Ta có thể dùng một mảng để lưu lại các handle này, đồng thời dùng chính url đang xét để làm key phân biệt các item được lưu với nhau.
  private handlers: {[key: string]: DetachedRouteHandle} = {};

 store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
    this.handlers[route.url.join('/')] = handle;
  }
  1. Tương tự như vậy, shouldAttach được gọi, đây là nơi mà ta quyết định xem liệu nó nên sử dụng lại 1 component đã lưu hay không.

Ở chỗ này, có khá nhiều logic ta có thể xử lý: - Nếu user logout khỏi app => user mới KHÔNG ĐƯỢC xem cached view từ người dùng trước. - User cũng cần phải được thấy nội dung mới khi app có thay đổi về mặt giao diện (ngôn ngữ hiển thị, thay đổi layout ...) - Nên xử dụng lại 1 component khi nào ? : (với ví dụ ở đầu bài, trang search chỉ nên được xử dụng lại khi navigate trở lại từ trang detail của kết quả ...)

Với trường hợp user logout, ta cần xóa hết cache:

shouldAttach(route: ActivatedRouteSnapshot): boolean {
    // ...
   if (route.component == LogoutComponent) {
     this.handlers = {};
     return false;
   }
   // ...
}

Với trường hợp 2, ta có thể lưu biến lại để kiểm tra nội dung có thay đổi hay không ?

private currentLanguage: string;

constructor() {
  this.currentLanguage = localStorage.getItem('language');
}
....
shouldAttach(route: ActivatedRouteSnapshot): boolean {
   // ...
   let newLang = localStorage.getItem('language');
   if (newLang != this.currentLanguage) {
       this.currentLanguage = newLang;
       this.handlers = {};
       return false;
   }
   // ...
}

Với trường hợp cuối, do biến route trong params của hàm shouldAttach đại diện cho router mà ta đang navigate tới => để có thể biết được ta đang navigate từ component nào , trước khi navigate, ta có thể lưu 1 biến tạm vào localStorage

localStorage.setItem('useCached', '1');

Khi đó, trong shouldAttach ta có thể check biến tạm này và quyết định sử dụng lại component khi cần.

  shouldAttach(route: ActivatedRouteSnapshot): boolean {
    // ...
    const shouldAttach = !!route.routeConfig && !!this.handlers[route.url.join('/')] && !!localStorage.getItem('useCached');
    if (shouldAttach) {
      localStorage.removeItem('useCached');                      // xóa biến tạm ngay sau khi dùng xong
      return true;
    } else {
      return false;
    }
    // ...
  }
  1. Cuối cùng, khi shouldAttach trả về true, retrieve được gọi. Đây là nơi ta lấy lại component đã lưu ở trên.
retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {
    if (!route.routeConfig || !this.handlers[route.url.join('/')]) 
    { 
        return null; 
    }
    return this.handlers[route.url.join('/')];
  }

Khai báo sử dụng lại lớp kế thừa trong AppModule

Sau khi đã tạo 1 lớp kế thừa lại RouteReuseStrategy, ta có thể khai báo sử dụng nó rất dễ dàng bằng cách khai báo trong provider của AppModule.

@NgModule({
	[...],
	providers: [
	    {provide: RouteReuseStrategy, useClass: CustomReuseStrategy}
	]
)}
export class AppModule {
}

cẩn thận khi sử dụng với lazyload module của angular

Nếu như trong App có sử dụng lazyload, nếu ta sử dụng route.url.joins("/") ta sẽ chỉ nhận được "" tương ứng với root module. Ta có thể tận dụng việc ActivatedRouteSnapshot thực chất là một cấu trúc dạng cây. Lúc này, ta có thể viết một hàm để lần ngược lại cây này, join tất cả các module lên tới module root để lấy được full url. (hoặc với ứng dụng đơn giản với chỉ 1 tầng deep module, ta có thể dùng luôn được như sau: route.url.join("/") || route.parent.url.join("/" )

Kết luận

Bằng cách kế thừa và custom lại class RouteReuseStrategy, ta có thể điều khiển cách mà angular 2 tái sử dụng các component, với một số lợi ích sau:

  • giảm thiểu số lần query tới server
  • tăng tốc độ client
  • QUAN TRONG NHẤT - đó là có thể render 1 component như khi ta rời khỏi nó.