JavaScript の基礎となる原則、同時実行モデル、EventLoop、非同期プログラミングの概要

JavaScript はイベント ループ ベースの同時実行モデルであり、他のマルチスレッド言語とは異なり、初期の JavaScript はシングルスレッドの EventLoop のみを維持していました。ハードウェアの進化に伴い、コンピューターは強力なマルチコア システムに進化し、JavaScript はコンピューティングの世界で最も広く使用されている言語の 1 つになりました。最も人気のあるアプリケーションの多くは、少なくとも部分的に JavaScript コードに基づいています。これをサポートするには、シングルスレッド言語の制約からプロジェクトを解放する方法を見つける必要がありました。対応する JavaScript のマルチスレッド プログラミングも議題に上っています。

JavaScript の EventLoop の利点を強化するために、初期の非同期タスク モデルから、 setTimeout() setInterval()にも拡張されています同時に、マイクロタスクキューとサブスレッドワーカーの実装も導入されています。Promise()queueMicrotask()requestAnimationFrame()requestIdleCallback()

1. 同時実行モデルとイベントループ

最新の JavaScript エンジンは、最も基本的な Java イベント モデルから始めて、以下で説明するセマンティクスを実装および最適化します。

画像.png

スタック

関数呼び出しはフレームのスタックを形成します。

function foo(b) {
  let a = 10;
  return a + b + 11;
}

function bar(x) {
  let y = 3;
  return foo(x * y);
}

console.log(bar(7)); // 返回 42

が呼び出されると bar 、最初のフレームが作成され、 bar パラメータとローカル変数を含むスタックにプッシュされます。が bar 呼び出される foo と、パラメータとローカル変数を含む 2 番目のフレームが作成され、スタックの最初のフレームの上にプッシュされます foo 。実行が完了して戻ると foo 、2 番目のフレームがスタックからポップされます ( bar 関数の呼び出しフレームは残ります)。実行が終了して戻ると bar 、最初のフレームもポップされ、スタックがクリアされます。

ヒープ

オブジェクトはヒープ上に割り当てられます。ヒープとは、メモリの大きな (通常は構造化されていない) 領域を指すコンピュータ用語です。

JavaScript ランタイムには、保留中のメッセージのメッセージ キューが含まれています。各メッセージは、メッセージを処理するコールバック関数に関連付けられています。

在 事件循环 期间的某个时刻,运行时会从最先进入队列的消息开始处理队列中的消息。被处理的消息会被移出队列,并作为输入参数来调用与之关联的函数。正如前面所提到的,调用一个函数总是会为其创造一个新的栈帧。

函数的处理会一直进行到执行栈再次为空为止;然后事件循环将会处理队列中的下一个消息(如果还有的话)。

事件循环

之所以称之为 事件循环,是因为它经常按照类似如下的方式来被实现:

while (queue.waitForMessage()) {
  queue.processNextMessage();
}

queue.waitForMessage() 会同步地等待消息到达 (如果当前没有任何消息等待被处理)。

执行至完成

每一个消息完整地执行后,其他消息才会被执行。这为程序的分析提供了一些优秀的特性,包括:当一个函数执行时,它不会被抢占,只有在它运行完毕之后才会去运行任何其他的代码,才能修改这个函数操作的数据。这与 C 语言不同,例如,如果函数在线程中运行,它可能在任何位置被终止,然后在另一个线程中运行其他代码。

这个模型的一个缺点在于当一个消息需要太长时间才能处理完毕时,Web 应用程序就无法处理与用户的交互,例如点击或滚动。为了缓解这个问题,浏览器一般会弹出一个“这个脚本运行时间过长”的对话框。一个良好的习惯是缩短单个消息处理时间,并在可能的情况下将一个消息裁剪成多个消息。

添加消息

在浏览器里,每当一个事件发生并且有一个事件监听器绑定在该事件上时,一个消息就会被添加进消息队列。如果没有事件监听器,这个事件将会丢失。所以当一个带有点击事件处理器的元素被点击时,就会像其他事件一样产生一个类似的消息。

