前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >从简单中窥见高端,彻底搞懂任务可中断机制与任务插队机制

从简单中窥见高端,彻底搞懂任务可中断机制与任务插队机制

作者头像
用户6901603
发布2024-01-18 13:11:56
1890
发布2024-01-18 13:11:56
举报
文章被收录于专栏:不知非攻

React 知命境第 42 篇,原创第 155 篇

前面用了几篇文章来跟大家分享什么是任务可中断,不过呢,可能是我介绍的方式太过于简单粗暴,以致于还是有部分同学没太明白,所以今天我就用最基础的方式重新跟大家分享一下什么是任务可中断

0

任务拆分

首先,我们要明确的一个前提,是一个完整的函数执行是不可以中断的。因此如果你把一整个任务全部都放到一个函数中来执行,那么想要做到任务可中断是不可能的。

例如,现在我有一个任务,往父级元素中插入 10 万个子节点 <span>1</span>,然后我们可以随便写这样一个函数来完成这个逻辑

代码语言:javascript
复制
btn.onclick = () => {
  let i = 0
  for (; i < 100000; i++) {
    let span = document.createElement('span')
    span.innerText = 1
    container.appendChild(span)
  }
}

然后这个时候,我们就发现一个问题,当我们点击之后,页面上并不会立即显示插入的内容,而是会卡顿一会儿,才会显示

原因是因为 10 万个节点的插入逻辑是一个同步的过程,JS 逻辑的执行时间过长导致了浏览器迟迟无法执行渲染。

那么为了优化这种情况,我们可以考虑将渲染 10 万个节点这个大的任务,拆分成 10 万个渲染 1 个节点的小任务

代码语言:javascript
复制
function task() {
  let span = document.createElement('span')
  span.innerText = 1
  container.appendChild(span)
}

并将这 10 万个任务,放进一个数组中

代码语言:javascript
复制
const taskQueue = Array.from({ length: 100000 }, () => task)

执行这 100 万个任务,就通过遍历 taskQueue 的方式来执行,这样,我们就可以通过中断队列遍历的方式,来中断任务的执行。

1

需要中断的原因

在浏览器中,渲染引擎在每一帧都有机会渲染页面,那么页面的表现就不会卡顿。但是刚才我们的情况是,JS 执行时间过长,导致渲染引擎一直没有机会渲染,所以用户感受到的就是卡顿。

那么解决这个问题的原理,就是根据浏览器渲染频率,对 JS 要执行的任务进行拆分,JS 执行一部分,然后渲染引擎渲染一部分,完成之后,JS 再继续执行,渲染引擎再渲染。

通过这样间隔执行的方式,让用户感知不到卡顿的存在。

2

中断的判断条件

如果你的显示器是 60 Hz,那么浏览器一帧的渲染间隔时间大约就是 16.7ms,因此,我们可以利用浏览器渲染任务完成之后的空余时间来执行被拆分的 JS 任务,浏览器给我们提供了一个钩子函数 requestIdleCallback 在空余时间执行我们想要的逻辑

需要注意的是,许多朋友对 1ms 没什么概念,对于计算机来说,16.7ms 时间其实非常的长,简单的函数 1 ms 可以执行非常多次

例如,如果只是简单的递增

代码语言:javascript
复制
var k = 0
let startTime = performance.now()

while (performance.now() - startTime <= 1) {
  // console.log('xx')
  k += 1
}
console.log(k)

在我的电脑上,1ms k 值最高可以递增到 6500+,如果我要执行 console.log 函数,最高可以执行 100+ 次。

我们来学习一下 requestIdleCallback 的语法。

代码语言:javascript
复制
requestIdleCallback(callback[, options])

callback 是需要执行的任务,接收一个 IdleDeadline 对象作为参数。IdleDeadline 包含 2 个重要字段

  • didTimeout,布尔值,表示任务是否超时
  • timeRemaining() ,用于获取当前帧的剩余时间

options 是一个可选参数,目前只有一个值 timeout,表示如果超过这个时间,任务还没有执行,则强制执行任务,不需要等待空闲时间。

因此当我们通过上面的 deadline 发现没有剩余时间执行更多的任务了,那我们就中断遍历过程

3

代码实现

实现起来非常简单,我们用 while 循环来遍历 queueTask,然后根据 deadline 的情况来中断遍历过程,代码如下

代码语言:javascript
复制
btn.onclick = () => {
  btn.innerText = '已点击,插入中'

  requestIdleCallback((deadline) => {
    let task;
    while ((task = taskQueue.pop()) && !deadline.didTimeout && deadline.timeRemaining() > 0) {
      task()
    }
  })
}

此时因为没有加入递归逻辑去连续触发 requestIdleCallback,但是我们可以通过连续点击的方式查看执行效果

然后我们加入递归逻辑让他们自动把剩余的任务全部执行完,定义一个 performWorkUnit

