出处:掘金
原作者:寅时码
工具已发布到了 NPM
核心实现思路
利用事件循环机制,抓住每次事件循环的间隙,执行任务
挑选符合要求的API
- 微任务。如
Promise.then|MutationObserver - 宏任务。如
setTimeout|setInterval|MessageChannel - 动画帧前回调:
requestAnimationFrame - 浏览器空闲回调:
requestIdleCallback
来,我们一个个排除
首先排除微任务,因为每次事件循环就会清空所有微任务
其次排除 requestAnimationFrame,主要原因是当你把浏览器窗口收起来,他就会暂停;其次是因为各个浏览器实现略有不同
那么 requestIdleCallback 呢?很好,他就是最佳人选,你啥都不用干,调用它给你的函数,就知道当前有多少时间可用了。这个 4.5 就是当前所剩余的可用时间

来看看兼容性呢?看来用不得。Safari 总是低人一等

那么最后就剩下宏任务了
setTimeout 行吗?不行,因为当 setTimeout 嵌套超过 5 层执行时,它的最低延迟时间为 4ms
那么就剩下 MessageChannel 了
实现思路
首先你给我任务数组,再来个任务完成的回调吧,函数签名如下:
/**
* 类似 React 调度器,在浏览器空闲时,用 MessageChannel 调度任务
* @param taskArr 任务数组
* @param onEnd 任务完成的回调
*/
export declare function scheduleTask(taskArr: Function[], onEnd?: Function): void;把你的任务,放入宏任务,并根据当前是否有剩余时间决定是否执行
这个剩余时间怎么定义呢?
首先明确一点,大多数设备是 60 帧的,也就是浏览器会刷新 60 次,1 秒等于 1000 毫秒
那么就可以定义一个常量,来规定否有时间
/** 一帧 */
const TICK = 1000 / 60接下来把任务放入宏任务队列里执行,伪代码如下(后面再写完整版,这里方便理解)
这里的 port1,只要发送信息,那么 port2 就会执行,并且是在宏任务里
const { port1, port2 } = new MessageChannel()
port2.onmessage = () => {
// 运行你的一个个任务
}
/** 开始调度 */
port1.postMessage(null)怎么判断当前是否有时间
如何判断当前是否有时间,循环吗?
port2.onmessage = () => {
while (我有时间) {
// ... 执行任务
}
}显然你这里想破脑袋也无法实现,必须有一个函数告诉你,就像 requestIdleCallback 那样
那我就写个回调函数呗,我只要调用 hasIdle,就知道是否可执行
这说明什么?说明我必须被一个函数包一层,让他调用,并且我把我现在的时间 st 给他
type HasIdle = (st: number) => boolean
function hasIdleRunTask(hasIdle: HasIdle) {
const st = performance.now()
while (hasIdle(st)) {
if (i >= taskArr.length) return
try {
taskArr[i++]()
} catch (error) {
console.warn(`第${i}个任务执行失败`, error)
}
}
}核心基本讲完了,接下来实现包装函数
你猜到我要做什么了吗?这个 hasIdleRunTask 函数的参数类型是不是很熟悉,没错,他就是上面那个函数
/** 放入宏任务执行 并回调执行时间和开始时间的差值 */
function runMacroTasks(hasIdleRunTask: (hasIdle: HasIdle) => void) {
hasIdleRunTask((st) => performance.now() - st < TICK)
}诶,那我就包装一下。这样是不是就能知道当前的时间了
包装函数 runMacroTasks 调用我要执行函数的 hasIdle,并利用它的开始时间参数,返回剩余时间是否小于一帧
runMacroTasks(hasIdleRunTask)OK,接下来就是放入微任务了,那就写个开始执行函数吧。包装成函数是为了语义化,以及方便管理,马上你就能看到好处
function start() {
if (i >= taskArr.length) {
onEnd?.()
} else {
port1.postMessage(null)
}
}那我们一开始就可以订阅消息,然后执行了
port2.onmessage = () => {
runMacroTasks(hasIdleRunTask)
}
start()这里调用一次 start 够吗?万一你任务很多没执行完呢?
所以我要在一个关键时刻继续调用 start,那就是任务执行完后,因为 start 函数做了跳出函数判断,所以不会栈溢出
port2.onmessage = () => {
runMacroTasks(hasIdleRunTask)
start()
}
start()这里包装成函数的好处就非常明显,如果不用函数是无法递归的
至此已经完成,下面有完整代码。
测试
一次性创建 1000000 个元素试试,点击这个按钮就执行
来看效果,秒加载,不使用调度器会卡死
const
reactBtn = document.createElement('button'),
taskArr = Array.from({ length: 1000000 }).map((_, i) => genTask(i + 1)),
onEnd = () => console.log('end')
reactBtn.textContent = 'React任务调度器方式执行'
document.body.appendChild(reactBtn)
reactBtn.onclick = () => {
scheduleTask(taskArr, onEnd)
}
function genTask(item: number) {
return () => {
const el = document.createElement('div')
el.textContent = item + ''
document.body.appendChild(el)
}
}
/** 一帧 */
const TICK = 1000 / 60
/**
* 类似`React`调度器 在浏览器空闲时 用`MessageChannel`调度任务
* @param taskArr 任务数组
* @param onEnd 任务完成的回调
* @param needStop 是否停止任务
*/
export function scheduleTask(taskArr: Function[], onEnd?: Function, needStop?: () => boolean) {
let i = 0
const { port1, port2 } = new MessageChannel()
port2.onmessage = () => {
runMacroTasks(hasIdleRunTask)
start()
}
start()
function start() {
if (i >= taskArr.length) {
onEnd?.()
}
else {
port1.postMessage(null)
}
}
function hasIdleRunTask(hasIdle: HasIdle) {
const st = performance.now()
while (hasIdle(st)) {
if (i >= taskArr.length) return
try {
taskArr[i++]()
}
catch (error) {
console.warn(`第${i}个任务执行失败`, error)
}
}
}
/** 放入宏任务执行 并回调***执行时间和开始时间的差值*** */
function runMacroTasks(hasIdleRunTask: (hasIdle: HasIdle) => void) {
hasIdleRunTask((st) => performance.now() - st < TICK)
}
}
type HasIdle = (st: number) => boolean还有可以改进的地方,那就是加个参数,用来停止执行
这个参数必须是函数,才能实时知道是否要停止
export function scheduleTask(taskArr: Function[], onEnd?: Function, needStop?: () => boolean) {
// 在判断条件里 改成
if (i >= taskArr.length || needStop?.())
}这可是个可选参数,那要是他不传给我,我每次还得判断一下,是不是太浪费性能了?
这可是要执行上千万次的,于是就可以使用惰性函数,来仅在初始时判断一下条件
function genFunc() {
const isEnd = needStop
? () => i >= taskArr.length || needStop()
: () => i >= taskArr.length
function start() {
if (isEnd()) {
onEnd?.()
}
else {
port1.postMessage(null)
}
}
function hasIdleRunTask(hasIdle: HasIdle) {
const st = performance.now()
while (hasIdle(st)) {
if (isEnd()) return
try {
taskArr[i++]()
}
catch (error) {
console.warn(`第${i}个任务执行失败`, error)
}
}
}
return {
/** 开始调度 */
start,
/** 空闲时执行 */
hasIdleRunTask
}这样就能通过调用这个函数,拿到两个函数用于执行了
/**
* 类似`React`调度器 在浏览器空闲时 用`MessageChannel`调度任务
* @param taskArr 任务数组
* @param onEnd 任务完成的回调
* @param needStop 是否停止任务
*/
export function scheduleTask(taskArr: Function[], onEnd?: Function, needStop?: () => boolean) {
let i = 0
const { start, hasIdleRunTask } = genFunc()
const { port1, port2 } = new MessageChannel()
port2.onmessage = () => {
runMacroTasks(hasIdleRunTask)
start()
}
start()
...
}