Trình duyệt hoạt động như thế nào? 

Tech Knowledge

Nguyên lý render của trình duyệt là kiến thức mà mọi nhà phát triển frontend đều cần biết.

Đối với nhà phát triển frontend, trình duyệt (browser) gần như là tất cả. Họ phát triển thông qua trình duyệt, kiểm thử và phát hành thông qua trình duyệt, sau đó người dùng cuối cũng tiếp cận với ứng dụng web thông qua trình duyệt. Như vậy, trình duyệt là một yếu tố không thể tách rời đối với nhà phát triển frontend. 

Tuy nhiên, như vậy không có nghĩa là nhà phát triển frontend sẽ hiểu hết tất cả nguyên lý hoạt động của trình duyệt, vì trình duyệt là ứng dụng cao cấp và cực kỳ phức tạp để có thể thực hiện rất nhiều tính năng. Việc hiểu hết tất cả mọi thứ diễn ra bên trong trình duyệt… thật sự là vô cùng khó. Chính vì vậy, chúng ta thường chỉ kiểm tra xem trình duyệt có hoạt động đúng theo những đoạn mã HTML, CSS, Javascript mình đã viết hay không mà thôi, vì việc lập trình để thực hiện các logic kinh doanh cũng đã đủ khiến ta bận lắm rồi.

Tuy nhiên, mọi chuyện sẽ khác khi bạn cần tạo ra các ứng dụng web phức tạp. Khi cần quản lý mã nguồn đồ sộ, hỗ trợ các tương tác đa dạng và áp dụng các animation tinh vi, bạn sẽ cần thực hiện tối ưu hóa ở cấp độ trình duyệt. Lúc này, chắc chắn bạn sẽ không thể tối ưu hóa ứng dụng của mình nếu không hiểu về nguyên lý hoạt động của trình duyệt. Rốt cuộc thì nền tảng thực thi các ứng dụng do nhà phát triển frontend tạo ra chính là trình duyệt mà.

Tóm tắt
Bài viết gồm 4 chủ đề chính sau đây:

  1. Xây dựng DOM Tree từ HTML, CSSOM Tree từ CSS
  2. Kết hợp DOM và CSSOM để tạo Render Tree
  3. Thực hiện Layout từ Render Tree để tính toán trạng thái hình học của các node
  4. Thực hiện Paint các node riêng lẻ trên màn hình.

Các thành phần cấu tạo của trình duyệt

Mối quan hệ giữa các lớp (layer) cấu tạo nên trình duyệt

Đầu tiên, hãy tìm hiểu về các thành phần cấu tạo nên trình duyệt. Trình duyệt được cấu tạo từ nhiều thành phần thuộc nhiều lớp như hình trên.

  • User Interface (UI – Giao diện người dùng) là lớp trên cùng, bao gồm tất cả những gì có thể thấy được trên trình duyệt: trang được yêu cầu và các yếu tố trực quan như thanh địa chỉ, nút hủy, nút quay lại trang trước/ tiến đến tảng sau, nút tải lại trang, bookmark hay cài đặt.
  • Browser Engine là lớp đóng vai trò trung gian giữa giao diện người dùng và Rendering Engine. Lấy ví dụ, khi bạn nhấn nút tải lại trên lớp giao diện người dùng, Browser Engine sẽ hiểu mệnh lệnh này và thực hiện tác vụ tương ứng.
  • Rendering Engine đóng vai trò phân tích cú pháp (parsing) HTML, CSS, JavaScript và dựa trên kết quả phân tích để dựng trang. Các trình duyệt sử dụng các engine khác nhau: Chrome, Opera và Edge sử dụng Blink, Firefox sử dụng Gecko, Internet Explorer sử dụng Trident, còn Safari sử dụng WebKit. 
  • Network Layer (Lớp mạng) là lớp thực hiện tải các tài nguyên từ bên ngoài thông qua các giao thức như HTTP hay HTTPS, được sử dụng khi có yêu cầu gửi đến server. 
  • JavaScript Interpreter (Lớp thông dịch JavaScript) đóng vai trò phân tích và thực hiện mã nguồn JavaScript, nổi tiếng nhất là Chrome V8 của Google.
  • UI Backend đóng vai trò xử lý các giao diện liên quan đến hệ điều hành. Một ví dụ dễ dàng nhận thấy chính là sự khác nhau trong cách hoạt động của Alert và Select box trên các hệ điều hành. 
  • Web Storage là lớp lưu trữ dữ liệu cục bộ, hoạt động giống như ổ cứng ngay trong trình duyệt, được sử dụng khi cần truy cập và lưu trữ dữ liệu vào Cookie, Local Storage, Session Storage, IndexedDB, Web SQL hay File System.

