Thay đổi một giá trị và giao diện tự thay đổi theo. Nhưng runtime vẫn phải biết code nào đã đọc giá trị, derived value nào vừa hết hạn, effect nên chạy lúc nào, và cách xóa subscription cũ mà không rò rỉ bộ nhớ.

Mental model hữu ích không phải là “framework theo dõi mọi biến”. Reactivity là đồ thị được duy trì khi code thực thi. Reactive source và computation là node; một lần đọc tạo edge giữa chúng. Khi có write, runtime đi theo edge để invalidate hoặc schedule phần việc có thể đã đổi.

Bài viết này xây reactive engine TypeScript nhỏ hơn Vue, Solid, MobX hay Angular signals, nhưng đủ để phơi bày effect stack, dynamic dependency, lazy computed, scheduler, batching, cleanup, identity và debugging.

Reactivity là một dependency graph luôn thay đổi

Giả sử render effect tính price.value * quantity.value. Khi function đó chạy, runtime đánh dấu nó là active consumer. Mỗi signal getter ghi một edge từ signal đến consumer. Sau đó, phép gán quantity.value = 3 tìm subscriber của quantity và đưa render effect vào lịch chạy.

flowchart LR P[signal price] --> T[computed total] Q[signal quantity] --> T D[signal discount] --> T T --> R[render effect] T --> A[analytics effect] Q -. write .-> S[hàng đợi scheduler] S -. chạy lại .-> T S -. chạy lại .-> R

Thông tin di chuyển theo hai hướng:

  • Pull nghĩa là consumer chủ động yêu cầu giá trị. Gọi computed getter sẽ pull dữ liệu từ input. Computed lazy có thể giữ trạng thái dirty mà chưa làm gì cho đến khi có nơi đọc nó.
  • Push nghĩa là source thông báo mình đã đổi. Signal setter push sự kiện invalidation đến subscriber. Effect eager sau đó có thể chạy ngay hoặc vào scheduler queue.

Hệ thống thực tế thường kết hợp cả hai. Write push thông báo “đã stale”; read pull derived value mới. Pure push lan truyền khi không ai cần, còn pure pull cần event ngoài để biết lúc render lại.

Tracking có nhiều mức chi tiết. Proxy chặn property access, nên state.user.name subscribe key name của đúng object. Signal bọc một giá trị và biểu diễn read qua .value hoặc function call. Proxy thuận tiện cho object state; signal làm ownership và dependency boundary rõ hơn. Cả hai dùng cùng subscriber graph.

::: info Reactivity track những lần đọc diễn ra trong computation, không track mọi giá trị xuất hiện trong source code. Giá trị được lấy từ trước, chỉ đọc sau await, hoặc destructure bên ngoài effect có thể không tạo edge như bạn nghĩ. :::

Xây một reactive engine nhỏ bằng TypeScript

Engine dưới đây có năm lớp. track ghi nhận read, trigger phát write, effect quản lý active consumer, reactive instrument property của object, còn signal instrument một giá trị đơn. computed, scheduler và batch đều được xây trực tiếp trên các primitive đó.

ts
type Job = () => void;
type Dependency = Set<ReactiveEffect>;

interface ReactiveEffect {
  readonly dependencies: Set<Dependency>;
  readonly run: Job;
  readonly stop: Job;
  active: boolean;
  running: boolean;
  scheduler?: (job: Job) => void;
}

interface EffectOptions {
  lazy?: boolean;
  scheduler?: (job: Job) => void;
}

const targetMap = new WeakMap<object, Map<PropertyKey, Dependency>>();
const proxyCache = new WeakMap<object, object>();
const effectStack: ReactiveEffect[] = [];
let activeEffect: ReactiveEffect | undefined;

function cleanupEffect(effect: ReactiveEffect): void {
  for (const dependency of effect.dependencies) dependency.delete(effect);
  effect.dependencies.clear();
}

function track(target: object, key: PropertyKey): void {
  if (!activeEffect || !activeEffect.active) return;

  let dependencyMap = targetMap.get(target);
  if (!dependencyMap) {
    dependencyMap = new Map();
    targetMap.set(target, dependencyMap);
  }

  let dependency = dependencyMap.get(key);
  if (!dependency) {
    dependency = new Set();
    dependencyMap.set(key, dependency);
  }

  if (!dependency.has(activeEffect)) {
    dependency.add(activeEffect);
    activeEffect.dependencies.add(dependency);
  }
}

