React18 flushSync 完整深度解析(场景全覆盖+避坑+面试真题)
前言
React18 引入了并发渲染与全局自动批处理,默认情况下所有setState都会异步合并渲染优化性能。但部分业务需要修改状态后立刻拿到最新DOM,官方提供了flushSync强制同步刷新API。本文覆盖所有执行场景、陷阱、优先级、异常、对比、面试题
导入方式:
import { flushSync } from 'react-dom'
一、基础介绍
1. 核心作用
强制同步执行回调内状态更新,跳过并发调度、批处理机制,同步走完render → commit → DOM真实更新全流程。
2. 基础示例
js
const [count, setCount] = useState(0)
const handle = () => {
flushSync(() => {
setCount(1)
})
// 此处DOM已经刷新完成,可读取offset、焦点等
}
3. 适用场景
- 修改状态后立即读取DOM宽高、位置、滚动距离
- 渲染完成马上控制输入框焦点
- 对接图表、动画等第三方库,必须依赖最新DOM实例
- 多步骤逻辑强依赖上一步渲染结果
4. 禁止使用场景
- 组件渲染函数体内直接调用
useEffect、useLayoutEffect内部调用(会控制台警告)- 无必要场景滥用,会破坏并发渲染、降低页面性能
二、全场景执行逻辑拆解
场景1:flushSync前置存在普通setState
规则
执行flushSync时,会优先清空之前所有堆积未渲染的更新队列,前置更新单独渲染一次,内部更新合并再渲染一次。
js
const [n, setN] = useState(0)
const run = () => {
setN(1) // 缓存入队,暂不渲染
flushSync(() => {
setN(prev => prev + 2)
})
console.log(n) // 输出3
}
执行流程:
setN(1)进入待更新队列- 进入flushSync,先冲刷前置队列,n=1,第一次同步渲染
- 内部
setN(prev+2)存入同步批次 - 回调执行完毕批量渲染,n=3,第二次渲染
场景2:flushSync内部多个setState
规则
同一个flushSync回调内多个状态更新,自动合并批量,只同步渲染1次。区别普通更新:普通是异步批处理,这里是同步批处理。
js
flushSync(() => {
setCount(1)
setName('demo')
setVisible(true)
})
// 三个状态一次性同步更新DOM,仅渲染一次
场景3:flushSync嵌套调用
底层铁律
同一时间只能存在一个同步刷新上下文;同步栈内嵌套的flushSync直接失效,仅执行回调,不会新增渲染次数。
js
flushSync(() => {
setCount(p => p + 1)
// 内层全部失效,无独立刷新
flushSync(() => {
setCount(p => p + 2)
flushSync(() => setCount(p => p + 3))
})
})
// 总计只渲染1次,最终count=6
嵌套问题解决方案
想要分步多次同步刷新,禁止嵌套,拆分为平级独立flushSync:
js
flushSync(() => setA(1)) // 第一次渲染
flushSync(() => setB(2)) // 第二次渲染
flushSync(() => setC(3)) // 第三次渲染
场景4:flushSync内部包含异步代码(async/await/setTimeout/Promise)
核心结论
只有同步执行栈内的setState受flushSync管控;一旦进入异步回调(定时器、then、await后),状态更新脱离同步上下文,回归默认异步批处理。
- setTimeout宏任务示例
js
flushSync(() => {
setNum(1)
setTimeout(() => {
setNum(2) // 异步队列,延迟渲染,不跟随同步刷新
}, 0)
})
// 外层DOM先变为1,宏任务执行后才变为2
- async/await陷阱(极易踩坑)
js
const fn = async () => {
flushSync(async () => {
setN(1)
await Promise.resolve() // 让出同步栈,后续代码变成微任务
setN(2) // 不受flushSync管控,异步渲染
})
}
- 异步回调内新开flushSync
异步执行栈中创建的flushSync是全新独立同步上下文,可以正常强制刷新:
js
flushSync(() => {
setNum(1)
setTimeout(() => {
flushSync(() => setNum(2)) // 独立生效,同步渲染
}, 0)
})
场景5:flushSync内部抛出异常
规则
回调内报错不会回滚已执行的状态更新,已经执行的setState依旧会同步渲染;错误同步抛出,可用try/catch捕获。
js
const [n, setN] = useState(0)
const test = () => {
try {
flushSync(() => {
setN(1) // 正常渲染生效
throw new Error('执行异常')
setN(2) // 不会执行
})
} catch (err) {}
console.log(n) // 打印1
}
场景6:flushSync搭配startTransition(优先级对比)
优先级排序:flushSync(最高同步) > 普通更新 > startTransition(低优先级过渡)
flushSync会插队优先执行,transition内更新延后渲染:
js
const [n, setN] = useState(0)
const run = () => {
startTransition(() => setN(10)) // 低优先级延后
flushSync(() => setN(1)) // 最高优先级立刻渲染
console.log(n) // 打印1
}
场景7:useLayoutEffect / useEffect中调用flushSync
渲染周期钩子内部调用会触发React警告,属于不规范写法。渲染阶段本身正在执行更新流程,无法二次开启同步刷新上下文。
js
// 警告不推荐
useLayoutEffect(() => {
flushSync(() => setCount(1))
}, [])
场景8:实战标准DOM读取场景
业务最常用:先展示隐藏元素,立刻读取尺寸计算动画、定位:
js
const boxRef = useRef(null)
const openBox = () => {
flushSync(() => setShow(true))
// DOM已渲染完成,精准获取高度
const height = boxRef.current.offsetHeight
}
三、经典闭包快照陷阱
flushSync仅同步DOM视图,组件内state变量为函数执行瞬间的闭包快照,不会实时变化;状态累加务必使用函数式更新setX(prev => prev + val)。
js
const [val, setVal] = useState(0)
const run = () => {
setVal(1)
flushSync(() => {
console.log(val) // 0,闭包捕获旧快照
setVal(p => p + 2) // 函数式更新不受快照影响
})
}
四、重要注意事项(避坑清单)
- 杜绝滥用:强制同步渲染会打断React并发机制,频繁调用造成页面卡顿
- 同步栈内禁止嵌套,分步刷新只能平级书写
- 不要给flushSync传递async回调,await会撕裂同步执行栈
- 异步逻辑如需同步DOM,在异步回调内部单独包裹flushSync
- 循环中批量调用flushSync会产生多次重渲染,性能损耗极大
- 遇到Suspense时,flushSync强制同步会直接展示fallback加载态,无法等待异步资源
- 状态累加统一使用函数式更新,规避闭包快照数值错误
五、flushSync vs Vue nextTick 对比
| 对比项 | React flushSync | Vue nextTick |
|---|---|---|
| 核心行为 | 主动强制立刻同步刷新DOM | 被动等待DOM更新完毕再执行回调 |
| 执行类型 | 同步阻塞代码 | 微任务异步执行 |
| 批处理影响 | 打破React默认自动批处理 | 不改变Vue自身批处理逻辑 |
| 使用目的 | 修改state马上拿到最新DOM | 修改数据后等渲染完成操作DOM |
| 两者关系 | 逻辑完全相反,不能互相替代 | 逻辑完全相反,不能互相替代 |
六、高频面试真题(含完整答案)
面试题1 前置setState+内部多更新
js
const [n, setN] = useState(0);
function handleClick() {
setN(1);
flushSync(() => {
setN(prev => prev + 2);
setN(prev => prev + 3);
});
console.log(n);
}
答案:打印6;总渲染2次(前置1次、内部合并1次)
面试题2 同步多层嵌套flushSync
js
const [num, setNum] = useState(0);
function test() {
flushSync(() => {
setNum(1);
flushSync(() => {
setNum(2);
flushSync(() => {
setNum(3);
});
});
});
console.log(num);
}
答案:打印3;仅渲染1次,内层嵌套全部失效
面试题3 压轴综合大题
js
const [count, setCount] = useState(0);
function handleClick() {
setCount(1);
flushSync(() => {
setCount(p => p + 1);
flushSync(() => {
setCount(p => p + 2);
flushSync(() => {
setCount(p => p + 3);
});
});
});
flushSync(() => {
setCount(p => p + 4);
});
console.log(count);
}
问题1:最终打印数值?问题2:原始代码渲染几次?问题3:第二个平级flushSync挪入最深嵌套渲染几次?
答案:
- 1+1+2+3+4 = 11,打印11
- 原始渲染3次:前置1次、嵌套批量1次、平级第二个独立1次
- 移入嵌套后全程单同步上下文,仅渲染2次
面试题4 异常场景
js
const [n, setN] = useState(0);
function test() {
try {
flushSync(() => {
setN(1);
throw Error('err');
});
} catch (e) {}
console.log(n);
}
答案:打印1,异常不会回滚已执行状态
面试题5 优先级startTransition对比
js
const [n, setN] = useState(0);
function test() {
startTransition(() => setN(10));
flushSync(() => setN(1));
console.log(n);
}
答案:打印1,flushSync优先级高于transition
七、开发最佳实践
- 优先使用
useLayoutEffect派生数据、操作DOM,非必要不用flushSync - 接口请求、异步逻辑放在flushSync外部,拿到结果后再同步刷新视图
- 同一批次无关多状态统一放入单个flushSync,减少渲染次数
- 多层分步DOM操作拆分平级flushSync,不依赖嵌套逻辑
- 所有数值叠加更新全部采用函数式
setX(prev => prev + value)写法
总结
- flushSync为最高优先级同步刷新,前置队列会提前冲刷
- 同回调内多setState合并一次渲染;同步栈嵌套内层失效
- 异步代码脱离同步上下文,只有异步内新开flushSync才可再次同步
- 异常、闭包、transition、生命周期均有固定执行规则
- 和Vue nextTick行为完全相反,一个强制立刻刷,一个等待刷完再执行
- 谨慎使用,仅DOM读取、焦点、第三方库兼容场景作为兜底方案