Như vậy, nhiệm vụ của các lớp rất đa dạng và phức tạp. Tuy nhiên nhà phát triển frontend cần đặc biệt lưu ý đến Rendering Engine vì đây là lớp trực tiếp liên quan đến hoạt động của ứng dụng. Hãy tìm hiểu sâu hơn về Rendering Engine trong phần dưới đây.

Nguyên lý hoạt động của Rendering Engine

Quá trình rendering chính thức bắt đầu ở bước phân tích cú pháp HTML và CSS nhận được thông qua lớp mạng. 

Khi người dùng truy cập vào một trang web, file HTML của trang web đó sẽ được tải về, sau đó Rendering Engine sẽ phân tích file HTML này theo các bước giống như sơ đồ trên. Các engine trình duyệt có thể phân tích theo những cách khác nhau, nhưng nhìn chung là cần trải qua 4 giai đoạn như sau:

  • Parsing 
  • Xây dựng Render Tree
  • Layout / Reflow
  • Paint

Tất cả các quá trình trên được gọi chung là Critical Rendering Path (lộ trình hiển thị trọng yếu). Tốc độ phản hồi của trang web phụ thuộc vào thứ tự tải các tài nguyên và nội dung script trong mỗi bước. Thông qua việc tối ưu hóa các quá trình này, nhà phát triển frontend có thể rút ngắn thời gian rendering để không gây ảnh hưởng đến trải nghiệm người dùng. 

Tiếp theo, hãy tìm hiểu chi tiết hơn về từng bước trong Critical Rendering Path. 

Parsing

Parsing (Phân tích cú pháp) là quá trình cấu trúc hóa mã nguồn đã được token hóa. Thành phần thực hiện quá trình parsing này được gọi là Parser (trình phân tích cú pháp). 

Chính xác hơn, sau khi Lexical Scanner (hay Lexer – Trình phân tích từ vựng) tạo ra mã nguồn được token hóa, Parser thực hiện phân tích mã nguồn này. Token hóa có nghĩa là phân chia mã nguồn thành các thành phần có nghĩa nhỏ nhất có thể. Ví dụ, nếu token hóa mã nguồn <div></div> ta sẽ được kết quả là ['<','div','>','</','div','>'].

Parsing là quá trình xác nhận xem mảng ký tự nhận được (input string) có tuân thủ chính xác các ngữ pháp (grammar) được định sẵn hay không. Mỗi ngữ pháp bao gồm từ vựng (vocabulary)quy tắc cú pháp (syntax rule). Từ vựng có nghĩa là các từ ngữ và tổ hợp từ ngữ có thể sử dụng được, quy tắc cú pháp là những quy tắc có thể áp dụng giữa các từ ngữ, cũng giống như các quy tắc trong một phép tính: trước và sau toán tử nhân cần có hai thừa số hay toán tử nhân có thứ tự ưu tiên tính toán cao hơn toán tử trừ. 

Trình duyệt có thể phân tích ba loại ngôn ngữ: HTML, CSS và JavaScript. Trong số đó, JavaScript không được phân tích ở Rendering Engine mà ở một lớp riêng là JavaScript Interpreter. Do đó, Rendering Engine chỉ phân tích cú pháp HTML và CSS. 

Parsing HTML 

Parsing flow

Trình duyệt sử dụng các chuỗi HTML được token hóa ở trên để tạo ra Parse Tree. Parse Tree mô hình hóa các đoạn mã HTML trình duyệt cần đọc theo dạng cây. 

Trình duyệt có thể sử dụng Parse Tree này để render ngay lập tức không? Không, trình duyệt sử dụng Parse Tree để tạo ra cây mới – DOM (Document Object Model) Tree. Vậy điểm khác biệt giữa hai cây này là gì?

Parse Tree chỉ đơn giản là cây cấu trúc hóa chuỗi token hóa đầu vào, còn DOM Tree là cây tạo ra những phần tử HTML (HTML element) mà lập trình viên có thể tương tác trên thực tế. Do đó, những phần chúng ta có thể tương tác bằng JavaScript thực chất chính là DOM Tree.  

Bên cạnh đó, HTML Parser có một vài đặc điểm đặc biệt hơn so với những parser khác. Đặc điểm đầu tiên của HTML Parser là forgiving nature, có nghĩa là nếu có lỗi nào phát sinh trong quá trình phân tích cú pháp HTML, trình duyệt sẽ tự động khắc phục lỗi đó. Hãy lấy ví dụ bằng đoạn mã HTML sau.

