Sự phức tạp của thanh cuộn vô hạn

Tóm tắt: Sử dụng lại các phần tử DOM và xoá những phần tử ở xa khung nhìn. Sử dụng phần giữ chỗ để tính đến dữ liệu bị trễ. Sau đây là bản minh hoạ cho trình cuộn vô hạn.

Tính năng cuộn vô hạn xuất hiện ở khắp mọi nơi trên Internet. Danh sách nghệ sĩ của Google Music là một, dòng thời gian của Facebook là một và nguồn cấp dữ liệu trực tiếp của Twitter cũng là một. Bạn di chuyển xuống và trước khi đến cuối trang, nội dung mới sẽ xuất hiện một cách kỳ diệu, dường như là từ hư không. Đây là một trải nghiệm liền mạch cho người dùng và rất dễ nhận thấy sức hấp dẫn của tính năng này.

Tuy nhiên, thách thức kỹ thuật đằng sau một trình cuộn vô hạn khó hơn bạn tưởng. Có rất nhiều vấn đề bạn gặp phải khi muốn làm điều đúng đắn. Việc này bắt đầu từ những điều đơn giản như các đường liên kết ở chân trang gần như không thể truy cập được vì nội dung liên tục đẩy chân trang ra xa. Nhưng các vấn đề sẽ khó hơn. Bạn xử lý sự kiện thay đổi kích thước như thế nào khi người dùng xoay điện thoại từ chế độ dọc sang chế độ ngang hoặc làm cách nào để ngăn điện thoại của bạn dừng đột ngột khi danh sách quá dài?

The right thing™

Chúng tôi cho rằng đó là lý do đủ để đưa ra một cách triển khai tham chiếu cho thấy cách giải quyết tất cả những vấn đề này theo cách có thể sử dụng lại trong khi vẫn duy trì các tiêu chuẩn về hiệu suất.

Chúng ta sẽ sử dụng 3 kỹ thuật để đạt được mục tiêu: tái chế DOM, đánh dấu và cố định vị trí cuộn.

Trường hợp minh hoạ của chúng ta sẽ là một cửa sổ trò chuyện tương tự như Hangouts, nơi chúng ta có thể cuộn qua các tin nhắn. Điều đầu tiên chúng ta cần là một nguồn vô tận các tin nhắn trò chuyện. Về mặt kỹ thuật, không có trình cuộn vô hạn nào là thực sự vô hạn, nhưng với lượng dữ liệu có sẵn để đưa vào các trình cuộn này, chúng có thể là vô hạn. Để đơn giản, chúng ta sẽ chỉ mã hoá cứng một bộ tin nhắn trò chuyện và chọn tin nhắn, tác giả và tệp đính kèm hình ảnh ngẫu nhiên (nếu có) với một chút độ trễ nhân tạo để hoạt động giống mạng thực hơn một chút.

Ảnh chụp màn hình ứng dụng nhắn tin

Tái chế DOM

Tái chế DOM là một kỹ thuật chưa được khai thác hết để giữ cho số lượng nút DOM ở mức thấp. Ý tưởng chung là sử dụng các phần tử DOM đã được tạo nằm ngoài màn hình thay vì tạo các phần tử mới. Mặc dù các nút DOM có chi phí thấp, nhưng chúng không miễn phí vì mỗi nút sẽ làm tăng thêm chi phí về bộ nhớ, bố cục, kiểu và quá trình hiển thị. Các thiết bị cấp thấp sẽ chậm hơn đáng kể, thậm chí không dùng được nếu trang web có DOM quá lớn để quản lý. Ngoài ra, hãy lưu ý rằng mọi lần bố trí lại và áp dụng lại các kiểu của bạn (một quy trình được kích hoạt bất cứ khi nào một lớp được thêm hoặc xoá khỏi một nút) sẽ tốn kém hơn khi DOM lớn hơn. Việc tái chế các nút DOM có nghĩa là chúng ta sẽ giảm đáng kể tổng số nút DOM, giúp tất cả các quy trình này diễn ra nhanh hơn.

Rào cản đầu tiên là chính thao tác cuộn. Vì chỉ có một tập hợp con nhỏ của tất cả các mục có sẵn trong DOM tại một thời điểm bất kỳ, nên chúng ta cần tìm một cách khác để thanh cuộn của trình duyệt phản ánh đúng lượng nội dung theo lý thuyết. Chúng ta sẽ sử dụng một phần tử sentinel 1px x 1px với một phép biến đổi để buộc phần tử chứa các mục (đường băng) có chiều cao mong muốn. Chúng ta sẽ chuyển mọi phần tử trong đường băng sang lớp riêng để đảm bảo lớp của đường băng hoàn toàn trống. Không có màu nền, không có gì. Nếu lớp của đường băng không trống, thì lớp đó không đủ điều kiện để được trình duyệt tối ưu hoá và chúng ta sẽ phải lưu trữ một hoạ tiết trên card đồ hoạ có chiều cao vài trăm nghìn pixel. Chắc chắn không khả thi trên thiết bị di động.

