探索前端任务调度

1,452 阅读9分钟

1 前言

众所周知 JavaScript 是一门单线程语言,通过维持任务队列实现异步操作,避免异步阻塞。任务又分为宏任务、微任务,JavaScript 通过 eventLoop 事件循环,在执行完一个宏任务后,再从队列里面取出下一个宏任务执行,即可以理解为一个宏任务需要等待上一个宏任务周期执行完才会被执行。

那是否存在一些场景,可以缩减宏任务数量,或者把几个任务合并成一个任务最终执行,从而实现性能提升?本文将从两个常见的前端场景来探索其内部的核心实现,从而掌握前端任务调度实现的核心原理

2 场景一:合并网络请求

先抛出一个场景问题:

后端提供一个 /user/info?email=1&email=2.... 接口,即允许前端通过传递一组邮箱列表,获取一群人的用户信息,如名字、头像等信息。

前端一个页面上,有好多处需要用到需要根据 email 来获取用户详情,如有工单列表,每个工单创建人、修改人。页面上还有各种创建人等信息展示。如下图:

前端开发者怎么处理这个问题?不能在页面上一一收集email,然后再发请求,在开发体验,我们期望,哪里用到用户数据,哪里就展示详情,不需要主动收集。具体做法:

  • 抽象一个 User 组件
  • 充分利用后端接口支持批量查询的能力

最后形成的组件用法:

// User 组件使用
import React from 'react'
export default (props) => {
  //items 指通过请求获取的工单列表
  const [items,setItems] = useState([])
  return (
    <>
      items.map(item =><User email={item.email}></User>)
    </>
  )
}

问题转化为:

User 组件里面如何合并请求?避免频繁触发网络 IO

2.1 核心思路

  1. 维护任务列表 scheduler, User 再使用 scheduler.push(task),即记录一次email ,只记录,并不真实触发网络请求

  2. 启动一个宏任务,释放 Dispatch 上一个步骤记录的任务,即收集到 email 列表,这时候一并调用接口实现 scheduler.scheduleTask

我们把核心逻辑放到 dataloader 这个包实现

2.2 场景实践


import React from 'react'

import Dataloader from './dataloader'
const dataloader = new Dataloader(async (emails) =>
  axios.get('/user/info', {
    params: { emails },
  })
)

function User(props) {
  const { email } = props
  const info = dataloader.load(email)
  return <>{info.avatar + info.name}</>
}
export default User

Dataloader 基本逻辑

class Dataloader {
  constructor(fetch) {
    this._batch = null
    this._batchLoadFn = fetch
  }

  //释放任务
  async dispatch(key) {
    const infos = await this._batchLoadFn(this._batch.keys).catch((error) => {
      this._batch.callbacks.forEach(async ({ reject }) => {
        reject(error)
      })
    })

    this._batch.callbacks.forEach(async ({ resolve }, index) => {
      resolve(infos[index])
    })
  }

  load(key) {
    const batch = this.getCurrentBatch()
    batch.keys.push(key)
    return new Promise((resolve, reject) => {
      batch.callbacks.push({ resolve, reject })
    })
  }
  //获取任务队列,并启动宏任务
  getCurrentBatch() {
    if (this._batch) {
      return this._batch
    } else {
      this._batch = { hasDispatched: false, keys: [], callbacks: [] }
    }
    setTimeout(() => {
      this.dispatch()
    }, 0)
  }
}

上面的 dataloader 通过在组件在一次宏任务周期里面存储任务,最后合并释放。

合并请求,不单单在前端网络io 常见,在服务端数据获取上也很常见,如数据库查询等。关于 合并网络,facebook 提出 Graphql 概念,并为了解决 Graphql N+1 问题,写了一个 dataloader npm 包,核心逻辑和上面思路一样。

2.3 精读 dataloader 源码

dataloader 是把一些可以合并请求进行合并,减少请求次数。业务使用时候仍然是独自获取,保证开发体验。

2.3.1 dataloader 使用

本身是一个类,其接受一个批处理方法,这也是上面要求你是一个可合并的请求一个批处理加载方法接受一个数组的键名,并且返回包裹一个 Promise<(error|User)[]> 对象。

 const DataLoader = require('dataloader') 
//批量获取数据的请求实现 
const userLoader = new DataLoader((keys) => myBatchGetUsers(keys)) 
 
const user = await userLoader.load(1) 
//这一行不会合并请求,因为是 await,需要其他地方,如下文 
const invitedBy = await userLoader.load(user.invitedByID) 
console.log(`User 1 was invited by ${invitedBy}`) 
 
// 在你应用中的其他地方,需要在其他地方,才能实现在同一个任务周期里面 load 函数被调用 
const user = await userLoader.load(2) 
const lastInvited = await userLoader.load(user.lastInvitedID) 
console.log(`User 2 last invited ${lastInvited}`) 