函数 setTimeout 接受两个参数:待加入队列的消息和一个时间值(可选,默认为 0)。这个时间值代表了消息被实际加入到队列的最小延迟时间。如果队列中没有其他消息并且栈为空,在这段延迟时间过去之后,消息会被马上处理。但是,如果有其他消息,setTimeout 消息必须等待其他消息处理完。因此第二个参数仅仅表示最少延迟时间,而非确切的等待时间。

下面的例子演示了这个概念(setTimeout 并不会在计时器到期之后直接执行):

const s = new Date().getSeconds();

setTimeout(function() {
  // 输出 "2",表示回调函数并没有在 500 毫秒之后立即执行
  console.log("Ran after " + (new Date().getSeconds() - s) + " seconds");
}, 500);

while(true) {
  if(new Date().getSeconds() - s >= 2) {
    console.log("Good, looped for 2 seconds");
    break;
  }
}

零延迟

零延迟并不意味着回调会立即执行。以 0 为第二参数调用 setTimeout 并不表示在 0 毫秒后就立即调用回调函数。

其等待的时间取决于队列里待处理的消息数量。在下面的例子中,"这是一条消息" 将会在回调获得处理之前输出到控制台,这是因为延迟参数是运行时处理请求所需的最小等待时间,但并不保证是准确的等待时间。

基本上,setTimeout 需要等待当前队列中所有的消息都处理完毕之后才能执行,即使已经超出了由第二参数所指定的时间。

(function() {

  console.log('这是开始');

  setTimeout(function cb() {
    console.log('这是来自第一个回调的消息');
  });

  console.log('这是一条消息');

  setTimeout(function cb1() {
    console.log('这是来自第二个回调的消息');
  }, 0);

  console.log('这是结束');

})();

// "这是开始"
// "这是一条消息"
// "这是结束"
// "这是来自第一个回调的消息"
// "这是来自第二个回调的消息"

永不阻塞

JavaScript 的事件循环模型与许多其他语言不同的一个非常有趣的特性是,它永不阻塞。处理 I/O 通常通过事件和回调来执行,所以当一个应用正等待一个 IndexedDB 查询返回或者一个 XHR 请求返回时,它仍然可以处理其他事情,比如用户输入。

由于历史原因有一些例外,如 alert 或者同步 XHR,但应该尽量避免使用它们。注意,例外的例外也是存在的(但通常是实现错误而非其他原因)。

二、运行时环境

在执行 JavaScript 代码的时候,JavaScript 运行时实际上维护了一组用于执行 JavaScript 代码的代理。每个代理由一组执行上下文的集合、执行上下文栈、主线程、一组可能创建用于执行 worker 的额外的线程集合、一个任务队列以及一个微任务队列构成。除了主线程(某些浏览器在多个代理之间共享的主线程)之外,其他组成部分对该代理都是唯一的。

现在我们来更加详细的了解一下运行时是如何工作的。

事件循环

每个代理都是由事件循环(Event loop)驱动的,事件循环负责收集事件(包括用户事件以及其他非用户事件等)、对任务进行排队以便在合适的时候执行回调。然后它执行所有处于等待中的 JavaScript 任务,然后是微任务,然后在开始下一次循环之前执行一些必要的渲染和绘制操作。

网页或者 app 的代码和浏览器本身的用户界面程序运行在相同的线程中,共享相同的事件循环。该线程就是主线程,它除了运行网页本身的代码之外,还负责收集和派发用户和其他事件,以及渲染和绘制网页内容等。

事件循环驱动着浏览器中发生的一切,因为它与用户的交互有关,但对于我们这里的目的来说,更重要的是它负责调度和执行在其线程中运行的每一段代码。

有如下三种事件循环:

Window 事件循环

window 事件循环驱动所有共享同源的窗口(尽管这有进一步的限制,如下所述)。

Worker 事件循环

worker 事件循环驱动 worker 的事件循环。这包括所有形式的 worker,包括基本的 web worker、shared worker 和 service worker。Worker 被保存在一个或多个与“主”代码分开的代理中;浏览器可以对所有特定类型的工作者使用一个事件循环,也可以使用多个事件循环来处理它们。

