react-admin: 多标签及KeepAlive缓存

1,051 阅读6分钟

react-admin是一个开箱即用的中大型后台管理系统,不仅只有前端解决方案,更是提供了基于nestjs的后端解决方案。

前端项目地址

后端项目地址

多标签页在后台管理系统中可谓是必不可少的,他可以很方便的给用户在不同的页面间进行切换,而页面缓存可以保留用户浏览的一些信息,比如表单数据,位置信息等。

多标签页

多标签页实现也比较简单,可以直接使用antdTabs组件也可以自己写一个。项目使用的是基于rc-tabs组件进行扩展的,rc-tabs组件是antdTabs组件的底层组件。主要是增加指示器的显隐、上下/左右滚动按钮、自定义tabbar时会增加传入nodeKeyindex参数功能。具体实现代码可查看RaTabs

扩展rc-tabs

这里列出的都是基于rc-tabs的扩展功能,其他功能跟rc-tabsantdTabs组件保持一致。

增加指示器的显隐

在多页标签时不需要指示器需要去掉,这里通过扩展一个showInkBar prop来控制

// src/components/RaTabs/TabNavList/index.tsx
// ......
 {showInkBar ? (
    <div
      className={classNames(`${prefixCls}-ink-bar`, {
        [`${prefixCls}-ink-bar-animated`]: animated?.inkBar,
      })}
      style={indicatorStyle}
    />
  ) : null}
 // ......

上下/左右滚动按钮

在多页标签里面如果页签太多可以提供向左或是向右滚动的功能,这个功能跟本身的滑动不冲突,只是增加了按钮操作。

// src/components/RaTabs/TabNavList/index.tsx
// ......
const smoothScroll = (
  setState: React.Dispatch<React.SetStateAction<number>>,
  offset: number,
): void => {
  // 每帧滚动的距离
  const scrollAmount = 20;
  let remaining = Math.abs(offset);

  const scrollStep = () => {
    const scrollOffset =
      Math.sign(offset) * Math.min(scrollAmount, remaining);
    doMove(setState, scrollOffset);
    remaining -= Math.abs(scrollOffset);

    if (remaining > 0) {
      requestAnimationFrame(scrollStep);
    }
  };

  requestAnimationFrame(scrollStep);
};
const handleNext = () => {
  if (tabPositionTopOrBottom) {
    smoothScroll(setTransformLeft, -100);
  } else {
    smoothScroll(setTransformTop, -100);
  }
};
const handlePrev = () => {
  if (tabPositionTopOrBottom) {
    smoothScroll(setTransformLeft, 100);
  } else {
    smoothScroll(setTransformTop, 100);
  }
};
 // ......
<ScrollButton
   show={needScroll}
   prefixCls={prefixCls}
   position="left"
   disabled={!pingLeft}
   onClick={handlePrev}
   ref={scrollButtonLeftRef}
/>
 // ......
<ScrollButton
  show={needScroll}
  disabled={!pingRight}
  prefixCls={prefixCls}
  position="right"
  onClick={handleNext}
  ref={scrollButtonRightRef}
/>
// ......

增加自定义tabbar时参数

在自定义tab时需要用到tabnodeKeyindex,这里扩展一下。

//src/components/RaTabs/TabNavList/TabNode.tsx
// ......
 return renderWrapper
   ? renderWrapper(node, props, genDataNodeKey(key), index)
   : node;

KeepAlive缓存

KeepAlive缓存社区方案有很多种,这里选用的是更接近官方实现的Activity方案,其核心是使用Suspense在匹配缓存时展示缓存,不匹配展示fallback,Suspense具有天生的缓存效果,也更贴近官方的实现。主要扩展了多页签相关的功能及白名单缓存弹出层等相关功能。完整代码可参考RaKeepAlive。这里主要看缓存核心功能。

// src/components/RaKeepAlive/hooks/useKeepAlive.ts
// ......
// 这个key是更新缓存的核心,如果是缓存路由则用传入的key或者url,如果不缓存路由则被包裹在KeepAlive组件里的children必须要有一个全局唯一key,这样在组件卸载再次转载后才能根据全局缓存里面找到她
const resolvedKey = keepRoutes
    ? cachedKey
      ? cachedKey
      : defaultRouteKey || uuidv4()
    : children?.key || uuidv4();
