3 điểm bởi GN⁺ 2 giờ trước | Chưa có bình luận nào. | Chia sẻ qua WhatsApp
  • Khi xây dựng GUI cơ sở dữ liệu cho MongoDB và PostgreSQL, cần hỗ trợ kiểu BSON/JSONB, cột lồng nhau, tìm kiếm, chỉnh sửa, ghim và kéo thả; vì vậy đã dành khoảng 1 năm để tối ưu ảo hóa hai trục và cấu trúc quản lý trạng thái
  • Tạo một shadow table tách biệt với tài liệu gốc để tính trước chuỗi hiển thị, kiểu dữ liệu, đường dẫn làm phẳng, thứ tự cột và kết quả tìm kiếm, rồi chỉ render các hàng và cột đang nhìn thấy bằng DOM có kích thước cố định
  • Trên đường xử lý cuộn, áp dụng passive event listener, requestAnimationFrame, buffer và hysteresis, cùng theo dõi tốc độ; đồng thời dùng transformopacity thay cho các thuộc tính layout để giảm công việc trên main thread
  • Biến icon theo từng ô thành ảnh nền SVG dùng chung và chỉ mount trình chỉnh sửa khi cần; đồng thời dùng DOM pooling theo vị trí hàng và cột để loại bỏ việc tạo node khi cuộn
  • Canvas cho trần hiệu năng 60fps cao hơn DOM, nhưng bất lợi về văn bản, chọn nội dung, accessibility và mở rộng tính năng, nên đã chọn thiết kế dựa trên DOM để giữ khả năng chọn văn bản thật và tốc độ phát triển nhanh

Mục tiêu và các ràng buộc ban đầu

  • Bắt đầu từ một mảng hai chiều đơn giản và vòng lặp lồng nhau, nhưng cuối cùng kéo dài thành gần 1 năm tối ưu ngắt quãng
  • Bảng trong GUI cơ sở dữ liệu không chỉ để hiển thị mà còn phải hỗ trợ nhiều trạng thái và tương tác khác nhau
    • Hiểu mọi kiểu BSON của MongoDB và cả JSONB của PostgreSQL cùng các hệ khác, rồi hiển thị icon màu theo từng kiểu
    • Phân biệt các kiểu khiến kết quả truy vấn thay đổi, như chuỗi "123" và số nguyên 123
    • Mở rộng tài liệu lồng nhau thành các cột con thực sự, tìm kiếm trên toàn bộ đường dẫn lồng nhau và tô sáng phần khớp bên trong ô
    • Cần các tính năng đổi thứ tự cột, đổi kích thước, ghim cột, chỉnh sửa trong ô và kéo giá trị/hàng/cột vào trình dựng truy vấn trực quan
  • Trạng thái của các tính năng này không có trong tài liệu gốc và phải được giữ lại sau khi cuộn, nên cần một cấu trúc render riêng

Bước 1: render trực tiếp mọi phần tử

  • Cách lặp lồng nhau qua hàng và trường để tạo toàn bộ ô hoạt động ở 100 hàng, nhưng sụp đổ với dữ liệu lớn
    • 10.000 hàng × 30 cột tạo ra khoảng 300.000 DOM node, và cơ chế phát hiện thay đổi của framework sẽ liên tục duyệt qua chúng
    • Nếu giả sử một DOM node tiêu tốn khoảng 1KB kể cả cấu trúc nội bộ trình duyệt, thì trước cả dữ liệu thật đã cần đến hàng trăm MB
    • Ngân sách khung hình ở 60fps là 16,7ms, và style/layout/paint cũng phải chia sẻ khoảng thời gian này
  • Trong triển khai thực tế, việc render 1.000 hàng với khoảng 20 cột đã thất bại
  • Muốn chỉ render một phần thì phải theo dõi riêng các hàng và cột đang hiển thị, thứ tự của chúng, việc mở rộng trường lồng nhau và kết quả tìm kiếm

