在 JavaScript 中应用 Time Slice 模式 分解长时任务 优化性能

导读

Time Slice(时间切片)模式主要用于操作系统中的任务调度机制。它是一种多任务处理技术,允许多个任务在单个处理器上看似同时运行。

Time Slice 调度的基本概念

以下是时间切片模式的一些关键点:

  1. Time Slice:每个任务被分配一个固定的时间段,称为时间切片(Time Slice),通常是几毫秒到几十毫秒不等。
  2. 轮转调度:操作系统将每个任务按顺序循环执行,当一个任务的时间切片用完时,系统会将该任务挂起并切换到下一个任务。
  3. 公平性:所有任务按照相同的时间切片轮流执行,确保每个任务都能获得CPU的执行时间,从而提高系统的响应能力。

Time Slice 调度的工作原理

  1. 任务队列:操作系统维护一个任务队列,所有待执行的任务按顺序排列。
  2. 调度器:操作系统中的调度器负责分配CPU时间给每个任务。当一个任务的时间切片用完时,调度器会将CPU控制权转交给下一个任务。
  3. 上下文切换:在时间切片结束时,操作系统保存当前任务的状态(如寄存器、程序计数器等),并加载下一个任务的状态。这种切换称为上下文切换。

Time Slice 模式优点

  1. 响应能力强:由于每个任务都会在一个短时间切片内执行,系统对用户输入等事件的响应速度较快。
  2. 公平性:每个任务都能获得执行时间,避免某些任务长时间占用CPU资源。

Time Slice 模式缺点

  1. 上下文切换开销:频繁的上下文切换会增加系统开销,影响整体性能。
  2. 时间切片选择难度:选择合适的时间切片长度需要权衡。如果时间切片太短,会导致过多的上下文切换;如果时间切片太长,则会影响系统的响应能力。

Time Slice 模式在 JavaScript 中的应用场景

在 JavaScript 中,Time Slice 模式通常用于处理异步任务和长时间运行的任务,以避免阻塞主线程(UI线程)。

由于 JavaScript 是单线程的,在长时间运行的任务中使用时间切片,通过将长时间运行的任务拆分为较小的任务块,每个任务块执行一小部分,可以保持应用程序的响应性,以避免长时任务阻碍整个应用出现卡死。

requestIdleCallback()

在了解了 Time Slice 模式的原理和作用后,那么要如何在 JavaScript 中实现它呢?这里就要特别介绍一下 requestIdleCallback() 方法了。

requestIdleCallback() 方法插入一个函数,这个函数将在浏览器空闲时期被调用 。使用 requestIdleCallback() 方法实现 Time Slice API 也正是利用了 函数将在浏览器空闲时期被调用 这个特性。

这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间 timeout,则有可能为了在超时前执行函数而打乱执行顺序。

语法

js 复制代码
requestIdleCallback(callback)
requestIdleCallback(callback, options)

参数

  • callback:一个在事件循环空闲时即将被调用的函数的引用。函数会接收到一个名为 IdleDeadline 的参数,这个参数可以获取当前空闲时间以及回调是否在超时时间前已经执行的状态。

  • options :可选,包括可选的配置参数。具有如下属性:

    • timeout:如果指定了 timeout,并且有一个正值,而回调在 timeout 毫秒过后还没有被调用,那么回调任务将放入事件循环中排队,即使这样做有可能对性能产生负面影响。

返回值

一个 ID,可以把它传入 Window.cancelIdleCallback() 方法来结束回调。

浏览器兼容性

总的来说 requestIdleCallback() 方法的浏览器兼容性还不错。如果浏览器不支持,下文的 Time Slice API 代码实现中也给出了使用 setTimeout() 方法的实现。

封装 Time Slice API

Time Slice API 的实现主要是通过 requestidlecallback() 实现的,这里先给出 API 实现的代码:

js 复制代码
/**
 * timeSlice.js 时间切片功能函数
 * =================================================
 * Created By: Yaohaixiao
 * Update: 2023.09.04
 */
import isFunction from './isFunction'
import later from './later'

const queue = []
let isHandling
let done

// requestIdleCallback 方法的一个实现
// Shim from https://developers.google.com/web/updates/2015/08/using-requestidlecallback
if (typeof window.requestIdleCallback === 'undefined') {
  window.requestIdleCallback = function (cb) {
    const start = Date.now()
    return later(function () {
      cb({
        didTimeout: false,
        timeRemaining: function () {
          return Math.max(0, 50 - (Date.now() - start))
        }
      })
    }, 10)
  }

  window.cancelIdleCallback = function (id) {
    clearTimeout(id)
  }
}

function runIdle(idleDeadline) {
  while (idleDeadline.timeRemaining() > 0 && queue.length) {
    const fn = queue.shift()

    if (!isFunction(fn)) {
      return false
    }

    fn()
  }

  if (queue.length) {
    isHandling = requestIdleCallback(runIdle)
  } else {
    isHandling = 0

    if (isFunction(done)) {
      done()
      done = null
    }
  }
}

