从 EventLoop 到 Proformance ,再到 Web Work

431 阅读12分钟

背了一堆干巴巴的概念,一个都用不上,不知道他是干嘛?背了忘,忘了背,我在干嘛呀!不开心。

那今天我们体验下不一样的 EventLoop 呗!

一.基本概念

1.进程和线程

你打开chrome浏览器以后,在任务管理器里面吗,你看到只有八个进程,分别是浏览器主进程,GPU进程,插件进程,渲染进程、网络进程等这几个。 image.png

粗略来看是一个标签页对应一个渲染进程,共用一个浏览器主进程。

在渲染进程里面只有一个主线程,来完成html解析,js执行,css计算,页面布局,绘制等工作。

这也说明了,为什么存在事件循环机制的原因了,就是因为他一个人一次性只能干一件,干了这个干不了那个,要想快速干完所有的事情,就必须给那些事情指定一个规则,把他们按照优先级,排个序,然后再一个一个执行。这就是:事件循环机制(eventLoop)

其实事件循环机制你可以理解成是任务循环机制,因为它内部维护的就是一个个的task

2.任务

浏览器将它所干的事情划分成一个个的task,然后把这些 task 放到任务队列里面,一个一个执行,而这些任务在执行的时候,必须得划分优先级,所以在浏览器期初,将任务划分成了宏任务和延迟任务,其实延迟任务就是宏任务的另一种表现形式。

但是后来,由于页面变得越来越复杂,浏览器想要宏任务产生的其他任务早点执行,但是又不能放到延迟任务里面去,因为延迟任务的执行时间并不准确,一旦线程阻塞,就会出现延时问题。

所以为了解决这些冲突,就出现了promise,它相当于是在宏任务里面又维护了一个微任务队列,每个宏任务都有一个微任务队列,当宏任务执行的时候,他会把所有产生的微任务都放在这个队列里面去,当宏任务里面的事情干完之后,它才会去执行微任务队列里面的事情,直到所有微任务执行完毕,才会去执行下一个宏任务。

我们用案例来证明下面这些结论:

一个script标签,会开启一个单独的task。

一个定时器,会开启一个单独的task。

一个事件,会开启一个单独的task。

微任务不开启task,它归属于它所在的宏任务里面。

具体是不是,就看看下面这四个例子:

1.1 一个script产生一个task

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <script>
    function a (){
      let sum1 = 0
      for(let i = 0; i < 30000000; i++){
          sum1 += i
      }
      
      console.log(sum1);
      return sum1
    }
    const sum1 = a();
    console.log('window下面的', sum1)
   </script>

   <script>
    function a (){
      let sum = 0
      for(let i = 0; i < 30000000; i++){
          sum += i
      }
      
      console.log(sum);
      return sum
    }
    const sum = a();
    console.log('window下面的', sum)
   </script>
</body>
</html>

image.png

1.2 一个定时器开启一个task

比如这段代码:一个函数,一个定时器,产生2个task

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <script>
    function a (){
      let sum = 0
      for(let i = 0; i < 30000000; i++){
          sum += i
      }
      console.log(sum);
    }
    a();

    setTimeout(()=>{
      a()
    },0)    
  </script>
</body>
</html>

image.png

你看到了吗?延迟任务是宏任务,它单独开启了一个task。

1.3 一个事件开启一个task

在这个script标签里面,如果你有点击事件,都是放在一个task里面的。

比如:

  <script>
    function a (){
      let sum = 0
      for(let i = 0; i < 30000000; i++){
          sum += i
      }
      
      console.log(sum);
      return sum
    }
    const sum = a();
    console.log('window下面的', sum)

    setTimeout(()=>{
      const sum = a();
      console.log('window下setTimeout的', sum)
    },0)  

    const btn = document.getElementById("btn");

    btn.onclick = function(){
       const sum = a();
       console.log('按钮里面的', sum)
    } 
  </script>

image.png

image.png