Bước 2: tách trạng thái hiển thị bằng shadow table

  • Tài liệu gốc lồng nhau và kiểu dữ liệu không đồng nhất, nên khó dùng trực tiếp làm đầu vào render
    • MongoDB có các giá trị BSON như ObjectId, Decimal128, timestamp, binary
    • Dữ liệu SQL có JSONB và timestamp kèm múi giờ, nên mỗi ô cần được xác định định dạng
  • Nếu quyết định định dạng ngay trong vòng lặp render thì sẽ lặp lại cùng một chi phí ở mỗi frame; hơn nữa dữ liệu gốc cũng không có trạng thái bảng như thứ tự cột, trạng thái mở rộng hay kết quả tìm kiếm
  • Shadow table đóng vai trò điểm tham chiếu cho trạng thái mà bảng thực sự sẽ hiển thị, mà không làm thay đổi tài liệu gốc
    • Được xây dựng một lần khi tải và cập nhật khi trạng thái đổi, nhưng không thay đổi trong lúc cuộn
    • Tính trước cho từng ô chuỗi hiển thị đã cắt ngắn, kiểu dữ liệu đã xác định và đường dẫn đã làm phẳng
    • Giới hạn chuỗi hiển thị để tài liệu 16MB không tạo nguyên một chuỗi 16MB trong trạng thái render
    • Kiểu dữ liệu quyết định icon, editor và cách tìm kiếm
    • Dùng đường dẫn làm phẳng như "address.geo.lat" làm khóa để không phải duyệt cây mỗi lần
  • Khi mở rộng đối tượng lồng nhau, các đường dẫn con được nâng thành cột thực sự; kết quả sắp xếp, tìm kiếm, thứ tự cột và trạng thái mở rộng cũng được lưu trong cùng cấu trúc này
  • Ở giai đoạn này số lượng DOM chưa giảm, nhưng đã tạo nền tảng để tính nhanh thứ tự và độ rộng cột về sau

Bước 3: ảo hóa theo chiều dọc và vùng cuộn ma

  • Ảo hóa theo chiều dọc chỉ render các hàng trong viewport cùng một ít buffer, còn phần chiều cao còn lại được tạo bằng vùng giả
  • Vùng ma (phantom) là một container bên trong có chiều cao rowCount × rowHeight
    • Với 1 triệu hàng × 40px, sẽ tạo ra một div cao 40 triệu px gần như không có nội dung
    • Trình duyệt dùng chiều cao này để cung cấp scrollbar và hành vi cuộn native
  • Phạm vi hiển thị được tính như sau
    • firstRow = floor(scrollTop / rowHeight)
    • lastRow = floor((scrollTop + viewportHeight) / rowHeight)
    • Thêm các hàng buffer ở hai phía để không phải render lại với mỗi chuyển động nhỏ
  • Các hàng hiển thị được đặt trong container slab tại vị trí firstRow × rowHeight, và dùng transform sẽ tốt hơn top
  • Người dùng thấy 1 triệu hàng, nhưng trong DOM chỉ có khoảng 40 hàng
  • Tuy nhiên nếu có 300 cột thì chỉ riêng 40 hàng cũng đã tạo ra 12.000 ô, nên cũng phải ảo hóa cột

Bước 4: ảo hóa ngang với độ rộng biến thiên

  • Bộ sưu tập trong cơ sở dữ liệu tài liệu có thể sinh ra hàng trăm trường, nên cũng cần ảo hóa cột
  • Vì độ rộng cột không cố định, nên thay vì chia theo giá trị cố định thì dùng tổng tích lũy và tìm kiếm nhị phân
    • Dùng tổng tích lũy dạng position[n] = width[0] + ... + width[n-1] để lấy tọa độ x của từng cột chỉ bằng một lần truy cập mảng
    • Cột tương ứng với offset cuộn x được tìm bằng tìm kiếm nhị phân trên mảng tổng tích lũy
    • Dù có 1.000 cột, việc tìm kiếm vẫn kết thúc ở mức micro giây
  • Tổng tích lũy chỉ được dựng lại khi độ rộng thực sự thay đổi, như đổi kích thước cột, ẩn cột hoặc đổi thứ tự; không tạo lại trong lúc cuộn
  • Đặt buffer khoảng 200px ở hai bên để render trước cột tiếp theo trước khi nó xuất hiện trên màn hình
  • Sau khi ảo hóa hai trục, diện tích render được giữ ở mức khoảng 40 hàng × 12 cột bất kể kích thước dữ liệu
    • Ngay cả với collection 500 cột, mỗi lần cũng chỉ tồn tại khoảng 12 cột nên thời gian tải không tăng
  • Số phần tử cần render đã giảm, nhưng vẫn còn vấn đề phải tính lại phạm vi ở mỗi sự kiện cuộn xảy ra hàng trăm lần mỗi giây

