写 React 的时候几乎每个人都撞到过这么一堵墙明明控制台里报的是TypeError: Cannot assign to read only property xxx of object #Object或者开发者工具里弹出一句 Props should not be mutated 的警告代码看起来却没什么毛病。我早几年前第一次遇到这个报错时第一反应是是不是哪里把对象冻住了翻遍代码也没找到Object.freeze折腾了半天才反应过来是自己在组件里动了 props 的脑筋。这篇文章就把这个问题的来龙去脉讲透props 为什么是只读的、哪些写法最容易触发报错、报错背后的设计逻辑是什么以及五个可以立刻落地的替代方案。最后用一条真实踩坑链路演示完整的排查过程并补充 memo、深拷贝、状态管理工具这些进阶场景的边界。适合刚接触 React 的新人也适合那些写了几年但没系统性梳理过这个问题的同学。1. 报错现场还原哪种写法最容易把 props 改掉1.1 报错信息的两种形态先说说你真正会看到什么。很多同学在群里贴报错只会贴最后一行红字但 React 的报错其实有两层。第一层是运行时 TypeError通常长这样TypeError: Cannot assign to read only property list of object #Object还有一种更常见的变体TypeError: Cannot assign to read only property 0 of object [object Array]如果你在代码里执行的是props.a[0] xxx报错往往以 Cannot assign to read only property 0 出现如果你直接props.a xxx那就会报a是只读属性。很多人对这两种形态不敏感以为这是两个不同的 bug其实根源完全一样——你触碰了 React 的只读数据。第二层是开发者工具里的警告。在 React DevTools 的 Console 面板会看到Warning: A component is changing an uncontrolled input to be controlled.这句严格来说不是 props 修改但它跟我们要讨论的问题同源——组件对数据归属的理解出了问题。真正和 props 修改直接相关的还有一种警告React 在开发模式下检测到你在渲染过程里改了不属于自己的数据时会提示Warning: Cannot update a component (Xxx) while rendering a different component (Yyy).这跟 props 只读报错不一定同时出现但底层都是同一个问题你在子组件里动了父组件的数据。所以排查的时候不要只盯着红字黄字警告同样要重视。1.2 我见过的高频踩雷写法我这些年看过不少同事和网友的代码真正触发 props 只读报错的绕来绕去就是下面几类。第一类数组原地修改。sort、reverse、splice、push、unshift这些方法都会改变原数组。很多人排序时顺手就写function ScoreList({ scores }) { scores.sort((a, b) b.score - a.score); return ( ul {scores.map((s) li key{s.id}{s.score}/li)} /ul ); }sort返回的是排序后的同一个数组引用这个方法原地改值React 在开发模式下冻结了 props 指向的数组于是抛错。如果项目关了严格模式或者生产环境里没冻结代码不报错但父组件的状态已经被污染界面可能出现叶公好龙式的灵异刷新。第二类对象属性直接赋值。function Profile({ user }) { user.nickname 新名字; // 试图顺手改一下 return div{user.nickname}/div; }这行的危险在于如果user是父组件 state 里的对象你直接改了它React 的 setState 根本不知道发生了变更界面不会更新等下一次意外刷新你改的值要么保留要么被覆盖全看运气。而且 React DevTools 可能会抓到 props 的变化产生各种迷惑警告。第三类在事件回调里改 props。有些同学知道不能在渲染里改但觉得事件里应该没问题function Counter({ step }) { function handleAdd() { step.value step.value 1; // 只改了 props 下的对象 } return button onClick{handleAdd}1/button; }这里step本身是 props 里的一个对象step.value的赋值在 dev 模式下同样会被 Object.freeze 拦截。就算不拦截父组件也不会因此重渲染。你以为改了数据实际是改了一份没人认领的副本界面纹丝不动排查起来更加痛苦。这三类写法看起来不一样本质一模一样你把 props 当成了可以随意改的本地变量。要根治先得搞清楚 React 为什么要把 props 设成只读。2. 只读不是信仰问题是单向数据流的地基2.1 从单向数据流看 props 的身份定位React 官方文档有一句话很直白props 是组件的外部输入是父组件借给子组件的数据子组件没有所有权。用生活的例子类比你的朋友把自己的笔记借给你复习你可以看、可以划重点、可以抄录但你不能直接在别人的笔记本上涂改更不能把笔记撕了改装成自己的。你要改得先跟朋友说让朋友改完再拿给你看。这就是单向数据流——数据永远从父组件往子组件流子组件想改变数据只能通过向父组件打报告的方式。这个设计的第一层价值是可预测性。如果每个子组件都能修改 props那同一个数据源就会被拆成无数个事实。当界面出问题时你根本不知道是哪个组件改了什么、什么时候改的。数据流变成了一张乱麻排查只能靠猜。第二层价值是渲染一致性。React 的更新机制依赖前后两次渲染之间props 没有被动过这个前提。渲染函数被调用后React 要把虚拟 DOM 和上次的结果做比较。如果子组件在渲染过程中偷偷改了 props父组件下次渲染拿到的数据可能已经是错的diff 结果就是不可信的界面就会出现各种说不清为什么的 bug。你可以把 props 理解成一份只读的会议纪要——大家传阅时可以划线做记号但纪要正文一旦被乱改下次开会所有人拿到的都是错的信息。2.2 冻结仪式与渲染一致性说到这里很多同学会问那 dev 模式下的Cannot assign to read only property到底是怎么来的是 React 源码里写了Object.freeze(props)吗准确地说React 在开发模式下会内部调用 freeze 逻辑对 props 对象做防御性冻结。这意味着你直接给props.xxx赋值或者修改 props 对象上的属性都会立刻抛错。数组也一样——props 里的数组在 dev 模式下通常也是冻结过的sort、push、splice这些原地修改的方法一执行就炸。这个设计的目的很单纯让错误尽快暴露。如果你在 production 模式下写了这种代码通常不报错但你的数据已经被污染了Bug 会在某个不确定的时刻以更隐蔽的方式出现。开发模式的冻结就是提前引爆——宁可让你在开发时炸一次也不让你带着地雷上线。理解了这一点你就明白为什么修复方案不是想办法绕过只读限制而是换一种符合数据流的写法把需求实现出来。React 不是不让你改数据而是不让你在错误的地方改数据。接下来讲的就是正确的地方。3. 五个立即可用的替代方案把改 props翻译成合法操作3.1 状态提升让数据住在真正的主人那里先说最正统的方案。如果子组件需要改变某个数据那这个数据就不应该放在子组件手里而应该提升到它们共同的父组件里。父组件用自己的 useState 保存数据再把数据和修改函数一起传给子组件。function Parent() { const [scores, setScores] useState(initialScores); function updateScore(id, newScore) { setScores((prev) prev.map((s) s.id id ? { ...s, score: newScore } : s)); } return ScoreList scores{scores} onUpdateScore{updateScore} /; } function ScoreList({ scores, onUpdateScore }) { return ( ul {scores.map((s) ( li key{s.id} span{s.score}/span button onClick{() onUpdateScore(s.id, s.score 1)}加分/button /li ))} /ul ); }这里的关键是setScores必须放在父组件里子组件只负责调用onUpdateScore。这样数据在父组件是唯一事实来源子组件永远不直接改数据。状态提升的适用场景很广多个子组件要共享同一个数据、子组件之间要互相联动、或者数据需要被页面其他地方复用。一句话总结谁的数据谁持有谁要改谁上报。3.2 回调下传用事件请求变更状态提升本质上也是回调下传——父组件把允许修改的能力封装成回调函数传给子组件子组件通过调用回调来请求变更。这是 React 数据流里最常用、也最容易被新手忽略的机制。一个典型的受控组件写法是这样的function SearchInput({ value, onChange }) { return input value{value} onChange{(e) onChange(e.target.value)} /; } function Parent() { const [keyword, setKeyword] useState(); return SearchInput value{keyword} onChange{setKeyword} /; }输入框自己不能直接改value它只是把用户输入的事件向上抛真正的状态变更由父组件完成。这个模式的名字叫受控组件它保证了任何时候只有一个地方持有并修改数据界面永远不会出现显示的值和实际值不一致的问题。有些同学会在子组件里写props.value e.target.value然后问为什么报错就是这个受控/非受控的边界没理清。记住一句话props 里的 value 是读的onChange 是写的通道。读和写分开数据就不会打架。3.3 useState 初始化在组件内部建一份可变副本有些数据虽然来自 props但它其实是组件私有的临时状态比如一个弹窗的展开状态、一个用来做本地编辑草稿的值。这种情况下你可以把 props 当作初始值在组件内部建一份可变副本。function NoteEditor({ note }) { const [draft, setDraft] useState(note.content); return ( input value{draft} onChange{(e) setDraft(e.target.value)} / ); }这里draft是组件自己的状态初始值来自 props之后随便改不会报错。但要注意一个经典坑useState的初始值只在组件首次挂载时生效。如果父组件更新了note.content子组件的draft不会自动跟着变。因为useState(initialValue)只是在第一次渲染时读一次 initialValue后续渲染里这个参数直接被忽略了。如果子组件需要props 变了就同步本地状态最常见做法有两个。一种是配合key强制重建组件。父组件在需要重置时给子组件换一个不同的 key让子组件重新挂载本地状态自然重置NoteEditor key{note.id} note{note} /另一种是用useEffect监听 props 变化并同步状态function NoteEditor({ note }) { const [draft, setDraft] useState(note.content); useEffect(() { setDraft(note.content); }, [note.content]); return input value{draft} onChange{(e) setDraft(e.target.value)} /; }这两种方案各有代价。key重建简单粗暴但如果重建开销大比如子组件内部有很重的初始计算就不合适。useEffect setState能保留组件实例但要小心渲染后立刻再 setState带来的额外渲染以及两次渲染之间可能造成的闪烁。我的建议是优先考虑key因为它逻辑最清晰只有确认重建开销不可接受时再用 effect 同步。3.4 用派生值代替存储排序、过滤交给 useMemo回到文章开头那个scores.sort()的例子。这类需求其实根本不需要改 props也不需要额外存一份 state——你要的只是基于 props 计算出一个新的展示结果。React 世界把这种计算叫做派生状态正确做法是用useMemo基于 props 计算。import { useMemo } from react; function ScoreList({ scores }) { const sortedScores useMemo(() { return [...scores].sort((a, b) b.score - a.score); }, [scores]); return ( ul {sortedScores.map((s) li key{s.id}{s.score}/li)} /ul ); }关键区别在[...scores]先展开数组创建一份新引用再对新数组排序。props 里的原数组没有被碰过报错自然消失。useMemo的作用是缓存计算结果只有当scores引用变化时才重新排序避免每次渲染都无谓地重复排序。其实就算不用 useMemo直接const sorted [...scores].sort(...)每次渲染都会重算但至少不报错。useMemo 是性能优化手段不是修复报错的前提。新手容易搞混这一点——先把不改 props这件事做对再考虑少算一点。这个顺序不能反否则你会在一个错误的方向上做性能优化。同类需求还有很多过滤列表filter、统计总和reduce、格式化日期、从嵌套对象里提取展示字段……它们的共同点是输入是 props输出是新的值都应该用派生值处理而不是把 props 改掉。3.5 重置场景给组件换一个 key这条其实刚才在 3.3 里已经提到了但它值得单独拿出来讲因为很多人不知道 key 除了列表渲染还能干这个。当你想让子组件忘掉过去、重新开始时直接给子组件换 key 就可以了。React 会认为这是两个不同的组件实例旧实例卸载新实例挂载所有本地状态初始化。function Parent() { const [version, setVersion] useState(0); return ( div ProfileCard key{version} user{user} / button onClick{() setVersion(version 1)}重置卡片/button /div ); }点击按钮后version变化ProfileCard被重建它内部的 useState 全部回到初始值。这个技巧在重置表单刷新详情页等场景下非常好用比在子组件里手动清空状态要干净得多也能避免为了重置而去修改 props 的冲动。提示key 变化意味着整个组件树被卸载重建如果子组件内部有定时器、网络请求或者大图加载重建会带来额外开销。用之前先确认这些场景不敏感。4. 一个数组排序报错的完整排查链路4.1 报错堆栈暴露的第一条线索前面讲了一堆方案现在带你走一遍真实的排查过程。模拟项目 X 是一个成绩管理页父组件从接口拿到全班成绩数组传给子组件 ScoreBoard 展示子组件支持按分数排序。某天同事在控制台看到了这个报错TypeError: Cannot assign to read only property 0 of object [object Array] at ScoreBoard (ScoreBoard.js:18)第一眼Cannot assign to read only property 0很唬人很多人会去查哪个对象是只读的。但堆栈已经给了明确线索报错发生在 ScoreBoard 组件的第 18 行。如果项目开了 Source Map直接点击堆栈跳转过去几乎都是直接看到问题代码。我处理这类报错的第一步永远是看堆栈里的组件名和行号而不是先怀疑框架。因为 props 在 dev 模式下被冻结是 React 的既定行为报错的代码 99% 是你自己写的原地修改不是框架的锅。先把报错行打开看一眼比在搜索引擎里复制粘贴报错文本要快得多。4.2 根因确认props 被原地修改ScoreBoard 第 18 行的代码是这样的function ScoreBoard({ scores }) { const displayScores scores.sort((a, b) b.score - a.score); // ... }scores.sort()直接在原数组上排序。因为scores来自父组件的 props而 props 里的数组在 dev 模式下是冻结的sort尝试改写第 0 个元素的位置时就抛出了 TypeError。为了确认根因还应该检查两点。第一父组件传过来的数组到底是不是直接引用了 state 里的数组。如果是那就罪证确凿——子组件就算侥幸没报错修改的也是父组件的 state 本体会引发隐性 bug。检查方法很简单在父组件 render 里console.log(todos)排序操作后再打印一次看引用是否同一个、内容是否被改。第二项目是否开了 StrictMode。开了 StrictMode 会让组件渲染两遍报错会被更频繁地触发问题暴露得更明显。这其实是好事别为了消停把 StrictMode 关掉那是掩耳盗铃。StrictMode 只是放大镜不是制造 bug 的元凶。4.3 修复落地与验证回归根因确认后修复方案其实有两层可选。第一层是最小修复——保证不改原数组function ScoreBoard({ scores }) { const displayScores [...scores].sort((a, b) b.score - a.score); // ... }这张方案改动最小报错立即消失。但要注意displayScores每次渲染都会重新排序如果成绩列表很大会有性能损耗。可以顺手加个 useMemo把scores作为依赖缓存排序结果。第二层是架构级修复——思考排序这个需求到底应该属于谁。如果排序只是展示层的临时行为那第一层就够了如果排序结果还要被父组件或兄弟组件使用那就应该把排序逻辑提升到父组件或者把排序请求作为回调传给父组件由父组件决定是否修改数据。同事最终选择了第一层加 useMemo。因为成绩列表只有几十条排序开销可以忽略而且需求方明确排序只影响本页展示不改变原始数据。这个选择是对的——不要为了显得高级把所有逻辑都提升到父组件。数据归属的原则是谁真正拥有并需要这份数据谁才持有它。验证分三步刷新页面确认控制台无报错点击排序按钮确认排序功能正常在父组件里打印原始数组确认没有被排序污染。第三步很多人会漏但它恰恰是这个 bug 最容易留下的隐患——一旦在生产环境关闭了冻结污染就会无声发生。所以修复 props 报错时一定要确认原始数据还是不是我预期的样子。5. 进阶避坑深拷贝、memo 与状态管理的边界5.1 深拷贝不是万能药有些同学在被 props 报错折磨后会选择一劳永逸的方案在子组件里先深拷贝一份 props 再用。比如const localData JSON.parse(JSON.stringify(props.data));这是我最不推荐的做法原因有三。第一它掩盖了设计问题。深拷贝让报错消失了但你会习惯性地认为所有 props 都可以先拷贝再改于是组件数据所有权观念永远建立不起来代码里到处都是无意义的拷贝维护成本直线上升。第二性能代价很高。JSON.stringifyJSON.parse是同步操作遇到大数据量或者频繁渲染会卡顿。对象里如果有 Date、Map、Set、函数、循环引用JSON 序列化还会直接丢数据或抛错。第三深拷贝会破坏引用一致性。子组件拷贝了一份孤立的副本和父组件的数据彻底断开了关系后续任何一方的变更都不会同步。很多时候你根本不想断开关联你只是想安全地基于 props 算个新值——那浅拷贝就够了。正确的思路是能用浅拷贝[...array]、{ ...obj }解决的就用浅拷贝解决浅拷贝搞不定的大概率是数据设计本身出了问题应该拆组件或者改状态结构而不是上深拷贝。5.2 memo 失效与 props 引用的关系React.memo 通过浅比较 props 来决定组件是否重新渲染。浅比较只看引用地址是否变化不看内容。这就是为什么每次渲染都新建一个对象传下去会让 memo 形同虚设function Parent() { const [count, setCount] useState(0); const config { theme: light }; // 每次渲染都是新对象 return Child config{config} /; }就算 count 变了跟 Child 一点关系都没有Child 的 memo 也拦不住这次渲染因为config的引用每次都是新的。更隐蔽的是如果父组件把从 props 里算出来的派生数据传给 memo 子组件而派生数据没有用 useMemo 缓存memo 同样会失效。这和本篇文章的主题有什么关系关系很大——很多同学为了修复 props 报错写出了[...props.list]但没包 useMemo导致每渲染一次就创建一个新数组下游的 memo 全部失效。报错是没了性能却悄悄降了。所以我的建议是凡是基于 props 生成派生值并传给子组件的地方一定要配 useMemo凡是父组件每次渲染都会生成的对象/函数要么用 useCallback / useMemo 固化要么接受它的渲染成本。不要为了修复一个报错制造另一个性能坑。这两件事常常是同一件事的两面。5.3 状态管理库的 props 同样是只读的用 Redux、Zustand、MobX 这些状态管理库时很多人会误以为从 store 里拿到的数据可以随便改。这个认知是错的。从 useSelector 或者 useStore 拿到的值本质上和 props 一样具有只读约束。你同样只能读不能原地改。const todos useSelector((state) state.todos); // 错误示范 todos.push({ id: 9, title: 新的待办 });正确的做法是 dispatch 一个 action让 reducer 返回新的状态。在 Zustand 里则是调用 store 的 update 方法创建新对象。不管工具怎么变核心原则不变状态只有一个权威来源修改必须走它提供的通道。从另一个角度看如果项目用了状态管理库props 只读报错反而更少出现因为大家已经习惯了数据在 store 里、修改走 action的模式。真正需要警惕的是混合写法——有的数据在 store有的数据在本地 state组件在两者之间倒腾时最容易把 store 的值当本地值改或者相反。定一个团队约定会很有帮助所有跨页面的共享数据走 store页面内部的私有数据走 useState谁都不能跨边界改别人的数据。5.4 严格模式双调用会放大问题React 18 的 StrictMode 会在开发环境下让组件函数、初始化函数、reducer 执行两次目的是暴露不纯的函数。如果你的组件在渲染里写了改 props这种副作用StrictMode 会毫不犹豫地让它炸出两次报错。有些新手被报错吓到第一反应是删掉StrictMode。这是饮鸩止渴——StrictMode 本身不做任何数据修改它只是放大镜把你代码里潜伏的不规范行为照出来。删掉它报错暂时消失但那个隐性的数据污染还在迟早会在某个深夜以更诡异的形式回来找你。正确的态度是报错出现时感谢 StrictMode 帮你定位问题把根因修掉然后再看到控制台干净的样子。开发模式下的每一次报错都是一次免费的代码审查。你要做的不是让审查闭嘴而是让代码经得起审查。写 React 这几年我最大的体会是props 只读报错本质上不是语法问题而是一次次提醒你把数据的归属搞清楚。我自己的习惯很简单——写任何组件前先问一句这份数据是谁的我要不要改它如果要改的通道在哪里三个问题问完大部分 props 相关 bug 在动手之前就已经被消灭了。最后再分享一个小技巧把sort、push、splice这类原地修改的方法名记牢写代码时只要看到它们出现在 props 相关变量上立刻警觉。这个条件反射练成之后你再也不会被这个报错偷袭。