<body>
<p class=highlight>Hello
<div><span>World

Ví dụ trên là một đoạn mã HTML chưa đúng chuẩn: không sử dụng thẻ <html> ở đầu, thiếu thẻ đóng tương ứng cho các thẻ <body>, <p>, <div>, <span> và thiếu dấu ngoặc kép khi gán giá trị cho thuộc tính class. Tuy nhiên, khi thực thi đoạn mã này ở trình duyệt, ta sẽ có đoạn mã hoàn chỉnh như sau:

<body>
<p class="highlight">Hello</p>
<div><span>World</span></div>
</body>

Những quy tắc này được định nghĩa trong HTML Document Type Definition. HTML Parser phải xử lý các ngoại lệ tuân theo các quy tắc đã được định sẵn, và việc thực hiện điều này rất khó nếu chỉ áp dụng quy tắc của các trình phân tích cú pháp thông thường. 

Lý do chính xác là vì phần lớn các ngôn ngữ lập trình đều thuộc nhóm Context-Free Grammar trong Chomsky Hierarchy, tuy nhiên HTML thì không thuộc nhóm vì những đặc điểm của riêng ngôn ngữ này. 

Quá trình parsing có thể bị gián đoạn

Đặc điểm thứ hai của HTML Parser là quá trình parsing có thể bị gián đoạn. Quá trình phân tích cú pháp HTML sẽ dừng lại ngay lập tức nếu trình phân tích gặp các thẻ liên kết đến tài nguyên bên ngoài như <script> hay <link>, và bắt đầu phân tích các thẻ này. Nếu các thẻ này tham khảo các tập tin ở nguồn khác, trình phân tích sẽ bắt đầu phân tích sau khi các tập tin tương ứng được tải xuống. 

Lý do là vì trái ngược với việc có thể bắt đầu phân tích mã HTML ngay lập tức do mã đã được tải sẵn, các nội dung bên ngoài không thể được phân tích một cách tăng tiến (incrementally). Một lý do khác là trong thẻ <script> có thể bao gồm những lệnh trực tiếp chỉnh sửa DOM. Ví dụ, một API như document.write() có thể chèn thêm một phần tử DOM (DOM element) ngay giữa quá trình phân tích cú pháp HTML. Chính vì vậy, quá trình phân tích HTML phải tạm dừng cho đến khi tất cả các nội dung bên ngoài đã được phân tích và thực hiện xong. Các ngôn ngữ script hỗ trợ một số lựa chọn riêng để giải quyết vấn đề này.

Một số trình duyệt áp dụng kỹ thuật Speculative parsing để tải các tài nguyên bên ngoài như script, link hay style trên một thread riêng. 

Đặc điểm thứ ba của HTML Parser là Reentrant. Như đã đề cập ở trên, quá trình phân tích cú pháp HTML có thể bị gián đoạn vì các yếu tố bên ngoài. Trong quá trình phân tích cú pháp, DOM có thể được thêm, chỉnh sửa hoặc bị xóa do các yếu tố bên ngoài. Trong những trường hợp đó, cần phải thực hiện phân tích cú pháp HTML lại từ đầu. Có nghĩa là cần phải bắt đầu lại các bước chuyển đổi byte thành ký tự, rồi phân biệt token và tạo node, sau đó xây dựng DOM Tree. Chính vì vậy, thời gian phân tích cú pháp có thể rất lâu trong trường hợp cần xử lý nhiều HTML. 

Parsing CSS 

CSSOM

Mặt khác, quá trình phân tích cú pháp CSS không phức tạp như phân tích HTML vì đã có những quy tắc chi tiết chính thức áp dụng cho CSS. Thông thường, lệnh liên kết đến tập tin CSS được đặt trong mã nguồn HTML nên quá trình phân tích cú pháp CSS sẽ bắt đầu trong khi phân tích cú pháp HTML. Khác với trình phân tích HTML có thể bắt đầu phân tích ngay lập tức vì mã đã được tải từ lần tải đầu tiên, trình phân tích CSS phải chờ đến khi tất cả các tập tin được tải xuống mới có thể bắt đầu phân tích. 

Sau khi tất cả các tập tin CSS được tải xuống và quá trình phân tích cú pháp CSS kết thúc, một cây tương tự DOM Tree sẽ được xây dựng dựa trên các nội dung và thứ tự được chỉ định trong mã nguồn, đó là CSSOM (CSS Object Model) Tree. Các node của CSSOM Tree chứa thông tin về style, quy tắc và selector. 

Render Tree

