React 19 多页签 ERP 应用:Activity API 完全指南

2026/3/23

ReactActivityERP多页签前端架构

React 19 多页签 ERP 应用:Activity API 完全指南

React 19.2 (2025年10月) 正式发布了 <Activity> 组件,这是实现多页签 ERP 应用的官方推荐方案。前身是 unstable_Offscreen,经过多年打磨终于稳定。


🎯 核心问题

ERP 应用通常需要:

  • 同时打开多个页签(如订单、库存、报表)
  • 切换页签时保留状态(表单输入、滚动位置)
  • 避免重复加载数据和渲染

传统方案:

// ❌ 问题:切换后状态丢失
{activeTab === 'orders' && <Orders />}
{activeTab === 'inventory' && <Inventory />}

✅ React Activity API

基本用法

import { Activity } from 'react';

function ERPApp() {
  const [activeTab, setActiveTab] = useState('orders');

  return (
    <>
      <TabBar active={activeTab} onChange={setActiveTab} />
      
      <Activity mode={activeTab === 'orders' ? 'visible' : 'hidden'}>
        <Orders />
      </Activity>
      
      <Activity mode={activeTab === 'inventory' ? 'visible' : 'hidden'}>
        <Inventory />
      </Activity>
      
      <Activity mode={activeTab === 'reports' ? 'visible' : 'hidden'}>
        <Reports />
      </Activity>
    </>
  );
}

Activity 的两种模式

模式行为
visible正常渲染,Effects 运行,优先级最高
hiddendisplay: none,Effects 清理,状态保留,低优先级更新

🔥 核心优势

1. 保留组件状态

// 订单页签中的表单输入,切换后再回来,输入内容还在
function Orders() {
  const [searchText, setSearchText] = useState('');
  const [expandedRows, setExpandedRows] = useState(new Set());
  
  return (
    <div>
      <input value={searchText} onChange={e => setSearchText(e.target.value)} />
      {/* 切换到其他页签再回来,searchText 和 expandedRows 都保留 */}
    </div>
  );
}

2. 保留 DOM 状态

// 滚动位置、视频播放进度、文本选择等 DOM 状态自动保留
<Activity mode={activeTab === 'video' ? 'visible' : 'hidden'}>
  <VideoPlayer />
</Activity>

3. 预渲染(后台加载)

// 隐藏状态下仍会渲染,可以提前加载数据
<Activity mode="hidden">
  <SlowComponent />  {/* 后台渲染,切换过来时秒开 */}
</Activity>

结合 Suspense 使用:

<Suspense fallback={<Loading />}>
  <Activity mode={activeTab === 'orders' ? 'visible' : 'hidden'}>
    <Orders />
  </Activity>
  <Activity mode={activeTab === 'reports' ? 'visible' : 'hidden'}>
    <Reports />  {/* 后台预加载数据 */}
  </Activity>
</Suspense>

🛠 与 React Router 结合

方案一:在 Router 层使用 Activity

import { Activity } from 'react';
import { Routes, Route, useLocation } from 'react-router-dom';

function App() {
  const location = useLocation();
  
  return (
    <div className="app">
      <TabBar />
      
      {/* 所有页面都保持挂载,只是隐藏/显示 */}
      <Activity mode={location.pathname.startsWith('/orders') ? 'visible' : 'hidden'}>
        <Routes>
          <Route path="/orders/*" element={<OrdersModule />} />
        </Routes>
      </Activity>
      
      <Activity mode={location.pathname.startsWith('/inventory') ? 'visible' : 'hidden'}>
        <Routes>
          <Route path="/inventory/*" element={<InventoryModule />} />
        </Routes>
      </Activity>
    </div>
  );
}

方案二:自定义 TabRouter 组件

import { Activity, createContext, useContext, useState } from 'react';

const TabContext = createContext();

export function TabRouter({ children }) {
  const [tabs, setTabs] = useState([
    { id: 'home', label: '首页', path: '/' }
  ]);
  const [activeTab, setActiveTab] = useState('home');

  const openTab = (id, label, path) => {
    setTabs(prev => {
      if (prev.find(t => t.id === id)) return prev;
      return [...prev, { id, label, path }];
    });
    setActiveTab(id);
  };

  return (
    <TabContext.Provider value={{ tabs, activeTab, openTab, setActiveTab }}>
      <div className="tab-bar">
        {tabs.map(tab => (
          <button 
            key={tab.id}
            className={activeTab === tab.id ? 'active' : ''}
            onClick={() => setActiveTab(tab.id)}
          >
            {tab.label}
            <span onClick={(e) => {
              e.stopPropagation();
              closeTab(tab.id);
            }}>×</span>
          </button>
        ))}
      </div>
      {children}
    </TabContext.Provider>
  );
}

