You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何防止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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.11 19:47:01