/**
 * 时间切片功能函数:主要用于优化长时任务的性能,将长时任务分解成
 * 多个短时间任务
 * ====================================================
 * @param {Function} fn - 需要在空闲时执行的回调函数
 * @param {Function} afterComplete - queen 的
 * @return {(function(): (boolean|undefined))|*|boolean}
 */
const timeSlice = (fn, afterComplete = null) => {
  queue.push(fn)

  if (isFunction(afterComplete)) {
    done = afterComplete
  }

  if (!isHandling) {
    requestIdleCallback(runIdle)
  }
}

export default timeSlice

API 解读

通过代码可以看到 Time Slice API 中维护了一个简单的任务列队 queue,这个任务列队,正是前文说的长时任务被拆分后的产物:

js 复制代码
const queue = []
let isHandling
let done

/**
 * 时间切片功能函数:主要用于优化长时任务的性能,将长时任务分解成
 * 多个短时间任务
 * ====================================================
 * @param {Function} fn - 需要在空闲时执行的回调函数
 * @param {Function} afterComplete - queen 的
 * @return {(function(): (boolean|undefined))|*|boolean}
 */
const timeSlice = (fn, afterComplete = null) => {
  // 往列队中添加拆分任务
  queue.push(fn)

  if (isFunction(afterComplete)) {
    done = afterComplete
  }

  // 如果已经处理完,则执行下一个任务
  if (!isHandling) {
    requestIdleCallback(runIdle)
  }
}

然后通过 requestIdleCallback() 方法在浏览器空闲时期 或者间隔一段时间执行任务列队中的一段任务,requestIdleCallback() 方法也就是前文指的调度器

js 复制代码
function runIdle(idleDeadline) {
  while (idleDeadline.timeRemaining() > 0 && queue.length) {
    // 先进先出的原则,执行排在列队中最前面的任务
    const fn = queue.shift()

    if (!isFunction(fn)) {
      return false
    }

    fn()
  }
  
  // 只要列队中有任务,则持续执行列队中的任务
  if (queue.length) {
    isHandling = requestIdleCallback(runIdle)
  } else {
    // 任务执行完毕
    isHandling = 0

    if (isFunction(done)) {
      done()
      done = null
    }
  }
}

然后通过 runIdle() 方法实现上下文切换,直到列队中的所有任务都执行完。

其它用到的方法

这里是 isFunction() 和 later() 方法的代码:

isFunction()

js 复制代码
const isFunction = (fn) => { 
  return fn && {}.toString.call(fn) === '[object Function]' 
} 

export default isFunction

later()

js 复制代码
import isFunction from '../isFunction'

/**
 * later - 延迟执行方法
 * ========================================================================
 * @method later
 * @param {Function} fn
 * @param {Number} [delay]
 * @returns {number|boolean}
 */
const later = (fn, delay = 300) => {
  if (!isFunction(fn)) {
    return false
  }

  return setTimeout(() => {
    fn()
  }, delay)
}

export default later

Time Slice 的应用实例

介绍完 Time Slice API 的实现后,接下来就看看在 JavaScript 中如何应用 Time Slice 模式分解任务。这里我以 outilne.js 项目的应用场景的实例代码为例:

实例代码

js 复制代码
import createElement from './utils/dom/createElement'
import timeSlice from './utils/lang/timeSlice'

const _paintChapters = ($list, chapters, showCode = false) => {
  const LIMIT = 400
  const count = chapters.length
  const clones = [...chapters]
  const paint = (parts) => {
    const byId = (id) => $list.querySelector(`#${id}`)
    parts.forEach((chapter) => {
      const pid = chapter.pid
      const id = chapter.id
      const code = chapter.code
      const rel = chapter.rel
      const children = []
      const $text = createElement(
        'span',
        {
          className: 'outline-navigator__text'
        },
        chapter.text
      )
      let $link
      let $code
      let $li
      let $subject
      let $chapter

      if (showCode) {
        $code = createElement(
          'span',
          {
            className: 'outline-navigator__code',
            'data-id': id
          },
          chapter.code
        )

        children.push($code)
      }

      children.push($text)

      $link = createElement(
        'a',
        {
          id: `chapter__anchor-${id}`,
          className: 'outline-navigator__anchor',
          href: '#' + rel,
          rel: rel,
          'data-id': id,
          'data-code': code
        },
        children
      )

      $li = createElement(
        'li',
        {
          id: `chapter-${id}`,
          className: 'outline-navigator__item',
          'data-id': id,
          'data-code': code
        },
        $link
      )

      if (pid === -1) {
        $list.appendChild($li)
      } else {
        $chapter = byId(`chapter-${pid}`)
        $subject = byId(`subject-${pid}`)

        if (!$subject) {
          $subject = createElement(
            'ul',
            {
              id: 'subject-' + pid,
              className: 'outline-navigator__subject'
            },
            $li
          )

          $chapter.appendChild($subject)
        } else {
          $subject.appendChild($li)
        }
      }
    })
  }

  // 在大量 DOM 菜单绘制的时候,使用 TIME SLICE 拆分绘制任务
  // 以避免一次绘制大量 DOM 导致占用资源过高,导致卡死
  if (count > LIMIT) {
    // 同步绘制
    paint(clones.splice(0, LIMIT))
    
    // 剩余的采用 timeSlice 机制绘制
    while (clones.length > 0) {
      const once = clones.splice(0, LIMIT)
      timeSlice(() => {
        paint(once)
      })
    }
  } else {
    paint(clones)
  }
}