我的代码产生了三个task,一个是全局作用下执行的a,一个是定时器里面的a,还有一个btn执行的a

1.4 点击事件产生延迟也是一个task

那如果在同步任务里面产生了延迟任务呢?

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <button id="btn">点我呀</button>
  <script>
    function a (){
      let sum = 0
      for(let i = 0; i < 30000000; i++){
          sum += i
      }
      
      console.log(sum);
      return sum
    }
    const sum = a();
    console.log('window下面的', sum)

    setTimeout(()=>{
      const sum = a();
      console.log('window下setTimeout的', sum)
    },0)  

    const btn = document.getElementById("btn");

    btn.onclick = function(){
       const sum = a();
       console.log('按钮里面的', sum)

       setTimeout(()=>{
         const sum = a();
         console.log('按钮产生的定时器的', sum)
       },0) 
       
       setTimeout(()=>{
         const sum = a();
         console.log('按钮产生的定时器的', sum)
       },12)  

    } 
  </script>
</body>
</html>


结果:

image.png

image.png

产生了五个task,也印证了一件事,就是一个setTimeout是一个task,一个事件是一个task。(不管你这个事件是直接onclick的还是用addEventListener监听的。)

1.5点击事件里面产生微任务

  const btn = document.getElementById("btn");

    btn.onclick = function(){
       const sum = a();
       console.log('按钮里面的', sum)

       setTimeout(()=>{
         const sum = a();
         console.log('按钮产生的定时器的', sum)
       },0) 
       
       setTimeout(()=>{
         const sum = a();
         console.log('按钮产生的定时器的', sum)
       },12)  

       new Promise((resolve)=>{
         const sum = a();
         console.log('按钮产生的微任务构造里面的', sum)
         resolve(sum)
       }).then((res)=>{
          const sum = a();
          console.log('按钮产生的微任务then产生的', sum)
          console.log('按钮产生的微任务then接受到的', sum)
       })

    } 

image.png

上面这段代码只产生了三个task,后面2个是延迟任务的,微任务是谁产生的就在谁的task里面。