function trigger(target: object, key: PropertyKey): void {
  const dependency = targetMap.get(target)?.get(key);
  if (!dependency) return;

  // Clone vì lần chạy mới sẽ thay đổi chính các subscription set này.
  for (const effect of new Set(dependency)) {
    if (effect === activeEffect) continue;
    if (effect.scheduler) effect.scheduler(effect.run);
    else effect.run();
  }
}

function effect(fn: Job, options: EffectOptions = {}): ReactiveEffect {
  const reactiveEffect: ReactiveEffect = {
    active: true,
    running: false,
    dependencies: new Set(),
    scheduler: options.scheduler,
    run: () => {
      if (!reactiveEffect.active) {
        fn();
        return;
      }
      if (reactiveEffect.running) return;

      cleanupEffect(reactiveEffect);
      try {
        reactiveEffect.running = true;
        effectStack.push(reactiveEffect);
        activeEffect = reactiveEffect;
        fn();
      } finally {
        effectStack.pop();
        activeEffect = effectStack.at(-1);
        reactiveEffect.running = false;
      }
    },
    stop: () => {
      if (!reactiveEffect.active) return;
      cleanupEffect(reactiveEffect);
      reactiveEffect.active = false;
    },
  };

  if (!options.lazy) reactiveEffect.run();
  return reactiveEffect;
}

function reactive<T extends object>(target: T): T {
  const cached = proxyCache.get(target);
  if (cached) return cached as T;

  const proxy = new Proxy(target, {
    get(source, key, receiver) {
      const value = Reflect.get(source, key, receiver);
      track(source, key);
      return typeof value === 'object' && value !== null
        ? reactive(value)
        : value;
    },
    set(source, key, value, receiver) {
      const previous = Reflect.get(source, key, receiver);
      const changed = !Object.is(previous, value);
      const written = Reflect.set(source, key, value, receiver);
      if (written && changed) trigger(source, key);
      return written;
    },
  });

  proxyCache.set(target, proxy);
  return proxy;
}

interface Signal<T> {
  value: T;
}

function signal<T>(initialValue: T): Signal<T> {
  let value = initialValue;
  const holder = {};

  return {
    get value() {
      track(holder, 'value');
      return value;
    },
    set value(nextValue: T) {
      if (Object.is(value, nextValue)) return;
      value = nextValue;
      trigger(holder, 'value');
    },
  };
}

const queuedJobs = new Set<Job>();
let flushPending = false;
let batchDepth = 0;

function scheduleFlush(): void {
  if (flushPending || batchDepth > 0) return;
  flushPending = true;
  queueMicrotask(() => {
    try {
      while (queuedJobs.size > 0) {
        const jobs = [...queuedJobs];
        queuedJobs.clear();
        for (const job of jobs) job();
      }
    } finally {
      flushPending = false;
    }
  });
}

function queueJob(job: Job): void {
  queuedJobs.add(job);
  scheduleFlush();
}

function batch<T>(update: () => T): T {
  batchDepth += 1;
  try {
    return update();
  } finally {
    batchDepth -= 1;
    if (batchDepth === 0 && queuedJobs.size > 0) scheduleFlush();
  }
}

function computed<T>(getValue: () => T): Signal<T> {
  let cachedValue: T;
  let dirty = true;
  const holder = {};

  const computation = effect(
    () => {
      cachedValue = getValue();
      dirty = false;
    },
    {
      lazy: true,
      scheduler: () => {
        if (!dirty) {
          dirty = true;
          trigger(holder, 'value');
        }
      },
    },
  );

  return {
    get value() {
      track(holder, 'value');
      if (dirty) computation.run();
      return cachedValue;
    },
    set value(_nextValue: T) {
      throw new TypeError('computed values are read-only');
    },
  };
}

WeakMap cho phép raw target không còn reachable được thu hồi. Map bên trong tách subscription theo property. trigger phải clone dependency set vì effect tự remove rồi add lại trong lúc chạy; iterate live set có thể chạy lặp hoặc bỏ sót effect.

Stack xử lý nested computation. Render đọc computed sẽ chạy internal effect. Khi inner effect kết thúc, outer render phải active trở lại để nhận các read tiếp theo. Một global variable không có stack sẽ mất context này.