useEffect(() => {
    // 当需要缓存时进入if分支
    if (shouldCached(resolvedKey)) {
      // 这个是修复antd modal、drawer、popover、dropdown等在切换路由时会闪现面板的bug
      fixStyle(() => {
        setCachedComponents((prev) => {
          const newCachedComponents = [...prev];
          const index = newCachedComponents.findIndex(
            (item) => item.key === resolvedKey,
          );
          // 如果缓存里面没有
          if (index < 0) {
            let cachedItem: CachedComponent;
            const allCachedItem = allCachedComponentsRef?.current?.find?.(
              (i) => i.key === resolvedKey,
            );
            // 全局的缓存是从Context里面取的,所有的被KeepAlive包裹的组件都会在这备份包括嵌套的
            // 为什么存一个全局的cache是因为当不使用路由时组件卸载后,再次mount时需要从这里获取到缓存的组件,如果不使用全局缓存,那么找不到缓存组件会被当做新的组件缓存
            // 针对缓存路由时,路由上有modal、popover、drawer等时,需要再次嵌套一个KeepAlive来缓存滚动调位置,而其中的表单则会因为路由缓存而被缓存。
            // 如果从全局里面找到则用全局缓存
            if (allCachedItem) {
              cachedItem = allCachedItem;
            } else {
             // 新建一个cachedItem
              cachedItem = {
                component: children,
                key: resolvedKey,
                // refreshKey就是每个itm的key,刷新时这个key会变化
                refreshKey: uuidv4(),
                cachedScrollNodes: [],
                el: containerRef.current as HTMLDivElement,
                // 是否在刷新中,可以根据这个做loading之类的展示
                refreshing: false,
              };
            }
            // 更新缓存
            newCachedComponents.push(cachedItem);
            (
              cachedComponentsRef as MutableRefObject<CachedComponent[]>
            ).current = newCachedComponents;
            if (allCachedComponentsRef) {
              const allCachedIndex = allCachedComponentsRef.current.findIndex(
                (i) => i.key === resolvedKey,
              );
              // 更新全局存储的缓存
              if (allCachedIndex < 0) {
                allCachedComponentsRef.current.push(cachedItem);
              }
            }
            return newCachedComponents;
          }
          // 如果缓存里面有
          const oldCachedComponents = [
            ...(cachedComponentsRef as MutableRefObject<CachedComponent[]>)
              .current,
          ];
          if (allCachedComponentsRef) {
            const allCachedIndex = allCachedComponentsRef.current.findIndex(
              (i) => i.key === resolvedKey,
            );
            const cachedComponent = oldCachedComponents.find(
              (i) => i.key === resolvedKey,
            );
            // 更新全局缓存,有可能这个缓存是被修改过后的如:滚动,表单输入等
            if (allCachedIndex > -1 && cachedComponent) {
              allCachedComponentsRef.current.splice(
                allCachedIndex,
                1,
                cachedComponent,
              );
            }
          }
          return oldCachedComponents;
        });
      });
    } else {
    // 不需要缓存的走这个分支
      setExcludeComponents((prev) => {
        const newExcludeComponents = [...prev];
        const index = newExcludeComponents.findIndex(
          (item) => item.key === resolvedKey,
        );
        const newItem: ExcludeComponent = {
          component: children,
          key: resolvedKey,
          refreshKey: uuidv4(),
        };
        if (index < 0) {
          newExcludeComponents.push(newItem);
        } else {
          newExcludeComponents.splice(index, 1, newItem);
        }
        return newExcludeComponents;
      });
    }
  }, [resolvedKey]);
 // ......

多标签的KeepAlive缓存

多页签KeepAlive缓存主要就是有关多页签相关的操作如:重新加载页面、关闭当前页面、关闭其他页面等。

重新加载页面

