Cafedev chia sẻ về Vòng lặp sự kiện: microtasks and macrotasks trong javascript
Luồng thực thi JavaScript của trình duyệt, cũng như trong Node.js, dựa trên một vòng lặp sự kiện .
Hiểu cách thức hoạt động của vòng lặp sự kiện là quan trọng đối với việc tối ưu hóa và đôi khi đối với kiến trúc phù hợp.
Trong chương này, trước tiên chúng ta trình bày các chi tiết lý thuyết về cách mọi thứ hoạt động, và sau đó xem các ứng dụng thực tế của kiến thức đó.
Nội dung chính
1. Vòng lặp sự kiện
Khái niệm về vòng lặp sự kiện rất đơn giản. Có một vòng lặp vô tận, khi động cơ JavaScript đợi các tác vụ, thực thi chúng và sau đó ngủ chờ các tác vụ khác.
Thuật toán chung của động cơ:
- Trong khi có các nhiệm vụ:
- thực hiện chúng, bắt đầu với nhiệm vụ cũ nhất.
- Ngủ cho đến khi một nhiệm vụ xuất hiện, sau đó chuyển sang 1.
Đó là sự chính thức hóa những gì chúng ta thấy khi duyệt một trang. Công cụ JavaScript không làm gì trong hầu hết thời gian, chỉ chạy nếu một tập lệnh / trình xử lý / sự kiện kích hoạt.
Ví dụ về các nhiệm vụ:
- Khi một tập lệnh bên ngoài tải
<script src="...">
, nhiệm vụ là thực thi nó. - Khi người dùng di chuyển chuột, nhiệm vụ là gửi sự kiện mousemove và thực thi các trình xử lý.
- Khi thời gian đến hạn cho một lịch biểu setTimeout, nhiệm vụ là chạy lệnh gọi lại của nó.
- …và như thế.
Các tác vụ được thiết lập – công cụ xử lý chúng – sau đó chờ thêm các tác vụ (trong khi ngủ và tiêu thụ CPU gần bằng không).
Có thể xảy ra trường hợp một nhiệm vụ đến trong khi động cơ đang bận, sau đó nó được xếp vào hàng đợi.
Các tác vụ tạo thành một hàng đợi, được gọi là “hàng đợi macrotask” (thuật ngữ v8):
Ví dụ: trong khi động cơ đang bận thực hiện a script, người dùng có thể di chuyển chuột của họ gây ra mousemove và có thể do setTimeout, v.v., các tác vụ này tạo thành một hàng đợi, như minh họa trên hình trên.
Các nhiệm vụ từ hàng đợi được xử lý trên cơ sở “ai đến trước được phục vụ trước”. Khi trình duyệt công cụ được thực hiện xong script, nó sẽ xử lý sự kiện mousemove, sau đó xử lý setTimeout, v.v.
Cho đến nay, khá đơn giản, phải không?
Hai chi tiết khác:
- Kết xuất không bao giờ xảy ra trong khi động cơ thực hiện một tác vụ. Không thành vấn đề nếu nhiệm vụ mất nhiều thời gian. Các thay đổi đối với DOM chỉ được vẽ sau khi nhiệm vụ hoàn tất.
- Nếu một tác vụ mất quá nhiều thời gian, trình duyệt không thể thực hiện các tác vụ khác, xử lý các sự kiện của người dùng, vì vậy, sau một thời gian, nó sẽ đưa ra cảnh báo như “Trang không phản hồi” đề nghị hủy tác vụ với toàn bộ trang. Điều đó xảy ra khi có nhiều phép tính phức tạp hoặc một lỗi lập trình dẫn đến vòng lặp vô hạn.
Đó là một lý thuyết. Bây giờ chúng ta hãy xem làm thế nào chúng ta có thể áp dụng kiến thức đó.
2. Trường hợp sử dụng 1: Tách các tác vụ ngốn CPU
Giả sử chúng ta có một tác vụ ngốn CPU.
Ví dụ: tô sáng cú pháp (được sử dụng để tô màu các ví dụ mã trên trang này) khá nặng CPU. Để làm nổi bật code, nó thực hiện phân tích, tạo ra nhiều phần tử có màu, thêm chúng vào tài liệu – đối với một lượng lớn văn bản cần rất nhiều thời gian.
Trong khi công cụ đang bận rộn với việc tô sáng cú pháp, nó không thể thực hiện các công việc khác liên quan đến DOM, xử lý các sự kiện của người dùng, v.v. Nó thậm chí có thể khiến trình duyệt “trục trặc” hoặc thậm chí “treo” một chút, điều này không thể chấp nhận được.
Chúng ta có thể tránh các vấn đề bằng cách chia nhiệm vụ lớn thành nhiều phần. Đánh dấu 100 dòng đầu tiên, sau đó lên lịch setTimeout(với độ trễ bằng 0) cho 100 dòng tiếp theo, v.v.
Để chứng minh cách tiếp cận này, vì mục đích đơn giản, thay vì tô sáng văn bản, chúng ta hãy lấy một hàm đếm từ 1 đến 1000000000.
Nếu bạn chạy đoạn code dưới đây, động cơ sẽ “treo” trong một thời gian. Đối với JS phía máy chủ rõ ràng là đáng chú ý và nếu bạn đang chạy nó trong trình duyệt, hãy thử nhấp vào các nút khác trên trang – bạn sẽ thấy rằng không có sự kiện nào khác được xử lý cho đến khi quá trình đếm kết thúc.
let i = 0;
let start = Date.now();
function count() {
// do a heavy job
for (let j = 0; j < 1e9; j++) {
i++;
}
alert("Done in " + (Date.now() - start) + 'ms');
}
count();
Trình duyệt thậm chí có thể hiển thị cảnh báo “tập lệnh mất quá nhiều thời gian”.
Hãy phân chia công việc bằng các cuộc gọi lồng nhau setTimeout :
let i = 0;
let start = Date.now();
function count() {
// do a piece of the heavy job (*)
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
alert("Done in " + (Date.now() - start) + 'ms');
} else {
setTimeout(count); // schedule the new call (**)
}
}
count();
Bây giờ giao diện trình duyệt hoạt động đầy đủ trong quá trình “đếm”.
Một lần chạy thực hiện count một phần công việc (*) và sau đó tự lên lịch (**)nếu cần:
- Đếm chạy đầu tiên: i=1…1000000.
- Đếm chạy thứ hai: i=1000001..2000000.
- …và như thế.
Bây giờ, nếu một nhiệm vụ phụ mới (ví dụ: sự kiện onclick) xuất hiện trong khi động cơ đang bận thực hiện phần 1, nó sẽ được xếp hàng đợi và sau đó thực thi khi phần 1 kết thúc, trước phần tiếp theo. Việc quay trở lại vòng lặp sự kiện theo chu kỳ giữa các lần count thực thi chỉ cung cấp đủ “không khí” cho công cụ JavaScript để làm việc khác, để phản ứng với các hành động khác của người dùng.
Điều đáng chú ý là cả hai biến thể – có và không chia nhỏ công việc setTimeout- đều có tốc độ tương đương. Không có nhiều khác biệt về thời gian đếm tổng thể.
Để làm cho họ gần gũi hơn, chúng ta hãy cải tiến.
Chúng ta sẽ chuyển lịch trình sang đầu count():
let i = 0;
let start = Date.now();
function count() {
// move the scheduling to the beginning
if (i < 1e9 - 1e6) {
setTimeout(count); // schedule the new call
}
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
alert("Done in " + (Date.now() - start) + 'ms');
}
}
count();
Bây giờ khi chúng ta bắt đầu count() và thấy rằng chúng ta sẽ cần count() nhiều hơn nữa, chúng ta lập lịch trình đó ngay lập tức, trước khi thực hiện công việc.
Nếu bạn chạy nó, dễ dàng nhận thấy rằng nó mất ít thời gian hơn đáng kể.
Tại sao?
Điều đó đơn giản: như bạn nhớ, có độ trễ tối thiểu trong trình duyệt là 4ms cho nhiều cuộc gọi lồng nhau setTimeout. Ngay cả khi chúng ta đặt 0, nó 4ms(hoặc hơn một chút). Vì vậy, chúng ta lên lịch càng sớm – nó càng chạy nhanh.
Cuối cùng, chúng ta đã chia một tác vụ ngốn CPU thành các phần – bây giờ nó không chặn giao diện người dùng. Và thời gian thực hiện tổng thể của nó không còn lâu nữa.
3. Trường hợp sử dụng 2: chỉ báo tiến trình
Một lợi ích khác của việc phân chia các tác vụ nặng cho các tập lệnh trình duyệt là chúng ta có thể hiển thị chỉ báo tiến độ.
Thông thường trình duyệt hiển thị sau khi code hiện đang chạy hoàn tất. Không thành vấn đề nếu nhiệm vụ mất nhiều thời gian. Các thay đổi đối với DOM chỉ được vẽ sau khi tác vụ hoàn thành.
Mặt khác, điều đó thật tuyệt, bởi vì hàm của chúng ta có thể tạo ra nhiều phần tử, thêm từng phần tử một vào tài liệu và thay đổi kiểu của chúng – khách truy cập sẽ không thấy bất kỳ trạng thái “trung gian”, chưa hoàn thành nào. Một điều quan trọng, phải không?
Đây là bản demo, những thay đổi đối với sẽ không hiển thị cho đến khi hàm kết thúc, vì vậy chúng ta sẽ chỉ thấy giá trị cuối cùng:
<div id="progress"></div>
<script>
function count() {
for (let i = 0; i < 1e6; i++) {
i++;
progress.innerHTML = i;
}
}
count();
</script>
… Nhưng chúng ta cũng có thể muốn hiển thị một cái gì đó trong quá trình tác vụ, ví dụ như thanh tiến trình.
Nếu chúng ta chia nhiệm vụ nặng nề thành nhiều phần bằng cách sử dụng setTimeout, thì các thay đổi sẽ được tô ở giữa chúng.
Cái này trông đẹp hơn:
<div id="progress"></div>
<script>
let i = 0;
function count() {
// do a piece of the heavy job (*)
do {
i++;
progress.innerHTML = i;
} while (i % 1e3 != 0);
if (i < 1e7) {
setTimeout(count);
}
}
count();
</script>
Bây giờ, các giá trị <div>
ngày càng tăng của i, một loại thanh tiến trình.
4. Trường hợp sử dụng 3: làm gì đó sau sự kiện
Trong một trình xử lý sự kiện, chúng ta có thể quyết định hoãn một số hành động cho đến khi sự kiện nổi lên và được xử lý ở tất cả các cấp. Chúng ta có thể làm điều đó bằng cách gói mã trong thời gian trễ bằng 0 setTimeout.
Trong chương Điều phối sự kiện tùy chỉnh, chúng ta đã thấy một ví dụ: sự kiện tùy chỉnh menu-open được gửi đến setTimeout, để nó xảy ra sau khi sự kiện “nhấp chuột” được xử lý hoàn toàn.
menu.onclick = function() {
// ...
// create a custom event with the clicked menu item data
let customEvent = new CustomEvent("menu-open", {
bubbles: true
});
// dispatch the custom event asynchronously
setTimeout(() => menu.dispatchEvent(customEvent));
};
5. Macrotasks và Microtasks
Cùng với macrotasks , được mô tả trong chương này, còn tồn tại các nhiệm vụ cực nhỏ (Macrotasks) , được đề cập trong chương Microtasks .
Các vi nhiệm chỉ đến từ code của chúng ta. Chúng thường được tạo ra bởi các lời hứa: một .then/catch/finallytrình xử lý thực thi sẽ trở thành một microtask. Các vi nhiệm vụ cũng được sử dụng “dưới vỏ bọc” await, vì nó là một hình thức xử lý lời hứa.
Ngoài ra còn có một hàm đặc biệt queueMicrotask(func) để thực hiện trong hàng đợi microtask.
Ngay sau mỗi macrotask , công cụ sẽ thực thi tất cả các tác vụ từ hàng đợi microtask , trước khi chạy bất kỳ macrotask nào khác hoặc kết xuất hoặc bất kỳ thứ gì khác.
Ví dụ, hãy xem:
setTimeout(() => alert("timeout"));
Promise.resolve()
.then(() => alert("promise"));
alert("code");
Thứ tự ở đây là gì?
- code hiển thị đầu tiên, vì đó là một cuộc gọi đồng bộ thông thường.
- promise hiển thị thứ hai, bởi vì .then đi qua hàng đợi macrotask và chạy sau code hiện tại.
- timeout hiển thị cuối cùng, vì đó là một macrotask.
Hình ảnh vòng lặp sự kiện phong phú hơn trông như thế này (thứ tự từ trên xuống dưới, nghĩa là: tập lệnh trước, sau đó đến vi nhiệm, kết xuất, v.v.):
Tất cả các vi nhiệm vụ được hoàn thành trước khi xử lý hoặc hiển thị sự kiện hoặc bất kỳ nhiệm vụ nào khác diễn ra.
Điều đó rất quan trọng, vì nó đảm bảo rằng môi trường ứng dụng về cơ bản là giống nhau (không có thay đổi tọa độ chuột, không có dữ liệu mạng mới, v.v.) giữa các vi nhiệm vụ.
Nếu chúng ta muốn thực thi một hàm không đồng bộ (sau mã hiện tại), nhưng trước khi các thay đổi được hiển thị hoặc các sự kiện mới được xử lý, chúng ta có thể lập lịch cho nó queueMicrotask.
Đây là một ví dụ với “thanh tiến trình đếm”, tương tự như thanh được hiển thị trước đó, nhưng queueMicrotask được sử dụng thay vì setTimeout. Bạn có thể thấy rằng nó hiển thị ở cuối. Cũng giống như code đồng bộ:
<div id="progress"></div>
<script>
let i = 0;
function count() {
// do a piece of the heavy job (*)
do {
i++;
progress.innerHTML = i;
} while (i % 1e3 != 0);
if (i < 1e6) {
queueMicrotask(count);
}
}
count();
</script>
6. Tóm lược
Thuật toán chi tiết hơn của vòng lặp sự kiện (mặc dù vẫn được đơn giản hóa so với đặc tả ):
- Xếp hàng và chạy tác vụ cũ nhất từ hàng đợi macrotask (ví dụ: “script”).
- Thực thi tất cả các vi nhiệm :
- Trong khi hàng đợi microtask không trống:
- Dequeue và chạy microtask cũ nhất.
- Trong khi hàng đợi microtask không trống:
- Hiển thị các thay đổi nếu có.
- Nếu hàng đợi macrotask trống, hãy đợi cho đến khi macrotask xuất hiện.
- Chuyển sang bước 1.
Để lên lịch cho một macrotask mới :
- Sử dụng không bị trì hoãn setTimeout(f).
Điều đó có thể được sử dụng để chia một nhiệm vụ nặng tính toán lớn thành nhiều phần, để trình duyệt có thể phản ứng với các sự kiện của người dùng và hiển thị tiến trình giữa chúng.
Ngoài ra, được sử dụng trong trình xử lý sự kiện để lên lịch một hành động sau khi sự kiện được xử lý hoàn toàn (đã hoàn tất).
Để lên lịch cho một microtask mới
- Sử dụng queueMicrotask(f).
- Các trình xử lý hứa cũng đi qua hàng đợi microtask.
Không có giao diện người dùng hoặc xử lý sự kiện mạng giữa các nhiệm vụ cực nhỏ: chúng chạy ngay lập tức cái khác.
Vì vậy, người ta có thể muốn thực thi một hàm queueMicrotask không đồng bộ, nhưng trong trạng thái môi trường.
Nguồn và tài liệu tiếng anh tham khảo:
Full series tự học Javascript từ cơ bản tới nâng cao tại đây nha.
Nếu bạn thấy hay và hữu ích, bạn có thể tham gia các kênh sau của cafedev để nhận được nhiều hơn nữa:
Chào thân ái và quyết thắng!