小Cの已经记不起来的博客

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。代码不多,但页面不卡了,接口不报限流了,测试也没再提意见。先这样吧,等哪天真扛不住了再升级方案也不迟。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放