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

Ian Kilpatrick
Ian Kilpatrick
Koji Ishi
Koji Ishi

Tôi là Ian Kilpatrick, kỹ sư trưởng nhóm bố cục Blink, cùng với Koji Ishii. Trước khi làm việc với nhóm Blink, Tôi là kỹ sư quản lý giao diện người dùng (trước khi Google đảm nhận vai trò "kỹ sư phụ trách giao diện người dùng"), tạo 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 ở vai trò đó, tôi đã đánh một canh bạc lớn khi chuyển sang làm việc tại đội Blink, học C trong công việc một cách hiệu quả, và cố gắng tăng tốc trên 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ỏ. Cảm ơn bạn đã dành thời gian cho tôi trong khoảng thời gian này. Thực tế, có rất nhiều "các kỹ sư front-end đang hồi phục" đã chuyển đổi sang vai trò một "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ư giao diện người dùng, tôi liên tục gặp phải sự không nhất quán của trình duyệt, các vấn đề về hiệu suất, lỗi kết xuất và các tính năng bị thiếu. LayoutNG là cơ hội để tôi giúp khắc phục một cách có hệ thống những vấn đề này trong hệ thống bố cục của Blink, và đại diện cho tổng của nhiều kỹ sư những 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ư thế này có thể giúp giảm thiểu và giảm thiểu 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 mét

Trước đây, tôi gọi cây bố cục của Blink là "cây có thể biến đổi".

Hiển thị cây như được mô tả trong nội dung 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 có sẵn do cha mẹ đặt, vị trí của số thực dấu phẩy động bất kỳ và thông tin đầu ra, ví dụ: 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 hiển thị. Khi có thay đổi về kiểu, chúng tôi đã đánh dấu vật thể đó là bẩn và tương tự như vậy tất cả các thành phần mẹ của nó trong cây. Khi giai đoạn bố cục của quy trình kết xuất được chạy, chúng ta sẽ dọn sạch cây, di chuyển mọi đối tượng bẩn, sau đó chạy bố cục để đưa chúng về trạng thái sạch.

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

Về mặt lý thuyết, bố cục chạy 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 linh hoạt), 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.

Kiến 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. Để xuất kết quả, chúng ta tạo một đối tượng hoàn toàn mới, immutable có tên là immutable.

Cây mảnh.

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

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

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

Nói chung, lỗi bố cục được chia thành 4 loại riêng biệt, mỗi loại có những nguyên nhân gốc khác nhau.

Tính chính xác

Khi nói đến lỗi trong hệ thống kết xuất, chúng ta thường nghĩ đến 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 "Trình duyệt A và B đều bị hỏng". Trước đây, chúng tôi đã dành rất nhiều thời gian cho và trong quá trình đó, chúng tôi liên tục đấu tranh với hệ thống. Một chế độ lỗi phổ biến là áp dụng một bản sửa lỗi nhắm đến một lỗi, nhưng nhiều tuần sau đó chúng tôi đã gây ra sự hồi quy trong một phần khác (có vẻ 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ễ vỡ. Riêng về bố cục, chúng ta không có hợp đồng rõ ràng giữa bất kỳ lớp nào, khiến các kỹ sư trình duyệt phụ thuộc vào trạng thái mà họ không nên hoặc hiểu sai giá trị nào đó từ một phần khác của hệ thống.

Ví dụ: tại thời điểm chúng tôi có một chuỗi khoảng 10 lỗi trong vòng hơn một năm, liên quan đến bố cục linh hoạt. Mỗi lần khắc phục gây ra một vấn đề về độ chính xác hoặc hiệu suất trên 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 tôi nhận thấy rằng chúng tôi có thể áp dụng các thay đổi 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, 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 sự hồi quy thực sự trên kênh ổn định, thì mã này thường không có bài kiểm thử được liên kết trong kho lưu trữ WPT, và không xảy ra do hiểu lầm về các hợp đồng thành phần. Ngoài ra, trong chính sách sửa lỗi, chúng tôi luôn thêm quy trình kiểm tra WPT mới, giúp đảm bảo rằng không có trình duyệt nào mắc lỗi tương tự.

Hết hiệu lực dưới mức

Nếu bạn đã từng gặp một lỗi bí ẩn khiến cho lỗi đó biến mất bằng cách đổ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, 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ề hạn chế của thành phần mẹ, mã này không thể hiện đúng đầu ra.

Điều này rất phổ biến với tuỳ chọn hai lượt (di chuyển 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 cho 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ế để sửa lỗi.

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 bố cục con. Chúng ta lưu trữ dữ liệu này bằng mảnh không thể thay đổi thu được. Do đó, chúng ta 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 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 khép kín. Việc gỡ lỗi lớp 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 những gì trong đầu vào đã thay đổi sao cho cần phải truyền một bố cục khác.

Cách khắc phục mã khác biệt này thường đơn giản, và dễ kiểm thử đơn vị do sự đơ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à chiều rộng theo tỷ lệ phần trăm.
Phần tử chiều rộng/chiều cao cố định không quan tâm đến việc kích thước sẵn có được cung cấp cho phần tử đó tăng lên, tuy nhiên, chiều rộng/chiều cao dựa trên tỷ lệ phần trăm sẽ tăng. available-size (kích thước có sẵn) được biểu thị trên đối tượng available-size (Giới hạn gốc) và thuộc 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;
}

Trễ chậm

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 đây, rất khó để đảm bảo rằng bố cục có tính đồng nhất, tức là chạy lại bố cục với cùng đầu vào, dẫn đến kết quả tương tự.

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 sự "phát triển vô hạn" hình chữ nhật.

Video và bản minh hoạ cho thấy lỗi trễ 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 đây của chúng ta, việc tạo ra các lỗi như thế này lại vô cùng dễ dàng. Nếu mã mắc lỗi khi đọc 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 tôi không "xoá" kích thước hoặc vị trí trước đó), chúng tôi sẽ thêm ngay một lỗi trễ 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. Điều đáng lo ngại hơn nữa là chúng tôi biết rằng cần phải trì hoãn quá trình này để một số chế độ bố cục hoạt động chính xác. Chúng tôi có lỗi trong đó chúng tôi thực hiện tối ưu hoá để xoá lượt truyền bố cục, nhưng hãy đưa ra "lỗi" vì chế độ bố cục yêu cầu 2 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 phần văn bản trước.
Tuỳ thuộc vào thông tin kết quả bố cục trước đó, dẫn đến bố cục không bất động

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

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

