—— 适配 React 18,多页签缓存,解决 useRef 丢失问题


🧠 为什么要从 react-activation 迁移?

React18 在 StrictMode 下会:

  • double-render(mount → unmount → mount)

  • Offscreen 回收隐藏组件

  • 导致 KeepAlive 中的组件被卸载

  • useRef/state 直接丢失

👉 react-activation 已经不维护,不兼容 React18 + Umi4

而:

react-keepalive-router 完全兼容 React18
✔ 专为 react-router v6 & 后台系统的多 Tab 构建
✔ 不依赖 Offscreen hack
✔ useRef 永不丢失


🧩 迁移步骤

① 卸载旧包

npm remove react-activation

② 安装新包

npm install react-keepalive-router

③ 替换组件引用

- import { AliveScope, KeepAlive } from 'react-activation';
+ import { KeepAliveProvider, KeepAlive } from 'react-keepalive-router';

🔥 【最终版】AntD Pro 的 KeepAliveLayout(Markdown 完整可复制)

import { history, useIntl, useModel } from '@umijs/max';
import { Dropdown, Tabs } from 'antd';
import { useCallback, useMemo } from 'react';

import { CloseOutlined } from '@ant-design/icons';
import type { ItemType, MenuInfo } from 'rc-menu/lib/interface';

import {
  KeepAliveProvider,
  KeepAlive,
} from 'react-keepalive-router';

import { KeepAliveTabContext } from './context';
import { KeepAliveTab, useKeepAliveTabs } from './useKeepAliveTabs';

enum OperationType {
  REFRESH = 'refresh',
  CLOSE = 'close',
  CLOSEOTHER = 'close-other',
}

type MenuItemType = (ItemType & { key: OperationType }) | null;

const KeepAliveLayout = () => {
  const {
    keepAliveTabs,
    activeTabRoutePath,
    closeTab,
    refreshTab,
    closeOtherTab,
    onHidden,
    onShow,
    getTabDom,
    ROUTE_TAB_CONTENT_PREFIX,
  } = useKeepAliveTabs();

  const { initialState } = useModel('@@initialState');
  const intl = useIntl();

  const menuItems: MenuItemType[] = useMemo(
    () =>
      [
        {
          label: intl.formatMessage({
            id: 'component.layout.route.tabs.operation.refreshTab',
            defaultMessage: '刷新',
          }),
          key: OperationType.REFRESH,
        },
        keepAliveTabs.length <= 1
          ? null
          : {
              label: intl.formatMessage({
                id: 'component.layout.route.tabs.operation.closeTab',
                defaultMessage: '关闭',
              }),
              key: OperationType.CLOSE,
            },
        keepAliveTabs.length <= 1
          ? null
          : {
              label: intl.formatMessage({
                id: 'component.layout.route.tabs.operation.closeOtherTab',
                defaultMessage: '关闭其他',
              }),
              key: OperationType.CLOSEOTHER,
            },
      ].filter((o) => o),
    [keepAliveTabs],
  );

  const menuClick = useCallback(
    ({ key, domEvent }: MenuInfo, tab: KeepAliveTab) => {
      domEvent.stopPropagation();

      if (key === OperationType.REFRESH) {
        refreshTab(tab.routePath);
      } else if (key === OperationType.CLOSE) {
        closeTab(tab.routePath);
      } else if (key === OperationType.CLOSEOTHER) {
        closeOtherTab(tab.routePath);
      }
    },
    [closeOtherTab, closeTab, refreshTab],
  );

  const renderTabTitle = useCallback(
    (tab: KeepAliveTab) => {
      const menuMap = initialState?.permissionMenuMap;
      const menuInfo = menuMap?.get(tab.routePath);

      return (
        <Dropdown
          menu={{ items: menuItems, onClick: (e) => menuClick(e, tab) }}
          trigger={['contextMenu']}
        >
          <div style={{ margin: '-12px 0', padding: '12px 0' }}>
            {menuInfo && menuInfo.attr7 ? (
              <i className={menuInfo.attr7} style={{ marginRight: 4 }} />
            ) : null}
            {tab.title}
          </div>
        </Dropdown>
      );
    },
    [menuItems],
  );

  const tabItems = useMemo(() => {
    return keepAliveTabs.map((tab, tabIndex) => ({
      key: tab.routePath,
      label: renderTabTitle(tab),

      children: (
        <KeepAlive
          id={tab.routePath}
          name={tab.routePath}
          saveScroll
        >
          <div
            key={tab.key}
            id={ROUTE_TAB_CONTENT_PREFIX + tab.pathname}
            style={{
              height: 'calc(100vh - 96px)',
              overflow: 'auto',
              padding: 24,
            }}
          >
            {tab.children}
          </div>
        </KeepAlive>
      ),

      closeIcon: (
        <CloseOutlined className="keep-alive-tabs-close-icon" />
      ),
      closable: tabIndex > 0,
    }));
  }, [keepAliveTabs]);

  const onTabsChange = useCallback(
    (tabRoutePath: string) => {
      const curTab = keepAliveTabs.find((o) => o.routePath === tabRoutePath);
      if (curTab) {
        history.push(curTab.pathname);
      }
    },
    [keepAliveTabs],
  );

  const onTabEdit = (
    targetKey: React.MouseEvent | React.KeyboardEvent | string,
    action: 'add' | 'remove',
  ) => {
    if (action === 'remove') {
      closeTab(targetKey as string);
    }
  };

  const keepAliveContextValue = useMemo(
    () => ({
      closeTab,
      closeOtherTab,
      refreshTab,
      onHidden,
      onShow,
      getTabDom,
    }),
    [closeTab, closeOtherTab, refreshTab, onHidden, onShow, getTabDom],
  );

  return (
    <KeepAliveProvider>
      <KeepAliveTabContext.Provider value={keepAliveContextValue}>
        <Tabs
          type="editable-card"
          items={tabItems}
          activeKey={activeTabRoutePath}
          onChange={onTabsChange}
          className="keep-alive-tabs"
          hideAdd
          animated={false}
          onEdit={onTabEdit}
          destroyInactiveTabPane
        />
      </KeepAliveTabContext.Provider>
    </KeepAliveProvider>
  );
};

export default KeepAliveLayout;

🟩 你能获得的收益

功能

react-activation

react-keepalive-router

React 18 支持

❌ 不兼容

✔ 完全兼容

useRef 不丢失

❌ 经常丢

✔ 永不丢

多 Tab 缓存

半兼容

完整支持

Offscreen 回收问题

会被卸载

不会被卸载

中后台场景

不稳定

专为后台设计