React 项目中 任务调度 的设计取舍
最近在改一个数据看板的项目,首页一个页面要并发拉三十多个接口,然后渲染一堆图表和表格。测试机上一打开,浏览器直接卡住好几秒,接口那边还报了限流。同事说要不加个 loading 遮罩吧,我心想这不是 loading 的事,是任务全挤在一瞬间了。于是就开始折腾任务调度这摊子事,中间踩了不少坑,记录一下,顺便聊聊每个方案的取舍。
问题到底出在哪
先把问题拆开看,其实是有两类任务挤在一起:
一类是网络请求,三十多个接口同时发出去,浏览器对同一域名有并发限制(HTTP/1.1 一般是 6 个),剩下的全在排队,服务端那边也可能直接给你限流。
另一类是渲染计算,数据回来之后一堆 setState,React 一口气把几十个图表全渲染了,主线程被卡死,用户点啥都没反应。
这两类问题的解法不一样,不能一锅端。
最先想到的 setTimeout
最朴素的想法就是错峰,给每个请求加个递增的延迟:
ids.forEach((id, i) => {
setTimeout(() => fetchData(id), i * 100)
})
能用,但问题很明显:延迟是拍脑袋定的,网络快的时候白白等 100ms,网络慢的时候该挤还是挤。而且 setTimeout 排队之后到底啥时候执行,浏览器说了算,你控制不了。写完我自己都觉得心虚,就没往这个方向走。
requestIdleCallback 听起来很美好
对于渲染类的任务,很自然会想到 requestIdleCallback,意思是“浏览器闲下来再叫我”:
function idleTask(cb) {
if (window.requestIdleCallback) {
window.requestIdleCallback(cb, { timeout: 500 })
} else {
setTimeout(() => cb({ timeRemaining: () => 50 }), 1)
}
}
注意两个坑:一是 Safari 支持得很晚,fallback 必须写;二是空闲回调真的可能一直不来,页面一直忙它就一直憋着,所以 timeout 一定要给。另外 timeRemaining() 每次就几毫秒,指望在里面干大活是不现实的,只适合切碎了喂。
实测下来,用它做“分帧渲染”还行,做请求调度就不合适了——网络空闲不等于主线程空闲,这俩是两码事。
最后还是自己撸了个限流器
绕了一圈,请求这块的核心诉求其实就是并发数控制,那直接手写一个也就二十来行:
function createLimiter(max) {
const queue = []
let active = 0
function next() {
if (active >= max || queue.length === 0) return
active++
const { fn, resolve, reject } = queue.shift()
fn().then(resolve, reject).finally(() => {
active--
next()
})
}
return fn =>
new Promise((resolve, reject) => {
queue.push({ fn, resolve, reject })
next()
})
}
用起来就是这样:
const limit = createLimiter(4)
const results = await Promise.all(
ids.map(id => limit(() => fetch(`/api/chart/${id}`).then(r => r.json())))
)
为什么不用 p-limit?其实可以用,就一个依赖,没毛病。但项目里就这一处用到,我不想为二十行代码引个包,而且自己写的出问题好排查。这是取舍,不是技术优劣。
渲染那边配合着做了分帧,每渲染一小批就等一帧:
const nextFrame = () => new Promise(r => requestAnimationFrame(r))
async function renderInChunks(items, chunk = 20) {
for (let i = 0; i < items.length; i += chunk) {
setList(prev => [...prev, ...items.slice(i, i + chunk)])
await nextFrame()
}
}
严格讲等一帧不保险,讲究点应该等两帧,确保上一批真的画出来了,不过实测问题不大,先这样。
顺便说说没选的路
Web Worker 肯定是更“正确”的方案,重计算丢到 worker 里,主线程一点不卡。但我们的瓶颈主要在请求排队和 React 渲染上,不是纯计算,上 worker 还得处理数据序列化、打包配置,收益配不上成本,就没上。
React 18 的 startTransition 和 useDeferredValue 也试过,把图表更新包进 transition 里,输入确实不卡了。但它解决的是“优先级”问题,解决不了“三十个请求同时发”的问题,所以最后是两个一起用:请求靠限流器,更新靠 transition。
还有 React 内部那个 scheduler 包,暴露了 unstable_scheduleCallback,能力是强,但名字都带 unstable 了,我是不敢直接在业务里用,万一哪天 API 变了哭都来不及。
写在最后
回头看,这次折腾下来最大的感受是:任务调度这东西,方案没有高低,只有合不合适。setTimeout 简单但粗糙,requestIdleCallback 优雅但兼容性和触发时机都别扭,worker 强大但重,React 自带的能力好用但只管渲染那一半。
我最后的组合就是最土的那个:二十行限流器加分帧渲染,再加一个 startTransition。代码不多,但页面不卡了,接口不报限流了,测试也没再提意见。先这样吧,等哪天真扛不住了再升级方案也不迟。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。