上面 demo 接口支持批量获取用户信息,但在业务使用中我们是一个个获取,那么在一个任务周期里通过dataloader合并请求

2.3.2 核心实现

dataloader 有合并请求、缓存数据的功能,以及一些配置项,这里主要讲述合并请求的核心实现,其他不赘述。以下是是精简实现,原理和 dataloader,和源码几乎对齐,只描述合并请求的核心逻辑

读源码时候发现没使用 async await 这些语法,猜测那会 es6 还没普及。

class Dataloader {
  // 接受一个 fetcher 函数,用于批量获取数据,如何批量获取数据需要用户自行实现
  constructor(fetcher) {
    // 用于存储任务队列,以及任务完成回调☺️
    this._batch = null// { hasDispatched: false, keys: [], callbacks: [] };
    //这个是批处理的任务调度函数
    this._batchScheduleFn = enqueuePostPromiseJob
  }

  load(key) {
    //获取任务列表,这里很重要,其实获取新的任务列表时候,也会启动任务,在 nextTick 处理任务
    const batch = getCurrentBatch(this)
    batch.keys.push(key)
    // tips:这里把 promise 状态修改函数 resolve, reject 交给 callback ,用于请求结束时候可以修改 promise 状态
    var promise = new Promise((resolve, reject) => {
      batch.callbacks.push({ resolve, reject })
    })
    return promise
  }
}
//一些共有的方法,之所以不是类的方法,大概是因为不想被实例化。所以这些方法接收的是 loader 实例

//获取当前任务列表,如果无列表则新建,并启动任务执行(任务只有在 nextTick 才真的会被释放)
function getCurrentBatch(loader) {
  //如果已经有任务列表则不会重新创建,如果任务已经在执行也不会被创建
  var existingBatch = loader._batch
  if (existingBatch !== null && !existingBatch.hasDispatched) {
    return existingBatch
  }
  //创建任务
  //其中 keys用来存储任务,callback 是批处理请求完成后,回调该任务。这里的 callbacks 数组每一项不是函数,而是一个对象,{resolve,reject} 其实就是把 promise 状态更改。
  var newBatch = { hasDispatched: false, keys: [], callbacks: [] }

  // 更新实例
  loader._batch = newBatch

  // 这是关键代码。就是创建任务列表时候,同时启动任务执行,只不过是放在 nextTick 才会真的被执行。那么在目前的 tick 则可以不断往任务队列添加任务
  loader._batchScheduleFn(() => {
    dispatchBatch(loader, newBatch)
  })

  return newBatch
}

// 执行任务,
// 源码不是 async 函数,但个人觉得如果 用 async 代码更优美。这里保持和源码一致
function dispatchBatch(loader, batch) {
  // 标记当前任务队列开始执行
  batch.hasDispatched = true

  //如果没有任务,,则结束
  if (batch.keys.length === 0) {
    return
  }
  // 利用用户传的批量获取函数 fetcher 获取数据
  var batchPromise = loader._batchLoadFn(batch.keys)

  //错误时候
  if (!batchPromise || typeof batchPromise.then !== 'function') {
    return failedDispatch(loader, batch, new TypeError(String(batchPromise)))
  }
  batchPromise
    .then((values) => {
      //校验 values 格式
      if (Array.isArray(values) && values.length !== batch.keys.length) {
        throw new TypeError(
          'fetcher 函数返回结果的数量必须和 keys 数量一样,且顺序也一致,哪怕错误也应该是 error'
        )
      }
      for (let i = 0; i < batch.callbacks.length; i++) {
        let value = values[i]
        if (value instanceof Error) {
          batch.callbacks[i].reject(value)
        } else {
          batch.callbacks[i].resolve(value)
        }
      }
    })
    .catch((err) => {
      return failedDispatch(loader, batch, new TypeError(String(batchPromise)))
    })
}

// 批量获取失败处理函数
function failedDispatch(loader, batch, error) {
  for (var i = 0; i < batch.keys.length; i++) {
    batch.callbacks[i].reject(error)
  }
}

// 这个是 dataloader 任务周期实现,任务会在 process.nextTick 启动
// In order to avoid the DataLoader dispatch Job occuring before PromiseJobs ,
// A Promise Job is created with the sole purpose of enqueuing a global Job,
// ensuring that it always occurs after PromiseJobs ends.
const enqueuePostPromiseJob =
  typeof process === 'object' && typeof process.nextTick === 'function'
    ? function (fn) {
        if (!resolvedPromise) {
          resolvedPromise = Promise.resolve()
        }
        resolvedPromise.then(() => {
          process.nextTick(fn)
        })
      }
    : setImmediate || setTimeout

dataloadernextTick 释放任务,那是否有办法自定义这个任务释放时机呢?如手动释放任务,dataloader是提供配置 batchScheduleFn,允许业务自定义批量获取函数实现