Bước 5: quản lý ngân sách khung hình trên đường xử lý cuộn

  • Xử lý cuộn phải chia sẻ ngân sách 16,7ms mỗi frame với style/layout/paint, nên chỉ được làm tối thiểu
  • Đăng ký passive event listener bên ngoài cơ chế change detection của framework
    • Báo cho trình duyệt rằng sẽ không gọi preventDefault, để compositor có thể di chuyển pixel mà không phải chờ JavaScript
    • Bản thân sự kiện cuộn cũng không kích hoạt kiểm tra render của framework
  • Gộp nhiều sự kiện thành một lần mỗi frame
    • Chỉ lưu vị trí cuộn mới nhất và đặt lịch một callback requestAnimationFrame
    • Dù có 12 sự kiện trong một frame thì cũng chỉ tính phạm vi một lần
  • Áp dụng fast draw exit học từ mã nguồn Handsontable
    • Nếu phạm vi hiển thị mới vẫn nằm trong buffer đã render, chỉ cần hai phép so sánh số nguyên rồi trả về ngay
  • Áp dụng hysteresis ở biên buffer
    • Chỉ dựng lại khi phạm vi hiển thị tiến đến cách mép buffer khoảng 40px
    • Buffer khoảng 200px được đặt lại quanh vị trí mới để tránh lộ mép trống hoặc liên tục dựng lại ở ranh giới
  • Thêm bộ phát hiện tốc độ theo dõi px/ms giữa các sự kiện
    • Trong triển khai này, vượt quá 10px/ms được coi là flick nhanh và sẽ tạm dừng render
    • Để cuộn native tiếp tục di chuyển trên vùng ma, rồi chỉ lấp lại slab khi tốc độ ổn định
  • Sau khi giảm công việc JavaScript, hiện tượng tụt frame vẫn còn; layout và paint với style, nền sọc và icon trở thành nút thắt tiếp theo

Bước 6: loại bỏ chi phí từ thuộc tính layout

  • Main thread của trình duyệt xử lý style, layout và paint, còn compositor di chuyển các layer đã vẽ trên GPU
  • Các thuộc tính mà animation có thể xử lý ở compositor là transformopacity; còn top, left, width, height, background-color... sẽ đánh thức main thread
  • Khi cập nhật top để đồng bộ cuộn giữa số thứ tự hàng và panel cột ghim, đã phát sinh layout cưỡng bức 60 lần mỗi giây
    • Thay bằng translate3d giữ nguyên hình ảnh nhưng loại bỏ chi phí trên main thread
  • Nền sọc trước đây được tạo theo từng hàng được đổi thành một repeating-linear-gradient duy nhất cho toàn bộ phần thân
    • Chiều cao hàng được truyền qua biến CSS
    • Trình duyệt chỉ cần rasterize một tile bằng hai chiều cao hàng, rồi lặp lại bằng texture GPU
    • Không còn binding class theo từng hàng, và phần thân lẫn panel ghim cùng dùng một tile nên cũng tránh lệch màu
  • Vạch phân cách cột chỉ được vẽ theo chiều cao của slab đang render, thay vì toàn bộ vùng ma cao 40 triệu px
  • Cuộn thông thường đã mượt, nhưng ở thời điểm chuyển cửa sổ tạo ô mới vẫn còn chi phí do DOM thừa tích lũy theo từng ô

Bước 7: làm gọn icon và editor

  • Icon kiểu dữ liệu trong grid cơ sở dữ liệu không chỉ để trang trí, mà có chức năng phân biệt ý nghĩa của các giá trị như ObjectId, chuỗi, số nguyên, JSONB
  • Triển khai đầu tiên thêm một phần tử icon font vào mỗi ô, nhưng DOM node bổ sung và đường render văn bản của glyph tạo ra chi phí lớn
  • Chuyển icon vào background-image của chính ô và mã hóa bằng SVG data URI
    • Tất cả ô cùng kiểu dữ liệu sẽ tham chiếu đến cùng một chuỗi URI
    • Trình duyệt chỉ rasterize icon theo kiểu một lần rồi lặp lại từ texture GPU đã cache
    • Có thể hiển thị trang trí lặp lại mà không cần DOM node riêng
  • Áp dụng cách render kết hợp cho chỉnh sửa ô
    • Bình thường, ô chỉ dùng văn bản thường và một span
    • Chỉ khi double-click mới mount component editor nặng có nhận biết kiểu lên trên ô như một portal
  • Nếu dùng component framework cho mọi ô thì chi phí tạo instance sẽ cộng dồn, và việc ghép grid library với component renderer cho ô có thể làm hiệu năng giảm mạnh
  • Bản thân ô đã nhẹ hơn, nhưng vẫn còn vấn đề framework dựng lại cả các ô còn lại có nội dung giống hệt khi một cột mới xuất hiện

