出处:掘金

原作者:寅时码


工具已发布到了 NPM

核心实现思路

利用事件循环机制,抓住每次事件循环的间隙,执行任务

挑选符合要求的API

  1. 微任务。如 Promise.then | MutationObserver
  2. 宏任务。如 setTimeout | setInterval | MessageChannel
  3. 动画帧前回调:requestAnimationFrame
  4. 浏览器空闲回调: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()
    ...
}