ホームページ  >  記事  >  ウェブフロントエンド  >  JavaScriptイベントループの原理と例について話しましょう

JavaScriptイベントループの原理と例について話しましょう

WBOY
WBOY転載
2022-11-10 17:27:492158ブラウズ

この記事では、JavaScriptに関する知識を中心に、イベントループの関連内容を中心にご紹介しますので、一緒に見ていきましょう。

JavaScriptイベントループの原理と例について話しましょう

[関連する推奨事項: JavaScript ビデオ チュートリアル Web フロントエンド ]

理解するJavaScript イベント ループには、マクロタスクやマイクロタスク、JavaScript のシングルスレッド実行プロセス、ブラウザーの非同期メカニズムなどの関連問題が伴うことが多く、ブラウザーと NodeJS でのイベント ループの実装も大きく異なります。イベント ループに精通し、ブラウザの動作メカニズムを理解することは、JavaScript の実行プロセスを理解し、コード実行の問題をトラブルシューティングするのに非常に役立ちます。

ブラウザ JS の非同期実行の原理

JS はシングルスレッドです。つまり、同時に 1 つのことしか実行できません。そのため、次の理由を考えてください。同時に非同期で実行できます ミッションはどうですか?

ブラウザはマルチスレッドであるため、JS が非同期タスクを実行する必要がある場合、ブラウザはタスクを実行するために別のスレッドを開始します。つまり、「JSがシングルスレッド」とは、JSコードを実行するスレッドがブラウザが提供するJSエンジンスレッド(メインスレッド)の1つだけであることを意味します。ブラウザにはタイマー スレッドと HTTP リクエスト スレッドもありますが、これらのスレッドは主に JS コードの実行に使用されません。

たとえば、メイン スレッドで AJAX リクエストを送信する必要がある場合、このタスクは別のブラウザ スレッド (HTTP リクエスト スレッド) に渡されて、実際にリクエストが送信されます。リクエストが返されると、JSコールバックで実行する必要があります。コールバックは、実行のために JS エンジン スレッドに渡されます。 **つまり、リクエストを送信するタスクを実際に実行するのはブラウザであり、JS は最終的なコールバック処理の実行のみを担当します。 **したがって、ここでの非同期は JS 自体によって実装されるのではなく、実際にはブラウザによって提供される機能です。

Chrome を例に挙げると、ブラウザには複数のスレッドだけでなく、レンダリング処理、GPU 処理、プラグイン処理などの複数の処理が存在します。各タブ ページは独立したレンダリング プロセスであるため、1 つのタブが異常終了しても、他のタブは基本的に影響を受けません。 フロントエンド開発者として、私たちは主にレンダリング プロセスに重点を置いています。レンダリング プロセスには、JS エンジン スレッド、HTTP リクエスト スレッド、タイマー スレッドなどが含まれます。これらのスレッドは、JS がブラウザで非同期タスクを完了するための基盤を提供します。

イベント駆動型分析

ブラウザーの非同期タスクの実行原理は、実際には一連のイベント駆動型メカニズムです。イベントのトリガー、タスクの選択、およびタスクの実行はすべて、イベント駆動型のメカニズムによって実現されます。 NodeJS とブラウザの設計はイベント駆動型です。つまり、特定のタスクは特定のイベントによってトリガーされます。ここでのイベントは、クリック イベントなどのユーザー操作によってトリガーできます。また、プログラムによって自動的にトリガーすることもできます。たとえば、ブラウザのタイマー スレッドは、タイマーの終了後にタイマー イベントをトリガーします。この記事のテーマイベント ループは実際には、イベント駆動モデルでイベントを管理および実行するための一連のプロセスです

単純なシーンを例に挙げます。ゲーム インターフェイスに移動ボタンとキャラクター モデルがあるとします。クリックして右に移動するたびに、キャラクター モデルの位置を再レンダリングする必要があります。 1 ピクセル右に移動します。レンダリングのタイミングに応じて、さまざまな方法で実装できます。

#実装方法 1: イベント駆動型。 ボタンをクリックした後、座標位置 X が変更されると、インターフェイス レンダリング イベントが即座にトリガーされ、再レンダリングがトリガーされます。