Trong quá trình DOM Tree được xây dựng, trình duyệt sẽ bắt đầu dựng Render Tree. Render Tree còn có tên gọi khác là Frame Tree.

DOM + CSSOM = Render Tree

Về cơ bản, Render Tree là cây quyết định các phần tử xuất hiện trên màn hình. Nói cách khác, đây là cây quyết định việc hiển thị những phần tử nào, áp dụng những style nào và hiển thị theo thứ tự nào.

Render Tree được tạo thành từ sự kết hợp giữa DOM Tree và CSS Tree. Những phần tử không được hiển thị trên màn hình sẽ không xuất hiện trong cây. Ví dụ như các thẻ <head>, <script> hay các phần tử có style display: none. Những phần tử này được xem là không tồn tại về mặt thị giác, do đó không xuất hiện trong Render Tree. Nói cách khác, Render Tree không hoàn toàn tương xứng 1:1 với DOM Tree.  

Layout / Reflow

Layout là giai đoạn tiếp theo sau khi có Render Tree. Mozilla gọi quá trình này là Reflow. 

Layout là bước tính toán các thông tin chưa được tính trong Render Tree (kích thước và vị trí các node hay thứ tự giữa các layer) và hiển thị chúng trên hệ tọa độ. Quá trình này được router của HTML thực hiện theo phương pháp đệ quy.

Bạn có thể xem thêm minh họa về quá trình này qua các video ở phần Tài liệu tham khảo bên dưới. 

Theo phạm vi tính toán, Layout được chia thành Global LayoutIncremental Layout

Tính toán Global Layout là việc tính toán layout tổng thể của toàn màn hình. Ví dụ, layout tổng thể cần được tính toán lại trong những trường hợp cần thêm một font (phông chữ) khác, thay đổi kích thước font, kích thước viewport hay trường hợp sử dụng một số API JavaScript liên quan đến DOM như offsetHeight. 

Như vậy, trong giai đoạn Global Layout, việc tính toán hình học được thực hiện trong tất cả các node của Render Tree, do đó tốc độ layout sẽ chậm trong trường hợp cây có nhiều node. Trình duyệt có sẵn logic riêng để tối ưu hóa quá trình này.

Một trong số đó là Dirty bit system, một phương pháp tối ưu hóa để tránh lãng phí tài nguyên. Trong trường hợp layout của một thành phần cụ thể thay đổi, Dirty bit system sẽ không duyệt lại Render Tree từ đầu mà chỉ tính toán riêng cho thành phần bị thay đổi.

Incremental Layout sử dụng Dirty bit system. Trong lúc duyệt đệ quy trên Render Tree, nếu gặp dirty element – những phần tử bắt buộc phải thay đổi, việc tính toán trên thành phần đó sẽ không được thực hiện ngay lập tức mà được xử lý bất đồng bộ hàng loạt thông qua Scheduler. Phương pháp này làm giảm số lượng phép tính và thu hẹp phạm vi tính toán. 

Tuy nhiên, trong trường hợp layout vô cùng phức tạp, chỉ tối ưu hóa trên trình duyệt là chưa đủ. Nhà phát triển frontend cũng cần lưu ý để tối thiểu hóa số lượng phép tính trong quá trình dựng layout, do đó cũng cần làm việc giống như trình duyệt. Nếu bạn cần trực tiếp đọc hoặc thay đổi các giá trị liên quan đến layout của DOM, hãy cố gắng kết hợp các lệnh nhiều nhất có thể.

const divWidth = div1.clientWidth;
div2.style.width = `${divWidth}px`;
const divHeight = div1.clientHeight;
div2.style.height = `${divHeight}px`;

Đoạn mã trên thực hiện đọc giá trị chiều ngang và chiều cao của thẻ div1, sau đó trực tiếp thay đổi DOM bằng cách áp dụng các giá trị vừa đọc cho thẻ div2 bằng inline code. 

Như đã đề cập ở trên, để xử lý Incremental Layout mỗi khi dirty layout phát sinh, trình duyệt lưu các tính toán layout vào Scheduler. Trong đoạn mã trên, cần đọc lại div1 để lấy giá trị chiều cao sau khi thay đổi chiều rộng của div2, giữa hai quá trình này trên layout có thể xuất hiện những thay đổi, do đó sẽ cần thêm một bước tính toán không cần thiết nữa. Có nghĩa là theo góc nhìn tối ưu hóa, việc mã nguồn dùng để đọc các giá trị liên quan đến layout và mã nguồn chỉnh sửa layout được đặt chung với nhau là chưa hợp lý.

Do đó, để đơn giản hóa quá trình tính toán, đoạn mã phía trên có thể được sửa lại như sau:

const divWidth = div1.clientWidth;
const divHeight = div1.clientHeight;
div2.style.width = `${divWidth}px`;
div2.style.height = `${divHeight}px`;

Paint

Giai đoạn rendering cuối cùng là Paint. Đúng như tên gọi, Paint là giai đoạn đổ màu và quyết định vị trí layer cho các thành phần đã được bố trí trên màn hình sau giai đoạn Layout. Giai đoạn này cũng được Router thực hiện theo phương pháp đệ quy và tương tự Layout, Paint cũng được chia thành Global PaintIncremental Paint

Cũng giống như việc trình duyệt phải xử lý càng nhiều công việc khi document càng lớn, khi style càng phức tạp thì giai đoạn Painting sẽ càng tốn thời gian. Ví dụ, thời gian và khối lượng công việc cần thực hiện khi tô màu đơn sắc chắc chắn sẽ ít hơn so với khi cần áp dụng hiệu ứng đổ bóng. 

Công việc Painting được thực hiện theo thứ tự từ thấp đến cao, tương ứng với stacking context dựa vào z-index. Có nghĩa là, những thành phần có z-index thấp sẽ được xử lý painting trước. 

Dưới đây là thứ tự Painting theo đơn vị block theo quy ước CSS:

  • background-color
  • background-image
  • border
  • children
  • outline

Theo đó đó, nếu cả hai thuộc tính background-colorbackground-image đều được gán giá trị, khi tài nguyên được gán trong background-image có kích thước lớn, trình duyệt sẽ hiển thị theo background-color trước, sau đó sửa theo background-image khi hình ảnh được tải xong.

Virtual DOM

Trước khi tổng kết bài viết này, tôi muốn nhắc đến bối cảnh ra đời của Virtual DOM (DOM ảo) với các thông tin liên quan đến nội dung phía trên. DOM ảo là thuật ngữ chắc hẳn những ai đã sử dụng React và Vue đều biết, tuy nhiên có nhiều người mặc dù đã biết đến khái niệm này và sử dụng nó nhưng vẫn chưa hiểu đúng về nó. 

Thông thường, Critical Rendering Path thực hiện tính toán với tốc độ 60 phép tính mỗi giây. Giai đoạn tốn kém chi phí nhất là Layout và Paint, do đó việc tối ưu hóa tính toán trong hai giai đoạn này đóng vai trò quan trọng trong việc tối ưu hóa tính năng. 

Nếu sử dụng JavaScript để thao tác trực tiếp trên DOM, mỗi thay đổi đều sẽ kéo theo việc thực hiện lại giai đoạn Layout và Paint. Lấy ví dụ trường hợp xử lý 10 node bằng lệnh for, có thể xảy ra trường hợp cần phải cập nhật toàn bộ giao diện khi chỉnh sửa 1 node. Tuy nhiên không thể chỉnh sửa tất cả 10 node trong một lần mà phải chỉnh từng node một, sau 10 lần chỉnh sửa mới có thể cập nhật giao diện. Do đó, việc trực tiếp chỉnh sửa DOM thường tiêu tốn rất nhiều chi phí.

Mặt khác, DOM ảo tuy không được hiển thị trên thực tế nhưng phản ánh chính xác cấu trúc DOM thật trong bộ nhớ. Vì nằm trong bộ nhớ và không cần hiển thị trên màn hình, chi phí tính toán trên DOM ảo ít hơn so với DOM thật. Nhờ có đặc điểm này, trong trường hợp cần chỉnh sửa các node như trên, tất cả các thay đổi có thể được gộp lại và cập nhật trên DOM thật trong cùng một lần. Tất nhiên, lúc này số lượng thay đổi trong một lần ở giai đoạn Layout và Paint sẽ nhiều hơn, nhưng số lượng phép tính vẫn là tối thiểu nhờ khả năng tính toán tất cả trong một lần rồi mới cập nhật DOM.

Trên thực tế, có thể tối ưu hóa quá trình này bằng cách gom nhóm tất cả những thay đổi trên DOM trong script và thực hiện trong một lần. Tuy nhiên, bằng cách sử dụng DOM ảo, tất cả thao tác này sẽ được thực hiện một cách tự động, giảm thiểu lượng công việc nhà phát triển cần phải quản lý. React, Vue là những library và framework tiêu biểu áp dụng DOM ảo.

Tài liệu tham khảo:


The original article: 프론트엔드 개발자라면 알고 있어야 할 브라우저의 동작 과정
The translated article above belongs to the author 재그지그 and yozmIT (요즘IT). Metacoders commits not to use this content for any commercial purpose.