Tìm hiểu sâu về RenderingNG: LayoutNG

Ian Kilpatrick
Ian Kilpatrick
Koji Ishi
Koji Ishi

Tôi là Ian Kilpatrick, trưởng nhóm kỹ thuật của nhóm bố cục Blink cùng với Koji Ishii. Trước khi làm việc trong nhóm Blink, tôi là một kỹ sư phụ trách giao diện người dùng (trước khi Google có vai trò "kỹ sư phụ trách giao diện người dùng"), xây dựng các tính năng trong Google Tài liệu, Drive và Gmail. Sau khoảng 5 năm làm việc ở vị trí đó, tôi đã quyết định chuyển sang nhóm Blink, học C++ một cách hiệu quả trong công việc và cố gắng tăng cường cơ sở mã Blink cực kỳ phức tạp. Thậm chí đến hôm nay, tôi chỉ hiểu được một phần tương đối nhỏ. Tôi rất cảm ơn bạn đã dành thời gian cho tôi trong khoảng thời gian này. Tôi cảm thấy an ủi khi biết rằng có rất nhiều "kỹ sư phụ trách giao diện người dùng đang hồi phục" đã chuyển đổi sang làm "kỹ sư trình duyệt" trước tôi.

Kinh nghiệm trước đây của tôi đã trực tiếp hướng dẫn tôi khi làm việc tại nhóm Blink. Là một kỹ sư phụ trách giao diện người dùng, tôi liên tục gặp phải các vấn đề về sự không nhất quán của trình duyệt, hiệu suất, lỗi hiển thị và thiếu tính năng. LayoutNG là cơ hội để tôi giúp khắc phục có hệ thống những vấn đề này trong hệ thống bố cục của Blink, đồng thời thể hiện tổng hợp nỗ lực của nhiều kỹ sư trong nhiều năm qua.

Trong bài đăng này, tôi sẽ giải thích cách một thay đổi lớn về cấu trúc như vậy có thể giảm thiểu và giảm bớt nhiều loại lỗi cũng như vấn đề về hiệu suất.

Ảnh chụp kiến trúc công cụ bố cục từ độ cao 30.000 foot

Trước đây, cây bố cục của Blink là "cây có thể thay đổi".

Hiển thị cây như mô tả trong văn bản sau.

Mỗi đối tượng trong cây bố cục chứa thông tin đầu vào, chẳng hạn như kích thước hiện có do phần tử mẹ áp đặt, vị trí của mọi float và thông tin đầu ra, chẳng hạn như chiều rộng và chiều cao cuối cùng của đối tượng hoặc vị trí x và y của đối tượng đó.

Các đối tượng này được giữ lại giữa các lần kết xuất. Khi có thay đổi về kiểu, chúng ta đã đánh dấu đối tượng đó là không sạch và tương tự như tất cả các đối tượng mẹ trong cây. Khi chạy giai đoạn bố cục của quy trình kết xuất, chúng ta sẽ dọn dẹp cây, đi qua mọi đối tượng bẩn, sau đó chạy bố cục để đưa các đối tượng đó về trạng thái sạch.

Chúng tôi nhận thấy cấu trúc này dẫn đến nhiều loại vấn đề mà chúng tôi sẽ mô tả bên dưới. Nhưng trước tiên, hãy lùi lại một chút và xem xét dữ liệu đầu vào và đầu ra của bố cục.

Về mặt khái niệm, việc chạy bố cục trên một nút trong cây này sẽ lấy "Kiểu cộng với DOM" và mọi quy tắc ràng buộc mẹ từ hệ thống bố cục mẹ (lưới, khối hoặc flex), chạy thuật toán ràng buộc bố cục và tạo ra kết quả.

Mô hình khái niệm được mô tả ở trên.

