核心实现思路
利用事件循环机制,抓住每次事件循环的间隙,执行任务
挑选符合要求的API
- 微任务。如
Promise.then
|MutationObserver
- 宏任务。如
setTimeout
|setInterval
|MessageChannel
- 动画帧前回调:
requestAnimationFrame
- 浏览器空闲回调:
requestIdleCallback
来,我们一个个排除
首先排除微任务 ,因为每次事件循环就会清空所有微任务
其次排除requestAnimationFrame,主要原因是当你把浏览器窗口收起来,他就会暂停;其次是因为各个浏览器实现略有不同
那么requestIdleCallback 呢?很好,他就是最佳人选,你啥都不用干,调用它给你的函数,就知道当前有多少时间可用了。这个4.5就是当前所剩余的可用时间
来看看兼容性呢?看来用不得。Safari总是低人一等
那么最后就剩下宏任务了
setTimeout 行吗?不行,因为当setTimeout 嵌套超过5 层执行时,它的最低延迟时间,为4ms 。这是MDN
原文
那么就剩下MessageChannel了
实现思路
首先你给我任务数组,再来个任务完成的回调吧,函数签名如下
ts
/**
* 类似`React`调度器 在浏览器空闲时 用`MessageChannel`调度任务
* @param taskArr 任务数组
* @param onEnd 任务完成的回调
*/
export declare function scheduleTask(taskArr: Function[], onEnd?: Function): void;
把你的任务,放入宏任务,并根据当前是否有剩余时间决定是否执行
这个剩余时间怎么定义呢?
首先明确一点,大多数设备是60帧的,也就是浏览器会刷新60次,1秒等于1000毫秒
那么就可以定义一个常量,来规定你是否有时间
ts
/** 一帧 一眼盯帧 */
const TICK = 1000 / 60
接下来把你的任务放入宏任务队列里执行,伪代码如下,后面再写完整版,这里方便理解
这里的port1
,只要发送信息,那么port2
就会执行,并且是在宏任务里
ts
const { port1, port2 } = new MessageChannel()
port2.onmessage = () => {
// 运行你的一个个任务
}
/** 开始调度 */
port1.postMessage(null)
怎么判断当前是否有时间
如何判断当前是否有时间,循环吗?
ts
port2.onmessage = () => {
while (我有时间) {
// ... 执行任务
}
}
显然你这里想破脑袋也无法实现,必须有一个函数告诉你,就像requestIdleCallback
那样
那我就写个回调函数呗,我只要调用hasIdle
,就知道是否可执行
这说明什么?说明我必须被一个函数包一层,让他调用,并且我把我现在的时间st
给他
ts
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
函数的参数类型是不是很熟悉,没错,他就是上面那个函数
ts
/** 放入宏任务执行 并回调***执行时间和开始时间的差值*** */
function runMacroTasks(hasIdleRunTask: (hasIdle: HasIdle) => void) {
hasIdleRunTask((st) => performance.now() - st < TICK)
}
诶,那我就包装一下。这样是不是就能知道当前的时间了。
包装函数runMacroTasks
调用我要执行函数的hasIdle
,并利用它的开始时间参数,返回剩余时间是否小于一帧
ts
runMacroTasks(hasIdleRunTask)
OK,接下来就是放入微任务了,那就写个开始执行函数吧。包装成函数是为了语义化,以及方便管理,马上你就能看到好处
ts
function start() {
if (i >= taskArr.length) {
onEnd?.()
}
else {
port1.postMessage(null)
}
}
那我们一开始就可以订阅消息,然后执行了
ts
port2.onmessage = () => {
runMacroTasks(hasIdleRunTask)
}
start()
这里调用一次start
够吗?万一你任务很多没执行完呢?
所以我要在一个关键时刻继续调用start
,那就是任务执行完后,因为start
函数做了跳出函数判断,所以不会栈溢出
ts
port2.onmessage = () => {
runMacroTasks(hasIdleRunTask)
start()
}
start()
这里包装成函数的好处就非常明显,你如果不用函数,你是无法递归的
至此已经完成,下面有完整代码。
下面我写个测试代码,光写不测不行,我就一次性创建1000000个元素试试,点击这个按钮就执行。
来看效果,秒加载,如果不用他的话,会卡死
ts
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
还有可以改进的地方,那就是加个参数,用来停止执行
这个参数必须是函数,我才能实时知道是否要停止
ts
export function scheduleTask(taskArr: Function[], onEnd?: Function, needStop?: () => boolean) {
// 在判断条件里 改成
if (i >= taskArr.length || needStop?.())
}
这可是个可选参数,那要是他不传给我,我每次还得判断一下,是不是太浪费性能了?
这可是要执行上千万次的,于是就可以使用惰性函数,来仅在初始时判断一下条件
ts
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
}
这样我们就能通过调用这个函数,拿到两个函数用于执行了
ts
/**
* 类似`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()
...
}
注意到了吗?我的文档注释写到了返回值,这样会有悬浮提示的,如下图
后面有空教大家怎样如诗般写代码,让你甚至能拥有markdown的提示
并且显著提示代码可读性