Bài viết này khám phá sâu sắc cách trình duyệt web hoạt động, từ kiến trúc tổng thể, các giao thức mạng cơ bản, quy trình điều hướng và hiển thị trang, đến cơ chế thực thi JavaScript và nguyên lý hoạt động của V8. Cuối cùng, chúng ta sẽ tìm hiểu về các hệ thống vòng lặp của trang và các công nghệ web hiện đại như Virtual DOM, PWA và Web Components.
Tổng quan về kiến trúc trình duyệt
Kiến trúc của Chrome
Để hiểu cách trình duyệt hoạt động, trước tiên cần nắm vững các khái niệm cơ bản về xử lý tác vụ:
- Xử lý song song: Thực hiện nhiều tác vụ cùng lúc, giúp giảm số bước thực thi và tăng hiệu quả.
- Luồng (Thread): Đơn vị thực thi nhỏ nhất trong một tiến trình.
- Luồng đơn: Xử lý tác vụ tuần tự.
- Đa luồng: Xử lý tác vụ song song.
- Tiến trình (Process): Khi khởi chạy một chương trình, hệ điều hành cấp phát một vùng nhớ để chứa mã, dữ liệu đang chạy và một luồng chính để thực thi tác vụ. Môi trường chạy này được gọi là tiến trình. Mỗi tiến trình là một phiên bản chạy của chương trình. Luồng không thể tồn tại độc lập mà cần tiến trình để khởi tạo và quản lý.
Đặc điểm mối quan hệ giữa luồng và tiến trình:
- Bất kỳ luồng nào trong tiến trình gặp lỗi đều có thể làm sập toàn bộ tiến trình.
- Các luồng chia sẻ dữ liệu trong cùng tiến trình.
- Khi một tiến trình đóng, hệ điều hành thu hồi bộ nhớ mà tiến trình đó chiếm giữ.
- Nội dung giữa các tiến trình được cách ly lẫn nhau.
Kỷ nguyên trình duyệt đơn tiến trình
Trong kỷ nguyên này, tất cả các module chức năng của trình duyệt (mạng, plugin, môi trường JavaScript, engine hiển thị, trang web) đều chạy trong cùng một tiến trình. Điều này dẫn đến các vấn đề:
- Không ổn định: Sự cố của một module có thể làm sập toàn bộ trình duyệt.
- Không mượt mà: Chỉ một module có thể thực thi tại một thời điểm. Một module bị chặn (ví dụ: JavaScript) sẽ ảnh hưởng đến các module khác. Ngoài ra, việc đóng một trang phức tạp có thể không giải phóng hoàn toàn bộ nhớ do chia sẻ tiến trình.
- Không an toàn: Plugin trình duyệt có thể truy cập tài nguyên hệ thống. Script trên trang có thể khai thác lỗ hổng để chiếm quyền hệ thống và thực hiện hành vi độc hại.
Kỷ nguyên trình duyệt đa tiến trình
Để giải quyết các vấn đề trên, Chrome đã chuyển sang kiến trúc đa tiến trình với các thành phần chính:
- Tiến trình trình duyệt (Browser Process): Chịu trách nhiệm hiển thị giao diện, tương tác người dùng, quản lý các tiến trình con và cung cấp các chức năng lưu trữ.
- Tiến trình hiển thị (Renderer Process): Nhiệm vụ chính là chuyển đổi HTML, CSS và JavaScript thành trang web tương tác. Các engine như Blink (render) và V8 (JavaScript) chạy trong tiến trình này. Mặc định, Chrome tạo một tiến trình hiển thị riêng cho mỗi tab. Vì lý do bảo mật, tiến trình này hoạt động trong chế độ sandbox.
- Tiến trình GPU (GPU Process): Ban đầu không có. Được thêm vào để hỗ trợ hiệu ứng CSS 3D, sau đó trở thành yếu tố phổ biến cho việc vẽ giao diện web và UI của Chrome.
- Tiến trình mạng (Network Process): Chịu trách nhiệm tải tài nguyên mạng cho trang. Trước đây là một module trong tiến trình trình duyệt, gần đây đã được tách ra thành một tiến trình độc lập.
- Tiến trình Plugin (Plugin Process): Quản lý hoạt động của các plugin. Vì plugin dễ gặp sự cố, việc cách ly chúng trong một tiến trình riêng giúp đảm bảo sự cố plugin không ảnh hưởng đến trình duyệt hoặc các trang khác.
Kiến trúc đa tiến trình giải quyết các vấn đề của trình duyệt đơn tiến trình như sau:
- Không ổn định: Do cách ly tiến trình, sự cố của một trang hoặc plugin chỉ ảnh hưởng đến tiến trình hiện tại, không làm sập trình duyệt hoặc các trang khác.
- Không mượt mà: Ngay cả khi JavaScript làm chậm tiến trình hiển thị, nó chỉ ảnh hưởng đến trang hiện tại. Các script của trang khác vẫn chạy trong tiến trình hiển thị riêng của chúng. Đối với rò rỉ bộ nhớ, khi một trang đóng, toàn bộ tiến trình hiển thị cũng đóng, giải phóng bộ nhớ đã chiếm dụng.
- Không an toàn: Sử dụng sandbox an toàn. Ngay cả khi có chương trình độc hại chạy trong tiến trình hiển thị hoặc plugin, nó không thể vượt qua sandbox để chiếm quyền hệ thống.
Kiến trúc hướng dịch vụ trong tương lai
Kiến trúc hiện tại vẫn có những hạn chế:
- Tiêu thụ tài nguyên cao hơn: Mỗi tiến trình bao gồm bản sao của các cấu trúc nền tảng chung (ví dụ: môi trường chạy JavaScript), dẫn đến tiêu thụ nhiều bộ nhớ hơn.
- Kiến trúc phức tạp hơn: Các module trình duyệt có độ ghép nối cao, khả năng mở rộng kém, khó đáp ứng các yêu cầu mới.
Chrome đang chuyển sang kiến trúc hướng dịch vụ, tương tự như các hệ điều hành hiện đại. Các module sẽ được tái cấu trúc thành các dịch vụ độc lập, mỗi dịch vụ có thể chạy trong một tiến trình riêng. Việc truy cập dịch vụ phải thông qua các giao diện đã định nghĩa và giao tiếp qua IPC (Inter-Process Communication), xây dựng một hệ thống gắn kết hơn, ít ghép nối, dễ bảo trì và mở rộng.
Chrome dự kiến tái cấu trúc các module UI, cơ sở dữ liệu, tệp, thiết bị, mạng thành các dịch vụ cơ bản, tương tự như các dịch vụ hệ điều hành. Đồng thời, Chrome cung cấp kiến trúc linh hoạt: trên các thiết bị hiệu suất cao, các dịch vụ cơ bản sẽ chạy đa tiến trình; trên các thiết bị có tài nguyên hạn chế, nhiều dịch vụ sẽ được tích hợp vào một tiến trình để tiết kiệm bộ nhớ.
Quá trình chuyển đổi sang kiến trúc hướng dịch vụ sẽ mất vài năm. Tuy nhiên, Chrome phát triển dần dần, các tính năng mới được thêm vào liên tục.
Vấn đề: Một trang bị treo khiến tất cả các trang khác sập
Mặc định, Chrome phân bổ một tiến trình hiển thị cho mỗi tab. Tuy nhiên, nếu một trang mở một trang mới và trang mới này cùng nguồn gốc (same-site) với trang cha, thì trang mới sẽ tái sử dụng tiến trình hiển thị của trang cha. Chính sách này được gọi là process-per-site-instance. Điều này có nghĩa là nếu một số trang cùng nguồn gốc, chúng sẽ được gán vào cùng một tiến trình hiển thị. Do đó, trong trường hợp này, nếu một trang gặp sự cố, tất cả các trang cùng nguồn gốc sẽ sập theo vì chúng dùng chung một tiến trình hiển thị.
Giao thức TCP
IP: Chuyển gói tin đến máy chủ đích
Giao thức Internet (IP) thuộc tầng mạng, chịu trách nhiệm chuyển gói tin đến máy tính đích. Dữ liệu trên Internet được truyền qua các gói tin, và các gói tin này phải tuân thủ chuẩn IP. Nếu dữ liệu gửi đi lớn, nó sẽ được chia thành nhiều gói nhỏ để truyền.
Địa chỉ máy tính được gọi là địa chỉ IP. Việc truy cập bất kỳ trang web nào thực chất là máy tính của bạn yêu cầu thông tin từ một máy tính khác. Các thiết bị trực tuyến khác nhau trên Internet đều có địa chỉ duy nhất, tương tự như địa chỉ người nhận khi gửi bưu phẩm.
Để gửi một gói tin từ máy chủ A đến máy chủ B, trước khi truyền, gói tin sẽ được gắn thông tin địa chỉ IP của máy chủ B để định tuyến chính xác. Ngoài ra, địa chỉ IP của máy chủ A cũng được gắn vào để máy chủ B có thể phản hồi thông tin. Các thông tin bổ sung này được đóng gói vào một cấu trúc dữ liệu gọi là tiêu đề IP (IP Header).
UDP: Chuyển gói tin đến ứng dụng
Giao thức Datagram Người dùng (UDP) thuộc tầng truyền tải, chịu trách nhiệm chuyển dữ liệu đến ứng dụng. Một thông tin quan trọng nhất trong UDP là số cổng (port number), đây là một số và mỗi chương trình muốn truy cập mạng đều cần liên kết với một số cổng. IP chuyển gói tin đến máy tính cụ thể thông qua địa chỉ IP, còn UDP phân phối gói tin đến đúng chương trình thông qua số cổng.
Tương tự như tiêu đề IP, số cổng được đóng gói vào tiêu đề UDP (UDP Header). Tiêu đề UDP sau đó được hợp nhất với gói dữ liệu gốc để tạo thành gói UDP mới.
Ưu và nhược điểm của giao thức UDP:
- Ưu điểm: Tốc độ truyền rất nhanh.
- Nhược điểm: Có thể xảy ra mất gói tin, không đảm bảo tính toàn vẹn của dữ liệu; không thể tái tạo các gói tin nhỏ thành một tệp hoàn chỉnh.
TCP: Chuyển gói tin đến ứng dụng một cách toàn vẹn
Giao thức Điều khiển Truyền tải (TCP) là một giao thức truyền thông tầng truyền tải hướng kết nối, đáng tin cậy, dựa trên luồng byte. Giao thức này giải quyết hai vấn đề của UDP: tính toàn vẹn dữ liệu và việc lắp ráp gói tin.
So với UDP, TCP có hai đặc điểm sau:
- Đối với các trường hợp mất gói tin, TCP cung cấp cơ chế truyền lại.
- Giới thiệu cơ chế sắp xếp gói tin, đảm bảo các gói tin không theo thứ tự được sắp xếp lại thành một tệp hoàn chỉnh.
Tương tự như tiêu đề UDP, tiêu đề TCP ngoài việc chứa số cổng đích và số cổng cục bộ, còn cung cấp số thứ tự để phía nhận có thể sắp xếp lại các gói tin.
Quá trình kết nối của TCP để đảm bảo truyền dữ liệu toàn vẹn:
- Giai đoạn thiết lập kết nối: Thiết lập kết nối giữa client và server thông qua "three-way handshake" (bắt tay ba bước). TCP cung cấp truyền thông hướng kết nối, nghĩa là chuẩn bị sẵn sàng giữa hai đầu trước khi bắt đầu truyền dữ liệu.
- Giai đoạn truyền dữ liệu: Phía nhận xác nhận từng gói tin. Sau khi nhận được gói tin, cần gửi gói tin xác nhận về phía gửi. Nếu phía gửi gửi một gói tin mà không nhận được tin nhắn xác nhận từ phía nhận trong thời gian quy định, gói tin sẽ được coi là bị mất và kích hoạt cơ chế gửi lại của phía gửi. Tương tự, một tệp lớn được chia thành nhiều gói tin nhỏ trong quá trình truyền. Khi các gói tin này đến phía nhận, phía nhận sẽ sắp xếp chúng theo số thứ tự trong tiêu đề TCP để đảm bảo tạo thành dữ liệu hoàn chỉnh.
- Giai đoạn ngắt kết nối: Sau khi truyền dữ liệu hoàn tất, kết nối cần được chấm dứt. Giai đoạn này liên quan đến "four-way handshake" (bốn bước kết thúc) để đảm bảo cả hai bên đều ngắt kết nối.
Để đảm bảo độ tin cậy của việc truyền dữ liệu, TCP đã phải hy sinh tốc độ truyền. Các cơ chế như "three-way handshake" và "packet verification" đã làm tăng gấp đôi số lượng gói tin trong quá trình truyền. Do đó, đối với các lĩnh vực ứng dụng không yêu cầu nghiêm ngặt về tính toàn vẹn dữ liệu, chẳng hạn như video trực tuyến, trò chơi tương tác, giao thức UDP phù hợp hơn.
Bổ sung: Tại sao cần bắt tay ba bước và bốn bước kết thúc?
- Tại sao cần bắt tay ba bước?
Để đạt được truyền dữ liệu đáng tin cậy, cả hai bên giao tiếp TCP phải duy trì một số thứ tự để xác định gói dữ liệu đã gửi nào đã được bên kia nhận. Quá trình bắt tay ba bước là bước cần thiết để cả hai bên thông báo số thứ tự bắt đầu cho nhau và xác nhận rằng bên kia đã nhận được số thứ tự bắt đầu. Nếu chỉ có hai bước bắt tay, nhiều nhất chỉ có số thứ tự bắt đầu của bên khởi tạo kết nối có thể được xác nhận, số thứ tự do bên kia chọn sẽ không được xác nhận.
- Tại sao cần bốn bước kết thúc?
Trong quá trình bắt tay ba bước, SYN và ACK được gửi cùng nhau. Tuy nhiên, trong bốn bước kết thúc, FIN và ACK lại không được gửi cùng nhau mà được gửi riêng lẻ. Kết nối TCP là song công hoàn toàn, nghĩa là việc nhận FIN chỉ báo hiệu rằng không còn dữ liệu nào được gửi đến, nhưng vẫn có thể gửi dữ liệu đi. Nói cách khác, việc nhận một FIN chỉ đóng một chiều truyền dữ liệu, chiều còn lại vẫn có thể tiếp tục gửi dữ liệu. Hai bước kết thúc đầu tiên đóng một chiều kết nối; hai bước kết thúc sau đóng chiều còn lại.
Quy trình yêu cầu HTTP
1. Xây dựng yêu cầu
Trình duyệt xây dựng thông tin dòng yêu cầu, sẵn sàng gửi yêu cầu mạng.
GET /index.html HTTP/1.1
2. Tìm kiếm cache
Trước khi gửi yêu cầu, trình duyệt sẽ đọc tệp cache. Nếu tìm thấy tài nguyên được yêu cầu trong cache, nó sẽ chặn yêu cầu, trả về bản sao tài nguyên và kết thúc yêu cầu. Nếu tìm kiếm cache thất bại, trình duyệt sẽ tiến hành yêu cầu mạng, tải lại từ máy chủ gốc. Đối với trang web, cache là một phần quan trọng để tải tài nguyên nhanh chóng, giảm tải máy chủ và cải thiện hiệu suất.
3. Phân giải DNS
Trình duyệt sử dụng HTTP làm giao thức tầng ứng dụng để đóng gói thông tin yêu cầu văn bản; sử dụng TCP làm giao thức tầng truyền tải để gửi nó lên mạng. Do đó, trước khi HTTP bắt đầu hoạt động, trình duyệt cần thiết lập kết nối với máy chủ thông qua TCP, nghĩa là nội dung HTTP được truyền qua giai đoạn truyền dữ liệu của TCP.
Bước đầu tiên để thiết lập kết nối TCP là lấy địa chỉ IP và số cổng. Dịch vụ DNS chịu trách nhiệm ánh xạ và chuyển đổi URL tên miền thành địa chỉ IP. Nếu URL không chỉ định cổng, mặc định là 80.
Trình duyệt cung cấp dịch vụ cache DNS. Nếu một tên miền đã được phân giải, trình duyệt sẽ cache kết quả để sử dụng trực tiếp cho các truy vấn tiếp theo, giúp giảm một yêu cầu mạng.
4. Thiết lập kết nối TCP
Chrome có một cơ chế: một tên miền chỉ có thể thiết lập tối đa 6 kết nối TCP đồng thời. Các yêu cầu vượt quá giới hạn này sẽ vào trạng thái chờ đợi trong hàng đợi cho đến khi các yêu cầu đang xử lý hoàn thành. Sau khi số lượng kết nối dưới 6 hoặc hàng đợi chờ kết thúc, kết nối TCP sẽ bắt đầu. Sau "ba bước bắt tay", kênh truyền dữ liệu được thiết lập và dữ liệu HTTP được truyền đi.
5. Gửi yêu cầu HTTP
Trình duyệt sẽ gửi dòng yêu cầu đến máy chủ, bao gồm phương thức yêu cầu, URL yêu cầu và phiên bản giao thức HTTP. Gửi dòng yêu cầu là để thông báo cho máy chủ trình duyệt cần tài nguyên gì. Phương thức yêu cầu phổ biến nhất là GET. Một phương thức yêu cầu phổ biến khác là POST, được sử dụng để gửi dữ liệu đến máy chủ.
6. Máy chủ xử lý yêu cầu HTTP
Đầu tiên, máy chủ sẽ trả về dòng phản hồi, bao gồm phiên bản giao thức và mã trạng thái. Máy chủ sẽ thông báo kết quả xử lý cho trình duyệt thông qua mã trạng thái. Giống như trình duyệt gửi tiêu đề yêu cầu, máy chủ cũng gửi tiêu đề phản hồi cùng với phản hồi. Sau khi gửi tiêu đề phản hồi, máy chủ có thể tiếp tục gửi dữ liệu nội dung phản hồi, thường chứa nội dung HTML thực tế.
Nếu trình duyệt hoặc máy chủ thêm tiêu đề Connection: Keep-Alive, kết nối TCP sẽ được giữ mở sau khi gửi, cho phép trình duyệt tiếp tục gửi yêu cầu thông qua cùng một kết nối TCP. Duy trì kết nối TCP giúp tiết kiệm thời gian thiết lập kết nối cho yêu cầu tiếp theo, tăng tốc độ tải tài nguyên. Các phiên bản HTTP trước HTTP/1.1 mặc định là kết nối không bền vững; muốn duy trì kết nối bền vững, cần chỉ định giá trị của trường tiêu đề Connection là Keep-Alive. HTTP/1.1 trở lên mặc định là kết nối bền vững.
Các vấn đề liên quan
- Tại sao nhiều trang web tải rất nhanh khi mở lần thứ hai?
Lý do chính là trong quá trình tải trang lần đầu tiên, một số dữ liệu tốn thời gian đã được cache, chủ yếu là cache DNS và cache tài nguyên trang. Sau khi DNS được trình duyệt cache, lần yêu cầu tiếp theo sẽ tiết kiệm thời gian truy vấn DNS. Cache tài nguyên trang chủ yếu được thiết lập thông qua trường
Cache-Controltrong tiêu đề phản hồi để quyết định có cache tài nguyên đó hay không:Cache-Control: Max-age=2000Nếu cache hết hạn, trình duyệt sẽ tiếp tục gửi yêu cầu mạng và thêm vào tiêu đề yêu cầu HTTP:
If-None-Match: "4f80f-13c-3a1xb12a"Máy chủ sau khi nhận được tiêu đề yêu cầu sẽ dựa vào giá trị
If-None-Matchđể xác định tài nguyên được yêu cầu có cập nhật hay không. Nếu không có cập nhật, nó sẽ trả về mã trạng thái 304 (tương đương với việc máy chủ thông báo cho trình duyệt: cache này vẫn có thể sử dụng, lần này không cần gửi lại dữ liệu cho bạn). Nếu tài nguyên có cập nhật, máy chủ sẽ trực tiếp trả về tài nguyên mới nhất cho trình duyệt. - Trạng thái đăng nhập được duy trì như thế nào?
Phía trình duyệt gửi tài khoản và mật khẩu người dùng đến máy chủ thông qua yêu cầu HTTP. Sau khi xác thực thành công, máy chủ tạo một chuỗi thông tin định danh duy nhất cho người dùng này. Máy chủ thêm chuỗi thông tin định danh này vào bản tin phản hồi HTTP thông qua tiêu đề
Set-Cookie. Trình duyệt sau khi nhận phản hồi sẽ lưu chuỗi đó làm cookie trong trình duyệt dựa trên tiêu đềSet-Cookie. Lần yêu cầu HTTP tiếp theo, trình duyệt sẽ tự động gửi kèm thông tin định danh cookie đã lưu. Máy chủ kiểm tra backend, nếu tìm thấy thông tin người dùng, sẽ trả về dữ liệu phản hồi ở trạng thái đăng nhập cho trình duyệt.
Quy trình điều hướng URL
Quá trình từ khi người dùng gửi yêu cầu URL cho đến khi trang bắt đầu được phân tích cú pháp được gọi là điều hướng.
Các bước bao gồm:
- Nhập liệu của người dùng
- Yêu cầu URL
- Tiếp nhận dữ liệu phản hồi
- Chuẩn bị tiến trình hiển thị (renderer)
- Xác nhận "gửi tài liệu" (commit document)
- Bắt đầu hiển thị
Các bước chi tiết:
- Đầu tiên, tiến trình trình duyệt nhận yêu cầu URL từ người dùng và chuyển tiếp URL đó đến tiến trình mạng.
- Sau đó, tiến trình mạng thực hiện yêu cầu URL thực sự.
- Tiếp theo, tiến trình mạng nhận dữ liệu tiêu đề phản hồi thông qua quy trình yêu cầu HTTP, phân tích và chuyển tiếp chúng đến tiến trình trình duyệt.
- Tiến trình trình duyệt nhận dữ liệu tiêu đề phản hồi, gửi thông điệp "CommitNavigation" đến tiến trình hiển thị (chuẩn bị tiến trình hiển thị).
- Tiến trình hiển thị nhận thông điệp "CommitNavigation", bắt đầu chuẩn bị nhận dữ liệu HTML và thiết lập kênh dữ liệu với tiến trình mạng.
- Sau đó, tiến trình hiển thị "xác nhận gửi" (acknowledge commit) với tiến trình trình duyệt, thông báo đã sẵn sàng: "đã sẵn sàng nhận và phân tích dữ liệu trang".
- Tiến trình trình duyệt nhận được "xác nhận gửi" từ tiến trình hiển thị, bắt đầu loại bỏ tài liệu cũ, cập nhật trạng thái trang trong tiến trình trình duyệt (trạng thái giao diện, trạng thái bảo mật, URL thanh địa chỉ và trạng thái lịch sử tiến/lùi).
Chi tiết các bước:
- Người dùng nhập liệu:
- Khi người dùng nhập từ khóa vào thanh địa chỉ, thanh địa chỉ sẽ xác định từ khóa là nội dung tìm kiếm hay URL yêu cầu.
- Nếu là nội dung tìm kiếm, thanh địa chỉ sẽ sử dụng công cụ tìm kiếm mặc định của trình duyệt để tổng hợp một URL mới kèm từ khóa tìm kiếm.
- Nếu được nhận dạng là URL yêu cầu, ví dụ nhập
example.com, thanh địa chỉ sẽ thêm giao thức và tổng hợp thành URL hoàn chỉnh, nhưhttps://example.com. - Khi người dùng nhập từ khóa và nhấn Enter, điều này có nghĩa là trang hiện tại sắp được thay thế bằng trang mới. Tuy nhiên, trước khi quá trình này tiếp tục, trình duyệt cho phép trang hiện tại một cơ hội thực thi sự kiện
beforeunload. Sự kiệnbeforeunloadcho phép trang thực hiện một số thao tác dọn dẹp dữ liệu trước khi thoát. - Khi trình duyệt bắt đầu tải một địa chỉ, biểu tượng trên tab sẽ chuyển sang trạng thái tải. Nhưng lúc này, trang vẫn hiển thị nội dung của trang đã mở trước đó, không được thay thế ngay lập tức bằng trang mới. Bởi vì cần chờ đến giai đoạn gửi tài liệu thì nội dung trang mới được thay thế.
- Yêu cầu URL:
- Đầu tiên, tiến trình mạng sẽ tìm kiếm trong cache cục bộ xem tài nguyên này có được cache hay không. Nếu có, nó sẽ trả về trực tiếp cho tiến trình trình duyệt; nếu không, nó sẽ tiến hành quy trình yêu cầu mạng.
- Tiến trình trình duyệt sẽ gửi yêu cầu URL đến tiến trình mạng thông qua giao tiếp giữa các tiến trình (IPC).
- Khi vào quy trình yêu cầu, bước đầu tiên là phân giải DNS để lấy địa chỉ IP của máy chủ cho tên miền được yêu cầu.
- Nếu có cache DNS, nó sẽ trực tiếp tìm địa chỉ IP từ cache.
- Nếu giao thức yêu cầu là HTTPS, thì còn cần thiết lập kết nối SSL/TLS.
- Sau đó, thiết lập kết nối TCP với địa chỉ IP.
- Sau khi kết nối được thiết lập, phía trình duyệt sẽ xây dựng dòng yêu cầu, tiêu đề yêu cầu, v.v., và đính kèm các dữ liệu Cookie liên quan đến tên miền đó vào tiêu đề yêu cầu, sau đó gửi thông tin yêu cầu đã xây dựng đến máy chủ.
- Máy chủ sau khi nhận được thông tin yêu cầu sẽ tạo dữ liệu phản hồi dựa trên thông tin yêu cầu và gửi đến tiến trình mạng.
- Khi tiến trình mạng nhận được dòng phản hồi và tiêu đề phản hồi, nó sẽ bắt đầu phân tích nội dung tiêu đề phản hồi.
- Bổ sung khác:
- Chuyển hướng (Redirect): Trong quá trình điều hướng, nếu mã trạng thái của dòng phản hồi máy chủ chứa thông tin chuyển hướng như 301, 302, trình duyệt sẽ chuyển hướng đến địa chỉ mới và tiếp tục điều hướng; nếu dòng phản hồi là 200, điều đó có nghĩa là trình duyệt có thể tiếp tục xử lý yêu cầu này.
- Xử lý kiểu dữ liệu phản hồi:
Content-Typelà một trường rất quan trọng trong tiêu đề HTTP, nó cho trình duyệt biết kiểu dữ liệu của nội dung phản hồi từ máy chủ. Trình duyệt sẽ quyết định cách hiển thị nội dung phản hồi dựa trên giá trị củaContent-Type.
- Đầu tiên, tiến trình mạng sẽ tìm kiếm trong cache cục bộ xem tài nguyên này có được cache hay không. Nếu có, nó sẽ trả về trực tiếp cho tiến trình trình duyệt; nếu không, nó sẽ tiến hành quy trình yêu cầu mạng.
- Chuẩn bị tiến trình hiển thị:
- Sau khi yêu cầu URL nhận được phản hồi, tiến trình mạng sẽ chuyển tiếp thông tin tiêu đề phản hồi đến tiến trình trình duyệt, sau đó tiến trình trình duyệt bắt đầu chuẩn bị tiến trình hiển thị.
- Sau khi tiến trình hiển thị sẵn sàng, nó không thể ngay lập tức chuyển sang trạng thái phân tích tài liệu, vì dữ liệu tài liệu lúc này vẫn nằm trong tiến trình mạng, chưa được gửi đến tiến trình hiển thị. Vì vậy, bước tiếp theo là giai đoạn gửi tài liệu.
- Mặc định, Chrome sẽ phân bổ một tiến trình hiển thị cho mỗi trang, nhưng nếu hai trang thuộc cùng một trang web, chúng sẽ tái sử dụng một tiến trình hiển thị.
- Gửi tài liệu (Commit Document):
- Đầu tiên, khi tiến trình trình duyệt nhận được dữ liệu tiêu đề phản hồi từ tiến trình mạng, nó sẽ gửi thông điệp "gửi tài liệu" đến tiến trình hiển thị.
- Tiến trình hiển thị nhận được thông điệp "gửi tài liệu" sẽ thiết lập kênh truyền dữ liệu với tiến trình mạng.
- Sau khi dữ liệu tài liệu từ tiến trình mạng được truyền hoàn tất, tiến trình hiển thị sẽ trả về thông điệp "xác nhận gửi" cho tiến trình trình duyệt, bắt đầu tiến hành hiển thị.
- Tiến trình trình duyệt sau khi nhận được thông điệp "xác nhận gửi" sẽ cập nhật trạng thái giao diện trình duyệt (bao gồm trạng thái bảo mật, URL thanh địa chỉ, trạng thái lịch sử tiến/lùi, và cập nhật trang Web – việc hiển thị nội dung trang sẽ phụ thuộc vào tiến độ của tiến trình hiển thị).
- Giai đoạn hiển thị (Render Stage):
- ... (Nội dung này sẽ được trình bày chi tiết ở phần tiếp theo)
Quy trình hiển thị trang
Quy trình hiển thị của trình duyệt là một chuỗi các bước quan trọng để chuyển đổi mã nguồn thành hình ảnh mà người dùng nhìn thấy trên màn hình.
Các bước của quy trình hiển thị (pipeline):
- Xây dựng cây DOM: Chuyển đổi nội dung HTML thành cấu trúc cây DOM.
- Tính toán kiểu dáng (Style Calculation): Chuyển đổi CSS thành
styleSheetsvà tính toán kiểu dáng cụ thể cho từng nút DOM. - Bố cục (Layout): Tạo cây bố cục và tính toán thông tin vị trí hình học của các phần tử.
- Phân lớp (Layering): Dựa trên cây bố cục, tạo cây lớp (Layer Tree).
- Vẽ (Paint): Tạo danh sách các lệnh vẽ cho mỗi lớp, gửi chúng đến luồng tổng hợp.
- Tổng hợp (Compositing):
- Phân lớp thành các ô (tiles).
- Raster hóa (rasterize) các ô thành bitmap.
- Gửi lệnh vẽ ô "DrawQuad" đến tiến trình trình duyệt.
- Hiển thị (Display): Trình duyệt hiển thị hình ảnh lên màn hình.
Mô tả chi tiết quy trình:
Tiến trình hiển thị (renderer process) chuyển đổi nội dung HTML thành cấu trúc cây DOM mà trình duyệt có thể hiểu được.
Engine hiển thị chuyển đổi bảng kiểu CSS thành styleSheets mà trình duyệt có thể hiểu, tính toán kiểu dáng của các nút DOM.
Tạo cây bố cục (Layout Tree), tính toán thông tin bố cục của các phần tử.
Phân lớp cây bố cục, tạo cây lớp (Layer Tree).
Tạo danh sách vẽ (Paint List) cho mỗi lớp, gửi nó đến luồng tổng hợp.
Luồng tổng hợp chia các lớp thành các ô (tiles), và trong nhóm luồng raster hóa, chuyển đổi các ô thành bitmap.
Luồng tổng hợp gửi lệnh vẽ ô DrawQuad đến tiến trình trình duyệt.
Tiến trình trình duyệt dựa trên thông điệp DrawQuad tạo trang và hiển thị lên màn hình.
Xây dựng cây DOM (DOM)
Trình duyệt không thể trực tiếp hiểu và sử dụng HTML, do đó cần chuyển đổi HTML thành cấu trúc mà trình duyệt có thể hiểu được. Quá trình xây dựng cây DOM như sau:
- Tiến trình mạng nhận dữ liệu phản hồi, dựa vào trường
Content-Typetrong tiêu đề phản hồi để xác định kiểu tệp. - Nếu là tệp HTML, nó sẽ chọn hoặc tạo một tiến trình hiển thị cho yêu cầu này.
- Sau khi tiến trình hiển thị sẵn sàng, tiến trình mạng và tiến trình hiển thị sẽ thiết lập một kênh chia sẻ dữ liệu.
- Tiến trình mạng sau khi nhận dữ liệu sẽ đẩy vào kênh này, còn tiến trình hiển thị sẽ liên tục đọc dữ liệu từ đầu kia của kênh và "cung cấp" dữ liệu đã đọc cho HTML parser.
- HTML parser chuyển đổi luồng byte thành các Token (Tag Token, Text Token).
- HTML parser duy trì một ngăn xếp Token để tính toán mối quan hệ cha-con giữa các nút. Các Token được tạo ở bước trước sẽ được đẩy vào ngăn xếp theo thứ tự.
- Nếu là StartTag Token, HTML parser sẽ tạo một nút DOM cho Token đó, thêm vào cây DOM. Nút cha của nó là nút được tạo từ phần tử liền kề trong ngăn xếp.
- Nếu là Text Token, nó sẽ tạo một nút văn bản, thêm vào cây DOM. Text Token không cần đẩy vào ngăn xếp, nút cha của nó là nút DOM tương ứng với Token trên đỉnh ngăn xếp.
- Nếu là EndTag Token, ví dụ
EndTag div, HTML parser sẽ kiểm tra xem phần tử trên đỉnh ngăn xếp có phải làStartTag divkhông. Nếu có,StartTag divsẽ được bật ra khỏi ngăn xếp, báo hiệu phần tử div đã hoàn tất phân tích. - Các Token mới được tạo bởi phân tích cú pháp liên tục được đẩy vào và bật ra khỏi ngăn xếp, quá trình phân tích tiếp diễn cho đến khi toàn bộ luồng byte được xử lý xong.
Tính toán kiểu dáng (Style Calculation)
Trình duyệt cũng không thể trực tiếp hiểu các kiểu CSS thuần văn bản, cần thông qua engine hiển thị để chuyển đổi văn bản CSS thành cấu trúc mà trình duyệt có thể hiểu được — styleSheets. Mục đích của tính toán kiểu dáng là để tính toán kiểu dáng cụ thể cho mỗi phần tử trong cây DOM.
Các bước tính toán:
- Chuyển đổi văn bản CSS thành cấu trúc mà trình duyệt có thể hiểu (thể hiện trong cấu trúc dữ liệu của
document.styleSheets). Các kiểu này bao gồm kiểu từ file CSS bên ngoài (link), kiểu trong thẻ<style>, và kiểu trực tiếp (inline). - Chuyển đổi các giá trị thuộc tính trong bảng kiểu thành chuẩn hóa.
- Tính toán kiểu dáng cụ thể cho mỗi nút trong cây DOM. Bước này liên quan đến các quy tắc xếp tầng (cascading) và kế thừa (inheritance) của CSS.
Bố cục (Layout)
Chỉ có cây DOM và kiểu dáng phần tử vẫn chưa đủ để hiển thị trang. Cần biết thông tin vị trí hình học của các phần tử DOM. Quá trình tính toán thông tin vị trí này được gọi là bố cục (layout).
Các bước tác vụ:
- Tạo cây bố cục (Layout Tree): Duyệt qua tất cả các nút hiển thị trong cây DOM và thêm các nút này vào cây bố cục. Các nút không hiển thị (ví dụ: thẻ
<head>hoặc các thẻ có thuộc tínhdisplay: none) sẽ bị cây bố cục bỏ qua. - Tính toán bố cục: Tính toán tọa độ hình học của mỗi phần tử DOM và lưu trữ thông tin này trong cây bố cục. Trong quá trình ghi lại kết quả tính toán bố cục vào cây bố cục, cây bố cục vừa là đầu vào vừa là đầu ra, đây là một điểm không hợp lý trong giai đoạn bố cục vì nó không phân biệt rõ ràng đầu vào và đầu ra. Để giải quyết vấn đề này, nhóm Chrome đang tái cấu trúc mã bố cục, hệ thống bố cục thế hệ tiếp theo được gọi là LayoutNG, cố gắng tách biệt đầu vào và đầu ra rõ ràng hơn, giúp thuật toán bố cục mới đơn giản hơn.
Phân lớp (Layering)
Sau khi bố cục hoàn tất, engine hiển thị cần tạo các lớp chuyên biệt cho các nút cụ thể và tạo một cây lớp tương ứng. Engine hiển thị chia trang thành nhiều lớp, các lớp này được xếp chồng lên nhau theo một thứ tự nhất định để tạo thành trang cuối cùng.
Cây lớp được tạo dựa trên cây bố cục. Để xác định phần tử nào cần nằm trong lớp nào, engine hiển thị duyệt cây bố cục để tạo cây lớp (LayerTree). Không phải mọi nút trong cây bố cục đều có một lớp riêng. Nếu một nút không có lớp tương ứng, nó sẽ thuộc về lớp của nút cha.
Các điều kiện để tạo một lớp mới:
- Các phần tử có thuộc tính ngữ cảnh xếp chồng (stacking context) sẽ được nâng lên thành một lớp riêng biệt (ví dụ: các phần tử sử dụng thuộc tính định vị, thuộc tính độ trong suốt, bộ lọc CSS).
- Các vị trí cần cắt (clip) cũng sẽ được tạo thành lớp (ví dụ: cắt văn bản). Nếu xuất hiện thanh cuộn, thanh cuộn cũng sẽ được nâng lên thành một lớp riêng.
Vẽ (Paint)
Sau khi hoàn tất việc xây dựng cây lớp, engine hiển thị sẽ tiến hành vẽ từng lớp trong cây lớp. Engine hiển thị sẽ biến mỗi hành động vẽ thành các bước, sắp xếp các tác vụ theo tuyến tính, tạo thành một danh sách cần vẽ (paint list).
Tổng hợp (Compositing)
Tổng hợp = chuẩn bị các ô (prepare tiles) + raster hóa (raster) + vẽ tứ giác (draw quad).
Danh sách vẽ chỉ dùng để ghi lại thứ tự và lệnh vẽ; hoạt động vẽ thực tế được thực hiện bởi luồng tổng hợp trong engine hiển thị. Khi danh sách vẽ của lớp đã sẵn sàng, luồng chính sẽ gửi (commit) danh sách vẽ này đến luồng tổng hợp.
Cách luồng tổng hợp hoạt động:
- Luồng tổng hợp sẽ chia các lớp thành các ô (tiles), kích thước các ô này thường là 256x256 hoặc 512x512.
- Luồng tổng hợp sẽ ưu tiên tạo bitmap cho các ô gần viewport (khu vực hiển thị), hoạt động tạo bitmap thực tế được thực hiện bởi rasterization (raster hóa).
- Raster hóa là quá trình chuyển đổi các ô thành bitmap. Một ô là đơn vị nhỏ nhất để thực hiện raster hóa.
- Tiến trình hiển thị duy trì một nhóm luồng raster hóa, tất cả các raster hóa ô đều được thực hiện trong nhóm luồng này.
- Quá trình raster hóa thường sử dụng GPU để tăng tốc độ tạo. Quá trình tạo bitmap bằng GPU được gọi là raster hóa nhanh hoặc raster hóa GPU. Bitmap được tạo ra được lưu trữ trong bộ nhớ GPU.
- Hoạt động GPU chạy trong tiến trình GPU. Nếu hoạt động raster hóa sử dụng GPU, thì hoạt động tạo bitmap cuối cùng được hoàn thành trong GPU, điều này liên quan đến hoạt động giữa các tiến trình.
- Một khi tất cả các ô đã được raster hóa, luồng tổng hợp sẽ tạo một lệnh vẽ ô — "DrawQuad", sau đó gửi lệnh này đến tiến trình trình duyệt.
Hiển thị (Display)
Trong tiến trình trình duyệt có một thành phần gọi là viz, được dùng để nhận lệnh DrawQuad từ luồng tổng hợp. Sau đó, dựa trên lệnh DrawQuad, nó vẽ nội dung trang vào bộ nhớ, và cuối cùng hiển thị bộ nhớ lên màn hình.
Các khái niệm liên quan
- Reflow (Tái bố cục): Cập nhật các thuộc tính hình học của phần tử. Nếu bạn thay đổi các thuộc tính vị trí hình học của phần tử thông qua JavaScript hoặc CSS (ví dụ: thay đổi chiều rộng, chiều cao), trình duyệt sẽ kích hoạt tái bố cục và một loạt các giai đoạn con sau đó. Quá trình này được gọi là reflow. Reflow cần cập nhật toàn bộ quy trình hiển thị, do đó chi phí là lớn nhất.
- Repaint (Tái vẽ): Cập nhật các thuộc tính vẽ của phần tử. Nếu bạn thay đổi màu nền của phần tử, giai đoạn bố cục sẽ không được thực thi vì không có sự thay đổi vị trí hình học. Do đó, nó sẽ trực tiếp đi vào giai đoạn vẽ và thực hiện một loạt các giai đoạn con sau đó. Quá trình này được gọi là repaint. So với reflow, repaint bỏ qua giai đoạn bố cục và phân lớp, vì vậy hiệu quả thực thi sẽ cao hơn.
- Direct Compositing (Tổng hợp trực tiếp): Nếu bạn thay đổi một thuộc tính không yêu cầu bố cục cũng không yêu cầu vẽ, engine hiển thị sẽ bỏ qua bố cục và vẽ, chỉ thực hiện các thao tác tổng hợp tiếp theo. Quá trình này được gọi là compositing. Vì tổng hợp được thực hiện trên một luồng không phải luồng chính, nó không chiếm tài nguyên của luồng chính. Ngoài ra, nó cũng bỏ qua hai giai đoạn con là bố cục và vẽ. Do đó, so với repaint và reflow, compositing có thể cải thiện đáng kể hiệu suất vẽ.
Cơ chế thực thi JavaScript trong trình duyệt
Khái niệm Hoisting (Nâng biến)
Hoisting là gì?
Hoisting (nâng biến) là hành vi của engine JavaScript trong quá trình thực thi mã JS, đưa phần khai báo biến và hàm lên đầu đoạn mã. Sau khi biến được nâng lên, nó sẽ được gán giá trị mặc định là undefined.
Khi có các biến hoặc hàm trùng tên, định nghĩa sau sẽ ghi đè lên định nghĩa trước. Nâng hàm có ưu tiên cao hơn nâng biến và không bị ghi đè bởi khai báo biến, nhưng sẽ bị ghi đè bởi gán giá trị biến sau đó.
Quy trình thực thi:
Về mặt khái niệm, hoisting dường như là việc khai báo biến và hàm sẽ di chuyển vật lý lên đầu mã, nhưng thực tế điều này không chính xác. Trên thực tế, vị trí của khai báo biến và hàm trong mã không thay đổi, mà chúng được engine JS đưa vào bộ nhớ trong giai đoạn biên dịch. Do đó, sự tồn tại của hoisting dựa trên việc JS cần được biên dịch.
Sau khi biên dịch, hai phần nội dung sẽ được tạo ra: môi trường thực thi (execution context) và mã có thể thực thi (executable code). Trong giai đoạn biên dịch, các biến và hàm sẽ được lưu trữ trong môi trường biến (variable environment), và giá trị mặc định của biến sẽ được đặt là undefined. Trong giai đoạn thực thi, engine JS sẽ tìm kiếm các biến và hàm tùy chỉnh từ môi trường biến.
Môi trường thực thi (Execution Context)
Môi trường thực thi là môi trường chạy khi JS thực thi một đoạn mã. Nó chứa môi trường biến (variable environment) và môi trường từ vựng (lexical environment). Môi trường thực thi được chia thành: môi trường thực thi toàn cục, môi trường thực thi hàm, môi trường thực thi eval.
- Môi trường biến: Nơi các biến khai báo bằng
var(hoisting) được đặt, dùng để tra cứu khi JS thực thi. - Môi trường từ vựng: Nơi các biến khai báo bằng
letvàconstđược đặt, cũng dùng để tra cứu khi JS thực thi, và cũng tồn tại như một cấu trúc ngăn xếp cho phạm vi khối, dùng để đẩy vào và bật ra các biến phạm vi khối.
Engine JS sẽ biên dịch mã ngoài khai báo thành bytecode, sau đó chuyển đổi thành mã có thể thực thi.
Ngăn xếp gọi (Call Stack)
Gọi hàm:
Xem xét đoạn mã sau:
let valX = 5;
function multiply(numY) {
let numZ = 10;
return valX + numY + numZ;
}
multiply(7); // Gọi hàm
Trước khi thực thi multiply(7), engine JS sẽ tạo môi trường thực thi toàn cục cho đoạn mã trên, bao gồm các hàm và biến đã khai báo. Khi thực thi hàm multiply, engine JS sẽ biên dịch đoạn mã của hàm multiply và tạo môi trường thực thi hàm tương ứng.
Từ ví dụ này, có thể thấy chương trình có thể có nhiều môi trường thực thi, và việc quản lý các môi trường này được thực hiện thông qua một cấu trúc dữ liệu gọi là ngăn xếp (stack).
Cấu trúc ngăn xếp:
Ngăn xếp giống như một con đường một chiều bị chặn ở một đầu, các xe hơi giống như các phần tử trong ngăn xếp. Các phần tử trong ngăn xếp tuân thủ đặc điểm Vào sau ra trước (Last-In, First-Out - LIFO).
Ngăn xếp môi trường thực thi (Execution Context Stack - Call Stack):
Ngăn xếp gọi (Call Stack) là một cấu trúc dữ liệu được sử dụng để quản lý mối quan hệ gọi hàm. Engine JS sử dụng cấu trúc ngăn xếp này để quản lý môi trường thực thi. Sau khi môi trường thực thi được tạo, engine JS sẽ đẩy nó vào ngăn xếp. Ngăn xếp này thường được gọi là ngăn xếp môi trường thực thi hoặc ngăn xếp gọi.
Ngăn xếp gọi có kích thước giới hạn. Khi số lượng môi trường thực thi được đẩy vào ngăn xếp vượt quá một giới hạn nhất định, engine JS sẽ báo lỗi. Lỗi này được gọi là tràn ngăn xếp (stack overflow). Kích thước ngăn xếp gọi được đo bằng hai chỉ số: dung lượng ngăn xếp tối đa và độ sâu gọi tối đa. Nếu đạt bất kỳ chỉ số nào, tràn ngăn xếp sẽ xảy ra.
Nếu một môi trường thực thi có hiện tượng bao đóng (closure), nó sẽ không ảnh hưởng đến việc bật ra và hủy bỏ, vì các biến được tham chiếu bởi hàm bên trong sẽ được lưu trữ trên heap, do đó không ảnh hưởng đến hoạt động của ngăn xếp.
Ngăn xếp gọi là một cơ chế mà engine JS sử dụng để theo dõi việc thực thi hàm. Hãy xem xét một ví dụ phức tạp hơn để hiểu quá trình thực thi và sự thay đổi của ngăn xếp gọi.
Hoạt động của môi trường thực thi:
let globalVarA = 2;
function calculateSum(paramB, paramC) {
return paramB + paramC;
}
function processAll(paramB, paramC) {
let localVarD = 10;
let result = calculateSum(paramB, paramC);
return globalVarA + result + localVarD;
}
processAll(3, 6);
- Đầu tiên, tạo môi trường thực thi toàn cục và đẩy nó vào đáy ngăn xếp.
- Thực thi mã toàn cục, thực hiện gán
globalVarA = 2. - Thực thi mã toàn cục, gọi hàm
processAll.Engine JS tạo môi trường thực thi cho
processAllvà đẩy nó lên đỉnh ngăn xếp. Bên trongprocessAll,localVarDđược khai báo và gán giá trị. - Khi thực thi đến hàm
calculateSumbên trongprocessAll, engine JS tạo môi trường thực thi chocalculateSumvà đẩy nó lên đỉnh ngăn xếp. - Khi hàm
calculateSumtrả về, môi trường thực thi của hàm đó sẽ được bật ra khỏi đỉnh ngăn xếp. - Ngay sau đó,
processAllthực hiện phép cộng cuối cùng và trả về, sau đó môi trường thực thi của nó cũng được bật ra khỏi đỉnh ngăn xếp. - Toàn bộ quy trình JavaScript kết thúc, ngăn xếp gọi được làm trống.
Phạm vi khối (Block Scope)
Phạm vi (Scope):
Phạm vi (scope) là khu vực trong chương trình nơi biến được định nghĩa. Nó là phạm vi có thể truy cập của biến và hàm, kiểm soát tính khả kiến và vòng đời của biến và hàm. Trước ES6, JavaScript chỉ có hai loại phạm vi: phạm vi toàn cục và phạm vi hàm. Sau ES6, các từ khóa let và const được giới thiệu, hiện thực hóa phạm vi khối.
Phạm vi khối:
Để hiểu sự hỗ trợ của JS đối với phạm vi khối, cần nhìn từ góc độ môi trường thực thi. Phạm vi khối trong JS được hiện thực hóa thông qua cấu trúc ngăn xếp của môi trường từ vựng. Hoisting biến trong JS được hiện thực hóa thông qua môi trường biến.
Xem xét một đoạn mã để giải thích:
function exampleScope() {
var varA = 1;
let letB = 2;
{
let letB = 3; // letB mới trong phạm vi khối
var varC = 4;
let letD = 5;
console.log(varA); // 1
console.log(letB); // 3 (trong phạm vi khối)
}
console.log(letB); // 2 (ngoài phạm vi khối)
console.log(varC); // 4
try {
console.log(letD); // ReferenceError: letD is not defined
} catch (e) {
console.log(e.message);
}
}
exampleScope();
- Bước 1: Biên dịch và tạo môi trường thực thi
- Các biến được khai báo bằng
varbên trong hàm đều được lưu trữ trong môi trường biến trong giai đoạn biên dịch. - Các biến được khai báo bằng
letbên trong hàm đều được lưu trữ trong môi trường từ vựng trong giai đoạn biên dịch. - Các biến được khai báo bằng
letbên trong khối mã trong hàm không được lưu trữ trong môi trường từ vựng trong giai đoạn biên dịch (chính xác hơn là chúng được tạo và lưu trữ cục bộ khi khối được thực thi).
- Các biến được khai báo bằng
- Bước 2: Tiếp tục thực thi mã
- Bên trong môi trường từ vựng, một cấu trúc ngăn xếp nhỏ được duy trì. Đáy ngăn xếp là các biến ở lớp ngoài cùng của hàm. Khi vào một khối phạm vi, các biến bên trong khối phạm vi đó sẽ được đẩy lên đỉnh ngăn xếp; khi phạm vi thực thi hoàn tất, thông tin của phạm vi đó sẽ được bật ra khỏi đỉnh ngăn xếp. Đây là cấu trúc của môi trường từ vựng.
- Khi thực thi đến khối mã, các biến
letmới khai báo sẽ được thêm vào cấu trúc ngăn xếp trong phạm vi từ vựng, tồn tại độc lập. Các biến trong khu vực này không ảnh hưởng đến các biếnletbên ngoài khối. - Chỉ các biến được khai báo bằng
lethoặcconstmới đi vào cấu trúc ngăn xếp từ vựng này. Các biến được khai báo bằngvarchỉ đi vào môi trường biến. - Khi JS thực thi mã, nó sẽ tìm kiếm biến trong môi trường từ vựng và môi trường biến. Thứ tự tìm kiếm là: dọc theo đỉnh ngăn xếp của môi trường từ vựng xuống dưới. Nếu tìm thấy trong một khối nào đó của môi trường từ vựng, nó sẽ trả về trực tiếp cho engine JS. Nếu không tìm thấy, nó sẽ tiếp tục tìm kiếm trong môi trường biến.
- Bước 3: Khi khối phạm vi kết thúc, các biến được định nghĩa bên trong sẽ bật ra khỏi đỉnh ngăn xếp môi trường từ vựng.
Thông tin bổ sung:
- Các biến được tạo bằng
var,letvàconstđều trải qua 3 giai đoạn: tạo, khởi tạo và gán giá trị. - Các giá trị khai báo bằng
varvàfunctionđược lưu trữ trong môi trường biến sau khi biên dịch; các biến khai báo bằngletvàconst, ngoài việc được lưu trữ trong môi trường từ vựng khi biên dịch, cũng sẽ được thêm vào khi thực thi dựa trên khai báoletvàconstcục bộ mới. function: tạo, khởi tạo và gán giá trị đều được nâng lên.var: tạo và khởi tạo được nâng lên, gán giá trị không được nâng lên.letvàconst: tạo được nâng lên, khởi tạo và gán giá trị không được nâng lên.- Vùng chết tạm thời (Temporal Dead Zone - TDZ): Là khoảng thời gian mà các biến khai báo bằng
letvàconstđược liên kết với khối mã này, không còn bị ảnh hưởng bởi bên ngoài. Ảnh hưởng của TDZ là trước khi các biếnletvàconstđược khai báo, biến đó không thể sử dụng được. Sử dụng trước sẽ báo lỗi "Cannot access 'b' before initialization" (Không thể truy cập 'b' trước khi khởi tạo). - Theo kiểm tra với công cụ F12 của Chrome:
- Nếu sử dụng biến được khai báo bằng
letvàconsttrước khi khai báo, thanh công cụ Scope của Chrome không thể thấy biến đó, và khi thực thi sẽ báo lỗi "Cannot access 'b' before initialization". - Nếu không sử dụng biến được khai báo bằng
letvàconsttrước khi khai báo, thanh công cụ Scope của Chrome vẫn thấy biến đó, và giá trị làundefined. - Kết luận: TDZ xảy ra trước khi khởi tạo. Khi vào phạm vi của mã khai báo
letvàconst, nếu không xảy ra TDZ, biến được tạo và nâng lên môi trường từ vựng sẽ được gán giá trịundefined.
- Nếu sử dụng biến được khai báo bằng
- ES6 quy định rằng các hàm được khai báo trong phạm vi khối và các biến được khai báo bằng
letvàconsthoạt động tương tự, không thể được nâng lên. Tuy nhiên, để tương thích ngược, các trình duyệt lớn coi khai báo hàm trong khối như hàm được định nghĩa bằngvar. Do đó, việc gọi bên ngoài khối sẽ trả vềundefined.
Chuỗi phạm vi và Bao đóng (Scope Chain and Closure)
Chuỗi phạm vi (Scope Chain):
Trong môi trường biến của mỗi môi trường thực thi, có một tham chiếu bên ngoài (external reference) trỏ đến môi trường thực thi bên ngoài. Tham chiếu bên ngoài này được gọi là outer. Khi một đoạn mã sử dụng một biến, engine JS sẽ tìm kiếm trước tiên trong môi trường thực thi hiện tại. Nếu không tìm thấy, nó sẽ tiếp tục tìm kiếm trong môi trường thực thi mà biến outer trỏ đến.
Lấy một đoạn mã làm ví dụ:
function displayOuterName() {
console.log(outerName);
}
function processName() {
var outerName = "Geekbang Time";
displayOuterName();
}
var outerName = "Geekbang Course";
processName();
Từ ví dụ trên, có thể thấy các hàm displayOuterName và processName đều có outer trỏ đến môi trường toàn cục. Điều này có nghĩa là nếu một biến bên ngoài được sử dụng trong hàm displayOuterName hoặc processName, engine JS sẽ tìm kiếm trong môi trường thực thi toàn cục. Do đó, chúng ta có thể gọi chuỗi tìm kiếm biến thông qua phạm vi này là chuỗi phạm vi (scope chain). Vị trí tìm kiếm outer và chuỗi này được quyết định bởi phạm vi từ vựng.
Phạm vi từ vựng (Lexical Scope):
Phạm vi từ vựng có nghĩa là phạm vi được xác định bởi vị trí khai báo hàm trong mã. Do đó, phạm vi từ vựng là phạm vi tĩnh (static scope), qua đó có thể dự đoán cách mã tìm kiếm định danh trong quá trình thực thi. Phạm vi từ vựng được quyết định tại giai đoạn định nghĩa mã, không liên quan đến cách hàm được gọi.
Tìm kiếm biến trong phạm vi khối:
Trong một môi trường thực thi đơn, ưu tiên của môi trường từ vựng cao hơn môi trường biến, do đó thứ tự là từ phải sang trái. Cách tìm kiếm cấp trên không cần nói nhiều. Do đó, thứ tự tìm kiếm môi trường là 1, 2, 3, 4, 5.
Bao đóng (Closure):
Lấy một đoạn mã làm ví dụ:
function createCounter() {
var count = 0;
let multiplier = 2;
const initialValue = 1;
var counterFunctions = {
increment: function() {
console.log("Current multiplier:", multiplier);
count++;
return count;
},
setCount: function(newCount) {
count = newCount;
}
};
return counterFunctions;
}
var myCounter = createCounter();
myCounter.setCount(5);
console.log(myCounter.increment()); // Output: 6
console.log(myCounter.increment()); // Output: 7
Khi thực thi đến vị trí return counterFunctions:
Theo quy tắc của phạm vi từ vựng, các hàm bên trong increment và setCount luôn có thể truy cập các biến trong hàm bên ngoài createCounter. Do đó, khi thực thi setCount và increment, trạng thái của toàn bộ ngăn xếp gọi là:
Từ hình trên, có thể thấy sau khi hàm createCounter thực thi xong, môi trường thực thi của nó đã bật ra khỏi đỉnh ngăn xếp. Tuy nhiên, vì các phương thức setCount và increment được trả về đã sử dụng các biến count và multiplier bên trong hàm createCounter, nên hai biến này vẫn được lưu trong bộ nhớ. Tập hợp các biến này được gọi là bao đóng (closure).
Theo quy tắc của phạm vi từ vựng, một hàm bên trong luôn có thể truy cập các biến được khai báo trong hàm bên ngoài của nó. Khi một hàm bên ngoài được gọi và trả về một hàm bên trong, ngay cả khi hàm bên ngoài đã kết thúc thực thi, các biến mà hàm bên trong tham chiếu từ hàm bên ngoài vẫn được giữ trong bộ nhớ. Chúng ta gọi tập hợp các biến này là bao đóng.
Khi có bao đóng, thứ tự tìm kiếm biến của engine JS là: môi trường thực thi hiện tại -> bao đóng của hàm cụ thể -> môi trường thực thi toàn cục.
Thu hồi bao đóng:
- Nếu hàm tham chiếu bao đóng là một biến toàn cục, bao đóng sẽ tồn tại cho đến khi trang đóng.
- Nếu hàm tham chiếu bao đóng là một biến cục bộ, sau khi hàm bị hủy, lần tiếp theo engine JS thực hiện thu gom rác, nó sẽ xác định rằng nội dung bao đóng này không còn được sử dụng và bộ thu gom rác của engine JS sẽ thu hồi nó.
- Nếu bao đóng sẽ được sử dụng liên tục, nó có thể tồn tại dưới dạng biến toàn cục. Nhưng nếu tần suất sử dụng không cao và chiếm nhiều bộ nhớ, thì nên cố gắng làm cho nó trở thành một biến cục bộ để tránh chiếm dụng bộ nhớ (rò rỉ bộ nhớ).
`this`
this được liên kết với môi trường thực thi và là một phần của môi trường thực thi. Chuỗi phạm vi và this là hai hệ thống khác nhau, chúng hầu như không có nhiều liên hệ. Môi trường thực thi được chia thành 3 loại: môi trường thực thi toàn cục, môi trường thực thi hàm và môi trường thực thi eval.
Môi trường thực thi toàn cục:
this trong môi trường thực thi toàn cục trỏ đến đối tượng window. Đáy của chuỗi phạm vi chứa đối tượng window, đây là điểm giao duy nhất giữa this và chuỗi phạm vi.
Môi trường thực thi hàm:
- Khi gọi một hàm trong môi trường toàn cục,
thisbên trong hàm trỏ đến biến toàn cụcwindow. - Khi gọi một phương thức bên trong một đối tượng thông qua đối tượng đó,
thisbên trong hàm trỏ đến chính đối tượng đó.
3 cách thiết lập `this` trong môi trường thực thi hàm:
- Thông qua phương thức
callcủa hàm:let appConfig = { appName: "Geekbang App", version: 1.0 }; function updateAppInfo() { this.appName = "New Geekbang App"; } updateAppInfo.call(appConfig); // Có thể sử dụng bind và apply console.log(appConfig); // { appName: "New Geekbang App", version: 1.0 } // console.log(appName) // ReferenceError: appName is not defined (trong môi trường toàn cục không có biến appName) - Thông qua đối tượng gọi phương thức:
var systemMonitor = { name: "System Monitor", displayContext: function() { console.log(this); } }; systemMonitor.displayContext(); // Sử dụng đối tượng để gọi phương thức nội bộ, this trỏ đến chính đối tượng - Thông qua constructor:
function Widget(widgetName) { this.name = widgetName; } var myWidget = new Widget("My Awesome Widget"); console.log(myWidget.name); // My Awesome Widget // Quá trình new Widget() tương tự như: /* var tempObj = {}; Widget.call(tempObj, "My Awesome Widget"); tempObj.__proto__ = Widget.prototype; return tempObj; */
Các vấn đề thiết kế của `this`:
- `this` trong các hàm lồng nhau không kế thừa từ hàm bên ngoài.
Giải pháp:
- Hàm mũi tên (Arrow functions): Vì hàm mũi tên trong ES6 không tạo môi trường thực thi riêng của nó, nên
thistrong hàm mũi tên phụ thuộc vào hàm bên ngoài của nó (lexicalthis). - Sử dụng
thisnhư một biến (lưuthisvào một biến khác).
- Hàm mũi tên (Arrow functions): Vì hàm mũi tên trong ES6 không tạo môi trường thực thi riêng của nó, nên
- `this` trong các hàm thông thường mặc định trỏ đến đối tượng toàn cục
window.Giải pháp:
- Sử dụng
callhoặcapplyđể liên kết và gọi. - Đặt chế độ nghiêm ngặt (strict mode) (
thismặc định làundefined).
- Sử dụng
Nguyên lý hoạt động của V8
Không gian ngăn xếp và không gian heap (Stack Space và Heap Space)
Loại ngôn ngữ:
- Ngôn ngữ yêu cầu xác định kiểu dữ liệu biến trước khi sử dụng được gọi là ngôn ngữ tĩnh (static languages).
- Ngôn ngữ kiểm tra kiểu dữ liệu biến trong quá trình chạy được gọi là ngôn ngữ động (dynamic languages).
- Ngôn ngữ hỗ trợ chuyển đổi kiểu ngầm định được gọi là ngôn ngữ yếu kiểu (weakly-typed languages).
- Ngôn ngữ không hỗ trợ chuyển đổi kiểu ngầm định được gọi là ngôn ngữ mạnh kiểu (strongly-typed languages).
- Hành vi mà engine tự động chuyển đổi kiểu dữ liệu của biến thành kiểu dữ liệu của giá trị gán trong quá trình gán mã được gọi là chuyển đổi kiểu ngầm định.
JavaScript là một ngôn ngữ yếu kiểu và động.
Kiểu dữ liệu:
- Kiểu nguyên thủy (Primitive types): Boolean, Null, Undefined, Number, BigInt, String, Symbol.
- Kiểu tham chiếu (Reference types): Object.
Không gian bộ nhớ:
- Không gian mã (Code Space): Lưu trữ mã có thể thực thi.
- Không gian ngăn xếp (Stack Space):
- Lưu trữ dữ liệu kiểu nguyên thủy.
- Lưu trữ địa chỉ của dữ liệu kiểu tham chiếu.
- Lưu trữ môi trường thực thi.
- Không gian được thiết lập nhỏ.
- Còn được gọi là ngăn xếp gọi (call stack).
- Không gian heap (Heap Space):
- Lưu trữ dữ liệu kiểu tham chiếu.
- Lưu trữ dữ liệu bao đóng (closure data).
- Không gian được thiết lập lớn (có thể chứa nhiều dữ liệu lớn).
- Lý do thiết kế: Kiểm soát kích thước không gian ngăn xếp, đảm bảo hiệu quả chuyển đổi môi trường thực thi, từ đó đảm bảo hiệu quả thực thi chương trình.
Ví dụ mã:
function exampleMemoryUsage() {
var primValue1 = "Hello";
var primValue2 = primValue1; // primValue2 là bản sao giá trị của primValue1
var refObj1 = { key: "Geekbang" };
var refObj2 = refObj1; // refObj2 và refObj1 cùng tham chiếu đến một đối tượng trên heap
}
exampleMemoryUsage();
Nói lại về bao đóng:
function outerFunction() {
var hiddenValue = 100;
var innerFuncContainer = {
getValue: function() {
return hiddenValue;
},
};
return innerFuncContainer;
}
var resultContainer = outerFunction(); // Bước 1
resultContainer.getValue(); // Bước 2
- Bước 1: Trước khi thực thi
outerFunction(), tạo môi trường thực thi. Engine thông qua quét từ vựng, phát hiệngetValuetham chiếu biến của hàm bên ngoài, xác định đây là bao đóng, do đó tạo một đối tượngclosure(outerFunction)trên không gian heap. - Bước 2: Khi thực thi
getValue(), môi trường thực thi củaouterFunctionbật ra khỏi ngăn xếp. Đối tượngclosure(outerFunction)vẫn được lưu trên không gian heap. Sau đó, engine tạo môi trường thực thi chogetValue, lấy biếnhiddenValuetừ dữ liệu bao đóng và trả về.
Thu gom rác (Garbage Collection)
Chiến lược thu gom rác:
- Thu gom thủ công.
- Thu gom tự động.
Thu gom rác dữ liệu ngăn xếp:
Lấy một đoạn mã làm ví dụ:
function mainFunction() {
var numA = 1;
var objB = { name: "Geekbang" };
function nestedFunction() {
var strC = "Geekbang Time";
var objD = { name: "Geekbang Time" };
}
nestedFunction();
}
mainFunction();
Khi hàm nestedFunction thực thi đến dòng cuối cùng, trạng thái của ngăn xếp gọi và không gian heap:
Sau khi hàm nestedFunction thực thi xong:
Có thể thấy, trong quá trình đẩy vào và bật ra khỏi ngăn xếp, có một con trỏ ghi lại trạng thái thực thi hiện tại (gọi là ESP). Khi nội dung của ngăn xếp gọi bật ra, engine JS sẽ di chuyển ESP xuống môi trường thực thi của hàm mainFunction. Thao tác di chuyển xuống này chính là quá trình hủy môi trường thực thi của hàm nestedFunction.
Do đó, có thể kết luận rằng khi một hàm kết thúc thực thi, engine JS sẽ hủy môi trường thực thi của hàm đó được lưu trong ngăn xếp bằng cách di chuyển ESP xuống. Khi hàm mainFunction kết thúc thực thi, ESP trỏ đến môi trường thực thi toàn cục, nhưng hai đối tượng được lưu trong heap vẫn chiếm không gian.
Thu gom rác dữ liệu heap:
Vẫn lấy ví dụ mã của thu gom rác ngăn xếp. Để loại bỏ rác dữ liệu heap còn lại, cần sử dụng bộ thu gom rác trong engine JS. Các chiến lược thu gom rác đều dựa trên giả thuyết thế hệ (generational hypothesis), giả thuyết này áp dụng cho hầu hết các ngôn ngữ động, bao gồm cả JS.
Hai đặc điểm của giả thuyết thế hệ:
- Phần lớn các đối tượng tồn tại trong bộ nhớ trong thời gian rất ngắn. Nói cách khác, nhiều đối tượng sau khi được cấp phát bộ nhớ sẽ nhanh chóng trở nên không thể truy cập được.
- Các đối tượng không bị hủy sẽ sống lâu hơn.
Trong V8, heap được chia thành hai vùng: thế hệ trẻ (young generation) và thế hệ già (old generation):
- Thế hệ trẻ: Lưu trữ các đối tượng có thời gian sống ngắn, thường chỉ hỗ trợ dung lượng từ 1 đến 8MB, chịu trách nhiệm thu gom bởi bộ thu gom rác phụ.
- Thế hệ già: Lưu trữ các đối tượng có thời gian sống lâu, hỗ trợ dung lượng lớn hơn nhiều, chịu trách nhiệm thu gom bởi bộ thu gom rác chính.
Quy trình làm việc của bộ thu gom rác:
Bất kể loại bộ thu gom rác nào, chúng đều có một quy trình thực hiện chung:
- Đánh dấu: Đánh dấu các đối tượng đang hoạt động và không hoạt động trong không gian.
- Thu hồi: Thu hồi bộ nhớ bị chiếm bởi các đối tượng không hoạt động.
- Sắp xếp bộ nhớ:
- Một số bộ thu gom rác tạo ra phân mảnh bộ nhớ (memory fragmentation), ảnh hưởng đến việc cấp phát bộ nhớ sau này, ví dụ bộ thu gom rác chính.
- Một số thì không, ví dụ bộ thu gom rác phụ.
Bộ thu gom rác phụ:
- Chủ yếu chịu trách nhiệm thu gom rác trong vùng thế hệ trẻ, hầu hết các đối tượng nhỏ đều được cấp phát vào vùng này.
- Trong thế hệ trẻ, thuật toán Scavenge được sử dụng để xử lý, chia không gian thế hệ trẻ thành hai vùng: một nửa là vùng đối tượng và một nửa là vùng trống.
- Các đối tượng mới thêm vào đều được lưu trữ vào vùng đối tượng. Khi vùng đối tượng sắp đầy, cần thực hiện một thao tác dọn dẹp rác.
- Trong quá trình thu gom rác, trước tiên cần đánh dấu rác trong vùng đối tượng. Sau khi đánh dấu hoàn tất, sẽ chuyển sang giai đoạn dọn dẹp rác.
- Bộ thu gom rác phụ sẽ sao chép các đối tượng còn sống sót sang vùng trống, đồng thời sắp xếp các đối tượng này một cách có trật tự, hoàn thành việc sắp xếp bộ nhớ, và vùng trống sau khi sao chép sẽ không còn phân mảnh bộ nhớ.
- Sau khi sao chép hoàn tất, vùng đối tượng và vùng trống sẽ đảo vai trò, tức là vùng đối tượng cũ trở thành vùng trống, và vùng trống cũ trở thành vùng đối tượng, hoàn thành việc thu gom rác.
- Thao tác đảo vai trò này cũng cho phép hai vùng trong thế hệ trẻ được sử dụng lại vô hạn.
- Chính vì không gian của vùng thế hệ trẻ không lớn, nên rất dễ bị đầy bởi các đối tượng còn sống sót. Để giải quyết vấn đề này, engine JS đã áp dụng chiến lược thăng cấp đối tượng, tức là các đối tượng vẫn còn sống sau hai lần thu gom rác sẽ được chuyển sang vùng thế hệ già.
Bộ thu gom rác chính:
- Chủ yếu chịu trách nhiệm thu gom rác trong vùng thế hệ già. Ngoài các đối tượng được thăng cấp từ vùng thế hệ trẻ, một số đối tượng lớn sẽ được cấp phát trực tiếp vào vùng thế hệ già.
- Bộ thu gom rác chính sử dụng thuật toán Mark-Sweep (đánh dấu-xóa) để thu gom rác.
- Trong quá trình thu gom rác, trước tiên nó bắt đầu từ một tập hợp các phần tử gốc, duyệt đệ quy tập hợp các phần tử gốc này để đánh dấu.
- Trong quá trình duyệt này, các phần tử có thể truy cập được gọi là đối tượng đang hoạt động. Các phần tử không truy cập được có thể được coi là dữ liệu rác (khi môi trường thực thi hàm bật ra khỏi ngăn xếp, các tham chiếu heap trong phạm vi của nó không thể được duyệt tới, do đó bị đánh dấu là dữ liệu rác).
- Tiếp theo là quá trình xóa rác, hoàn toàn khác với quá trình xóa rác của bộ thu gom rác phụ, có thể hiểu là quá trình xóa các dữ liệu được đánh dấu màu đỏ.
- Việc xóa rác dựa trên thuật toán này dễ tạo ra nhiều phân mảnh bộ nhớ không liên tục. Vì vậy, một thuật toán khác đã được phát triển, gọi là Mark-Compact (đánh dấu-nén).
- Quá trình đánh dấu của thuật toán này giống với thuật toán Mark-Sweep, nhưng các bước tiếp theo không trực tiếp dọn dẹp các đối tượng có thể thu hồi, mà di chuyển tất cả các đối tượng còn sống sót về một phía, sau đó trực tiếp dọn dẹp bộ nhớ nằm ngoài ranh giới cuối cùng.
Dừng toàn bộ (Full Stop / Stop-The-World):
- Vì JS chạy trên luồng chính, một khi thuật toán thu gom rác được thực thi, nó cần tạm dừng script JS đang chạy, và sau khi thu gom rác hoàn tất mới tiếp tục thực thi script. Hành vi này được gọi là dừng toàn bộ (Stop-The-World).
- Trong thu gom rác thế hệ trẻ của V8 (bộ thu gom rác phụ), do không gian nhỏ và ít đối tượng còn sống sót, ảnh hưởng của việc dừng toàn bộ không lớn. Nhưng đối với thế hệ già (bộ thu gom rác chính) thì khác, điều này chắc chắn sẽ gây ra vấn đề về trải nghiệm.
- Để giảm thiểu tình trạng giật lag do thu gom rác thế hệ già, V8 chia quá trình đánh dấu thành nhiều quy trình đánh dấu con, đồng thời cho phép đánh dấu thu gom rác và logic ứng dụng JS xen kẽ nhau cho đến khi giai đoạn đánh dấu hoàn tất. Thuật toán này được gọi là thuật toán đánh dấu tăng cường (Incremental Marking).
- Bằng cách chia một nhiệm vụ thu gom rác hoàn chỉnh thành nhiều nhiệm vụ nhỏ hơn, các nhiệm vụ nhỏ này có thời gian thực thi ngắn và có thể xen kẽ vào giữa các nhiệm vụ JS khác, từ đó giải quyết được vấn đề trải nghiệm do thu gom rác gây ra.
Trình biên dịch và trình thông dịch (Compiler và Interpreter)
Trình biên dịch và trình thông dịch:
Sự tồn tại của trình biên dịch và trình thông dịch là do máy móc không thể trực tiếp hiểu mã mà chúng ta viết. Do đó, trước khi thực thi chương trình, cần "dịch" mã mà chúng ta viết thành ngôn ngữ máy mà máy có thể đọc được.
Theo quy trình thực thi ngôn ngữ, có thể chia ngôn ngữ thành ngôn ngữ biên dịch (compiled languages) và ngôn ngữ thông dịch (interpreted languages).
- Ngôn ngữ biên dịch: Trước khi chương trình thực thi, cần trải qua quá trình biên dịch của trình biên dịch. Sau đó, nó sẽ trực tiếp giữ lại tệp nhị phân mà máy có thể đọc được, vì vậy mỗi khi chạy chương trình, có thể trực tiếp chạy tệp nhị phân đó mà không cần biên dịch lại.
- Ngôn ngữ thông dịch: Mỗi khi chạy, cần thông qua trình thông dịch để động thông dịch và thực thi chương trình.
V8 thực thi một đoạn mã JS như thế nào?
- Bước 1: Chuyển mã nguồn thành Cây Cú pháp Trừu tượng (AST) và tạo môi trường thực thi.
Ngôn ngữ cấp cao là ngôn ngữ mà các nhà phát triển có thể hiểu, còn AST là thứ mà trình biên dịch hoặc trình thông dịch có thể hiểu. Dù bạn sử dụng ngôn ngữ thông dịch hay ngôn ngữ biên dịch, chúng đều tạo ra một AST trong quá trình biên dịch.
Ví dụ chuyển đổi AST:
var studentName = "Alice"; function getStudentId() { return 123; } studentName = "Bob"; getStudentId();Sau khi phân tích từ vựng/cú pháp, cấu trúc AST được tạo ra.
Có thể coi AST là một biểu diễn có cấu trúc của mã, công việc tiếp theo của trình biên dịch hoặc trình thông dịch đều phụ thuộc vào nó, chứ không phải mã nguồn.
AST là một cấu trúc dữ liệu rất quan trọng, được ứng dụng rộng rãi trong nhiều dự án, ví dụ Babel và ESLint, chúng đều sử dụng chuyển đổi AST để hiện thực hóa.
Việc tạo AST phải trải qua 2 giai đoạn (trước tiên là phân từ, sau đó là phân tích cú pháp):
- Giai đoạn 1: Phân từ (tokenize), còn gọi là phân tích từ vựng.
Nhiệm vụ của nó là chia từng dòng mã nguồn thành từng token. Token là ký tự hoặc chuỗi ký tự đơn lẻ nhỏ nhất không thể chia nhỏ hơn nữa về mặt cú pháp.
Ví dụ token:
Trong đó, từ khóa "var", định danh "studentName", toán tử gán "=", chuỗi "Alice" đều là các token, và các thuộc tính mà chúng đại diện cũng khác nhau.
- Giai đoạn 2: Phân tích cú pháp (parse), còn gọi là phân tích ngữ pháp.
Nhiệm vụ của nó là chuyển đổi dữ liệu token được tạo ở bước trước thành Cây Cú pháp Trừu tượng (AST) dựa trên các quy tắc ngữ pháp. Nếu mã nguồn tuân thủ các quy tắc ngữ pháp, bước này sẽ hoàn thành suôn sẻ. Nhưng nếu mã nguồn có lỗi cú pháp, bước này sẽ dừng lại và báo lỗi "SyntaxError".
- Giai đoạn 1: Phân từ (tokenize), còn gọi là phân tích từ vựng.
- Bước 2: Tạo bytecode.
Trình thông dịch (Ignition) tạo bytecode dựa trên AST. Bytecode là một loại mã nằm giữa AST và mã máy, không phụ thuộc vào loại mã máy cụ thể.
Bytecode cần được chuyển đổi thành mã máy thông qua trình thông dịch trước khi có thể thực thi.
Sự ra đời của bytecode xuất phát từ việc phương pháp biên dịch trực tiếp thành mã máy ban đầu chiếm bộ nhớ rất cao trên các thiết bị di động. Do đó, nhóm V8 đã tối ưu cấu trúc engine, giới thiệu bytecode.
Có thể thấy, không gian mà mã máy chiếm dụng lớn hơn nhiều so với bytecode, vì vậy sử dụng bytecode có thể giảm việc sử dụng bộ nhớ hệ thống.
- Bước 3: Thực thi mã.
Trình thông dịch (Ignition) ngoài việc chịu trách nhiệm tạo bytecode, còn chịu trách nhiệm thông dịch và thực thi bytecode.
Việc thực thi cụ thể liên quan đến công nghệ biên dịch JIT (Just-In-Time), tức là bytecode phối hợp làm việc cùng trình thông dịch và trình biên dịch.
Cụ thể trong V8, điều này có nghĩa là trong khi trình thông dịch đang thông dịch và thực thi bytecode, nó thu thập thông tin mã. Khi phát hiện một phần mã nào đó trở nên "nóng" (được thực thi nhiều lần), trình biên dịch TurboFan sẽ tham gia vào, chuyển đổi bytecode nóng thành mã máy và lưu trữ lại để sử dụng lần sau.
Phương pháp chuyển đổi thành mã máy này loại bỏ quá trình "dịch" bytecode thành mã máy, cải thiện đáng kể hiệu suất thực thi mã, đồng thời giải quyết vấn đề chiếm dụng bộ nhớ quá cao khi tất cả mã ban đầu đều được biên dịch thành mã máy.
Nói một cách đơn giản, một phần mã được thực thi bằng trình thông dịch Ignition, một phần mã được biên dịch và thực thi bằng trình biên dịch TurboFan. Phần mã được biên dịch này là mã "hot spot" được sử dụng thường xuyên, do đó được biên dịch thành mã máy và lưu trữ lại để tái sử dụng.
Hướng tối ưu hiệu năng JavaScript:
Do sự tối ưu hóa của engine, trọng tâm tối ưu hóa JavaScript hiện nay nên tập trung vào thời gian thực thi script một lần và việc tải script qua mạng.
Chủ yếu chú ý đến ba nội dung sau:
- Tăng tốc độ thực thi script một lần, tránh các tác vụ JS dài chiếm giữ luồng chính, tăng tốc phản hồi tương tác của trang.
- Tránh các script nội tuyến lớn, vì trong quá trình phân tích HTML, việc phân tích và biên dịch JS cũng chiếm giữ luồng chính.
- Giảm dung lượng tệp JS, vì tệp nhỏ hơn sẽ tăng tốc độ tải xuống và chiếm ít bộ nhớ hơn.
Hệ thống vòng lặp trang trong trình duyệt
Hàng đợi tin nhắn và vòng lặp sự kiện (Message Queue và Event Loop)
Xử lý tác vụ tuần tự đơn luồng:
Nếu có một số tác vụ đã được xác định trước, có thể sử dụng một luồng đơn để xử lý các tác vụ này theo thứ tự. Tuy nhiên, không phải tất cả các tác vụ đều được sắp xếp thống nhất trước khi thực thi. Trong hầu hết các trường hợp, các tác vụ mới được tạo ra trong quá trình chạy luồng, thì mô hình này không thể hoạt động được.
Xử lý tác vụ mới trong quá trình chạy luồng:
Để có thể nhận và thực thi các tác vụ mới trong quá trình chạy luồng, cần áp dụng cơ chế vòng lặp sự kiện (event loop mechanism). Ở đây, chúng ta sẽ mô phỏng bằng các câu lệnh vòng lặp và hệ thống sự kiện. Mô hình này, tất cả các tác vụ đều đến từ bên trong luồng. Nếu một luồng khác muốn luồng chính thực thi một tác vụ, thì không thể.
Xử lý tác vụ từ các luồng khác:
Lấy trình duyệt làm ví dụ để xem giao tiếp thông tin giữa các luồng. Để dễ dàng nhận tin nhắn từ các luồng khác, thường sử dụng hàng đợi tin nhắn (message queue). Hàng đợi tin nhắn là một cấu trúc dữ liệu, có thể chứa các tác vụ cần thực thi. Các tác vụ trong đó tuân theo đặc điểm "vào trước ra trước".
Mô hình luồng dựa trên giao tiếp giữa các luồng và hàng đợi tin nhắn như sau:
Trong trình duyệt, giao tiếp giữa các tiến trình sẽ áp dụng cơ chế giao tiếp IPC của trình duyệt. Trong tiến trình hiển thị của trình duyệt, có một luồng IO chuyên dụng để nhận các tin nhắn từ các tiến trình khác. Sau khi luồng IO nhận được tin nhắn, nó sẽ tổng hợp các tin nhắn này thành tác vụ và gửi đến luồng chính hiển thị.
Luồng chính thoát an toàn như thế nào?
Chrome giải quyết vấn đề này bằng cách: khi xác định muốn thoát trang hiện tại, luồng chính của trang sẽ đặt một biến cờ thoát. Sau mỗi khi thực thi xong một tác vụ, nó sẽ kiểm tra xem cờ thoát đã được đặt chưa. Nếu đã đặt, nó sẽ trực tiếp dừng tất cả các tác vụ hiện tại và thoát luồng.
Nhược điểm của việc trang sử dụng đơn luồng:
Tất cả các tác vụ được thực thi bởi luồng trang đều đến từ hàng đợi tin nhắn. Hàng đợi tin nhắn có thuộc tính "vào trước ra trước", nghĩa là các tác vụ được đưa vào hàng đợi cần chờ các tác vụ phía trước được thực thi xong mới được thực thi. Với thuộc tính này, có hai vấn đề cần giải quyết:
- Làm thế nào để xử lý các tác vụ ưu tiên cao?
Lấy cập nhật DOM làm ví dụ, nếu DOM thay đổi:
- Nếu sử dụng phương thức đồng bộ, nó sẽ ảnh hưởng đến hiệu quả thực thi của tác vụ hiện tại.
- Nếu sử dụng phương thức bất đồng bộ, nó sẽ ảnh hưởng đến tính thời gian thực của việc giám sát tác vụ.
Làm thế nào để cân bằng giữa hiệu quả và tính thời gian thực?
- Vi tác vụ (Microtasks):
Thiết kế vi tác vụ:
- Thông thường, chúng ta gọi các tác vụ trong hàng đợi tin nhắn là tác vụ macro (macrotasks). Mỗi tác vụ macro đều chứa một hàng đợi vi tác vụ (microtask queue).
- Trong quá trình thực thi tác vụ macro, nếu DOM có thay đổi (MutationObserver), thì thay đổi đó sẽ được thêm vào danh sách vi tác vụ, từ đó không ảnh hưởng đến việc tiếp tục thực thi tác vụ macro, giải quyết được vấn đề hiệu quả thực thi.
- Khi các chức năng chính trong tác vụ macro đã hoàn tất, engine hiển thị không vội vàng thực thi tác vụ macro tiếp theo mà thực thi các vi tác vụ trong tác vụ macro hiện tại. Vì các sự kiện thay đổi DOM đều được lưu trữ trong các hàng đợi vi tác vụ này, điều này giải quyết được vấn đề tính thời gian thực.
- Làm thế nào để giải quyết vấn đề tác vụ đơn lẻ thực thi quá lâu?
Vì tất cả các tác vụ đều được thực thi trong một luồng đơn, nên mỗi lần chỉ có thể thực thi một tác vụ, và các tác vụ khác đều ở trạng thái chờ đợi. Nếu một trong các tác vụ thực thi quá lâu, thì tác vụ tiếp theo sẽ phải chờ rất lâu.
Ví dụ, trong quá trình thực thi hoạt ảnh, nếu một tác vụ JS chiếm thời gian một khung hình hoạt ảnh quá lâu, nó sẽ tạo cảm giác giật lag cho người dùng, đây là một trải nghiệm người dùng rất tồi. Đối với trường hợp này, JS tránh vấn đề bằng cách sử dụng chức năng callback, tức là cho phép tác vụ JS cần thực thi được thực thi sau.
Bổ sung kiến thức:
- Engine hiển thị Blink và engine JS V8 đều hoạt động trên luồng chính của tiến trình hiển thị, do đó chỉ có thể thực thi một trong hai tại một thời điểm, vì vậy chúng loại trừ lẫn nhau.
- Vì engine JS chạy trên luồng chính của trang, nên cơ chế vòng lặp sự kiện của trình duyệt tương tác với luồng chính của trang, chứ không phải cái gọi là luồng engine JS. Cách nói này là sai, JS không có luồng thực thi riêng.
- Vì engine JavaScript chạy trên luồng chính của tiến trình hiển thị, nên chúng ta nói JavaScript là đơn luồng.
setTimeout
Trình duyệt hiện thực setTimeout như thế nào?
Chrome duy trì một hàng đợi tin nhắn riêng cho các tác vụ trễ, về bản chất là một cấu trúc hashmap, được sử dụng để lưu trữ các tác vụ cần thực thi trễ. Trong vòng lặp sự kiện của trình duyệt, sau mỗi khi một tác vụ trong hàng đợi tin nhắn được thực thi xong, trình duyệt sẽ duyệt qua danh sách các tác vụ trong hàng đợi trễ, lấy ra các tác vụ callback đã đến hạn và thực thi, sau đó chuyển sang vòng lặp sự kiện tiếp theo.
Việc hủy setTimeout rất đơn giản, chỉ cần tìm tác vụ tương ứng trong hàng đợi trễ thông qua ID hẹn giờ và sau đó xóa nó khỏi hàng đợi.
Một số lưu ý khi sử dụng setTimeout:
- Khi một tác vụ tin nhắn trong vòng lặp sự kiện thực thi quá lâu, nó sẽ ảnh hưởng đến độ chính xác thời gian thực thi của tác vụ hẹn giờ.
- Nếu
setTimeoutđược gọi lồng nhau, khoảng thời gian là 0, và được gọi hơn năm lần, hệ thống sẽ đặt khoảng thời gian tối thiểu là 4 mili giây. - Đối với các trang không hoạt động, khoảng thời gian thực thi tối thiểu của
setTimeoutlà 1000 mili giây, mục đích là để tối ưu hóa việc tiêu thụ tài nguyên của các trang nền và giảm mức tiêu thụ pin. - Thời gian thực thi trễ có giá trị tối đa, nếu vượt quá giá trị tối đa sẽ bị tràn, thời gian trễ được đặt lại về 0, và callback do đó được thực thi rất nhanh. Giá trị tối đa là 2147483647 mili giây, khoảng 24.8 ngày.
thistrong hàm callback củasetTimeoutkhông phù hợp với trực giác,thistrong callback mặc định được liên kết vớiwindow.
XMLHttpRequest
Hàm callback so với ngăn xếp gọi hệ thống:
Trang trình duyệt được điều khiển thông qua cơ chế vòng lặp sự kiện. Mỗi tiến trình hiển thị đều có một hàng đợi tin nhắn, luồng chính của trang thực thi các sự kiện trong hàng đợi tin nhắn theo thứ tự. Cơ chế hàng đợi tin nhắn và vòng lặp luồng chính đảm bảo trang hoạt động trật tự.
Khi hệ thống vòng lặp thực thi một tác vụ, nó cần duy trì một ngăn xếp gọi hệ thống cho tác vụ này. Ngăn xếp gọi hệ thống này tương tự như ngăn xếp gọi của JS, nhưng được duy trì bằng ngôn ngữ phát triển C++ của Chromium.
Mỗi tác vụ trong hệ thống vòng lặp đều có ngăn xếp gọi riêng trong quá trình thực thi. Callback đồng bộ là hàm callback được thực thi trong môi trường của hàm chính hiện tại. Callback bất đồng bộ là hàm callback được thực thi bên ngoài hàm chính, thường có hai cách:
- Cách thứ nhất là biến hàm bất đồng bộ thành một tác vụ và thêm vào cuối hàng đợi tin nhắn.
- Cách thứ hai là thêm hàm bất đồng bộ vào hàng đợi vi tác vụ, từ đó có thể thực thi vi tác vụ vào cuối tác vụ hiện tại.
Cơ chế hoạt động của XMLHttpRequest:
- Tiến trình hiển thị sẽ gửi yêu cầu đến tiến trình mạng, sau đó tiến trình mạng chịu trách nhiệm tải tài nguyên. Khi tiến trình mạng nhận được dữ liệu, nó sẽ sử dụng IPC để thông báo cho tiến trình hiển thị.
- Tiến trình hiển thị nhận được tin nhắn, sẽ đóng gói hàm callback của xhr thành tác vụ và thêm vào hàng đợi tin nhắn. Khi hệ thống vòng lặp của luồng chính thực thi đến tác vụ này, nó sẽ gọi hàm callback tương ứng dựa trên trạng thái liên quan:
- Nếu yêu cầu mạng gặp lỗi,
xhr.onerrorsẽ được thực thi. - Nếu hết thời gian,
xhr.ontimeoutsẽ được thực thi. - Nếu là nhận dữ liệu bình thường,
onreadystatechangesẽ được thực thi để phản hồi trạng thái tương ứng.
- Nếu yêu cầu mạng gặp lỗi,
Những "lỗi" trong quá trình sử dụng XMLHttpRequest:
- Vấn đề cross-origin (khác nguồn gốc):
Do chính sách bảo mật cùng nguồn gốc của trình duyệt, các yêu cầu cross-origin giữa các tên miền khác nhau không được phép.
- Vấn đề nội dung hỗn hợp HTTPS (HTTPS mixed content):
Nội dung hỗn hợp HTTPS có nghĩa là trang HTTPS chứa nội dung không tuân thủ yêu cầu bảo mật HTTPS. Ví dụ, hình ảnh, video, bảng kiểu, script được tải qua HTTP đều thuộc nội dung hỗn hợp.
Nếu trang HTTPS sử dụng nội dung hỗn hợp, trình duyệt sẽ hiển thị cảnh báo về nội dung hỗn hợp HTTPS để cho người dùng biết rằng trang HTTPS này chứa tài nguyên không an toàn.
Đối với các yêu cầu tài nguyên nội dung hỗn hợp như vậy, trình duyệt chỉ hiển thị cảnh báo. Tuy nhiên, nếu là yêu cầu XMLHttpRequest, trình duyệt sẽ cho rằng yêu cầu này có thể do kẻ tấn công khởi tạo và yêu cầu sẽ bị chặn.
Macrotasks và Microtasks
Macrotasks (Tác vụ Macro):
- Định nghĩa: Các tác vụ được lưu trong hàng đợi tin nhắn của tiến trình hiển thị.
- Thực thi: Được thực thi thông qua hệ thống vòng lặp sự kiện.
- Lấy tác vụ cũ nhất.
- Ghi lại thời gian bắt đầu tác vụ, đánh dấu là đang thực thi.
- Sau khi thực thi xong, xóa tác vụ này, làm sạch nó khỏi hàng đợi tin nhắn.
- Thống kê thời gian thực thi hoàn tất.
- Hạn chế: Độ chi tiết thời gian tương đối lớn, khoảng thời gian thực thi không thể kiểm soát chính xác, không phù hợp với một số yêu cầu độ thời gian thực cao.
- Lý do: Trang web có nhiều tác vụ tin nhắn, việc chèn các loại tác vụ khác nhau khiến JS không thể kiểm soát chính xác vị trí tác vụ của mình trong hàng đợi, do đó rất khó kiểm soát thời gian bắt đầu thực thi tác vụ.
Microtasks (Vi tác vụ):
- Định nghĩa: Một tác vụ hàm cần được thực thi bất đồng bộ.
- Tạo: Được tạo, sử dụng và duy trì bởi engine JS.
- Phát sinh trong quá trình thực thi tác vụ macro.
- Khi tạo môi trường thực thi toàn cục, một hàng đợi vi tác vụ sẽ được tạo bên trong.
- Mỗi tác vụ macro đều liên kết với một hàng đợi vi tác vụ.
- Thực thi:
- Thời điểm: Sau khi hàm chính kết thúc thực thi, trước khi tác vụ macro hiện tại kết thúc.
- Đặc điểm: Các tác vụ trong hàng đợi vi tác vụ sẽ được thực thi hết một lần. Các tác vụ mới được thêm vào trong quá trình này cũng sẽ được thực thi hết một lần, không để lại cho tác vụ macro tiếp theo.
Tóm tắt:
- Vi tác vụ và tác vụ macro được liên kết với nhau. Mỗi tác vụ macro khi thực thi sẽ tạo ra hàng đợi vi tác vụ riêng của nó.
- Thời gian thực thi của vi tác vụ sẽ ảnh hưởng đến thời gian của tác vụ macro hiện tại.
- Trong một tác vụ macro, nếu tạo một tác vụ macro và một vi tác vụ cho callback, thì trong mọi trường hợp, vi tác vụ sẽ được thực thi sớm hơn tác vụ macro.
Sự phát triển của phương pháp lắng nghe thay đổi DOM:
setTimeout/setInterval:- Khoảng thời gian đặt quá dài, phản hồi thay đổi DOM không đủ kịp thời.
- Khoảng thời gian đặt quá ngắn, sẽ lãng phí nhiều công việc vô ích để kiểm tra DOM, làm trang kém hiệu quả.
- Mutation Event:
- Khi DOM có thay đổi, sự kiện tương ứng sẽ được kích hoạt ngay lập tức — callback đồng bộ.
- Mỗi khi DOM thay đổi, engine hiển thị sẽ gọi JS, gây ra vấn đề hiệu suất nghiêm trọng.
- Khi lượng cập nhật quá lớn, JS chiếm thời gian thực thi quá lâu, khiến trang mất tương tác.
- MutationObserver:
- Giám sát thay đổi DOM, bao gồm thay đổi thuộc tính, thêm/bớt nút, thay đổi nội dung.
- Hàm phản hồi của MutationObserver sử dụng gọi bất đồng bộ, không phải mỗi khi DOM thay đổi đều kích hoạt gọi bất đồng bộ, mà đợi sau nhiều lần thay đổi DOM mới kích hoạt một lần.
- MutationObserver sẽ sử dụng một cấu trúc dữ liệu để ghi lại tất cả các thay đổi DOM trong khoảng thời gian đó, do đó ngay cả khi thao tác DOM thường xuyên, cũng không ảnh hưởng lớn đến hiệu suất.
- MutationObserver đã giải quyết vấn đề hiệu suất thông qua gọi bất đồng bộ và giảm số lần kích hoạt; giải quyết vấn đề kịp thời thông qua việc thêm thiết kế vi tác vụ.
Promise
Vấn đề của lập trình bất đồng bộ: Logic mã không liên tục
- Mô hình lập trình bất đồng bộ của ứng dụng Web:
- Luồng chính của trang khởi tạo một tác vụ tốn thời gian và giao tác vụ đó cho một tiến trình khác xử lý. Lúc này, luồng chính của trang sẽ tiếp tục thực thi các tác vụ trong hàng đợi tin nhắn.
- Khi tiến trình đó xử lý xong tác vụ, nó sẽ thêm tác vụ đó vào hàng đợi tin nhắn của tiến trình hiển thị và chờ đợi hệ thống vòng lặp xử lý.
- Sau khi xếp hàng xong, hệ thống vòng lặp sẽ lấy tác vụ từ hàng đợi tin nhắn để xử lý và kích hoạt các thao tác callback liên quan.
- Kiến trúc đơn luồng của trang Web quyết định callback bất đồng bộ, và callback bất đồng bộ ảnh hưởng đến cách chúng ta viết mã.
- Mã mẫu lập trình bất đồng bộ:
// Trạng thái thực thi function handleResponse(response) { console.log(response); } function handleError(error) { console.error("Error:", error); } let xhrRequest = new XMLHttpRequest(); xhrRequest.ontimeout = function(e) { handleError("Timeout: " + e.type); }; xhrRequest.onerror = function(e) { handleError("Network Error: " + e.type); }; xhrRequest.onreadystatechange = function() { if (xhrRequest.readyState === 4 && xhrRequest.status === 200) { handleResponse(xhrRequest.responseText); } }; // Thiết lập kiểu yêu cầu, URL, thông tin đồng bộ let targetURL = 'https://api.example.com/data'; xhrRequest.open('GET', targetURL, true); // Thiết lập tham số xhrRequest.timeout = 5000; // Thời gian chờ 5 giây xhrRequest.responseType = "text"; // Định dạng dữ liệu phản hồi xhrRequest.setRequestHeader("X-Custom-Header", "My-Value"); // Gửi yêu cầu xhrRequest.send();Một đoạn mã ngắn như trên mà có tới năm hàm callback, quá nhiều callback như vậy dẫn đến logic mã không liên tục, không tuyến tính, rất không trực quan. Đây chính là cách callback bất đồng bộ ảnh hưởng đến cách chúng ta viết mã.
Đóng gói mã bất đồng bộ, làm cho quy trình xử lý tuyến tính hơn
Tập trung vào đầu vào (thông tin yêu cầu) và đầu ra (thông tin phản hồi).
Ý tưởng tổng thể:
Mã hiện thực như sau:
// makeRequest dùng để tạo đối tượng request
function buildRequest(requestUrl) {
let requestOpts = {
method: 'GET',
url: requestUrl,
headers: {},
body: '',
credentials: false,
sync: false, // Thường là async
responseType: 'text',
referrer: ''
};
return requestOpts;
}
// [in] requestOpts, thông tin yêu cầu, tiêu đề, giá trị delay, kiểu trả về, v.v.
// [out] successCallback, thực thi thành công, gọi hàm này
// [out] errorCallback, thực thi thất bại, gọi hàm này
function performFetch(requestOpts, successCallback, errorCallback) {
let xhr = new XMLHttpRequest();
xhr.ontimeout = function (e) { errorCallback(e); };
xhr.onerror = function (e) { errorCallback(e); };
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) { // request finished and response is ready
if (xhr.status === 200) {
successCallback(xhr.responseText);
} else {
errorCallback(new Error(`Request failed with status ${xhr.status}`));
}
}
};
xhr.open(requestOpts.method, requestOpts.url, requestOpts.sync);
xhr.timeout = requestOpts.timeout || 0; // Default no timeout
xhr.responseType = requestOpts.responseType;
// Bổ sung các thông tin yêu cầu khác
// ...
xhr.send();
}
performFetch(buildRequest('https://api.example.com/data/users'),
function success(data) {
console.log("Success:", data);
},
function error(err) {
console.error("Error:", err.message);
});
Mã này đã khá phù hợp với tư duy tuyến tính của con người, hoạt động tốt trong các kịch bản đơn giản. Nhưng khi đối mặt với các kịch bản phức tạp hơn, việc lồng quá nhiều hàm callback sẽ dẫn đến callback hell (địa ngục callback).
performFetch(buildRequest('https://api.example.com/data/users?page=1'),
function successPage1(response1) {
console.log("Page 1 data:", response1);
performFetch(buildRequest('https://api.example.com/data/users?page=2'),
function successPage2(response2) {
console.log("Page 2 data:", response2);
performFetch(buildRequest('https://api.example.com/data/users?page=3'),
function successPage3(response3) {
console.log("Page 3 data:", response3);
}, function errorPage3(err3) {
console.error("Error fetching Page 3:", err3.message);
})
}, function errorPage2(err2) {
console.error("Error fetching Page 2:", err2.message);
})
}, function errorPage1(err1) {
console.error("Error fetching Page 1:", err1.message);
});
Vấn đề của đoạn mã trên:
- Gọi lồng nhau: Tác vụ tiếp theo phụ thuộc vào kết quả của tác vụ trước đó.
- Tính không chắc chắn của tác vụ: Mỗi tác vụ đều có hai kết quả có thể xảy ra (thành công hoặc thất bại).
Hướng giải quyết vấn đề:
- Loại bỏ gọi lồng nhau.
- Kết hợp xử lý lỗi của nhiều tác vụ.
Promise: Loại bỏ gọi lồng nhau và xử lý lỗi nhiều lần
Viết lại mã bằng Promise như sau:
function asyncFetch(requestOptions) {
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.open(requestOptions.method, requestOptions.url, true);
xhr.ontimeout = function (e) { reject({ type: 'timeout', event: e, xhr: this }); };
xhr.onerror = function (e) { reject({ type: 'network error', event: e, xhr: this }); };
xhr.onreadystatechange = function () {
if (this.readyState === 4) {
if (this.status >= 200 && this.status < 300) {
resolve(this.responseText, this);
} else {
let error = {
code: this.status,
response: this.response,
xhr: this
};
reject(error, this);
}
}
};
// Set headers if provided
if (requestOptions.headers) {
for (let header in requestOptions.headers) {
xhr.setRequestHeader(header, requestOptions.headers[header]);
}
}
// Set timeout if provided
if (requestOptions.timeout) {
xhr.timeout = requestOptions.timeout;
}
xhr.send(requestOptions.body);
});
}
var req1 = asyncFetch(buildRequest('https://api.example.com/data/products?category=electronics'));
var req2 = req1.then(value => {
console.log("Products (electronics):", value);
return asyncFetch(buildRequest('https://api.example.com/data/orders'));
});
var req3 = req2.then(value => {
console.log("Orders data:", value);
return asyncFetch(buildRequest('https://api.example.com/data/customers'));
});
req3.catch(error => {
console.error("An error occurred during API calls:", error);
});
Mã đã được sửa đổi rất tuyến tính và trực quan. Giải quyết callback lồng nhau:
- Hiện thực việc liên kết hàm callback trễ.
- Chuyển giá trị trả về (Promise) của hàm callback
onResolvexuyên suốt ra lớp ngoài cùng.
Xử lý lỗi ngoại lệ kết hợp:
- Các lỗi của đối tượng Promise có tính chất "nổi bọt", sẽ liên tục truyền về phía sau cho đến khi được xử lý bởi hàm
onRejecthoặc bắt bởi câu lệnhcatch.
Promise và Microtasks:
Mô phỏng hiện thực Promise:
function MyPromise(executor) {
let onFulfilledCallback = null; // Callback khi promise được fulfilled
this.then = function (onFulfilled) {
onFulfilledCallback = onFulfilled;
};
function resolve(value) {
// Sử dụng setTimeout để trì hoãn việc gọi callback, biến nó thành async
setTimeout(() => {
if (onFulfilledCallback) {
onFulfilledCallback(value);
}
}, 0);
}
// Thực thi hàm executor ngay lập tức, truyền vào hàm resolve của chúng ta
executor(resolve, null); // Giả định đơn giản, chưa hỗ trợ reject
}
// Hàm executor mẫu
function sampleExecutor(res, rej) {
res(123); // Giải quyết promise với giá trị 123
}
let myDemoPromise = new MyPromise(sampleExecutor);
function processResolvedValue(val) {
console.log("Resolved value:", val);
}
myDemoPromise.then(processResolvedValue);
Vì .then được liên kết trễ, onResolve nên được thực thi trễ, do đó được hiện thực thông qua hẹn giờ. Nhưng chúng ta biết rằng hiệu suất sử dụng hẹn giờ không cao lắm, thuộc về tác vụ macro. Do đó, engine V8 hiện thực nó thành vi tác vụ, điều này vừa hiện thực gọi trễ vừa nâng cao hiệu quả thực thi mã.
Trong Promise, việc thêm vi tác vụ xảy ra khi thực thi resolve hoặc reject. then và catch chỉ là các hành động callback liên quan đến vi tác vụ.
Async/Await
Vấn đề của Promise:
Mặc dù Promise giải quyết được callback hell, nhưng khi nghiệp vụ phức tạp, việc lặp lại mã .then khiến ngữ nghĩa mã không rõ ràng, không thể thể hiện tốt quy trình thực thi. Vì lý do này, ES7 đã giới thiệu async/await, đây là một cải tiến lớn trong lập trình bất đồng bộ của JS, cung cấp khả năng truy cập tài nguyên bất đồng bộ bằng mã đồng bộ mà không chặn luồng chính, đồng thời làm cho logic mã rõ ràng hơn.
Generators so với Coroutines:
- Generator (Hàm sinh):
// Hàm có dấu sao là hàm generator function* myGenerator() { console.log("--- Bắt đầu thực thi đoạn 1 ---"); yield 'Bước 1 hoàn thành'; console.log("--- Bắt đầu thực thi đoạn 2 ---"); yield 'Bước 2 hoàn thành'; console.log("--- Bắt đầu thực thi đoạn 3 ---"); yield 'Bước 3 hoàn thành'; console.log("--- Thực thi kết thúc ---"); return 'Generator hoàn tất'; } console.log('Main: Khởi tạo generator'); let genInstance = myGenerator(); console.log(genInstance.next().value); // Main: Khởi tạo generator -> --- Bắt đầu thực thi đoạn 1 --- -> Bước 1 hoàn thành console.log('Main: Giữa bước 1 và 2'); console.log(genInstance.next().value); // Main: Giữa bước 1 và 2 -> --- Bắt đầu thực thi đoạn 2 --- -> Bước 2 hoàn thành console.log('Main: Giữa bước 2 và 3'); console.log(genInstance.next().value); // Main: Giữa bước 2 và 3 -> --- Bắt đầu thực thi đoạn 3 --- -> Bước 3 hoàn thành console.log('Main: Giữa bước 3 và kết thúc'); console.log(genInstance.next().value); // Main: Giữa bước 3 và kết thúc -> --- Thực thi kết thúc --- -> Generator hoàn tất console.log('Main: Kết thúc chương trình'); - Coroutine (Đồng trình):
Coroutine là một thực thể nhẹ hơn luồng, có thể coi nó là một tác vụ chạy trên luồng. Một luồng có thể có nhiều coroutine, nhưng chỉ một coroutine có thể thực thi tại một thời điểm trên luồng. Nhiều coroutine chạy trên luồng chính cần phối hợp làm việc, các coroutine chuyển giao quyền kiểm soát luồng chính cho nhau để hiện thực một tác vụ chương trình hoàn chỉnh. Nếu một coroutine A khởi động một coroutine B, chúng ta gọi coroutine A là cha của coroutine B. Coroutine không được quản lý bởi nhân hệ điều hành, mà hoàn toàn được kiểm soát bởi chương trình, điều này mang lại lợi ích về hiệu suất, không tiêu tốn tài nguyên như chuyển đổi luồng.
Lấy ví dụ mã hàm generator ở trên, quy trình thực thi coroutine như sau:
Có thể thấy bốn quy tắc của coroutine:
- Tạo một coroutine
genInstancebằng cách gọi hàm generatormyGenerator. Sau khi tạo, coroutinegenInstancekhông thực thi ngay lập tức. - Để coroutine
genInstancethực thi, cần gọigenInstance.next(). - Khi coroutine đang thực thi, có thể tạm dừng việc thực thi của coroutine
genInstancebằng từ khóayieldvà trả về thông tin chính cho coroutine cha. - Nếu coroutine gặp từ khóa
returntrong quá trình thực thi, engine JS sẽ kết thúc coroutine hiện tại và trả về nội dung saureturncho coroutine cha.
Chuyển đổi giữa các coroutine:
Từ hình trên có thể thấy:
- Coroutine
genInstancevà coroutine cha được thực thi xen kẽ trên luồng chính, không phải thực thi đồng thời. Việc chuyển đổi giữa chúng được hoàn thành thông qua sự phối hợp củayieldvàgenInstance.next(). - Khi gọi phương thức
yieldtrong coroutinegenInstance, engine JS sẽ lưu thông tin ngăn xếp gọi hiện tại của coroutinegenInstancevà khôi phục thông tin ngăn xếp gọi của coroutine cha. - Tương tự, khi thực thi
genInstance.next()trong coroutine cha, engine JS sẽ lưu thông tin ngăn xếp gọi của coroutine cha và khôi phục thông tin ngăn xếp gọi của coroutinegenInstance.
- Tạo một coroutine
- Mối quan hệ với
async/await:Trong JS, hàm generator là một cách hiện thực hóa coroutine. Công nghệ
async/awaitchính là ứng dụng của Promise và generator. Nói ở tầng thấp hơn, đó là ứng dụng của vi tác vụ và coroutine.
Async/Await:
async:asynclà một hàm thực thi bất đồng bộ và trả về ngầm định một Promise làm kết quả. Việc trả về ngầm định của hàmasyncthể hiện ở chỗ không cần cố ýreturn Promisehoặc bất kỳ giá trị nào, sau khi hàm thực thi sẽ mặc định trả về một Promise.await:async function runExample() { console.log(1); // Đồng bộ let valueA = await 100; // Tạm dừng, xử lý bất đồng bộ console.log(valueA); // Chạy sau khi promise được giải quyết console.log(2); // Chạy sau console.log(valueA) } console.log(0); // Đồng bộ runExample(); // Gọi hàm async console.log(3); // Đồng bộKết hợp với khái niệm coroutine, sơ đồ thực thi mã trên như sau:
Phân tích trọng tâm quá trình
await:- Khi thực thi đến
await 100, một đối tượng Promise sẽ được tạo nội bộ, mặc định thực thiresolve(100), thêm một vi tác vụ vào hàng đợi vi tác vụ. - Sau đó, engine JS sẽ tạm dừng thực thi coroutine hiện tại, chuyển quyền kiểm soát luồng chính cho coroutine cha thực thi, đồng thời trả về Promise này cho coroutine cha.
- Coroutine cha sau khi nhận được quyền kiểm soát luồng chính, sẽ hiện thực
promise.thennội bộ để giám sát sự thay đổi trạng thái của Promise, sau đó tiếp tục thực thi quy trình của coroutine cha, thực thiconsole.log(3). - Sau đó, coroutine cha sẽ kết thúc thực thi. Trước khi kết thúc, nó sẽ vào điểm kiểm tra vi tác vụ, sau đó thực thi hàng đợi vi tác vụ, kích hoạt callback
thennội bộ liên quan đến vi tác vụresolve(100)trước đó. - Khi callback
thenđược kích hoạt, nó sẽ chuyển quyền kiểm soát luồng chính cho coroutine của hàmrunExample, đồng thời truyền giá trị đã giải quyết cho coroutine đó. - Coroutine
runExamplesau khi được kích hoạt, sẽ gán giá trị đó cho biếnvalueA, sau đó coroutinerunExampletiếp tục thực thi các câu lệnh tiếp theo. Sau khi thực thi xong, nó sẽ trả quyền kiểm soát về cho coroutine cha.
- Khi thực thi đến
Các trang trong trình duyệt
Cây DOM (Document Object Model)
DOM là gì?
- Từ góc độ trang: DOM là cấu trúc dữ liệu cơ bản để tạo trang, dùng để biểu diễn tài liệu HTML.
- Từ góc độ JS: DOM là giao diện được cung cấp cho JS để thao tác, thông qua bộ giao diện này để truy cập và sửa đổi cấu trúc DOM.
- Từ góc độ bảo mật: DOM là một lớp bảo vệ an toàn, một số nội dung không an toàn đều bị loại bỏ trong giai đoạn phân tích DOM.
Trước khi cây DOM được tạo:
- Tiến trình mạng tải bao nhiêu dữ liệu, HTML parser sẽ phân tích bấy nhiêu dữ liệu.
- Quy trình cụ thể:
- Tiến trình mạng sau khi nhận được tiêu đề phản hồi, sẽ dựa vào trường
content-typetrong tiêu đề phản hồi để xác định kiểu tệp. - Nếu xác định đây là tệp kiểu HTML, nó sẽ chọn hoặc tạo một tiến trình hiển thị cho yêu cầu này.
- Sau khi tiến trình hiển thị sẵn sàng, tiến trình mạng và tiến trình hiển thị sẽ thiết lập một kênh chia sẻ dữ liệu.
- Tiến trình mạng nhận được dữ liệu, sẽ đẩy vào kênh này, còn tiến trình hiển thị sẽ liên tục đọc dữ liệu từ đầu kia của kênh, đồng thời "cung cấp" dữ liệu đã đọc cho HTML parser.
- Tiến trình mạng sau khi nhận được tiêu đề phản hồi, sẽ dựa vào trường
Tạo cây DOM:
Quy trình tạo:
- Giai đoạn 1: Thông qua phân từ (tokenizer) chuyển luồng byte thành Token.
- Sau khi nhận được luồng byte từ tiến trình mạng, nó sẽ được khôi phục thành các ký tự tài liệu HTML.
- Tiếp theo, phân từ thông qua phân tích từ vựng, chuyển đổi các ký tự này thành từng Token.
- Có thể thấy, có Tag Token và Text Token, Tag Token lại chia thành StartTag và EndTag.
- Giai đoạn 2: Phân tích Token thành nút DOM và thêm vào cây DOM.
- HTML parser duy trì một cấu trúc ngăn xếp Token, ngăn xếp Token này chủ yếu dùng để tính toán mối quan hệ cha-con giữa các nút. Các Token được tạo trong giai đoạn đầu tiên sẽ được đẩy vào ngăn xếp này theo thứ tự.
- Nếu Token được đẩy vào ngăn xếp là thẻ StartTag, HTML parser sẽ tạo một nút DOM cho Token đó, sau đó thêm nút đó vào cây DOM. Nút cha của nó là nút được tạo từ phần tử liền kề trong ngăn xếp.
- Nếu phân từ phân tích ra là Text Token, thì một nút văn bản sẽ được tạo, sau đó thêm nút đó vào cây DOM. Text Token không cần đẩy vào ngăn xếp, nút cha của nó là nút DOM tương ứng với Token trên đỉnh ngăn xếp hiện tại.
- Nếu phân từ phân tích ra là thẻ EndTag, ví dụ EndTag
div, HTML parser sẽ kiểm tra xem phần tử trên đỉnh ngăn xếp Token có phải là StartTagdivkhông. Nếu có, nó sẽ bật StartTagdivra khỏi ngăn xếp, báo hiệu phần tửdivđã hoàn tất phân tích. - Các Token mới được tạo bởi phân từ cứ liên tục được đẩy vào và bật ra khỏi ngăn xếp, toàn bộ quá trình phân tích cứ tiếp diễn cho đến khi phân từ hoàn tất phân từ tất cả các luồng byte.
Ví dụ tạo cây DOM:
Lấy đoạn mã sau làm ví dụ:
<html>
<body>
<div>First content</div>
<div>Second content</div>
</body>
</html>
- Sau khi nhận dữ liệu trên từ tiến trình mạng, nó được truyền dưới dạng luồng byte đến HTML parser.
- HTML parser bắt đầu làm việc, mặc định tạo một cấu trúc DOM trống với gốc là
document, đồng thời đẩy một TokenStartTag documentvào đáy ngăn xếp. - Tiếp theo, Token
StartTag htmlđầu tiên được phân tích bởi phân từ được đẩy vào ngăn xếp, và một nút DOMhtmlđược tạo, thêm vàodocument. - Sau đó, theo cùng quy trình, phân tích ra
StartTag bodyvàStartTag div. - Tiếp theo, phân tích ra Text Token của
divđầu tiên. Engine hiển thị sẽ tạo một nút văn bản cho Token này và thêm nút này vào DOM. Nút cha của nó là nút tương ứng với phần tử trên đỉnh ngăn xếp Token hiện tại. - Sau đó, phân từ phân tích ra
EndTag divđầu tiên. Lúc này, HTML parser sẽ kiểm tra xem phần tử trên đỉnh ngăn xếp hiện tại có phải là StartTagdivkhông. Nếu có, thì StartTagdivsẽ được bật ra khỏi ngăn xếp. - Theo cùng quy tắc, quá trình phân tích tiếp diễn, và kết quả cuối cùng là một cây DOM hoàn chỉnh.
Trong môi trường sản xuất thực tế, mã nguồn HTML bao gồm cả CSS và JavaScript, cũng như các tệp hình ảnh, âm thanh, video, v.v., vì vậy quá trình xử lý phức tạp hơn nhiều so với ví dụ trên.
Ảnh hưởng của JS đến phân tích DOM:
- Chèn mã thẻ
<script>:Khi trong quá trình phân tích DOM, gặp mã thẻ
<script>, HTML parser tạm dừng công việc, JS engine can thiệp. Sau khi mã thực thi xong, HTML parser tiếp tục công việc, hoàn thành việc phân tích nội dung tiếp theo cho đến khi tạo DOM cuối cùng. - Thẻ
<script>nhập script JS bên ngoài:Thêm một quá trình tải xuống, quá trình này cũng chiếm thời gian, chặn công việc phân tích HTML. Về việc tải tài nguyên, trình duyệt Chrome đã thực hiện tối ưu hóa dự phân tích (pre-parsing), thông qua luồng dự phân tích để phân tích tài nguyên liên quan đến HTML, thực hiện việc tải xuống trước.
- Script JS phụ thuộc vào bảng kiểu CSS:
Vì JS có khả năng sửa đổi CSS, và việc hình thành CSSOM lại ảnh hưởng đến việc xây dựng hiển thị bố cục. Do đó, trình duyệt quy định rằng nếu có CSS trước script JS, thì trước khi JS thực thi, cần đảm bảo CSS đã được tải xuống và phân tích hoàn tất. Engine V8 trước khi phân tích JS, không biết liệu có thao tác với bảng kiểu CSS trước nó hay không, vì vậy khi engine hiển thị gặp script JS, bất kể script đó có thao tác với CSS hay không, nó sẽ luôn thực hiện việc tải xuống và phân tích tệp CSS trước, sau đó mới thực thi script JS. Điều này làm cho việc tải xuống/phân tích CSS chặn việc thực thi JS, và JS lại chặn DOM, do đó CSS gián tiếp chặn việc phân tích DOM. Nếu mã JS nhập tệp CSS bên ngoài, quá trình chặn này còn bao gồm thời gian tải xuống hoàn tất của CSS.
Sau khi giới thiệu async/defer:
(Màu xanh là phân tích DOM, màu tím là tải script, màu vàng là thực thi script, màu xanh lá cây là kích hoạt DOMContentLoaded)
<script>thông thường:- Việc tải xuống và thực thi script đều chặn phân tích DOM.
- Tốc độ tải xuống script khác nhau, nhưng việc thực thi script được thêm sau phụ thuộc vào việc tải xuống và thực thi hoàn tất của script được thêm trước.
<script defer>:- Tải xuống script song song với phân tích DOM. Chỉ khi phân tích DOM hoàn tất, nội dung JS mới được thực thi.
- Tốc độ tải xuống script khác nhau, nhưng việc thực thi script được thêm sau phụ thuộc vào việc tải xuống và thực thi hoàn tất của script được thêm trước.
<script async>:- Tải xuống script song song với phân tích DOM. Bất kể DOM đã được phân tích xong hay chưa, ngay khi script được tải xuống hoàn tất, nội dung JS sẽ được thực thi.
- Tốc độ tải xuống script khác nhau, việc thực thi script không có mối quan hệ phụ thuộc. Script nào tải xong trước thì script đó thực thi trước.
Tuyến ống Render (Rendering Pipeline)
Xây dựng render chỉ chứa CSS:
- Đầu tiên là hành động yêu cầu trang chính. Bên khởi tạo có thể là tiến trình hiển thị hoặc tiến trình trình duyệt. Lệnh này được gửi đến tiến trình mạng để thực thi.
- Tiến trình mạng sau khi nhận được dữ liệu HTML trả về, sẽ gửi nó đến tiến trình hiển thị. Tiến trình hiển thị sẽ phân tích dữ liệu HTML và xây dựng DOM.
- Trong quá trình phân tích HTML này, một luồng dự phân tích sẽ được khởi động. Nếu gặp các tệp tài nguyên như JS và CSS, luồng dự phân tích sẽ tải xuống các dữ liệu này trước.
CSSOM trong tuyến ống render:
Giống như HTML, engine hiển thị cũng không thể trực tiếp hiểu nội dung tệp CSS, vì vậy cần phân tích nó thành cấu trúc mà engine hiển thị có thể hiểu được. Cấu trúc này chính là CSSOM. Hai tác dụng của CSSOM:
- Cung cấp khả năng thao tác bảng kiểu cho JavaScript.
- Cung cấp thông tin kiểu cơ bản cho việc tổng hợp cây bố cục.
Việc tổng hợp cây bố cục cần phụ thuộc vào CSSOM. Chỉ thông qua CSSOM mới có thể thực hiện tính toán bố cục, mới có thể hoàn thành việc xây dựng cây bố cục.
Xây dựng render chứa CSS và JS (chèn mã Script vào cấu trúc DOM):
Trong quá trình phân tích DOM, gặp script JS, nó sẽ tạm dừng phân tích DOM để thực thi JS, vì JS có thể sửa đổi DOM ở trạng thái hiện tại. Ngoài ra, vì JS có khả năng sửa đổi CSSOM, nên trước khi thực thi JS, cũng cần phụ thuộc vào việc xây dựng CSSOM hoàn tất.
Xây dựng render chứa CSS và JS (chèn script ngoài vào cấu trúc DOM):
- Luồng dự phân tích đồng thời khởi tạo yêu cầu tải xuống các tệp CSS và JS. Quá trình tải xuống hai tệp này chồng lấn nhau, thời gian tải tính theo tệp nào lâu nhất.
- Bất kể tệp CSS và JS cái nào đến trước, đều phải đợi tệp CSS tải xuống hoàn tất và tạo CSSOM, sau đó mới thực thi script JS, cuối cùng tiếp tục xây dựng DOM, xây dựng cây bố cục và vẽ trang.
Các yếu tố ảnh hưởng đến hiển thị trang và chiến lược tối ưu:
Tuyến ống render ảnh hưởng đến tốc độ hiển thị trang lần đầu, và tốc độ hiển thị trang lần đầu lại trực tiếp ảnh hưởng đến trải nghiệm người dùng. Từ khi URL được khởi tạo đến khi trang hiển thị, trải qua ba giai đoạn:
- Giai đoạn 1: Từ điều hướng URL, đến yêu cầu mạng được gửi đi, đến khi nhận dữ liệu chuẩn bị render.
- Giai đoạn 2: Sau khi gửi dữ liệu, tiến trình render sẽ tạo một trang trắng. Chúng ta thường gọi khoảng thời gian này là thời gian trắng phân tích (parse white screen). Sau đó, trải qua một loạt các bước để chuẩn bị cho lần render đầu tiên.
- Giai đoạn 3: Sau khi lần render đầu tiên hoàn tất, bắt đầu vào giai đoạn tạo trang hoàn chỉnh, trang dần dần được vẽ ra.
Trọng tâm tối ưu hóa là thời gian trắng. Nút cổ chai ở đây chủ yếu thể hiện ở việc tải xuống và phân tích các tệp CSS và JS, vì chúng ảnh hưởng đến việc xây dựng cây bố cục, từ đó ảnh hưởng đến việc render và vẽ trang.
Các tài nguyên như hình ảnh, âm thanh và video sẽ không chặn lần render đầu tiên của trang. Chỉ HTML, CSS và JS trực tiếp liên quan đến render trang mới chặn render. Chúng ta gọi các tài nguyên này là tài nguyên quan trọng (critical resources).
Các yếu tố tối ưu hóa:
- Số lượng tài nguyên quan trọng: Càng nhiều tài nguyên quan trọng, thời gian tải trang lần đầu càng lâu.
- Kích thước tài nguyên quan trọng: Nội dung tài nguyên quan trọng càng nhỏ, thời gian tải xuống toàn bộ tài nguyên càng ngắn, thời gian chặn càng ngắn.
- Số lượng RTT (Round Trip Time - thời gian khứ hồi) của yêu cầu tài nguyên quan trọng: Một tệp tài nguyên thường cần được chia thành nhiều gói dữ liệu để truyền, và một gói dữ liệu HTTP khoảng 14KB. Gói dữ liệu lớn hơn cần được chia nhỏ.
Phương pháp tối ưu hóa:
- Giảm số lượng tài nguyên quan trọng:
- Chuyển CSS và JS sang dạng nội tuyến để giảm số lượng.
- Đặt thuộc tính
async/defercho JS để tránh trở thành tài nguyên quan trọng ban đầu. - Đặt loại hiển thị media hợp lý cho CSS để tránh tất cả các kiểu trở thành tài nguyên quan trọng ban đầu.
- Giảm kích thước tài nguyên quan trọng:
- Nén tài nguyên HTML, CSS và JS, xóa bỏ các bình luận.
- Giảm số lượng RTT của yêu cầu tài nguyên quan trọng:
- Thông qua CDN để giảm thời gian RTT mỗi lần.
- Thông qua việc giảm số lượng và kích thước tài nguyên quan trọng, cũng giảm được thời gian RTT.
Cơ chế phân lớp và tổng hợp (Layering and Compositing)
Màn hình hiển thị hình ảnh như thế nào?
- Thẻ đồ họa thường có hai bộ đệm (buffer). Hình ảnh hiển thị trên màn hình nằm ở bộ đệm trước (front buffer), và hình ảnh sắp hiển thị nằm ở bộ đệm sau (back buffer).
- Màn hình có tần số làm mới cố định, thường là 60Hz, tức là cập nhật 60 hình ảnh mỗi giây. Các hình ảnh được cập nhật đều đến từ bộ đệm trước.
- Trách nhiệm của thẻ đồ họa là tổng hợp các hình ảnh mới và lưu chúng vào bộ đệm sau. Một khi hình ảnh mới được ghi vào bộ đệm sau, hệ thống sẽ hoán đổi bộ đệm sau và bộ đệm trước, đảm bảo màn hình có thể đọc được hình ảnh mới nhất đã được tổng hợp trong thẻ đồ họa.
- Trách nhiệm của màn hình là đọc hình ảnh từ bộ đệm trước 60 lần mỗi giây và hiển thị hình ảnh đã đọc lên màn hình.
- Thông thường, tần số cập nhật của thẻ đồ họa và tần số làm mới của màn hình là nhất quán. Nhưng đôi khi trong một số kịch bản phức tạp, tốc độ xử lý một hình ảnh của thẻ đồ họa sẽ chậm lại, điều này sẽ gây ra tình trạng giật hình (stuttering) về mặt thị giác.
Frame (Khung hình) và Frame Rate (Tốc độ khung hình):
- Khi trang hiển thị, engine hiển thị sẽ tạo ra các hình ảnh mới thông qua tuyến ống render và gửi đến bộ đệm sau của thẻ đồ họa.
- Hầu hết các màn hình thiết bị có tần số cập nhật là 60 lần/giây. Để đạt được hiệu ứng hoạt ảnh mượt mà, engine hiển thị cần cập nhật 60 hình ảnh mỗi giây vào bộ đệm sau của thẻ đồ họa.
- Chúng ta gọi mỗi hình ảnh được tạo ra bởi tuyến ống render là một khung hình (frame), và gọi số khung hình mà tuyến ống render cập nhật mỗi giây là tốc độ khung hình (frame rate).
- Vì người dùng dễ dàng nhận thấy các khung hình bị mất. Nếu trong một quá trình hoạt ảnh, engine hiển thị tạo ra một số khung hình quá lâu, người dùng sẽ cảm thấy giật hình, điều này sẽ tạo ấn tượng rất xấu cho người dùng.
Cách tạo một khung hình:
- Reflow (Tái bố cục): Cần chạy lại toàn bộ tuyến ống render.
- Repaint (Tái vẽ): Không có giai đoạn bố cục, hiệu quả hoạt động hơi cao hơn, nhưng vẫn cần tính toán lại thông tin vẽ và kích hoạt một loạt các thao tác sau thao tác vẽ.
- Compositing (Tổng hợp): Bỏ qua hai giai đoạn bố cục và vẽ, hiệu quả cao nhất.
Phân lớp và tổng hợp:
Nếu bất kỳ thay đổi nhỏ nào cũng kích hoạt cơ chế tái bố cục hoặc tái vẽ, chiến lược vẽ "kéo theo toàn bộ" này sẽ ảnh hưởng nghiêm trọng đến hiệu suất render của trang. Để nâng cao hiệu suất render mỗi khung hình, Chrome đã giới thiệu cơ chế phân lớp và tổng hợp.
Việc phân lớp và tổng hợp giải quyết hiệu suất render ở chỗ, các lớp khác nhau được vẽ độc lập, các thay đổi gây ra không ảnh hưởng lẫn nhau. Thứ hai, nhiều thao tác vẽ có thể được hoàn thành trực tiếp bằng luồng tổng hợp, không cần đi qua quy trình bố cục.
Trong tuyến ống render của Chrome, phân lớp thể hiện ở việc sau khi tạo cây bố cục, engine hiển thị sẽ chuyển đổi nó thành cây lớp (Layer Tree) dựa trên các đặc điểm của cây bố cục. Cây lớp là cấu trúc cơ bản cho các quy trình tiếp theo của tuyến ống render.
Mỗi nút trong cây lớp đều thuộc về một lớp. Giai đoạn vẽ tiếp theo phụ thuộc vào các nút trong cây lớp. Giai đoạn vẽ thực chất không phải là vẽ hình ảnh mà là nhóm các lệnh vẽ thành một danh sách, sau đó sẽ đi vào giai đoạn raster hóa, tạo hình ảnh theo các lệnh trong danh sách vẽ.
Mỗi lớp tương ứng với một hình ảnh. Sau khi luồng tổng hợp có các hình ảnh này, nó sẽ tổng hợp các hình ảnh này thành "một" hình ảnh duy nhất và cuối cùng gửi hình ảnh đã tạo đến bộ đệm sau. Hoạt động tổng hợp được thực hiện trên luồng tổng hợp, điều này có nghĩa là khi thực hiện hoạt động tổng hợp, nó sẽ không ảnh hưởng đến việc thực thi của luồng chính.
Phân khối (Tiling):
Nếu phân lớp nâng cao hiệu suất render ở cấp độ vĩ mô, thì phân khối nâng cao hiệu suất render ở cấp độ vi mô.
Thông thường, nội dung của trang thường lớn hơn nhiều so với màn hình. Khi hiển thị một trang, nếu chờ tất cả các lớp được tạo xong rồi mới tiến hành tổng hợp, sẽ phát sinh một số chi phí không cần thiết, và cũng làm cho thời gian tổng hợp hình ảnh lâu hơn.
Do đó, luồng tổng hợp sẽ chia mỗi lớp thành các ô có kích thước cố định, sau đó ưu tiên vẽ các ô gần viewport (khu vực hiển thị), điều này có thể tăng tốc đáng kể tốc độ hiển thị trang.
Tuy nhiên, đôi khi ngay cả khi chỉ vẽ những ô có độ ưu tiên cao nhất, cũng mất khá nhiều thời gian, vì liên quan đến một yếu tố rất quan trọng - tải lên texture. Điều này là do thao tác tải lên từ bộ nhớ máy tính lên bộ nhớ GPU khá chậm.
Để giải quyết vấn đề này, Chrome áp dụng chiến lược: khi lần đầu tiên tổng hợp các ô, trước tiên tạo một hình ảnh độ phân giải thấp, sau đó trình tổng hợp tiếp tục vẽ nội dung trang web với tỷ lệ bình thường. Khi nội dung trang web với tỷ lệ bình thường được vẽ xong, nó sẽ thay thế nội dung độ phân giải thấp hiện đang hiển thị.
Cách tận dụng kỹ thuật phân lớp để tối ưu hóa mã:
Khi cần thực hiện một số phép biến đổi hình học và thao tác độ trong suốt, việc thao tác JS sẽ kích hoạt quy trình tái bố cục, do đó hiệu suất vẽ sẽ rất thấp.
Có thể sử dụng thuộc tính will-change của CSS. Nó sẽ khiến engine hiển thị chuẩn bị một lớp độc lập cho phần tử đó. Tối ưu hóa của nó thể hiện ở ba khía cạnh:
- Biến đổi (
transform) của phần tử tương ứng chạy trên một lớp độc lập, thoát khỏi luồng tài liệu, không kích hoạt tái bố cục. - Nó xử lý trực tiếp biến đổi thông qua luồng tổng hợp, không liên quan đến luồng chính, cải thiện đáng kể hiệu suất render.
- Nó kích hoạt tăng tốc GPU, biến đổi của phần tử tương ứng được quản lý bởi GPU, giảm gánh nặng CPU.
Tuy nhiên, mọi thứ đều có hai mặt. Mỗi khi engine hiển thị chuẩn bị một lớp độc lập cho một phần tử, bộ nhớ mà nó chiếm dụng cũng sẽ tăng lên đáng kể, vì từ cây lớp trở đi, mỗi giai đoạn tiếp theo sẽ có thêm một cấu trúc lớp, tất cả những điều này đều cần bộ nhớ bổ sung. Vì vậy, bạn cần sử dụng will-change một cách hợp lý.
Các tác vụ có thể hoàn thành trực tiếp trong luồng tổng hợp sẽ không thay đổi nội dung của lớp, ví dụ như thay đổi thông tin văn bản, thay đổi bố cục, thay đổi màu sắc, tất cả đều không liên quan. Các thay đổi liên quan đến những nội dung này sẽ phải liên quan đến tái bố cục hoặc tái vẽ.
Các tác vụ có thể hiện thực trực tiếp trong luồng tổng hợp là biến đổi hình học, biến đổi độ trong suốt, đổ bóng, v.v., của toàn bộ lớp. Các biến đổi này sẽ không ảnh hưởng đến nội dung của lớp, do đó bỏ qua giai đoạn tái vẽ (Paint).
Hiệu năng trang (Page Performance)
Các giai đoạn của trang:
- Giai đoạn tải: Là quá trình từ khi gửi yêu cầu đến khi render trang hoàn chỉnh. Các yếu tố chính ảnh hưởng đến giai đoạn này là mạng và script JavaScript.
- Giai đoạn tương tác: Chủ yếu là quá trình tích hợp từ khi tải trang hoàn tất đến khi người dùng tương tác. Các yếu tố chính ảnh hưởng đến giai đoạn này là script JavaScript.
- Giai đoạn đóng: Chủ yếu là một số thao tác dọn dẹp mà trang thực hiện sau khi người dùng đưa ra lệnh đóng.
Giai đoạn tải:
Trong một tuyến ống render, các tài nguyên như hình ảnh, âm thanh và video sẽ không chặn lần render đầu tiên của trang. Chỉ HTML, CSS và JS trực tiếp liên quan đến render trang mới chặn render. Chúng ta gọi các tài nguyên này là tài nguyên quan trọng.
Các yếu tố tối ưu hóa:
- Số lượng tài nguyên quan trọng: Càng nhiều tài nguyên quan trọng, thời gian tải trang lần đầu càng lâu.
- Kích thước tài nguyên quan trọng: Nội dung tài nguyên quan trọng càng nhỏ, thời gian tải xuống toàn bộ tài nguyên càng ngắn, thời gian chặn càng ngắn.
- Số lượng RTT (Round Trip Time - thời gian khứ hồi) của yêu cầu tài nguyên quan trọng: Một tệp tài nguyên thường cần được chia thành nhiều gói dữ liệu để truyền, và 1 gói dữ liệu HTTP khoảng 14KB. Gói dữ liệu lớn hơn cần được chia nhỏ.
Phương pháp tối ưu hóa:
- Giảm số lượng tài nguyên quan trọng:
- Chuyển CSS và JS sang dạng nội tuyến để giảm số lượng.
- Đặt thuộc tính
async/defercho JS để tránh trở thành tài nguyên quan trọng ban đầu. - Đặt loại hiển thị media hợp lý cho CSS để tránh tất cả các kiểu trở thành tài nguyên quan trọng ban đầu.
- Giảm kích thước tài nguyên quan trọng:
- Nén tài nguyên HTML, CSS và JS, loại bỏ các bình luận.
- Giảm số lượng RTT của yêu cầu tài nguyên quan trọng:
- Thông qua CDN để giảm thời gian RTT mỗi lần.
- Thông qua việc giảm số lượng và kích thước tài nguyên quan trọng, cũng giảm được thời gian RTT.
Giai đoạn tương tác:
Nói về tối ưu hóa giai đoạn tương tác, thực chất là nói về tốc độ tạo khung hình của tiến trình render, vì trong giai đoạn tương tác, tốc độ render khung hình quyết định độ mượt mà của tương tác.
Tuyến ống render của giai đoạn tương tác:
- Việc tạo khung hình trong giai đoạn tương tác chủ yếu phát sinh từ thao tác DOM và CSS của JS, một phần khác thì do CSS tự kích hoạt.
- Nếu thao tác JS có sửa đổi thông tin bố cục, thì sẽ kích hoạt tái bố cục.
- Nếu chỉ sửa đổi thông tin màu sắc, thì sẽ không liên quan đến điều chỉnh bố cục, mà chỉ kích hoạt tái vẽ.
- Nếu chỉ CSS hiện thực một số hiệu ứng biến dạng, chuyển màu, hoạt ảnh, v.v., thì điều này chỉ được thực thi trên luồng tổng hợp. Quá trình này được gọi là tổng hợp.
- Trọng tâm tối ưu hóa là làm thế nào để tăng tốc độ tạo một khung hình đơn lẻ.
Phương pháp tối ưu hóa:
- Giảm thời gian thực thi JS:
- Tránh JS thực thi chiếm dụng luồng chính trong thời gian dài.
- Phân tách hàm thực thi một lần thành nhiều tác vụ, để thời gian thực thi mỗi lần không quá lâu.
- Sử dụng Web Workers để tạo các luồng độc lập trong nền xử lý các tác vụ JS bổ sung.
- Tránh bố cục đồng bộ bắt buộc:
- Sau khi thực hiện thao tác DOM thông qua giao diện DOM, cần tính toán lại kiểu dáng và bố cục. Trong trường hợp bình thường, các thao tác này đều được hoàn thành bất đồng bộ trong các tác vụ khác.
- Khi JS thực hiện thao tác DOM, thay đổi thông tin DOM, sau đó ngay lập tức truy cập thông tin DOM, sẽ buộc trình duyệt phải tái bố cục, thao tác này được gọi là bố cục đồng bộ bắt buộc (forced synchronous layout). Các tác vụ như vậy chiếm dụng luồng chính quá lâu.
- Nên đọc và lưu trữ thông tin DOM trước khi thay đổi.
- Tránh giật cục bố cục (layout thrashing):
- Là việc thực hiện nhiều thao tác bố cục đồng bộ bắt buộc trong một lần thực thi JS.
- Trường hợp này thường xảy ra trong một vòng lặp thao tác DOM.
- Nên cố gắng tránh việc truy vấn một số giá trị liên quan khi đang sửa đổi cấu trúc DOM.
- Tận dụng hợp lý hoạt ảnh tổng hợp CSS:
- Hoạt ảnh tổng hợp được thực thi trực tiếp trên luồng tổng hợp, điều này khác với các thao tác bố cục, vẽ, v.v., được thực thi trên luồng chính. Ngay cả khi luồng chính đang chạy các tác vụ JS, nó cũng sẽ không ảnh hưởng đến các tác vụ trên luồng tổng hợp.
- Vì vậy, nên cố gắng tận dụng tốt hoạt ảnh tổng hợp CSS. Nếu có thể để CSS xử lý hoạt ảnh, thì hãy cố gắng giao cho CSS thực hiện.
- Tránh thu gom rác thường xuyên:
- Nếu trong một số hàm thường xuyên tạo các đối tượng tạm thời, thì bộ thu gom rác cũng sẽ thường xuyên thực hiện các chiến lược thu gom rác.
- Khi thao tác thu gom rác xảy ra, nó sẽ chiếm dụng luồng chính, từ đó ảnh hưởng đến việc thực thi các tác vụ khác. Nghiêm trọng hơn, nó còn khiến người dùng cảm thấy mất khung hình, không mượt mà.
- Vì vậy, nên cố gắng tránh tạo ra các dữ liệu rác tạm thời, tối ưu hóa cấu trúc lưu trữ càng nhiều càng tốt, cố gắng tránh tạo ra các đối tượng hạt nhỏ.
Virtual DOM (DOM ảo)
Nhược điểm của DOM:
Thao tác DOM thông qua JS sẽ ảnh hưởng đến toàn bộ tuyến ống render. Sau khi JS thao tác DOM, sẽ kích hoạt các tác vụ tính toán kiểu dáng, bố cục, vẽ, raster hóa, tổng hợp, v.v. Chúng ta gọi quá trình này là tái bố cục (reflow). Nó cũng có thể gây ra tái vẽ (repaint) hoặc thao tác tổng hợp (compositing). Hiểu một cách hình ảnh là "kéo theo toàn bộ".
Thao tác DOM không đúng cách còn có thể gây ra vấn đề bố cục đồng bộ bắt buộc (forced synchronous layout) và giật cục bố cục (layout thrashing). Các thao tác này sẽ làm giảm đáng kể hiệu suất render.
Virtual DOM là gì?
Virtual DOM giải quyết vấn đề:
- Áp dụng các thay đổi trang vào Virtual DOM, chứ không trực tiếp áp dụng vào DOM.
- Khi các thay đổi được áp dụng vào Virtual DOM, Virtual DOM không vội vàng render trang, mà chỉ điều chỉnh trạng thái nội bộ của Virtual DOM. Chi phí của thao tác này rất nhẹ.
- Khi Virtual DOM thu thập đủ các thay đổi, nó sẽ áp dụng các thay đổi này vào DOM thực tế một lần.
Hoạt động của Virtual DOM:
Lấy React làm ví dụ:
- Giai đoạn tạo: Đầu tiên, dựa vào JSX và dữ liệu cơ bản để tạo ra Virtual DOM, phản ánh cấu trúc cây DOM thực tế. Sau đó, từ cây Virtual DOM tạo ra cây DOM thực tế. Sau khi cây DOM thực tế được tạo xong, sẽ kích hoạt tuyến ống render để xuất trang ra màn hình.
- Giai đoạn cập nhật: Nếu dữ liệu thay đổi, sẽ tạo một cây Virtual DOM mới dựa trên dữ liệu mới; sau đó so sánh hai cây, tìm ra những điểm thay đổi và cập nhật những điểm thay đổi đó vào cây DOM thực tế một lần; cuối cùng engine render cập nhật tuyến ống render và tạo ra trang mới.
Thuật toán cập nhật Virtual DOM:
Ban đầu, quá trình so sánh hai Virtual DOM được thực hiện trong một hàm đệ quy, thuật toán cốt lõi là reconciliation. Thông thường, quá trình so sánh này thực thi rất nhanh. Tuy nhiên, khi Virtual DOM phức tạp, việc thực thi hàm so sánh có thể chiếm luồng chính trong một thời gian khá lâu, điều này sẽ khiến các tác vụ khác phải chờ đợi, gây ra tình trạng giật lag trang.
Để giải quyết vấn đề này, nhóm React đã viết lại thuật toán reconciliation. Thuật toán mới được gọi là Fiber reconciler, thuật toán cũ trước đây được gọi là Stack reconciler. Trong Fiber reconciler, Fiber thực chất là một tên gọi khác của coroutine. Bằng cách tận dụng các đặc tính của coroutine, nó nhường quyền kiểm soát luồng chính trong quá trình thực thi thuật toán, từ đó giải quyết vấn đề hàm Stack reconciler chiếm thời gian quá lâu.
Double Buffering (Đệm kép):
Trong xử lý và hiển thị đồ họa, đệm kép được sử dụng. Tức là, kết quả trung gian tính toán được lưu trữ trong một bộ đệm khác. Sau khi tất cả các tính toán hoàn tất và bộ đệm đó đã lưu trữ một đồ họa hoàn chỉnh, dữ liệu đồ họa của bộ đệm đó sẽ được sao chép một lần vào bộ đệm hiển thị. Điều này giúp cho việc xuất hình ảnh toàn bộ rất ổn định.
Có thể coi Virtual DOM là một bộ đệm của DOM. Giống như hiển thị đồ họa, nó sẽ áp dụng kết quả vào DOM sau khi hoàn thành một thao tác hoàn chỉnh, điều này có thể giảm một số cập nhật không cần thiết, đồng thời đảm bảo đầu ra ổn định của DOM.
Mô hình MVC:
Cấu trúc tổng thể của MVC khá đơn giản, bao gồm Model, View và Controller. Tư tưởng cốt lõi là tách biệt dữ liệu và giao diện, nghĩa là View và Model không được phép giao tiếp trực tiếp, việc giao tiếp giữa chúng đều được thực hiện thông qua Controller.
Dựa trên các đường dẫn giao tiếp khác nhau và các cách hiện thực Controller khác nhau, MVC có thể phát triển thành nhiều mô hình khác như MVP, MVVM, v.v., nhưng dù thay đổi thế nào, khung xương cơ bản của chúng đều dựa trên MVC.
Khi phân tích dự án React, chúng ta có thể coi một phần của React là View trong MVC. Trong dự án, kết hợp với Redux có thể xây dựng một cấu trúc mô hình MVC.
Trong sơ đồ này, chúng ta có thể coi Virtual DOM là phần View của MVC, Controller và Model của nó đều được cung cấp bởi Redux. Quy trình hiện thực cụ thể như sau:
- Controller trong sơ đồ dùng để giám sát sự thay đổi của DOM. Một khi DOM thay đổi, Controller sẽ thông báo cho Model để cập nhật dữ liệu.
- Sau khi dữ liệu Model được cập nhật, Controller sẽ thông báo cho View, cho biết dữ liệu Model đã thay đổi.
- View nhận được thông báo cập nhật, sẽ tạo ra Virtual DOM mới dựa trên dữ liệu mà Model cung cấp.
- Sau khi Virtual DOM mới được tạo, cần so sánh với Virtual DOM trước đó để tìm ra các nút thay đổi.
- Sau khi xác định các nút thay đổi, React sẽ áp dụng các nút ảo thay đổi vào DOM, điều này sẽ kích hoạt cập nhật DOM.
- Sự thay đổi của các nút DOM lại kích hoạt một loạt các thay đổi tuyến ống render tiếp theo, từ đó hiện thực việc cập nhật trang.
Progressive Web Apps (PWA)
Làm thế nào để hiểu Progressive Web App?
- Đối với nhà phát triển, nó cung cấp một cách rất nhẹ nhàng để nhà phát triển dần dần chuyển đổi các trang web thông thường thành ứng dụng Web.
- Đối với công nghệ, nó là sự tiến hóa dần dần, từ từ phát huy tối đa ưu điểm của công nghệ Web, đồng thời thu hẹp dần khoảng cách với các ứng dụng gốc.
Ứng dụng Web so với ứng dụng gốc:
- Đầu tiên, ứng dụng Web thiếu khả năng sử dụng ngoại tuyến, về cơ bản không thể sử dụng khi ngoại tuyến hoặc trong môi trường mạng yếu. Trong khi người dùng cần trải nghiệm đắm chìm, khả năng sử dụng mượt mà khi ngoại tuyến hoặc trong môi trường mạng yếu là yêu cầu cơ bản của người dùng đối với một ứng dụng.
- Thứ hai, ứng dụng Web còn thiếu khả năng thông báo đẩy (push notification). Là một nhà cung cấp ứng dụng, cần có khả năng gửi tin nhắn đến ứng dụng.
- Cuối cùng, ứng dụng Web thiếu lối vào cấp một (first-class entry point), tức là cài đặt ứng dụng Web lên màn hình desktop, khi cần có thể mở ứng dụng Web trực tiếp từ desktop, thay vì mỗi lần đều phải mở thông qua trình duyệt.
Làm thế nào để hiểu PWA?
Nó là một bộ ý tưởng, dần dần tăng cường ưu điểm của Web, và thông qua các phương tiện kỹ thuật dần dần thu hẹp khoảng cách với các ứng dụng gốc hoặc mini-app. Các công nghệ dựa trên ý tưởng này đều có thể được phân loại vào PWA.
Để giải quyết các thiếu sót về khả năng, PWA đã giới thiệu Service Worker để cố gắng giải quyết vấn đề lưu trữ ngoại tuyến và thông báo đẩy, giới thiệu manifest.json để giải quyết vấn đề lối vào cấp một.
Service Worker là gì?
Ý tưởng chính của Service Worker là thêm một bộ chặn (interceptor) giữa trang và mạng, dùng để cache và chặn các yêu cầu.
- Trước khi cài đặt Service Worker, WebApp đều trực tiếp yêu cầu tài nguyên thông qua module mạng.
- Sau khi cài đặt module Service Worker, khi WebApp yêu cầu tài nguyên, nó sẽ đi qua Service Worker trước, để Service Worker quyết định xem nên trả về tài nguyên đã cache của Service Worker hay yêu cầu lại tài nguyên từ mạng.
Ý tưởng thiết kế Service Worker:
- Kiến trúc:
Web Worker do trình duyệt hiện thực, mặc dù tránh được tình trạng JS chiếm dụng luồng chính của trang quá lâu, nhưng nó chỉ có thể thực thi một số tác vụ JS không liên quan đến DOM, và vòng đời của nó cũng liên quan đến trang.
Cho phép nó chạy ngoài luồng chính là một ý tưởng cốt lõi của Service Worker từ Web Worker. Vì cần phục vụ nhiều trang, nên không thể để nó liên kết với một trang đơn lẻ.
Web Worker là tạm thời, mỗi lần script JS thực thi xong đều sẽ thoát, và kết quả thực thi cũng không thể lưu lại. Nếu lần sau có thao tác tương tự, vẫn phải thực hiện lại từ đầu. Vì vậy, Service Worker đã thêm chức năng lưu trữ dựa trên Web Worker.
Trong kiến trúc Chrome hiện tại, Service Worker chạy trong tiến trình trình duyệt, vì vòng đời của tiến trình trình duyệt là dài nhất. Do đó, trong vòng đời của trình duyệt, nó có thể cung cấp dịch vụ cho tất cả các trang.
- Thông báo đẩy:
Thông báo đẩy cũng được hiện thực dựa trên Service Worker. Khi thông báo đẩy, trang trình duyệt có thể chưa khởi động. Lúc này, Service Worker cần nhận tin nhắn đẩy từ máy chủ và hiển thị tin nhắn cho người dùng theo một cách nhất định.
- Bảo mật:
Chúng ta biết rằng HTTP sử dụng truyền thông tin dạng văn bản thuần túy, tồn tại rủi ro bị nghe lén, sửa đổi và chiếm đoạt. Sử dụng HTTP để truyền dữ liệu trong dự án chắc chắn là "trần trụi".
Service Worker sử dụng giao thức HTTPS, dữ liệu truyền thông đều được mã hóa. Ngay cả khi dữ liệu bị chặn, cũng không thể giải mã nội dung dữ liệu. Hơn nữa, HTTPS còn có cơ chế xác minh, hai bên giao tiếp dễ dàng biết được dữ liệu có bị sửa đổi hay không.
Vì vậy, để trang web hỗ trợ Service Worker, bước đầu tiên là nâng cấp trang web lên HTTPS. Ngoài ra, còn cần đồng thời hỗ trợ các chính sách bảo mật mặc định của trang Web, bao gồm chính sách cùng nguồn gốc (same-origin policy), chính sách bảo mật nội dung (Content Security Policy - CSP), v.v.
WebComponent
Componentization (Thành phần hóa) là gì?
- Độ kết dính cao bên trong, ghép nối lỏng lẻo bên ngoài. Bên trong, các phần tử liên kết chặt chẽ với nhau, phụ thuộc lẫn nhau. Bên ngoài, liên hệ với các thành phần khác là tối thiểu và giao diện đơn giản.
- Thông qua thành phần hóa, có thể giảm độ ghép nối của toàn bộ hệ thống, đồng thời giảm độ phức tạp giao tiếp giữa các lập trình viên, làm cho hệ thống dễ bảo trì hơn.
Các yếu tố cản trở thành phần hóa frontend:
- Ô nhiễm kiểu dáng: Các kiểu dáng chồng chéo và ảnh hưởng lẫn nhau.
- DOM dùng chung: Bất kỳ đâu cũng có thể đọc và sửa đổi.
Phát triển thành phần hóa WebComponent:
- Giải quyết vấn đề thành phần hóa frontend:
Cung cấp khả năng đóng gói giao diện cục bộ, cho phép DOM, CSSOM và JavaScript chạy trong môi trường cục bộ, điều này làm cho CSS và DOM cục bộ không ảnh hưởng đến toàn cục.
WebComponent là một bộ kết hợp các công nghệ, liên quan đến Custom elements (phần tử tùy chỉnh), Shadow DOM (DOM bóng) và HTML templates (mẫu HTML).
- Ví dụ mã:
<html> <head> <title>WebComponent Example</title> </head> <body> <template id="custom-card-template"> <style> .card-title { background-color: #663399; /* Deep Purple */ color: #ffffff; padding: 10px; font-weight: bold; border-top-left-radius: 8px; border-top-right-radius: 8px; } .card-content { width: 250px; background-color: #f0e6fa; /* Light Purple */ border: 2px solid #663399; border-radius: 8px; padding: 15px; } p { margin: 5px 0; } </style> <div class="card-content"> <div class="card-title">Custom Card</div> <p>This is content from the custom card.</p> <p>More details here.</p> </div> <script> function internalLog() { console.log('Internal script inside Shadow DOM'); } </script> </template> <!-- Sử dụng phần tử tùy chỉnh --> <custom-app-card></custom-app-card> <!-- Phần tử thông thường bên ngoài --> <div> <p style="color: red;">This is global content.</p> </div> <custom-app-card></custom-app-card> <script> class CustomAppCard extends HTMLElement { constructor() { super(); // Lấy nội dung mẫu const templateContent = document.querySelector('#custom-card-template').content; // Tạo nút Shadow DOM const shadowRoot = this.attachShadow({ mode: 'open' }); // 'open' để có thể truy cập từ bên ngoài bằng JS // Thêm mẫu vào Shadow DOM shadowRoot.appendChild(templateContent.cloneNode(true)); } } // Định nghĩa phần tử tùy chỉnh customElements.define('custom-app-card', CustomAppCard); // Truy cập script bên trong Shadow DOM // const card = document.querySelector('custom-app-card'); // card.shadowRoot.querySelector('script').onload = () => { // card.shadowRoot.querySelector('script').contentWindow.internalLog(); // Có thể truy cập // }; // Kiểm tra ô nhiễm style // const globalP = document.querySelector('div > p'); // console.log('Global P color:', getComputedStyle(globalP).color); // Red // const shadowP = document.querySelector('custom-app-card').shadowRoot.querySelector('p'); // console.log('Shadow P color:', getComputedStyle(shadowP).color); // cornsilk (do style trong template) </script> </body> </html>- Các phần tử mẫu được tạo bằng thuộc tính
<template>sẽ không được render lên trang. Các cấu trúc phần tử cơ bản này có thể được tái sử dụng. - Cần tạo một lớp thành phần. Tác dụng của Shadow DOM là cách ly nội dung trong mẫu với DOM và CSS toàn cục, hiện thực tính riêng tư của phần tử và kiểu dáng.
- Trong môi trường toàn cục, để truy cập kiểu dáng hoặc phần tử bên trong Shadow DOM, cần thông qua các giao diện đã định nghĩa.
- Lưu ý rằng JS của Shadow DOM sẽ không bị cách ly, nó có thể được truy cập từ bên ngoài.
- Các phần tử mẫu được tạo bằng thuộc tính
- Hiệu ứng đầu ra:
Trình duyệt hiện thực Shadow DOM như thế nào?
Mỗi Shadow DOM có thể được coi là một DOM độc lập, nó có kiểu dáng, thuộc tính riêng. Kiểu dáng bên trong không ảnh hưởng đến kiểu dáng bên ngoài, và kiểu dáng bên ngoài cũng không ảnh hưởng đến kiểu dáng bên trong.
Khi tìm kiếm phần tử thông qua giao diện DOM, engine render sẽ kiểm tra xem phần tử shadow-root dưới thuộc tính thành phần có phải là Shadow DOM hay không. Nếu có, nó sẽ trực tiếp bỏ qua việc truy vấn phần tử shadow-root, do đó DOM API không thể trực tiếp truy vấn các phần tử bên trong Shadow DOM.
Khi tạo cây bố cục, engine render cũng sẽ kiểm tra xem phần tử shadow-root dưới thuộc tính thành phần có phải là Shadow DOM hay không. Nếu có, thì khi các nút bên trong Shadow DOM chọn kiểu CSS, nó sẽ trực tiếp sử dụng các thuộc tính CSS bên trong Shadow DOM. Điều này làm cho hiệu ứng render cuối cùng là kiểu dáng được định nghĩa bên trong Shadow DOM.