Cấu trúc mới của chúng tôi chính thức hoá mô hình khái niệm này. Chúng ta vẫn có cây bố cục nhưng chủ yếu dùng cây này để giữ lại dữ liệu đầu vào và đầu ra của bố cục. Đối với đầu ra, chúng ta tạo một đối tượng không thể thay đổi hoàn toàn mới có tên là cây mảnh.

Cây mảnh.

Tôi đã đề cập đến cây mảnh không thể thay đổi trước đó, mô tả cách cây này được thiết kế để sử dụng lại các phần lớn của cây trước đó cho các bố cục gia tăng.

Ngoài ra, chúng ta lưu trữ đối tượng ràng buộc mẹ đã tạo mảnh đó. Chúng ta sẽ dùng thông tin này làm khoá bộ nhớ đệm mà chúng ta sẽ thảo luận thêm dưới đây.

Thuật toán bố cục nội tuyến (văn bản) cũng được viết lại để phù hợp với cấu trúc không thể thay đổi mới. Không chỉ tạo ra biểu diễn danh sách phẳng không thể thay đổi cho bố cục nội tuyến, mà còn có tính năng lưu vào bộ nhớ đệm cấp đoạn văn bản để bố cục lại nhanh hơn, hình dạng cho mỗi đoạn văn bản để áp dụng các tính năng phông chữ trên các phần tử và từ, thuật toán Unicode hai chiều mới sử dụng ICU, nhiều bản sửa lỗi chính xác và nhiều tính năng khác.

Các loại lỗi bố cục

Nói chung, lỗi bố cục thuộc 4 danh mục khác nhau, mỗi danh mục có nguyên nhân gốc khác nhau.

Tính chính xác

Khi nghĩ về lỗi trong hệ thống kết xuất, chúng ta thường nghĩ về tính chính xác, ví dụ: "Trình duyệt A có hành vi X, trong khi trình duyệt B có hành vi Y" hoặc "Cả trình duyệt A và B đều bị hỏng". Trước đây, đây là điều chúng tôi đã dành nhiều thời gian để thực hiện, và trong quá trình đó, chúng tôi liên tục phải đấu tranh với hệ thống. Một lỗi thường gặp là áp dụng một bản sửa lỗi rất cụ thể cho một lỗi, nhưng sau vài tuần, chúng tôi phát hiện ra rằng chúng tôi đã gây ra sự hồi quy trong một phần khác (có vẻ như không liên quan) của hệ thống.

Như mô tả trong các bài đăng trước, đây là dấu hiệu của một hệ thống rất dễ bị lỗi. Cụ thể về bố cục, chúng tôi không có hợp đồng rõ ràng giữa bất kỳ lớp nào, điều này khiến các kỹ sư trình duyệt phụ thuộc vào trạng thái không nên hoặc hiểu sai một số giá trị từ một phần khác của hệ thống.

Ví dụ: có thời điểm, chúng tôi có một chuỗi khoảng 10 lỗi trong khoảng thời gian hơn một năm, liên quan đến bố cục linh hoạt. Mỗi bản sửa lỗi đều gây ra vấn đề về độ chính xác hoặc hiệu suất trong một phần của hệ thống, dẫn đến một lỗi khác.

Giờ đây, LayoutNG xác định rõ ràng hợp đồng giữa tất cả các thành phần trong hệ thống bố cục, chúng ta nhận thấy rằng chúng ta có thể áp dụng các thay đổi một cách tự tin hơn nhiều. Chúng tôi cũng hưởng lợi rất nhiều từ dự án Kiểm thử nền tảng web (WPT) tuyệt vời. Dự án này cho phép nhiều bên đóng góp vào một bộ kiểm thử web chung.

Hiện nay, chúng tôi nhận thấy rằng nếu phát hành một lượt hồi quy thực trên kênh chính thức, thì kênh này thường không có các chương trình kiểm thử liên quan trong kho lưu trữ WPT và không xảy ra do hiểu lầm về hợp đồng thành phần. Ngoài ra, theo chính sách sửa lỗi của mình, chúng tôi luôn thêm một bài kiểm thử WPT mới để đảm bảo rằng không có trình duyệt nào mắc lại lỗi tương tự.

