jakubszpil

Framework na 70 linii, czyli implementacja Signal API od zera

Dowiedz się, jak zbudować własny, minimalistyczny system reaktywny inspirowany Signal API – od koncepcji po gotowy kod. Ten artykuł przeprowadzi Cię krok po kroku przez założenia architektury, implementację, przykłady użycia i pomysły na dalszy rozwój. 🚦

>>Spis treści

  1. Czym jest Signal API?
  2. Po co nam sygnały?
  3. Podstawowe założenia architektury
  4. Implementacja Signal API
  5. Prosty przykład użycia
  6. Co dalej? Inspiracje i rozwijanie własnego frameworka

>>Czym jest Signal API?

Signal API to nowoczesny sposób zarządzania stanem i propagowania zmian w aplikacjach frontendowych. Sygnał (Signal) to po prostu wartość, która jest obserwowalna – każda zmiana tej wartości automatycznie powiadamia powiązane fragmenty kodu, które mogą na nią zareagować (np. przerysować UI). Dzięki temu nie musisz ręcznie pilnować subskrypcji i aktualizacji — system robi to za Ciebie w sposób wydajny i przewidywalny.


>>Po co nam sygnały?

Mechanika sygnałów leży u podstaw takich rozwiązań jak SolidJS Signals, Preact Signals czy system reaktywności w Vue.js.


>>Podstawowe założenia architektury

  1. Signal: funkcja przechowująca wartość i listę „tasków” (efektów) zależnych od tej wartości.
  2. effect: funkcja, która wykona zadanie reagując automatycznie na zmiany sygnału.
  3. computed: sygnał zależny, obliczany na podstawie innych sygnałów.
  4. Task i AbortSignal: pozwalają bezpiecznie anulować zadania, gdy są już niepotrzebne.

Cel: zachować prostotę implementacji i zrozumienie działania reaktywności od podstaw.


>>Implementacja Signal API

Poniżej znajdziesz kompletny kod minimalistycznego Signal API – całość w TypeScript, gotowa do użycia i dalszej rozbudowy! 🛠️

type Task = (abortSignal: AbortSignal) => void;

type Signal<T> = () => T;

type WritableSignal<T> = Signal<T> & {
  set(value: T): void;
  update(fn: (value: T) => T): void;
};

let currentTask: Task | null = null;
let currentAbortController: AbortController | null = null;

function runTask(task: Task) {
  if (currentAbortController) currentAbortController.abort();
  currentAbortController = new AbortController();
  task(currentAbortController.signal);
}

export function effect(task: Task): void {
  currentTask = task;
  runTask(task);
  currentTask = null;
}

export function signal<T>(setupOrValue: T | (() => T)): WritableSignal<T> {
  let tasks: Set<Task> = new Set();
  let value: T;
  let valueSnapshot: string;
  let previousValueSnapshot: string;

  function callSetupOrValue() {
    if (setupOrValue instanceof Function) return setupOrValue();
    return setupOrValue;
  }

  function getValue() {
    const currentValue = callSetupOrValue();
    const currentValueSnapshot = JSON.stringify(currentValue);

    if (currentValueSnapshot !== previousValueSnapshot) {
      previousValueSnapshot = currentValueSnapshot;
      value = currentValue;
      valueSnapshot = currentValueSnapshot;
    }

    return value;
  }

  const signal: WritableSignal<T> = () => {
    if (currentTask) tasks.add(currentTask);
    return getValue();
  };

  function updateValue(payload: T): void {
    const payloadSnapshot = JSON.stringify(payload);
    if (valueSnapshot === payloadSnapshot) return;
    value = payload;
    valueSnapshot = payloadSnapshot;
    tasks.forEach((task) => runTask(task));
  }

  signal.set = (value) => updateValue(value);

  signal.update = (setter) => updateValue(setter(getValue()));

  return signal;
}

export const computed = <T>(setup: () => T): Signal<T> => setup;

>>Prosty przykład użycia

Zobacz, jak można wykorzystać własny system sygnałów w praktyce: 👇

import { signal, effect } from "./lib";

const count = signal(0);

effect(() => {
  console.log("Wartość licznika:", count());
});

count.set(1); // Wartość licznika: 1
count.update((v) => v + 5); // Wartość licznika: 6

>>>signal z obiektem

import { signal, effect } from "./lib";

interface User {
  id: number;
  name: string;
}

interface State {
  users: Array<User>;
}

const state = signal<State>({
  users: [],
});

effect(() => {
  console.log("Wartość stanu aplikacji: ", JSON.stringify(state()));
});

state.set({ users: [{ id: 1, name: "Kuba" }] }); // Wartość stanu aplikacji:  {"users":[{"id":1,"name":"Kuba"}]}

state.update((prev) => ({
  ...prev,
  users: [...prev.users, { id: 2, name: "Ola" }],
})); // Wartość stanu aplikacji:  {"users":[{"id":1,"name":"Kuba"},{"id":2,"name":"Ola"}]}

>>>computed

Obliczanie wartości zależnej od innych sygnałów jest bardzo proste i pozwala na deklaratywność podobną do nowoczesnych frameworków:

import { signal, effect, computed } from "./lib";

const a = signal(2);
const b = signal(3);
const sum = computed(() => a() + b());

effect(() => {
  console.log("Suma:", sum());
});

a.set(10); // Suma: 13
b.set(20); // Suma: 30

>>Co dalej? Inspiracje i rozwijanie własnego frameworka

Minimalizm daje moc! Dzięki prostej implementacji możesz zrozumieć, jak działają sygnały pod maską i stworzyć swój własny system reaktywności dostosowany do potrzeb. 💡

Widzisz jakiś błąd, bądź literówkę? Chcesz coś poprawić?✏️ Przejdź do edycji tego pliku