Computed, scheduler và batch không tạo glitch

Computed không chỉ là effect ghi sang signal khác. Nó lazy, cache kết quả, và chỉ đánh dấu dirty khi source đổi. Getter kế tiếp mới pull giá trị, nên nhiều consumer dùng chung một phép tính đến khi input thay đổi.

Scheduler tách cái gì đã invalid khỏi khi nào chạy việc. Source write đồng bộ, nhưng render thường không nên chạy ngay. queueJob lưu stable job identity trong Set, nên năm write trong một JavaScript turn chỉ tạo một render ở microtask kế tiếp:

ts
const cart = reactive({ price: 40, quantity: 1, member: false });
const total = computed(() => {
  const subtotal = cart.price * cart.quantity;
  return cart.member ? subtotal * 0.9 : subtotal;
});

const view = effect(
  () => console.log(`Total: ${total.value}`),
  { scheduler: queueJob },
);

batch(() => {
  cart.quantity = 3;
  cart.member = true;
});

// View đã schedule thấy 108, không bao giờ thấy giá trị trung gian 120.

Quan sát trung gian không nhất quán gọi là glitch. Nếu fullName phụ thuộc firstNamelastName, cập nhật tuần tự có thể để lộ tên nửa cũ nửa mới. Batching hoãn outward effect đến cuối nhóm update. Nó không rollback hay cung cấp database isolation, nhưng tạo observation boundary ổn định.

Thứ tự vẫn quan trọng. Computed node phải dirty trước khi user effect đọc nó, còn production scheduler thường có priority queue riêng cho computed invalidation, component rendering và post-render callback. Nó cũng cần recursion limit, thứ tự parent-before-child xác định được, error isolation và policy cho job được thêm giữa lúc flush. Vòng lặp trong mini engine xử lý job mới được queue, nhưng chủ ý bỏ qua các policy lớn đó.

Warning

Biến mọi effect thành async không tự động làm hệ thống đúng hơn. Validation, cache invalidation và state-machine transition có thể cần semantics đồng bộ. Scheduling là một phần contract của từng effect, không phải optimization switch dùng chung cho tất cả.

Dynamic dependency và cleanup bộ nhớ

Dependency có tính điều kiện vì control flow của chương trình có tính điều kiện. Xét effect chỉ đọc companyCard khi account.kind === 'business'. Ở lần chạy đầu, nó có thể subscribe kindpersonalCard; sau khi kind đổi, nó phải unsubscribe personalCard rồi subscribe companyCard.

ts
const account = reactive({
  kind: 'personal' as 'personal' | 'business',
  personalCard: 'P-101',
  companyCard: 'C-900',
});

const cardEffect = effect(() => {
  const card = account.kind === 'business'
    ? account.companyCard
    : account.personalCard;
  console.log(card);
});

account.kind = 'business';
account.personalCard = 'P-202'; // Không chạy: edge này đã bị xóa.
account.companyCard = 'C-901'; // Chạy: đây là edge hiện tại.
cardEffect.stop();

Vì vậy mỗi effect giữ reverse list đến các dependency set. Trước khi chạy, cleanupEffect xóa edge cũ; execution khám phá graph hiện tại. Cách này nhiều set operation hơn generation marking của runtime tối ưu, nhưng dễ kiểm chứng.

Cleanup có hai nghĩa. Dependency cleanup xóa graph edge, ngăn stale trigger và giữ effect. Side-effect cleanup dispose event listener, timer, subscription, observer hoặc request do callback tạo. Effect API production thường gọi disposer đăng ký qua onCleanup trước rerun và khi stop.

Stop là yêu cầu về memory. targetMap dùng weak target key, nhưng dependency set giữ strong reference đến effect, còn effect closure giữ mọi thứ nó capture. Component biến mất phải stop render effect và watcher. Weak map không cứu được target còn sống khi subscriber giữ cả component tree.

Async read tạo thêm một boundary. Sau await, synchronous effect ban đầu đã kết thúc và không còn active. Signal chỉ được đọc lần đầu sau thời điểm đó sẽ không được engine này track. Hãy đọc reactive input trước khi await, tách operation thành tracked phase và async phase, hoặc dùng framework API có async dependency semantics tường minh.

API, những chiếc bẫy, debugging và performance

Cơ chế lõi khá nhỏ; lựa chọn API mới quyết định nó có dễ dự đoán trong ứng dụng hay không.

