frontend · intermediate · 4d
Мини-сигналы
Собери реактивную библиотеку сигналов примерно в 100 строк (signal/computed/effect) с автоматическим отслеживанием зависимостей и безглючными батч-обновлениями — та же модель, что лежит в основе Solid, Preact Signals и Vue 3.
Результат
Библиотека ~100 строк, где цепочки signal → computed → effect обновляются ровно один раз за батч, с тест-сьютом, доказывающим отсутствие устаревших чтений, глюков (ромб), динамических зависимостей и бенчмарком 10k узлов с O(1) стоимостью батча.
Этапы
0/5 · 0%- 01signal() + effect() с автотрекингом
Реализуй signal() и effect() с автоматическим отслеживанием зависимостей через глобальную переменную 'currently-executing'. Сигнал хранит значение и множество подписчиков; effect(fn) ставит глобальную в себя, запускает fn, и любой сигнал, прочитанный внутри fn, проверяет глобальную и добавляет эффект в свои подписчики — без явного списка `effect.subscribe(signal)`. Запись сигнала (`signal.value = x`) перезапускает ровно зависимые эффекты. Зависимости динамические: перед каждым перезапуском предыдущее множество подписчиков эффекта очищается, так что сигнал, условно читаемый в одной ветке (например, `if (flag.value) a.value else b.value`), перестаёт запускать эффект, когда эта ветка больше не проходится — без очистки устаревшие подписки вызывают лишние перезапуски и мешают GC пока сигналы живы. Докажи: эффект, читающий сигнал A в ветке true и B в ветке false, отписывается от непроходимой ветки при переключении условия, тест где сигнал перестают читать перестаёт запускать эффект.
Критерии готовности- Чтение сигнала внутри эффекта авто-подписывает через глобальную currently-executing; запись сигнала перезапускает ровно зависимые эффекты — проверено прямым тестом чтения/записи.
- Эффект, переставший читать сигнал (переключение условной ветки), больше им не перезапускается; тест с `if (flag) a else b` с переключением доказывает динамическую отписку и отсутствие лишнего устаревшего запуска.
Опирается наСамопроверка
Покажи тест условной ветки где переключение flag отписывает от непроходимого сигнала. Senior-ревьюер проверяет очистку множества подписчиков перед каждым перезапуском и сохранение/восстановление глобальной при вложенности (эффект внутри эффекта).
- 02Ленивый кэшируемый computed()
Добавь computed(): ленивый, кешируемый, транзитивно отслеживаемый и никогда не пересчитываемый пока источник реально не изменился. Computed хранит thunk `() => value`, флаг dirty и собственное множество подписчиков; вычисляется только при чтении (лениво) и возвращает кешированное значение если ни один источник не испачкал его с последнего чтения. При вычислении он также участвует в отслеживании зависимостей — так что чтение computed внутри эффекта транзитивно подписывает эффект на исходные сигналы computed, а не только на сам узел computed. Цепочка: запись источника → пометить computed грязным → запланировать эффект → при сбросе computed лениво пересчитывается (один раз) до чтения эффектом. Докажи: computed чей источник не менялся возвращает кеш с нулём пересчётов (посчитай вызовы thunk); чтение computed внутри эффекта заставляет эффект перезапускаться при изменении источника, но не при изменении несвязанного сигнала; измени источник, дважды прочитай computed и убедись что thunk сработал ровно один раз.
Критерии готовности- computed() ленивый (без вычисления до первого чтения) и кешируемый (то же значение с нулём вызовов thunk если источник не менялся) — проверено подсчётом вызовов thunk.
- Чтение computed внутри эффекта транзитивно отслеживается через computed до его источников; изменение источника перезапускает эффект, computed вычисляется ровно один раз при следующем сбросе (посчитано).
Опирается наСамопроверка
Покажи подсчёт вызовов thunk доказывающий ленивое кеширование и тест транзитивного отслеживания (источник → computed → эффект, один eval при сбросе). Senior-ревьюер проверяет выставление dirty при записи источника и сброс при чтении и что изменения несвязанного сигнала не перезапускают.
- 03Батчинг без глитчей
Реализуй батчинг так, чтобы несколько записей сигнала внутри `batch(() => {...})` запускали каждый эффект ровно один раз, после всех записей, без устаревших промежуточных чтений (глюков). Глюк: в наивном push-графе запись A немедленно распространяется на B и C, которые немедленно запускают подписчиков. Если D зависит от B и C (ромб A→B, A→C, B&C→D), D запускается при обновлении B (читая устаревший C) и снова при обновлении C — два вычисления, одно с несогласованным промежуточным состоянием. Фикс — отложить сброс: пометить грязные узлы, отсортировать реактивный граф топологически и сбросить в топологическом порядке, чтобы computed никогда не вычислялся пока хотя бы один из его источников ещё грязный. Докажи ромбовидным тестом: A→B, A→C, B&C→D обновляет D один раз без устаревшего чтения при изменении A внутри batch, плюс подсчёт что эффект D сработал ровно один раз за две записи внутри batch. Объясни, почему наивный BFS/push без топологической сортировки порождает глюки.
Критерии готовности- Несколько записей внутри batch(() => …) перезапускают каждый эффект ровно один раз, после всех записей — проверено подсчётом вызовов эффекта за две записи в одном batch.
- Ромбовидная зависимость (A→B, A→C, B&C→D) обновляет D один раз без устаревшего промежуточного чтения при изменении A внутри batch — эффект D видит финальные согласованные B и C, а не устаревший C на первом проходе.
Опирается наСамопроверка
Покажи ромбовидный тест с числом запусков эффекта = 1 без устаревшего чтения и тест две-записи-один-эффект. Senior-ревьюер проверяет топологическую сортировку графа перед сбросом и умение объяснить почему BFS push дал бы глюк.
- 04untrack() и очистка эффекта
Добавь untrack() для чтения сигнала без подписки и onCleanup(), чтобы эффекты освобождали ресурсы перед повторным запуском. untrack(() => signal.value) вычисляет функцию с временно обнулённой глобальной currently-executing, так что чтение невидимо для отслеживания зависимостей — нужно когда эффект должен прочитать значение не становясь реактивным к нему (например, сигнал логгера или кеш предыдущего значения). onCleanup(fn) регистрирует колбэк, выполняющийся перед следующим перезапуском эффекта (и при dispose), так что подписки, таймеры или abort-контроллеры, созданные в предыдущем прогоне, прибиваются до следующего — без этого каждый перезапуск утекал бы. Докажи оба: чтение сигнала B через untrack внутри эффекта, подписанного на A, не заставляет записи B перезапускать эффект; эффект создающий таймер и регистрирующий onCleanup(() => clearTimeout) чистит ровно один раз на перезапуск без утёкших таймеров.
Критерии готовности- untrack(() => sig.value) внутри эффекта не подписывает эффект на sig — запись sig не перезапускает эффект, проверено тестом отслеживаемого vs неотслеживаемого чтения.
- onCleanup(fn) выполняется перед каждым перезапуском эффекта и при dispose; эффект создающий таймер и регистрирующий очистку показывает ноль утёкших таймеров после N перезапусков (посчитано).
Опирается наСамопроверка
Покажи неподписку untrack (запись B не перезапускает) и вызов onCleanup раз на перезапуск без утечек. Senior-ревьюер проверяет что untrack обнуляет глобальную только на свой колбэк и что очистка идёт до перезапуска, а не после.
- 05Замерь и наблюдай граф
Докажи масштабируемость библиотеки и сделай реактивный граф наблюдаемым. Бенчмарк: создай 10k сигналов с графом computed веером и измерь стоимость batch — топологическая сортировка и сброс должны быть O(узлы + рёбра), а не O(узлы × записи), так что батчинг 100 записей всё равно запускает каждый эффект один раз, стоимость пропорциональна размеру графа, а не числу записей. Снимай лёгкие метрики самой библиотеки: число запусков эффектов, число вычислений computed, число и длительность сбросов batch. Затем отработай инцидент: создай цепочку 1000 узлов и убей батчинг (пиши без batch), чтобы увидеть как та же цепочка триггерит 1000 каскадных обновлений против 1 батчированного сброса — обрыв производительности и есть доказательство что батчинг несущий. Задокументируй сложность: маркировка на запись O(подписчики), сброс O(отсортированных грязных узлов) и почему виртуализованный рендеринг графа (не библиотеки) — отдельный вопрос.
Критерии готовности- Бенчмарк 10k узлов показывает стоимость батча O(граф), а не O(записи): 100 записей в одном batch запускают каждый эффект один раз, число сбросов 1, числа эффектов/computed зафиксированы.
- Цепочка 1000 узлов без батчинга показывает каскадные N обновлений против 1 батчированного сброса — обрыв измерен, сложность (маркировка O(подписчики), сброс O(грязных)) задокументирована.
Опирается наСамопроверка
Покажи бенчмарк 10k (100 записей, 1 сброс, счёт на эффект) и обрыв цепочки 1000 без батчинга. Senior-ревьюер проверяет топологический порядок сброса, стоимость O(граф) не O(записи) и наличие метрик.
Стартер
fallowlone/skein-projects
projects/signals-mini
- README.md
- src/signals.ts
- test/signals.test.ts
npx degit fallowlone/skein-projects/projects/signals-mini signals-mini Реализуй заглушки, затем гоняй тесты, пока не позеленеют: bun test
Форкни репозиторий и запушь свою работу — workflow grade прогонит тесты и статические проверки на твоих раннерах.
Рубрика
| Джуниор | Миддл | Сеньор | |
|---|---|---|---|
| Механизм отслеживания зависимостей | Эффекты перезапускаются явной подпиской (effect.subscribe(signal)); автоматического отслеживания нет — разработчик перечисляет каждую зависимость вручную. | Глобальная переменная 'currently-executing' автоматически отслеживает чтения: любой сигнал, прочитанный внутри тела функции эффекта, регистрируется как зависимость без явного перечисления. | Зависимости динамические: перед каждым перезапуском предыдущее множество подписчиков очищается, поэтому сигнал, условно читаемый в одной ветке, перестаёт держать эффект подписанным, когда ветка больше не проходит. Ты можешь показать тест, где сигнал, который перестают читать, перестаёт запускать эффект. |
| Безглючное / топологическое распространение | Эффекты перезапускаются немедленно на каждую запись сигнала; ромбовидная зависимость (A→B, A→C, B&C→D) заставляет D запуститься дважды и при первом запуске может прочитать устаревшее промежуточное значение. | Несколько записей внутри batch() запускают каждый эффект ровно один раз, после всех записей; D обновляется один раз без устаревшего чтения при изменении A внутри batch. | Реактивный граф сортируется топологически перед сбросом, поэтому computed никогда не вычисляется, пока хотя бы один из его источников ещё грязный; ты можешь доказать это ромбовидным тестом и объяснить, почему наивное BFS/push-распространение порождает глюки без топологической сортировки. |
| Кэширование computed и ленивое вычисление | computed() пересчитывается при каждом чтении, независимо от того, изменился ли какой-либо исходный сигнал с момента последнего чтения. | computed() ленивый (вычисляется только при чтении) и кэшируемый (возвращает последнее значение, если источник не изменился); пересчитывается только когда помечен грязным записью источника. | Чтение computed внутри эффекта отслеживается транзитивно через computed до исходных сигналов, так что эффект подписывается на источники, а не только на узел computed. Ты можешь показать, что изменение источника помечает computed грязным, планирует эффект и computed пересчитывается ровно один раз при следующем сбросе. |
Эталонный разбор (спойлер)
Трюк с 'currently-executing': автоматическое отслеживание зависимостей работает через переменную уровня модуля, хранящую текущий выполняющийся эффект (или null). Когда вызывается getter сигнала, он проверяет эту переменную и добавляет эффект в множество подписчиков. Когда эффект завершается, переменная восстанавливается до предыдущего значения. Вложенность работает, потому что переменная сохраняется и восстанавливается при каждом вызове эффекта.
Глюк: в наивном push-реактивном графе запись сигнала A немедленно распространяется на B и C, которые немедленно запускают своих подписчиков. Если D зависит от B и C, он запускается при обновлении B (читая устаревший C) и снова при обновлении C — два вычисления, одно с несогласованным промежуточным состоянием. Топологическая сортировка или батчинг предотвращают это, откладывая весь сброс до тех пор, пока все грязные узлы текущего батча не помечены.
Динамические зависимости: очистка множества подписчиков перед каждым перезапуском необходима для корректности, когда эффекты используют условия. Если эффект читает сигнал A в ветке true и сигнал B в ветке false, и условие меняется, эффект должен отписаться от ветки, которую больше не проходит. Без очистки устаревшие подписки вызывают лишние перезапуски и делают невозможным сборку мусора для эффекта, пока его сигналы живы.
Сделай по-сеньорски
- Добавь effect scopes и dispose(), прибивающий все эффекты в скоупе — и докажи, что ни один эффект не срабатывает после dispose скоупа, колбэки очистки вызваны один раз.
- Добавь DevTools-инспектор графа, визуализирующий рёбра зависимостей и подсвечивающий грязные узлы во время сброса batch.
- Реализуй proxy-based глубокий сигнал (реактивный объект) поверх примитивных сигналов и измерь оверхед против точечных сигналов на обновлении 10k свойств.