Worklet 事件循环

worklet (en-US) 事件循环驱动运行 worklet 的代理。这包含了 Worklet (en-US)、AudioWorklet (en-US) 以及 PaintWorklet (en-US)。

多个同源窗口可能运行在相同的事件循环中,每个队列任务进入到事件循环中以便处理器能够轮流对它们进行处理。记住这里的网络术语“window”实际上指的是“用于运行网页内容的浏览器级容器”,包括实际的 window、标签页或者一个 frame。

在特定情况下,同源窗口之间共享事件循环,例如:

如果一个窗口打开了另一个窗口,它们可能会共享一个事件循环。 如果窗口是包含在 中的容器,则它可能会和包含它的窗口共享一个事件循环。 在多进程浏览器中多个窗口碰巧共享了同一个进程。 这种特定情况依赖于浏览器的具体实现,各个浏览器可能并不一样。

三、异步编程

JavaScript本质上只是一个单线程的语言,如果一个任务占用时间过长,就会造成浏览器的假死,并阻塞后面的任务,所以异步编程是JavaScript处理任务核心特性。

1.回调

事件处理程序是一种特殊类型的回调函数。而回调函数则是一个被传递到另一个函数中的会在适当的时候被调用的函数。正如我们刚刚所看到的:回调函数曾经是 JavaScript 中实现异步函数的主要方式。

然而,当回调函数本身需要调用其他同样接受回调函数的函数时,基于回调的代码会变得难以理解。当你需要执行一些分解成一系列异步函数的操作时,这将变得十分常见。例如下面这种情况:

    function doStep1(init) {
      return init + 1;
    }
    function doStep2(init) {
      return init + 2;
    }
    function doStep3(init) {
      return init + 3;
    }
    function doOperation() {
      let result = 0;
      result = doStep1(result);
      result = doStep2(result);
      result = doStep3(result);
      console.log(`结果:${result}`);
    }
    doOperation();

回调有明显的缺陷,很容易写成“回调炼狱”形式的函数,给阅读和理解造成了麻烦。

2.事件

JavaScript是基于事件编程实现异步操作的,回调和事件组成了JavaScript的基石。JavaScript提供了addEventListener语法处理事件。

   
<button id="btn"> 点我哦 </button>
<script>
  const btn = document.getElementById('btn');

  // 单击时触发
  btn.addEventListener('click', event => console.log('click!'));

  // 鼠标移入触发
  btn.addEventListener('mouseover', event => console.log('mouseover!'));

  // 鼠标移出触发
  btn.addEventListener('mouseout', event => console.log('mouseout!'));
</script>

2.基于契约

Promise 是现代 JavaScript 中异步编程的基础,是一个由异步函数返回的可以向我们指示当前操作所处的状态的对象。在 Promise 返回给调用者的时候,操作往往还没有完成,但 Promise 对象可以让我们操作最终完成时对其进行处理(无论成功还是失败)。

    const fetchPromise = fetch('https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json');

    fetchPromise
      .then( response => {
        return response.json();
      })
      .then( json => {
        console.log(json[0].name);
      });

4.使用queueMicrotask()

Window 或 Worker 接口的 queueMicrotask() 方法,将微任务加入队列以在控制返回浏览器的事件循环之前的安全时间执行。

微任务是一个简短的函数,它将在当前任务完成其工作后运行,并且在执行上下文的控制权返回到浏览器的事件循环之前没有其他代码等待运行时运行。

  MyElement.prototype.loadData = function (url) {
  if (this._cache[url]) {
    queueMicrotask(() => {
      this._setData(this._cache[url]);
      this.dispatchEvent(new Event("load"));
    });
  } else {
    fetch(url)
      .then((res) => res.arrayBuffer())
      .then((data) => {
        this._cache[url] = data;
        this._setData(data);
        this.dispatchEvent(new Event("load"));
      });
  }
};

5.使用setTime()、 setInterval()、 requestAnimationFrame()

window对象下这三个函数都能实现异常操作

6.requestIdleCallback()

window.requestIdleCallback() 方法插入一个函数,这个函数将在浏览器空闲时期被调用。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间timeout,则有可能为了在超时前执行函数而打乱执行顺序。

