3 điểm bởi GN⁺ 2024-04-01 | 1 bình luận | Chia sẻ qua WhatsApp

Bản thảo đề xuất tiêu chuẩn JavaScript Signals

  • Đây là tài liệu mô tả định hướng chung ban đầu cho signals trong JavaScript, tương tự như nỗ lực Promises/A+ trước khi Promises được TC39 chuẩn hóa trong ES2015.
  • Nỗ lực này tập trung vào việc điều phối hệ sinh thái JavaScript, và nếu sự điều phối này thành công thì tiêu chuẩn có thể xuất hiện dựa trên kinh nghiệm đó.
  • Nhiều tác giả framework đang hợp tác về một mô hình chung có thể hỗ trợ lõi phản ứng.
  • Bản thảo hiện tại dựa trên các ý kiến thiết kế từ tác giả/người duy trì của Angular, Bubble, Ember, FAST, MobX, Preact, Qwik, RxJS, Solid, Starbeam, Svelte, Vue, Wiz và các dự án khác.

Bối cảnh: Vì sao là signals?

  • Để phát triển giao diện người dùng (UI) phức tạp, các nhà phát triển ứng dụng JavaScript cần lưu trữ, tính toán, vô hiệu hóa, đồng bộ hóa và đẩy trạng thái tới lớp hiển thị của ứng dụng theo cách hiệu quả.
  • UI thường không chỉ bao gồm việc quản lý các giá trị đơn giản mà còn cả việc render trạng thái được tính toán phụ thuộc vào các giá trị hoặc trạng thái khác.
  • Mục tiêu của signals là cung cấp hạ tầng để quản lý trạng thái ứng dụng như vậy, giúp nhà phát triển tập trung vào logic nghiệp vụ thay vì các chi tiết lặp đi lặp lại.
Ví dụ - bộ đếm VanillaJS
  • Có một biến tên là counter, và mỗi khi biến này thay đổi thì ta muốn cập nhật vào DOM việc bộ đếm có phải số chẵn hay không.
  • Trong Vanilla JS, có thể có đoạn mã như sau:
let counter = 0;
const setCounter = (value) => {
  counter = value;
  render();
};

const isEven = () => (counter & 1) == 0;
const parity = () => isEven() ? "even" : "odd";
const render = () => element.innerText = parity();

// Simulate external updates to counter...
setInterval(() => setCounter(counter + 1), 1000);
  • Đoạn mã này có một số vấn đề:
    • Việc thiết lập counter khá rườm rà và có nhiều boilerplate.
    • Trạng thái counter gắn chặt với hệ thống render.
    • Nếu counter thay đổi nhưng parity không đổi (ví dụ từ 2 sang 4), thì vẫn thực hiện tính toán và render không cần thiết.
    • Có thể có các phần khác của UI chỉ muốn render khi counter được cập nhật.
    • Các phần khác của UI chỉ phụ thuộc vào isEven hoặc parity sẽ không thể cập nhật nếu không tương tác trực tiếp với counter.

Giới thiệu signals

  • Trừu tượng hóa data binding giữa model và view từ lâu đã là cốt lõi của các framework UI, dù JS hay nền tảng web không hề tích hợp sẵn cơ chế như vậy.
  • Trong các framework và thư viện JS, đã có rất nhiều thử nghiệm về các cách biểu diễn kiểu binding này, và sức mạnh của cách tiếp cận giá trị phản ứng hạng nhất để biểu diễn trạng thái hoặc các phép tính được suy ra từ dữ liệu khác — thường được gọi là "Signals" — đã được chứng minh.
  • Nếu hình dung lại ví dụ trên bằng API signals, ta sẽ có như sau:
const counter = new Signal.State(0);
const isEven = new Signal.Computed(() => (counter.get() & 1) == 0);
const parity = new Signal.Computed(() => isEven.get() ? "even" : "odd");

// A library or framework defines effects based on other Signal primitives
declare function effect(cb: () => void): (() => void);