Bước 8: tái sử dụng DOM theo vị trí

  • Tiêu chí theo dõi như trackBy của Angular hay key của React và Vue quyết định liệu cửa sổ dịch chuyển sẽ tái sử dụng phần tử cũ hay phá đi tạo lại
  • Nếu hàng không được bind lại với dữ liệu mới mà luôn bị thay thế hoàn toàn, thì component, DOM node và event listener sẽ liên tục bị tạo lại
  • Theo dõi hàng và cột theo vị trí trên màn hình, thay vì theo giá trị dữ liệu
    • Cùng khoảng 40 phần tử hàng và cột được giữ lại, chỉ thay nội dung bằng giá trị mới
    • Grid hoạt động như object pool và không cấp phát DOM mới khi cuộn
  • Đến bước này, chi phí của cuộn hai trục và dựng lại cửa sổ đã được giải quyết, nhưng vẫn còn các vấn đề hoàn thiện như rung một frame khi thả cột hay sai lệch nửa pixel ở số thứ tự hàng

Bước 9: hoàn thiện các tương tác chi tiết

  • Trong lúc kéo cột, dùng transform để di chuyển các cột hiện có như thể đã ở thứ tự mới, và khi thả thì xử lý đổi thứ tự cùng việc reset transform trong cùng một lượt render
    • Làm cho frame kéo cuối cùng và frame đổi thứ tự đầu tiên giống nhau đến từng pixel để chuyển tiếp không bị lộ
  • Tooltip không được bind vào từng ô mà dùng một delegated hover listener duy nhất ở container
    • Chỉ tính tooltip cho ô đang nằm dưới con trỏ
    • Cả việc tạo chuỗi tốn kém như pretty-print giá trị JSONB của SQL cũng được hoãn đến đúng lúc hover thật sự
  • Viền 1px ở cột số thứ tự hàng làm lệch nửa pixel giữa hàng dữ liệu và baseline chữ, gây rung khi cuộn
    • Đã sửa để cả hai cột dùng cùng box model
  • Nền sọc được quyết định theo chỉ số hàng tuyệt đối, không theo vị trí DOM tái sử dụng, nên màu không nhấp nháy khi cửa sổ ảo hóa thay đổi
  • Các tính năng từng biện minh cho việc tự xây grid cũng được giữ lại
    • Mở rộng tài liệu lồng nhau thành các cột con thực sự thay vì để dưới dạng chuỗi JSON
    • Tô sáng chuỗi khớp của đường dẫn lồng nhau ngay trong ô
    • Kéo trực tiếp giá trị có kiểu dữ liệu từ grid vào trình dựng truy vấn trực quan
  • Chi phí thêm các tính năng như vậy vào grid library phổ thông cao hơn chi phí tự nắm quyền kiểm soát renderer

Bước 10: đọc mã nguồn của các grid hiệu năng cao hiện có

  • Khi học về hiệu năng frontend, thói quen có giá trị nhất là đọc mã nguồn của các dự án khác; nhiều tối ưu không được tài liệu hóa vẫn nằm trong kho mã công khai
  • AG-Grid, một grid DOM đã được nghiên cứu, áp dụng các kỹ thuật sau
    • Chia nhỏ công việc DOM theo thời gian bằng ngân sách thời gian rõ ràng cho từng frame và hàng đợi tác vụ theo ưu tiên
    • Băm viewport của cột để những lần cuộn không có thay đổi chỉ cần một phép so sánh chuỗi
    • Tạo hàng theo hướng cuộn để nội dung phía người dùng đang tiến tới được hiển thị trước
    • Tạo ô mới trước rồi trì hoãn việc hủy ô cũ để nội dung mới được vẽ trước nội dung biến mất
    • Event listener theo từng ô là nút thắt đã được đo đạc, nên dùng event delegation ở cấp container
  • Grid dựa trên Canvas bỏ qua DOM, nên có thể vẽ lại hàng trăm đoạn văn bản mỗi frame mà không cần layout hay tính lại style
    • Có thể giữ 60fps ngay cả khi thao tác quá mức, nên trần hiệu năng cao hơn grid DOM
    • Văn bản rasterize lệch lưới pixel có thể bị mờ
    • Việc chọn chỉ hoạt động ở mức ô, và dấu ba chấm cũng sẽ không xuất hiện nếu không tự vẽ
    • Mỗi tính năng mới của ô lại cần thêm mã vẽ và mã hit-testing
  • Cuối cùng vẫn chọn DOM để giữ chữ sắc nét, khả năng chọn văn bản thật, accessibility và tốc độ phát triển tính năng nhanh
  • Dù không đạt được độ mượt như Canvas, đây là sự đánh đổi có chủ đích dựa trên trải nghiệm người dùng cần thiết và chi phí phát triển

Chưa có bình luận nào.

Chưa có bình luận nào.