你可以在空闲回调函数中调用 requestIdleCallback(),以便在下一次通过事件循环之前调度另一个回调。

6.并发编程worker线程

一个 worker 是使用一个构造函数创建的一个对象(例如 Worker())运行一个命名的 JavaScript 文件——这个文件包含将在 worker 线程中运行的代码; worker 运行在另一个全局上下文中,不同于当前的window。因此,在 Worker 内通过 window 获取全局作用域(而不是self)将返回错误。

在专用 worker 的情况下,DedicatedWorkerGlobalScope 对象代表了 worker 的上下文(专用 worker 是指标准 worker 仅在单一脚本中被使用;共享 worker 的上下文是 SharedWorkerGlobalScope (en-US) 对象)。一个专用 worker 仅能被首次生成它的脚本使用,而共享 worker 可以同时被多个脚本使用。

四、任务队列与微任务队列

一个任务就是指计划由标准机制来执行的任何 JavaScript,如程序的初始化、事件触发的回调等。除了使用事件,你还可以使用 setTimeout() 或者 setInterval() 来添加任务。

任务队列

  1. setTimeOut()setInterval()函数添加
  2. 各种事件回调,浏览器addEventListener,node on
  3. XHR

微任务队列

  1. promse()
  2. queueMicrotask()
  3. Mutation observer()

image.png

image.png

任务队列和微任务队列的区别很简单,但却很重要:

  • 当执行来自任务队列中的任务时,在每一次新的事件循环开始迭代的时候运行时都会执行队列中的每个任务。在每次迭代开始之后加入到队列中的任务需要在下一次迭代开始之后才会被执行
  • タスクが終了し、実行コンテキスト スタックが空になるたびに、マイクロタスク キュー内の各マイクロタスクが順番に実行されます。違いは、途中にマイクロタスクが追加された場合でも、マイクロタスク キューが空になるまで待機してから実行を停止することです。つまり、マイクロタスクは新しいマイクロタスクをキューに追加でき、これらの新しいマイクロタスクは、次のタスクの実行が開始される前、つまり現在のイベント ループの反復が終了する前に実行されます。

Promise などの非同期 JavaScriptテクニックを使用して、  リクエストが返されるのを待機している間もメインスレッドが実行を継続できるようにすることで、上記の状況をさらに軽減できます。ただし、一部のフレームワーク コードなど、基本機能に近いコードでは、リクエストやタスクの結果とは関係なく、安全な時間にメイン スレッドでコードを実行するようにスケジュールする必要がある場合があります。

マイクロタスクは、この問題に対するもう 1 つの解決策であり、次のイベント ループが開始されるまで待つのではなく、次のイベント ループが開始する前にコードを実行するようにスケジュールすることで、より良いレベルのアクセスを提供します。

マイクロタスク キューはしばらく前から存在していましたが、以前は Promise などのタスクを実行するために内部でのみ使用されていました。queueMicrotask() の追加により、開発者は、JavaScript 実行コンテキスト スタック上の実行コンテキストなしでコードを安全に実行するようにスケジュールできる必要がある場合に使用する統合マイクロタスク キューを作成できるようになります。複数のインスタンス、ブラウザ、JavaScript ランタイムにわたって、標準化されたマイクロキュー メカニズムにより、これらのマイクロタスクが同じ順序で確実に実行され、発見が難しい潜在的なバグが回避されます。

5. 適用事例

タスクとマイクロタスクの出力順序

  1. 一般的な筆記試験の問題の場合は、次の実行順序を出力します。
console.log('start')
 
setTimeout(() => {
  console.log('setTimeout')
}, 0)
 
new Promise((resolve) => {
  console.log('promise')
  resolve()
})
  .then(() => {
    console.log('then1')
  })
  .then(() => {
    console.log('then2')
  })
 
console.log('end')

回答: start promise end then1 then2 setTimeout

特に、新しい Promise() 関数の内部コードは同期的に実行され、その後でのみマイクロタスク反対に追加されます。

おすすめ

転載: juejin.im/post/7253437782333472826