effect(() => element.innerText = parity.get());

// Simulate external updates to counter...
setInterval(() => counter.set(counter.get() + 1), 1000);

Động lực cho việc chuẩn hóa signals

Khả năng tương tác
  • Mỗi cách triển khai signals đều có cơ chế auto-tracking riêng, khiến việc chia sẻ model, component và thư viện giữa các framework khác nhau trở nên khó khăn.
  • Mục tiêu của đề xuất này là tách hoàn toàn mô hình phản ứng khỏi view render, để nhà phát triển không phải viết lại mã không thuộc UI khi chuyển sang công nghệ render mới, hoặc có thể phát triển bằng JS các mô hình phản ứng dùng chung để triển khai trong bối cảnh khác.
Hiệu năng/mức sử dụng bộ nhớ
  • Vì việc tích hợp sẵn thư viện thường dùng luôn có thể giúp giảm lượng mã phải gửi đi, nên điều này luôn có thể mang lại một mức cải thiện hiệu năng tiềm năng nhỏ; tuy nhiên, triển khai signals thường khá nhỏ nên không kỳ vọng hiệu ứng này sẽ quá lớn.
Công cụ cho nhà phát triển
  • Khi dùng các thư viện signals hiện có trong JS, rất khó theo dõi call stack qua chuỗi computed signal, đồ thị tham chiếu giữa các signal, v.v.
  • Signals tích hợp sẵn sẽ cho phép runtime JS và các công cụ cho nhà phát triển hỗ trợ tốt hơn trong việc kiểm tra signals.
Lợi ích phụ
Lợi ích của thư viện tiêu chuẩn
  • Nhìn chung, JavaScript vốn có thư viện tiêu chuẩn khá tối giản, nhưng xu hướng của TC39 là biến JS thành một ngôn ngữ "kèm sẵn pin", với bộ tính năng tích hợp chất lượng cao.
Tích hợp HTML/DOM (khả năng trong tương lai)
  • W3C và các nhà triển khai trình duyệt hiện đang thúc đẩy việc đưa template gốc vào HTML.
  • Để đạt được các mục tiêu đó, HTML cuối cùng sẽ cần các primitive phản ứng.

Mục tiêu thiết kế signals

  • Các thư viện signals hiện có thực ra không khác nhau quá nhiều ở phần lõi.
  • Đề xuất này muốn xây dựng trên thành công của chúng bằng cách hiện thực hóa những đặc tính quan trọng từ nhiều thư viện.

Tính năng cốt lõi

  • Kiểu Signal biểu diễn trạng thái, tức Signal có thể ghi.
  • Kiểu Signal tính toán/memo/suy diễn phụ thuộc vào các signal khác, được tính toán lười và có cache.
  • Cho phép các framework JS tự thực hiện scheduling của riêng mình.

Phác thảo API

  • Ý tưởng API signals ban đầu như bên dưới. Đây chỉ là bản thảo ban đầu và được kỳ vọng sẽ thay đổi theo thời gian.
namespace Signal {
  // A read-write Signal
  class State<T> implements Signal<T> {
    // Create a state Signal starting with the value t
    constructor(t: T, options?: SignalOptions<T>);
    
    // Get the value of the signal
    get(): T;
    
    // Set the state Signal value to t
    set(t: T): void;
  }
  
  // A Signal which is a formula based on other Signals
  class Computed<T> implements Signal<T> {
    // Create a Signal which evaluates to the value returned by the callback.
    // Callback is called with this signal as the this value.
    constructor(cb: (this: Computed<T>) => T, options?: SignalOptions<T>);
    
    // Get the value of the signal
    get(): T;
  }
  
  // This namespace includes "advanced" features that are better to
  // leave for framework authors rather than application developers.
  // Analogous to `crypto.subtle`
  namespace subtle {
    // Run a callback with all tracking disabled (even for nested computed).
    function untrack<T>(cb: () => T): T;
    