Bất cứ khi nào chúng ta cuộn, chúng ta sẽ kiểm tra xem khung hiển thị đã đến gần cuối đường băng hay chưa. Nếu có, chúng ta sẽ kéo dài khoảng thời gian này bằng cách di chuyển phần tử sentinel và di chuyển những mục đã rời khỏi khung hiển thị xuống cuối khoảng thời gian này, đồng thời điền sẵn nội dung mới vào các mục đó.

Runway Sentinel Viewport

Điều tương tự cũng áp dụng cho thao tác di chuyển theo hướng khác. Tuy nhiên, chúng tôi sẽ không bao giờ thu hẹp khoảng trống trong quá trình triển khai để vị trí thanh cuộn luôn nhất quán.

Bia mộ

Như đã đề cập trước đó, chúng tôi cố gắng làm cho nguồn dữ liệu của mình hoạt động giống như một thứ gì đó trong thế giới thực. Với độ trễ mạng và mọi thứ. Điều đó có nghĩa là nếu sử dụng tính năng cuộn nhanh, người dùng có thể dễ dàng cuộn qua phần tử cuối cùng mà chúng ta có dữ liệu. Nếu điều đó xảy ra, chúng tôi sẽ đặt một mục đánh dấu (một phần giữ chỗ) và mục này sẽ được thay thế bằng mục có nội dung thực tế sau khi dữ liệu đến. Các dấu hiệu cũng được tái chế và có một nhóm riêng cho các phần tử DOM có thể sử dụng lại. Chúng ta cần điều đó để có thể chuyển đổi mượt mà từ một dấu hiệu sang mục có nội dung. Nếu không, người dùng sẽ cảm thấy rất khó chịu và có thể mất dấu vết của những gì họ đang tập trung vào.

Ngôi mộ như vậy. Rất nhiều đá. Wow.

Một thách thức thú vị ở đây là các mục thực có thể có chiều cao lớn hơn mục đánh dấu vì số lượng văn bản khác nhau cho mỗi mục hoặc hình ảnh được đính kèm. Để giải quyết vấn đề này, chúng ta sẽ điều chỉnh vị trí cuộn hiện tại mỗi khi dữ liệu xuất hiện và một phần tử đánh dấu đang được thay thế phía trên khung hiển thị, neo vị trí cuộn vào một phần tử thay vì một giá trị pixel. Khái niệm này được gọi là neo cuộn.

Ghim khi cuộn

Tính năng cố định vị trí cuộn sẽ được gọi khi các tảng đá đánh dấu được thay thế cũng như khi cửa sổ được đổi kích thước (cũng xảy ra khi thiết bị bị lật!). Chúng ta sẽ phải tìm ra phần tử hiển thị trên cùng trong khung nhìn. Vì phần tử đó có thể chỉ hiển thị một phần, nên chúng ta cũng sẽ lưu trữ độ lệch so với đầu phần tử nơi khung nhìn bắt đầu.

Sơ đồ neo cuộn.

Nếu khung hiển thị được đổi kích thước và đường băng có thay đổi, chúng tôi có thể khôi phục một tình huống mà người dùng cảm thấy giống hệt về mặt hình ảnh. Chiến thắng! Ngoại trừ trường hợp cửa sổ được đổi kích thước, tức là mỗi mục có thể đã thay đổi chiều cao. Vậy làm cách nào để biết nội dung được neo nên đặt ở vị trí nào? Chúng tôi không! Để tìm ra điều này, chúng ta phải bố trí mọi phần tử phía trên mục được neo và cộng tất cả chiều cao của chúng; điều này có thể gây ra một khoảng dừng đáng kể sau khi thay đổi kích thước và chúng ta không muốn điều đó. Thay vào đó, chúng ta giả định rằng mọi mục ở trên đều có cùng kích thước với một dấu phân cách và điều chỉnh vị trí cuộn cho phù hợp. Khi các phần tử được cuộn vào đường băng, chúng ta sẽ điều chỉnh vị trí cuộn, trì hoãn hiệu quả công việc bố cục cho đến khi thực sự cần thiết.