実装方法 2: 状態駆動型またはデータ駆動型。 ボタンをクリックすると、座標位置 X のみが変更され、インターフェイスのレンダリングはトリガーされません。この前に、タイマー setInterval が開始されるか、requestAnimationFrame が使用されて、positionX が変化したかどうかを継続的に検出します。変更があった場合は、すぐに再レンダリングしてください。

ブラウザでのクリック イベント処理も、通常はイベント駆動型です。イベントドリブンシステムでは、イベントがトリガーされると、トリガーされたイベントが順番にキューに一時的に格納され、JS同期タスクが完了した後、このキューから処理対象のイベントが取り出して処理されます。したがって、いつタスクをフェッチするか、どのタスクを最初にフェッチするかは、イベント ループ プロセスによって制御されます。

ブラウザ内のイベント ループ

実行スタックとタスク キュー

JS がコードを解析すると、同期コードはどこかに順番に配置されます。つまり、実行スタックです。そして、内部の関数を順番に実行します。非同期タスクが発生すると、処理のために他のスレッドに渡されます。現在の実行スタック内のすべての同期コードが実行された後、完了した非同期タスクのコールバックがキューから取り出され、実行スタックに追加されます。実行を継続します。非同期タスクが発生すると、再度処理され、他のスレッドに引き渡されます。他の非同期タスクが完了すると、コールバックはタスク キューに配置され、実行のために実行スタックから取り出されます。

JS は、実行スタック内のメソッドを順番に実行します。メソッドが実行されるたびに、このメソッドに対して固有の実行環境 (コンテキスト) が生成されます。このメソッドの実行が完了すると、現在の実行が環境は破棄されます。 そして、このメソッドをスタックからポップし (つまり、消費が完了し)、次のメソッドに進みます。

イベント駆動モードでは、タスク キューに新しいタスクがあるかどうかを検出するために、少なくとも 1 つの実行ループが含まれていることがわかります。継続的にループして非同期コールバックを取り出して実行することにより、このプロセスはイベント ループとなり、各ループはイベント サイクルまたはティックとなります。

マクロタスクとマイクロタスク

タスクキューは複数存在し、タスクの種類に応じてマイクロタスクキューとマクロタスクキューに分かれます。

イベント ループ中、同期コードの実行が完了した後、実行スタックはまずマイクロタスク キューに実行する必要のあるタスクがあるかどうかを確認します。ない場合はマクロタスク キューに移動して、実行するタスクがあるかどうかなどを確認します。通常、マイクロタスクは現在のサイクルで最初に実行されますが、マクロタスクは次のサイクルまで待機します。 したがって、マイクロタスクは通常、マクロタスクの前に実行され、マイクロタスク キューは 1 つだけあり、複数のマクロタスク キューが存在する場合もあります。さらに、一般的なクリック イベントやキーボード イベントもマクロ タスクに属します。

一般的なマクロ タスクと一般的なミクロ タスクを見てみましょう。

#一般的なマクロ タスク:

    setTimeout()
  • setInterval()
  • setImmediate()

一般的なマイクロタスク:

    promise.then()、promise.catch()
  • new MutaionObserver()
  • process.nextTick()
console.log('同步代码1');setTimeout(() => {    console.log('setTimeout')
}, 0)new Promise((resolve) => {  console.log('同步代码2')  resolve()
}).then(() => {    console.log('promise.then')
})console.log('同步代码3');// 最终输出"同步代码1"、"同步代码2"、"同步代码3"、"promise.then"、"setTimeout"
上記のコードは、「同期コード1」、「同期コード2」、「同期コード3」、「promise.then」、の順に出力されます。 「setTimeout」の詳細な分析は以下の通りです。

(1) setTimeout コールバックと Promise.then は非同期で実行され、すべての同期コードの後に​​実行されます;

ところで、ブラウザで setTimeout が遅延が 0 に設定されている場合、デフォルトは 4 ミリ秒、NodeJS は 1 ミリ秒です。正確な値は異なる場合がありますが、0 ではありません。

(2)promise.thenは後から書いていますが、マイクロタスクなので実行順序はsetTimeoutより優先されます;

(3)新しいPromiseが同期されるとき実行されると、promise.then のコールバックは非同期になります。

上記のコードの実行プロセスのデモを見てみましょう:

このように理解している人もいます: マイクロタスクは最後に実行されます。現在のイベント ループ; マクロ タスクは次のイベント ループの開始時に実行されます。マイクロタスクとマクロタスクの本質的な違いを見てみましょう。