    // Get the current computed signal which is tracking any signal reads, if any
    function currentComputed(): Computed | null;
    
    // Returns ordered list of all signals which this one referenced
    // during the last time it was evaluated.
    // For a Watcher, lists the set of signals which it is watching.
    function introspectSources(s: Computed | Watcher): (State | Computed)[];
    
    // Returns the Watchers that this signal is contained in, plus any
    // Computed signals which read this signal last time they were evaluated,
    // if that computed signal is (recursively) watched.
    function introspectSinks(s: State | Computed): (Computed | Watcher)[];
    
    // True if this signal is "live", in that it is watched by a Watcher,
    // or it is read by a Computed signal which is (recursively) live.
    function hasSinks(s: State | Computed): boolean;
    
    // True if this element is "reactive", in that it depends
    // on some other signal. A Computed where hasSources is false
    // will always return the same constant.
    function hasSources(s: Computed | Watcher): boolean;
    
    class Watcher {
      // When a (recursive) source of Watcher is written to, call this callback,
      // if it hasn't already been called since the last `watch` call.
      // No signals may be read or written during the notify.
      constructor(notify: (this: Watcher) => void);
      
      // Add these signals to the Watcher's set, and set the watcher to run its
      // notify callback next time any signal in the set (or one of its dependencies) changes.
      // Can be called with no arguments just to reset the "notified" state, so that
      // the notify callback will be invoked again.
      watch(...s: Signal[]): void;
      
      // Remove these signals from the watched set (e.g., for an effect which is disposed)
      unwatch(...s: Signal[]): void;
      
      // Returns the set of sources in the Watcher's set which are still dirty, or is a computed signal
      // with a source which is dirty or pending and hasn't yet been re-evaluated
      getPending(): Signal[];
    }
    
    // Hooks to observe being watched or no longer watched
    var watched: Symbol;
    var unwatched: Symbol;
  }
  
  interface Options<T> {
    // Custom comparison function between old and new value. Default: Object.is.
    // The signal is passed in as the this value for context.
    equals?: (this: Signal<T>, t: T, t2: T) => boolean;
    
    // Callback called when isWatched becomes true, if it was previously false
    [Signal.subtle.watched]?: (this: Signal<T>) => void;
    
    // Callback called whenever isWatched becomes false, if it was previously true
    [Signal.subtle.unwatched]?: (this: Signal<T>) => void;
  }
}

Thuật toán signals

  • Mô tả các thuật toán được triển khai cho từng API được phơi bày ra JavaScript.
  • Có thể xem đây là đặc tả ban đầu, nhằm chốt lại một tập ý nghĩa càng nhiều càng tốt cho một đề xuất vẫn còn rất mở với thay đổi.

Ý kiến của GN⁺

  • Đề xuất tiêu chuẩn JavaScript Signals nhằm cải thiện khả năng tương tác giữa các framework và giúp nhà phát triển triển khai lập trình phản ứng dễ dàng hơn.
  • Đây là nỗ lực chuẩn hóa các chức năng cốt lõi của nhiều thư viện signals hiện có, qua đó có thể cung cấp cho nhà phát triển một mô hình lập trình nhất quán.
  • Khái niệm signals không chỉ hữu ích cho phát triển UI mà còn có thể áp dụng hiệu quả trong các ngữ cảnh không phải UI, đặc biệt là giúp tránh các lần build lại không cần thiết trong hệ thống build.
  • API được đề xuất có thể mang đến công cụ hữu ích cho các nhà phát triển framework, từ đó được kỳ vọng sẽ đạt hiệu năng tốt hơn và quản lý bộ nhớ hiệu quả hơn.
  • Tuy vậy, để công nghệ này được chấp nhận rộng rãi, vẫn cần thêm nhiều nguyên mẫu và phản hồi từ cộng đồng, đồng thời phải được tích hợp vào ứng dụng thực tế để chứng minh hiệu quả.
  • Hiện nay các framework như React, Vue và Svelte đã có sẵn hệ thống phản ứng riêng, nên khả năng tương thích hoặc chiến lược tích hợp với các framework này cũng sẽ là một điểm cần cân nhắc quan trọng.

