如何防止React组件重渲染?拆分窗口状态丢失问题解决
问题描述
我正在开发一款支持垂直与水平拆分的窗口UI,采用树结构递归实现。WindowManager连接至rootstore,接收窗口的拆分/删除指令并执行操作,核心组件为SplitWindow和Window。SplitWindow存储指定方向的子窗口节点并渲染拆分器,Window为带有标签页的主UI组件。
执行窗口拆分操作时,MobX的observer导致部分React组件重渲染,进而丢失状态:例如垂直拆分窗口1后,窗口1未重渲染,但下方窗口重渲染且丢失标签页。
WindowManager
import { useRootStore } from "../RootStore" import WindowNode from "./WindowNode"; import SplitWindowNode from "./SplitWindowNode"; import { makeObservable, observable, action } from "mobx" export function useWindowStore() { const { windowManager } = useRootStore() return windowManager } export default class WindowManager { constructor() { this.layoutRoot = new SplitWindowNode(null, [new WindowNode()]) makeObservable(this, { splitWindow: action, deleteWindow: action, layoutRoot: observable, }) } splitWindow(dir, windowNode) { const parent = this.getNode(windowNode.parentKey) if (parent.direction === dir || parent.direction == null) { parent.direction = dir const insertIdx = parent.children.findIndex(c => c.key === windowNode.key) + 1; parent.addChild(new WindowNode(), insertIdx) } else { const split = new SplitWindowNode(dir, [windowNode, new WindowNode()]) parent.swap(windowNode, split) } } deleteWindow(windowNode) { let parent = this.getNode(windowNode.parentKey) const windowNodeIndex = parent.children.indexOf(windowNode) parent.children.splice(windowNodeIndex, 1) if (parent.children.length < 2) { const parentOfParent = this.getNode(parent.parentKey) if (parentOfParent) { if (parent.children.length == 1) { parentOfParent.swap(parent, parent.children[0]) parent = null } } else { if (parent.children.length == 0) { parent.addChild(new WindowNode(), null) } } } } getNode(nodeKey) { if (nodeKey) { const queue = [this.layoutRoot] while (queue.length > 0) { const node = queue.shift() if (!node) return null if (node.key === nodeKey) return node if (node instanceof SplitWindowNode) { node.children.forEach(c => queue.push(c)) } } } return null } }
WindowContainer
import { useWindowStore } from "./WindowManager" import { Container } from "./WindowStyles"; import SplitWindow from "./SplitWindow"; function WindowContainer() { const windowManager = useWindowStore() return ( <Container> <SplitWindow splitWindowNode={windowManager.layoutRoot} /> </Container > ) } export default WindowContainer
SplitWindowNode
import getID from "../UniqueId" import { makeObservable, observable, computed } from "mobx" class SplitWindowNode { key = getID() children = observable.array([]) direction = null constructor(direction, nodes) { this.direction = direction for (const n of nodes) { this.addChild(n) } makeObservable(this, { children: observable.shallow, direction: observable, }) } set parentKey(key) { this._parentKey = key; } get parentKey() { return this._parentKey; } addChild(node, pos) { node.parentKey = this.key; if (pos) { this.children.splice(pos, 0, node) } else { this.children.push(node); } } swap(toSwap, newNode) { const swapIdx = this.children.findIndex(c => c.key === toSwap.key); if (swapIdx === -1) { throw new Error(`Cannot swap, node '${toSwap.key}' wasn't found in children.`); } newNode.parentKey = this.key; this.children[swapIdx] = newNode; } } export default SplitWindowNode
SplitWindow
import React from "react" import { observer } from "mobx-react-lite" import ReactSplit, { SplitDirection as ReactSplitDirection } from "@devbookhq/splitter" import Window from "./Window"; import WindowNode from "./WindowNode"; import { SplitDirection } from "../SplitDirection"; const SplitWindow = observer(({ splitWindowNode }) => { const direction = splitWindowNode.direction === SplitDirection.Horizontal ? ReactSplitDirection.Horizontal : ReactSplitDirection.Vertical return ( <ReactSplit direction={direction} onDidResize={console.log}> {splitWindowNode.children.map(c => ( <React.Fragment key={c.key}> {c instanceof WindowNode ? ( <Window key={c.key} windowNode={c} /> ) : ( <SplitWindow key={c.key} splitWindowNode={c} /> )} </React.Fragment> ))} </ReactSplit> ) }) export default SplitWindow
WindowNode
import getID from "../UniqueId" import { makeAutoObservable } from "mobx" import AppsIcon from '@mui/icons-material/Apps'; class WindowNode { key = getID() tabs = [{ index: "Tab 0", icon: <AppsIcon />, application: "application_selector" }] selectedTab = "Tab 0" tabIndex = 1 constructor() { makeAutoObservable(this) } set parentKey(key) { this._parentKey = key } get parentKey() { return this._parentKey } createTab() { const index = `Tab ${this.tabIndex}` const newTab = { index: index, icon: <AppsIcon />, application: "application_selector" } this.tabs = [...this.tabs, newTab] this.selectedTab = index this.tabIndex += 1 } handleTabClose(tab) { if (this.tabs.length > 1) { const index = this.tabs.indexOf(tab) this.tabs = this.tabs.filter(x => x !== tab) if (tab.index === this.selectedTab) { this.selectedTab = this.tabs[Math.min(index, this.tabs.length - 1)].index } } } setApp(icon, app) { const selectedTabIndex = this.tabs.findIndex(tab => tab.index === this.selectedTab) if (selectedTabIndex !== -1) { this.tabs[selectedTabIndex] = { ...this.tabs[selectedTabIndex], icon, application: app } } } setSelectedTab(newTab) { this.selectedTab = newTab } } export default WindowNode
Window
import React from "react" import { observer } from "mobx-react-lite" import { useWindowStore } from "./WindowManager"; import { SplitDirection } from "../SplitDirection"; import { WindowContainer, WindowHeader, WindowControls, WindowBody, TabContainer } from "./WindowStyles" import { Grid, IconButton, Tab } from '@mui/material'; import { TabList, TabContext, TabPanel } from '@mui/lab'; import SwapHorizIcon from '@mui/icons-material/SwapHoriz'; import SwapVertIcon from '@mui/icons-material/SwapVert'; import CloseIcon from '@mui/icons-material/Close'; import AddIcon from '@mui/icons-material/Add'; import ApplicationButton from "../applications/ApplicationButton"; const Window = observer(({ windowNode }) => { const windowManager = useWindowStore() function splitHorizontally() { windowManager.splitWindow(SplitDirection.Horizontal, windowNode) } function splitVertically() { windowManager.splitWindow(SplitDirection.Vertical, windowNode) } function close() { windowManager.deleteWindow(windowNode) } const handleChange = (event, newTab) => { if (event.target.type === "button") { windowNode.setSelectedTab(newTab); } }; return ( <WindowContainer> <TabContext value={windowNode.selectedTab}> <WindowHeader> <TabContainer> <TabList onChange={handleChange}> {windowNode.tabs.map(tab => ( <Tab icon={ <CloseIcon onClick={(e) => { e.stopPropagation(); windowNode.handleTabClose(tab); }} /> } iconPosition='end' key={tab.index} label={tab.icon} value={tab.index} /> ))} </TabList> <IconButton onClick={() => windowNode.createTab()}> <AddIcon /> </IconButton> </TabContainer> {windowNode.key} <WindowControls> <IconButton onClick={splitHorizontally}> <SwapHorizIcon /> </IconButton> <IconButton onClick={splitVertically}> <SwapVertIcon /> </IconButton> <IconButton onClick={close}> <CloseIcon /> </IconButton> </WindowControls> </WindowHeader> <WindowBody> {windowNode.tabs.map(tab => ( <TabPanel key={tab.index} value={tab.index}> {/* 渲染对应应用 */} </TabPanel> ))} </WindowBody> </TabContext> </WindowContainer> ) }) export default Window
解决方案
1. 迁移本地状态至MobX节点(核心修复)
Window组件的本地useState状态会在组件重渲染/重新挂载时丢失,将标签相关状态(tabs、selectedTab、tabIndex)和操作方法移至WindowNode,利用MobX的可观察性绑定状态与节点:
- 在
WindowNode中初始化标签状态,通过makeAutoObservable让所有属性可被MobX追踪 - 封装标签操作方法,确保状态变更在MobX的响应范围内
2. 完善MobX可观察属性配置
- 给
WindowManager的layoutRoot添加observable标记,确保根节点变更能被组件追踪 - 给
SplitWindowNode的direction添加observable标记,保证方向变更时组件正确响应
3. 精准控制组件重渲染
- Window组件现在仅依赖
windowNode的可观察属性,observer会精准追踪这些属性的变化,仅在关联状态变更时触发重渲染 - 所有状态变更通过MobX可观察属性执行,避免直接修改数组/对象导致MobX无法追踪
4. 修复事件冒泡问题
在标签关闭按钮中添加e.stopPropagation(),防止点击关闭时触发Tab切换事件
通过以上修改,窗口状态与对应的WindowNode绑定,即使组件因布局变更重渲染,状态也不会丢失,同时MobX仅在必要时触发重渲染,提升性能。
内容的提问来源于stack exchange,提问作者Sachen Sampath
相关产品推荐
相关产品推荐

