← Tất cả bài viết
GSAP Scrub Timeline Lỗi Khi Cuộn Nhanh: Cách Sửa
Timeline GSAP scrub theo scroll chạy hoàn hảo cho tới khi cuộn nhảy nhanh — một phần tử bị kẹt hiện mãi. Nguyên nhân thật, cách sửa, và cách QA đúng.

Tôi từng dựng một timeline GSAP pin cứng, scrub theo scroll, đổi hiển thị từng phần theo nhịp —
một chuỗi kể chuyện mà mỗi đoạn cuộn hiện panel tiếp theo và ẩn panel trước. Cuộn từ từ, tuần tự
thì mượt hoàn hảo. Rồi có người bấm End, rồi Home, và một panel thuộc ba nhịp sau đó bị kẹt
luôn trên màn hình — autoAlpha: 1, đè lên mọi thứ khác, suốt phần còn lại của phiên. Không lỗi
console. Không cảnh báo. Trang chỉ hỏng vĩnh viễn cho tới khi reload cứng.
Nếu timeline GSAP scrub theo scroll của bạn lỗi khi cuộn nhanh — kéo scrollbar, nhảy
End/Home, hoặc một cú seek anchor-link xa vị trí hiện tại — trong khi cuộn mượt tuần tự thì
hoàn toàn ổn, gần như chắc chắn đây là đúng con bug tôi từng dính: đổi hiển thị nằm trong một
callback của timeline thay vì một tween thật.
Triệu chứng: cuộn thì ổn, nhảy thì vỡ
Dấu hiệu này đủ đặc trưng để chẩn đoán chính xác. Một timeline ScrollTrigger scrub:
- Chạy đúng với mọi cú cuộn mượt, tuần tự — tới hay lui.
- Chỉ hỏng sau một cú nhảy vị trí scroll lớn, tức thời:
EndrồiHome, kéo nhanh thanh scrollbar, hoặc router/anchor scroll nhảy xa khỏi vị trí playhead cũ. - Để lại đúng một phần tử kẹt ở trạng thái "bật" cuối cùng nó từng được set, đè lên các nhịp sau nó, cho tới khi reload trang toàn bộ để reset lại state JS.
Vì QA thông thường chỉ cuộn tuần tự, bug này ẩn kín khi review và chỉ lộ ra khi người dùng thật kéo cuộn nhanh — điều họ chắc chắn sẽ làm, nhất là ở một section pin dài mà họ đang lướt qua.
Vì sao xảy ra: callback không phải là tween
Timeline trong ví dụ trên đổi hiển thị theo kiểu này:
tl.add(() => {
gsap.set(panelA, { autoAlpha: 0 });
gsap.set(panelB, { autoAlpha: 1 });
}, position);
Nhìn có vẻ hợp lý — nó nằm trong timeline, ở một vị trí cụ thể, dùng gsap.set(). Nhưng một hàm
thuần được đưa vào tl.add() không phải là một tween có thể render lại được.
Timeline của GSAP chỉ biết cách tính lại trạng thái từ
các tween (.to, .fromTo, .set) khi playhead bị seek tới một vị trí bất kỳ — nó nội suy tiến
độ của từng tween dựa trên thời gian tuyệt đối của timeline. Một callback không có giá trị tiến độ
nào để nội suy. GSAP chỉ có thể gọi lại nó đúng lúc playhead đi qua vị trí đó, theo bất kỳ
hướng nào nó đang di chuyển.
Khi cuộn mượt, playhead đi qua từng vị trí theo đúng thứ tự, nên callback bắn đúng lúc bạn mong
đợi và các lệnh gsap.set() một chiều trông có vẻ đúng. Nhưng khi nhảy nhanh — ví dụ từ giây 40
của timeline nhảy thẳng về giây 2 — playhead bỏ qua hoàn toàn vị trí của callback. Nó không bao
giờ được gọi lại. Bất kể gsap.set() ghi gì lần cuối sẽ giữ nguyên mãi mãi, vì không có gì báo cho
GSAP đối chiếu lại nó theo thời gian playhead mới. Trong khi đó mọi tween thật khác trên cùng
timeline vẫn tính lại đúng sau cú nhảy — đó là lý do chỉ đúng phần tử do callback điều khiển bị
sai, còn mọi thứ khác tự sửa đúng và trông như có một panel bị "ma ám" ngẫu nhiên.
Cách sửa: tl.set(), không phải tl.add(() => gsap.set())
Cách sửa là dùng một tween thời lượng-bằng-0 thật trên timeline, thay vì một callback bọc cùng API đó:
tl.set(panelA, { autoAlpha: 0 }, position);
tl.set(panelB, { autoAlpha: 1 }, position);
Đó là toàn bộ diff. tl.set() là một tween — thời lượng bằng 0 — nên
nó tham gia vào phép tính tiến độ bình thường của timeline
giống hệt một .to(). Ở bất kỳ cú seek nào, theo bất kỳ hướng nào, khoảng cách nào, GSAP đều tính
lại xem lệnh tl.set() nào nên "đang active" ở thời điểm playhead mới rồi áp dụng nó. Kết quả hình
ảnh giống hệt bản callback — một cú cắt cứng tức thời — nhưng giờ nó có thể đảo ngược và an toàn
với seek, thay vì một trigger một chiều.
// trước — trigger một chiều, vỡ khi seek nhảy
tl.add(() => {
gsap.set(panelA, { autoAlpha: 0 });
gsap.set(panelB, { autoAlpha: 1 });
}, position);
// sau — một tween thời lượng-bằng-0 thật, được tính lại ở mọi lần seek
tl.set(panelA, { autoAlpha: 0 }, position);
tl.set(panelB, { autoAlpha: 1 }, position);
tl.add(callback) so với tl.set() — vì sao một bên sống sót qua cú nhảy
tl.add(() => gsap.set(...)) | tl.set(target, vars, position) | |
|---|---|---|
| Tính lại khi seek/nhảy | Không — chỉ bắn khi bị đi qua | Có — luôn khớp với thời gian playhead |
| Đảo ngược được khi cuộn lùi | Không — trigger một chiều | Có — hoạt động như mọi tween khác |
| Kết quả hình ảnh khi cuộn mượt | Trông đúng | Trông đúng (giống hệt) |
| Kết quả hình ảnh khi nhảy nhanh | Có thể kẹt vĩnh viễn | Đúng — tự phục hồi |
| Khi nào nên dùng | Một side effect thật sự (gửi analytics, đổi route) | Bất kỳ thay đổi hiển thị/trạng thái nào gắn với vị trí scroll |
Nếu bạn thực sự cần một side effect chạy một lần — ví dụ bắn sự kiện analytics — thì callback vẫn ổn, vì side effect chỉ cần bắn một lần khi bị đi qua chính là mục đích của nó. Bug chỉ xảy ra khi callback được dùng để đặt trạng thái hiển thị phải đúng ở một vị trí playhead bất kỳ. Loại trạng thái đó thuộc về một tween.
QA bằng phép thử "nhảy", không phải cuộn mượt
Cuộn mượt tuần tự sẽ không bao giờ bắt được bug này — nó liên quan chính xác tới các cú seek không tuần tự. Thêm phép thử nhảy vào checklist review cho bất kỳ timeline scrub nào:
- Cuộn tới khoảng giữa section pin/scrub.
- Bấm
End, rồi bấm ngayHome(hoặcHomerồiEnd). - Kéo thanh scrollbar từ trên xuống dưới thật nhanh, rồi kéo ngược lên thật nhanh.
- Trên thiết bị cảm ứng, vuốt cuộn mạnh và nhanh qua section đó, rồi cuộn ngược lại.
- Sau mỗi cú nhảy, kiểm tra mọi phần tử timeline điều khiển — không chỉ phần tử đang hiện
trong khung nhìn. Bất kỳ phần tử nào bị kẹt sai
autoAlpha,opacity,visibility, haydisplaylà dấu hiệu một callback cần chuyển thành tween.
Nếu bước 5 phát hiện vấn đề, grep timeline tìm .add(() => và .call(, rồi chuyển bất kỳ đoạn nào
đặt trạng thái hiển thị/animation sang .set() / .to() / .fromTo() ở cùng vị trí. Nếu một
callback side-effect thực sự không thể chuyển thành tween, hãy làm nó idempotent và đối chiếu lại
theo giá trị tiến độ onUpdate/onRefresh của chính ScrollTrigger, thay vì dựa vào hướng đi qua
— như vậy nó tự sửa đúng ở lần update kế tiếp bất kể nó tới đó bằng cách nào.
Quy tắc cho mọi timeline scrub
Đưa toàn bộ trạng thái scrub theo scroll qua các tween thật — không bao giờ qua một callback
bọc cùng lệnh gsap.set()/gsap.to(). Một tween là giá trị GSAP có thể tính lại từ tiến độ; một
callback là trigger một chiều chỉ bắn khi bị đi qua. Ngay khi tốc độ cuộn đủ nhanh để bỏ qua một
lần đi-qua — và trên một trang dài, người dùng sẽ cuộn nhanh tới mức đó — bất cứ thứ gì nằm trong
callback là một bug chờ lộ ra ở production, chứ không phải ở vòng QA của bạn.
Đây cùng họ với gotcha trong bài
hướng dẫn GSAP ScrollTrigger về pin, scrub và parallax —
nếu bạn đang dựng chính timeline scrub, bắt đầu từ đó để nắm cú pháp start/end và cách sửa lỗi
pin-jump. Nếu cái giật bạn đang săn nằm ở một video scrub theo scroll chứ không phải timeline,
lỗi gần như luôn nằm ở keyframe, không phải JS. Và nếu
cả trang cảm giác nặng dưới một timeline scrub, kiểm tra xem
Lenis và GSAP ScrollTrigger có đang dùng chung một driver scroll
hay không — hai vòng lặp scroll cạnh tranh nhau khiến bug kiểu nhảy này khó cô lập hơn, chứ không dễ hơn.
FAQ
Vì sao animation GSAP của tôi chỉ lỗi khi cuộn nhanh?
Vì bug nằm ở cách playhead tiến tới, không phải ở logic animation. Cuộn mượt cho playhead đi qua từng vị trí trung gian, nên bất cứ thứ gì gắn với "đi qua một điểm" (một callback) đều bắn đúng. Một cú nhảy nhanh bỏ qua hoàn toàn các vị trí đó, nên callback không bao giờ bắn trong khi các tween thật vẫn tính lại đúng theo thời gian tuyệt đối mới — chỉ để lại trạng thái do callback điều khiển là cũ.
Điều này có ảnh hưởng tới các lệnh gsap.set() nằm ngoài timeline không?
Không — một gsap.set() độc lập chạy một lần, ngay lập tức, đúng như bạn muốn cho một thay đổi
trạng thái một lần. Bug chỉ xảy ra với gsap.set() (hay bất kỳ code nào) được bọc trong
tl.add(callback)/tl.call() bên trong một timeline scrub, nơi GSAP cần tính lại trạng thái
từ một giá trị tiến độ bất kỳ và một callback không cho nó gì để tính lại.
tl.set() có chậm hơn callback không?
Không — tl.set() là một tween thời lượng bằng 0; chi phí render giống hệt gọi gsap.set() trực
tiếp. Bạn không thêm thời gian animation, bạn chỉ đang thay đổi cách GSAP theo dõi cùng một thay
đổi trạng thái tức thời để nó sống sót qua một cú seek.
Nếu bạn đang dựng một chuỗi kể chuyện scrub theo scroll và muốn nó được audit trước khi lỗi này lộ ra với người dùng thật — hoặc muốn cả hệ thống animation được xây đúng ngay từ đầu — liên hệ với tôi.