export default _paintChapters

Time Slice 应用解读

演示页面地址:yaohaixiao.github.io/outline.js/...

这个页面就一个比较适合使用 Time Slice 模式的应用场景。页面的左侧菜单要显示 1800 多项导航菜单。在 outline.js 绘制文章的导航菜单的时候,当菜单的数量超过 400 个节点的时候,一次性绘制出整个菜单就会出现占用资源过多,会超过 50ms。一次性绘制超过 1000 个节点就开始有卡顿的现象,再多些就会页面导致卡死。

上图就是未使用 Time Slice 模式一次绘制 1800 个菜单的渲染性能统计,一开始 navigator.js 模块的 render() 方法绘制界面需要 143 毫秒,而使用资源管理器查看 CPU 会最高到达 90% 以上。这个时候,就是 Time Slice 模式发挥作用的场景:

js 复制代码
// 在大量 DOM 菜单绘制的时候,使用 TIME SLICE 拆分绘制任务
  // 以避免一次绘制大量 DOM 导致占用资源过高,导致卡死
  if (count > LIMIT) {
    // 同步绘制
    paint(clones.splice(0, LIMIT))
    
    // 剩余的采用 timeSlice 机制绘制
    while (clones.length > 0) {
      const once = clones.splice(0, LIMIT)
      timeSlice(() => {
        paint(once)
      })
    }
  } else {
    paint(clones)
  }

Time Slice 模式将绘制任务按 400 个节点一次,分多次绘制。确保每次的绘制时间在 50ms 内(lighthouse 对长时任务的时间节点定义就是 50ms)。

现在初始化 navigator.js 模块中 render() 方法只需要 78 毫秒了。更关键的是此时不会出现页面卡死,导航菜单无法操作的情况了。而这个统计图还是写文章时采用的最新的 chrome 浏览器统计的结果,在开发 outline.js 的时候,使用和不使用 Time Slice 的差距更大。

Time Slice 单个任务的划分值怎么处理?

Time Slice 单个任务的划分值怎么处理?我想现在应该是读者最大的疑惑。不过很可惜,这个值不是固定的。前文的 Time Slice 缺点段落也说了:

时间切片选择难度:选择合适的时间切片长度需要权衡。如果时间切片太短,会导致过多的上下文切换;如果时间切片太长,则会影响系统的响应能力。

当然,outline.js 中 Time Slice 单个任务的 400 这个数字也是通过调试绘制耗时分析出来的。也就是 Time Slice 切分单个任务的划分数值,需要大家根据自己的应用场景做数据分析,执行一次的任务的耗时要控制在 50ms 以内为佳。

总结

Time Slice 模式对于拆分长时任务,优化代码性能的效果还是不错。不过还是需要牢记,凡事都是有两面性的。时间切片单个任务的拆分数量是有选择难度,选择合适的时间切片长度需要权衡。如果时间切片太短,会导致过多的上下文切换。如果时间切片太长,则会影响系统的响应能力。这需要我们多加练习并根据自身的应用场景自己分析取舍。

相关推荐
卡兰芙的微笑13 分钟前
get_property --Cmakelist之中
前端·数据库·编辑器
覆水难收呀15 分钟前
三、(JS)JS中常见的表单事件
开发语言·前端·javascript
猿来如此呀23 分钟前
运行npm install 时,卡在sill idealTree buildDeps没有反应
前端·npm·node.js
hw_happy29 分钟前
解决 npm ERR! node-sass 和 gyp ERR! node-gyp 报错问题
前端·npm·sass
FHKHH33 分钟前
计算机网络第二章:作业 1: Web 服务器
服务器·前端·计算机网络
视觉小鸟1 小时前
【JVM安装MinIO】
前端·jvm·chrome
二川bro2 小时前
【已解决】Uncaught RangeError: Maximum depth reached
前端
qq22951165023 小时前
python毕业设计基于django+vue医院社区医疗挂号预约综合管理系统7918h-pycharm-flask
前端·vue.js·express
WebGIS皮卡茂3 小时前
【数据可视化】Arcgis api4.x 热力图、时间动态热力图、timeSlider时间滑块控件应用 (超详细、附免费教学数据、收藏!)
javascript·vue.js·arcgis·信息可视化
八了个戒3 小时前
Koa (下一代web框架) 【Node.js进阶】
前端·node.js