js这绕绕的事件机制

593 阅读5分钟

众所周知,js是单线程,负责页面绘制和用户事件等,即我们通常理解的UI线程,对于页面的绘制,只能在一个线程上更新,这是共识,不然同时有多个线程更新UI,这是不可理解的。虽然android的UI线程也是如此,但是android是支持多线程的,而js就只有单线程,这意味着我们同一时间只能做一件事情,虽说有异步任务,但那也只是把执行时间延后罢了。

当然后面为了适应时代发展,js通过web worker支持了多线程能力,这有一个例子

下面是一些概念:

同步任务

JS 主线程里面立即被推入执行栈且可以被执行的函数。在主线程上排队执行的任务,只有前一个执行完毕,才能执行后一个,代码是阻塞的,顺序执行。要注意的是,click,dispatchEvent等人工合成事件是同步任务,同步调用事件处理程序。可以参考以下链接:

// 下面输出为
// on click
// end

const App = () => {
  return (
    <div  ref={(ref) => {
      if(ref) {
        ref.click()
        console.log('end')
      }
    }} onClick={() =>{
      console.log('on click')
    }}>
    </div>
  );
}

异步任务

如果一个函数在调用之后 不能马上得到预期结果 那么就是异步任务,任务是非阻塞的。

每次执行异步任务,就会将任务放进对应的任务队列。

  • setTimeout setInterval
  • promise
  • dom事件
  • 网络请求
  • ...

事件循环

js的主线程通过等待任务队列,执行任务源源不断地处理用户事件和页面绘制,每次事件循环称为一次tick,包括从任务队列取出任务执行,清空微任务队列,页面重绘(不一定执行)。

宏任务(浏览器发起的)

执行宏任务进入宏任务队列

  • I/O
  • dom事件
  • setTimeout setInterval(web api) 浏览器有个定时器模块,定时器到了执行时间才会把异步任务放到异步队列,setTimeOut setInterval的延时就是指多少时间后回调函数会放入任务队列

微任务(js引擎发起的)

执行微任务会进入当前任务的微任务队列,在下一个事件到来之前会被清空执行。微任务的好处就是优先级高, 但是如果反复执行微任务,会造成下一个事件的处理延后。

  • Promise.then .catch
  • MutationObserver
  • queueMicrotask 把函数当成微任务入队

requestAnimationFrame

requestAnimationFrame也属于异步任务,但是它比较特殊,既不属于宏也不属于微,它是在event下次重绘之前调用,也就是晚于微任务,早于下一次事件。具体可以看这个。但是每个eventloop不一定会进行重绘,所以在不同浏览器中,requestAnimationFrame的执行时机不太一样,这是重绘时机的规范

任务队列

有一个演示效果的demo latentflip.com/loupe

pic2.zhimg.com/v2-edca0b28…