export function TabPanel({ id, children }) {
  const { activeTab } = useContext(TabContext);
  
  return (
    <Activity mode={activeTab === id ? 'visible' : 'hidden'}>
      {children}
    </Activity>
  );
}

// 使用
function ERPApp() {
  return (
    <TabRouter>
      <TabPanel id="orders"><Orders /></TabPanel>
      <TabPanel id="inventory"><Inventory /></TabPanel>
      <TabPanel id="reports"><Reports /></TabPanel>
    </TabRouter>
  );
}

⚠️ 注意事项

1. Effects 会被清理

隐藏时,组件的 Effects 会被 cleanup:

function ChatPanel() {
  useEffect(() => {
    const ws = connectWebSocket();
    return () => ws.close();  // 隐藏时会调用
  }, []);
  
  return <div>...</div>;
}

// 隐藏时 WebSocket 会断开,显示时重新连接

2. 处理有副作用的 DOM 元素

<video>, <audio>, <iframe> 需要手动清理:

function VideoTab() {
  const videoRef = useRef();

  useLayoutEffect(() => {
    return () => {
      // Activity 隐藏时暂停视频
      videoRef.current?.pause();
    };
  }, []);

  return <video ref={videoRef} src="..." controls />;
}

3. 不要过度使用

Activity 有内存开销。只在真正需要保留状态的场景使用:

  • 用户可能频繁切换的页签
  • 表单输入中途可能离开的页面
  • 有复杂交互状态的组件

📊 性能对比

方案状态保留内存占用切换速度
条件渲染慢(重新渲染)
CSS 隐藏
Activity快(低优先级更新)

Activity 的优势:

  • 隐藏时以低优先级渲染,不影响可见内容性能
  • Effects 自动清理/恢复,更安全
  • 与 Suspense/SSR 完美配合

🚀 实战示例:完整的多页签 ERP

import { Activity, useState, Suspense, lazy } from 'react';

// 懒加载模块
const Orders = lazy(() => import('./modules/Orders'));
const Inventory = lazy(() => import('./modules/Inventory'));
const Reports = lazy(() => import('./modules/Reports'));
const Customers = lazy(() => import('./modules/Customers'));

const MODULES = {
  orders: { label: '订单管理', Component: Orders },
  inventory: { label: '库存管理', Component: Inventory },
  reports: { label: '报表中心', Component: Reports },
  customers: { label: '客户管理', Component: Customers },
};

function ERPMultiTabApp() {
  const [openTabs, setOpenTabs] = useState(['orders']);
  const [activeTab, setActiveTab] = useState('orders');

  const openModule = (moduleId) => {
    if (!openTabs.includes(moduleId)) {
      setOpenTabs([...openTabs, moduleId]);
    }
    setActiveTab(moduleId);
  };

  const closeTab = (moduleId) => {
    const newTabs = openTabs.filter(t => t !== moduleId);
    setOpenTabs(newTabs);
    if (activeTab === moduleId && newTabs.length > 0) {
      setActiveTab(newTabs[newTabs.length - 1]);
    }
  };

  return (
    <div className="erp-app">
      {/* 侧边栏导航 */}
      <nav className="sidebar">
        {Object.entries(MODULES).map(([id, { label }]) => (
          <button key={id} onClick={() => openModule(id)}>
            {label}
          </button>
        ))}
      </nav>

      {/* 页签栏 */}
      <div className="tab-bar">
        {openTabs.map(tabId => (
          <div 
            key={tabId} 
            className={`tab ${activeTab === tabId ? 'active' : ''}`}
            onClick={() => setActiveTab(tabId)}
          >
            {MODULES[tabId].label}
            <button onClick={(e) => { e.stopPropagation(); closeTab(tabId); }}>
              ×
            </button>
          </div>
        ))}
      </div>

      {/* 内容区域 - 所有打开的模块都保持挂载 */}
      <main className="content">
        <Suspense fallback={<Loading />}>
          {openTabs.map(tabId => {
            const { Component } = MODULES[tabId];
            return (
              <Activity key={tabId} mode={activeTab === tabId ? 'visible' : 'hidden'}>
                <Component />
              </Activity>
            );
          })}
        </Suspense>
      </main>
    </div>
  );
}

function Loading() {
  return <div className="loading">加载中...</div>;
}

📚 参考资源


总结

React 19.2 的 <Activity> API 是实现多页签 ERP 应用的官方推荐方案

  1. 状态保留:切换页签时组件状态和 DOM 状态自动保留
  2. 性能优化:隐藏内容低优先级渲染,不影响可见内容
  3. 预渲染支持:可后台加载用户可能访问的页面
  4. 与生态集成:完美配合 React Router、Suspense、SSR

如果你正在开发 ERP 或类似的复杂后台系统,Activity API 是目前最靠谱的选择。

📝 文章反馈