微任务和宏任务可以做什么

1,390 阅读4分钟

看了神光大大 @zxg_神说要有光 的文章 juejin.cn/post/715535… ,使用 Performance 工具深入理解事件循环。这真是个好办法,看 spec 看半天不如看一眼 performance 的 trace!但问题来了,我知道这玩意是这个顺序,面试题也背了一堆,但有什么用?

举个例子

我们有这么一段代码

  const sleep = (ms) => {
    let s = Date.now();
    while (Date.now() - s < ms) {
    }
  };
  const doit = () => {
    sleep(1000);
    Promise.resolve().then(() => {
      sleep(1000);
    });
    setTimeout(() => {
      sleep(1000);
    });
    requestAnimationFrame(() => {
      sleep(1000);
    });
  };

使用 performance 工具查看运行过程,他长这样:

ok,那我们开始

微任务

新建一个微任务很简单:

Promise.resolve().then(() => {
    // 微任务在此运行
});

从图中可以看出,“当前任务”运行结束,紧接微任务就会执行。

那么可以用来干嘛呢?

可见/可交互耗时统计。 lighthouse 只能给你一个大概的参考时间,无法用来做稳定的线上指标追踪。比如你想统计 TTI,那么数据回来后, React 渲染结束,此时就可以认为是 TTI。那么用微任务就很好,刚好是这个时间点,比如:

function main() {
    const [ data, setData ] = useState([]);
    useEffect(() => {
        request().then((data) => {
            setData(data);
            Promise.resolve().then(() => {
                reportTTI(performance.now());
            });
        })
    });
    return (
        <ul>
            {...data.map(e => <li>{e}</li>}
        </ul>
    );
}

坏消息是,这样 hack 了 react 的实现,这默认 setData 不会被分到多个任务中执行。但 react 不会天天升级,这样大体还是够用的。

屎山代码 bug 修复: 不再维护的屎山代码的 bug 修复,时序逻辑复杂得过了分,你需要保住自己的工作。

宏任务

// 宏任务在此运行
function nextTick(callback) {
    setTimeout(() => {
        callback();
    });
}

// 另外一种新建方式
// 比 setTimeout 好,因为递归调用 setTimeout 会间隔越来越长。 
function nextTick(callback) {
    const { port1, port2 } = new MessageChannel();
    port1.onmessage = callback;
    port2.postMessage();
}

Note: 仅为了演示,省略了一堆细节。

这个比较常见,很多人会使用这个来做任务分片。比如 react-scheduler,让出主进程的时间,将 cpu 留给绘制,比如这篇:zhuanlan.zhihu.com/p/570318956

这里介绍一个更有意思的玩法,优化首屏。

举一个场景,你刚接手一个项目,发现首屏很慢,你发现埋点上报的耗时很长,举个例子:

function firstScreen() {
    task1();
    collectData();
    task2();
    collectData();
    task3();
}

我们看一下 performance

哇,首屏一共 2.5s,两个埋点上报就要 100ms * 2,这怎么忍?

好消息是,咨询了一波,大家都认为埋点的实时性不重要,那么我们有办法让 collectData 不在首屏执行吗?我们改下代码:

function firstScreen() {
    task1();
    nextTick(collectData);
    task2();
    nextTick(collectData);
    task3();
}

也就是说,我们让 collectData 在下一个宏任务内执行。 再看看 performance。

优化前:

优化后:

哈,collectData 被成功挪到了首屏后!而且只要 10 分钟搞定。

一种特殊的调度方式 requestAnimationFrame

其实不太推荐用这种方法调度,因为 requestAnimationFrame 有个致命缺点 --- 如果页面不可见,requestAnimationFrame 的回调函数也不会执行。 spec 是这么说的:paint 之前执行 requestAnimationFrame,这也意味着 requestAnimationFrame 什么时候触发全看浏览器实现。

如果你需要编排 requestAnimationFrame 和宏任务的时序,这意味着噩梦:

举个 bad case:

function howLoopWork(ms) {
    const s = Date.now();
    requestAnimationFrame(() => {
        console.log(2);
    });
    while(Date.now() - s < ms) {};
    setTimeout(() => {
        console.log(1);
    });
}

howLoopWork(10); // log: 1 2
// 等一会再执行
howLoopWork(100); // log: 2 1

你以为这就结束了?我们就变个位置:

function howLoopWork(ms) {
    const s = Date.now();
    while(Date.now() - s < ms) {};
    setTimeout(() => {
        console.log(1);
    });
    requestAnimationFrame(() => {
        console.log(2);
    });
}

howLoopWork(10); // log: 1 2
// 等一会再执行
howLoopWork(100); // log: 1 2 

那 requestAnimationFrame 除了做动画和绘制前改 DOM 就没卵用了吗?

我们再举几个 requestAnimationFrame 的神秘表现:

function howLoopWork() {
    requestAnimationFrame(() => console.log(1));
    requestAnimationFrame(() => console.log(2));
    setTimeout(() => {
        const s = Date.now();
        console.log(3);
        while(Date.now() - s < 10) {};
    });
}
howLoopWork(); // 1 2 3

这次恭喜你,结果必然是 1 2,在一个任务内执行多个 requestAnimationFrame,那么他会在收益 paint 时按序触发。

那么再改一下:

function howLoopWork() {
    requestAnimationFrame(() => {
        console.log(1);
        requestAnimationFrame(() => console.log(2)); // 第 4 行
    });
    
    setTimeout(() => {
        const s = Date.now();
        while(Date.now() - s < 10) {};
    });
}
howLoopWork(); // 1 3 2

哈哈,这次有点不同,为什么呢?

和微任务不一样,在 callback 内嵌套调用 requestAnimationFrame,也就是第 4 行,嵌套的 callback 会在下一次 paint 执行! 这么说有点抽象,我们看看 performance:

好吧,知道了又怎么样。可以监控首屏啊。

我们知道一次任务的流程大概是:

当前任务执行 -> requestAnimationFrame -> paint

那么无论是微任务还是 requestAnimationFrame 都无法监控到 paint 阶段的耗时,这么说嵌套 requestAnimationFrame 就可以,但问题也很明显:

  • 页面不可见时凉凉,间隔会变得异常长
  • 中间宏任务插入过多也凉,下次 paint 被延后许多

我们实践了一段时间后果断放弃了这种做法,乖乖使用微任务的方式,因为我们的 paint 耗时极短。

这个章节就权当给大家介绍了一个有趣的技术游戏。

再来一道面试题

function howLoopWork() {
    let a = 0;

    setTimeout(() => {
        a = 1;
        setTimeout(() => {
            console.log('4', a); 
        });
    });
    Promise.resolve().then(() => {
        console.log('1', a);
        Promise.resolve().then(() => {
            const s = Date.now();
            while(Date.now() - s < 16) {};
            console.log('2', a);
        });
    });
    requestAnimationFrame(() => {
        console.log('3', a);
        requestAnimationFrame(() => {
            console.log('5', a);
        });
    });
    console.log('0', a);
}

howLoopWork();

答案:

你掌握了吗~

Note:以上均是我的日常工作总结,欢迎加我好友,一起玩啊