1.6 延迟任务产生个微任务

 const btn = document.getElementById("btn");

    btn.onclick = function(){
       const sum = a();
       console.log('按钮里面的', sum)

       setTimeout(()=>{
         const sum = a();
         console.log('按钮产生的定时器的', sum)
         new Promise((resolve)=>{
           const sum = a();
           console.log('按钮产生的微任务构造里面的', sum)
           resolve(sum)
         }).then((res)=>{
            const sum = a();
            console.log('按钮产生的微任务then产生的', sum)
            console.log('按钮产生的微任务then接受到的', sum)
         })
       },0) 
       
       setTimeout(()=>{
         const sum = a();
         console.log('按钮产生的定时器的', sum)
       },12)  

       new Promise((resolve)=>{
         const sum = a();
         console.log('按钮产生的微任务构造里面的', sum)
         resolve(sum)
       }).then((res)=>{
          const sum = a();
          console.log('按钮产生的微任务then产生的', sum)
          console.log('按钮产生的微任务then接受到的', sum)
       })

image.png

这个案例也印证了,谁产生的微任务就放在谁的task里面。

如果宏任务里面产生了另一个宏任务怎么办?那就再开启一个task呗!

总结

  • 宏任务(macrotask):代表一个离散的工作单元,script,setTimeout,setInterval,setImmediate,I/O,UI-rendering(ui渲染)

  • 微任务(microtask):比宏任务更小的任务单元,promise.then(),MutationObserver。微任务会在当前宏任务代码执行完毕后,去执行他自己的微任务,只有它自己的微任务全部执行完毕以后,才会开启下一个宏任务。

二.performance如何监测性能

单独学习什么EventLoop肯定越学越蒙,那借助performance呢,你都不用再背诵概念了吧!真的起到了事半功倍的效果。

1.performance介绍

打开开发者工具,进入performance,点击reload录制

image.png

其中 Main 这部分就是网页的主线程,也就是执行 Event Loop 的部分:

image.png 鼠标划到想看的部分,滚动鼠标,或者向下拖动,就可以放大那个区域:

image.png

我们把这个花花绿绿的图叫做火焰图,不过他是向下燃烧的。

在这里我们只需要记住它的几个颜色代表就好了,如果你记不住,也没有关系,点击图会有介绍,比如下面

image.png

但是为了你能快速分辨出来它到底是谁的task,我劝你还是记一下下面这个总结,因为你看到的大部分线条都是浏览器自己的task,不是你自己的task

灰色就代表宏任务 task

橙色的是浏览器内部的 JS

蓝色的是 html 的 parse

紫色是样式的 reflow、repaint

绿色的部分就是渲染

其余的颜色都是用户 JS 的执行了

react的调用栈

image.png

至于宏任务微任务是怎么执行的,在任务里面我就说的很清楚了,不再赘述。

2.优化目标长任务

其实上面已经说清楚了task,也简单的了解了proformance下面的task,现在我们就看看proformance怎么做性能优化?

为什么要做性能分析?不就是因为页面卡么!

那么多代码,我咋知道是谁在卡,总不可能打个console.log(11)就把页面卡死了吧,所以说要想看数据呀,就要看看谁的task有异常,那么什么样子的task有异常的?

image.png

上面这个task,是不是异常了,人家都给你标红了,熟悉不?那我们就点开看看它的特性吧

image.png

它明确的告诉你这是一个长任务,一般显示器的刷屏频率是16.7HZ,也就是每60ms刷新一次,如果你的task执行时间超过50ms,我们就定义为你这个任务是长任务

说人话就是本来我60ms刷新一次,页面就要变动了,但是你还没有执行完,我不得等你,我就等呀等,页面白呀白,刷了也没有东西显示,所以就白屏呗。

现在优化目标已经明确了,就是找长任务,那找到长任务以后,我怎么知道它到底哪里耗时了?

image.png

点这个文件你就可以看到具体哪个文件在作妖。

image.png

三.优化手段Web Worker

文档https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Workers_API/Using_web_workers

1.基础说明

Web Worker是在后台运行的脚本,不会影响用户界面,因为它在单独的线程中运行,而不是在主线程中。

之前就说了,渲染进程里面只有一个线程,干所有渲染相关的事情,现在我们用new Worker开启另外一个线程,干我们自己定义的事情。你可能说了,既然能定义线程,为啥渲染线程还是单线程的,咋不多搞几个,不就快了么!想法很美好,或许未来可以实现呀!

它限制肯定是有各种原因的,因为当你开启worker的时候,他也是一对一的,一次只能开一个,用完以后立即销毁。限制还是比较大的。

由于我们在项目开发时,使用不同的打包工具(vite/webpack)。幸运的是,最新版的vite/webpack都支持Web Worker了。

文档如下:

2.webpack/vite的支持

我们可以用new URL()的方式 --vite/webpack都支持

new Worker(
  new URL(
    './worker.js', 
    import.meta.url
  )
);

也可以用import 方式 只有vite支持

import MyWorker from './worker?worker' 
const worker = new MyWorker()

3.基本使用

在index.html里面引入new Worker(),但是一定要注意的是:new Worker的入参是一个url,且一定是一个url。

相当于就是两个文件,我创建worker实例以后,用postMessage发送出去一个消息,然后对应的文件用 addEventListener('message',()=》{})监听到消息,监听到以后,他会做具体的处理,处理完以后,再用postMessage发送出来处理结果,这边worker接受,然后做其他处理,弄完以后销毁worker。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title> worker performance optimization</title>
</head>
<body>
    <script>
        console.log('线程之前')
        const worker= new Worker('./work.js');
        worker.postMessage(200);
        worker.addEventListener('message', function (evt) {
            console.log(evt.data)
        });
        worker.onerror = ()=>{
          console.log('error')
        };
        worker.terminate()//销毁
    </script>
</body>
</html>

worker.js

addEventListener('message', function (evt) {
  let total = 0;
  let num = evt.data;
  for (let i = 0; i < num; i++) {
    total += i;
  }
  postMessage(total);
}); 

4. Blob 方式

其实也是上面的变体,之前不是说Worker的构造函数的参数一定是个url么,如果我要在本文件里面使用work呢?也就是new Worker和他的执行提都在一个文件里面,咋办?

其实,我们要知道一个道理,内存里面的东西都是二进制,二进制就有对应的内存地址,你还记得下载文件吗?先把文件存在浏览器对应的内存里,然后用window.URL.createObjectURL()拿到内存地址,然后再用img的src=url展示出来。

其实,现在也一样,我们可以把执行函数所在的内存地址拿到,然后把这个地址丢给new Worker(),这样线程就建立好了。

// 定义要在Worker中执行的脚本内容
  const workerScript = `
    self.onmessage = function(e) {
      console.log('来自主线程的消息: ' + e.data);
      self.postMessage('向主线程发送消息: ' + 'Hello, ' + e.data);
    };
  `;

  // 创建一个Blob对象,指定脚本内容和类型
  const blob = new Blob(
    [workerScript], 
    { type: 'application/javascript' }
  );

  // 使用URL.createObjectURL()方法创建一个URL,用于生成Worker
  const blobURL = URL.createObjectURL(blob);

  // 生成一个新的Worker
  const worker = new Worker(blobURL);

  // 监听来自Worker的消息
  worker.onmessage = function(e) {
    console.log('来自worker的消息: ' + e.data);
  };

  // 向Worker发送消息
  worker.postMessage('Front789');

你觉得写到字符串末班里面太烦人了,你也可以把他写到script标签里面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Web Worker</title>
</head>
<body>
    <script id="worker" type="app/worker">
        console.log(self);

        self.onmessage = function(event) {
            console.log('子线程收到消息:', event.data);

            self.postMessage('get✔');
        }
        self.onerror = function (err) {
            console.log('子线程异常:', err);
        }

        throw new Error('test error');
    </script>
    <script>
        let blob = new Blob([document.querySelector('#worker').textContent]);
        let url = window.URL.createObjectURL(blob);
        let worker = new Worker(url);

        worker.onmessage = function(event) {
            console.log('主线程收到消息:', event.data);
        }
        worker.onerror = function (err) {
            console.log('主线程收到子线程异常:', err);
        }

        worker.postMessage('Hello World');
    </script>
</body>
</html>

type是个乱七八糟的东西,浏览器不认识,所以不解析,其实内核都一样的,一个发消息,一个处理,处理完之后再把结果返回给他就ok了。

5.在vue里面使用

在/src/worker.js

// 
/**
 * 计算斐波那契数
 * @param {number} n 
 * @returns {number} 返回第 n 个斐波那契数
 */
function fib(n) { 
    let arr = [0,1];
    for (let i = 2; i <= n; i++) {
        arr[i] = arr[i-1] + arr[i-2];
    }
    return arr[n];
}

self.onmessage = function (e) {
    let n = e.data;
    self.postMessage(fib(n));
    self.close();
}

在组件里面

image.png

如果你的react项目是vite搭建的,所以你完全可以使用上面这种做法。如果你是webpack的,那你得试试原始的办法了。

6.在react里面使用worker

如果你在react的webpack项目里面,建立一个work.js,然后直接new Worker('./worker.js');就会报错

image.png

报错的主要原因是react文件会编译,编译打包以后,地址都会发生变化,除非你在打包的时候能够把work.js单独给分出来。

正确使用如下,先创建一个worker.js

const workerCode = () => {
  onmessage = (e) => {
    // 打印从主线程发送的消息
    console.log(e.data);
    postMessage('woker收到回复');
  };
};
export default workerCode;

在组件里面

import React, { useEffect } from 'react';
import workerCode from './worker'; // 导入 Worker 脚本

const APP: React.FC = () => {

  useEffect(() => {
    const workerUrl = URL.createObjectURL(
      new Blob([`(${workerCode})()`], { type: 'application/javascript' }),
    );

    const myWorker = new Worker(workerUrl);
    myWorker.postMessage('主线程发送信息');
    myWorker.onmessage = function (event) {
      // 接收 Worker 发送的消息
      console.log(event.data);
    };

    return () => {
      myWorker.terminate();
      URL.revokeObjectURL(workerUrl);
    };
  }, []);

  return <div>React组件内容</div>;
};

export default APP;

你会说,弄了半天,这不是bolb使用法么,是的,他就是的!

7. @koale/useWorker

useWorker是一个轻量级的React Hooks库,专为充分利用Web Workers而设计。它封装了一个简单的API,允许开发者在React组件中无缝地使用Web Workers处理后台任务。只需几行代码,即可实现复杂的计算任务,而不会影响用户的交互体验。

优点:

  • 无UI阻塞:通过Web Worker,在后台执行耗时任务,确保UI流畅。
  • Promise 风格:使用Promise进行通讯,使得异步编程更直观。
  • 轻量级:体积小于3KB,对项目影响极小。
  • TypeScript 支持:提供类型定义,增强开发体验。
  • 自动管理:自动创建和销毁Web Worker,避免资源浪费。
  • 远程依赖支持:可以加载外部脚本到Web Worker中使用。
  • 超时控制:可设定任务执行超时选项。
npm install --save @koale/useworker

使用

import React from 'react';
import { useWorker } from '@koale/useworker';

const numbers = [...Array(5000)].map(() => (Math.random() * 1000));
const sortNumbers = nums => nums.sort();

const Example = () => {
  const [sortWorker] = useWorker(sortNumbers);
  const runSort = async () => {
    const result = await sortWorker(numbers);
    console.log(result);
  };
  return (
    <button type='button' onClick={runSort}>
      Run Sort
    </button>
  );
};

export default Example;

image.png

用起来好优雅呀,是不是?

8.自己封装

靠天靠地不如靠自己,不管怎么样,其实还得自己有货才行,对不对?

hook/useWorker.jsx

import React from 'react';

const createWorkerCode = (code) => {
  return (`
      onmessage = async (e) => {
        const __worker_run = ${code}
        const __worker_result = await __worker_run(e.data);
        postMessage(__worker_result);
      };
  `);
};


const useWorker = (code, config = {}) => {
  const {
    params,
    closeWorkerOnUnmount = true, // hooks卸载时是否结束worker
    manual = false, // 是否手动执行worker
  } = config;
  const [data, setData] = React.useState();
  const content = createWorkerCode(code);
  console.log(content, 555);
  const url = URL.createObjectURL(new Blob([content], { type: 'text/javascript' }));
  const worker = new Worker(url);

  React.useEffect(() => {

    worker.onmessage = (e) => { // 监听worker接收事件,然后更新result
      setData(e.data);
    };

    return () => {
      closeWorkerOnUnmount && worker.terminate(); // hooks卸载时是否结束worker
    };
  }, [code]);

  React.useEffect(() => {
    !manual && worker.postMessage(params); // 执行worker文件并给worker传参
  }, [JSON.stringify(params)]);

  // 手动执行worker逻辑
  const run = (p) => {
    worker.postMessage(p);
  };

  return {
    data, // worker执行的结果
    run, // 手动执行worker
    worker, // worker实例
  };
};

export default useWorker;

使用

import React from 'react';
import useWorker from '../hooks/useWorker';

const workerCode = (nums) => {
  return nums.sort();
};


const numbers = [...Array(5000)].map(() => (Math.random() * 1000));

const Worker = () => {
  const { data, run } = useWorker(workerCode, { manual: true });
  console.log('排序结果:', data);

  return (
    <button onClick={() => run(numbers)}>大数据排序</button>
  );
};

export default Worker;

其实说来说去就是在封装blob方式