function createScheduler() {
  let callbacks = [];
  return {
    schedule(callback) {
      callbacks.push(callback);
    },
    dispatch() {
      callbacks.forEach(callback => callback());
      callbacks = [];
    }
  };
}
 
 
const { schedule, dispatch } = createScheduler(); 
const myLoader = new DataLoader(myBatchFn, { batchScheduleFn: schedule }); 
 
myLoader.load(1); 
myLoader.load(2); 
dispatch(); 

这样可以将好几个 tick 任务存储起来,手动调度

2.4 实践小结

  1. 存储任务,在下一个任务周期释放任务
  1. 在请求结束后,如何处理任务。dataloadercallback 存储一个 promise 修改函数,通过在不同的场景调用resolvereject实现 。

3 场景二:React Fiber 与 scheduler 实现 UI 渲染调度

我们知道,React 中短时间多次调用 setState,在 react 底层是会将这些任务存储,等某个时间合并处理。这就是 react filberScheduler 做的事情。这里不详细介绍 fiber, 主要介绍 fiber 是如何处理任务,然后中断任务后又是如何恢复?

fiber 是为了避免渲染 React 长时间占用 JavaScript 主线程,导致页面卡断,如动画渲染,影响视觉体验。fiber 我理解的关键点就是:任务拆分,然后处理任务,一个任务处理结束后判断是继续处理任务还是将线程归还浏览器。

这里有一个思考点就是:归还之后,如何回来?如何从上次任务中断点继续开始? 下面代码能答疑解惑。

3.1 关键思考

  1. 什么时候启动任务

  2. 使用哪种方式启动宏任务

  3. 任务中断时候,如何重启

3.2 核心思路

  1. 通过 pushTask()存入任务
  1. 在某个时机通过 scheduleTask() 调度任务
  1. 通过 shouldYield()在执行完一个任务后是否需要把线程归还浏览器
  1. 判断是否还有任务未完成,在下一个调度时候接着执行

3.3 MessageChannel

其中实现宏任务,不是用 setTimeout(fn, 0),也不是 requestAnimationFrame(fn),这里的宏任务是基于浏览器的 MessageChannel 原因:setTimeout()调用会使调用间隔变为 4ms,导致浪费了 4ms,requestAnimationFrame 过分依赖浏览器渲染帧率

const { port1, port2 } = new MessageChannel() 
 
port1.onmessage = (msg) => { 
 console.log('port1 接收到信息:' + msg.data) 
} 
port2.onmessage = (msg) => { 
 console.log('port2 接收到信息:' + msg.data) 
} 
 
port1.postMessage('来自 port1 的信息') 
port2.postMessage('来自 port2 的信息') 

port1port2 可以互相通信,但一个 port 发出消息后,需要在下一个宏任务才会触发 onmessage

3.4 核心实现

class Scheduler {
  constructor() {
    this.taskQueue = []
    this.lastTask = undefined
    this.channel = new MessageChannel()
    this.channel.port1.onmessage = this.scheduleTask
  }
  pickTask() {
    return this.lastTask || this.taskQueue.shift()
  }
  pushTask(task) {
    // 1. 存入任务
    this.taskQueue.push(task)
  }
  shouldYield() {
    // 3. 由调用方调用,调用方判断是否需要暂停
    //React 内部的 isShouldYield 是判断 navigator.isInputPending
  }

  scheduleTask() {
    // 挑选一个任务并执行
    const task = pickTask()
    const continuousTask = task()

    // 如果当前任务未完成,则在下个宏任务继续执行
    if (continuousTask) {
      this.lastTask = continuousTask
      this.channel.port2.postMessage(null)
    }
  }
}

判断是否要把线程归还浏览器是在 task 中判断,task 的代码大概逻辑如下:

// 当用户点击时修改了组件状态,伪代码如下
const handleClick = () => {
  // React 组件更新时,产生任务
  const task = () => {
    const fiber = root
    while (!scheduler.shouldYield() && fiber) {
      // reconciliation() 对当前的 fiber 执行调和阶段
      // 并返回下一个 fiber
      fiber = reconciliation(fiber)
    }

    return fiber
  }

  scheduler.pushTask(task)

  // React 会在将来某个时间执行 scheduler.scheduleTask()
  //requestIdleCallback(performWork, {timeout})
  //this.channel.port2.postMessage(null)
  // 这里假设立即执行 scheduler.scheduleTask()
  scheduler.scheduleTask()
}

3.5 实践小结

  1. 任务队列没执行完毕,记录当前任务点,在下一个宏任务时候,接着执行
  1. 使用 MessageChannel 起一个宏任务做任务调度

4 总结

  1. 通过自定义任务队列,缓存异步任务/大开销运算,减少占用 js 线程
  1. 延缓任务执行,在下一次宏任务时机批量释放任务

5 附录