Bố cục

Tôi đã bỏ qua một chi tiết quan trọng: Bố cục. Mỗi lần tái chế một phần tử DOM thường sẽ bố trí lại toàn bộ đường băng, điều này sẽ khiến chúng ta thấp hơn nhiều so với mục tiêu 60 khung hình/giây. Để tránh điều này, chúng tôi sẽ tự mình đảm nhận việc bố trí và sử dụng các phần tử có vị trí tuyệt đối với các phép biến đổi. Bằng cách này, chúng ta có thể giả vờ rằng tất cả các phần tử ở phía trên đường băng vẫn chiếm không gian, trong khi thực tế chỉ có không gian trống. Vì chúng ta đang tự bố trí, nên chúng ta có thể lưu vào bộ nhớ đệm các vị trí mà mỗi mục kết thúc và chúng ta có thể tải ngay đúng phần tử từ bộ nhớ đệm khi người dùng cuộn ngược.

Lý tưởng nhất là các mục chỉ được vẽ lại một lần khi được đính kèm vào DOM và không bị ảnh hưởng bởi việc thêm hoặc xoá các mục khác trong đường băng. Điều đó có thể xảy ra, nhưng chỉ với các trình duyệt hiện đại.

Các chế độ điều chỉnh mới nhất

Gần đây, Chrome đã thêm tính năng hỗ trợ CSS Containment. Đây là một tính năng cho phép chúng tôi (nhà phát triển) cho trình duyệt biết rằng một phần tử là ranh giới cho bố cục và hoạt động vẽ. Vì chúng ta đang tự thực hiện bố cục tại đây, nên đây là ứng dụng chính để ngăn chặn. Bất cứ khi nào chúng ta thêm một phần tử vào đường băng, chúng ta biết rằng các mục khác không cần phải chịu ảnh hưởng của việc bố trí lại. Vì vậy, mỗi mục phải nhận được contain: layout. Chúng tôi cũng không muốn ảnh hưởng đến phần còn lại của trang web, vì vậy, chính đường băng cũng phải nhận được chỉ thị về kiểu này.

Một điều khác mà chúng tôi cân nhắc là sử dụng IntersectionObservers làm cơ chế phát hiện thời điểm người dùng đã cuộn đủ xa để chúng tôi bắt đầu tái chế các phần tử và tải dữ liệu mới. Tuy nhiên, IntersectionObserver được chỉ định có độ trễ cao (như thể đang sử dụng requestIdleCallback), vì vậy, chúng ta có thể thực sự cảm thấy ít phản hồi hơn khi có IntersectionObserver so với khi không có. Ngay cả việc triển khai hiện tại của chúng tôi bằng sự kiện scroll cũng gặp phải vấn đề này, vì các sự kiện cuộn được gửi trên cơ sở "nỗ lực hết mình". Cuối cùng, Compositor Worklet của Houdini sẽ là giải pháp có độ trung thực cao cho vấn đề này.

Tính năng này vẫn chưa hoàn hảo

Việc triển khai hiện tại của tính năng tái chế DOM không lý tưởng vì tính năng này sẽ thêm tất cả các phần tử truyền qua khung hiển thị, thay vì chỉ quan tâm đến những phần tử thực sự trên màn hình. Điều này có nghĩa là khi bạn cuộn rất nhanh, bạn sẽ đặt quá nhiều công việc cho bố cục và vẽ trên Chrome đến mức Chrome không thể theo kịp. Bạn sẽ không thấy gì ngoài hình nền. Đây không phải là điều gì quá nghiêm trọng nhưng chắc chắn bạn cần cải thiện.

Chúng tôi hy vọng bạn thấy được những vấn đề đơn giản có thể trở nên khó khăn như thế nào khi bạn muốn kết hợp trải nghiệm người dùng tuyệt vời với các tiêu chuẩn hiệu suất cao. Khi Ứng dụng web tiến bộ trở thành trải nghiệm cốt lõi trên điện thoại di động, điều này sẽ trở nên quan trọng hơn và các nhà phát triển web sẽ phải tiếp tục đầu tư vào việc sử dụng các mẫu tuân thủ những hạn chế về hiệu suất.

Bạn có thể tìm thấy tất cả mã trong kho lưu trữ của chúng tôi. Chúng tôi đã cố gắng hết sức để giữ cho nó có thể tái sử dụng, nhưng sẽ không xuất bản nó dưới dạng một thư viện thực tế trên npm hoặc dưới dạng một kho lưu trữ riêng biệt. Mục đích sử dụng chính là giáo dục.