"我明明改了state,页面怎么没反应?"这句话我至少在三个不同的项目里对自己说过。三次踩坑的代码看起来完全不一样,但debug到最后,罪魁祸首是同一个东西:我以为自己改了数据,但只是改了数据的内容,没有改数据的引用。
第一次:数组push之后不重新渲染
tsx
// ❌ push改变了数组内容,但引用没变
function TodoList() {
const [todos, setTodos] = useState([]);
const addTodo = (text) => {
todos.push({ id: Date.now(), text }); // 直接mutate原数组
setTodos(todos); // 传进去的还是同一个引用!
};
return (
<div>
{todos.map((t) => <div key={t.id}>{t.text}</div>)}
<button onClick={() => addTodo('新任务')}>添加</button>
</div>
);
}
点击按钮,控制台打印todos确实多了一条数据,但页面死活不刷新。这是我第一次遇到这个问题时最懵的地方------数据明明变了,为什么不渲染?
第二次:嵌套对象属性修改后不生效
tsx
// ❌ 直接改嵌套属性,外层引用没变
function UserProfile() {
const [user, setUser] = useState({ name: '张三', settings: { theme: 'light' } });
const toggleTheme = () => {
user.settings.theme = user.settings.theme === 'light' ? 'dark' : 'light';
setUser(user); // 还是同一个user对象
};
return (
<div>
<p>当前主题:{user.settings.theme}</p>
<button onClick={toggleTheme}>切换主题</button>
</div>
);
}
这次更隐蔽,因为改的是嵌套两层的属性------很容易让人觉得"我确实改了一个具体的值,应该生效"。但结果和第一次一样:点了按钮,UI毫无反应。
第三次:useMemo依赖数组失效
tsx
// ❌ 依赖的对象被mutate,useMemo认为"没变"
function FilterPanel({ initialFilters }) {
const [filters, setFilters] = useState(initialFilters);
const activeCount = useMemo(() => {
return Object.values(filters).filter(Boolean).length;
}, [filters]);
const toggleFilter = (key) => {
filters[key] = !filters[key]; // 直接mutate
setFilters(filters); // 引用没变
};
return (
<div>
<p>已选:{activeCount}项</p>
{/* activeCount永远显示初始值,因为useMemo的依赖比较发现filters引用没变,直接返回缓存值 */}
</div>
);
}
这次最难排查,因为bug表现是"某个计算值一直不更新",而不是"整个组件不渲染"------第一反应根本不会往"引用没变"这个方向想。
三次坑的共同原因:React用浅比较判断"变没变"
React(以及useMemo、useCallback、React.memo)判断一个值是否变化,用的是Object.is(本质上是===浅比较),不会递归比较对象内部的每一个字段。
js
const a = { count: 1 };
const b = a;
b.count = 2;
console.log(a === b); // true!引用完全相同
console.log(a.count); // 2,值确实变了
当你直接修改对象/数组的属性(mutate),JS引擎里这仍然是"同一个对象",只是它内部的字段变了。但Object.is(oldState, newState)比较的是引用地址,不是内容------所以React(或useMemo)看到的结论永远是"没变",直接跳过重新渲染或跳过重新计算。
这也解释了为什么直接console.log能看到最新值------你打印的是同一个对象,它的内容确实更新了。但React根本不关心内容,只关心"这还是不是同一个引用"。
统一的修复方式:永远创建新引用
tsx
// ✅ 数组:用展开运算符创建新数组
const addTodo = (text) => {
setTodos([...todos, { id: Date.now(), text }]);
};
// ✅ 嵌套对象:逐层展开
const toggleTheme = () => {
setUser({
...user,
settings: {
...user.settings,
theme: user.settings.theme === 'light' ? 'dark' : 'light',
},
});
};
// ✅ useMemo依赖的对象同理,更新时创建新对象
const toggleFilter = (key) => {
setFilters({ ...filters, [key]: !filters[key] });
};
原则:更新state永远不要直接修改原对象/数组的属性,永远返回一个新的引用。 [...arr]、{...obj}、arr.map()、arr.filter()都是安全的,因为它们都会生成新的容器。
数组常用操作的安全/危险对照表
| 操作 | 是否mutate原数组 | 安全用法 |
|---|---|---|
push / pop / shift / unshift |
✅ 会mutate | 用[...arr, item] / arr.slice(1) |
splice |
✅ 会mutate | 用arr.filter()或展开+slice组合 |
sort / reverse |
✅ 会mutate | 先[...arr]拷贝一份再排序 |
map / filter / slice |
❌ 不mutate,安全 | 直接用 |
concat |
❌ 不mutate,安全 | 直接用 |
sort和reverse是最容易被忽略的两个------很多人以为它们"只是排序展示,应该没事",但它们会直接修改原数组,只是刚好排序后位置变了,如果你恰好没有依赖引用比较,表面上看不出问题,但一旦配合useMemo或React.memo就会诡异地失效。
为什么这个坑会反复踩
因为mutate的代码"看起来是对的"------todos.push(item)完全符合直觉,user.settings.theme = 'dark'也是最朴素的赋值写法。JS语言本身完全允许你这样写,不会有任何警告或报错。
真正的坑在于:React的渲染机制建立在"引用比较"这个假设上,但JS语言本身的默写习惯是"直接改内容" 。这两者的错位就是bug的根源,而且因为不报错,只在UI层表现为"数据变了但没重新渲染",很容易让人怀疑是不是state更新的时机问题、要不要加forceUpdate、要不要检查依赖数组------反而不会想到"我是不是mutate了"。
排查这类问题的最快方法
如果你遇到"改了数据UI不更新"的情况,先做这个测试:
js
const before = state;
// 执行你的更新逻辑
const after = state; // 或者拿到你以为的"新state"
console.log(before === after); // 如果是true,说明你在mutate
如果这行打印true,不用继续排查别的可能性了,直接去检查更新逻辑是不是在mutate原数据。
你有没有也在"改了但不渲染"这个坑里反复摔过?评论区聊聊你踩过的版本。