业务架构
本文主要系统复盘bug修复过程中用到的核心知识和业务逻辑。
应用业务逻辑:
用户交互 → 视图层状态更新 → 业务层状态更新 → 持久化
↑ ↓ ↓
└─── 状态同步断裂点 ───┘
推荐的架构模式:
TypeScript
// 1. 单一数据源
const useCanvasStore = create((set, get) => ({
elements: [],
updateElement: (id, updates) => {
// 更新本地状态
set(state => ({/* 不可变更新 */}));
// 同步到editor模块
editor.updateElement(id, updates);
}
}));
// 2. 所有组件都从store读取
const ElementToolbar = () => {
const { elements, updateElement } = useCanvasStore();
// ...
}
1 React 状态管理 (State Management)
问题体现: 状态不同步
TypeScript
// CanvasView 中的状态
const [elements, setElements] = useState<CanvasElement[]>([]);
// editor 模块中的状态
let currentDocument: CanvasDocument | null = null;
官方文档重点:
业务逻辑理解:
-
你的应用存在 多个状态副本:组件状态 + 模块状态
-
当用户在工具栏修改时,只更新了组件状态,editor模块状态未同步
-
这属于典型的 "状态分散" 架构问题
currentDocument 为什么是 "不同步状态"?
核心原因是:它是普通变量,而非 React 状态 / 上下文,不具备 "响应式更新" 和 "跨组件状态同步" 的能力。我们从「定义形式」「工作机制」「问题表现」三个维度拆解:
一、先明确两个状态的本质区别
| 特征 | CanvasView 中的 elements(React 状态) |
editor 模块的 currentDocument(普通变量) |
|---|---|---|
| 定义方式 | useState 声明 |
let 普通变量声明 |
| 响应式能力 | 有:setElements 触发组件重新渲染,读取到最新值 |
无:赋值后不会触发任何组件更新 |
| 跨组件同步 | 可通过 Props/Context 共享,所有使用处拿到一致值 | 仅当前模块内有效,跨组件 / 跨模块读取可能拿到旧值 |
| 生命周期绑定 | 与组件生命周期同步(组件卸载后状态销毁) | 与模块生命周期绑定(页面不刷新则一直存在,可能内存泄漏) |
| 状态追踪 | React DevTools 可查看 / 调试 | 无法被 React 追踪,调试困难 |
二、currentDocument 是 "不同步状态" 的具体原因
1. 它不是 React 管理的状态,不具备 "响应式更新"!!!!
React 状态(如 useState)的核心是:状态更新 → 触发组件重新渲染 → 组件内读取到最新状态 。而 currentDocument 是 let 声明的普通变量,哪怕你在某个地方给它赋值(如 currentDocument = new CanvasDocument()):
- 赋值操作不会通知 React 进行重渲染;
- 其他依赖
currentDocument的组件,因为没有重新渲染,读取到的还是赋值前的旧值(本质是闭包捕获的旧变量引用)。
2. 模块级变量的 "全局性" 导致状态不一致
currentDocument 是「模块级变量」 (定义在 editor 模块顶层),特点是:
- 页面不刷新时,模块只会加载一次,变量会一直存在(相当于 "全局单例");
- 若多个组件 / 函数同时读写它,没有 "原子操作" 和 "更新通知" 机制,容易出现 "竞态条件":例:组件 A 赋值
currentDocument = doc1,组件 B 同时赋值currentDocument = doc2,最终可能导致某个组件读取到错误的值; - 跨组件使用时,组件可能捕获的是变量的 "旧引用"(比如组件挂载时读取了
currentDocument并存在本地状态,后续模块变量更新后,组件本地状态不会同步)。
3. 与 React 组件生命周期脱节
React 组件的状态(useState)会随着组件的挂载 / 卸载而创建 / 销毁,状态更新会和组件渲染周期同步。而 currentDocument 不受 React 生命周期管理:
- 组件卸载后,模块变量依然存在,若它持有组件相关的引用(如 DOM、回调函数),会导致内存泄漏;
- 组件重新挂载时,可能读取到上一次组件卸载前的旧值(状态没有 "重置")。
三、常见问题表现(你可能遇到的场景)
- 给
currentDocument赋值后,组件渲染的还是旧数据; - 多个组件使用
currentDocument,有的组件拿到新值,有的拿到旧值; - 页面路由切换后(组件卸载再挂载),
currentDocument没有重置,出现异常数据; - 调试时,无法通过 React DevTools 看到
currentDocument的更新记录。
四、如何修复:让 currentDocument 成为 "同步状态"
核心思路是:将 currentDocument 纳入 React 状态管理,根据使用范围选择方案:
方案 1:组件内使用(仅 CanvasView 相关)
直接将 currentDocument 定义为组件内状态,与 elements 同级:
TypeScript
// CanvasView 组件内
const [elements, setElements] = useState<CanvasElement[]>([]);
const [currentDocument, setCurrentDocument] = useState<CanvasDocument | null>(null);
// 更新时使用 setCurrentDocument,触发组件重渲染
const handleOpenDocument = (doc: CanvasDocument) => {
setCurrentDocument(doc);
// 同步更新 elements(如果需要)
setElements(doc.elements);
};
方案 2:跨组件共享(多个组件需要用到 currentDocument)
用 createContext + useContext 全局共享状态,避免 props 透传:
- 创建上下文:
TypeScript
// src/contexts/CanvasContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
import { CanvasDocument, CanvasElement } from './types';
interface CanvasContextType {
currentDocument: CanvasDocument | null;
elements: CanvasElement[];
setCurrentDocument: (doc: CanvasDocument | null) => void;
setElements: (elements: CanvasElement[]) => void;
}
// 默认值仅用于类型提示,实际使用时必须提供 Provider
const CanvasContext = createContext<CanvasContextType | undefined>(undefined);
// Provider 组件:包裹整个应用或需要用到的区域
export const CanvasProvider = ({ children }: { children: ReactNode }) => {
const [currentDocument, setCurrentDocument] = useState<CanvasDocument | null>(null);
const [elements, setElements] = useState<CanvasElement[]>([]);
return (
<CanvasContext.Provider value={{ currentDocument, elements, setCurrentDocument, setElements }}>
{children}
</CanvasContext.Provider>
);
};
// 自定义 Hook:方便组件使用上下文
export const useCanvas = () => {
const context = useContext(CanvasContext);
if (!context) throw new Error('useCanvas 必须在 CanvasProvider 内使用');
return context;
};
- 在根组件包裹 Provider:
TypeScript
// src/App.tsx
import { CanvasProvider } from './contexts/CanvasContext';
import CanvasView from './CanvasView';
import EditorToolbar from './EditorToolbar'; // 其他需要用到文档状态的组件
function App() {
return (
<CanvasProvider>
<EditorToolbar />
<CanvasView />
</CanvasProvider>
);
}
- 组件中使用(包括
editor模块相关组件):
TypeScript
// CanvasView 或 EditorToolbar 组件
import { useCanvas } from './contexts/CanvasContext';
const CanvasView = () => {
const { currentDocument, elements, setCurrentDocument } = useCanvas();
// 直接使用,状态自动同步,更新时触发重渲染
return <div>{currentDocument?.title}</div>;
};
// editor 模块中的函数(如果需要操作状态)
export const updateDocumentTitle = (newTitle: string, setCurrentDocument: (doc: CanvasDocument | null) => void) => {
setCurrentDocument(prev => prev ? { ...prev, title: newTitle } : null);
};
方案 3:复杂场景(多页面、大规模状态)
如果 currentDocument 需要跨页面共享,或状态逻辑复杂(如异步加载文档、多文档切换),可以使用:
- Redux Toolkit / Zustand / Jotai 等状态管理库;
- 核心思路:将
currentDocument作为全局状态,通过库提供的 "更新方法" 触发状态变更,所有订阅组件都会收到通知并更新。
总结
currentDocument 是 "不同步状态" 的根本原因:
它是模块级普通变量,不受 React 管理,没有响应式更新和跨组件同步能力。
修复的核心是:将其纳入 React 状态体系(组件内状态 / 上下文 / 状态管理库),让状态更新能触发组件重渲染,确保所有使用处拿到一致的最新值。

