Nếu bạn là người đã nghe về các thuật ngữ vòng lặp sự kiện, hàng đợi gọi lại, mô hình đồng thời và ngăn xếp gọi nhưng thực sự không hiểu chúng có nghĩa gì, bài viết này dành cho bạn. Nói như vậy, nếu bạn là một nhà phát triển có kinh nghiệm, bài viết này có thể giúp bạn hiểu về cách làm việc bên trong ngôn ngữ và giúp bạn viết giao diện người dùng hiệu suất cao hơn.
JavaScript là một ngôn ngữ đã phát triển một cách đáng kể trong thập kỷ qua và đã mở rộng sự ảnh hưởng của mình ở nhiều cấp độ trong ngăn xếp phát triển, ví dụ như giao diện người dùng, phía máy chủ, ứng dụng kết hợp, v.v. Đã qua rồi những ngày chúng ta chỉ nói về JavaScript trong ngữ cảnh của trình duyệt. Mặc dù nó rất phổ biến và có nhu cầu tăng lên, nhưng rất ít nhà phát triển thực sự hiểu cách ngôn ngữ hoạt động bên trong. Bài viết này là một cố gắng để làm rõ và nhấn mạnh cách JavaScript hoạt động và điều gì làm cho nó kỳ lạ khi so sánh với các ngôn ngữ bạn có thể đã sử dụng trước đây.
Tổng quan
Không giống như các ngôn ngữ như C++ hoặc Ruby, JavaScript là một ngôn ngữ đơn luồng. Điều đó có nghĩa là nó chỉ có thể làm một việc vào một thời điểm và trong khi nó đang làm điều đó, nó không thể làm bất kỳ thứ gì khác. Mặc dù điều này có lợi ích riêng của nó khi bạn không cần phải đối mặt với sự phức tạp đi kèm với các ngôn ngữ đa luồng như deadlocks, nhưng nó cũng có nghĩa là bạn không thể thực hiện các thao tác phức tạp như chỉnh sửa hình ảnh hoặc bất kỳ quy trình nặng nào khác vì trình duyệt sẽ tạm dừng tất cả mọi thứ khác để thực hiện thao tác đó.
Môi trường Thực thi JavaScript
Bạn có thể đã nghe về V8, trình chạy JavaScript mạnh mẽ của Chrome. Một trình chạy JS bao gồm hai thành phần chính – Heap (Ngăn xếp) và Call Stack (Ngăn xếp gọi). Heap là nơi mà tất cả việc cấp phát (và giải phóng) bộ nhớ diễn ra. Ngăn xếp gọi đơn giản là một cấu trúc dữ liệu ghi lại vị trí hiện tại trong chương trình. Điều đó có nghĩa nếu có một ngữ cảnh thực thi hiện diện trong chương trình, nó sẽ đẩy nó vào ngăn xếp và lấy ra ngữ cảnh khi gặp một câu lệnh return. Trong hầu hết các tình huống, ngữ cảnh thực thi chỉ là một cuộc gọi hàm. Để làm cho điều này rõ hơn, hãy xem qua chương trình đơn giản này và hình dung cách nó được thực thi bên trong Ngăn xếp gọi.
function multiply(a, b) {
return a*b;
}
function square(a) {
const sq = multiply(a, a);
console.log(sq);
}
square(3);
Trình chạy JS sẽ khởi tạo bộ nhớ cho các khai báo hàm trong Heap. Khi nó đến dòng 11, nó sẽ gặp một thực thi hàm và JS runtime sẽ đẩy nó vào Ngăn xếp gọi. Các bước sau đây minh họa trạng thái của ngăn xếp gọi tại mỗi bước –
Mỗi mục trong Ngăn xếp gọi được gọi là một khung ngăn xếp. Bạn có thể đã thấy nó khi trình duyệt in ra dấu vết ngăn xếp khi xảy ra lỗi.
function customError() {
throw new Error("Print the stack trace from here!!");
}
function foo() {
customError();
}
function bar() {
foo();
}
bar();
Giả sử tệp được đặt tên là main.js
, dấu vết ngăn xếp sau đây sẽ được hiển thị trên bảng điều khiển.
Hãy tưởng tượng chúng ta có một hàm đệ quy gọi chính nó vô hạn lần. Ví dụ –
function bar() {
bar();
}
bar();
Trên thực thi, hàm bar sẽ được thêm vào ngăn xếp gọi sau mỗi lần gọi cho đến khi ngăn xếp gọi cuối cùng cạn kiệt bộ nhớ và ném ra lỗi vượt quá phạm vi. Điều này nổi tiếng được biết đến như đầy ngăn xếp.
Hãy nói về bất đồng bộ
Trước khi chúng ta thực sự đi sâu vào mã bất đồng bộ, chúng ta sẽ thấy làm thế nào mã chặn hoặc đồng bộ ảnh hưởng đến Ngăn xếp gọi của chúng ta. Hãy xem ví dụ mã jQuery dưới đây.
$.ajaxSetup({async:false});
const resposne1 = $.get('https://example.com/api/data1', (res) => {
return res;
});
const resposne2 = $.get('https://example.com/api/data2', (res) => {
return res;
});
const resposne3 = $.get('https://example.com/api/data3', (res) => {
return res;
});
console.log(resposne1);
console.log(resposne2);
console.log(resposne3);
Dòng đầu tiên cơ bản là đặt tất cả các yêu cầu ajax thành đồng bộ. Do đó, trong khi chúng ta đang chờ phản hồi từ các yêu cầu ajax, Ngăn xếp gọi bị chặn và chương trình của chúng ta sẽ không phản hồi trong thời gian đó. Nếu bạn mô phỏng cùng một hành vi của mã trên trong trình duyệt, tất cả các phần tử khác của trang sẽ vào trạng thái bị chặn, tức là bạn không thể tương tác với chúng cho đến khi chúng thoát khỏi trạng thái bị chặn. Đây là lý do tại sao thực hành thực hiện các yêu cầu mạng đồng bộ hoặc bất kỳ hoạt động nào khác đòi hỏi thời gian tính toán lớn là một thực hành không tốt.
Giải pháp cho điều này khá đơn giản – gọi lại bất đồng bộ. Bạn có thể đã sử dụng mã bất đồng bộ trong chương trình của bạn. Các API như setTimeout
hoặc các yêu cầu xhr
là bất đồng bộ. Nhưng trước khi chúng ta tìm hiểu cách chúng hoạt động thực sự, hãy hình dung xem Ngăn xếp gọi trông như thế nào khi nó thực thi mã bất đồng bộ.
Khi chạy, khi chương trình gặp một setTimeout
, nó đưa nó vào hàng đợi để thực thi sau một khoảng thời gian nhất định và di chuyển đến dòng tiếp theo. Sau khi ngăn xếp trống (và thời gian chờ đã kết thúc), hàm gọi lại setTimeout
thần kỳ được đẩy vào ngăn xếp và được thực thi. Chúng ta sẽ thấy cách điều này xảy ra trong phần tiếp theo.
Mô hình đồng thời và Vòng lặp Sự kiện
Bạn có thể nghĩ rằng làm thế nào quá trình đồng thời có thể được thực hiện khi JavaScript chỉ có một luồng. Mặc dù điều này đúng rằng JavaScript runtime chỉ có thể thực hiện một công việc vào một thời điểm, trình duyệt chính nó còn nhiều hơn chỉ là runtime. Trình duyệt bao gồm các yếu tố khác như Web Apis, hàng đợi gọi lại và Vòng lặp Sự kiện. Các Web Apis là các luồng mà bạn có thể yêu cầu và cho họ thực hiện bất kỳ quy trình nào trong khi giữ cho Ngăn xếp gọi rõ ràng.
Đây là cách môi trường JavaScript hoàn chỉnh trông như. Trong ngữ cảnh của node.js, hình ảnh trên vẫn giữ nguyên ngoại trừ việc thay vì Web Apis, bạn có C++ APIs như luồng, hệ thống tệp, v.v. Hãy quay lại mã chúng ta đã thực thi trong phần trước và xem cách nó phù hợp trong bức tranh lớn hơn.
Các API như setTimeout
, xhr
, v.v. không có trong runtime mà thay vào đó được cung cấp bởi Web Apis. Khi bạn gọi hàm setTimout
, nó đăng ký một hàm timer
cùng với gọi lại. Khi hết thời gian của bộ đếm, nó gửi gọi lại vào hàng đợi sự kiện, sau đó Vòng lặp Sự kiện đẩy nó vào Ngăn xếp gọi khi Ngăn xếp gọi trống.
Vòng lặp Sự kiện có một nhiệm vụ duy nhất – nó theo dõi Ngăn xếp gọi và Hàng đợi gọi lại. Khi Ngăn xếp gọi trống, nó lấy sự kiện đầu tiên trong hàng đợi và đẩy nó vào ngăn xếp, hiệu quả chạy nó. Một vòng lặp như vậy được gọi là một tick trong Vòng lặp Sự kiện. Mỗi sự kiện chỉ là một cuộc gọi hàm gọi lại.
Một điều về setTimeout(..)
Một trong những trải nghiệm sớm nhất của tôi với mã bất đồng bộ là như sau –
console.log("I'll execute first!");
setTimeout(() => {
console.log("I'll execute only when stack is empty :(");
}, 0);
console.log("I'll execute second");
Ban đầu, có vẻ như hàm gọi lại của setTimeout
sẽ được thực thi gần như ngay lập tức. Tuy nhiên, quan trọng là lưu ý rằng bộ đếm thời gian chưa bao giờ đặt hàm gọi lại vào Hàng đợi gọi lại (Callback Queue). Khi bộ đếm thời gian hết thời gian, môi trường mới đặt hàm gọi lại vào hàng đợi, và sau đó nó sẽ được đẩy vào Ngăn xếp gọi lại khi nó trống.
Do đó, setTimeout
không khiến cho hàm gọi lại của bạn chạy sau một khoảng thời gian xác định. Thay vào đó, nó chỉ đảm bảo thời gian tối thiểu trước khi hàm gọi lại thực thi. Với một khoảng thời gian chờ là 0 giây, bộ đếm thời gian sẽ hết thời gian gần như ngay lập tức và hàm gọi lại sẽ được đặt vào Hàng đợi gọi lại. Nhưng Vòng lặp Sự kiện vẫn phải chờ đến khi ngăn xếp gọi trống trước khi nó có thể đẩy hàm gọi lại vào đó. Điều này có nghĩa là chúng ta về cơ bản trì hoãn việc thực thi của hàm gọi lại cho đến khi ngăn xếp trống.
Kết luận
Hiểu về môi trường mà chương trình của bạn chạy có thể tăng đáng kể hiệu suất và hiệu quả của bạn như một nhà phát triển. Nó làm nổi bật lý do tại sao một chương trình cụ thể lại hoạt động như cách nó làm. Nếu bạn cảm thấy có điều gì đó thiếu sót hoặc nếu bạn thích bài viết này, hãy để tôi biết trong phần bình luận. Bạn cũng có thể theo dõi tôi trên Github hoặc Twitter nơi tôi chia sẻ những thông tin về lập trình và phát triển nói chung.