5分钟教你实现React调度器,让你执行上千万个函数也不卡顿

916 阅读4分钟

核心实现思路

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

挑选符合要求的API

  1. 微任务。如Promise.then | MutationObserver
  2. 宏任务。如setTimeout | setInterval | MessageChannel
  3. 动画帧前回调:requestAnimationFrame
  4. 浏览器空闲回调:requestIdleCallback

来,我们一个个排除

首先排除微任务,因为每次事件循环就会清空所有微任务

其次排除requestAnimationFrame,主要原因是当你把浏览器窗口收起来,他就会暂停;其次是因为各个浏览器实现略有不同

那么requestIdleCallback呢?很好,他就是最佳人选,你啥都不用干,调用它给你的函数,就知道当前有多少时间可用了。这个4.5就是当前所剩余的可用时间

image.png

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

image.png

那么最后就剩下宏任务了

setTimeout行吗?不行,因为当setTimeout嵌套超过5层执行时,它的最低延迟时间,为4ms。这是MDN原文

image.png

那么就剩下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)
        }
    }
}

PS: performance.now,是一个比Date.now更加精准的时间函数

核心基本讲完了,接下来实现包装函数

你猜到我要做什么了吗?这个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个元素试试,点击这个按钮就执行。

来看效果,秒加载,如果不用他的话,会卡死

run.gif

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()
    ...
}

注意到了吗?我的文档注释写到了返回值,这样会有悬浮提示的,如下图

image.png

后面有空教大家怎样如诗般写代码,让你甚至能拥有markdown的提示

并且显著提示代码可读性