代码语言:javascript
复制
function performWorkUnit() {
  // 任务执行完毕后结束递归
  if (taskQueue.length === 0) {
    btn.innerText = '执行'
    return
  }

  requestIdleCallback(deadline => {
    let task;
    while ((task = taskQueue.pop()) && !deadline.didTimeout && deadline.timeRemaining() > 0) {
      task()
    }
    performWorkUnit()
  })
}

然后在点击事件中调用即可

代码语言:javascript
复制
btn.onclick = () => {
  btn.innerText = '已点击,插入中'
  requestIdleCallback(performWorkUnit)
}

执行效果如图所示,我们会发现卡顿消失了。

此时我们为了更好的观察效果,让每一个小任务的执行都阻塞 1ms

代码语言:javascript
复制
function task() {
  const startTime = performance.now()
  let span = document.createElement('span')
  span.innerText = 1

  while (performance.now() - startTime < 1) {
    // 阻塞 1 ms
  }
  container.appendChild(span)
}

然后把任务数量改成 1000

代码语言:javascript
复制
const taskQueue = Array.from({ length: 1000 }, () => task)

执行效果如下

4

插队

我们另外起一个按钮,专门用于执行一些插队任务。插队的逻辑非常简单,只需要往 taskQueue 中添加任务即可。不过插队任务的优先级更高一些,因此要通过 push 来添加,以确保任务能够更早的执行。

首先声明一个 highPriorityTask 函数用于创建优先级更高的任务

代码语言:javascript
复制
function highPriorityTask() {
  const startTime = performance.now()
  let span = document.createElement('span')
  span.style.color = 'red'
  span.innerHTML = '<strong>插队任务</strong>'

  while (performance.now() - startTime < 1) {
    // 阻塞 1 ms
  }
  container.appendChild(span)
}

新增一个按钮,用于触发插队任务的执行

代码语言:javascript
复制
pushBtn.onclick = function () {
  taskQueue.push(highPriorityTask)
}

我们来看一下执行效果,每当我点击插队任务按钮,就会执行一个优先级更高的任务

代码非常的简单,不过理解可能需要稍微思考一下。因为 performWorkUnit 中递归在遍历队列 taskQueue,并且这个递归过程是一直处于中断 -> 恢复的过程中,因此,当遍历被中断后,在它恢复之前,我们可以往 taskQueue 中插入新的任务到队列头部,当它重新开始遍历时,新加入的任务就会被执行。

这里一个小的细节是,在事件循环的运行规则中,点击事件的回调会比 requestIdleCallback 更早执行。

5

总结

这个逻辑就是 React 并发模式的底层原理。只不过在 React 中,同时兼容了同步更新与异步更新,并且设计了更加复杂的优先级机制,增加了更多场景的条件判断,导致源码看上去变得更加复杂了。

当然,React 由于为了兼容更多的场景,改写了任务中断的判断条件。因为在别的环境里,例如 node/React Native 等,不支持 requestIdleCallback,在这些场景之下,React 把中断策略改为 5ms 中断一次,然后利用 performance.now 或者 Date.now 来记录时间

代码语言:javascript
复制
/* eslint-disable no-var */
var getCurrentTime;
var hasPerformanceNow = typeof performance === 'object' && typeof performance.now === 'function';

if (hasPerformanceNow) {
  var localPerformance = performance;

  getCurrentTime = function () {
    return localPerformance.now();
  };
} else {
  var localDate = Date;
  var initialTime = localDate.now();

  getCurrentTime = function () {
    return localDate.now() - initialTime;
  };
代码语言:javascript
复制
function shouldYieldToHost() {
  var timeElapsed = getCurrentTime() - startTime;

  if (timeElapsed < frameInterval) { // 5ms
    // 主线程只被阻塞了很短时间;
    // smaller than a single frame. Don't yield yet.
    return false;
  } 
  // 主线程被阻塞的时间不可忽视
  return true;
}

并使用别的方式来替代 requestIdleCallback

  • node/old IE:setImmediate
  • DOM/worker:MessageChannel
  • 兜底方案:setTimeout
代码语言:javascript
复制
let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === 'function') {
  // Node.js and old IE.
  schedulePerformWorkUntilDeadline = () => {
    localSetImmediate(performWorkUntilDeadline);
  };
} else if (typeof MessageChannel !== 'undefined') {
  // DOM and Worker environments.
  // We prefer MessageChannel because of the 4ms setTimeout clamping.
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
} else {
  // We should only fallback here in non-browser environments.
  schedulePerformWorkUntilDeadline = () => {
    // $FlowFixMe[not-a-function] nullable value
    // @ts-ignore
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}
本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2024-01-18,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 这波能反杀 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 0
  • 1
  • 2
  • 3
  • 4
  • 5
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档