Đang vô hiệu hoá

Nếu bạn đã từng gặp một lỗi bí ẩn trong đó việc đổi kích thước cửa sổ trình duyệt hoặc bật/tắt thuộc tính CSS một cách kỳ diệu sẽ biến lỗi đó, thì bạn đã gặp phải sự cố không hợp lệ. Trên thực tế, một phần của cây có thể thay đổi được coi là sạch, nhưng do một số thay đổi về các điều kiện ràng buộc ở cấp độ gốc, nên một phần của cây không thể hiện đúng kết quả.

Điều này rất phổ biến với các chế độ bố cục hai lần (đi qua cây bố cục hai lần để xác định trạng thái bố cục cuối cùng) được mô tả bên dưới. Trước đây, mã của chúng ta sẽ có dạng như sau:

if (/* some very complicated statement */) {
  child->ForceLayout();
}

Cách khắc phục loại lỗi này thường là:

if (/* some very complicated statement */ ||
    /* another very complicated statement */) {
  child->ForceLayout();
}

Cách khắc phục cho loại vấn đề này thường gây ra sự hồi quy hiệu suất nghiêm trọng (xem trường hợp vô hiệu hoá quá mức bên dưới) và rất tinh tế để khắc phục.

Hôm nay (như mô tả ở trên), chúng ta có một đối tượng ràng buộc mẹ bất biến. Đối tượng này mô tả mọi dữ liệu đầu vào từ bố cục mẹ đến con. Chúng ta lưu trữ thông tin này bằng mảnh không thể thay đổi thu được. Do đó, chúng tôi có một điểm tập trung để khác biệt hai dữ liệu đầu vào này nhằm xác định xem phần tử con có cần thực hiện một lượt truyền bố cục khác hay không. Logic so sánh này phức tạp nhưng được chứa gọn gàng. Việc gỡ lỗi cho loại vấn đề không hợp lệ này thường dẫn đến việc kiểm tra hai đầu vào theo cách thủ công và quyết định nội dung nào trong đầu vào đã thay đổi để yêu cầu một lượt truyền bố cục khác.

Các bản sửa lỗi cho mã so sánh này thường đơn giản và dễ dàng kiểm thử đơn vị do tính đơn giản của việc tạo các đối tượng độc lập này.

So sánh hình ảnh có chiều rộng cố định và hình ảnh có chiều rộng theo tỷ lệ phần trăm.
Phần tử có chiều rộng/chiều cao cố định không quan tâm đến việc kích thước có sẵn được cung cấp cho phần tử đó có tăng lên hay không, tuy nhiên, chiều rộng/chiều cao dựa trên tỷ lệ phần trăm thì có. available-size được biểu thị trên đối tượng Parent Constraints (Ràng buộc mẹ) và là một phần của thuật toán so sánh sẽ thực hiện việc tối ưu hoá này.

Mã so sánh cho ví dụ trên là:

if (width.IsPercent()) {
  if (old_constraints.WidthPercentageSize() 
    != new_constraints.WidthPercentageSize())
   return kNeedsLayout;
}
if (height.IsPercent()) {
  if (old_constraints.HeightPercentageSize() 
    != new_constraints.HeightPercentageSize())
   return kNeedsLayout;
}

Hiệu ứng trễ

Lớp lỗi này tương tự như trường hợp vô hiệu hoá. Về cơ bản, trong hệ thống trước, rất khó để đảm bảo bố cục đó là idempotent – tức là việc chạy lại bố cục với cùng một dữ liệu đầu vào sẽ dẫn đến cùng một kết quả đầu ra.

Trong ví dụ bên dưới, chúng ta chỉ cần chuyển đổi một thuộc tính CSS qua lại giữa hai giá trị. Tuy nhiên, điều này dẫn đến một hình chữ nhật "không ngừng phát triển".