2 React 组件通信 (Component Communication)
问题体现: ElementToolbar → CanvasView → editor 的调用链
// 调用链条
ElementToolbar → handleSizeChange
→ CanvasView → handleUpdateElement
→ editor → updateElement
官方文档重点:
业务逻辑理解:
-
你的架构是 "控制反转" 模式:子组件触发,父组件执行
-
但数据流出现了 "断链":视图层和业务层状态脱节
-
应该采用 "单一数据源" 原则
控制反转(IoC)
React 中文文档没有直接使用 "控制反转(IoC)" 这个术语 ,但 React 的核心设计思想(如组件组合、Props 传递、Context API、Hooks 等)本质上是控制反转模式的具体体现------React 通过 "框架接管核心流程,开发者填充具体逻辑" 的方式,实现了 "控制权从开发者转移到框架",这正是 IoC 的核心内涵。
先明确:什么是 "控制反转(IoC)"?
IoC 的核心是「反转依赖的创建和管理权」:
- 传统开发:开发者主动创建依赖(如
new A())、控制流程(如手动调用函数更新 UI); - IoC 模式:框架 / 容器接管依赖的创建、生命周期管理、流程调度,开发者只需提供 "具体逻辑"(如组件渲染内容、事件处理函数),由框架在合适的时机调用。
简单说:开发者不用管 "什么时候执行",只需要管 "执行什么",控制权在框架手里。
React 中 IoC 思想的体现(中文文档间接覆盖的场景)
React 中文文档虽然没提 "IoC",但以下核心特性都是 IoC 的落地,且文档对这些特性有详细说明:
1. Props 传递 + 组件组合(最基础的 IoC 体现)
React 中文文档「组件 & Props」章节强调:"组件通过 Props 接收外部数据和回调,自身只关注渲染和内部逻辑"。这正是 IoC 的核心:
- 控制权反转:组件不自己决定 "要渲染什么数据""点击后执行什么逻辑",而是由父组件通过 Props 传递(反转了 "数据 / 逻辑的控制权");
- 开发者职责:只需编写组件的 "渲染规则"(如
props.data如何展示)和 "局部逻辑",无需关心数据来源、回调的具体实现。
示例(对应你之前的 ElementToolbar 组件):
TypeScript
// 子组件(ElementToolbar):不控制数据和逻辑,只接收 Props 并执行
const ElementToolbar = ({ element, onUpdateElement }) => {
// 只关注"用户点击后调用 onUpdateElement",不关心 onUpdateElement 具体做什么
const handleColorChange = (color) => onUpdateElement(element.id, { color });
return <button onClick={() => handleColorChange('red')}>红色</button>;
};
// 父组件(CanvasView):提供数据和逻辑,通过 Props 注入子组件
const CanvasView = () => {
const updateElement = (id, updates) => { /* 核心更新逻辑 */ };
return <ElementToolbar element={selectedElement} onUpdateElement={updateElement} />;
};
React 文档对这种模式的描述是 "组件复用和组合",但本质是 "子组件的依赖(数据、回调)由父组件注入,反转了依赖控制权"------ 这是 IoC 最基础的形式。
2. Context API + 依赖注入(IoC 的进阶体现)
React 中文文档「Context」章节提到:"Context 用于跨组件共享数据,避免 Props 透传"。从 IoC 角度看,Context 本质是「依赖注入容器」:
- 控制权反转:组件无需通过 Props 层层传递依赖(如你之前的
currentDocument),而是通过useContext从 Context 中 "获取" 依赖,依赖的创建、更新由 Context Provider 统一管理; - 开发者职责:只需在需要的地方 "消费" 依赖,无需关心依赖的传递路径和更新时机(由 React 框架接管)。
示例(对应你之前的 CanvasContext):
TypeScript
// Context 容器(反转依赖的管理权)
const CanvasContext = createContext();
export const CanvasProvider = ({ children }) => {
const [currentDocument, setCurrentDocument] = useState(null);
const updateElement = (id, updates) => { /* 核心逻辑 */ };
// 提供依赖(数据 + 方法)
return <CanvasContext.Provider value={{ currentDocument, updateElement }}>{children}</CanvasContext.Provider>;
};
// 组件消费依赖(无需关心依赖来源)
const ElementToolbar = ({ element }) => {
const { updateElement } = useContext(CanvasContext); // 直接获取依赖
return <button onClick={() => updateElement(element.id, { color: 'red' })}>红色</button>;
};
React 文档将 Context 描述为 "跨组件数据共享方案",但本质是 "依赖的注入和管理由框架(Context)接管,开发者无需手动传递"------ 这是 IoC 中 "依赖注入(DI)" 的典型实现(DI 是 IoC 的一种具体落地方式)。
3. React 框架接管渲染流程(最核心的 IoC 体现)
React 中文文档「主概念」开篇就强调:"React 是一个用于构建用户界面的 JavaScript 库,它通过组件化的方式,让你可以将 UI 拆分为独立的、可复用的部分"。背后的核心逻辑是「框架接管渲染控制权」:
- 传统 DOM 开发:开发者手动控制 DOM 增删改查(如
document.createElement、innerHTML),需要关心 "什么时候更新 DOM""怎么更新 DOM"; - React 开发:开发者只需编写「渲染函数(JSX)」和「状态更新逻辑(setState/useState)」,React 框架会自动处理 "虚拟 DOM 对比""DOM 批量更新""组件生命周期调度"------ 控制权从开发者反转到了 React 框架。
这是 IoC 最核心的体现:开发者只关注 "UI 应该是什么样"(声明式),而不关注 "如何实现 UI 更新"(命令式),框架接管了核心流程的控制权。React 文档中反复强调的 "声明式编程""虚拟 DOM""React 渲染机制",本质都是这种控制权反转的落地。
4. Hooks(IoC 思想在状态和副作用上的延伸)
React 中文文档「Hooks」章节提到:"Hooks 让你在不编写 class 的情况下使用 state 以及其他 React 特性"。从 IoC 角度看:
- 控制权反转:开发者无需手动管理组件生命周期(如
componentDidMount、componentDidUpdate),而是通过useEffect声明 "副作用逻辑",由 React 框架在合适的时机(组件挂载后、状态更新后)自动执行; - 开发者职责:只需声明 "要执行什么副作用",无需关心 "什么时候执行""如何清理"(如
useEffect的返回清理函数)。
TypeScript
// 开发者只声明"要监听窗口大小变化",不关心"何时监听/取消监听"
useEffect(() => {
const handleResize = () => console.log(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
React 文档对 useEffect 的描述是 "副作用钩子",但本质是 "副作用的执行和清理由框架接管,反转了生命周期的控制权"------ 延续了 IoC 的核心思想。
总结:React 文档与 IoC 的关系
- React 中文文档没有直接提及 "控制反转(IoC)" 术语,因为它是一个 "设计思想",而非 React 的 "具体特性";
- React 的核心特性(组件组合、Props、Context、Hooks、渲染机制)全部基于 IoC 思想设计,文档对这些特性的讲解,本质上是在教你如何使用 IoC 模式开发 React 应用;
- 你之前的状态同步设计(Toolbar 接收回调、CanvasView 中转、Editor 管理状态),正是 React IoC 思想的实践 ------ 组件不控制依赖(数据 / 逻辑),而是通过 Props/Context 注入,由上层组件 / 框架管理,这与 React 的设计理念完全一致。
简单说:React 文档不教你 "什么是 IoC",但教你的每一个核心用法,都是 IoC 的具体落地。
(附录)建议的学习路径
-
立即补课:
-
React状态管理 - 重点!
-
-
中期提升:
-
长期架构:
-
React Query 用于服务器状态
关键洞察
这个bug暴露的不是编码能力问题,而是架构设计认知。我现在应该:
-
识别状态归属:明确每个状态应该由谁拥有
-
建立数据流契约:定义清晰的状态同步规则
-
设计错误恢复:状态不一致时的修复策略