Điều này ngược lại 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 một vách đá hiệu suất.

Chúng tôi thường phải đưa ra những lựa chọn khó khăn, ưu tiên tính chính xác hơn là 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 linh hoạt và bố cục lưới thể hiện sự thay đổi về tính 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 ra đời trước chúng.

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ả thành phần con chính xác một lần. Điều này rất tốt về hiệu suất, nhưng cuối cùng không được biểu đạt như mong muốn của nhà phát triển web.

Ví dụ: thường bạn muốn kích thước của tất cả các phần tử con mở rộng đến kích thước 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, thì truyền bố cục để kéo dài tất cả phần tử con đến kích thước này. Đây là hành vi mặc định cho cả bố cục linh hoạt và bố cục lưới.

Hai nhóm hộp, hộp đầ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 và hộp thứ hai có chiều cao bằng nhau.

Các bố cục hai lượt này ban đầu được chấp nhận về hiệu suất, vì mọi người thường không lồng chúng quá sâu. Tuy nhiên, chúng tôi bắt đầu nhận thấy nhiều vấn đề nghiêm trọng về hiệu suất khi xuất hiện nhiều nội dung phức tạp hơ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, cây bố cục sẽ xung đột giữa trạng thái đo lường và trạng thái bố cục cuối cùng.

Các bố cục 1, 2 và 3 lượt được giải thích trong chú thích.
Ở hình trên, chúng ta có 3 phần tử <div>. Bố cục một lượt đơn giản (như bố cục khối) sẽ truy cập ba nút bố cục (độ phức tạp O(n)). Tuy nhiên, đối với bố cục 2 lượt (như linh hoạt hoặc lưới), điều này có thể khiến lượt truy cập O(2n) trở nên phức tạp 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 luỹ thừa với bố cục Lưới. Điều này được khắc phục trong Chrome 93 do di chuyển Grid sang kiến trúc mới

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

LayoutNG cho phép chúng ta tạo cấu trúc dữ liệu rõ ràng cho cả đầu vào và đầu ra của bố cục, và trên hết, chúng tôi đã tạo bộ nhớ đệm cho các lượt đo lường và bố cục truyền. Điều này đưa trở lại O(n), mang lại hiệu suất tuyến tính có thể dự đoán cho 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 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 – một ví dụ về cách kết xuất NG về cơ bản 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 3 lượt, nhưng hiện tại thì cực kỳ 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 biệt là với bố cục, 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 trong quy trình. Nếu thay đổi gia tăng 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, đây có thể là lỗi bố cục luỹ thừa.

Tóm tắt

Bố cục là một khu vực cực kỳ phức tạp, và chúng tôi không đề cập đến tất cả các chi tiết thú vị như tối ưu hoá bố cục cùng dòng (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à thậm chí những khái niệm được nói đến ở đây mới chỉ đề cập đến những khái niệm mới và trau chuốt 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 biết có nhiều loại vấn đề (cả hiệu suất và độ chính xác) mà chúng tôi đang tìm cách giải quyết, và rất háo hức về tính năng bố cục mới sắp ra mắt CSS. Chúng tôi tin rằng kiến trúc của LayoutNG giúp việc giải quyết các vấn đề này trở nên an toàn và có thể truy cập được.

Một hình ảnh của Una Kravets.