Video và bản minh hoạ cho thấy lỗi hysterisis trong Chrome 92 trở xuống. Lỗi này đã được khắc phục trong Chrome 93.

Với cây có thể thay đổi trước đó, rất dễ gặp phải các lỗi như thế này. Nếu mã đọc sai kích thước hoặc vị trí của một đối tượng tại thời điểm hoặc giai đoạn không chính xác (ví dụ: chúng ta không "xoá" kích thước hoặc vị trí trước đó), thì chúng ta sẽ ngay lập tức thêm một lỗi hồi quy tinh vi. Những lỗi này thường không xuất hiện trong quá trình kiểm thử vì phần lớn các kiểm thử đều tập trung vào một bố cục và lượt kết xuất duy nhất. Đáng lo ngại hơn nữa, chúng tôi biết rằng một số độ trễ này là cần thiết để một số chế độ bố cục hoạt động đúng cách. Chúng tôi gặp lỗi khi thực hiện tối ưu hoá để xoá một lượt truyền bố cục, nhưng lại gây ra "lỗi" vì chế độ bố cục yêu cầu hai lượt truyền để có được kết quả chính xác.

Cây minh hoạ các vấn đề được mô tả trong văn bản trước.
Tuỳ thuộc vào thông tin kết quả bố cục trước đó, kết quả sẽ dẫn đến bố cục không idempotent

Với LayoutNG, vì chúng ta có cấu trúc dữ liệu đầu vào và đầu ra rõ ràng, đồng thời không cho phép truy cập vào trạng thái trước đó, nên chúng ta đã giảm thiểu đáng kể loại lỗi này khỏi hệ thống bố cục.

Hiệu suất và việc vô hiệu hoá quá mức

Đây là trường hợp đối lập trực tiếp với lớp lỗi không hợp lệ. Thông thường, khi khắc phục lỗi không hợp lệ, chúng ta sẽ kích hoạt hiệu suất giảm mạnh.

Chúng tôi thường phải đưa ra những lựa chọn khó khăn, ưu tiên độ chính xác hơn hiệu suất. Trong phần tiếp theo, chúng ta sẽ tìm hiểu sâu hơn về cách giảm thiểu các loại vấn đề về hiệu suất này.

Bố cục hai lượt và vách đá hiệu suất tăng lên

Bố cục Flex và lưới thể hiện sự thay đổi trong khả năng biểu đạt của bố cục trên web. Tuy nhiên, các thuật toán này về cơ bản khác với thuật toán bố cục khối trước đó.

Bố cục khối (trong hầu hết các trường hợp) chỉ yêu cầu công cụ thực hiện bố cục trên tất cả các thành phần con chính xác một lần. Điều này rất tốt cho hiệu suất, nhưng cuối cùng lại không thể hiện được như nhà phát triển web mong muốn.

Ví dụ: thường thì bạn muốn kích thước của tất cả các phần tử con mở rộng thành kích thước của phần tử lớn nhất. Để hỗ trợ điều này, bố cục mẹ (linh hoạt hoặc lưới) sẽ thực hiện một lượt đo lường để xác định kích thước của mỗi phần tử con, sau đó truyền bố cục để kéo dài tất cả phần tử con đến kích thước này. Hành vi này là mặc định cho cả bố cục flex và lưới.

Hai nhóm hộp, nhóm đầu tiên cho thấy kích thước nội tại của các hộp trong lượt đo lường, nhóm thứ hai ở bố cục đều có chiều cao bằng nhau.

Ban đầu, các bố cục hai lượt này được chấp nhận về mặt hiệu suất, vì mọi người thường không lồng ghép các bố cục này quá sâu. Tuy nhiên, chúng tôi bắt đầu thấy các vấn đề nghiêm trọng về hiệu suất khi nội dung phức tạp hơn xuất hiện. Nếu bạn không lưu kết quả của giai đoạn đo lường vào bộ nhớ đệm, thì cây bố cục sẽ bị treo giữa trạng thái đo lường và trạng thái bố cục cuối cùng.

