如何允许同一组件的多个标签页,并保持其状态?

huangapple go评论73阅读模式
英文:

How to allow multiple tabs of the same component while keeping its state?

问题

My app uses a lazy loading tab system that is managed with a service. 当用户在导航菜单上选择选项时,会发生两件事:在选项卡服务中,会添加一个条目到选项卡数组中。然后激活一个新路由并加载相应的组件。

This works fine but I need something more complex: 我需要更复杂的功能:

  • Being able to click any tab and restore its state -> 如果表单已填写,按钮已点击等,我希望当我返回到组件时,它保持完全相同的状态。从我的理解来看,我认为实现RouteReuseStrategy最适合这种行为。
  • Being able to open multiple instances of the same component. 如果用户两次从导航菜单中选择相同的选项,我希望打开两个选项卡。每个选项卡都有自己的状态。我希望能够在导航到任何这些选项卡时恢复该状态。

For now, the only solution that I found is to store the state of each component in session storage and update the components state when clicking on the tab. 目前,我找到的唯一解决方案是将每个组件的状态存储在会话存储中,并在点击选项卡时更新组件的状态。

英文:

My app uses a lazy loading tab system that is managed with a service.
When a user selects an option on the nav menu, two things happen :

  • In the tab service, an entry is added to the array of tabs.
  • A new route is activated and the appropriate component is loaded.

This works fine but I need something more complex :

  • Being able to click any tab and restore its state -> If forms have been filled, buttons have been clicked etc. I want the exact same state when I route back to my component. From what I understand, I think that implementing RouteReuseStrategy is best suited for this kind of behaviour.
  • Being able to open multiple instances of the same component. If my user selects the same option from the nav menu twice, I want to open two tabs. Each tab having its own state. I want to be able to restore that state when navigating to any of these tabs.

For now, the only solution that I found is to store the state of each component in session storage and update the components state when clicking on the tab. To achieve this, my components need to subscribe to NavigationStart and NavigationEnd events.

  • NavigationStart -> Save current state in session storage.
  • NavigationEnd -> Load current state from session storage.

This works fine for fairly simple components but for some very complex components containing dozens of buttons, form etc. It can quickly become a nightmare to manage since I have to write the loading and saving logic for each component. Session storage being limited in size, this is also a limit of this method.

Any suggestion would be very much appeciated.

答案1

得分: 1

以下是您要翻译的内容:

"对于遇到相同问题的人,可以通过实现RouteReuseStrategy来实现粘性状态。您可以在这里找到一个示例。

我原以为这种方法不能与相同组件的多个实例一起使用,但实际上是可以的。您只需要稍微调整实现方式:

  • 在组件的构造函数中声明this.router.routeReuseStrategy.shouldReuseRoute = () => false;,这样即使路由不改变,也会加载新的组件。
  • 存储的路由被保存在一个映射中。在上面的文章中,这个映射的键是不同的路由。我将我的tabId连接到键上,这样我可以根据我想要访问的选项卡来获得相同路由/组件的不同条目。示例如下:
CacheRouteReuseStrategy 

@Injectable()
export class CacheRouteReuseStrategy implements RouteReuseStrategy {

	constructor(private linkService : LinkService) {
	}

	shouldDetach(route: ActivatedRouteSnapshot): boolean {
		return true;
	}

	store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
		// 如果前一路由和当前路由相同,句柄为null。
		// 在这种情况下,我们不希望更新存储(我们不希望为路由设置一个空对象并丢失信息)
		if (handle != null && this.linkService.previousLink != null){
  			this.linkService.storedRoutes.set(route.routeConfig?.path + this.linkService.previousLink.tabId, handle);
		}
	}

	shouldAttach(route: ActivatedRouteSnapshot): boolean {
		if (this.linkService.activeLink != null) {
  			return !!route.routeConfig && !!this.linkService.storedRoutes.get(<string>route.routeConfig.path + this.linkService.activeLink.tabId);
		} else {
  			return false;
		}
	}

	retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {
		// @ts-ignore
		return this.linkService.storedRoutes.get(route.routeConfig.path + this.linkService.activeLink.tabId);
	}

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

}