重新加载页面核心就是利用reactkey机制,key不同页面就会刷新

  // src/components/RaKeepAlive/hooks/useKeepAlive.ts
  // ......
  const onRefreshCache = useCallback((key: string) => {
    setCachedComponents((prev) => {
      const newCachedComponents = [...prev];
      const index = cachedComponents.findIndex((item) => item.key === key);
      const newRefreshKey = uuidv4();
      if (index > -1) {
      // 重新加载的核心就是更新item的key,refreshing是个刷新标识,可以根据他做loading之类的展示
        newCachedComponents[index].refreshKey = newRefreshKey;
        newCachedComponents[index].refreshing = true;
      }
      cachedComponentsRef.current = newCachedComponents;
      // 更新全局缓存里面的key
      updateAllCachedComponents(key, (allCachedComponentsRef, index) => {
        if (index > -1) {
          allCachedComponentsRef.current[index].refreshKey = newRefreshKey;
          allCachedComponentsRef.current[index].refreshing = true;
        }
      });
      return newCachedComponents;
    });
    // 更新refreshing标识
    setTimeout(() => {
      setCachedComponents((prev) => {
        const newCachedComponents = [...prev];
        const index = cachedComponents.findIndex((item) => item.key === key);
        const newRefreshKey = uuidv4();
        if (index > -1) {
          newCachedComponents[index].refreshKey = newRefreshKey;
          newCachedComponents[index].refreshing = false;
        }
        cachedComponentsRef.current = newCachedComponents;
        updateAllCachedComponents(key, (allCachedComponentsRef, index) => {
          if (index > -1) {
            allCachedComponentsRef.current[index].refreshKey = newRefreshKey;
            allCachedComponentsRef.current[index].refreshing = false;
          }
        });
        return newCachedComponents;
      });
    }, refreshInterval);
  }, []);
  // ......

关闭当前页

关闭当前页就是从缓存数组里面过滤掉当前页面

 // src/components/RaKeepAlive/hooks/useKeepAlive.ts
 // ......
 // 关闭当前页就是一个数组过滤,把当前key的移除缓存数组
  const onRemoveCache = useCallback((key: string) => {
    setCachedComponents((prev) => {
      const newCachedComponents = [...prev];
      const index = cachedComponents.findIndex((item) => item.key === key);
      if (index > -1) {
        newCachedComponents.splice(index, 1);
      }
      cachedComponentsRef.current = newCachedComponents;
      updateAllCachedComponents(key, (allCachedComponentsRef, index) => {
        if (index > -1) {
          allCachedComponentsRef.current.splice(index, 1);
        }
      });
      return newCachedComponents;
    });
  }, []);
 // ......

关闭其他页面

关闭其他页面核心就是根据传入的需要过滤的keys返回剩下的页面

 // src/components/RaKeepAlive/hooks/useKeepAlive.ts
 // ......
 // 关闭其他页也是过滤数组,把不需要的keys从缓存数组里移除
  const onRemoveCacheByKeys = useCallback((keys: string[]) => {
    setCachedComponents((prev) => {
      const newCachedComponents = prev.filter(
        (item) => !keys.includes(item.key),
      );
      cachedComponentsRef.current = newCachedComponents;
      updateAllCachedComponents(keys, (allCachedComponentsRef, index) => {
        if (index) {
          allCachedComponentsRef.current =
            allCachedComponentsRef.current.filter(
              (item) => !keys.includes(item.key),
            );
        }
      });
      return newCachedComponents;
    });
  }, []);
  // ......

其他功能

为了能够全局缓存,提供了一个KeepAliveRoot组件,这个组件需要放在缓存路由组件的祖先位置,该组件不能嵌套使用。其核心就是一个Provider,子组件能够拿到里面全局缓存

// src/components/RaKeepAlive/components/KeepAliveRoot/index.tsx
import { useRef } from 'react';
import type { ReactNode } from 'react';

import { KeepAliveContext } from '../../KeepAliveContext';

import type { CachedComponent } from '../../interface';

type KeepAliveRootProps = {
  children: ReactNode;
};
const KeepAliveRoot = ({ children }: KeepAliveRootProps) => {
  const allCachedComponentsRef = useRef<CachedComponent[]>([]);

  return (
    <KeepAliveContext.Provider
      value={{
        allCachedComponentsRef,
      }}
    >
      {children}
    </KeepAliveContext.Provider>
  );
};

export default KeepAliveRoot;

KeepAliveRoot组件使用,可放在Layout中或是更靠前的位置

// src/layouts/index.tsx
import { Layout } from 'antd';

import { KeepAliveRoot } from '@/components/RaKeepAlive';

import AppContent from './components/Content';
import AppHeader from './components/Header';
import AppSider from './components/Sider';
import useMenu from './hooks/useMenu';

const BasicLayout = () => {
  useMenu();
  return (
  // 注意KeepAliveRoot组件使用位置
    <KeepAliveRoot>
      <Layout className="h-full" hasSider>
        <AppSider />
        <Layout className="relative overflow-hidden h-full flex flex-col">
          <AppHeader />
          <AppContent />
        </Layout>
      </Layout>
    </KeepAliveRoot>
  );
};

export default BasicLayout;

其他功能目前还没实现,后续根据需求在进行开发

参考:

rc-tabs

react离屏缓存(keep-alive)实现

react-offscreen