Bố cục một, hai và ba lượt được giải thích trong chú thích.
Trong hình ảnh trên, chúng ta có ba phần tử <div>. Một bố cục một lượt đơn giản (chẳng hạn như bố cục khối) sẽ truy cập vào ba nút bố cục (độ phức tạp O(n)). Tuy nhiên, đối với bố cục hai lượt (như flex hoặc lưới), việc này có thể dẫn đến độ phức tạp của các lượt truy cập O(2n) cho ví dụ này.
Biểu đồ cho thấy thời gian bố cục tăng theo cấp số nhân.
Hình ảnh và bản minh hoạ này cho thấy bố cục mũ với bố cục Lưới. Điều này được khắc phục trong Chrome 93 nhờ chuyển Lưới sang kiến trúc mới

Trước đây, chúng ta đã cố gắng thêm các bộ nhớ đệm rất cụ thể vào bố cục flex và lưới để chống lại loại hiệu suất này. Cách này đã hoạt động (và chúng tôi đã tiến rất xa với Flex), nhưng liên tục phải chiến đấu với các lỗi vô hiệu hoá dưới và trên.

LayoutNG cho phép chúng ta tạo các cấu trúc dữ liệu rõ ràng cho cả đầu vào và đầu ra của bố cục, và hơn hết, chúng ta còn tạo bộ nhớ đệm cho các lượt đo lường và bố cục truyền. Điều này đưa độ phức tạp trở lại O(n), dẫn đến hiệu suất tuyến tính có thể dự đoán được cho các nhà phát triển web. Nếu có trường hợp bố cục thực hiện bố cục 3 lượt, chúng ta sẽ chỉ cần lưu dữ liệu đó vào bộ nhớ đệm. Điều này có thể mở ra cơ hội giới thiệu các chế độ bố cục nâng cao hơn một cách an toàn trong tương lai – ví dụ về cách về cơ bản RenderingNG mở rộng khả năng mở rộng trên bảng. Trong một số trường hợp, bố cục Lưới có thể yêu cầu bố cục ba lượt, nhưng hiện tại trường hợp này rất hiếm.

Chúng tôi nhận thấy rằng khi nhà phát triển gặp phải vấn đề về hiệu suất cụ thể với bố cục, thì thường là do lỗi thời gian bố cục theo cấp số nhân thay vì thông lượng thô của giai đoạn bố cục của quy trình. Nếu một thay đổi nhỏ (một phần tử thay đổi một thuộc tính css) dẫn đến bố cục 50-100 mili giây, thì đây có thể là lỗi bố cục theo cấp số nhân.

Tóm tắt

Bố cục là một lĩnh vực vô cùng phức tạp, và chúng tôi không đề cập đến tất cả các loại chi tiết thú vị như tối ưu hoá bố cục nội tuyến (thực sự là cách toàn bộ hệ thống con nội tuyến và văn bản hoạt động), và ngay cả các khái niệm được đề cập ở đây thực sự chỉ là bề nổi, và bỏ qua nhiều chi tiết. Tuy nhiên, hy vọng chúng ta đã chỉ ra được việc cải thiện kiến trúc hệ thống một cách có hệ thống có thể mang lại những lợi ích to lớn như thế nào về lâu dài.

Dù vậy, chúng tôi biết rằng mình vẫn còn rất nhiều việc cần làm ở phía trước. Chúng tôi nhận thấy có một số vấn đề (cả về hiệu suất và độ chính xác) mà chúng tôi đang nỗ lực giải quyết, đồng thời rất hào hứng với các tính năng bố cục mới sắp ra mắt trên CSS. Chúng tôi tin rằng cấu trúc của LayoutNG giúp giải quyết các vấn đề này một cách an toàn và dễ dàng.

Một hình ảnh (bạn biết hình ảnh đó!) của Una Kravets.