
React 面试题里State 和 Props 几乎是必问题。大部分人都能答出一句一个是组件内部数据一个是外部传入数据但再往深问一步——props 变化时 state 会重置吗setState 之后的代码能立刻拿到新值吗直接修改 props 会发生什么——不少人就开始懵了。这种背过定义但没建立体系的状态写写简单页面还行一上复杂业务就翻车。这篇想把这俩概念放到一起用数据归属、流向、更新机制三条线串起来讲顺带把面试里常见追问一起拆了。适合刚学 React 想搞懂基础概念的也适合正在准备 React 面经的同学。1. 先分清楚props 是外面传进来的state 是自己记下来的很多教程喜欢用函数入参 vs 局部变量来类比 props 和 state这个类比方向是对的但有两个细节需要补上第一props 不只是一份普通入参它还是父组件向子组件暴露的唯一正式数据通道第二state 也不是简单的局部变量它是被 React 纳入生命周期管理的内存单元更新它一定会触发重新渲染。把这两个补丁打上再去看任何组件代码思路都会清晰很多。1.1 类组件和函数组件里的两种形态先说类组件这里最容易踩的坑是忘记在 constructor 里调 super(props)。看这段代码class Counter extends React.Component { constructor(props) { super(props); this.state { count: 0 }; } render() { return ( div h2{this.props.title}/h2 p当前值{this.state.count}/p /div ); } }在类组件里props 挂在 this.props 上state 挂在 this.state 上。constructor 里必须调 super(props)否则 this.props 在构造函数阶段是 undefined虽然之后的 render 阶段可能也能拿到值但如果你在 constructor 里写了this.props.xxx做初始化就会直接报错。这是类组件时代面试官爱挖的第一个小坑。再来看函数组件用 Hooks 之后代码简洁很多function Counter({ title }) { const [count, setCount] useState(0); return ( div h2{title}/h2 p当前值{count}/p /div ); }函数组件里 props 直接作为函数入参接收state 通过 useState 返回的数组解构出来。注意setCount这个更新函数它的命名惯例是set前缀加状态名React 官方没有强制要求但团队协作时保持这个约定能省掉很多沟通成本。1.2 谁拥有数据谁才负责更新判断一个数据到底该放 props 还是放 state有一个特别好用的判断标准看这个数据的所有权也就是谁创建它、谁有权利改它。如果数据由父组件创建父组件修改后要展示给子组件看那它就是 props。如果数据由组件自己创建只在组件内部使用修改也只发生在组件内部那它就是 state。这个原则听起来像废话但实际项目里经常看到有人把父组件传下来的 props 又塞进 useState 里做副本美其名曰防止外部修改。十次里有八次是不必要的还会引入 props 更新后 state 不同步的新问题。后面第六章我会专门讲真正需要响应 props 变化的场景用什么方案。2. 数据流向是单行道父传子子回调不许逆行React 的数据流是单向的这是框架最核心的设计约束之一。所谓单向指的是数据只会从父组件流向子组件props 就是这条河里的水。这个设计带来的最大好处是可预测性当页面上某个数据不对你只需要顺着组件树往上游找一定能在某层 state 里找到它的源头。如果是双向的谁都可能改数据会让排查变成一个侦探游戏你永远不知道这个值是被谁在什么时候改掉的。2.1 子组件想改父组件的数据得靠回调向上传单向数据流会引出一个非常高频的疑问父组件传下来的 props子组件想改怎么办答案是子组件不直接改 props而是调父组件传下来的回调函数由父组件修改自己的 state再通过 props 把新值传给子组件。举个例子function Child({ onIncrement }) { return button onClick{onIncrement}1/button; } function Parent() { const [count, setCount] useState(0); const handleIncrement () { setCount(prev prev 1); }; return ( div p计数{count}/p Child onIncrement{handleIncrement} / /div ); }这段代码看起来简单但注意一个细节Child 并不知道 count 存在哪也不知道 setCount 是什么它只知道自己手上有一个 onIncrement 函数按一下就会通知父组件该加点东西了。这就是状态提升后的典型结构——数据放在父组件手里所有涉及修改的动作都以回调形式往下传。这种数据往下流事件往上走的环形结构是理解整个 React 数据流的关键。你后面接触 Redux、Zustand 这些状态管理库时会发现它们本质上也是在维持这个结构只是把共享 state 搬到了组件树之外。2.2 props 只读背后的工程原因React 明确规定 props 是只读的官方原话是无论是使用函数还是类组件都绝不能修改自己的 props。这个规矩不是框架的物理限制而是工程约定。你非要写props.name xxxJavaScript 层面通常不会报错除非对象被冻结但会引发一系列难排查的问题子组件改了 props父组件完全不知道下次父组件 setState 触发重渲染props 又变成原值子组件里的修改被静默覆盖。React 做浅比较时props 引用变了才认为需要更新你在子组件内部直接改属性不会改变引用可能导致渲染结果和真实数据不一致。调试工具看到的数据是父组件视角的子组件偷偷改掉的值在 DevTools 里根本体现不出来。更现实的影响是性能优化。React.memo 判断子组件要不要重新渲染时比较的是 props 的引用是否变化。如果父组件的 render 函数里写了内联函数或内联对象Child onClick{() setCount(c c 1)} data{{ id: 1 }} /那么每次父组件渲染onClick 和 data 都是新引用memo 会比较出props 变了从而放弃优化。这时候要用 useCallback 包函数、useMemo 包对象把引用稳定下来。很多新手以为 memo 是万能药其实它只是帮你拦截引用没变的更新真正的引用稳定性要靠 useCallback 和 useMemo 来保证。3. setState 的批处理和闭包陷阱为什么拿到的总是旧值先说一句话在 React 里setState 不是改数据而是安排一次更新。这个观念没掰过来的话后面写业务代码会踩不少坑。3.1 React 18 的自动批处理试想一下这个问题在同一个事件处理函数里连续调用两次 setCount组件会渲染几次function App() { const [count, setCount] useState(0); const handleClick () { setCount(count 1); setCount(count 1); console.log(count); // 这里打印的是旧值 0 }; return button onClick{handleClick}点击 {count}/button; }答案是一次。React 会把同一个同步代码块里的多个 setState 合并成一次更新这个机制叫批处理。原因是每次 setState 触发一次渲染的成本太高尤其是组件树很大的时候连续多次 setState 会导致同一帧内多次执行 render、协调和 diff白白浪费性能。在 React 18 之前批处理只在事件处理器内部生效setTimeout、Promise 回调、原生事件处理器里调用 setState 依然会逐次渲染。React 18 引入了 createRoot 之后自动批处理覆盖了所有场景。也就是说上面这段代码不管 setCount 是在什么异步环境里调用最终 count 都只会加 2 一次渲染也只触发一次。所以拿到旧值的直觉反应是代码写错了但更准确的理解是本次渲染还没被提交React 只是在记账。React 把状态更新和渲染提交分离开你调用 setState 时React 只是把这次更新排了个队等当前任务处理完再统一把新 state 一次性渲染出来。3.2 为什么直接改 state 是禁忌很多初学者试过this.state.count 1或者state.count 1发现有时候页面也会更新有时候不会非常玄学。原因藏在 React 的更新判断机制里。React 判断一个组件是否需要重新渲染核心是比较新旧 state 的引用是否变化。如果你直接修改已有的 state 对象对象的引用没变React 就会认为数据没变从而跳过渲染。用 Vue 的人习惯了响应式代理对深层属性的追踪但 React 走的是不可变数据路线——每次更新都应该产生一个新的对象引用。const [user, setUser] useState({ name: 张三, age: 18 }); // 错误直接改了 user 的 name但引用没变 user.name 李四; setUser(user); // 正确创建一个新对象引用变化React 才能感知到 setUser({ ...user, name: 李四 });不可变数据还带来了一个附加福利可以很方便地做时间旅行、撤销重做、历史对比。因为这些快照都存在内存里需要回退时重新赋值就行。3.3 闭包陷阱异步回调里的旧值这是实际项目里出现频率最高的暗坑。看下面这个场景function App() { const [count, setCount] useState(0); const addAfterDelay () { setTimeout(() { setCount(count 1); // 这里的 count 是哪一次的 }, 1000); }; return button onClick{addAfterDelay}延迟加一/button; }如果你在 1 秒内快速点了三次这个按钮它最终会停在 1 而不是 3。原因是setTimeout 回调里的 count 来自组件创建这次点击处理函数时的渲染快照三次点击拿到的是同一次渲染里的同一个 count 值0。这不是 setState 的问题是 JavaScript 闭包对旧数据的捕获。解决方案是使用函数式更新让 React 在真正计算新 state 时传入最新的值setCount(prev prev 1);这样不管闭包捕获的是哪个版本的 countReact 都会把当前最新值传进来三次点击最终得到 3。这个技巧也适用于连续多次更新同一个 state 的场景比反复读外部变量可靠得多。4. 面试追问拆解props 变了我的 state 会跟着变吗面试官特别爱问的问题父组件重新渲染传了新 props 过来子组件内部通过 useState 创建的 state 会不会自动重置成初始值答案是不怎么会。这是 React 初学者理解上最容易出偏差的地方。4.1 一图拆清楚两者的对比清单把 props 和 state 的关键差异放到同一张表里看框架感会强得多对比维度propsstate数据来源父组件传入组件自身初始化所有权属于父组件属于当前组件可修改性只读组件不能改写用 setState / setCount 更新触发渲染父组件渲染时会带来新 propssetState 调用后触发渲染生命周期影响触发子组件更新阶段触发组件重新渲染初始化时机每次渲染都会从父组件拿只在首次挂载时初始化作用范围影响当前组件及所有子孙组件默认只影响当前组件注意更新时机这一行的差异。props 变化意味着父组件觉得这个子组件需要拿到新数据了所以子组件会重新渲染state 变化意味着组件自己觉得要重画了也会触发渲染。两者最终都会走到重新渲染这一步但触发方和更新链路完全不同。4.2 一个反向小测验props 和 state 独立性的验证这题我每次面试人都会问能答对的人不多。看代码function Child({ num }) { const [double] useState(num * 2); return div{double}/div; } function Parent() { const [num, setNum] useState(2); return ( div Child num{num} / button onClick{() setNum(3)}把 num 改成 3/button /div ); }问题点击按钮后Child 里显示的 double 会变成 6 吗答案是不会。它会一直停留在 4。虽然 num 变成了 3Child 确实重新渲染了但useState(num * 2)这个初始化表达式只在组件首次挂载时执行一次后面的渲染里 useState 返回的是上一次保存下来的 state也就是 4。props 变化不会重新触发 useState 的初始化。这个知识点在各种真实业务里经常变成 bug比如用户切换了一个编辑对象表单页面因为没有重置 state上一份数据还残留在输入框里。想解决这个问题有两条常用路线一条是给组件换 key 强制重新挂载一条是在渲染期间比较 props 和 state 后决定要不要调整 state第六章我会给具体代码。4.3 一套可以落笔的回答框架如果面试遇到 State 和 Props 区别我建议按三个层面来组织回答而不是只背定义第一层讲定义props 是组件对外的数据接口由父组件传入组件内部只能读取不能修改state 是组件内部私有的数据存储由组件自己创建和更新。第二层讲数据流React 是单向数据流props 从父流向子子组件需要改变父组件数据时通过回调函数通知父组件由父组件更新自己的 state 后再往下传。第三层讲更新机制props 变化和 state 变化都会触发重新渲染但 props 变化不重置 statesetState 是异步批处理的多个更新会被合并成一次提交直接修改 state 对象在 React 的浅比较机制下可能被静默跳过。能把第三层说清楚面试官基本就能确认你不是背题而是真的写过代码。5. Hooks 时代useState 与 this.setState 的底层对应关系React 16.8 引入 Hooks 后函数组件从没有状态变成了有状态的完整组件。理解这个演进过程能帮你更好地把握 props 和 state 的本质而不是只盯着 API 变化。5.1 useState 为什么返回数组而不是对象这是面经常见的延伸问题。有人猜是为了解构时命名方便这只是其中一半原因真正的关键在于 Hooks 的底层实现。每个使用了 useState 的函数组件在 render 时React 都会按调用顺序在 fiber 节点上挂一个链表结构。第一次渲染时React 依次执行每个 Hook把初始值和更新函数按顺序记录后续渲染时React 再按同样的顺序从链表里取出上一次保存的值。顺序一旦错乱比如用了条件语句跳过某个 Hook整个链表就对不上了React 会直接抛错。之所以返回数组而不是对象是因为数组的写法不绑定字段名const [count, setCount] useState(0); const [name, setName] useState();每次调用 useState 返回的结构是一模一样的解构名称完全由业务代码决定。如果是对象useState({ value, setValue })还得给每个 Hook 想一个 key 名Hook 的顺序匹配机制也没法像数组这么直观。这也是为什么 Hooks 的规则强调不能放在条件语句和循环里一旦 Hook 数量变化React 就不知道这次渲染该取哪个 state 了。5.2 生命周期映射与 SSR 场景的坑类组件时代state 的初始化放在 constructor 里props 在构造参数里。函数组件时代useState 直接承担了 state 初始化的职责props 则是函数入参。两者的数据来源逻辑完全一致变化只是书写方式。这里有一个容易忽略的点useEffect 和 useLayoutEffect 在服务端渲染SSR时不会执行。如果你在 useState 初始值里依赖了 window、document 之类只在浏览器存在的对象const [width, setWidth] useState(window.innerWidth);那么服务端渲染时页面会直接报错因为服务端没有 window。SSR 场景下正确做法是给 useState 一个保守的默认值在 useEffect 里再根据真实环境去更新const [width, setWidth] useState(0); useEffect(() { setWidth(window.innerWidth); }, []);这样服务端先渲染出 width 0 的页面浏览器端水合完成后用真实窗口宽度再渲染一次。这也是为什么很多 React 做 SSR 的项目会遇到服务端渲染出来的内容和客户端不一致的警告——根因往往是 state 初始化时依赖了浏览器环境。React 里 props 和 state 的职责划分在 SSR 场景下会被放大得更明显服务端能拿到的是初始 props 和初始 state而这些不能依赖浏览器上下文。6. 实战选型受控组件、状态提升和渲染时调整 state如果说前面几章是搞懂概念这一章就是写得顺手。实际项目里数据到底放 props 还是 state什么时候提上去什么时候用 key都有成熟的套路可循。6.1 两个兄弟组件共享数据状态提升场景很常见两个输入框一个输入内容另一个实时显示同样的内容。如果把各自的 value 放各自的 state 里两个组件之间根本不知道对方在干嘛。这时就要把共享数据放到它们最近的共同父组件里用状态提升来解决。function ChildInput({ value, onChange }) { return input value{value} onChange{e onChange(e.target.value)} /; } function Parent() { const [text, setText] useState(); return ( div ChildInput value{text} onChange{setText} / ChildInput value{text} onChange{setText} / /div ); }两个输入框都是受控组件数据源都指向同一份 state。无论你改哪个框另一个框都会同步更新。这个例子的核心是把数据所有权往上提子组件只负责调用回调不持有数据。判断要不要状态提升就看这个标准如果两个或多个组件需要反映同一份数据的变化就把这份数据提升到它们的共同祖先。不要为了避免多传一层 props而强行用状态管理库提升 state 在绝大多数场景里才是 React 本身的惯用解法。6.2 受控组件和非受控组件的取舍受控指的是表单元素的 value 由 React state 控制用户输入通过 onChange 更新 state页面显示的值永远来自 React。不受控则是把值留在 DOM 里用 ref 去读取。实际开发里我的建议是默认受控特殊情况不受控。受控组件写起来多一些样板代码但换来的是数据唯一来源清晰、便于实时校验和联动。举个例子一个输入框需要限制最多输入 10 个字受控写法是function LimitInput() { const [text, setText] useState(); const handleChange e { if (e.target.value.length 10) { setText(e.target.value); } }; return input value{text} onChange{handleChange} /; }如果是非受控你还得在 onChange 里手动操作并读取 DOM 值逻辑分散、可复制性差。只有当表单很大、又不需要实时联动时非受控的 ref 方案才能帮你少写一些 setState。这个取舍本质上是代码组织复杂度和实时监控能力之间的换不能一概而论。6.3 想让 props 变化时重置 statekey 和渲染期调整前面说过 props 变化不会重置 state。实际业务里我们经常需要这个人换了就给表单清空之类的效果官方推荐两种方式。第一种是用 key 强制重新挂载组件。key 是 React 用来区分同层组件的标识key 变了 React 会卸载旧组件、创建新组件state 自然就是从零开始。不依赖子组件内部写任何逻辑function Parent() { const [userId, setUserId] useState(u1); return ( div ProfileForm key{userId} userId{userId} / button onClick{() setUserId(u2)}切换用户/button /div ); }userId 切换后ProfileForm 被整体重建所有 state 全部重置。这种方案特别适合不同实体之间不能复用表单残留数据的场景。第二种是 React 文档里提到的在渲染期间调整 state模式。适用场景是不能卸载组件、只希望某些 state 跟随 props 变化的情况function Product({ price }) { const [discounted, setDiscounted] useState(price); if (discounted ! price) { setDiscounted(price); // 渲染期间直接 setState 是被 React 允许的 } return div促销价{discounted}/div; }注意这段代码的运行机制组件渲染时发现 discounted 和最新 price 不一致立刻 setDiscountedReact 会终止本次渲染用新状态立即重新渲染该组件。它不会触发子组件的整棵子树重渲染所以不会死循环。这个模式比在 useEffect 里同步 props 到 state 要快因为 useEffect 会在渲染完成后才执行用户可能会看到一眼旧值渲染期调整则不会有这个问题。我个人在实际带项目时给自己定过一条规矩写任何组件之前先在注释或者文档里写清楚这份数据从哪里来、会不会变、由谁更新。一句话写明白代码设计自然就清晰了。很多纠结 props 和 state 的问题最后都不是语法问题而是你没想清楚数据的归属权在谁手里。