29

我一直在尝试找出一种方法,将 select 运算符与 rxjs 的其他运算符结合使用来查询树数据结构(在存储中标准化为平面列表),以保持 ChangeDetectionStrategy.OnPush 的引用完整性语义,但我的最佳尝试会导致当树的任何部分发生更改时重新渲染整个树。有没有人有任何想法?如果您认为以下接口代表存储中的数据:

export interface TreeNodeState {
 id: string;
 text: string;
 children: string[] // the ids of the child nodes
}
export interface ApplicationState {
 nodes: TreeNodeState[]
}

我需要创建一个非规范化上述状态的选择器,以返回实现以下接口的对象图:

export interface TreeNode {
 id: string;
 text: string;
 children: TreeNode[]
}
也就是说,我需要一个函数,它接受一个 Observable<ApplicationState> 并返回一个 Observable<TreeNode[]> ,这样每个 TreeNode 实例都保持引用完整性,除非它的一个子实例已更改

理想情况下,我希望图表的任何一部分仅在其子节点发生更改时才更新,而不是在任何节点更改时返回一个全新的图形。有谁知道如何使用 ngrx/store 和 rxjs 构建这样的选择器?

有关我尝试过的各种事情的更具体示例,请查看以下代码段:

// This is the implementation I'm currently using. 
// It works but causes the entire tree to be rerendered
// when any part of the tree changes.
export function getSearchResults(searchText: string = '') {
    return (state$: Observable<ExplorerState>) =>
        Observable.combineLatest(
            state$.let(getFolder(undefined)),
            state$.let(getFolderEntities()),
            state$.let(getDialogEntities()),
            (root, folders, dialogs) =>
                searchFolder(
                    root,
                    id => folders ? folders.get(id) : null,
                    id => folders ? folders.filter(f => f.parentId === id).toArray() : null,
                    id => dialogs ? dialogs.filter(d => d.folderId === id).toArray() : null,
                    searchText
                )
        );
}

function searchFolder(
    folder: FolderState,
    getFolder: (id: string) => FolderState,
    getSubFolders: (id: string) => FolderState[],
    getSubDialogs: (id: string) => DialogSummary[],
    searchText: string
): FolderTree {
  console.log('searching folder', folder ? folder.toJS() : folder);
  const {id, name } = folder;
  const isMatch = (text: string) => !!text && text.toLowerCase().indexOf(searchText) > -1;
  return {
    id,
    name,
    subFolders: getSubFolders(folder.id)
        .map(subFolder => searchFolder(
            subFolder,
            getFolder,
            getSubFolders,
            getSubDialogs,
            searchText))
      .filter(subFolder => subFolder && (!!subFolder.dialogs.length || isMatch(subFolder.name))),
    dialogs: getSubDialogs(id)
      .filter(dialog => dialog && (isMatch(folder.name) || isMatch(dialog.name)))

  } as FolderTree;
}

// This is an alternate implementation using recursion that I'd hoped would do what I wanted
// but is flawed somehow and just never returns a value.
export function getSearchResults2(searchText: string = '', folderId = null)
: (state$: Observable<ExplorerState>) => Observable<FolderTree> {
    console.debug('Searching folder tree', { searchText, folderId });
    const isMatch = (text: string) =>
        !!text && text.search(new RegExp(searchText, 'i')) >= 0;
    return (state$: Observable<ExplorerState>) =>
        Observable.combineLatest(
            state$.let(getFolder(folderId)),
            state$.let(getContainedFolders(folderId))
                .flatMap(subFolders => subFolders.map(sf => sf.id))
                .flatMap(id => state$.let(getSearchResults2(searchText, id)))
                .toArray(),
            state$.let(getContainedDialogs(folderId)),
            (folder: FolderState, folders: FolderTree[], dialogs: DialogSummary[]) => {
                console.debug('Search complete. constructing tree...', {
                    id: folder.id,
                    name: folder.name,
                    subFolders: folders,
                    dialogs
                });
                return Object.assign({}, {
                    id: folder.id,
                    name: folder.name,
                    subFolders: folders
                        .filter(subFolder =>
                            subFolder.dialogs.length > 0 || isMatch(subFolder.name))
                        .sort((a, b) => a.name.localeCompare(b.name)),
                    dialogs: dialogs
                        .map(dialog => dialog as DialogSummary)
                        .filter(dialog =>
                            isMatch(folder.name)
                            || isMatch(dialog.name))
                        .sort((a, b) => a.name.localeCompare(b.name))
                }) as FolderTree;
            }
        );
}

// This is a similar implementation to the one (uses recursion) above but it is also flawed.
export function getFolderTree(folderId: string)
: (state$: Observable<ExplorerState>) => Observable<FolderTree> {
    return (state$: Observable<ExplorerState>) => state$
        .let(getFolder(folderId))
        .concatMap(folder =>
            Observable.combineLatest(
                state$.let(getContainedFolders(folderId))
                    .flatMap(subFolders => subFolders.map(sf => sf.id))
                    .concatMap(id => state$.let(getFolderTree(id)))
                    .toArray(),
                state$.let(getContainedDialogs(folderId)),
                (folders: FolderTree[], dialogs: DialogSummary[]) => Object.assign({}, {
                    id: folder.id,
                    name: folder.name,
                    subFolders: folders.sort((a, b) => a.name.localeCompare(b.name)),
                    dialogs: dialogs.map(dialog => dialog as DialogSummary)
                        .sort((a, b) => a.name.localeCompare(b.name))
                }) as FolderTree
            ));
}

4

1 回答 1

2

如果愿意重新考虑问题,您可以使用 Rxjs operator scan

  1. 如果不存在先前的 ApplicationState,则接受第一个。递归地将其转换为 TreeNodes。由于这是一个实际对象,因此不涉及 rxjs。
  2. 每当接收到新的应用程序状态时,即扫描触发时,实现一个函数,使用接收到的状态改变先前的节点,并扫描运算符中返回先前的节点。这将保证您的引用完整性。
  3. 您现在可能会遇到一个新问题,因为对变异树节点的更改可能不会被拾取。如果是这样,要么通过为每个节点进行签名来查看跟踪,要么考虑将changeDetectorRef添加到节点(由组件渲染节点提供),从而允许您标记组件以进行更新。这可能会表现得更好,因为您可以使用更改检测策略 OnPush

伪代码:

state$.scan((state, nodes) => nodes ? mutateNodesBy(nodes, state) : stateToNodes(state))

输出保证保持参照完整性(在可能的情况下),因为节点只构建一次,然后只发生变异。

于 2017-01-23T15:57:23.417 回答