我有一个名为LinkService的服务,用于跟踪我离开的路由(previousLink)和我要访问的路由(activeLink)。previousLink是我在离开路由时要添加到存储中的对象,而activeLink是我在访问路由时要从存储中获取的对象。

@Injectable({
  providedIn: 'root'
})
export class LinkService {
  links: LinkModel[];
  activeLink: LinkModel;
  previousLink: LinkModel;
  storedRoutes = new Map<string, DetachedRouteHandle>();
}

export interface LinkModel {
  route: string,
  parameters: string | null,
  tabId: string,
  tabTitle: string
}

这些信息由另一个名为TabService的服务更新。它的操作非常直接(创建、更改和删除选项卡逻辑),但如果有人需要的话,我可以稍后发布它。

"

英文:

For anyone running into the same issue, the sticky state can be achieved by implementing RouteReuseStrategy. You can find an example here.

I thought that this approach would not work with multiple instances of the same component but it actually does. You just need to tweak the implementation a little bit :

  • Declare this.router.routeReuseStrategy.shouldReuseRoute = () =&gt; false; in the constuctor of your component so a new component is loaded even if the route doesn't change.
  • Stored routes are kept in a map. In the article above, the keys of this map are the different routes. I concatenated my tabId to the key so I can have different entries for the same route/component, depending on the tab I want to access. Example below :

CacheRouteReuseStrategy

    @Injectable()
    export class CacheRouteReuseStrategy implements RouteReuseStrategy {

    	constructor(private linkService : LinkService) {
    	}

    	shouldDetach(route: ActivatedRouteSnapshot): boolean {
    		return true;
   	 	}

    	store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
    		// If previous route and active route are the same, handle is null.
    		// In that case, we don&#39;t want to update the store (we don&#39;t want to set a null object for the route and loose informations)
    		if (handle != null &amp;&amp; this.linkService.previousLink != null){
      			this.linkService.storedRoutes.set(route.routeConfig?.path + this.linkService.previousLink.tabId, handle);
    		}
    	}

   	 	shouldAttach(route: ActivatedRouteSnapshot): boolean {
    		if (this.linkService.activeLink != null) {
      			return !!route.routeConfig &amp;&amp; !!this.linkService.storedRoutes.get(&lt;string&gt;route.routeConfig.path + this.linkService.activeLink.tabId);
    		} else {
      			return false;
    		}
    	}

    	retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {
    	// @ts-ignore
    		return this.linkService.storedRoutes.get(route.routeConfig.path + this.linkService.activeLink.tabId);
    	}

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

    }

I have a service called LinkService to keep track of the route I'm leaving (previousLink) and the route I'm trying to accesss (activeLink). previousLink is the object I want to add to the store when leaving a route and activeLink is the object I want to get from the store when accessing a route.

    @Injectable({
      providedIn: &#39;root&#39;
    })
    export class LinkService {
      links: LinkModel[];
      activeLink: LinkModel;
      previousLink: LinkModel;
      storedRoutes = new Map&lt;string, DetachedRouteHandle&gt;();
    }
    
    export interface LinkModel {
      route: string,
      parameters: string | null,
      tabId: string,
      tabTitle: string
    }

These informations are updated by another service, called TabService. What it does is pretty straightforward (create, change and remove tab logic) but I can post it later if anyone needs it.

答案2

得分: 0

你可以使用 ngOnInitngOnDestroy 生命周期钩子来从/向某些持久存储加载/存储状态。你建议使用本地存储是一个不错的选择,特别是如果你希望状态在浏览器重新加载/刷新或用户在以后的某个时刻重新访问页面时也能恢复。否则,会话存储也是一个选项(在MDN 上有详细的区别说明) 。如果不希望在页面重新加载时恢复状态,你还可以简单地将状态存储在某个服务中(存储在浏览器内存而不是 Web 存储中)。

官方 Angular 文档中了解更多关于这些 Angular 生命周期钩子的信息


更新

正如你在评论中提到的,如果你导航到完全相同的组件而不销毁它,这种方法不会起作用。

在这种情况下,你可以使用路由解析器:

假设你的应用程序或模块中有一个名为 DataService 的实例,你希望使用它来提供数据。

配置你的 TabComponent 路由,类似于:

{
  path: '',
  component: TabPageComponent,
  runGuardsAndResolvers: 'always', // 设置标志以始终运行
  resolve: {
    // 提供数据服务,假设它在注入器中可用
    data: DataService, 
  },
}

现在,你的 DataService 应该实现来自 @angular/routerResolve 接口:

import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';

export class DataService implements Resolve<any> {

  // 根据你的情况需要实现带有依赖项的构造函数。

  public resolve(route?: ActivatedRouteSnapshot, state?: RouterStateSnapshot): Observable<any> |
    // 在这里使用 route 和 state 做你的事情。
    // 例如 API 调用、从 Web 存储中加载或者直接从内存中加载数据。
  }

}

现在,每当路由更改时,路由器都会调用你服务中的 resolve。在你的组件内,你可以订阅激活路由的数据,它是一个会根据情况发出的可观察对象:

import { ActivatedRoute } from '@angular/router';

//...

class TabComponent implements OnInit {

  constructor(private route: ActivatedRoute) {}

  public ngOnInit(): void {
    this.route.data.subscribe((data) => {
      const data = data.data;
      // 对数据执行操作。
    }
  }

}

另一种方法是创建一个可观察对象,并直接在你的视图中使用它:

import { ActivatedRoute } from '@angular/router';

//...

class TabComponent implements OnInit {

  constructor(private route: ActivatedRoute) {}

  public data$ = Observable<any>;

  public ngOnInit(): void {
    this.data$ = this.route.data.pipe(
      map(data => data.data),
    );
  }

}

根据你的需求,你可以通过使用其他选项之一配置 runGuardsAndResolvers 来减少其运行频率。有关可能选项,请参阅Angular 文档

英文:

You can use the ngOnInit and ngOnDestroy lifecycle hooks to load/store the state from/in some persistent storage. You proposed to use local-storage which is a good option, especially if you want the state also to be restored on for example a reload/refresh in the browser or when a user revisits the page at a later moment. Otherwise session-storage is also an option (read on the differences here on MDN). In case restoring on page reload is not desirable you could also simply store the state in some service (store in browser memory instead of Web storage).

Read more on these Angular lifecycle hooks here in the official Angular documentation.


UPDATE

As you mention in your comment, this won't work if you route to exactly the same component without destroying it.

You could in such case use route resolvers:

Let's assume you have a DataService instance in your application or module that you want to use to provide the data.

Configure your route for your TabComponent, something like:

{
  path: &#39;&#39;,
  component: TabPageComponent,
  runGuardsAndResolvers: &#39;always&#39;, // Set flag to run always
  resolve: {
    // Provide the data service, assuming it is available in the injector
    data: DataService, 
  },
}

Now your DataService should implement the Resolve interface from &#39;@angular/router&#39;:

import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from &#39;@angular/router&#39;;

export class DataService implements Resolve&lt;any&gt; {

  // constructor with dependencies should be implemented as needed for your case.

  public resolve(route?: ActivatedRouteSnapshot, state?: RouterStateSnapshot): Observable&lt;any&gt; |
    // Do your thing here using route and state.
    // i.e. API call, load from Web Storage or simply from memory in this service.
  }

}

Now the router will call resolve in your service each time your route changess. Inside your component you can subscribe to the activated route data which is an observable that emits accordingly:

import { ActivatedRoute } from &#39;@angular/router&#39;;

//...

class TabComponent implements OnInit {

  constructor(private route: ActivatedRoute) {}

  public ngOnInit(): void {
    this.route.data.subscribe((data) =&gt; {
      const data = data.data;
      // Do something with your data.
    }
  }

}

Alternative would be to create an observable and use it in your view directly:

import { ActivatedRoute } from &#39;@angular/router&#39;;

//...

class TabComponent implements OnInit {

  constructor(private route: ActivatedRoute) {}

  public data$ = Observable&lt;any&gt;;

  public ngOnInit(): void {
    this.data$ = this.route.data.pipe(
      map(data =&gt; data.data),
    );
  }

}

Depending on your needs you can change the runGuardsAndResolvers to run less frequently by configuring it with one of the other options. See the Angular documentation for the possible options.

huangapple
  • 本文由 发表于 2023年4月17日 20:12:07
  • 转载请务必保留本文链接:https://go.coder-hub.com/76035035.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定