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 运行,优先级最高 |
hidden | display: 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 应用的官方推荐方案:
- 状态保留:切换页签时组件状态和 DOM 状态自动保留
- 性能优化:隐藏内容低优先级渲染,不影响可见内容
- 预渲染支持:可后台加载用户可能访问的页面
- 与生态集成:完美配合 React Router、Suspense、SSR
如果你正在开发 ERP 或类似的复杂后台系统,Activity API 是目前最靠谱的选择。