欢迎来到“React 状态同步大教堂”。我是你们的主讲人,一个在 React 代码里摸爬滚打了十年的老油条。
今天我们要聊的话题,听起来有点枯燥,甚至有点像是在给计算机念经:“生命周期”、“副作用”、“外部状态”。但在座的各位,不管是刚入门的新手,还是觉得自己已经看透红尘的老手,请把手里的咖啡放下,听我说完。因为如果你搞不懂这个,你的应用迟早会变成一个“幽灵应用”——它在内存里活着,但在外部世界里,它早就死了。
我们要解决的核心问题是:当 React 的内部状态(比如 UI 上的数字变了)和外部存储(比如数据库、Redux、或者 LocalStorage)发生冲突时,你怎么保证它们是一致的?
别担心,这不像处理婆媳关系那么难,虽然有时候感觉差不多。
第一部分:类组件的旧时代遗物
在 Hooks 出现之前,React 给了我们一套非常明确的规则,就像交通信号灯一样。那时候,外部状态同步全靠这三盏灯:componentDidMount(挂载),componentDidUpdate(更新),componentWillUnmount(卸载)。
1. 挂载:你是谁?你在哪?
当你把一个组件扔到页面上,React 就像是在招租。componentDidMount 就是租客进门的那一刻。
这是你第一次接触到“外部状态”的最佳时机。通常,外部状态是静态的或者需要从服务器拉取的。你不能在构造函数里做这件事,因为构造函数只负责“搭骨架”,不负责“搞装修”。
场景: 假设我们有一个用户信息组件,数据存在一个叫 UserService 的外部服务里。
import React, { Component } from 'react';
class UserProfile extends Component {
constructor(props) {
super(props);
this.state = {
user: null,
loading: true
};
}
// 这就是那盏“挂载”绿灯
componentDidMount() {
console.log("挂载了!我要去拉取用户数据了!");
// 去外部存储(比如 API 或 Redux)拿数据
UserService.getUser(this.props.userId)
.then(data => {
// 更新 React 状态
this.setState({ user: data, loading: false });
})
.catch(err => {
this.setState({ loading: false, error: err.message });
});
}
render() {
if (this.state.loading) return <div>正在加载你的灵魂...</div>;
if (this.state.error) return <div>哎呀,加载失败了。</div>;
return (
<div>
<h1>欢迎, {this.state.user.name}</h1>
<p>ID: {this.state.user.id}</p>
</div>
);
}
}
你看,这就是同步的第一步:初始化。外部存储告诉 React “你是谁”,React 告诉用户“我拿到你了”。
2. 更新:别光顾着照镜子
当你修改了 this.state,或者父组件传了新的 props,React 就会触发 componentDidUpdate。这就像是你换了个发型,或者换了件衣服,你得去照照镜子确认一下,顺便告诉你的经纪人(外部服务):“嘿,我变了!”
但是,这里有个巨大的陷阱。如果你在 componentDidUpdate 里直接去更新外部存储,而外部存储的更新又触发了 React 的状态更新……哎哟,这就死循环了。
正确的姿势是:比较。
componentDidUpdate(prevProps, prevState) {
// 只有当用户 ID 真的变了,我才去请求新数据
if (prevProps.userId !== this.props.userId) {
console.log("用户变了,我要去拉取新数据了!");
this.setState({ loading: true }); // 先把 UI 变成 loading
UserService.getUser(this.props.userId)
.then(data => {
this.setState({ user: data, loading: false });
});
}
// 另一种场景:如果 React 状态变了,同步到外部存储
if (this.state.user && prevState.user !== this.state.user) {
console.log("用户信息变了,我要去保存!");
UserService.saveUser(this.state.user);
}
}
3. 卸载:记得关灯
这是新手最容易忽略的。当你离开这个页面,组件被销毁了。如果此时你的组件还在默默地向外部存储发送心跳包,或者订阅了某个 WebSocket,那么这个连接就会一直存在,直到服务器超时。
这就好比你在酒店退房了,但是你还在走廊里大声放音乐。房东会恨死你的。
componentWillUnmount() {
console.log("我要走了,我要断开连接了!");
// 绝对不能忘!
UserService.disconnect();
// 或者 unsubscribe(eventId)
}
第二部分:Hooks 时代,副作用大爆发
好,时光飞逝,React 16.8 带来了 Hooks。类组件那套显式的生命周期被“副作用”这个词取代了。
useEffect 就像一个万能插座。它可以在组件渲染后执行任何事:订阅数据、修改 DOM、或者更新外部状态。
1. 初始化与挂载
在 useEffect 里,[](空依赖数组)就等同于 componentDidMount。
import React, { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
console.log("组件挂载了,或者 userId 变了,我要去拉数据。");
const controller = new AbortController(); // 防止竞态条件的利器
UserService.getUser(userId, { signal: controller.signal })
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});
// 返回清理函数,相当于 componentWillUnmount
return () => {
console.log("组件卸载了,或者 userId 变了,我要取消请求。");
controller.abort(); // 取消那个还没回来的请求
};
}, [userId]); // 依赖项:userId
if (loading) return <div>加载中...</div>;
return <div>用户: {user?.name}</div>;
}
这里有个非常重要的点:清理函数。useEffect 的清理函数会在组件卸载时执行,也会在组件因为依赖项变化而重新运行之前执行。这意味着,如果你快速切换用户 ID,React 会先执行上一个 userId 对应的 useEffect 的清理函数(取消请求),然后重新运行新的 useEffect。
2. 同步外部变更回 React
有时候,外部状态变了,React 不知道。比如用户在另一个标签页登录了,或者 Redux 的 store 更新了。
我们怎么监听外部变化?
方案 A:轮询(不推荐,除非你疯了)
useEffect(() => {
const interval = setInterval(() => {
// 去检查外部状态
if (externalStore.isDirty) {
// 更新 React 状态
setLocalData(externalStore.getData());
}
}, 1000);
return () => clearInterval(interval);
}, []);
这就像你每隔一秒去看一眼手机有没有消息,虽然能收到,但太累了,而且容易漏。
方案 B:事件监听(推荐)
假设外部状态是一个全局的 PubSub 事件。
import PubSub from 'pubsub-js';
function MyComponent() {
const [count, setCount] = useState(0);
useEffect(() => {
// 订阅事件
const token = PubSub.subscribe('GLOBAL_UPDATE', (msg, data) => {
console.log("收到外部广播:", data);
setCount(data.value);
});
// 清理:退订
return () => {
PubSub.unsubscribe(token);
};
}, []);
return <div>计数: {count}</div>;
}
第三部分:Redux 与 Context API 的同步艺术
这是 React 生态里最常见的外部状态存储。它们就像一个巨大的中央银行。我们的组件是储户,React 是银行柜台。
1. Redux 的订阅陷阱
在 Redux 里,我们常用 useSelector 来获取状态。但是,如果你在 useEffect 里去 dispatch action,这通常是不对的,因为 dispatch 会导致组件重新渲染,而重新渲染会再次触发 useEffect(如果依赖项没控制好),导致无限循环。
正确的做法是利用 Redux 的订阅机制,或者在 useEffect 里做那些“只做一次”的副作用。
错误示范:在 useEffect 里 dispatch
useEffect(() => {
// 危险!这会触发 Redux 更新 -> 触发 Selector 变化 -> 触发组件重渲染 -> 再次触发 useEffect
dispatch(incrementCounter());
}, [dispatch]);
正确示范:利用 useSelector 监听外部状态
Redux 是单向数据流。只要你在 Redux 里更新了数据,所有订阅了该数据的组件都会收到通知。
import { useSelector, useDispatch } from 'react-redux';
function Counter() {
const count = useSelector(state => state.count);
const dispatch = useDispatch();
useEffect(() => {
console.log("计数器变了,我需要执行一些副作用,比如记录日志");
}, [count]);
return (
<div>
<h1>{count}</h1>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>加 1</button>
</div>
);
}
在这里,React 和 Redux 是自动同步的。你不需要手动写 componentDidUpdate 去同步 Redux,因为 React 的渲染循环会自动处理。但是,你依然需要 useEffect 来处理副作用(比如发起 API 请求,或者调用第三方库)。
2. Context API 的同步
Context API 本质上也是一个订阅者模式。当你 Provider 的 value 变了,所有 Context 的消费者都会重新渲染。
const ThemeContext = React.createContext();
function App() {
const [theme, setTheme] = useState('dark');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Toolbar />
</ThemeContext.Provider>
);
}
function Toolbar() {
const { theme, setTheme } = useContext(ThemeContext);
useEffect(() => {
// 这里同步到外部系统,比如修改 document 的 title 或者发送埋点
document.title = `当前主题: ${theme}`;
console.log(`主题已切换为: ${theme}`);
}, [theme]);
return (
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
切换主题
</button>
);
}
第四部分:LocalStorage 与 IndexedDB 的持久化同步
这是最让人头疼的,因为 localStorage 是同步的。而 React 是异步的。这会导致性能问题和竞态条件。
1. 简单的同步:从存储初始化状态
如果你只是想把状态存下来,下次打开页面还能读出来,你可以这么做。
function MyForm() {
const [name, setName] = useState('');
useEffect(() => {
// 1. 初始化:从 LocalStorage 读取
const savedName = localStorage.getItem('my_app_name');
if (savedName) {
setName(savedName);
}
}, []);
useEffect(() => {
// 2. 同步:当 React 状态改变时,写回 LocalStorage
localStorage.setItem('my_app_name', name);
}, [name]);
return <input value={name} onChange={(e) => setName(e.target.value)} />;
}
这看起来很完美,对吧?但如果你在同一个页面有多个输入框,它们都监听 name 变化,你每敲一个字,整个页面可能就会因为 localStorage 的同步读写导致闪烁,或者因为频繁的 useEffect 触发而卡顿。
2. 高级技巧:防抖
为了避免每敲一个字都触发 localStorage 写入,我们需要“防抖”。
import { useState, useEffect } from 'react';
function MyForm() {
const [name, setName] = useState('');
useEffect(() => {
// 使用一个定时器来延迟保存
const timer = setTimeout(() => {
console.log("保存到 LocalStorage:", name);
localStorage.setItem('my_app_name', name);
}, 500); // 停止输入 500ms 后才保存
// 清理函数:如果用户继续输入,清除上一次的定时器
return () => clearTimeout(timer);
}, [name]);
return <input value={name} onChange={(e) => setName(e.target.value)} />;
}
3. IndexedDB:大数据的归宿
localStorage 有个巨大的限制:只有 5MB。如果你的数据量大,或者有图片、文件,你得上 IndexedDB。
IndexedDB 是异步的,这很好,不会阻塞 UI。但它的 API 很复杂(Promise 链很长)。这里我们用一个小技巧来封装它,让它看起来像是一个同步的存储,但内部是异步的。
// 封装一个简单的 IndexedDB 包装器
const dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open('MyAppDB', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('settings')) {
db.createObjectStore('settings', { keyPath: 'key' });
}
};
request.onsuccess = (event) => resolve(event.target.result);
request.onerror = (event) => reject(event.target.error);
});
async function saveToDB(key, value) {
const db = await dbPromise;
const transaction = db.transaction(['settings'], 'readwrite');
const store = transaction.objectStore('settings');
store.put({ key, value });
}
async function getFromDB(key) {
const db = await dbPromise;
return new Promise((resolve, reject) => {
const transaction = db.transaction(['settings'], 'readonly');
const store = transaction.objectStore('settings');
const request = store.get(key);
request.onsuccess = () => resolve(request.result?.value);
request.onerror = () => reject(request.error);
});
}
// 在组件中使用
function MySettings() {
const [theme, setTheme] = useState('light');
useEffect(() => {
// 初始化
getFromDB('theme').then(savedTheme => {
if (savedTheme) setTheme(savedTheme);
});
}, []);
useEffect(() => {
// 同步更新
saveToDB('theme', theme);
}, [theme]);
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
切换主题
</button>
);
}
第五部分:WebSocket 与实时同步
这是外部状态同步的“硬核”领域。React 组件是静态的,但网络连接是动态的。
假设你正在做一个股票交易软件。后端通过 WebSocket 推送实时价格。React 组件怎么知道价格变了?
1. 简单的 Socket 连接
你需要在组件挂载时建立连接,卸载时断开连接。同时,当收到消息时,更新 React 状态。
import React, { useState, useEffect } from 'react';
function StockTicker({ symbol }) {
const [price, setPrice] = useState(0);
const [socket, setSocket] = useState(null);
useEffect(() => {
console.log(`连接到 ${symbol} 的股票行情...`);
// 模拟建立连接
const ws = new WebSocket(`wss://api.example.com/stocks/${symbol}`);
ws.onopen = () => {
console.log("连接成功!");
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// 收到外部数据,更新 React 状态
setPrice(data.price);
};
// 保存 socket 到 state,以便在 cleanup 中使用
setSocket(ws);
// 清理函数:断开连接
return () => {
console.log("断开连接...");
ws.close();
};
}, [symbol]);
return (
<div className="ticker">
<h2>{symbol}</h2>
<p className="price">当前价格: ${price.toFixed(2)}</p>
</div>
);
}
2. 处理断线重连
现实比代码复杂。网络会断。如果 Socket 断了,React 组件还在那儿显示着旧价格。你怎么知道它断了?
通常的做法是监听 ws.onclose 事件,然后触发重连逻辑。
useEffect(() => {
let ws;
let interval;
const connect = () => {
ws = new WebSocket(url);
ws.onmessage = (event) => {
setPrice(JSON.parse(event.data).price);
};
ws.onclose = () => {
console.log("连接断开,3秒后重试...");
interval = setInterval(connect, 3000);
};
};
connect();
return () => {
if (ws) ws.close();
if (interval) clearInterval(interval);
};
}, [url]);
这里有一个微妙的同步问题:当 WebSocket 断开时,React 的状态(价格)可能已经过期了。你应该在 UI 上显示一个“数据已过期”的标记。
return (
<div>
<span className={price > 0 ? 'up' : 'down'}>
{price > 0 ? `$${price.toFixed(2)}` : '等待连接...'}
</span>
</div>
);
第六部分:进阶挑战与“屎山”预警
现在,你已经掌握了基本原理。但现实世界的代码往往比这要混乱得多。这里有几个高级场景,能帮你区分“初级工程师”和“资深架构师”。
1. 竞态条件
这是 React 同步外部状态时最可怕的敌人。
场景: 用户点击了一个“刷新数据”按钮。
- 你发起了请求 A。
- 用户又点击了一次“刷新数据”按钮(或者 React 重新渲染了)。
- 你发起了请求 B。
- 请求 B 先回来了,更新了状态。
- 请求 A 后回来了,覆盖了状态 B。
结果:用户明明想看最新的数据,却看到了旧数据。
解决方案:AbortController(AbortSignal)
这就是为什么我在第一部分给你展示 AbortController 的原因。当你发起请求时,给请求加个信号。如果你发起了新请求,就把旧请求 Abort。
useEffect(() => {
let isMounted = true; // React 18 的新特性,但 AbortController 更通用
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(data => {
// 必须检查 isMounted,防止组件已卸载后更新状态
if (isMounted) {
setMyData(data);
}
});
return () => {
controller.abort(); // 取消请求
isMounted = false;
};
}, [url]);
2. 闭包陷阱
在 useEffect 中,你经常需要访问最新的 props 或 state。但是,useEffect 的闭包会“捕获”当时的值。
function Counter() {
const [count, setCount] = useState(0);
const [step, setStep] = useState(1);
useEffect(() => {
const interval = setInterval(() => {
// 这里有个坑:interval 里的 setCount 永远是 0
// 因为 setCount 是在 useEffect 创建闭包时捕获的,而不是每次执行时捕获的
setCount(prevCount => prevCount + step);
}, 1000);
return () => clearInterval(interval);
}, [step]); // 只有 step 变了,effect 才会重新运行
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setStep(s => s + 1)}>增加步长</button>
</div>
);
}
注意看上面的代码。我在 setCount 里用了 prevCount => prevCount + step。这是 React 推荐的写法。因为 setCount 的回调函数会在下一次渲染时执行,它能看到最新的 step。
如果你写成了 setCount(count + step),那你就掉进坑里了。count 在闭包里是死的。
3. 性能优化:不要在 Effect 里做昂贵的计算
如果你的 useEffect 里有一个巨大的循环,或者一个复杂的计算,每次状态变化都会触发它。这会让你的应用像蜗牛一样慢。
useEffect(() => {
// 坏主意:每次渲染都重新计算整个图表
const data = heavyCalculation(myState);
updateChart(data);
}, [myState]);
解决方案:使用 useMemo 或 useCallback。
虽然 useMemo 是用来缓存计算结果的,但在这里,我们可以结合它们来控制何时更新外部状态。
const memoizedData = useMemo(() => heavyCalculation(myState), [myState]);
useEffect(() => {
updateChart(memoizedData);
}, [memoizedData]);
第七部分:总结与实战建议
好了,讲座接近尾声。让我们把这些零散的知识点串成一条项链。
- 生命周期是契约: 挂载是签约,更新是履约,卸载是解约。解约(Cleanup)必须做。
- Effect 是副作用: 它不是 React 的核心,它是 React 的“补丁”。它能让你在渲染后做一些外部的事(发请求、存数据、监听事件)。
- 依赖数组是承诺:
useEffect依赖数组里写了什么,它就在什么时候执行。如果你写了[],它只执行一次。如果你忘了写,它会在每次渲染后执行(除非你用了useCallback)。 - 同步是双向的: 外部 -> React (初始化),React -> 外部 (更新)。
- 内存泄漏是敌人: 订阅了没取消,定时器没清除,WebSocket 没关。这是新手最容易犯的错。
最后,给各位一个“防坑指南”:
- 永远不要在
useEffect里写return来直接修改状态(比如return setCount(c => c + 1)),这虽然能跑,但非常反直觉,而且可能导致不可预测的渲染次数。 - 如果你在 Effect 里使用了
setX或dispatch,确保它不改变 Effect 的依赖项,否则会导致无限循环。 - 使用 ESLint 插件:
eslint-plugin-react-hooks会告诉你哪些 Effect 缺了依赖,或者哪些依赖没写进去。相信这个插件,它比你自己脑子记得更清楚。
React 的世界是动态的,外部存储也是动态的。作为开发者,你的任务就是在这两者之间架起一座坚固的桥梁。不要让你的组件变成那个“退房了还在走廊放音乐”的租客。
好了,现在去写代码吧!记得关灯!