Mô hình Mức chi tiết dependency Điểm mạnh Chi phí hoặc bẫy thường gặp
Dirty checking Expression hoặc component pass Write đơn giản, không cần instrumentation Kiểm tra lại việc không liên quan
Proxy object Property của object Cú pháp object tự nhiên, deep wrapping lazy Bất ngờ về identity và destructuring
Signal Một cell tường minh Edge chính xác, ownership rõ Cú pháp .value/function và nhiều wrapper
Immutable store + selector Store snapshot và selector Time travel, lịch sử update rõ Allocation và yêu cầu kỷ luật selector
Observable stream Chuỗi event theo thời gian Async composition và cancellation Semantics khác current state

Proxy identity cần được xử lý cẩn thận. reactive(raw) !== raw, nên dùng lẫn raw và proxy làm key trong Map sẽ khiến lookup thất bại. Cache bảo đảm gọi reactive(raw) nhiều lần trả cùng một proxy, nhưng không thể làm raw và proxy giống nhau. Hãy normalize ở API boundary, tránh làm lộ raw object và chỉ cung cấp escape hatch khi interoperability thực sự cần.

Destructuring có thể âm thầm ngắt tracking:

ts
const state = reactive({ count: 0 });
const { count } = state; // Đọc một lần rồi lưu plain number.
effect(() => console.log(count)); // Không có reactive read để track.

const countSignal = computed(() => state.count);
effect(() => console.log(countSignal.value)); // Vẫn kết nối.

Method dùng private field, DOM object, date, map, set và class instance từ thư viện khác có thể từ chối proxy receiver hoặc cần trap chuyên biệt. Thư viện production thường có skip list và collection handler riêng. Array cũng cần quan tâm: ghi một index có thể ảnh hưởng length, còn iteration phụ thuộc việc key được thêm hay xóa chứ không chỉ value hiện có bị thay thế.

Debugging dễ hơn rất nhiều khi graph quan sát được. Development hook hữu ích gồm onTrack(effect, target, key)onTrigger(effect, target, key), tên effect, queue timestamp, run count và duration. Khi effect chạy lại bất ngờ, hãy hỏi key nào trigger nó và key đó được đọc ở đâu. Khi effect không chạy, hãy xác nhận read thực sự diễn ra đồng bộ trong lúc đúng effect đang active.

Về performance, hãy tối ưu graph shape trước runtime. Giữ effect tập trung, tránh đọc object quá rộng, tách state đổi thường xuyên khỏi state ổn định và dùng computed để chia sẻ phép tính đắt. Dependency có 10.000 subscriber làm write đắt; effect đọc 10.000 key làm rerun đắt. Hãy đo fan-out, cleanup churn, queue length, duplicate invalidation và effect duration.

Đừng deep-proxy dữ liệu opaque như response immutable lớn hoặc editor document. Shallow signal bọc snapshot có thể rẻ hơn. Ngược lại, thay cả tree vì một field có thể invalidate nhiều selector hơn property-level proxy. Granularity phụ thuộc workload.

Tip

Công cụ performance tốt nhất thường là ownership boundary: state nên sống gần nhất có thể với effect dùng nó, và effect phải được stop cùng owner đó.

Điều cần ghi nhớ

  • Reactivity là runtime dependency graph được dựng từ read và được duyệt bởi write.
  • Push invalidation và pull evaluation bổ sung cho nhau; computed kết hợp cả hai.
  • Effect cần stack, reverse dependency link và cleanup để hỗ trợ code lồng nhau, có điều kiện.
  • Scheduler kiểm soát thời điểm, còn deduplication và batching ngăn việc thừa cùng glitch có thể quan sát.
  • Weak target map có ích, nhưng explicit stop và cleanup tài nguyên người dùng vẫn bắt buộc.
  • Proxy và signal dùng chung cơ chế lõi nhưng có tradeoff khác nhau về identity, syntax và granularity.
  • Hãy instrument tracking, triggering, queueing và effect duration trước khi tối ưu engine hay ứng dụng.

Khi nhìn thấy các mảnh ghép này, framework reactivity không còn giống phép màu. Nó trở thành một protocol có kỷ luật: quan sát read, ghi edge, invalidate khi write, chạy việc tại thời điểm đã định nghĩa, rồi xóa mọi edge hoặc tài nguyên không còn owner.