You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Angular MatTree结合虚拟滚动:持久化节点展开状态方案

Angular虚拟滚动MatTree节点展开状态持久化方案

当前实现中,虚拟滚动切换渲染范围时,dataSource.data被重新赋值,导致树控件的展开状态丢失。这是因为每次切片数据后,树控件会重新生成扁平节点,原有展开状态没有被持久化。以下是解决方法:

实现步骤

  • 全局缓存展开状态:用Set<string>存储所有展开节点的唯一标识(示例中用节点name作为唯一键)
  • 监听展开/折叠事件:捕获树节点的状态变化,同步更新缓存
  • 数据更新后恢复状态:每次虚拟滚动加载新数据范围后,根据缓存自动恢复对应节点的展开状态

修改后的完整代码

import { bootstrapApplication } from '@angular/platform-browser';
import {
  AfterViewInit,
  Component,
  ViewChild,
  provideExperimentalZonelessChangeDetection,
} from '@angular/core';
import { Observable, of } from 'rxjs';
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
import { FlatTreeControl } from '@angular/cdk/tree';
import {
  MatTreeFlatDataSource,
  MatTreeFlattener,
} from '@angular/material/tree';
import { MatIconModule } from '@angular/material/icon';
import { MatTreeModule } from '@angular/material/tree';
import { MatButtonModule } from '@angular/material/button';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';

export interface NestedFileSystemItem {
  children: NestedFileSystemItem[];
  name: string;
}

export interface NestedFileSystemItemFlatNode {
  expandable: boolean;
  name: string;
  level: number;
}

const DATI = new Array(100_000)
  .fill({
    name: 'Folder',
    children: [
      { name: 'File 1' },
      { name: 'File 2' },
      {
        name: 'Sub Folder',
        children: [
          { name: 'Sub File 1' },
          { name: 'Sub File 2' },
          {
            name: 'Sub Sub Folder',
            children: [{ name: 'Sub Sub File 1' }, { name: 'Sub Sub File 2' }],
          },
        ],
      },
    ],
  })
  .map((item, index) => {
    return { ...item, name: item.name + ' (' + index + ')' };
  });

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [MatIconModule, MatButtonModule, MatTreeModule, ScrollingModule],
  styles: `@import "https://fonts.googleapis.com/icon?family=Material+Icons";`,
  template: `
  <cdk-virtual-scroll-viewport itemSize="48" style="height: 500px;">
    <ng-container *cdkVirtualFor="let item of fullDataSource"></ng-container>

    <mat-tree [dataSource]="dataSource" [treeControl]="treeControl">
        <mat-tree-node *matTreeNodeDef="let node" matTreeNodeToggle matTreeNodePadding>
          <button mat-icon-button disabled></button>
          {{node.name}}
        </mat-tree-node>

        <mat-tree-node *matTreeNodeDef="let node;when: hasChild" matTreeNodePadding>
          <button mat-icon-button matTreeNodeToggle
                  [attr.aria-label]="'toggle ' + node.name">
            <mat-icon class="mat-icon-rtl-mirror">
              {{treeControl.isExpanded(node) ? 'expand_more' : 'chevron_right'}}
            </mat-icon>
          </button>
          {{node.name}}
        </mat-tree-node>
    </mat-tree>
  </cdk-virtual-scroll-viewport>
  `,
})
export class AppComponent implements AfterViewInit {
  treeControl: FlatTreeControl<NestedFileSystemItemFlatNode>;
  treeFlattener: MatTreeFlattener<
    NestedFileSystemItem,
    NestedFileSystemItemFlatNode
  >;
  dataSource: MatTreeFlatDataSource<
    NestedFileSystemItem,
    NestedFileSystemItemFlatNode
  >;
  fullDataSource = DATI;
  @ViewChild(CdkVirtualScrollViewport) virtualScroll!: CdkVirtualScrollViewport;
  
  // 存储展开节点的唯一标识
  expandedNodes = new Set<string>();

  constructor() {
    this.treeFlattener = new MatTreeFlattener(
      this.transformer,
      this._getLevel,
      this._isExpandable,
      this._getChildren
    );
    this.treeControl = new FlatTreeControl<NestedFileSystemItemFlatNode>(
      this._getLevel,
      this._isExpandable
    );
    this.dataSource = new MatTreeFlatDataSource(
      this.treeControl,
      this.treeFlattener
    );
    this.dataSource.data = this.fullDataSource.slice(0, 10);
    
    // 监听节点展开/折叠事件,更新缓存
    this.treeControl.expansionModel.changed.subscribe(change => {
      change.added.forEach(node => this.expandedNodes.add(node.name));
      change.removed.forEach(node => this.expandedNodes.delete(node.name));
    });
  }

  transformer = (
    node: NestedFileSystemItem,
    level: number
  ): NestedFileSystemItemFlatNode => ({
    expandable: !!node.children,
    name: node.name,
    level,
  });

  _getLevel = (node: NestedFileSystemItemFlatNode) => node.level;

  _isExpandable = (node: NestedFileSystemItemFlatNode) => node.expandable;

  _getChildren = (
    node: NestedFileSystemItem
  ): Observable<NestedFileSystemItem[]> => of(node.children);

  hasChild = (_: number, _nodeData: NestedFileSystemItemFlatNode) =>
    _nodeData.expandable;

  ngAfterViewInit() {
    this.virtualScroll.renderedRangeStream.subscribe((range: any) => {
      this.dataSource.data = this.fullDataSource.slice(range.start, range.end);
      
      // 数据更新后,恢复节点展开状态
      this.treeControl.dataNodes.forEach(node => {
        if (this.expandedNodes.has(node.name)) {
          this.treeControl.expand(node);
        }
      });
    });
  }
}

bootstrapApplication(AppComponent, {
  providers: [
    provideExperimentalZonelessChangeDetection(),
    provideAnimationsAsync(),
  ],
}).catch((err) => console.error(err));

关键修改说明

  1. 添加expandedNodes集合:用Set<string>存储所有展开节点的name,作为唯一标识追踪状态
  2. 监听展开状态变化:通过treeControl.expansionModel.changed事件,将展开的节点加入集合,折叠的节点移除
  3. 虚拟滚动数据更新后恢复状态:在renderedRangeStream订阅中,新数据加载完成后遍历所有扁平节点,根据expandedNodes集合恢复对应节点的展开状态

这样就能保证虚拟滚动切换范围时,之前展开的节点会自动保持展开状态。

内容的提问来源于stack exchange,提问作者ar099968

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.21 15:40:54