React18 flushSync 完整深度解析

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. 适用场景

  1. 修改状态后立即读取DOM宽高、位置、滚动距离
  2. 渲染完成马上控制输入框焦点
  3. 对接图表、动画等第三方库,必须依赖最新DOM实例
  4. 多步骤逻辑强依赖上一步渲染结果

4. 禁止使用场景

  1. 组件渲染函数体内直接调用
  2. useEffectuseLayoutEffect内部调用(会控制台警告)
  3. 无必要场景滥用,会破坏并发渲染、降低页面性能

二、全场景执行逻辑拆解

场景1:flushSync前置存在普通setState

规则

执行flushSync时,会优先清空之前所有堆积未渲染的更新队列,前置更新单独渲染一次,内部更新合并再渲染一次。

js 复制代码
const [n, setN] = useState(0)
const run = () => {
  setN(1) // 缓存入队,暂不渲染
  flushSync(() => {
    setN(prev => prev + 2)
  })
  console.log(n) // 输出3
}

执行流程:

  1. setN(1)进入待更新队列
  2. 进入flushSync,先冲刷前置队列,n=1,第一次同步渲染
  3. 内部setN(prev+2)存入同步批次
  4. 回调执行完毕批量渲染,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后),状态更新脱离同步上下文,回归默认异步批处理。

  1. setTimeout宏任务示例
js 复制代码
flushSync(() => {
  setNum(1)
  setTimeout(() => {
    setNum(2) // 异步队列,延迟渲染,不跟随同步刷新
  }, 0)
})
// 外层DOM先变为1,宏任务执行后才变为2
  1. async/await陷阱(极易踩坑)
js 复制代码
const fn = async () => {
  flushSync(async () => {
    setN(1)
    await Promise.resolve() // 让出同步栈,后续代码变成微任务
    setN(2) // 不受flushSync管控,异步渲染
  })
}
  1. 异步回调内新开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) // 函数式更新不受快照影响
  })
}

四、重要注意事项(避坑清单)

  1. 杜绝滥用:强制同步渲染会打断React并发机制,频繁调用造成页面卡顿
  2. 同步栈内禁止嵌套,分步刷新只能平级书写
  3. 不要给flushSync传递async回调,await会撕裂同步执行栈
  4. 异步逻辑如需同步DOM,在异步回调内部单独包裹flushSync
  5. 循环中批量调用flushSync会产生多次重渲染,性能损耗极大
  6. 遇到Suspense时,flushSync强制同步会直接展示fallback加载态,无法等待异步资源
  7. 状态累加统一使用函数式更新,规避闭包快照数值错误

五、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+1+2+3+4 = 11,打印11
  2. 原始渲染3次:前置1次、嵌套批量1次、平级第二个独立1次
  3. 移入嵌套后全程单同步上下文,仅渲染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

七、开发最佳实践

  1. 优先使用useLayoutEffect派生数据、操作DOM,非必要不用flushSync
  2. 接口请求、异步逻辑放在flushSync外部,拿到结果后再同步刷新视图
  3. 同一批次无关多状态统一放入单个flushSync,减少渲染次数
  4. 多层分步DOM操作拆分平级flushSync,不依赖嵌套逻辑
  5. 所有数值叠加更新全部采用函数式setX(prev => prev + value)写法

总结

  1. flushSync为最高优先级同步刷新,前置队列会提前冲刷
  2. 同回调内多setState合并一次渲染;同步栈嵌套内层失效
  3. 异步代码脱离同步上下文,只有异步内新开flushSync才可再次同步
  4. 异常、闭包、transition、生命周期均有固定执行规则
  5. 和Vue nextTick行为完全相反,一个强制立刻刷,一个等待刷完再执行
  6. 谨慎使用,仅DOM读取、焦点、第三方库兼容场景作为兜底方案
相关推荐
小鱼程序员1 小时前
Reqable关于路径定位
前端
梦曦i2 小时前
Vite 0.1.7:构建追踪与资源映射新升级
前端
qq4356947012 小时前
Vue02
开发语言·前端·javascript
AsiaLYF2 小时前
Kotlin MutableSharedFlow: emit vs tryEmit 详解
开发语言·前端·kotlin
喜欢踢足球的老罗2 小时前
Chrome MV3 插件架构深度解析:Service Worker 生命周期与 Token 管理的三层博弈
前端·chrome·架构
小李云雾2 小时前
Pinia:Vue3 全局状态管理从入门到精通
前端·javascript·vue.js
Upsy-Daisy2 小时前
Hermes Agent 学习笔记 03:CLI 与 TUI 使用体验,让 Agent 真正进入终端工作流
服务器·前端·数据库
KaMeidebaby2 小时前
卡梅德生物技术快报|噬菌体筛选:技术实操:宽谱大肠杆菌噬菌体筛选全流程与性能验证方案
前端·人工智能·算法·数据挖掘·数据分析
风吹夏回2 小时前
Vue3 + Element Plus 完整使用指南
前端·javascript·vue.js·element