Trong bài viết này, ta sẽ xây dựng cửa sổ có tính năng cuộn tin nhắn – kiểu cửa sổ chat tiêu chuẩn mà bạn thường thấy ở góc dưới bên phải của một trang web. Khi bạn trò chuyện với người (hoặc bot) ở đầu bên kia, các tin nhắn mới sẽ xuất hiện và các tin nhắn cũ sẽ di chuyển lên trên để tạo chỗ trống.
Sẽ không sử dụng bất kỳ API nào trong bài viết này. Thành phần xây dựng sẽ chỉ xử lý bố cục và giao diện người dùng mà thôi. Để kiểm tra chức năng, ta sẽ giả lập một luồng tin nhắn đến bằng cách sử dụng một nút để thêm các “tin nhắn” mới vào cửa sổ chat.
Chức năng ta sẽ triển khai bao gồm:
- Bắt đầu với một loạt tin nhắn, và cửa sổ chat đã cuộn sẵn đến cuối.
- Khi một tin nhắn mới được thêm vào, đưa nó vào cuối danh sách và cuộn xuống một cách mượt mà để hiển thị tin nhắn đó.
- Nếu người dùng đã cuộn lên để xem các tin nhắn cũ và có một tin nhắn mới đến, không tự động cuộn xuống tin nhắn mới (điều này dẫn đến trải nghiệm không tốt cho người dùng). Thay vào đó, hiển thị một nút để người dùng có thể cuộn xuống tin nhắn mới khi họ muốn.
Kiến trúc thành phần sẽ như thế này:
Cửa sổ màu xanh lá cây sẽ có kích thước cố định, trong khi cửa sổ tin nhắn dài hơn sẽ mở rộng kích thước vô hạn, nhưng luôn neo ở phía dưới của container màu xanh lá cây.
Thành phần cấp cao nhất (nơi chúng ta đặt cửa sổ chat) sẽ cần trông như thế này:
Thành phần ScrollContainer
đại diện cho cửa sổ màu xanh lá cây trong sơ đồ kiến trúc, và nội dung đại diện cho chuỗi tin nhắn dài.
ScrollContainer
sẽ thực hiện các công việc phức tạp và đảm bảo mọi thứ được đặt đúng vị trí và cuộn mượt mà:
Dưới đây là cách có thể triển khai ScrollContainer
:
Điều này có nghĩa là từ góc nhìn của người dùng, ta đã bắt đầu cuộc trò chuyện với việc cuộn đến cuối danh sách tin nhắn.
useEffect
thứ hai thực hiện chính xác cùng một việc mỗi khi các thành phần con của ScrollContainer
được cập nhật (nhờ vào mảng phụ thuộc chứa ‘children’). Điểm khác biệt duy nhất là scrollTo
trong hiệu ứng này có thuộc tính ‘behavior: smooth’. Điều này có nghĩa là nếu các thành phần con thay đổi (một tin nhắn mới được thêm vào), useEffect sẽ tính toán lại giá trị tràn của cuộn và cuộn mượt mà xuống dưới cùng.
Sự kết hợp của hai hiệu ứng này mang lại chức năng mà ta mong muốn — bắt đầu cuộn đến cuối, sau đó cuộn mượt mà khi có bất kỳ tin nhắn mới nào đến .
Để thấy điều này hoạt động, ta cần một cách để cập nhật các thành phần con. Hãy cập nhật thành phần App
với một nút để thêm các thành phần con khi nhấn:
Ta đã thêm một thành phần nhỏ ChatMessage
ở đầu của thành phần chính. Đây chỉ là một thành phần giao diện người dùng để có thể thấy những gì đang diễn ra.
Bây giờ ta có một giá trị trạng thái hoạt động như một bộ đếm số lượng tin nhắn trong chuỗi. Ta cũng đã thêm một nút để tăng bộ đếm này khi nhấn, và bên trong ScrollContainer
, và sẽ render một số lượng phần tử ChatMessage
dựa trên giá trị của trạng thái.
Tóm lại — ta sẽ bắt đầu với 5 tin nhắn, và mỗi khi nhấn ‘Add Item’, chúng ta thêm một tin nhắn nữa.
Ở điểm này, ta đã có một thành phần hoạt động khá tốt. Bước tiếp theo là đảm bảo rằng nếu người dùng đã cuộn lên để xem tin nhắn cũ hơn, nó sẽ không tự động cuộn xuống dưới cùng khi có tin nhắn mới.
Để làm điều này, ta sẽ sửa đổi ScrollContainer
của mình như sau:
Giữ nguyên thành phần trừ những thay đổi ở trên và thêm một ref mới để theo dõi chiều cao của phần tử div bên trong giữa các lần render.
Ta đã thay thế hai useEffect thành một useEffect duy nhất, giờ đây nó sẽ xử lý cả việc cuộn ban đầu và cuộn sau này, và không tự động cuộn nếu người dùng không ở cuối luồng tin nhắn.
outerDivScrollTop
là vị trí cuộn hiện tại của các tin nhắn.
Ta có thể sử dụng nó để kiểm tra xem người dùng đã cuộn đến cuối bằng cách kiểm tra xem vị trí cuộn có bằng với độ lệch (nhớ lại rằng, đây chỉ là chiều cao của phần tử div bên trong trừ đi chiều cao của phần tử div bên ngoài). Nếu người dùng đang ở cuối của div khi tin nhắn mới đến, ta sẽ cuộn xuống dưới cùng.
Theo logic này, ta có thể kiểm tra outerDivScrollTop === innerDivHeight - outerDivHeight
, phải không?
Thật không may 😖. Đến khi ta vào trong useEffect, chiều cao của phần tử div bên trong đã thay đổi.
Ta cần kiểm tra xem vị trí cuộn có khớp với độ lệch từ trước khi tin nhắn đến, và đây là lúc ref có tác dụng.
Lần đầu tiên useEffect chạy, ta sẽ cuộn thanh cuộn đến xuống dưới cùng của các tin nhắn (như thông thường) và sau đó lưu chiều cao của phần tử div bên trong vào ref.
Bất kỳ lần nào useEffect chạy tiếp theo, ta sẽ so sánh vị trí cuộn hiện tại với giá trị độ lệch trước đó. Điều này sẽ cho chúng ta biết liệu người dùng đã cuộn đến cuối trước khi tin nhắn mới đến hay không.
Ta cũng có thể sử dụng ref mới prevInnerDivHeight
để xử lý việc tải ban đầu. Và sử dụng hai useEffect, một để cuộn khi tải lần đầu tiên, và một để cuộn khi nhận được tin nhắn mới.
Sự khác biệt duy nhất giữa hai đoạn là giá trị của scrollBehaviour
. Cuộn lần đầu cần nhảy xuống dưới cùng, trong khi các cuộn sau cần mượt mà.
Ta khởi tạo prevInnerDivHeight
với giá trị null
. Ở bên trong useEffect, có thể kiểm tra điều này để xác định xem đang ở lần render đầu tiên hay không, đó là điều ta đang làm ở đây:
Đoạn code trong if dùng để kiểm tra:
- Là lần render đầu tiên (!prevInnerDivHeight.current), vì vậy chắc chắn rằng ta phải cuộn xuống.
- Trước khi tin nhắn mới đến, ta đã cuộn đến cuối, vì vậy ta nên cuộn xuống dưới cùng của tin nhắn mới (outerDivScrollTop === prevInnerDivHeight.current — outerDivHeight)
Trong hàm scrollTo
, ta thiết lập hành vi dựa vào sự tồn tại của prevInnerDivHeight.current
. Nếu nó không tồn tại, đó là lần render đầu tiên nên ta cần cuộn xuống dưới cùng ngay lập tức. Nếu nó tồn tại thì có một tin nhắn mới đang đến, vì vậy ta thiết lập “smooth” cho thanh cuộn.
Bước cuối cùng là thêm một nút xuất hiện khi ta cuộn lên trên luồng tin nhắn và một tin nhắn mới đến. Nút này sẽ cuộn “smooth” xuống dưới cùng.
Ta sẽ cần thêm một nút vào thành phần, cùng với một trạng thái để xác định liệu có hiển thị nút hay không, và một hàm xử lý để xử lý sự kiện onClick
của nút đó.
Đây là ScrollContainer
sau khi đã thực hiện tất cả các thay đổi này:
Ta đã có một hàm xử lý mới gọi là handleScrollButtonClick
thực hiện một phiên bản thủ công của useEffect
— nó sẽ cuộn “smooth” xuống dưới cùng của luồng tin nhắn.
Ta kích hoạt hành động này với một nút mới mà ta đã thêm vào. Để trang trí, ta đã phải thêm một wrapper mới trong phần return — điều này cho phép ta đặt nút lơ lửng trên luồng tin nhắn. Điều kiện opacity: showScrollButton ? 1 : 0
trong CSS có nghĩa là chúng ta sẽ chỉ thấy nút khi trạng thái showScrollButton
là true.
Bên trong useEffect
, ta đã thêm:
Chức năng hoàn thiện bao gồm:
- Khi lần đầu tiên render, cuộn xuống dưới cùng với hành vi
auto
. - Khi một tin nhắn mới đến, nếu người dùng đã cuộn xuống dưới cùng, giữ nguyên vị trị thanh cuộn ở đó
- Nếu người dùng chưa cuộn xuống dưới cùng, cập nhật trạng thái để hiển thị một nút cho phép cuộn xuống dưới cùng của luồng tin nhắn bằng cách thủ công.