Học từ web lớn #28: Kéo, vuốt chung cho chuột và cảm ứng (Telegram)
Telegram Web dùng một hàm cho mọi cử chỉ kéo, vuốt, bấm: ngưỡng 15/50px, bỏ vuốt chéo, khoá trục. Tái hiện hai lỗi khi chạm và viết lại bằng Pointer Events.
Trong Telegram Web, bạn vuốt ngang danh sách chat để chuyển giữa các thư mục, vuốt trái phải trong trình xem ảnh để sang ảnh khác, vuốt dọc để đóng ảnh, và kéo con trỏ trong bảng chọn màu nền. Tất cả phải chạy được bằng chuột trên máy tính lẫn ngón tay trên điện thoại, và phải phân biệt được ba ý định rất gần nhau:
- Bấm (tay hơi rung vài pixel vẫn là bấm),
- Kéo (đang di chuyển một thứ gì đó theo tay),
- Vuốt (một cú gạt theo một hướng rõ ràng).
Trình duyệt không có sự kiện "swipe". Telegram tự làm bằng một hàm khoảng 200 dòng, captureEvents, dùng chung cho mọi nơi. Hàm này có những con số rất đáng học, và cũng có hai lỗi kinh điển của thời "mouse + touch" mà Pointer Events đã giải quyết.
Tip
Tóm tắt
- Phân biệt bấm và kéo bằng ngưỡng di chuyển (Telegram: 15px). Vuốt khi đi đủ xa theo một trục (50px). Hai trục gần bằng nhau (tỉ lệ < 2) thì là vuốt chéo, bỏ qua.
- Khoá trục khi đã xác định, để cử chỉ ngang không "trôi" thành dọc giữa chừng.
- Nghe riêng
mousedownvàtouchstartthì một lần chạm phát cả hai chuỗi sự kiện, nênonClickbị gọi hai lần. Pointer Events gộp chuột, cảm ứng, bút vào một chuỗi sự kiện. - Dùng
setPointerCapturethay vì gắn listener lêndocument,touch-actionthay vìpreventDefault, và gọionSwipemột lần cho mỗi cử chỉ.
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
Kéo ngang khung ảnh để chuyển ảnh. Ở chế độ "Mouse + touch (kiểu bản gốc)", vuốt một lần bằng chuột nhảy qua nhiều ảnh, vì onSwipe được gọi ở mỗi lần di chuyển. Mở trang trên điện thoại (hoặc bật chế độ cảm ứng trong DevTools) và chạm một lần: bộ đếm onClick tăng 2.
#Telegram làm thế nào?
Code nằm ở web.telegram.org/src/util/captureEvents.ts. Nơi dùng: trình xem ảnh (MediaViewer.tsx), thanh thư mục chat (ChatFolders.tsx), các tab trong hồ sơ (Profile.tsx), bảng chọn màu (SettingsGeneralBackgroundColor.tsx).
#Hai ngưỡng và một tỉ lệ
const MOVED_THRESHOLD = 15;
const SWIPE_THRESHOLD = 50;
ts
- Di chuyển dưới 15px theo cả hai trục: khi thả tay vẫn là bấm (
onClick). Ngón tay chạm màn hình hiếm khi đứng yên tuyệt đối, ngưỡng này nuốt phần rung. - Đi quá 50px theo một trục: bắt đầu tính là vuốt theo trục đó.
- Vuốt chéo: nếu cả hai trục đều có độ lệch và trục dài chỉ gấp chưa tới 2 lần trục ngắn, bỏ qua lần đo này:
function onSwipe(e, dragOffsetX, dragOffsetY) {
if (!currentSwipeAxis) {
const xAbs = Math.abs(dragOffsetX), yAbs = Math.abs(dragOffsetY);
if (dragOffsetX && dragOffsetY) {
const ratio = Math.max(xAbs, yAbs) / Math.min(xAbs, yAbs);
if (ratio < 2) return; // Diagonal swipe
}
if (xAbs >= SWIPE_THRESHOLD) currentSwipeAxis = 'x';
else if (yAbs >= SWIPE_THRESHOLD) currentSwipeAxis = 'y';
}
processSwipe(e, currentSwipeAxis, dragOffsetX, dragOffsetY, options.onSwipe);
}
ts
Khi đã có currentSwipeAxis, trục được khoá tới khi thả tay. Kéo ngang được 60px rồi tay trôi xuống dưới cũng không đổi thành vuốt dọc.
#Hai họ sự kiện, một hàm
element.addEventListener('mousedown', onCapture);
element.addEventListener('touchstart', onCapture, { passive: true });
ts
onCapture gắn mousemove/mouseup hoặc touchmove/touchend/touchcancel lên document, để vẫn nhận được sự kiện khi chuột hay ngón tay đã ra ngoài phần tử. Vì TouchEvent không có pageX/pageY, Telegram tự gán từ touches[0] (comment dẫn một câu trả lời StackOverflow: Android không có giá trị này, iOS có nhưng chỉ đọc). Trong lúc kéo, body được thêm class no-selection (và cursor-grabbing nếu cần) để chữ không bị bôi đen.
#Những chỗ cần biết khi mang về
Mình viết lại logic này trong demo (file GesturesLegacy.ts, ghi rõ là mô phỏng) và chạy bằng Playwright với chuột lẫn cảm ứng giả lập:
- Chạm một lần,
onClickchạy hai lần. Chạm màn hình pháttouchstart→touchend, rồi trình duyệt phát thêm các sự kiện chuột tương thích (mousedown→mouseup→click) cho các trang chỉ biết chuột.touchstartđược gắn vớipassive: truenên không gọi đượcpreventDefault()để chặn các sự kiện chuột này. Kết quả đo được: một lần chạm, log ghionClick (touchend)rồionClick (mouseup). Telegram có lẽ đã biết: trình xem ảnh chỉ đóng khi bấm nếu!IS_TOUCH_ENV. onSwipeđược gọi ở mọi lần di chuyển sau khi đã khoá trục, không phải một lần. Một cú vuốt bằng chuột trong demo gọionSwipe10 lần. Code của Telegram vẫn đúng vì nơi gọi viết theo kiểu idempotent:setActiveChatFolder(Math.min(activeChatFolder + 1, …))vớiactiveChatFolderlà giá trị cũ bị giữ trong closure, nên gọi 10 lần vẫn chỉ tăng 1. Demo đọc giá trị mới qua ref thay vì closure, và nhảy qua 3 ảnh.preventDefault()trongtouchmovecó thể vô tác dụng: listenertouchmovegắn lêndocumentmà không khai báopassive: false. Chrome coi listener chạm ở cấp document là passive theo mặc định, và bỏ quapreventDefault()trong đó. Bảng chọn màu của Telegram vẫn kéo được trên điện thoại vì CSS của nó cótouch-action: none(.color-picker{…touch-action:none}). Chính CSS mới là thứ ngăn trang cuộn theo ngón tay.- Hàm dọn dẹp chỉ gỡ
mousedown/touchstart. Nếu component unmount giữa lúc đang kéo, các listener trêndocumentvẫn còn tới khi thả tay.
#Bên trong trình duyệt
sequenceDiagram participant F as Ngón tay participant B as Trình duyệt participant L as "Listener kiểu cũ" F->>B: chạm và nhấc lên B->>L: touchstart, touchend L->>L: "onClick #1" Note over B: sự kiện chuột tương thích cho trang cũ B->>L: mousedown, mouseup L->>L: "onClick #2" B->>L: click
- Pointer Events phát một chuỗi
pointerdown→pointermove→pointerupcho chuột, ngón tay và bút, cópointerTypeđể phân biệt vàisPrimaryđể bỏ qua ngón thứ hai. Nghepointer*thì không còn chuyện chạm một lần bị đếm hai lần. setPointerCapture(pointerId)buộc mọi sự kiện của con trỏ đó gửi về phần tử, kể cả khi con trỏ đã ra ngoài, cho tới khi thả. Không cần gắn listener lêndocument.touch-actioncho trình duyệt biết trước cử chỉ nào nó được tự xử lý.pan-y: trình duyệt vẫn cuộn dọc, cử chỉ ngang để cho trang. Nếu trình duyệt bắt đầu cuộn, trang nhậnpointercancelvà không nhận thêmpointermovenữa.clicksau khi kéo: nhấn và thả trên cùng một phần tử thì trình duyệt vẫn phátclick, dù ở giữa đã kéo 200px. Carousel có link bên trong sẽ mở link sau mỗi lần kéo nếu không chặn.- Link và ảnh mặc định kéo được (HTML drag and drop). Bấm giữ vào link rồi kéo sẽ bắt đầu kéo-thả của trình duyệt thay vì cử chỉ của bạn. Demo phải đặt
draggable={false}cho link "Xem chi tiết" mới đo được hành vi click.
#Viết lại
captureGestures(element, options) giữ ngưỡng 15/50px và tỉ lệ 2 của Telegram, chuyển sang Pointer Events:
src/lib/captureGestures.ts
const onDown = (e: PointerEvent) => {
if (!e.isPrimary || pointerId !== null) return; // bỏ ngón thứ hai, chuột phải…
if (e.pointerType === 'mouse' && e.button !== 0) return;
if (options.exclude && (e.target as Element).closest(options.exclude)) return;
pointerId = e.pointerId;
startX = e.clientX;
startY = e.clientY;
moved = false;
swiped = false;
lockedAxis = undefined;
// mọi pointermove/pointerup sau đó đều về element này, kể cả khi con trỏ đã ra ngoài
// (thay cho việc gắn listener lên document như bản gốc)
element.setPointerCapture(e.pointerId);
gesture = new AbortController();
const signal = gesture.signal;
element.addEventListener('pointermove', onMove, { signal });
element.addEventListener('pointerup', onUp, { signal });
element.addEventListener('pointercancel', onCancel, { signal });
element.addEventListener('lostpointercapture', onCancel, { signal });
document.body.classList.add('no-selection');
options.onStart?.(e);
};
ts
Di chuyển: khoá trục, và chỉ gọi onSwipe một lần:
src/lib/captureGestures.ts
const onMove = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!moved && (Math.abs(dx) >= movedThreshold || Math.abs(dy) >= movedThreshold)) moved = true;
lockedAxis ??= decideAxis(dx, dy);
options.onDrag?.(e, { dx, dy, axis: lockedAxis });
if (swiped || !lockedAxis || !options.onSwipe) return;
if (allowed !== 'both' && lockedAxis !== allowed) return;
const offset = lockedAxis === 'x' ? dx : dy;
if (Math.abs(offset) < swipeThreshold) return;
swiped = true;
options.onSwipe(lockedAxis === 'x' ? (offset < 0 ? 'left' : 'right') : offset < 0 ? 'up' : 'down', e);
};
ts
Thả tay: chặn đúng một click nếu vừa kéo:
src/lib/captureGestures.ts
const onUp = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return;
const info = { dx: e.clientX - startX, dy: e.clientY - startY, axis: lockedAxis };
const wasMoved = moved;
end();
if (wasMoved) {
options.onRelease?.(e, info);
// trình duyệt vẫn phát `click` sau khi kéo nếu nhấn và thả trên cùng phần tử:
// chặn đúng một click đó để link/nút bên trong không bị kích hoạt nhầm
const swallow = (click: MouseEvent) => {
click.preventDefault();
click.stopPropagation();
};
element.addEventListener('click', swallow, { capture: true, once: true });
setTimeout(() => element.removeEventListener('click', swallow, { capture: true }), 0);
} else {
options.onClick?.(e);
}
};
ts
#Đi qua code
touch-actiontheo trục:axis: 'x'đặtpan-y, trang vẫn cuộn dọc bình thường;'both'đặtnone. Giá trị cũ được trả lại khi huỷ.- Chỉ nhận con trỏ chính, nút chuột trái, và một cử chỉ tại một thời điểm (
pointerId !== nullthì bỏ qua). setPointerCapturerồi gắnpointermove/pointerup/pointercancel/lostpointercapturelên chính phần tử, qua mộtAbortController. Kết thúc cử chỉ chỉ cầnabort().- Khoá trục ở 15px thay vì 50px như Telegram:
onDragbiết sớm cử chỉ là ngang hay dọc (demo dùng để không kéo ảnh khi tay đang cuộn dọc).onSwipevẫn đợi đủ 50px theo trục đã khoá. swipedđảm bảoonSwipechạy một lần mỗi cử chỉ, nên nơi gọi không phải viết kiểu idempotent.pointercancelvàlostpointercapturekết thúc cử chỉ mà không gọionClick: trình duyệt đã giành cử chỉ (cuộn, zoom) thì không coi là bấm.- Chặn
clicksau khi kéo bằng một listener capture chỉ chạy một lần, tự gỡ ở tick sau. - Huỷ giữa chừng: hàm dọn dẹp gọi
end(), nên unmount lúc đang kéo cũng gỡ hết listener.
Khác bản gốc: Pointer Events thay cho mouse + touch; capture thay cho listener trên document; touch-action thay cho preventDefault; khoá trục sớm hơn; onSwipe một lần; chặn click sau khi kéo; thêm pointercancel. Giữ nguyên ngưỡng 15px, 50px và tỉ lệ chéo 2.
Xem toàn bộ src/lib/captureGestures.ts
src/lib/captureGestures.ts
/**
* Kéo, vuốt, bấm dùng chung cho chuột, cảm ứng và bút (ý tưởng từ util/captureEvents.ts của
* Telegram Web), viết lại bằng Pointer Events + setPointerCapture:
* - phân biệt bấm và kéo bằng ngưỡng di chuyển (Telegram: 15px),
* - vuốt khi đi đủ xa theo một trục (Telegram: 50px), bỏ qua vuốt chéo (tỉ lệ 2 trục < 2),
* - khoá trục sau khi đã xác định để cử chỉ không "trôi" sang trục kia.
*/
export type SwipeDirection = 'left' | 'right' | 'up' | 'down';
export type Axis = 'x' | 'y';
export interface DragInfo {
dx: number;
dy: number;
/** trục đã khoá (undefined khi chưa đủ xa hoặc đang đi chéo) */
axis?: Axis;
}
export interface GestureOptions {
/** trục được phép vuốt. 'x': trình duyệt vẫn tự cuộn dọc (touch-action: pan-y) */
axis?: Axis | 'both';
/** di chuyển ít hơn chừng này px thì vẫn tính là bấm */
movedThreshold?: number;
/** đi xa chừng này px theo trục đã khoá thì tính là vuốt */
swipeThreshold?: number;
/** trục dài phải gấp ít nhất chừng này lần trục ngắn, nếu không coi là vuốt chéo */
diagonalRatio?: number;
/** bỏ qua cử chỉ bắt đầu trong các phần tử khớp selector (nút, thanh điều khiển…) */
exclude?: string;
onStart?: (e: PointerEvent) => void;
onDrag?: (e: PointerEvent, info: DragInfo) => void;
/** gọi MỘT lần cho mỗi cử chỉ (bản Telegram gọi lại ở mỗi lần di chuyển) */
onSwipe?: (direction: SwipeDirection, e: PointerEvent) => void;
/** thả tay sau khi đã kéo */
onRelease?: (e: PointerEvent, info: DragInfo) => void;
/** bấm (không kéo) bằng nút chính */
onClick?: (e: PointerEvent) => void;
}
export function captureGestures(element: HTMLElement, options: GestureOptions): () => void {
const { axis: allowed = 'both', movedThreshold = 15, swipeThreshold = 50, diagonalRatio = 2 } = options;
// Không có touch-action thì trên màn cảm ứng trình duyệt sẽ giành cử chỉ để cuộn/zoom và
// phát pointercancel giữa chừng. Chỉ nhường lại trục mà ta KHÔNG xử lý.
const previousTouchAction = element.style.touchAction;
element.style.touchAction = allowed === 'x' ? 'pan-y' : allowed === 'y' ? 'pan-x' : 'none';
let pointerId: number | null = null;
let startX = 0;
let startY = 0;
let moved = false;
let lockedAxis: Axis | undefined;
let swiped = false;
let gesture: AbortController | null = null;
const end = () => {
gesture?.abort(); // gỡ hết listener của cử chỉ hiện tại trong một lần
gesture = null;
pointerId = null;
document.body.classList.remove('no-selection');
};
const decideAxis = (dx: number, dy: number): Axis | undefined => {
const ax = Math.abs(dx);
const ay = Math.abs(dy);
if (ax && ay && Math.max(ax, ay) / Math.min(ax, ay) < diagonalRatio) return undefined; // vuốt chéo
if (ax >= movedThreshold && ax > ay) return 'x';
if (ay >= movedThreshold && ay > ax) return 'y';
return undefined;
};
const onMove = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!moved && (Math.abs(dx) >= movedThreshold || Math.abs(dy) >= movedThreshold)) moved = true;
lockedAxis ??= decideAxis(dx, dy);
options.onDrag?.(e, { dx, dy, axis: lockedAxis });
if (swiped || !lockedAxis || !options.onSwipe) return;
if (allowed !== 'both' && lockedAxis !== allowed) return;
const offset = lockedAxis === 'x' ? dx : dy;
if (Math.abs(offset) < swipeThreshold) return;
swiped = true;
options.onSwipe(lockedAxis === 'x' ? (offset < 0 ? 'left' : 'right') : offset < 0 ? 'up' : 'down', e);
};
const onUp = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return;
const info = { dx: e.clientX - startX, dy: e.clientY - startY, axis: lockedAxis };
const wasMoved = moved;
end();
if (wasMoved) {
options.onRelease?.(e, info);
// trình duyệt vẫn phát `click` sau khi kéo nếu nhấn và thả trên cùng phần tử:
// chặn đúng một click đó để link/nút bên trong không bị kích hoạt nhầm
const swallow = (click: MouseEvent) => {
click.preventDefault();
click.stopPropagation();
};
element.addEventListener('click', swallow, { capture: true, once: true });
setTimeout(() => element.removeEventListener('click', swallow, { capture: true }), 0);
} else {
options.onClick?.(e);
}
};
const onCancel = (e: PointerEvent) => {
// trình duyệt giành cử chỉ (cuộn trang, zoom) hoặc mất capture: huỷ, không coi là bấm
if (e.pointerId === pointerId) end();
};
const onDown = (e: PointerEvent) => {
if (!e.isPrimary || pointerId !== null) return; // bỏ ngón thứ hai, chuột phải…
if (e.pointerType === 'mouse' && e.button !== 0) return;
if (options.exclude && (e.target as Element).closest(options.exclude)) return;
pointerId = e.pointerId;
startX = e.clientX;
startY = e.clientY;
moved = false;
swiped = false;
lockedAxis = undefined;
// mọi pointermove/pointerup sau đó đều về element này, kể cả khi con trỏ đã ra ngoài
// (thay cho việc gắn listener lên document như bản gốc)
element.setPointerCapture(e.pointerId);
gesture = new AbortController();
const signal = gesture.signal;
element.addEventListener('pointermove', onMove, { signal });
element.addEventListener('pointerup', onUp, { signal });
element.addEventListener('pointercancel', onCancel, { signal });
element.addEventListener('lostpointercapture', onCancel, { signal });
document.body.classList.add('no-selection');
options.onStart?.(e);
};
element.addEventListener('pointerdown', onDown);
return () => {
element.removeEventListener('pointerdown', onDown);
end(); // khác bản gốc: huỷ luôn cử chỉ đang dở khi component unmount
element.style.touchAction = previousTouchAction;
};
}
ts
#Tại sao không làm cách khác?
Nghe cả touch* lẫn mouse* nhưng gọi preventDefault() trong touchend để chặn sự kiện chuột tương thích? Làm được nếu listener không passive, nhưng nó cũng chặn luôn click thật và focus của phần tử bên dưới, và bạn vẫn phải duy trì hai đường xử lý.
Dùng thư viện cử chỉ (Hammer.js, @use-gesture)? Hợp lý khi cần pinch, xoay, quán tính. Với kéo và vuốt một trục như ở đây, 140 dòng là đủ và không thêm phụ thuộc.
Tính vuốt theo vận tốc thay vì quãng đường? Cú gạt nhanh 30px cũng là vuốt trên điện thoại thật. Có thể thêm: đo thời gian từ pointerdown, nếu vận tốc lúc thả lớn hơn khoảng 0.5px/ms thì tính là vuốt. Telegram chỉ dùng quãng đường.
#Edge case và cạm bẫy
- Cuộn trang bị chặn:
touch-action: nonetrên một khung lớn làm người dùng không cuộn được trang khi đặt tay vào đó. Chỉ chặn trục bạn thật sự xử lý. - Hai ngón tay:
isPrimaryloại ngón thứ hai, nhưng pinch-zoom của trình duyệt vẫn có thể bắt đầu và phátpointercancelcho ngón thứ nhất. Xử lýpointercancelnhư một lần huỷ. - Phần tử con có nút bấm: dùng
exclude(Telegram:excludedClosestSelector) để cử chỉ không bắt đầu từ thanh điều khiển, nút đóng… - Chữ bị bôi đen khi kéo: thêm
user-select: nonelúc kéo (classno-selectionnhư Telegram). - Safari trên iOS đã hỗ trợ Pointer Events từ lâu, nhưng thao tác vuốt từ mép màn hình là cử chỉ quay lại của hệ điều hành. Đừng đặt vùng vuốt sát mép trái nếu không muốn tranh chấp.
- Click sau khi kéo trên link: ngoài việc chặn
click, nhớdraggable="false"cho link và ảnh trong vùng kéo.
#Dùng trong dự án
useEffect(
() =>
captureGestures(trackRef.current!, {
axis: 'x',
exclude: 'button, [data-no-swipe]',
onDrag: (_, { dx, axis }) => setOffset(axis === 'x' ? dx : 0),
onSwipe: (dir) => setIndex((i) => (dir === 'left' ? Math.min(i + 1, last) : Math.max(i - 1, 0))),
onRelease: () => setOffset(0),
onClick: () => openLightbox(),
}),
[last],
);
tsx
Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it, vi } from 'vitest';
import { captureGestures } from './captureGestures';
// jsdom chưa có PointerEvent/setPointerCapture: tự tạo sự kiện tối thiểu
function pointer(type: string, x: number, y: number) {
const e = new MouseEvent(type, { clientX: x, clientY: y, button: 0, bubbles: true });
Object.assign(e, { pointerId: 1, isPrimary: true, pointerType: 'touch' });
return e;
}
function setup(options: Parameters<typeof captureGestures>[1]) {
const el = document.createElement('div');
el.setPointerCapture = vi.fn();
document.body.append(el);
captureGestures(el, options);
return el;
}
describe('captureGestures', () => {
it('chạm không di chuyển là bấm, gọi onClick đúng một lần', () => {
const onClick = vi.fn();
const el = setup({ onClick });
el.dispatchEvent(pointer('pointerdown', 10, 10));
el.dispatchEvent(pointer('pointerup', 14, 12));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('vuốt ngang gọi onSwipe một lần dù di chuyển nhiều lần', () => {
const onSwipe = vi.fn();
const el = setup({ onSwipe, axis: 'x' });
el.dispatchEvent(pointer('pointerdown', 200, 10));
for (let x = 190; x >= 60; x -= 10) el.dispatchEvent(pointer('pointermove', x, 12));
el.dispatchEvent(pointer('pointerup', 60, 12));
expect(onSwipe).toHaveBeenCalledTimes(1);
expect(onSwipe.mock.calls[0][0]).toBe('left');
});
it('bỏ qua vuốt chéo', () => {
const onSwipe = vi.fn();
const el = setup({ onSwipe });
el.dispatchEvent(pointer('pointerdown', 0, 0));
for (let d = 10; d <= 100; d += 10) el.dispatchEvent(pointer('pointermove', d, d));
expect(onSwipe).not.toHaveBeenCalled();
});
it('pointercancel không gọi onClick', () => {
const onClick = vi.fn();
const el = setup({ onClick });
el.dispatchEvent(pointer('pointerdown', 10, 10));
el.dispatchEvent(pointer('pointercancel', 10, 10));
el.dispatchEvent(pointer('pointerup', 10, 10));
expect(onClick).not.toHaveBeenCalled();
});
});
ts
#So với thư viện và chuẩn web
- Pointer Events có trên mọi trình duyệt lớn (tính tới 10/2026). Đây là lựa chọn mặc định cho cử chỉ mới;
touch*chỉ cần khi bạn cần dữ liệu riêng của Touch Events nhưtouchesvới nhiều điểm chạm. - @use-gesture/react cung cấp
useDragvớiswipe(theo vận tốc và quãng đường),axisđể khoá trục,filterTapsđể phân biệt bấm. Các khái niệm trùng gần hết với Telegram. - CSS scroll snap (
scroll-snap-type: x mandatory) làm carousel vuốt được mà không cần JavaScript, kèm quán tính tự nhiên của hệ điều hành. Nếu chỉ cần vuốt qua lại giữa các trang, hãy thử cách này trước.
Bài này thuộc series Học từ mã nguồn các web lớn (65 bài, đọc theo thứ tự).
Xem nhanh danh sách bài trong series
Mở đầu
Phần 1: trải nghiệm người dùng
- #1 Dark mode không nháy (Gatsby)
- #2 useValueByMedia: giá trị theo breakpoint (Toptal)
- #3 Đoán ý định: idle, exit-intent, lắc chuột (Toptal, InVision)
- #4 Validate form thân thiện (Udacity, Pipedrive)
- #5 Magic number, EXIF và dán ảnh (Ello)
- #6 fastSmoothScroll: cuộn xa vẫn nhanh (Telegram)
- #7 Countdown time-travel và tombstone giỏ hàng (Best Buy)
Phần 2: hiệu năng và đo lường
- #8 Hàng đợi requestAnimationFrame 3ms (Apple)
- #9 srcset cho artwork và preconnect tự động (Apple)
- #10 Quick-fetch: gọi API trước khi bundle tải xong (HubSpot)
- #11 Performance span và long task (Postman, Calibre)
- #12 Gọi Web Worker như hàm async (Telegram)
- #13 Đồng hồ media: đếm giờ xem và sửa lệch (Skillshare, Simplified)
Phần 3: nhiều tab, phiên đăng nhập, cập nhật
- #14 Bầu tab chủ cho thông báo (HubSpot, Telegram)
- #15 Đồng bộ đăng nhập giữa tab và giới hạn cookie (Elastic, InVision)
- #16 SPA tự cập nhật: Trello, Zappos, Facebook, Elastic
Phần 4: kiến trúc
- #17 Reverse portal: di chuyển component không remount (Zappos)
- #18 Page behaviors: gắn hành vi từ CMS (Intercom)
- #19 Giới hạn tần suất hiển thị bằng bitmask (Outlook)
- #20 Chuyển REST sang GraphQL dần dần (Coursera)
Phần 5: soạn thảo và hiển thị nội dung
- #21 Autocomplete tại con trỏ trong contenteditable (Ello, Telegram)
- #22 Dark mode cho email HTML bằng CIELAB (Outlook)
- #23 Thu nhỏ bảng rộng cho vừa khung, vá font emoji (Outlook)
- #24 Cảnh báo quên đính kèm file (Outlook)
- #25 Cắt chữ nhiều dòng chính xác (Pipedrive, Apple, Toptal)
- #26 Parse ngày giờ gõ tay khoan dung (Outlook, Elastic)
Phần 6: tương tác
- #27 menuAim: rê chéo vào submenu không bị đóng (Udemy)
- #28 Kéo, vuốt chung cho chuột và cảm ứng (Telegram) (bài này)
- #29 Phím tắt theo phạm vi, IME và bàn phím phi Latinh (Trello)
- #30 Nội suy màu: đổi theme và nền theo cuộn (Telegram, Anchor)
- #31 Đường gióng hút cạnh và undo/redo trên canvas (Simplified)
- #32 Giả lập mù màu bằng feColorMatrix (Trello)
- #33 Vạch thước theo mức zoom và cấp phát màu cho biểu đồ (Anchor, Similarweb)
Phần 7: mạng, dữ liệu và async
- #34 Ô tìm kiếm chuẩn production (Similarweb, Udemy, Pipedrive)
- #35 Điều phối async: latest-wins, single-flight (Glitch, Toptal)
- #36 Polling thưa dần và debounce động (Trello)
- #37 Cache phía client theo version (Similarweb, Telegram, Udemy)
- #38 Upload file lớn theo chunk (Outlook, Tribe)
- #39 Phát video qua Service Worker bằng Range request (Telegram, Outlook)
Phần 8: hiệu năng và vận hành
- #40 TTI governor: hoãn việc phụ tới sau khi trang dùng được (Outlook)
- #41 Tự đo LCP/CLS/TBT và phục vụ bản nhẹ cho máy yếu (Udemy)
Phần 9: thuật toán và dữ liệu phía client
- #42 Three-way merge ngay trên trình duyệt (Postman)
- #43 Dọn cache GraphQL bằng đếm tham chiếu (Outlook)
- #44 LRU cache theo 3 cách (Apple, Similarweb, Outlook)
- #45 Lọc tăng dần trong Web Worker (Similarweb)
- #46 Xếp hạng gợi ý trên client, so ngầm với server (Outlook)
- #47 Nối OT với editor: undo stack và chặn echo (Glitch)
- #48 Ngôn ngữ markup riêng dịch hai chiều với HTML (Coursera)
Phần 10: thời gian, ngôn ngữ, định dạng
- #49 Xử lý giờ mùa hè không cần thư viện (Udemy, Pipedrive)
- #50 Intl thực chiến: Segmenter, số rút gọn, số nhiều, fallback locale (Apple, Outlook)
- #51 WebVTT và bôi chọn transcript (Udemy, Coursera)
Phần 11: tương tác và giao diện
- #52 Kéo-thả trên lịch (Pipedrive)
- #53 Focus stack cho popup lồng nhau (Outlook)
- #54 Clipboard: copy, dán chữ thuần, dán thông minh (Trello, Anchor, Pipedrive, Tribe)
- #55 Khôi phục vị trí cuộn khi trang còn đang tải (Apple, Zappos)
- #56 Lấy màu từ ảnh: gradient nền, màu chữ (Apple, Telegram, Trello)
- #57 JS đọc breakpoint từ CSS, phát hiện chế độ PWA (Ello, Apple)
Phần 12: media
- #58 Tin nhắn thoại: ghi âm, waveform realtime, nén 5-bit (Telegram)
- #59 Chụp frame video thành ảnh, canvas bị taint (Coursera, Outlook)
Phần 13: đo lường, bảo mật, vận hành
- #60 Đo lúc ảnh màn hình đầu tải xong, cả khi chuyển trang SPA (Zappos, Best Buy, Outlook)
- #61 Buffer analytics chờ SDK, phát hiện adblock (Best Buy)
- #62 Proof-of-work trên client để chặn API ẩn danh (Udemy)
- #63 Prefetch không làm lệch số liệu, nén cài đặt bằng bit (Outlook)
- #64 Micro-frontend: AngularJS và React chung app, giao tiếp xuyên frame (Similarweb, InVision, HubSpot)
Code + demo: examples/web-patterns · Mở trên StackBlitz

