聊天讨论 我踩了三次同一个坑才明白:React 里"改了数据却不重新渲染"的真正原因

193577746(kyriewen) · 2026年08月09日 · 9 次阅读

"我明明改了 state,页面怎么没反应?"这句话我至少在三个不同的项目里对自己说过。三次踩坑的代码看起来完全不一样,但 debug 到最后,罪魁祸首是同一个东西:我以为自己改了数据,但只是改了数据的内容,没有改数据的引用

第一次:数组 push 之后不重新渲染

// ❌ 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确实多了一条数据,但页面死活不刷新。这是我第一次遇到这个问题时最懵的地方——数据明明变了,为什么不渲染?

第二次:嵌套对象属性修改后不生效

// ❌ 直接改嵌套属性,外层引用没变
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 依赖数组失效

// ❌ 依赖的对象被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(以及useMemouseCallbackReact.memo)判断一个值是否变化,用的是Object.is(本质上是===浅比较),不会递归比较对象内部的每一个字段

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 根本不关心内容,只关心"这还是不是同一个引用"。

统一的修复方式:永远创建新引用

// ✅ 数组:用展开运算符创建新数组
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,安全 直接用

sortreverse是最容易被忽略的两个——很多人以为它们"只是排序展示,应该没事",但它们会直接修改原数组,只是刚好排序后位置变了,如果你恰好没有依赖引用比较,表面上看不出问题,但一旦配合useMemoReact.memo就会诡异地失效。

为什么这个坑会反复踩

因为 mutate 的代码"看起来是对的"——todos.push(item)完全符合直觉,user.settings.theme = 'dark'也是最朴素的赋值写法。JS 语言本身完全允许你这样写,不会有任何警告或报错。

真正的坑在于:React 的渲染机制建立在"引用比较"这个假设上,但 JS 语言本身的默写习惯是"直接改内容"。这两者的错位就是 bug 的根源,而且因为不报错,只在 UI 层表现为"数据变了但没重新渲染",很容易让人怀疑是不是 state 更新的时机问题、要不要加forceUpdate、要不要检查依赖数组——反而不会想到"我是不是 mutate 了"。

排查这类问题的最快方法

如果你遇到"改了数据 UI 不更新"的情况,先做这个测试:

const before = state;
// 执行你的更新逻辑
const after = state; // 或者拿到你以为的"新state"
console.log(before === after); // 如果是true,说明你在mutate

如果这行打印true,不用继续排查别的可能性了,直接去检查更新逻辑是不是在 mutate 原数据。

你有没有也在"改了但不渲染"这个坑里反复摔过?


觉得有用?点个「赞」👍 让更多人看到

关注我,获取更多技术干货。

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请 注册新账号