Học từ web lớn #35: Điều phối async kiểu latest-wins, single-flight (Glitch, Toptal)
Bốn công cụ nhỏ từ Glitch và Toptal: chỉ chạy lệnh mới nhất, gộp request trùng, chạy đúng một lần khi hết giờ, hàng đợi lệnh cho script bên thứ ba, kèm lỗi của bản gốc.
Phần lớn lỗi "lúc được lúc không" trên frontend không nằm trong một hàm async, mà nằm giữa các lời gọi async: gọi chồng lên nhau, gọi trùng, chờ một thứ không bao giờ tới, hoặc gọi trước khi bên kia sẵn sàng. Bốn tình huống quen thuộc:
- Slider điều khiển server. Kéo thanh thời gian của trình xem lịch sử: chuột bắn 60 sự kiện mỗi giây, mỗi lần "nhảy tới phiên bản X" mất 400ms. Đợi tuần tự thì sau khi thả chuột màn hình còn chạy thêm 20 giây.
- Năm component cùng cần một URL. Mỗi cái tự
fetchlà năm request giống hệt nhau. - Chờ analytics rồi mới chuyển trang. Bấm link, gửi sự kiện cho Google Analytics, đợi callback xác nhận rồi mới đổi
location. Adblock chặn GA thì callback không bao giờ tới, và link "chết". - Script bên thứ ba chạy trước app. Snippet A/B test muốn gọi GA ngay khi GA sẵn sàng, nhưng nó tải trước bundle của app.
Glitch giải bài 1 bằng 30 dòng tên track-serially. Trang chủ của Toptal giải ba bài còn lại bằng ba file nhỏ trong lib/. Cả bốn đều đáng chép, và đều có một chỗ nên sửa.
Tip
Tóm tắt
- latestWins: chỉ chạy một việc một lúc, và chỉ giữ một lệnh chờ (lệnh mới nhất). Lệnh ở giữa bị bỏ, không bao giờ dồn hàng đợi.
- singleFlight: gom các lời gọi cùng key vào một promise. Đừng cache lỗi, nếu không lần lỗi đầu tiên sẽ "dính" tới khi tải lại trang.
- withTimeout: callback chạy đúng một lần, khi được gọi hoặc khi hết giờ, cái nào tới trước. Luôn có đường thoát khi phụ thuộc vào thứ có thể bị chặn.
- Hàng đợi lệnh kiểu
window.x = window.x || []; x.push(cb): app nâng cấp mảng đó thành object cópushchạy ngay khi đã sẵn sàng.
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
Thử thẻ 1 với "Server lỗi ở mỗi lần lưu thứ 3": bấm "Kéo nhanh 0 → 100" ở cả ba chế độ. Hàng đợi tuần tự xếp hàng hàng chục lệnh. track-serially gốc dừng ở khoảng 48, không bao giờ lưu giá trị 100. latestWins chạy 4 lần (1 lần lỗi) và dừng đúng ở 100.
#Glitch làm thế nào?
glitch.com/source/utils/track-serially.js, dùng trong source/models/rewind.js cho tính năng Rewind (kéo thanh thời gian để xem project ở các phiên bản cũ):
module.exports = function trackSerially(f, errorHandler) {
async function run() {
try {
while (answer.pending !== null) {
const temp = answer.pending;
answer.pending = null;
await f(...temp);
}
} finally {
answer.idle = true;
}
}
const answer = Object.assign(
(...args) => {
if (answer.idle) {
answer.pending = args;
answer.idle = false;
run().catch(errorHandler); // fire-and-forget
} else {
answer.pending = args; // ghi đè lệnh đang chờ
}
},
{ idle: true, pending: null },
);
return answer;
};
js
(Đã bỏ các dòng eslint-disable.) Comment đầu file gọi nó là một dạng Actor: "nguồn sự kiện nhanh (chuột) điều khiển một server chậm và ngoan (RPC), và ta muốn nghe theo nguồn nhanh hết mức có thể mà không để hàng đợi lệnh tích tụ".
Ba trạng thái:
- Rảnh: gọi
g(x)thì chạyf(x)ngay. - Đang chạy, chưa có lệnh chờ: gọi
g(y)thì cấtyvàopending. - Đang chạy, đã có lệnh chờ: gọi
g(z)thì ghi đèpending = z,ybị bỏ.
Khi f(x) xong, vòng while lấy pending (lúc này là z, giá trị mới nhất) chạy tiếp. Kéo slider qua 50 giá trị trong 1 giây chỉ tạo khoảng 3 lần chạy: giá trị đầu, một giá trị ở giữa, và giá trị cuối.
#Lỗi: lệnh cuối cùng bị kẹt
Nếu f ném lỗi, await ném ra khỏi vòng while, finally đặt idle = true, rồi run() reject và errorHandler được gọi. Nhưng answer.pending không bị xoá. Lệnh mới nhất (thường chính là vị trí cuối cùng người dùng thả chuột) nằm đó cho tới khi người dùng thao tác thêm lần nữa. Comment trong file nói "lỗi làm Actor về trạng thái rảnh, nhưng không làm hỏng nó vĩnh viễn"; đúng, nhưng lệnh đang chờ thì bị bỏ quên. Demo ở trên tái hiện được: giá trị đã lưu dừng ở khoảng 48 trong khi UI hiện 100.
#Toptal làm thế nào?
#single-flight: một request cho nhiều component
toptal.com-home/lib/fetch-json-cache.js:
export const memoizedFetch = memoize((url) => {
const cachedQuizResultState = new FetchJSONWithCache();
cachedQuizResultState.fetch(url);
return cachedQuizResultState;
});
class FetchJSONWithCache {
subscribe(listener) {
if (this.status === FetchStatus.SUCCESS || this.status === FetchStatus.ERROR) listener(this.value, this.status);
else this.listeners.push(listener);
}
// fetch(): chỉ chạy khi status là INITIAL; xong thì _broadcast() cho mọi listener rồi xoá danh sách
}
js
memoize của lodash giữ một instance cho mỗi URL, mãi mãi. Comment trong file ghi rõ việc còn thiếu: "ToDo: Add retry support (now a failed request will remain in such state)". Request lỗi thì instance nằm ở trạng thái ERROR tới khi tải lại trang. Nơi dùng là lib/hooks/use-quiz-result-state.js, gọi subscribe trong useEffect nhưng không có cách huỷ đăng ký: component unmount trước khi request xong vẫn bị gọi setState.
#withTimeout: chạy đúng một lần
toptal.com-home/lib/with-timeout.js:
const withTimeout = (callback, timeout = 1000) => {
let called = false;
const fn = () => {
if (called) return;
called = true;
callback();
};
setTimeout(fn, timeout);
return fn;
};
js
Dùng trong components/PageTemplate/interactive-components.js cho link có gắn tracking:
trackGAEvent(gaCategory, gaEvent, gaLabel, {
transport: 'beacon',
event_callback: shouldPreventDefault && withTimeout(() => (window.location = href)),
});
shouldPreventDefault && e.preventDefault();
js
Chặn chuyển trang mặc định, gửi sự kiện GA, chuyển trang khi GA gọi event_callback, hoặc sau 1 giây nếu GA không gọi (bị adblock chặn, mạng chậm). Tài liệu analytics.js cũ của Google có hẳn một mục về chuyện này (theo dõi link ra ngoài bằng hitCallback, kèm cảnh báo callback có thể không bao giờ chạy). Thiếu phần timeout là lỗi kinh điển làm link "chết" với người dùng có adblock.
#exposeCallbacks: hàng đợi lệnh cho script bên thứ ba
toptal.com-home/lib/expose-callbacks.js:
export default function exposeCallbacks(name) {
if (!window[name]) window[name] = new CallbacksCollection();
else if (Array.isArray(window[name])) {
const callbacks = window[name];
window[name] = new CallbacksCollection();
callbacks.forEach((cb) => window[name].push(cb));
}
return window[name];
}
js
CallbacksCollection có push(cb) (đã trigger thì gọi cb ngay với tham số đã lưu, chưa thì xếp hàng) và trigger(...args) (chỉ được gọi một lần). Script bên thứ ba (comment trong file lấy ví dụ snippet Optimizely) viết:
window._runWhenGoogleAnalyticsReady = window._runWhenGoogleAnalyticsReady || [];
window._runWhenGoogleAnalyticsReady.push(() => gtag('config', /* ... */));
js
Chạy trước app thì nó tạo mảng thường và push vào đó. App tải xong gọi exposeCallbacks('_runWhenGoogleAnalyticsReady'): mảng được "nâng cấp" thành collection, các callback cũ được chuyển sang. GA sẵn sàng thì vendors/google-analytics.js gọi trigger(). Callback push sau đó chạy ngay. Đây đúng là cách dataLayer của Google Tag Manager và _paq của Matomo hoạt động.
Một điểm nhỏ: trigger gọi lần lượt các callback trong forEach không có try/catch. Một callback của bên thứ ba ném lỗi thì các callback sau nó không chạy.
#Bên trong: bốn hình dạng của việc chờ
sequenceDiagram participant U as Slider participant L as latestWins participant S as Server 400ms U->>L: 0 L->>S: lưu 0 U->>L: 2, 4, 6 ... 48 Note over L: chỉ giữ 48, bỏ 2..46 S-->>L: xong L->>S: lưu 48 U->>L: 50 ... 100 Note over L: chỉ giữ 100 S-->>L: lỗi Note over L: bản gốc dừng ở đây, 100 bị kẹt L->>S: lưu 100 (bản viết lại chạy tiếp) S-->>L: xong
Bốn công cụ trả lời bốn câu hỏi khác nhau:
| Công cụ | Câu hỏi | Lệnh trùng/thừa thì |
|---|---|---|
| latestWins | Nhiều lệnh khác nhau, chỉ cái cuối quan trọng | bỏ lệnh ở giữa |
| singleFlight | Nhiều lời gọi giống nhau cùng lúc | dùng chung một promise |
| withTimeout | Một việc có thể không bao giờ được xác nhận | tự chạy khi hết giờ |
| Hàng đợi lệnh | Người gọi tới trước khi bên kia sẵn sàng | xếp hàng, phát lại khi sẵn sàng |
#Viết lại
#latestWins
src/lib/asyncCoord.ts
async function loop() {
running = true;
while (pending !== null) {
const args = pending;
pending = null;
try {
await task(...args);
} catch (error) {
// Khác Glitch: lỗi chỉ bỏ qua lệnh hiện tại. Bản gốc thoát vòng lặp khi có lỗi,
// lệnh mới nhất đang chờ nằm im cho tới khi người dùng thao tác thêm lần nữa.
onError(error);
}
}
running = false;
idleWaiters.splice(0).forEach((resolve) => resolve());
}
const run = ((...args: A) => {
pending = args; // ghi đè lệnh đang chờ (nếu có)
if (!running) void loop();
}) as LatestWins<A>;
ts
Khác bản gốc: try/catch nằm trong vòng lặp, nên lỗi chỉ bỏ qua lệnh hiện tại và vòng lặp chạy tiếp với lệnh mới nhất. Có thêm whenIdle() để chờ tới khi lệnh cuối chạy xong (ví dụ nút "Lưu và đóng", hoặc trong test).
#singleFlight
src/lib/asyncCoord.ts
get(key: K): Promise<V> {
const entry = entries.get(key);
const fresh = entry && (entry.settledAt === undefined || Date.now() - entry.settledAt < ttl);
if (entry && fresh) return entry.promise;
const promise = load(key).then(
(value) => {
const current = entries.get(key);
if (current?.promise === promise) current.settledAt = Date.now();
return value;
},
(error) => {
// lỗi KHÔNG được cache: người gọi tiếp theo sẽ thử lại
if (entries.get(key)?.promise === promise) entries.delete(key);
throw error;
},
);
entries.set(key, { promise });
return promise;
},
ts
Khác bản gốc: lỗi không được cache (entries.delete(key) trong nhánh lỗi), nên lời gọi sau thử lại. Có ttl để kết quả thành công hết hạn, và invalidate(). Trả về promise thay vì subscribe(listener): người gọi tự bỏ qua kết quả nếu đã unmount, không cần huỷ đăng ký.
#withTimeout và hàng đợi lệnh
withTimeout giống bản của Toptal, thêm clearTimeout khi đã chạy. CallbackQueue (trong file đầy đủ) giữ API push giống Array#push (nhận nhiều callback, trả về độ dài), và gọi từng callback trong try/catch.
Xem toàn bộ src/lib/asyncCoord.ts
src/lib/asyncCoord.ts
/**
* Bốn công cụ nhỏ để điều phối việc bất đồng bộ, viết lại từ Glitch và Toptal.
*
* 1. latestWins (Glitch `utils/track-serially.js`): nguồn sự kiện nhanh (kéo slider) điều
* khiển một việc chậm (gọi server). Chỉ chạy 1 việc một lúc, và chỉ giữ LỆNH MỚI NHẤT
* đang chờ; lệnh ở giữa bị bỏ, không bao giờ dồn hàng đợi.
* 2. singleFlight (Toptal `lib/fetch-json-cache.js`): nhiều nơi cùng cần một URL -> chỉ một
* request. Bản gốc giữ lỗi mãi mãi; bản này xoá lỗi để lần sau thử lại, và có TTL.
* 3. withTimeout (Toptal `lib/with-timeout.js`): chạy callback ĐÚNG MỘT LẦN, khi được gọi
* hoặc khi hết giờ, cái nào tới trước.
* 4. exposeCallbacks (Toptal `lib/expose-callbacks.js`): biến mảng `window.x = []` mà script
* bên thứ ba push vào thành hàng đợi có replay cho người đến muộn.
*/
// ---------------------------------------------------------------------------
// 1. latestWins
// ---------------------------------------------------------------------------
export interface LatestWins<A extends unknown[]> {
(...args: A): void;
/** đang không chạy việc nào */
readonly idle: boolean;
/** promise resolve khi đã chạy xong lệnh mới nhất (dùng cho test, hoặc nút "Lưu và đóng") */
whenIdle(): Promise<void>;
}
export function latestWins<A extends unknown[]>(
task: (...args: A) => Promise<unknown>,
onError: (error: unknown) => void = (e) => console.error(e),
): LatestWins<A> {
let pending: A | null = null;
let running = false;
const idleWaiters: Array<() => void> = [];
async function loop() {
running = true;
while (pending !== null) {
const args = pending;
pending = null;
try {
await task(...args);
} catch (error) {
// Khác Glitch: lỗi chỉ bỏ qua lệnh hiện tại. Bản gốc thoát vòng lặp khi có lỗi,
// lệnh mới nhất đang chờ nằm im cho tới khi người dùng thao tác thêm lần nữa.
onError(error);
}
}
running = false;
idleWaiters.splice(0).forEach((resolve) => resolve());
}
const run = ((...args: A) => {
pending = args; // ghi đè lệnh đang chờ (nếu có)
if (!running) void loop();
}) as LatestWins<A>;
Object.defineProperty(run, 'idle', { get: () => !running });
run.whenIdle = () => (running ? new Promise<void>((resolve) => idleWaiters.push(resolve)) : Promise.resolve());
return run;
}
// ---------------------------------------------------------------------------
// 2. singleFlight
// ---------------------------------------------------------------------------
export interface SingleFlightOptions {
/** giữ kết quả thành công bao lâu (ms). Mặc định: mãi mãi như Toptal */
ttl?: number;
}
export function singleFlight<K, V>(load: (key: K) => Promise<V>, { ttl = Infinity }: SingleFlightOptions = {}) {
const entries = new Map<K, { promise: Promise<V>; settledAt?: number }>();
return {
get(key: K): Promise<V> {
const entry = entries.get(key);
const fresh = entry && (entry.settledAt === undefined || Date.now() - entry.settledAt < ttl);
if (entry && fresh) return entry.promise;
const promise = load(key).then(
(value) => {
const current = entries.get(key);
if (current?.promise === promise) current.settledAt = Date.now();
return value;
},
(error) => {
// lỗi KHÔNG được cache: người gọi tiếp theo sẽ thử lại
if (entries.get(key)?.promise === promise) entries.delete(key);
throw error;
},
);
entries.set(key, { promise });
return promise;
},
invalidate(key?: K) {
if (key === undefined) entries.clear();
else entries.delete(key);
},
};
}
// ---------------------------------------------------------------------------
// 3. withTimeout
// ---------------------------------------------------------------------------
export function withTimeout<A extends unknown[]>(callback: (...args: A | []) => void, timeout = 1000) {
let done = false;
const fire = (...args: A | []) => {
if (done) return;
done = true;
clearTimeout(timer); // bản gốc để timer chạy tiếp (vô hại nhưng giữ closure thêm 1 giây)
callback(...args);
};
const timer = setTimeout(() => fire(), timeout);
return fire;
}
// ---------------------------------------------------------------------------
// 4. exposeCallbacks
// ---------------------------------------------------------------------------
type Callback<A extends unknown[]> = (...args: A) => void;
export class CallbackQueue<A extends unknown[]> {
private triggered = false;
private args = [] as unknown as A;
private callbacks: Array<Callback<A>> = [];
constructor(private onError: (error: unknown) => void = (e) => console.error(e)) {}
/** Cùng chữ ký với Array#push, nên code `window.x.push(cb)` của bên thứ ba vẫn chạy. */
push = (...callbacks: Array<Callback<A>>) => {
for (const cb of callbacks) {
if (this.triggered) this.safeCall(cb);
else this.callbacks.push(cb);
}
return this.callbacks.length;
};
trigger(...args: A) {
if (this.triggered) throw new Error('Callbacks already triggered');
this.triggered = true;
this.args = args;
this.callbacks.splice(0).forEach((cb) => this.safeCall(cb));
}
get isTriggered() {
return this.triggered;
}
// Khác Toptal: một callback của bên thứ ba ném lỗi không được chặn các callback sau nó
private safeCall(cb: Callback<A>) {
try {
cb(...this.args);
} catch (error) {
this.onError(error);
}
}
}
export function exposeCallbacks<A extends unknown[]>(
name: string,
target: Record<string, unknown> = window as never,
onError?: (error: unknown) => void,
) {
const existing = target[name];
if (existing instanceof CallbackQueue) return existing as CallbackQueue<A>;
const queue = new CallbackQueue<A>(onError);
// script bên thứ ba chạy TRƯỚC app: `window[name] = window[name] || []; window[name].push(cb)`
if (Array.isArray(existing)) queue.push(...(existing as Array<Callback<A>>));
else if (existing !== undefined) console.warn(`exposeCallbacks: window.${name} có kiểu lạ, ghi đè`);
target[name] = queue;
return queue;
}
ts
#Đi qua code
pending = argsluôn ghi đè. Không có mảng, không có hàng đợi; bộ nhớ dùng là hằng số dù sự kiện tới nhanh cỡ nào.if (!running) void loop(). Cờrunningchỉ đổi trongloop(), nên không thể có hai vòng lặp chạy song song.voidđể nói rõ là cố ý không chờ promise.- So sánh
current?.promise === promisetrongsingleFlight. Nếu ai đó gọiinvalidate()rồiget()lại trong lúc request cũ đang bay, request cũ xong sau sẽ không ghi đè hay xoá nhầm entry mới. clearTimeout(timer)trongwithTimeout. Bản gốc để timer chạy tiếp sau khi đã gọi; vô hại vì có cờcalled, nhưng closure bị giữ thêm 1 giây.exposeCallbacksnhậntarget(mặc địnhwindow) để test và demo không phải đụng vàowindowthật.
#Tại sao không làm cách khác?
- Debounce cho slider? Debounce chỉ chạy sau khi người dùng ngừng kéo, nên trong lúc kéo màn hình đứng yên. latestWins chạy liên tục trong lúc kéo (nhanh nhất mà server cho phép) và luôn kết thúc ở giá trị cuối.
- Throttle? Throttle chạy đều theo thời gian, không biết server đã xong hay chưa: server chậm hơn chu kỳ throttle thì các lệnh lại chồng nhau. latestWins tự điều tốc theo tốc độ của server.
- AbortController huỷ lệnh cũ? Hợp khi lệnh là request đọc dữ liệu (như bài #34). Với lệnh ghi (lưu, chuyển phiên bản), huỷ giữa chừng có thể để server ở trạng thái nửa vời; chờ lệnh hiện tại xong rồi mới chạy lệnh mới an toàn hơn.
- React Query / SWR cho single-flight? Cả hai đều gộp request trùng theo key và không cache lỗi theo mặc định. Dùng chúng nếu dự án đã có;
singleFlightở đây hợp với code ngoài React (service, script).
#Edge case và cạm bẫy
- Lệnh phụ thuộc lệnh trước. latestWins bỏ lệnh ở giữa, nên chỉ dùng khi mỗi lệnh là trạng thái tuyệt đối ("nhảy tới phiên bản 48"), không phải thay đổi tương đối ("tiến thêm 2 bước").
- Lỗi lặp lại. Server lỗi liên tục thì latestWins vẫn chỉ chạy khi có lệnh mới; nó không tự thử lại. Muốn thử lại, gọi lại
run(lastArgs)trongonErrorsau một khoảng chờ. - singleFlight và tham số. Key phải bao gồm mọi thứ ảnh hưởng tới kết quả (URL, query, người dùng hiện tại). Đổi tài khoản mà không
invalidate()là trả dữ liệu của người khác. - withTimeout quá ngắn. 1 giây đủ cho GA trên mạng bình thường. Với
transport: 'beacon'(như Toptal dùng), sự kiện vẫn được gửi kể cả khi trang đã chuyển, nên timeout ngắn không làm mất dữ liệu. - Ai đó ghi đè
window.x = []sau khi app đã nâng cấp nó (snippet viếtwindow.x = []thay vìwindow.x = window.x || []). Callback push vào mảng mới sẽ không bao giờ chạy. Có thể chặn bằngObject.definePropertyvới setter, nhưng thường chỉ cần hướng dẫn đúng cú pháp cho bên tích hợp.
#Dùng trong dự án
// kéo timeline video: chỉ seek tới vị trí mới nhất, server/decoder chậm cũng không dồn lệnh
const seek = latestWins((t: number) => player.seekAndRenderFrame(t), reportError);
slider.addEventListener('input', () => seek(+slider.value));
// nhiều widget cùng cần cấu hình người dùng
const settings = singleFlight((userId: string) => api.get(`/users/${userId}/settings`), { ttl: 60_000 });
// link ra ngoài có tracking
link.addEventListener('click', (e) => {
e.preventDefault();
const go = withTimeout(() => (location.href = link.href), 1000);
gtag('event', 'outbound', { transport_type: 'beacon', event_callback: go });
});
ts
#Kiểm thử
Ví dụ test (bản phác, không có trong repo)
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { exposeCallbacks, latestWins, singleFlight, withTimeout } from './asyncCoord';
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
const slow = (ms: number) => new Promise((r) => setTimeout(r, ms));
describe('latestWins', () => {
it('chỉ chạy lệnh đầu và lệnh cuối khi gọi dồn', async () => {
const seen: number[] = [];
const run = latestWins(async (x: number) => (seen.push(x), slow(100)));
for (let i = 0; i <= 10; i++) run(i);
await vi.advanceTimersByTimeAsync(500);
expect(seen).toEqual([0, 10]);
});
it('lỗi không làm kẹt lệnh mới nhất', async () => {
const seen: number[] = [];
const onError = vi.fn();
const run = latestWins(async (x: number) => {
await slow(100);
if (x === 0) throw new Error('boom');
seen.push(x);
}, onError);
run(0);
run(5);
await vi.advanceTimersByTimeAsync(500);
expect(onError).toHaveBeenCalledOnce();
expect(seen).toEqual([5]);
await run.whenIdle();
});
});
describe('singleFlight', () => {
it('gộp lời gọi trùng và thử lại sau lỗi', async () => {
let n = 0;
const flight = singleFlight(async () => {
n++;
await slow(10);
if (n === 1) throw new Error('mạng lỗi');
return 'ok';
});
const first = Promise.allSettled([flight.get('u'), flight.get('u'), flight.get('u')]);
await vi.advanceTimersByTimeAsync(20);
expect((await first).every((r) => r.status === 'rejected')).toBe(true);
expect(n).toBe(1);
const again = flight.get('u');
await vi.advanceTimersByTimeAsync(20);
await expect(again).resolves.toBe('ok');
expect(n).toBe(2);
});
});
describe('withTimeout', () => {
it('chạy đúng một lần dù gọi tay rồi hết giờ', () => {
const cb = vi.fn();
const fire = withTimeout(cb, 1000);
fire();
vi.advanceTimersByTime(2000);
fire();
expect(cb).toHaveBeenCalledOnce();
});
});
describe('exposeCallbacks', () => {
it('nâng cấp mảng có sẵn và phát lại cho callback tới muộn', () => {
const target: Record<string, unknown> = { ready: [] };
const early = vi.fn();
(target.ready as Array<() => void>).push(early);
const queue = exposeCallbacks<[string]>('ready', target);
queue.trigger('ga');
const late = vi.fn();
queue.push(late);
expect(early).toHaveBeenCalledWith('ga');
expect(late).toHaveBeenCalledWith('ga');
});
});
ts
#So với thư viện và chuẩn web
- Trong RxJS, latestWins gần với
exhaustMapcộng thêm "nhớ giá trị cuối";switchMapthì huỷ việc cũ (hợp với đọc, không hợp với ghi);concatMaplà hàng đợi tuần tự. - Thư viện Go có package
singleflightvới đúng ý tưởng này ở phía server; tênsingleFlightở trên lấy từ đó. AbortSignal.timeout(ms)là phiên bản chuẩn của "hết giờ thì thôi" chofetch. withTimeout ở đây ngược lại: hết giờ thì làm (chuyển trang), không phải huỷ.- Hàng đợi lệnh là cách mọi snippet analytics phổ biến hoạt động (
dataLayer.push,_paq.push,fbq.queue). Nếu bạn viết SDK nhúng vào trang người khác, cho phéppushtrước khi SDK tải là trải nghiệm tích hợp tốt nhất.
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)
- #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) (bài này)
- #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

