React 受控组件底层:源码如何处理“闪烁”现象?
各位同学,大家好!
今天我们不讲 Hello World,也不讲 Redux 的中间件怎么写。我们要聊一个在 React 开发中看似不起眼,实则暗藏玄机,甚至经常让用户体验“抽搐”的难题——受控组件的“闪烁”现象。
大家肯定都写过这样的代码:
function ControlledInput() {
const [value, setValue] = useState("");
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
}
这看起来很完美,对吧?React 的状态(value)控制着原生 DOM 的值。但是,当你疯狂敲击键盘时,有没有发现输入框偶尔会“抖动”一下?光标会跳到最前面,或者输入的内容会消失一瞬?
这就是传说中的“闪烁”。
很多人以为这是浏览器的 Bug,或者只是巧合。其实不然。这背后是 JavaScript 状态与原生 Input 值之间的一场“拔河比赛”。作为资深开发者,我们必须知道,React 在幕后是如何处理这场“拔河”的,又是如何试图用“魔法”把光标强行塞回原位的。
今天,我们就穿上潜水服,潜入 React 的深海,看看它是如何通过批处理、调度器和 Fiber 架构来驯服这只暴躁的“受控野兽”的。
第一部分:问题的本质——同步的诅咒与异步的救赎
要理解闪烁,首先得理解 React 的渲染机制。这就像一个厨师(React)和一个顾客(浏览器)的关系。
1. 传统的同步模式(React 17 及以前)
在旧版本的 React 中,setState 是同步的。
当你输入一个字符,触发 onChange,React 立即执行 setState,紧接着触发渲染,紧接着更新 DOM。
// 伪代码演示 React 旧版行为
function handleChange(e) {
const newValue = e.target.value;
console.log("用户输入了:", newValue);
// 瞬间发生:状态更新 -> 渲染 -> DOM 更新
setValue(newValue);
// 此时 DOM 已经变了,但浏览器可能还没来得及处理焦点
}
场景重现:
- 用户输入字符
A。 onChange触发,value变为A。- React 渲染,将 DOM 的
value属性设为A。 - 关键点来了: 浏览器在处理
input事件时,它的selectionStart和selectionEnd(光标位置)是基于旧值(空)计算的。当 React 强行把 DOM 值改成A时,浏览器发现“嘿,这值变了!”,为了保持数据一致性,它决定重置输入框,把焦点拿回来,光标归零。 - 结果:用户看着自己的光标在跳动,输入框闪烁了一下。
这就像你正在用笔写字,突然有个黑手(React)把你笔里的墨水换了个颜色,然后强迫你重新写头两个字。当然,浏览器不会真的重置内容,但光标的跳动和焦点的丢失就是这种“强制写回”带来的视觉残留。
2. React 18 的异步模式(自动批处理)
React 18 引入了并发渲染和自动批处理,这是解决闪烁的关键。
// React 18+ 默认行为
function handleChange(e) {
setValue(e.target.value); // 1. 状态更新进入队列
setValue(e.target.value.toUpperCase()); // 2. 状态更新进入队列
// ... 其他代码
// 3. 只有在函数执行完毕后,React 才会统一渲染,统一更新 DOM
}
React 把多个状态更新“打包”了。在同一个事件处理函数内,React 只会重新渲染一次。这意味着,浏览器只会看到最终的那个值,而不会看到中间值的跳动。这大大减少了闪烁的概率。
但是,自动批处理不是万能的。有些场景下,React 必须同步更新 DOM(比如 flushSync),或者 useEffect 的执行打破了批处理。这时候,闪烁的幽灵就会卷土重来。
第二部分:批处理——React 的“合并同类项”神功
为了防止闪烁,React 使用了一个核心机制:批处理更新。
想象一下,你的 React 组件里有一个输入框,旁边有一个按钮。
function App() {
const [inputValue, setInputValue] = useState("");
const [count, setCount] = useState(0);
return (
<>
<input value={inputValue} onChange={(e) => setInputValue(e.target.value)} />
<button onClick={() => {
setInputValue("Hello");
setCount(count + 1);
}}>点击我</button>
</>
);
}
当你点击按钮时:
setInputValue("Hello"):想改输入框。setCount(1):想改数字。
如果 React 不批处理,它会执行两次渲染,两次 DOM 更新。第一次把输入框改成 “Hello”,导致光标跳动;第二次把数字改成 1。
React 的批处理逻辑(源码视角):
在 ReactUpdates 模块中,有一个核心函数 batchedUpdates。它的逻辑大概是这样的:
// 模拟 React 源码中的批处理逻辑
function batchedUpdates(fn) {
// 1. 挂起调度:告诉调度器,我现在先不渲染,先把所有要更新的状态收集起来。
const previousIsBatching = ReactDefaultBatchingStrategy.isBatchingUpdates;
ReactDefaultBatchingStrategy.isBatchingUpdates = true;
try {
// 2. 执行传入的函数(即你的 onClick 或 onChange)
fn();
} finally {
// 3. 恢复调度
ReactDefaultBatchingStrategy.isBatchingUpdates = previousIsBatching;
// 4. 关键点:函数执行完毕后,才去执行真正的渲染和 DOM 更新
// 只有这一次 DOM 更新,输入框和数字才会同时改变
if (ReactDefaultBatchingStrategy.isBatchingUpdates) {
// 将更新推入队列,等待调度器执行
ReactUpdates.flushBatchedUpdates();
}
}
}
为什么这能防闪烁?
因为 batchedUpdates 拦截了 React 的渲染指令。在 onClick 执行期间,React 的状态变了,但 DOM 没变。只有当函数跑完,React 才去执行一次渲染。此时,输入框的值从空变成了 “Hello”,浏览器只感知到了一次变化,光标自然就稳住了。
第三部分:Fiber 与 调度器——给浏览器喘息的机会
批处理解决了“短时间内多次渲染”的问题,但 React 18 还引入了另一个神器:时间切片 和 调度器。
React 不再是那种“一上来就把活全干完”的傻大个,它变成了一个会偷懒、会看气氛的职场老油条。
1. requestIdleCallback 的妙用
React 使用了浏览器的 requestIdleCallback API。这意味着,在浏览器忙着处理布局、绘制、响应其他用户操作的时候,React 可以悄悄地在后台“偷偷更新 DOM”。
// 源码逻辑示意:Scheduler.js
function scheduleUpdateOnFiber(fiber) {
// 检查当前是否有高优先级任务(比如点击、输入)
if (hasPriorityLevel('High')) {
// 如果是高优先级(如输入事件),立即执行
ensureRootIsScheduled(root);
} else {
// 如果是低优先级(如后台数据请求),交给 requestIdleCallback
// 这样不会阻塞主线程,输入框就不会卡顿,闪烁也就少了
requestIdleCallback(() => {
ensureRootIsScheduled(root);
});
}
}
为什么这对防闪烁很重要?
当你在输入时,React 的更新优先级很高。React 会尽量在输入事件的回调中完成更新。但如果更新量很大,React 会切出去一点时间片,等浏览器忙完了再回来写 DOM。
这种“分时渲染”机制,避免了因为 React 渲染时间过长,导致输入框失去焦点(因为主线程被占满了)。输入框不失去焦点,自然就不容易闪烁。
第四部分:useEffect 中的“幽灵闪烁”
虽然 React 18 改善了很多,但还有一个经典的“坑”依然存在,那就是 useEffect 中的闪烁。
假设我们想在输入框聚焦时改变背景色:
function ProblematicInput() {
const [value, setValue] = useState("");
const inputRef = useRef(null);
useEffect(() => {
// 当值更新时,强制聚焦
inputRef.current.focus();
}, [value]);
return (
<input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
}
问题在哪?
- 用户输入
A。 onChange触发,setValue("A")。- 渲染阶段: React 更新
value="A"。此时 DOM 已经变了。 - 提交阶段: React 执行
useEffect。inputRef.current.focus()。 - 结果: 浏览器发现
value变了,React 又强制把焦点设回去。虽然光标位置可能对得上,但 DOM 的重绘和焦点的重置过程在视觉上非常明显,这就是“闪烁”。
React 的“强制写回”策略:
React 源码中并没有直接在 useEffect 里写 input.value = value,而是利用了浏览器的 focus() 方法。
当你在 useEffect 中调用 focus() 时,React 会利用 requestAnimationFrame(下一帧动画)来执行。这给了浏览器一个机会,先完成当前的渲染,然后再去处理焦点。
但这依然有副作用。为了解决这个闪烁,React 官方文档(以及社区)给出的“大招”是:
不要在 useEffect 里依赖 value,而是依赖一个更稳定的变量,或者在 onChange 里手动处理。
或者,使用 flushSync 来控制更新的时机。
第五部分:源码深潜——updateFiberProps 与 DOM 同步
好了,理论讲得差不多了,现在让我们看看 React 是如何具体操作 DOM 的。核心函数是 updateFiberProps。
这个函数通常在 packages/react-dom/src/client/ReactDOMComponent.js 中。
function updateFiberProps(fiber, props) {
// 如果 props 和之前的一样,直接跳过,不操作 DOM
if (fiber.memoizedProps === props) {
return;
}
// 获取 DOM 节点
const domNode = fiber.stateNode;
const domProps = fiber.memoizedProps || {};
// 遍历所有 props,检查哪些变了
for (let propKey in props) {
if (props[propKey] !== domProps[propKey]) {
const propValue = props[propKey];
// 特殊处理 input 的 value 属性
if (propKey === 'value' && domNode.type === 'input') {
// 1. 检查浏览器当前的值
const currentValue = domNode.value;
// 2. React 想要设置的值
const newValue = propValue;
// 3. 关键逻辑:如果值没变,React 不会强制写回 DOM
// 这就是为什么有时候你觉得没闪烁,有时候觉得闪烁
if (currentValue !== newValue) {
// 只有当值确实改变时,才执行 DOM 属性赋值
// 浏览器处理 DOM 属性变化时,如果涉及焦点,可能会有副作用
domNode.value = newValue;
}
} else {
// 其他属性(如 onChange, style)直接赋值
domNode[propKey] = propValue;
}
}
}
// 更新 memoizedProps,记录这次变更
fiber.memoizedProps = props;
}
这段代码揭示了什么?
- React 是“懒”的: 它不会每次渲染都把 DOM 重写一遍。它只更新“变化”的部分。
- 强制写回的触发条件: 只有当
props[propKey] !== domProps[propKey]时,React 才会执行domNode[propKey] = propValue。
但是,为什么还是会闪烁?
因为 domNode.value = newValue 这个赋值操作是同步的。
当 React 在渲染阶段执行到 updateFiberProps 时:
- 它修改了
input.value。 - 浏览器立刻检测到属性变化。
- 如果此时输入框有焦点,浏览器可能会觉得“这值变了,我需要重置一下光标状态以保持一致性”。
- React 紧接着在
useEffect里调用focus(),试图把光标抢回来。
React 18 的优化:
React 18 引入了 flushSync。这是一个“破坏者”API。它强制 React 跳过批处理,立即执行 DOM 更新。
import { flushSync } from 'react-dom';
function App() {
const [value, setValue] = useState("");
const handleClick = () => {
// 强制同步更新,不等待批处理
flushSync(() => {
setValue("Sync Update");
});
// 此时 DOM 已经更新完毕,聚焦操作是安全的
document.getElementById('myInput').focus();
};
return (
<input id="myInput" value={value} onChange={(e) => setValue(e.target.value)} />
);
}
flushSync 解决了“在状态更新后立即获取 DOM 值”的问题,但它本身并不能直接解决输入框的闪烁。实际上,使用 flushSync 往往会增加闪烁的风险,因为它破坏了批处理。
第六部分:终极方案——useRef 与 聚焦控制
既然 React 的渲染和 DOM 更新是异步的,而浏览器的焦点管理是同步的,我们能不能绕过 React 的 value 属性,直接控制 DOM 呢?
答案是:可以,但这违背了受控组件的原则。
如果你不使用受控组件(即不绑定 value={state}),那么 React 就不会在渲染时强制写回 DOM。浏览器就会完全接管输入框。
function UncontrolledInput() {
const inputRef = useRef(null);
const handleFocus = () => {
inputRef.current.focus();
};
return (
<div>
<input ref={inputRef} type="text" />
<button onClick={handleFocus}>聚焦</button>
</div>
);
}
但是,如果你必须用受控组件(比如需要验证逻辑),怎么办?
这里有一个高级技巧,利用 useRef 来存储上一次的值,或者利用 requestAnimationFrame 来延迟聚焦。
React 团队推荐的最佳实践:
在 onChange 中更新状态,但在 useEffect 中处理聚焦时,要注意依赖项。
function OptimizedInput() {
const [value, setValue] = useState("");
const inputRef = useRef(null);
useEffect(() => {
// 只有当 inputRef 存在时才聚焦
if (inputRef.current) {
inputRef.current.focus();
}
}, []); // 依赖为空,只在挂载时聚焦一次
// 如果需要在每次输入时保持聚焦,可以使用 requestAnimationFrame
const handleChange = (e) => {
setValue(e.target.value);
// 不要在这里直接调用 focus,因为 React 渲染还没完
// 可以使用 requestAnimationFrame 告诉浏览器“渲染完记得帮我聚焦”
requestAnimationFrame(() => {
inputRef.current.focus();
});
};
return (
<input
ref={inputRef}
value={value}
onChange={handleChange}
/>
);
}
原理分析:
requestAnimationFrame 会在浏览器的下一帧重绘之前执行。这给了 React 的时间去完成 value 的更新,并且让浏览器完成一次完整的 DOM 渲染周期。然后,我们再手动触发 focus()。
虽然这看起来像是“手动写回”,但实际上它是利用了浏览器的渲染队列机制,避免了同步更新带来的冲突。
第七部分:React 源码中的“魔法”——合成事件与 DOM 回调
最后,我们得聊聊 React 的合成事件系统。
React 并没有直接把 input 的事件绑定到 DOM 上,而是绑定到了一个顶层容器(如 div#root)上,然后自己拦截事件。
// 简化的 React 事件冒泡逻辑
function handleInput(event) {
// 1. 伪造一个合成事件对象
const syntheticEvent = createSyntheticEvent(event);
// 2. 触发用户定义的 onChange
syntheticEvent.currentTarget.dispatchEvent(
new Event("input", { bubbles: true, cancelable: true })
);
// 3. 处理 React 内部逻辑(如自动聚焦、批处理)
// ...
}
这个设计非常巧妙,但也带来了延迟。
当你敲击键盘时:
- 浏览器捕获到
keydown。 - 浏览器捕获到
input。 - React 拦截
input,触发onChange。 onChange调用setState。- React 进入调度阶段,计划渲染。
在这个延迟过程中,浏览器已经处理了键盘输入。当 React 最终执行 updateFiberProps 更新 DOM 时,浏览器已经把焦点状态保存在了它的内部变量里。
React 的补丁:
React 源码中有一段逻辑,专门用来在渲染完成后恢复焦点。这通常发生在 useEffect 阶段,或者在 React 内部的“调度回调”中。
// 伪代码:React 内部的一个调度回调
function commitRoot() {
// 1. 完成所有 DOM 更新(包括 input.value)
commitAllHostEffects();
// 2. 执行 useEffect
flushPassiveEffects();
// 3. 恢复焦点(React 的自我修养)
if (focusedInstance) {
// React 会记录上一次的焦点在哪里,或者记录当前焦点在哪里
// 然后尝试重新聚焦
focusedInstance.focus();
}
}
总结:如何驾驭这只野兽?
通过今天的源码级探秘,我们终于看清了 React 处理受控组件闪烁的底层逻辑:
- 批处理是第一道防线: React 通过
batchedUpdates把多次状态更新合并,减少 DOM 写入次数,这是防止闪烁的根本。 - 异步调度是第二道防线: React 18 的并发特性让渲染不阻塞主线程,让浏览器有喘息空间,避免焦点丢失。
- DOM Diffing 是精准打击:
updateFiberProps只更新变化的属性,避免无效的 DOM 操作。 - useEffect 是最后的挣扎: 虽然它会导致闪烁,但它是 React 试图在渲染后“抢回”控制权(聚焦)的唯一手段。
- requestAnimationFrame 是作弊码: 在某些极端场景下,利用浏览器渲染队列来手动处理聚焦,可以绕过同步更新的冲突。
给开发者的建议:
- 优先使用 React 18: 开启自动批处理,享受并发渲染带来的流畅体验。
- 慎用
flushSync: 除非你真的需要同步读取 DOM 状态,否则不要用它,它会破坏批处理,增加闪烁风险。 - 理解受控与未控的权衡: 如果输入框极其敏感(如游戏手柄输入、高速数据录入),考虑使用“半受控组件”(即
value={value}但不监听onChange,或者使用ref直接操作 DOM),但这需要你自己处理状态同步,难度会增加。 - 聚焦管理: 在
useEffect中处理聚焦时,要意识到它带来的副作用,必要时使用requestAnimationFrame进行优化。
React 的强大不仅仅在于它帮你写代码,更在于它在幕后默默处理这些看似微小却极其复杂的交互细节。下次当你看到输入框闪烁时,别再以为是浏览器的问题了,那是 React 和浏览器在后台为了维护世界和平(数据一致性)而进行的激烈搏斗!
好,今天的讲座就到这里,大家回去可以试着改改源码,感受一下这种“掌控全局”的感觉!