JS が非同期タスクに遭遇すると、そのタスクを他のスレッドに渡して処理し、メインスレッドが引き続き同期タスクを実行することはすでにわかっています。たとえば、setTimeout のタイミングはブラウザのタイマー スレッドによって処理され、タイミングが終了すると、タイマー コールバック タスクがタスク キューに配置され、メイン スレッドが実行のために取り出すのを待ちます。前述したように、JS は単一のスレッドで実行されるため、非同期タスクを実行するには、他のブラウザ スレッドがサポートする必要があります。つまり、マルチスレッドは JS 非同期タスクの明らかな機能です。

promise.then(マイクロタスク)の処理を解析してみましょう。 Promise.then が実行されると、V8 エンジンは非同期タスクを他のブラウザ スレッドに引き渡さず、その代わりにコールバックを独自のキューに保存します。現在の実行スタックの実行が完了すると、すぐに、promise.then が格納されるキュー。 、promise.then マイクロタスクにはマルチスレッドは含まれません。いくつかの観点から見ても、マイクロタスクは完全に非同期であることはできません。コードの作成時に実行順序が変更されるだけです。

setTimeout には、タイマー スレッドによって実行する必要がある「タイミング待機」のタスクがあり、ajax リクエストには、HTTP スレッドによって実行する必要がある「リクエストの送信」というタスクがあります。 promise.then には、他のスレッドによって実行する必要がある非同期タスクはなく、コールバックのみがあり、たとえあったとしても、内部にネストされた別のマクロ タスクにすぎません。

マイクロタスクとマクロタスクの本質的な違いを簡単にまとめます。

  • マクロ タスクの機能: 実行する必要がある明確な非同期タスクとコールバックがあり、他の非同期スレッドからのサポートが必要です。
  • マイクロタスクの機能: 実行される明確な非同期タスクはなく、コールバックのみです。他の非同期スレッドのサポートは必要ありません。

タイマー エラー

イベント ループでは、常に同期コードが最初に実行され、次に非同期コールバックが実行のためにタスク キューからフェッチされます。 setTimeout が実行されると、ブラウザはタイミングのために新しいスレッドを開始します。タイミングが終了すると、タイマー イベントがトリガーされ、コールバックがマクロ タスク キューに格納され、JS メイン スレッドが実行を開始するのを待ちます。この時点でメインスレッドがまだ同期タスクを実行している場合、この時点のマクロタスクを最初に一時停止する必要があり、これによりタイマーが不正確になるという問題が発生します。同期コードに時間がかかるほど、タイマーの誤差が大きくなります。同期コードだけでなく、マイクロタスクが最初に実行されるため、マイクロタスクもタイミングに影響します。同期コード内に無限ループがある場合、またはマイクロタスク内の再帰によって常に他のマイクロタスクが開始される場合、マクロタスク内のコードは決して入手できない可能性があります。したがって、メインスレッドコードの実行効率を向上させることが非常に重要です。

#非常に単純なシナリオは、インターフェイス上に秒単位の精度の時計があり、時刻を毎秒更新するというものです。時々、秒が 2 秒間隔を飛ばすことがありますが、それが理由です。

ビュー更新レンダリング

マイクロタスク キューが実行された後、つまりイベント ループが終了した後、ブラウザはビュー レンダリングを実行します。もちろん、ここではブラウザの最適化が行われ、複数のこのループの結果はビューの再描画であるため、ビューはイベント ループの後に更新されるため、Dom 上のすべての操作でビューがすぐに更新されるとは限りません。 requestAnimationFrame コールバックはビューが再描画される前に実行されるため、requestAnimationFrame がマイクロタスクであるかマクロタスクであるかは議論の余地がありますが、ここからはマイクロタスクでもマクロタスクでもないはずです。

NodeJS のイベント ループ

JS エンジン自体にはイベント ループ メカニズムが実装されておらず、ホストによって実装されます。ブラウザーのイベント ループは主にブラウザーと NodeJS によって実装されます。独自のイベント ループ実装もあります。 NodeJS では、周期タスク キューとマイクロタスクの処理がマクロタスクよりも優先され、一般的なパフォーマンスはブラウザーと一致します。ただし、ブラウザとはいくつかの違いがあり、いくつかの新しいタスク タイプとタスク ステージが追加されています。次にNodeJSのイベントループ処理を紹介します。