1 bình luận

 
GN⁺ 2024-04-01
Ý kiến Hacker News
  • Ví dụ Vanilla JS vs. Signals

    • Có phải chỉ mình tôi thấy ví dụ Vanilla JS dễ đọc hơn và tiện làm việc hơn không?
      • Có cảm giác việc thiết lập khá phức tạp và có nhiều boilerplate.
      • Khi giá trị bộ đếm thay đổi, có thể phát sinh các phép tính và lần render không cần thiết.
      • Nếu muốn các phần khác của UI chỉ render khi bộ đếm được cập nhật, có thể sẽ phải thay đổi cách quản lý trạng thái.
      • Nếu các phần khác của UI chỉ phụ thuộc vào isEven hoặc parity, có thể sẽ cần thay đổi toàn bộ cách tiếp cận.
  • Promises và sự thay đổi của JavaScript

    • Ban đầu tôi lo rằng mình sẽ phải thường xuyên dùng new Promise, nhưng trên thực tế thì hầu như không dùng đến.
    • Thay vào đó, tôi dùng .then rất nhiều, và điều này giúp đơn giản hóa việc giao tiếp với nhiều thư viện bên thứ ba khác nhau.
    • Nếu đề xuất Signal có thể mang lại hiệu quả tương tự cho các framework UI phản ứng thì tôi ủng hộ.
  • Signals như một phần của ngôn ngữ

    • Signals không nhất thiết phải trở thành một phần của ngôn ngữ; chỉ cần ở dạng thư viện là đủ.
    • Nghĩ rằng Signals do các thư viện UI JS hiện tại thiết kế tốt đến mức xứng đáng trở thành một phần của ngôn ngữ là một sự ngạo mạn.
    • Việc thêm mọi xu hướng vào runtime của ngôn ngữ có vẻ là một góc nhìn ngắn hạn.
  • Sử dụng event trong ứng dụng

    • Dùng event trên toàn bộ ứng dụng để gửi tín hiệu.
    • Phát sinh và đăng ký event thông qua window.dispatchEventwindow.addEventListener.
  • Độ khó của việc quản lý trạng thái DOM và cập nhật

    • Tôi cố gắng hiểu vì sao suốt hàng chục năm qua mọi người lại thấy việc quản lý trạng thái và cập nhật DOM khó đến vậy.
    • Thật khó hiểu khi những hàm DOM đơn giản lại bị làm cho trở nên phức tạp.
  • Promises và lập trình bất đồng bộ

    • Promises là một trường hợp thành công, nhưng nếu không có async/await thì có lẽ đã không cần phải chuẩn hóa.
    • Tôi tò mò các tác giả của nhiều thư viện khác nhau nghĩ gì về đề xuất này.
  • S.js và Signals

    • Tôi thích Signals và khi làm UI thì ưu tiên chúng hơn các primitive khác.
    • Tuy vậy, tôi không nghĩ chúng nên được đưa vào chính ngôn ngữ JavaScript.
  • Signals tương tự MobX

    • MobX là hệ thống effect JS tôi thích nhất.
    • Có kèm ví dụ mã theo phiên bản MobX.
  • Thêm framework vào thư viện chuẩn

    • Điều này giống như việc đề nghị thêm framework mà bạn đang thích vào thư viện chuẩn.
  • Hiểu đề xuất Signal và các vấn đề của nó

    • Tôi gặp khó khăn trong việc hiểu các ví dụ của đề xuất Signal.
    • Có những câu hỏi như hàm effect phát hiện thay đổi parity bằng cách nào, liệu lambda này có được gọi với mọi thay đổi của signal hay không, v.v.
    • Ý tưởng Signal là hợp lý, nhưng trong các ứng dụng phức tạp, việc theo dõi event có thể trở nên khó khăn.