(图片来源zhuanlan.zhihu.com/p/105903652

例子

代码在这

模拟事件机制


  console.log("--- task start ---");

  setTimeout(() => {
    console.log("macro task 1");
  }, 0);

  console.log("wait countdown");

  new Promise((resolved, reject) => {
    let time = Date.now();
    console.log("countdown 500ms start");
    while (Date.now() - time < 500) {}
    console.log("countdown end");
    resolved('success')
  })
  .then((e) => {
      console.log("micro task 1");
  })

  console.log("wait end");

  queueMicrotask(() => {
    console.log("micro task 2");
  });

  queueMicrotask(() => {
    console.log("micro task 3");
    console.log("---micro task end---");
    console.log("---macro task start---");
  });

  setTimeout(() => {
    console.log("macro task 2");
    console.log("---macro task end---");
  }, 0);

  console.log("---task end---");
  console.log("---micro task start---");
输出结果如下:

--- task start --- 
wait countdown 
countdown 500ms start 
countdown end 
wait end 
---task end--- 
---micro task start--- 
micro task 1 
micro task 2 
micro task 3 
---micro task end--- 
---macro task start--- 
macro task 1 
macro task 2 
---macro task end---

// 可以看出promise.then是微任务 微任务执行顺序和入队顺序一致
// promise里是同步任务 进入执行栈执行
// setTimeout是宏任务  执行顺序和入队顺序一致 晚于微任务执行

模拟dom事件

import "./styles.css";

const mockEventLoop = () => {
  console.log("--- task start ---");

  setTimeout(() => {
    console.log("macro task 1");
  }, 0);

  console.log("wait countdown");

  new Promise((resolved, reject) => {
    let time = Date.now();
    console.log("countdown 500ms start");
    while (Date.now() - time < 500) {}
    console.log("countdown end");
    resolved("success");
  }).then((e) => {
    console.log("micro task 1");
  });

  console.log("wait end");

  queueMicrotask(() => {
    console.log("micro task 2");
  });

  queueMicrotask(() => {
    console.log("micro task 3");
    console.log("---micro task end---");
    console.log("---macro task start---");
  });

  setTimeout(() => {
    console.log("macro task 2");
    console.log("---macro task end---");
  }, 0);

  console.log("---task end---");
  console.log("---micro task start---");
};

const onClick = (type: string, event: string) => {
  console.log(`${type}`, `on ${event} click `);
  Promise.resolve().then((e) => {
    console.log(`${type}`, `${event} click micro task `);
  });
  setTimeout(() => {
    console.log(`${type}`, `${event} click macro task `);
  }, 0);
};

export default function App() {
  return (
    <div className="App">
      <h2 onClick={mockEventLoop}>点击模拟事件机制</h2>
      <h2>查看点击事件的过程</h2>
      <div
        ref={(ref) => {
          if (ref) {
            ref.addEventListener("click", (e) => {
              onClick("parent", "native");
            });
          }
        }}
        className="Parent"
        onClick={(e) => {
          onClick("parent", "synthetic");
        }}
      >
        <div
          className="Child"
          ref={(ref) => {
            if (ref) {
              ref.addEventListener("click", (e) => {
                onClick("child ", "native");
              });
              setTimeout(() => {
                console.log("--auto click --");
                ref.click();
              }, 1000);
            }
          }}
          onClick={(e) => {
            onClick("child ", "synthetic");
          }}
        >
          点我
        </div>
      </div>
    </div>
  );
}
页面加载成功结果如下:
--auto click -- 
child  native click  
parent native click  
child  synthetic click  
parent synthetic click  
child  native click micro task  
parent native click micro task  
child  synthetic click micro task  
parent synthetic click micro task  
child  native click macro task  
parent native click macro task  
child  synthetic click macro task  
parent synthetic click macro task

// onClick分两类 原生事件和react的合成事件
// 主动进行事件的合成和分发,这时候原生事件和合成事件的onClick是作为同步事件进入执行栈,
// onClick在同一个事件中,所以会先输出click 再输出微任务 宏任务

点击按钮之后输出结果如下:
child  native click  
child  native click micro task  
parent native click  
parent native click micro task  
child  synthetic click  
parent synthetic click  
child  synthetic click micro task  
parent synthetic click micro task  
child  native click macro task  
parent native click macro task  
child  synthetic click macro task  
parent synthetic click macro task  

// onClick分两类 原生事件和react的合成事件
// 手动点击则不一样
// 原生事件的onClick作为异步任务进入宏队列
// 两个onClick不在同一个事件中,所以child的onClick的promise会先于parent的onClick执行
// 合成事件的onClick作为同步任务进入执行栈
// 两个onClick在同一个事件中,所以child的onClick的promise会晚于parent的onClick执行

总结

在每次事件循环中,从宏任务队列取出任务t执行,然后把任务t里的同步任务按顺序执行, 异步任务则进入任务队列,如果是宏任务,则进入宏任务队列,如果是微任务,则进入当前微任务队列。接着,等执行栈清空之后,会执行当前微任务队列的所有任务,接着浏览器根据是否重绘页面调用requestAnimationFrame。

参考