Học từ Telegram #5: Store tự viết và diff danh sách có key
Telegram Web A tự viết cả React lẫn Redux. Đọc store teactn gom cập nhật theo frame, so sánh nông, công cụ debug, và thuật toán chèn danh sách bằng fragment.
Telegram Web A không dùng React. Nhóm phát triển tự viết một thư viện tên Teact (API giống React: component hàm, hook, JSX) và một store toàn cục tên TeactN (giống Redux + connect). Toàn bộ nằm trong src/lib/teact/, vài trăm dòng.
Tự viết lại React nghe như một quyết định khó hiểu, nhưng nó cho họ toàn quyền với hai việc mà ứng dụng chat cần nhất: khi nào giao diện được cập nhật, và cập nhật danh sách dài với ít thao tác DOM nhất. Bài này đọc hai phần đó.
Tip
Tóm tắt
setGlobalchỉ đổi tham chiếu state; việc báo cho component được gom lại, chạy một lần mỗi frame (requestAnimationFrame). Mười tin nhắn tới trong cùng một frame chỉ gây một lần tính lại.- Mỗi component nối vào store bằng một hàm
mapState; kết quả được so sánh nông, chỉ component có props đổi mới render. Cảnh báo khimapStatetrảNaN, vìNaN !== NaNlàm so sánh nông luôn báo "đã đổi". - Danh sách có key: node mới liền nhau được chèn một lần bằng
DocumentFragment; node cũ chỉ di chuyển khi thật sự cần. - Với React:
useSyncExternalStore+ selector so sánh nông (vd.useShallowcủa zustand) cho kết quả tương tự phần store.
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
Trong demo, bấm "10 tin nhắn liền nhau": bảng bên dưới cho thấy chỉ có 1 lần flush, HeaderBadge và ba ChatRow có tin mới render một lần, hai ChatRow còn lại và Typing không render. Bật "Flush đồng bộ" rồi bấm lại: 10 lần flush, HeaderBadge render 10 lần.
#Telegram làm thế nào?
#Store: một biến, một danh sách container
Toàn bộ store nằm trong src/lib/teact/teactn.tsx (236 dòng). State là một biến module, setGlobal chỉ đổi biến đó rồi hẹn lịch:
let currentGlobal = {} as GlobalState;
const callbacks: Function[] = [updateContainers];
const runCallbacksThrottled = throttleWithRaf(runCallbacks);
// `noThrottle = true` is used as a workaround for iOS gesture history navigation
export function setGlobal(newGlobal?: GlobalState, noThrottle = false) {
if (typeof newGlobal === 'object' && newGlobal !== currentGlobal) {
currentGlobal = newGlobal;
if (!noThrottle) {
runCallbacksThrottled();
} else {
runCallbacks();
}
}
}
ts
throttleWithRaf (trong util/schedulers.ts) đảm bảo runCallbacks chạy tối đa một lần mỗi frame, qua fastRaf: một hàng đợi gom mọi callback muốn chạy ở frame kế tiếp vào một lời gọi requestAnimationFrame. Ngoại lệ noThrottle dành cho cử chỉ vuốt để quay lại trên iOS: lúc đó giao diện phải đổi ngay, cùng nhịp với animation của hệ điều hành.
Mỗi component dùng withGlobal(mapStateToProps) được ghi vào một Map tên containers. Khi flush, updateContainers chạy lại mapStateToProps của mọi container và chỉ gọi forceUpdate() nếu kết quả khác cũ theo so sánh nông:
for (const container of containers.values()) {
// ...
try {
newMappedProps = {
...mapStateToProps(currentGlobal, ownProps),
...mapReducersToProps(setGlobal, actions),
};
} catch (err) {
handleError(err);
return;
}
// ...
if (Object.keys(newMappedProps).length && !arePropsShallowEqual(mappedProps!, newMappedProps)) {
container.mappedProps = newMappedProps;
container.areMappedPropsChanged = true;
container.DEBUG_updates++;
forceUpdate();
}
}
ts
Đây chính là mô hình connect của react-redux, chỉ khác ở chỗ lịch flush do store tự quyết.
#Công cụ debug nhỏ mà hữu ích
Trong chế độ DEBUG, store làm thêm ba việc:
- Cảnh báo
NaN: nếu một giá trị trong props map ra làNaN(vd.unread / totalkhitotal = 0), so sánh!==luôn đúng nên component render lại ở mọi lần flush. Store in cảnh báo kèm tên component. - Cảnh báo flush chậm: đo
performance.now()quanh vòng lặp, quá 7ms thì cảnh báo. Ở 60fps một frame có khoảng 16,7ms; dành gần nửa frame chỉ để tính props là có vấn đề. - Double-click ở bất kỳ đâu in bảng các container sắp theo số lần render (
DEBUG_updates). Bật thêmDEBUG_MOREthì mỗi lần render còn in lý do, tức danh sách props đã đổi (getUnequalProps).
#Hai lỗi nhỏ trong bản gốc
-
Một
mapStatelỗi chặn cả vòng lặp. Trong đoạn trên,catchgọireturnchứ không phảicontinue. Một component cómapStateToPropsném lỗi (vd. đọc thuộc tính của chat vừa bị xoá) làm mọi container phía sau nó trongMapkhông được cập nhật ở lần flush đó. Bản viết lại dùngcontinue. -
Id container sinh lại ở mỗi lần render và rò bộ nhớ. Container lấy id bằng
useState(generateIdFor(containers)). Đối số củauseStateđược tính ở mọi lần render (chỉ lần đầu được dùng), vàutil/generateIdFor.tsmỗi lần gọi lại ghi thêm một id vào mộtMapcấp module không bao giờ được dọn:
tsconst store = new Map<string, any>(); export default () => { let id; do { id = String(Math.random()).replace('0.', 'id'); store.set(id, true); } while (store.hasOwnProperty(id)); return id; };Hàm này còn bỏ qua đối số
containers, vàstore.hasOwnProperty(id)trên mộtMapluôn làfalse(khoá củaMapkhông phải thuộc tính của object), nên vòng chống trùng không làm gì. Lỗi trùng id khó xảy ra vì id ngẫu nhiên, nhưngMapcứ phình theo số lần render của mọi component nối store. Cách sửa trong React là dùnguseRef(hoặcuseState(() => …)với hàm khởi tạo).
#Danh sách có key: teactFastList
Phần thứ hai nằm trong renderer src/lib/teact/teact-dom.ts. Một phần tử có prop teactFastList thì con của nó được cập nhật bằng hàm renderFastListChildren, theo ba bước:
- Xoá các con có
keykhông còn trong danh sách mới. - Duyệt danh sách mới. Con mới (chưa có key cũ) chưa chèn ngay mà xếp vào hàng đợi; gặp một con cũ thì chèn cả hàng đợi một lần: một phần tử thì
insertBefore, nhiều phần tử thì gom vàoDocumentFragment(flushFragmentQueue). - Con cũ chỉ bị di chuyển khi vừa lệch vị trí vừa đổi
teactOrderKey:
// This is a "magic" `teactOrderKey` property that tells us the element is updated
const order = 'props' in $newChild ? $newChild.props.teactOrderKey : undefined;
const shouldMoveNode = currentChildInfo.index !== currentPreservedIndex && currentChildInfo.order !== order;
const isMovingDown = shouldMoveNode && currentPreservedIndex > currentChildInfo.index;
// When the node goes down, preserved indexing actually breaks, so the "magic" should help.
if (!shouldMoveNode || isMovingDown) {
currentPreservedIndex++;
}
ts
Điều kiện order !== order có một hệ quả thú vị: nếu không truyền teactOrderKey thì undefined !== undefined là false, node không bao giờ bị di chuyển. Danh sách chat dùng đúng điều này: mỗi dòng đặt bằng position: absolute; top: i * 72px (bài #6), nên thứ tự trong DOM không ảnh hưởng gì tới thứ tự hiển thị, không cần tốn công di chuyển node.
#Bên trong trình duyệt: vì sao gom theo frame
Trình duyệt chỉ vẽ lại tối đa một lần mỗi frame. Nếu mười update tới trong cùng một frame (WebSocket hay đẩy cả loạt tin), tính lại giao diện mười lần thì chín lần đầu là phí: người dùng chỉ thấy kết quả cuối.
sequenceDiagram
participant WS as "Update từ server"
participant S as "setGlobal"
participant R as "requestAnimationFrame"
participant C as "Các container"
WS->>S: tin nhắn 1
S->>R: hẹn flush (lần đầu trong frame)
WS->>S: tin nhắn 2 ... 10
Note over S: chỉ đổi biến state, không hẹn thêm
R->>C: flush: chạy mapState của từng container
C->>C: so sánh nông, chỉ forceUpdate container có props đổi
React 18 có automatic batching: các setState trong cùng một task (cùng một event handler, cùng một callback setTimeout) được gộp thành một lần render. Mười tin nhắn tới qua mười message WebSocket là mười task riêng, nên vẫn có thể thành nhiều lần render. Gom theo frame thì gộp được cả những update tới từ các task khác nhau trong cùng khoảng 16ms. Đổi lại, giao diện trễ tối đa một frame so với state.
#Viết lại
#Store
Phần flush gần như giữ nguyên ý tưởng, sửa return thành continue:
src/lib/tgStore.ts
function flush() {
flushScheduled = false;
flushCount++;
const startedAt = performance.now();
for (const container of containers.values()) {
const next = safeMap(container, true);
// Bản gốc `return` ở đây, nên MỘT mapper lỗi làm mọi container phía sau không được cập nhật.
if (!next) continue;
if (Object.values(next).some((value) => Number.isNaN(value))) {
// NaN !== NaN: store so sánh bằng !== (như bản gốc) sẽ render thừa mỗi lần flush.
// shallowEqual ở đây dùng Object.is nên không bị, nhưng NaN thường là dấu hiệu tính sai.
onDebug({ type: 'nan', message: `${container.name}: mapState trả về NaN (với so sánh !== sẽ render lại mỗi lần)` });
}
if (!shallowEqual(container.mapped, next)) {
container.mapped = next;
container.updates++;
container.forceUpdate();
}
}
const duration = performance.now() - startedAt;
if (duration > slowFlushMs) {
onDebug({ type: 'slow', message: `flush chậm: ${duration.toFixed(1)}ms cho ${containers.size} container` });
}
listeners.forEach((listener) => listener());
}
/**
* Đổi state. Mặc định gom theo frame; `sync: true` flush ngay (Telegram dùng cho cử chỉ
* vuốt back trên iOS, nơi giao diện phải đổi đồng bộ với animation của hệ điều hành).
*/
function setGlobal(next: S, { sync = false } = {}) {
if (next === current) return;
current = next;
if (sync) {
flush();
return;
}
if (!flushScheduled) {
flushScheduled = true;
schedule(flush);
}
}
ts
Hook useGlobal (xem file đầy đủ) tạo container một lần cho mỗi instance bằng useRef thay cho generateIdFor, đăng ký vào containers trong useLayoutEffect, xoá khi unmount, và trả về props đã map.
Xem toàn bộ src/lib/tgStore.ts
src/lib/tgStore.ts
import { useLayoutEffect, useReducer, useRef } from 'react';
/**
* Store toàn cục tối giản theo ý tưởng teactn của Telegram Web A (lib/teact/teactn.tsx):
* - `setGlobal` chỉ đổi tham chiếu state; việc báo cho component được gom lại, chạy
* MỘT lần trong requestAnimationFrame kế tiếp (nhiều action trong một frame = một lần tính lại).
* - Mỗi component đăng ký một "container" với hàm `mapState`. Khi flush, chạy lại mọi
* mapState và so sánh nông (shallow) với kết quả cũ, chỉ component có props đổi mới render.
* - Công cụ debug: cảnh báo mapState trả NaN, cảnh báo flush chậm, đếm số lần render.
*
* Khác bản gốc: một mapState ném lỗi không làm dừng việc cập nhật các container khác,
* và id container không bị sinh lại ở mỗi lần render (xem bài viết).
*/
type Mapper<S, P> = (state: S) => P;
interface Container<S> {
id: number;
name: string;
mapState: Mapper<S, Record<string, unknown>>;
mapped: Record<string, unknown>;
forceUpdate: () => void;
updates: number;
}
export interface StoreDebugEvent {
type: 'nan' | 'slow' | 'error' | 'flush';
message: string;
}
export interface StoreOptions {
/** ngưỡng cảnh báo flush chậm (ms), Telegram dùng 7ms */
slowFlushMs?: number;
/** nơi nhận cảnh báo debug, mặc định console.warn */
onDebug?: (event: StoreDebugEvent) => void;
/** hàm lên lịch flush, mặc định requestAnimationFrame (để test có thể truyền hàm đồng bộ) */
schedule?: (cb: () => void) => void;
}
export function shallowEqual(a: Record<string, unknown>, b: Record<string, unknown>) {
if (a === b) return true;
const aKeys = Object.keys(a);
if (aKeys.length !== Object.keys(b).length) return false;
return aKeys.every((key) => Object.is(a[key], b[key]));
}
/** Liệt kê props khác nhau, để biết "vì sao component này render lại". */
export function diffProps(a: Record<string, unknown>, b: Record<string, unknown>) {
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
return [...keys].filter((key) => !Object.is(a[key], b[key]));
}
export function createGlobalStore<S>(initial: S, options: StoreOptions = {}) {
const {
slowFlushMs = 7,
onDebug = (event) => console.warn(`[store] ${event.message}`),
schedule = (cb) => requestAnimationFrame(cb),
} = options;
let current = initial;
let flushScheduled = false;
let nextContainerId = 1;
let flushCount = 0;
const containers = new Map<number, Container<S>>();
const listeners = new Set<() => void>();
/**
* Chạy mapState có bắt lỗi: lỗi trong một component không được làm sập cả app.
* Chỉ báo lỗi khi flush; trong lúc render thì im lặng giữ props cũ (báo ở đó dễ thành vòng lặp
* nếu chính việc báo lỗi làm component render lại).
*/
function safeMap(container: Container<S>, report = false) {
try {
return container.mapState(current);
} catch (error) {
if (report) onDebug({ type: 'error', message: `${container.name}: mapState lỗi (${String(error)})` });
return undefined;
}
}
function flush() {
flushScheduled = false;
flushCount++;
const startedAt = performance.now();
for (const container of containers.values()) {
const next = safeMap(container, true);
// Bản gốc `return` ở đây, nên MỘT mapper lỗi làm mọi container phía sau không được cập nhật.
if (!next) continue;
if (Object.values(next).some((value) => Number.isNaN(value))) {
// NaN !== NaN: store so sánh bằng !== (như bản gốc) sẽ render thừa mỗi lần flush.
// shallowEqual ở đây dùng Object.is nên không bị, nhưng NaN thường là dấu hiệu tính sai.
onDebug({ type: 'nan', message: `${container.name}: mapState trả về NaN (với so sánh !== sẽ render lại mỗi lần)` });
}
if (!shallowEqual(container.mapped, next)) {
container.mapped = next;
container.updates++;
container.forceUpdate();
}
}
const duration = performance.now() - startedAt;
if (duration > slowFlushMs) {
onDebug({ type: 'slow', message: `flush chậm: ${duration.toFixed(1)}ms cho ${containers.size} container` });
}
listeners.forEach((listener) => listener());
}
/**
* Đổi state. Mặc định gom theo frame; `sync: true` flush ngay (Telegram dùng cho cử chỉ
* vuốt back trên iOS, nơi giao diện phải đổi đồng bộ với animation của hệ điều hành).
*/
function setGlobal(next: S, { sync = false } = {}) {
if (next === current) return;
current = next;
if (sync) {
flush();
return;
}
if (!flushScheduled) {
flushScheduled = true;
schedule(flush);
}
}
function getGlobal() {
return current;
}
/** Tạo action: reducer nhận state hiện tại, trả state mới (hoặc undefined nếu không đổi). */
function action<A extends unknown[]>(reducer: (state: S, ...args: A) => S | void) {
return (...args: A) => {
const next = reducer(current, ...args);
if (next) setGlobal(next);
};
}
/**
* Hook nối component với store. `mapState` có thể là hàm inline: kết quả được so sánh nông,
* nên trả về object mới mỗi lần cũng không sao, miễn các giá trị bên trong giữ tham chiếu.
*/
function useGlobal<P extends Record<string, unknown>>(mapState: Mapper<S, P>, name = 'Component'): P {
const [, forceUpdate] = useReducer((n: number) => n + 1, 0);
const containerRef = useRef<Container<S> | null>(null);
// Tạo container một lần cho mỗi instance. (Bản gốc gọi generateIdFor() ở mỗi lần render.)
if (!containerRef.current) {
containerRef.current = {
id: nextContainerId++,
name,
mapState: mapState as Mapper<S, Record<string, unknown>>,
mapped: {},
forceUpdate,
updates: 0,
};
containerRef.current.mapped = safeMap(containerRef.current) ?? {};
}
const container = containerRef.current;
// Luôn dùng mapState mới nhất: nó có thể đóng gói props của component (vd. chatId), nên
// khi component render lại vì props đổi thì phải tính lại (bản gốc làm khi ownProps đổi).
if (container.mapState !== mapState) {
container.mapState = mapState as Mapper<S, Record<string, unknown>>;
const fresh = safeMap(container);
if (fresh && !shallowEqual(container.mapped, fresh)) container.mapped = fresh;
}
useLayoutEffect(() => {
containers.set(container.id, container);
// state có thể đã đổi giữa lúc render và lúc đăng ký
const latest = safeMap(container);
if (latest && !shallowEqual(container.mapped, latest)) {
container.mapped = latest;
forceUpdate();
}
return () => {
containers.delete(container.id);
};
}, [container]);
return container.mapped as P;
}
/** Bảng "ai render bao nhiêu lần", giống double-click debug của Telegram. */
function getRenderStats() {
return [...containers.values()]
.map(({ name, updates }) => ({ name, updates }))
.sort((a, b) => b.updates - a.updates);
}
return {
getGlobal,
setGlobal,
action,
useGlobal,
getRenderStats,
getFlushCount: () => flushCount,
/** gọi sau mỗi lần flush (dùng cho công cụ debug, devtools) */
onFlush(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
export type GlobalStore<S> = ReturnType<typeof createGlobalStore<S>>;
ts
#Danh sách có key
Bản viết lại làm việc thẳng trên DOM (không có virtual DOM), nhận một mảng item và các hàm getKey, getOrder, create:
src/lib/tgFastList.ts
// 2 + 3. Duyệt danh sách mới.
items.forEach((item, i) => {
const key = getKey(item);
const info = remaining.get(key);
if (!info) {
if (queue.length === 0) queueIndex = i;
queue.push({ item, key, index: i });
return;
}
flushQueue();
const order = getOrder?.(item, i);
const shouldMove = info.index !== preservedIndex && info.order !== order;
const isMovingDown = shouldMove && preservedIndex > info.index;
// Khi node đi xuống, các node phía sau "dồn lên" nên chỉ số giữ chỗ vẫn tăng.
if (!shouldMove || isMovingDown) preservedIndex++;
if (shouldMove) {
const reference = parent.children[isMovingDown ? i + 1 : i] ?? null;
if (reference !== info.node) {
parent.insertBefore(info.node, reference);
ops.moved++;
}
}
update?.(info.node, item);
next.set(key, { node: info.node, order });
});
flushQueue();
renderedByParent.set(parent, next);
return ops;
}
ts
Xem toàn bộ src/lib/tgFastList.ts
src/lib/tgFastList.ts
/**
* Cập nhật một danh sách DOM có key với ít thao tác DOM nhất, theo ý tưởng `teactFastList`
* trong renderer tự viết của Telegram Web A (lib/teact/teact-dom.ts):
*
* 1. Xoá các node có key không còn trong danh sách mới.
* 2. Duyệt danh sách mới. Các item MỚI nằm liền nhau được gom vào một DocumentFragment và
* chèn một lần (tải thêm 50 tin = 1 lần chèn thay vì 50).
* 3. Node cũ chỉ bị di chuyển khi vị trí lệch VÀ `order` của nó đổi. Không có `order`
* thì không bao giờ di chuyển: Telegram dùng chế độ này khi vị trí hiển thị do CSS quyết định
* (vd. `top: i * 72px` trong danh sách chat), thứ tự trong DOM không còn quan trọng.
*/
export interface DomOps {
inserted: number;
moved: number;
removed: number;
/** số lần chèn bằng fragment (mỗi lần có thể chứa nhiều node) */
fragments: number;
}
export interface KeyedListOptions<T> {
getKey: (item: T) => string;
/** "thứ tự" của item; đổi thì node mới được phép di chuyển trong DOM */
getOrder?: (item: T, index: number) => number | string | undefined;
create: (item: T) => HTMLElement;
update?: (node: HTMLElement, item: T) => void;
}
interface Rendered {
node: HTMLElement;
order: number | string | undefined;
}
const renderedByParent = new WeakMap<HTMLElement, Map<string, Rendered>>();
function adoptChildren(parent: HTMLElement) {
const adopted = new Map<string, Rendered>();
for (const child of Array.from(parent.children) as HTMLElement[]) {
if (child.dataset.key !== undefined) adopted.set(child.dataset.key, { node: child, order: undefined });
else child.remove();
}
return adopted;
}
export function renderKeyedList<T>(parent: HTMLElement, items: T[], options: KeyedListOptions<T>): DomOps {
const { getKey, getOrder, create, update } = options;
const ops: DomOps = { inserted: 0, moved: 0, removed: 0, fragments: 0 };
// Lần đầu (hoặc DOM do code khác dựng): nhận các node đã có `data-key` làm trạng thái cũ.
const previous = renderedByParent.get(parent) ?? adoptChildren(parent);
const newKeys = new Set(items.map(getKey));
// 1. Xoá node không còn, ghi lại vị trí (theo thứ tự DOM) của các node còn lại.
const remaining = new Map<string, Rendered & { index: number }>();
let remainingIndex = 0;
for (const [key, rendered] of previous) {
if (!newKeys.has(key)) {
rendered.node.remove();
ops.removed++;
}
}
// thứ tự DOM thật sau khi xoá (Map giữ thứ tự chèn, nhưng node có thể đã bị di chuyển)
for (const child of Array.from(parent.children) as HTMLElement[]) {
const key = child.dataset.key;
const rendered = key !== undefined ? previous.get(key) : undefined;
if (key !== undefined && rendered && newKeys.has(key)) {
remaining.set(key, { ...rendered, index: remainingIndex++ });
}
}
const next = new Map<string, Rendered>();
let queue: { item: T; key: string; index: number }[] = [];
let queueIndex = 0;
let preservedIndex = 0;
const flushQueue = () => {
if (queue.length === 0) return;
const reference = parent.children[queueIndex] ?? null;
if (queue.length === 1) {
const { item, key, index } = queue[0];
const node = createNode(item, key, index);
parent.insertBefore(node, reference);
} else {
const fragment = document.createDocumentFragment();
queue.forEach(({ item, key, index }) => fragment.appendChild(createNode(item, key, index)));
parent.insertBefore(fragment, reference);
ops.fragments++;
}
ops.inserted += queue.length;
queue = [];
};
const createNode = (item: T, key: string, index: number) => {
const node = create(item);
node.dataset.key = key;
next.set(key, { node, order: getOrder?.(item, index) });
return node;
};
// 2 + 3. Duyệt danh sách mới.
items.forEach((item, i) => {
const key = getKey(item);
const info = remaining.get(key);
if (!info) {
if (queue.length === 0) queueIndex = i;
queue.push({ item, key, index: i });
return;
}
flushQueue();
const order = getOrder?.(item, i);
const shouldMove = info.index !== preservedIndex && info.order !== order;
const isMovingDown = shouldMove && preservedIndex > info.index;
// Khi node đi xuống, các node phía sau "dồn lên" nên chỉ số giữ chỗ vẫn tăng.
if (!shouldMove || isMovingDown) preservedIndex++;
if (shouldMove) {
const reference = parent.children[isMovingDown ? i + 1 : i] ?? null;
if (reference !== info.node) {
parent.insertBefore(info.node, reference);
ops.moved++;
}
}
update?.(info.node, item);
next.set(key, { node: info.node, order });
});
flushQueue();
renderedByParent.set(parent, next);
return ops;
}
/** Cách ngây thơ để so sánh: dựng lại toàn bộ danh sách mỗi lần. */
export function renderByRebuild<T>(parent: HTMLElement, items: T[], options: KeyedListOptions<T>): DomOps {
const removed = parent.children.length;
const fragment = document.createDocumentFragment();
items.forEach((item) => {
const node = options.create(item);
node.dataset.key = options.getKey(item);
fragment.appendChild(node);
});
parent.replaceChildren(fragment);
renderedByParent.delete(parent);
return { inserted: items.length, moved: 0, removed, fragments: 1 };
}
/** Cách "có key nhưng không khéo": giữ node cũ nhưng appendChild lại tất cả theo thứ tự mới. */
export function renderByReappend<T>(parent: HTMLElement, items: T[], options: KeyedListOptions<T>): DomOps {
const ops: DomOps = { inserted: 0, moved: 0, removed: 0, fragments: 0 };
const byKey = new Map<string, HTMLElement>();
for (const child of Array.from(parent.children) as HTMLElement[]) {
if (child.dataset.key !== undefined) byKey.set(child.dataset.key, child);
}
const keys = new Set(items.map(options.getKey));
for (const [key, node] of byKey) {
if (!keys.has(key)) {
node.remove();
ops.removed++;
}
}
items.forEach((item) => {
const key = options.getKey(item);
let node = byKey.get(key);
if (!node) {
node = options.create(item);
node.dataset.key = key;
ops.inserted++;
} else {
options.update?.(node, item);
ops.moved++;
}
parent.appendChild(node);
});
renderedByParent.delete(parent);
return ops;
}
ts
#Đi qua code
flushScheduledlà cờ chống hẹn lịch hai lần.setGlobalgọi 10 lần thì chỉ lần đầu gọischedule(flush).safeMap(container, report)bắt lỗi củamapState. Trong lúc flush thì báo lỗi rồi bỏ qua container đó; trong lúc render thì im lặng giữ props cũ. Phiên bản đầu tiên của mình báo lỗi cả trong lúc render và gặp ngay một vòng lặp vô tận: báo lỗi làm khung log render lại, khung log render lại làm component lỗi render lại, component render lại thì báo lỗi tiếp.mapStateinline:useGlobal((s) => ({ total: … }))tạo hàm mới mỗi lần render. Hook so sánh tham chiếu hàm để biết khi nào cần tính lại (props của component có thể đã đổi), và so sánh nông kết quả nên object mới mỗi lần không gây render thừa.preservedIndextrongrenderKeyedListđếm số node cũ đã "đứng đúng chỗ". Node đi lên (vd. dòng cuối lên đầu) được chèn trướcchildren[i]và không làm tăng bộ đếm; node đi xuống thì tăng, vì các node phía sau nó dồn lên một bậc.adoptChildren: lần đầu gặp một container đã có node mangdata-key(do code khác dựng), hàm nhận các node đó làm trạng thái cũ thay vì chèn trùng.
Khác bản gốc:
- Store:
continuethay choreturn; id container nằm tronguseRef. So sánh nông dùngObject.isthay cho!==: vìObject.is(NaN, NaN)làtrue, bản viết lại không render thừa khi gặpNaN. Cảnh báo vẫn được giữ, vìNaNtrong props thường là dấu hiệu của một phép tính sai (demo: bảng đếm củaNanProbeđứng yên dù cảnh báo hiện ra). - Danh sách: làm trên DOM thật, đánh dấu bằng
data-key; bỏ phần xử lý con không có key.
#Tại sao không làm cách khác?
- Dựng lại toàn bộ danh sách (
replaceChildren) là đơn giản nhất, nhưng mỗi lần tải thêm 50 tin thì 400 node cũ bị xoá rồi tạo lại: mất vị trí cuộn, mất focus, ảnh đang tải bị hủy. Demo đếm được khoảng 800 node bị đụng. - Giữ node cũ nhưng
appendChildlại tất cả theo thứ tự mới không tạo node mới, nhưng mọi node đều bị gỡ ra rồi gắn lại: khoảng 780 node bị đụng chỉ để đưa một dòng lên đầu. Cách của Telegram: 2 (một lần gỡ, một lần gắn). - Dùng React. Thuật toán của React (
reconcileChildrenArray) cũng dùng key, nhưng nó chỉ đi từ trái sang phải với một mốclastPlacedIndex: node nào có vị trí cũ nhỏ hơn mốc thì bị di chuyển. Đưa phần tử cuối lên đầu làm mốc nhảy lên cuối ngay, nên mọi phần tử còn lại đều bị di chuyển. Telegram có hướngup/downnên chỉ di chuyển một node. Với danh sách vài chục phần tử thì không đáng lo; với danh sách chat vài trăm dòng thì đáng.
#Edge case và cạm bẫy
- Di chuyển node làm mất focus.
insertBeforemột node đang chứa phần tử được focus sẽ làm nó mất focus (node bị gỡ khỏi DOM rồi gắn lại). Đây là một lý do để hạn chế di chuyển node. Tính tới 10/2026, Chromium đã cóElement.moveBefore()để di chuyển mà giữ trạng thái; các trình duyệt khác chưa có, nên vẫn phải dùnginsertBefore. - Thứ tự DOM khác thứ tự hiển thị. Khi không truyền
order, node không bao giờ di chuyển: chỉ dùng khi vị trí do CSS quyết định (top,ordercủa flex/grid). Trình đọc màn hình và phím Tab đi theo thứ tự DOM, nên cách này có cái giá về trợ năng. - Gom theo frame làm trễ một frame. Đo layout ngay sau
setGlobalsẽ thấy giao diện cũ. Code cần giao diện mới ngay (như cử chỉ iOS) phải dùngsync. mapStatenặng. Mỗi lần flush chạy lạimapStatecủa mọi container. Có 500 container mà mỗi cái lọc một mảng 1.000 chat là 500.000 phép so sánh mỗi frame. Cảnh báo 7ms là để bắt đúng trường hợp này; cách chữa là chọn dữ liệu thật hẹp (đọcchatsById[id]thay vìchats.find) hoặc memo selector.
#Dùng trong dự án
Với React, phần store nên dựa vào useSyncExternalStore, vì nó lo các trường hợp concurrent rendering mà hook tự viết ở trên không lo. Ý tưởng gom theo frame vẫn áp dụng được: cho subscribe gọi listener trong rAF thay vì ngay lập tức:
function createFrameStore<S>(initial: S) {
let state = initial;
const listeners = new Set<() => void>();
let scheduled = false;
return {
getState: () => state,
setState(next: S) {
state = next;
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
listeners.forEach((l) => l());
});
},
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
ts
Selector trả object thì kết hợp với useShallow (zustand) hoặc tự memo kết quả theo so sánh nông, nếu không useSyncExternalStore sẽ báo snapshot đổi liên tục.
Phần danh sách hữu ích khi bạn tự quản DOM: widget nhúng không dùng framework, log hiển thị realtime, bảng dữ liệu cập nhật từ WebSocket.
#Kiểm thử
Ví dụ test (bản phác, không có trong repo)
// vitest + jsdom
import { describe, expect, it, vi } from 'vitest';
import { createGlobalStore } from './tgStore';
import { renderKeyedList } from './tgFastList';
describe('createGlobalStore', () => {
it('gom nhiều setGlobal thành một lần flush', () => {
const queued: (() => void)[] = [];
const store = createGlobalStore({ n: 0 }, { schedule: (cb) => queued.push(cb), onDebug: () => {} });
for (let i = 1; i <= 10; i++) store.setGlobal({ n: i });
expect(queued).toHaveLength(1);
queued[0]();
expect(store.getFlushCount()).toBe(1);
expect(store.getGlobal().n).toBe(10);
});
it('sync: flush ngay', () => {
const store = createGlobalStore({ n: 0 }, { schedule: () => {}, onDebug: () => {} });
store.setGlobal({ n: 1 }, { sync: true });
store.setGlobal({ n: 2 }, { sync: true });
expect(store.getFlushCount()).toBe(2);
});
it('action không đổi state thì không hẹn flush', () => {
const schedule = vi.fn();
const store = createGlobalStore({ n: 0 }, { schedule, onDebug: () => {} });
store.action(() => undefined)();
expect(schedule).not.toHaveBeenCalled();
});
});
describe('renderKeyedList', () => {
const opts = {
getKey: (n: number) => String(n),
getOrder: (_n: number, i: number) => i,
create: (n: number) => Object.assign(document.createElement('div'), { textContent: String(n) }),
};
const texts = (el: HTMLElement) => Array.from(el.children, (c) => c.textContent);
it('chèn nhiều item mới liền nhau bằng một fragment', () => {
const el = document.createElement('div');
renderKeyedList(el, [1, 2, 3], opts);
const ops = renderKeyedList(el, [7, 8, 9, 1, 2, 3], opts);
expect(texts(el)).toEqual(['7', '8', '9', '1', '2', '3']);
expect(ops).toMatchObject({ inserted: 3, moved: 0, fragments: 1 });
});
it('đưa phần tử cuối lên đầu: chỉ một lần di chuyển', () => {
const el = document.createElement('div');
renderKeyedList(el, [1, 2, 3, 4, 5], opts);
const ops = renderKeyedList(el, [5, 1, 2, 3, 4], opts);
expect(texts(el)).toEqual(['5', '1', '2', '3', '4']);
expect(ops.moved).toBe(1);
});
it('đưa phần tử đầu xuống cuối', () => {
const el = document.createElement('div');
renderKeyedList(el, [1, 2, 3, 4], opts);
renderKeyedList(el, [2, 3, 4, 1], opts);
expect(texts(el)).toEqual(['2', '3', '4', '1']);
});
it('không có order: không bao giờ di chuyển node', () => {
const el = document.createElement('div');
const noOrder = { ...opts, getOrder: undefined };
renderKeyedList(el, [1, 2, 3], noOrder);
const ops = renderKeyedList(el, [3, 1, 2], noOrder);
expect(ops.moved).toBe(0);
expect(texts(el)).toEqual(['1', '2', '3']); // thứ tự hiển thị phải do CSS lo
});
});
ts
#So với thư viện và chuẩn web
- Redux + react-redux dùng đúng mô hình container + so sánh nông; khác ở chỗ lịch cập nhật đi theo React (đồng bộ trong
useSyncExternalStore), không gom theo frame. - zustand, jotai, valtio đều dựa trên
useSyncExternalStore; muốn gom theo frame thì tự bọcsubscribenhư đoạn trên. - Danh sách có key: React, Vue, Solid, lit-html đều có thuật toán riêng. Vue 3 tính dãy con tăng dài nhất (longest increasing subsequence) để biết node nào đứng yên, nên số lần di chuyển là ít nhất; React chọn thuật toán đơn giản hơn như đã nói ở trên.
Bài này thuộc series Học từ mã nguồn Telegram (25 bài, đọc theo thứ tự).
Xem nhanh danh sách bài trong series
Mở đầu
Phần 1: dữ liệu và đồng bộ
- #1 Gửi tin lạc quan, khớp tin thật bằng randomId
- #2 Lịch sử tin nhắn chia hai đảo
- #3 Lưu global state lúc rảnh, chỉ giữ phần cần
- #4 Gom update realtime theo nhịp animation
- #5 Store tự viết và diff danh sách có key (bài này)
Phần 2: animation và giao diện
- #6 Animation sắp xếp danh sách chat không đo DOM
- #7 Ảnh bay giữa hai cây DOM trong media viewer
- #8 Bố cục album chọn theo hàm chi phí
- #9 Giờ gửi nằm gọn trong bong bóng chat
- #10 Composer biến hình, Transition và gạch chân tab
Phần 3: media, mobile, thông báo
- #11 Thumbnail 100 byte và viền sticker nén
- #12 Player sticker: DPR, hạ fps, cache khung
- #13 Mobile: --vh, bàn phím ảo, nhấn giữ, số điện thoại
- #14 Web Push không trùng và đánh dấu đã đọc
Phần 4: soạn tin, media, chi tiết UI
- #15 Rich text ⇄ entities, thanh định dạng nổi, nháp theo chat
- #16 Ô nhập tự giãn, emoji phóng to, gom tin theo ngày/người/album
- #17 Bảng emoji/sticker chỉ render nhóm đang xem
- #18 mediaLoader gộp request, ảnh mờ sang nét, spinner không chớp
- #19 Trình phát audio toàn cục và playlist
- #20 UiLoader chạy đua 700ms, ba mức animation
- #21 PanZoom bằng matrix() trong media viewer
- #22 Ngày dính khi cuộn, khoá cuộn, dừng momentum iOS
- #23 Vùng thả file, nén ảnh trước khi gửi, copy ảnh
- #24 Tiêu đề nhấp nháy và favicon đếm tin mới
Code + demo: examples/web-patterns · Mở trên StackBlitz