NodeJS の非同期メソッド

これらはすべて V8 エンジンに基づいているため、ブラウザーに含まれる非同期メソッドは NodeJS でも同じです。 NodeJS には他にも一般的な非同期形式がいくつかあります。

  • ファイル I/O: ローカル ファイルを非同期的に読み込みます。
  • setImmediate(): setTimeout を 0ms に設定するのと同様に、特定の同期タスクが完了した直後に実行されます。
  • process.nextTick(): 特定の同期タスクが完了した直後に実行されます。
  • server.close、socket.on('close',...) など: コールバックを閉じます。

上記のフォームが setTimeout や Promise などと同時に存在する場合、コードの実行順序をどのように分析するかを想像してください。 NodeJS のイベント ループの仕組みを理解していれば、それは明らかです。

イベント ループ モデル

NodeJS のクロスプラットフォーム機能とイベント ループ メカニズムはすべて Libuv ライブラリに基づいて実装されているため、このライブラリの特定の内容を気にする必要はありません。 Libuv ライブラリはイベント駆動型であり、さまざまなプラットフォーム上の API 実装をカプセル化して統合することだけを知っておく必要があります。

NodeJS では、V8 エンジンが JS コードを解析して Node API を呼び出し、Node API はタスクを Libuv に渡して割り当て、最後に実行結果を V8 エンジンに返します。これらのタスクの実行を管理する一連のイベント ループ プロセスが Libux に実装されています。そのため、NodeJS のイベント ループは主に Libuv で完了します。

Libuv のループがどのようなものかを見てみましょう。

イベント ループの各ステージ

NodeJS での JS の実行において、主に考慮する必要があるプロセスは次のステージに分かれており、以下の各ステージには独自のタスク キューがあります。 . 実行時 該当ステージに到達すると、現在のステージのタスクキューに処理が必要なタスクがあるかどうかを判定します。

  • timers 阶段:执行所有 setTimeout() 和 setInterval() 的回调。
  • pending callbacks 阶段:某些系统操作的回调,如  TCP  链接错误。除了 timers、close、setImmediate 的其他大部分回调在此阶段执行。
  • poll 阶段:轮询等待新的链接和请求等事件,执行 I/O 回调等。V8 引擎将 JS 代码解析并传入 Libuv 引擎后首先进入此阶段。如果此阶段任务队列已经执行完了,则进入 check 阶段执行 setImmediate 回调(如果有 setImmediate),或等待新的任务进来(如果没有 setImmediate)。在等待新的任务时,如果有 timers 计时到期,则会直接进入 timers 阶段。此阶段可能会阻塞等待。
  • check 阶段:setImmediate 回调函数执行。
  • close callbacks 阶段:关闭回调执行,如 socket.on('close', ...)。

上面每个阶段都会去执行完当前阶段的任务队列,然后继续执行当前阶段的微任务队列,只有当前阶段所有微任务都执行完了,才会进入下个阶段。这里也是与浏览器中逻辑差异较大的地方,不过浏览器不用区分这些阶段,也少了很多异步操作类型,所以不用刻意去区分两者区别。代码如下所示:

const fs = require('fs');
fs.readFile(__filename, (data) => {    // poll(I/O 回调) 阶段
    console.log('readFile')    Promise.resolve().then(() => {        console.error('promise1')
    })    Promise.resolve().then(() => {        console.error('promise2')
    })
});setTimeout(() => {    // timers 阶段
    console.log('timeout');    Promise.resolve().then(() => {        console.error('promise3')
    })    Promise.resolve().then(() => {        console.error('promise4')
    })
}, 0);// 下面代码只是为了同步阻塞1秒钟,确保上面的异步任务已经准备好了var startTime = new Date().getTime();var endTime = startTime;while(endTime - startTime < 1000) {
    endTime = new Date().getTime();
}// 最终输出 timeout promise3 promise4 readFile promise1 promise2

另一个与浏览器的差异还体现在同一个阶段里的不同任务执行,在 timers 阶段里面的宏任务、微任务测试代码如下所示:

setTimeout(() => {  console.log('timeout1')    Promise.resolve().then(function() {    console.log('promise1')
  })
}, 0);setTimeout(() => {  console.log('timeout2')    Promise.resolve().then(function() {    console.log('promise2')
  })
}, 0);
  • 浏览器中运行

    每次宏任务完成后都会优先处理微任务,输出“timeout1”、“promise1”、“timeout2”、“promise2”。

  • NodeJS 中运行

    因为输出 timeout1 时,当前正处于  timers 阶段,所以会先将所有 timer 回调执行完之后再执行微任务队列,即输出“timeout1”、“timeout2”、“promise1”、“promise2”。

上面的差异可以用浏览器和 NodeJS 10 对比验证。是不是感觉有点反程序员?因此 NodeJS 在版本 11 之后,就修改了此处逻辑使其与浏览器尽量一致,也就是每个 timer 执行后都先去检查一下微任务队列,所以 NodeJS 11 之后的输出已经和浏览器一致了。

nextTick、setImmediate 和 setTimeout

实际项目中我们常用 Promise 或者 setTimeout 来做一些需要延时的任务,比如一些耗时计算或者日志上传等,目的是不希望它的执行占用主线程的时间或者需要依赖整个同步代码执行完成后的结果。

NodeJS 中的 process.nextTick() 和 setImmediate() 也有类似效果。其中 setImmediate() 我们前面已经讲了是在 check 阶段执行的,而 process.nextTick() 的执行时机不太一样,它比 promise.then() 的执行还早,在同步任务之后,其他所有异步任务之前,会优先执行 nextTick。可以想象是把 nextTick 的任务放到了当前循环的后面,与 promise.then() 类似,但比 promise.then() 更前面。意思就是在当前同步代码执行完成后,不管其他异步任务,先尽快执行 nextTick。如下面的代码,因此这里的 nextTick 其实应该更符合“setImmediate”这个命名才对。

setTimeout(() => {    console.log('timeout');
}, 0);Promise.resolve().then(() => {    console.error('promise')
})
process.nextTick(() => {    console.error('nextTick')
})// 输出:nextTick、promise、timeout

接下来我们再来看看 setImmediate 和 setTimeout,它们是属于不同的执行阶段了,分别是 timers 阶段和 check 阶段。

setTimeout(() => {  console.log('timeout');
}, 0);setImmediate(() => {  console.log('setImmediate');
});// 输出:timeout、 setImmediate

分析上面代码,第一轮循环后,分别将 setTimeout   和 setImmediate 加入了各自阶段的任务队列。第二轮循环首先进入  timers 阶段,执行定时器队列回调,然后  pending callbacks 和 poll 阶段没有任务,因此进入check 阶段执行 setImmediate 回调。所以最后输出为“timeout”、“setImmediate”。当然这里还有种理论上的极端情况,就是第一轮循环结束后耗时很短,导致 setTimeout 的计时还没结束,此时第二轮循环则会先执行 setImmediate 回调。

再看这下面一段代码,它只是把上一段代码放在了一个 I/O 任务回调中,它的输出将与上一段代码相反。

const fs = require('fs');
fs.readFile(__filename, (data) => {    console.log('readFile');    setTimeout(() => {        console.log('timeout');
    }, 0);    setImmediate(() => {        console.log('setImmediate');
    });
});// 输出:readFile、setImmediate、timeout

如上面代码所示:

  • サイクルの第 1 ラウンドで実行する必要がある非同期タスク キューはありません。
  • サイクルの第 2 ラウンドのタイマーおよびその他のステージにはタスクがありません。ポーリング ステージには I/O コールバック タスクがあります。つまり、「readFile」が出力されます。;
  • 前のイベント ステージの説明を参照してください。次に、ポーリング ステージは setImmediate タスク キューがあるかどうかを検出します。タイマータスクのコールバックがあればタイマーステージに戻るため、setImmediateを実行してsetImmediateを出力するためにチェックフェーズに入る必要があります。
  • その後、最後のクローズ コールバック フェーズに入り、このサイクルは終了します。
  • ##最後に 3 番目のサイクルに進み、タイマー フェーズに入り、「timeout」を出力します。
つまり、「setImmediate」の最終出力は「timeout」の前になります。 2 つの実行順序が現在の実行ステージに関連していることがわかります。

【関連する推奨事項:

JavaScript ビデオ チュートリアル Web フロントエンド ]

以上がJavaScriptイベントループの原理と例について話しましょうの詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事はjuejin.imで複製されています。侵害がある場合は、admin@php.cn までご連絡ください。