nanoeffects 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +6 -5
  2. package/dist/{action-creator-jeEqIKgK.d.ts → action-creator-6rlBTmH9.d.ts} +27 -2
  3. package/dist/actions.d.ts +3 -3
  4. package/dist/actions.js +1 -1
  5. package/dist/{bus-0uYn_kRj.js → bus-DFlmNJ6h.js} +1 -1
  6. package/dist/context-B5Z2RpJt.js +1 -0
  7. package/dist/{context-types-Bme1mzkn.d.ts → context-types-DKNc5mkS.d.ts} +1 -1
  8. package/dist/context.d.ts +2 -2
  9. package/dist/dev.d.ts +49 -3
  10. package/dist/dev.js +2 -1
  11. package/dist/effects.d.ts +2 -2
  12. package/dist/effects.js +1 -1
  13. package/dist/emit-key-C6FL7Ewn.js +1 -0
  14. package/dist/guard.d.ts +59 -0
  15. package/dist/guard.js +1 -0
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/match.d.ts +1 -1
  19. package/dist/{module-BWMW83qk.d.ts → module-G39MaVOt.d.ts} +2 -2
  20. package/dist/notrace/{action-creator-xpQ_I2h8.d.ts → action-creator-BrMSO3RP.d.ts} +1 -0
  21. package/dist/notrace/actions.d.ts +2 -2
  22. package/dist/notrace/effects.d.ts +2 -2
  23. package/dist/notrace/index.d.ts +1 -1
  24. package/dist/notrace/{plugin-6LhNx0th.d.ts → plugin-7GUL2EW5.d.ts} +1 -1
  25. package/dist/notrace/policies.d.ts +1 -1
  26. package/dist/notrace/redux.d.ts +1 -1
  27. package/dist/observable.d.ts +1 -1
  28. package/dist/oop.d.ts +1 -1
  29. package/dist/oop.js +1 -1
  30. package/dist/{plugin-BaErevLy.d.ts → plugin-BWNP23hr.d.ts} +2 -2
  31. package/dist/policies.d.ts +2 -2
  32. package/dist/policies.js +1 -1
  33. package/dist/production/dev.js +1 -1
  34. package/dist/react.d.ts +2 -2
  35. package/dist/react.js +1 -1
  36. package/dist/redux.d.ts +1 -1
  37. package/dist/scope.d.ts +2 -2
  38. package/dist/signal.d.ts +22 -2
  39. package/dist/signal.js +1 -1
  40. package/dist/site-DLvUoXs3.js +1 -0
  41. package/dist/svelte.d.ts +1 -1
  42. package/dist/trace.d.ts +151 -2
  43. package/dist/trace.js +3 -1
  44. package/dist/{types-D5xsR37j.d.ts → types-N_cdRr1r.d.ts} +2 -2
  45. package/dist/vue.d.ts +1 -1
  46. package/dist/{with-policy-C6BIiPIi.js → with-policy-CeM1rtES.js} +1 -1
  47. package/dist/{with-policy-BZVRBUOm.d.ts → with-policy-CthiBp_z.d.ts} +1 -1
  48. package/docs/README.md +62 -31
  49. package/docs/actions.md +44 -27
  50. package/docs/cancel-stale-requests.md +2 -0
  51. package/docs/chat-example.md +3 -1
  52. package/docs/chat-widget.md +2 -0
  53. package/docs/context.md +322 -9
  54. package/docs/debounce-and-abort.md +3 -1
  55. package/docs/debugging-loops.md +318 -0
  56. package/docs/effects.md +255 -138
  57. package/docs/errors.md +4 -2
  58. package/docs/examples.md +60 -0
  59. package/docs/guide.md +39 -22
  60. package/docs/hmr.md +35 -1
  61. package/docs/loop-guard.md +109 -0
  62. package/docs/migrating-from-effector.md +4 -2
  63. package/docs/migrating-from-mitt.md +6 -4
  64. package/docs/migrating-from-rxjs.md +2 -0
  65. package/docs/migrating-from-saga.md +2 -0
  66. package/docs/motivation.md +14 -21
  67. package/docs/observable.md +2 -0
  68. package/docs/oop.md +3 -1
  69. package/docs/package.md +4 -1
  70. package/docs/pass-the-signal.md +346 -0
  71. package/docs/plugins.md +22 -5
  72. package/docs/policies.md +73 -46
  73. package/docs/prevent-double-submit.md +3 -1
  74. package/docs/query-libraries.md +3 -1
  75. package/docs/react.md +42 -13
  76. package/docs/recipes.md +528 -0
  77. package/docs/ru/README.md +62 -0
  78. package/docs/ru/actions.md +412 -0
  79. package/docs/ru/cancel-stale-requests.md +106 -0
  80. package/docs/ru/chat-example.md +259 -0
  81. package/docs/ru/chat-widget.md +131 -0
  82. package/docs/ru/context.md +402 -0
  83. package/docs/ru/debounce-and-abort.md +105 -0
  84. package/docs/ru/debugging-loops.md +318 -0
  85. package/docs/ru/effects.md +538 -0
  86. package/docs/ru/errors.md +36 -0
  87. package/docs/ru/examples.md +60 -0
  88. package/docs/ru/guide.md +530 -0
  89. package/docs/ru/hmr.md +83 -0
  90. package/docs/ru/loop-guard.md +109 -0
  91. package/docs/ru/migrating-from-effector.md +339 -0
  92. package/docs/ru/migrating-from-mitt.md +99 -0
  93. package/docs/ru/migrating-from-rxjs.md +197 -0
  94. package/docs/ru/migrating-from-saga.md +97 -0
  95. package/docs/ru/motivation.md +47 -0
  96. package/docs/ru/observable.md +233 -0
  97. package/docs/ru/oop.md +431 -0
  98. package/docs/ru/package.md +42 -0
  99. package/docs/ru/pass-the-signal.md +346 -0
  100. package/docs/ru/plugins.md +398 -0
  101. package/docs/ru/policies.md +201 -0
  102. package/docs/ru/prevent-double-submit.md +122 -0
  103. package/docs/ru/query-libraries.md +392 -0
  104. package/docs/ru/react.md +309 -0
  105. package/docs/ru/recipes.md +528 -0
  106. package/docs/ru/semantics.md +19 -0
  107. package/docs/ru/server.md +204 -0
  108. package/docs/ru/starter.md +118 -0
  109. package/docs/ru/stores.md +673 -0
  110. package/docs/ru/svelte.md +78 -0
  111. package/docs/ru/tracing.md +466 -0
  112. package/docs/ru/typescript.md +170 -0
  113. package/docs/ru/vue.md +92 -0
  114. package/docs/semantics.md +2 -0
  115. package/docs/server.md +2 -0
  116. package/docs/starter.md +2 -0
  117. package/docs/stores.md +2 -0
  118. package/docs/svelte.md +3 -1
  119. package/docs/tracing.md +58 -1
  120. package/docs/typescript.md +2 -0
  121. package/docs/vue.md +3 -1
  122. package/llms-full.txt +2087 -418
  123. package/llms.txt +11 -8
  124. package/package.json +23 -6
  125. package/dist/context-DtUnzC-1.js +0 -1
  126. package/docs/scope.md +0 -278
  127. package/docs/stability.md +0 -90
@@ -0,0 +1,197 @@
1
+ # nanoeffects рядом с RxJS
2
+
3
+ > **[Уровень E](README.md#виды-проектов)**: для тех, кто знает RxJS. Сначала прочитайте: [Руководство](guide.md#1-создание-шины-событий).
4
+
5
+ nanoeffects не заменяет RxJS, они делят работу: RxJS формирует потоки значений, nanoeffects выполняет побочные эффекты, которые запускают события. Операторы сопоставлены, поле поиска и цикл опроса показаны в обоих вариантах, перечислено то, что остаётся в RxJS.
6
+
7
+ ## Разделение работы
8
+
9
+ | Задача | Использовать |
10
+ | ------ | ------------ |
11
+ | Начать запрос, когда что-то происходит, отменить его, когда приходит более новый, повторить, ограничить по времени | nanoeffects |
12
+ | Знать, какая задача что запустила, логировать и трассировать | nanoeffects ([Трассировка](tracing.md)) |
13
+ | Объединять несколько потоков, хранить накопленное значение, буферизовать, окна, слияние во времени | RxJS |
14
+ | Работать с потоком как со значением (`map`, `scan`, `combineLatest`) | RxJS |
15
+
16
+ Обработчик в nanoeffects — одна функция на событие. Конвейер в RxJS — цепочка операторов над потоком. Если ваш конвейер — несколько операторов, заканчивающихся `switchMap` и запросом, он помещается в обработчик с политикой и становится короче. Конвейер, который объединяет потоки, остаётся в RxJS.
17
+
18
+ ## Соответствие
19
+
20
+ | RxJS | nanoeffects |
21
+ | ---- | ----------- |
22
+ | `mergeMap` | `bus.on(event, handler)`: каждое событие запускает задачу |
23
+ | `switchMap` | [`latest`](policies.md) |
24
+ | `exhaustMap` | [`leading`](policies.md) |
25
+ | `concatMap` | [`queue`](policies.md) |
26
+ | `debounceTime` | [`debounce`](policies.md) |
27
+ | `throttleTime` | [`throttle`](policies.md) |
28
+ | `groupBy` + `switchMap` | `latest(handler, { key })` |
29
+ | `retry`, `retryWhen` | [`retry`, `backoff`](effects.md) |
30
+ | `timeout` | [`timeout`](effects.md) |
31
+ | `interval` + `switchMap` | [`poll`](effects.md) |
32
+ | `takeUntil(stop$)` | `ctx.signal` и [`take`](effects.md), чтобы дождаться события остановки |
33
+ | `forkJoin` | [`all`](effects.md) |
34
+ | `race` | [`race`](effects.md) |
35
+ | `firstValueFrom(fromEvent(…))` | [`take(ctx, event)`](effects.md) |
36
+ | `Subject` | `bus.emit` и `bus.on` |
37
+ | `map`, `filter`, `scan`, `combineLatest`, `buffer`, `window`, `share` | остаются в RxJS |
38
+ | `distinctUntilChanged` | проверка в обработчике или в подписке на store (см. [State managers](stores.md)) |
39
+
40
+ Политика и оператор различаются в одном. `switchMap` отбрасывает старый внутренний поток. `latest` прерывает старую задачу через её `signal`, так что `fetch`, получивший сигнал, останавливается. Передавайте сигнал дальше, иначе работа продолжится в фоне.
41
+
42
+ ## Переписанное поле поиска
43
+
44
+ Та же задача в RxJS: дождаться паузы, пропустить повторы, отменить старый запрос.
45
+
46
+ <!-- docs-check: skip - нужен rxjs, от которого проект не зависит -->
47
+ ```js
48
+ // RxJS
49
+ import { fromEvent } from "rxjs";
50
+ import { debounceTime, distinctUntilChanged, map, switchMap } from "rxjs/operators";
51
+
52
+ fromEvent(input, "input")
53
+ .pipe(
54
+ map((event) => event.target.value),
55
+ debounceTime(300),
56
+ distinctUntilChanged(),
57
+ switchMap((query) => api.searchStream(query)),
58
+ )
59
+ .subscribe((items) => render(items));
60
+ ```
61
+
62
+ Та же задача в nanoeffects:
63
+
64
+ ```js
65
+ import { createBus } from "nanoeffects";
66
+ import { debounce, latest } from "nanoeffects/policies";
67
+
68
+ const bus = createBus(); // шина: события и обработчики
69
+
70
+ // latest: новый поиск прерывает запрос в полёте
71
+ const searchLatest = latest(async (query, { signal }) => {
72
+ const items = await api.search(query, { signal });
73
+ bus.emit("results", items);
74
+ });
75
+
76
+ // debounce: запуск после 300 мс без нового "typed"
77
+ bus.on("typed", debounce(300, searchLatest));
78
+
79
+ bus.on("results", (items) => render(items));
80
+
81
+ const input = document.querySelector("#search"); // поле поиска
82
+ input.addEventListener("input", (event) => bus.emit("typed", event.target.value));
83
+ ```
84
+
85
+ Пропуска повторяющихся значений здесь нет. Добавьте в обработчик `if (query === lastQuery) return;` или оставьте этот единственный шаг в RxJS (следующий раздел).
86
+
87
+ ## Переписанный цикл опроса
88
+
89
+ Проверять статус задачи каждые 5 секунд. Повторять упавшую проверку и остановиться, когда пользователь покидает экран.
90
+
91
+ Та же задача в RxJS: `timer` тикает, `exhaustMap` пропускает тик, пока идёт проверка, `retry` повторяет упавшую проверку, `takeUntil` останавливает.
92
+
93
+ <!-- docs-check: skip - нужен rxjs, от которого проект не зависит -->
94
+ ```js
95
+ // RxJS
96
+ import { timer } from "rxjs";
97
+ import { exhaustMap, retry, takeUntil } from "rxjs/operators";
98
+
99
+ timer(5000, 5000)
100
+ .pipe(
101
+ exhaustMap(() => api.statusStream().pipe(retry({ count: 3, delay: 500 }))),
102
+ takeUntil(leave$),
103
+ )
104
+ .subscribe((status) => render(status));
105
+ ```
106
+
107
+ Та же задача в nanoeffects. `poll` тикает, `retry` повторяет одну проверку, а `race` завершает цикл на первом событии `"leave"`:
108
+
109
+ ```js
110
+ import { createBus } from "nanoeffects";
111
+ import { poll, race, retry, take } from "nanoeffects/effects";
112
+
113
+ const bus = createBus(); // шина: события и обработчики
114
+
115
+ bus.on("watch", async (_, ctx) => {
116
+ // одна проверка, до 3 попыток, 500 мс между ними
117
+ const checkStatus = async () => {
118
+ const status = await retry(ctx, () => api.status({ signal: ctx.signal }), {
119
+ times: 3,
120
+ delay: 500,
121
+ });
122
+ ctx.emit("status", status);
123
+ };
124
+
125
+ await race(ctx, {
126
+ // poll без ctx: проверка каждые 5 с; waitForFn: следующая пауза начинается после окончания проверки
127
+ polling: poll(5000, checkStatus, { waitForFn: true }),
128
+ // take без ctx: завершается на ближайшем "leave", а race останавливает опрос
129
+ leave: take("leave"),
130
+ });
131
+ });
132
+
133
+ bus.on("status", (status) => render(status));
134
+
135
+ bus.emit("watch"); // старт
136
+ bus.emit("leave"); // остановка
137
+ ```
138
+
139
+ От конвейера отличаются две вещи:
140
+
141
+ - `waitForFn: true` играет роль `exhaustMap`: проверки никогда не пересекаются. Без него `poll` держит ровный темп, и медленная проверка может пересечься со следующей.
142
+ - Если падают все 3 попытки, ошибка останавливает `poll`, как ошибка завершает Observable. Чтобы продолжать опрос после упавшей проверки, поймайте ошибку внутри `checkStatus` и отправьте событие `"error"`.
143
+
144
+ ## Совместное использование в одном приложении
145
+
146
+ Оставьте RxJS там, где удобно мыслить потоком, а события передавайте между RxJS и шиной в любом направлении.
147
+
148
+ **Шина → RxJS.** `createObserve(bus)` даёт Observable события ([Observable](observable.md)).
149
+
150
+ **RxJS → шина.** `bus.emitter(action)` возвращает callback, отправляющий action, так что он годится как аргумент `subscribe` ([State managers](stores.md) описывают плагин).
151
+
152
+ ```js
153
+ // bus.js
154
+ import { createBus } from "nanoeffects";
155
+ import { emitterPlugin } from "nanoeffects/actions";
156
+
157
+ export const bus = createBus().use(emitterPlugin()); // добавляет bus.emitter
158
+ ```
159
+
160
+ ```js
161
+ // filters.js
162
+ import { createAction } from "nanoeffects/actions";
163
+
164
+ export const filtersChanged = createAction("filters/changed"); // обычный action creator
165
+ ```
166
+
167
+ <!-- docs-check: skip - нужен rxjs, от которого проект не зависит -->
168
+ ```js
169
+ import { combineLatest } from "rxjs";
170
+ import { latest } from "nanoeffects/policies";
171
+ import { bus } from "./bus";
172
+ import { filtersChanged } from "./filters";
173
+
174
+ // RxJS: объединить два потока в одно значение; nanoeffects этого не делает
175
+ const subscription = combineLatest([query$, category$]).subscribe(
176
+ bus.emitter(filtersChanged), // вызывает bus.emit(filtersChanged(value))
177
+ );
178
+
179
+ // nanoeffects: один отменяемый запрос на каждое изменение фильтров
180
+ bus.on(
181
+ filtersChanged,
182
+ latest(async ([query, category], { signal }) => {
183
+ const items = await api.search(query, category, { signal });
184
+ bus.emit("results", items);
185
+ }),
186
+ );
187
+ ```
188
+
189
+ Подписка не привязана к задаче. Вызовите `subscription.unsubscribe()`, когда экран исчезает.
190
+
191
+ ## Отличия
192
+
193
+ - **Нет потоков значений.** Обработчик получает одну payload и запускает задачу. Нет `pipe`, нет оператора для последующих значений.
194
+ - **Прерывание заменяет отписку для работы в полёте.** Остановленная задача останавливается через её `signal`. Передайте его в `fetch` или в клиент.
195
+ - **Ошибки не часть потока.** Упавшая задача отклоняет собственный Promise. Без `onError` на шине об ошибке никто больше не узнает ([Ошибки](errors.md)).
196
+ - **Нет завершения.** Шина событий не заканчивается. Задача заканчивается, когда кончается её работа или прерывается её сигнал.
197
+ - **Нет marble-тестов.** Тестируйте настоящий обработчик с поддельными таймерами.
@@ -0,0 +1,97 @@
1
+ # Переход с redux-saga
2
+
3
+ > **[Уровень E](README.md#виды-проектов)**: для тех, кто знает redux-saga. Сначала прочитайте: [Руководство](guide.md#1-создание-шины-событий).
4
+
5
+ Для тех, кто знает redux-saga и ищет те же задачи в nanoeffects. Имена сопоставлены, одна сага переписана, перечислено то, у чего аналога нет. Саму библиотеку страница не объясняет: начните с [Руководства](guide.md).
6
+
7
+ ## Соответствие
8
+
9
+ | redux-saga | nanoeffects |
10
+ | ---------- | ----------- |
11
+ | `takeEvery` | `bus.on(event, handler)` |
12
+ | `takeLatest` | [`latest`](policies.md) |
13
+ | `takeLeading` | [`leading`](policies.md) |
14
+ | `debounce`, `throttle` | [`debounce`, `throttle`](policies.md) (те же правила, что у lodash) |
15
+ | `call(fn, x)` | `await ctx.run(task, x)` |
16
+ | `fork(fn, x)` | `ctx.run(task, x)` без `await` |
17
+ | `spawn(fn, x)` | `ctx.emit(event, x)` или `bus.emit(event, x)` |
18
+ | `put(action)` | `ctx.emit(action)` |
19
+ | `take(pattern)` | [`take(ctx, event, { predicate })`](effects.md), где `event` — имя или action creator |
20
+ | `select` | [поле контекста](context.md), например `store` |
21
+ | `all`, `race` | [`all`, `race`](effects.md) |
22
+ | `retry`, `delay` | [`retry`, `delay`](effects.md) |
23
+ | `cancel(task)` | `ctx.abort()` на контексте или более новый вызов под `latest` |
24
+ | `eventChannel` | `bus.emitter(action)` как callback подписки |
25
+ | `actionChannel` | [`queue`](policies.md) и другие политики |
26
+
27
+ ## Переписанная сага
28
+
29
+ Сага на каждый новый запрос запускает поиск, отменяет предыдущий и повторяет упавший вызов:
30
+
31
+ <!-- docs-check: skip - код redux-saga, не nanoeffects -->
32
+ ```js
33
+ // redux-saga
34
+ function* search({ payload }) {
35
+ const items = yield retry(3, 1000, api.search, payload);
36
+ yield put(found(items));
37
+ }
38
+ function* watchSearch() {
39
+ yield takeLatest("search", search);
40
+ }
41
+ ```
42
+
43
+ Та же задача в nanoeffects:
44
+
45
+ ```js
46
+ import { createBus } from "nanoeffects";
47
+ import { retry } from "nanoeffects/effects";
48
+ import { latest } from "nanoeffects/policies";
49
+
50
+ const bus = createBus(); // шина: события и обработчики
51
+
52
+ const search = (query, { signal }) => api.search(query, { signal });
53
+
54
+ bus.on(
55
+ "search",
56
+ latest(async (query, ctx) => {
57
+ // latest: новый "search" прерывает этот запуск; ctx.signal прерван
58
+ const load = retry(search, { times: 3, delay: 1000 }); // retry: до 3 попыток с паузой 1 с
59
+ const items = await ctx.run(load, query); // дочерняя задача, прерывается вместе с этим обработчиком
60
+ ctx.emit("found", items); // как put
61
+ }),
62
+ );
63
+ ```
64
+
65
+ Задачи — обычные `async`-функции. `ctx.signal` заменяет `cancel`: передайте его в `fetch` или в ваш клиент, и запрос остановится.
66
+
67
+ [Пример counter](../../examples/counter) показывает счётчик, `takeLatest` с `delay`, отмену и падающий вызов. В нём две страницы: без store и с Redux Toolkit.
68
+
69
+ ## Запуск задачи без ожидания
70
+
71
+ `fork` и `spawn` различаются тем, что происходит, когда вызывающий прерывается. Выбирайте по этому признаку.
72
+
73
+ ```js
74
+ import { createBus } from "nanoeffects";
75
+
76
+ const bus = createBus(); // шина: события и обработчики
77
+
78
+ const loadSidebar = (_, { signal }) => api.sidebar({ signal });
79
+
80
+ bus.on("page-opened", async (_, ctx) => {
81
+ // как fork: дочерняя задача этой, прерывается вместе с ней
82
+ const warmUp = ctx.run(loadSidebar);
83
+ warmUp.catch(console.error); // никто её не ждёт, так что обработайте ошибку здесь
84
+
85
+ // как spawn: новая задача, не останавливается, когда эта прервана
86
+ ctx.emit("analytics", { page: "home" });
87
+ });
88
+ ```
89
+
90
+ ## Отличия
91
+
92
+ - **Ошибки поднимаются только через `await`.** `await ctx.run(task)` и `all(ctx, …)` выбрасывают ошибку дочерней задачи вызывающему. Дочерняя задача, которую никто не ждёт, не прерывает родителя, в отличие от fork в redux-saga. Обработайте ошибку сами, как выше. См. [Ошибки](errors.md).
93
+ - **Прерывание идёт вниз.** Прерывание задачи прерывает её дочерние задачи. Дочерняя задача не прерывает соседей.
94
+ - **Эффекты — не данные.** `yield call(fn)` возвращает описание, которое тест может сравнить. Здесь `await` вызывает функцию. Тестируйте настоящий обработчик с поддельными таймерами, как тесты этой библиотеки.
95
+ - **Нет генераторов.** Последовательные сценарии — это `async`-функции с `await take(ctx, event)`.
96
+ - **Нет `END` канала.** Задача останавливается, когда прерывается её сигнал.
97
+ - **Одна payload.** У задачи один аргумент, поэтому передавайте объект там, где сага передаёт несколько.
@@ -0,0 +1,47 @@
1
+ # Зачем нужна nanoeffects
2
+
3
+ > **[Уровень A](README.md#виды-проектов)**: небольшое приложение, виджет или MVP.
4
+
5
+ Страница объясняет, зачем нужна библиотека. nanoeffects выносит бизнес-логику и побочные эффекты из компонентов React, Vue или Svelte в обычные `async`-функции: с отменой, повторами и контекстом для зависимостей. API здесь не объясняется: начните с [Руководства](guide.md).
6
+
7
+ ## Вынесение логики из пользовательского интерфейса
8
+
9
+ Компоненты с запросами, таймерами и гонками трудно тестировать и легко сломать. Обычное решение — вынести логику в сервисы и чистые функции. Тогда каждому сервису нужно одно и то же: отменять ненужную работу, повторять запросы, делиться зависимостями и показывать, что выполнилось и почему.
10
+
11
+ Привычные инструменты требуют многого: redux-saga описывает эту логику генераторами, RxJS — потоками и операторами. nanoeffects оставляет обычные `async`-функции и добавляет одну идею — `AbortSignal`.
12
+
13
+ Ещё один способ вынести логику — контейнеры внедрения зависимостей (DI), например в Angular или в отдельных библиотеках. В nanoeffects контейнера нет, но внедрять зависимости можно на уровне контракта: обработчик берёт нужное из `ctx`, а что именно дать, решает шина. См. [Поля контекста](context.md).
14
+
15
+ Шина событий — основа библиотеки, но не главная ценность: любой эмиттер отправляет события (`nanoevents` весит около 100 байт, [таких библиотек ещё десятки](https://github.com/gajus/sister#similar-libraries)). nanoeffects добавляет:
16
+
17
+ - отменяемые задачи, политики и эффекты;
18
+ - контекст — небольшой контейнер зависимостей;
19
+ - actions и модули, чтобы сгруппировать фичу;
20
+ - [трассировку](tracing.md) с адаптером для Redux DevTools.
21
+
22
+ Библиотека работает с тем, что уже есть в проекте: рецепты и небольшие адаптеры для [stores](stores.md) (Redux Toolkit, Zustand, MobX, Pinia), мост к [RxJS](migrating-from-rxjs.md), помощники для [React](react.md), [Vue](vue.md) и [Svelte](svelte.md). Для старта ничего переписывать не нужно.
23
+
24
+ ## Микрофронтенды
25
+
26
+ В микрофронтендах каждое приложение может принести свои версии одних и тех же библиотек. Хуже всего библиотеки state и событий: они хранят **внутреннее state и идентичность** (store, контекст, класс). Две копии или версии такой библиотеки не сливаются и молча перестают видеть друг друга. Разные версии и копии не только работают несогласованно: они тянут больше кода и ресурсов, чем одна согласованная копия. Поэтому цель — максимально простой проект с минимумом общих зависимостей, которые хранят state.
27
+
28
+ В Module Federation это видно сразу. Каждый remote добавляет свою копию, пока команды не договорятся о `singleton` и диапазоне версий. [Руководство Nx по версиям библиотек](https://nx.dev/docs/kb/manage-library-versions-with-module-federation) описывает последствия: библиотеки с внутренним state конфликтуют, а метод, которого нет в одной версии, ломает вызывающих, написанных под другую.
29
+
30
+ Реальные случаи из библиотек, с которыми сравнивали nanoeffects:
31
+
32
+ | Библиотека | Что идёт не так | Источник |
33
+ | ---------- | --------------- | -------- |
34
+ | Redux, React Redux | Общая библиотека компонентов использует `react-redux`. Каждый микрофронтенд должен взять ту же версию, иначе `react-redux` «странно ломается». | [react-redux#1949](https://github.com/reduxjs/react-redux/issues/1949) |
35
+ | Redux | Store доступен только внутри `Provider`. Каждому под-приложению нужен свой провайдер, но при внедрении модуля второй создавать нельзя. Редьюсеры, продублированные в хосте и модуле, усугубляют проблему. | [Redux on Module Federation](https://dev.to/manomano-tech-team/how-to-work-with-redux-on-module-federation-13gm) |
36
+ | Effector | Библиотека с `effector-react/scope`, собранная в другой проект, получает два экземпляра. `useStore` приложения не видит её `Provider`: «no scope found». | [effector#727](https://github.com/effector/effector/issues/727) |
37
+ | RxJS | Две копии дают два класса `Observable`. Типы одной версии не подходят другой, а операторы, добавленные в одну копию, отсутствуют в другой. Совет — «одна версия в `node_modules`». | [RxJS#2984](https://github.com/ReactiveX/rxjs/issues/2984), [neo4j-javascript-driver#978](https://github.com/neo4j/neo4j-javascript-driver/issues/978) |
38
+
39
+ У nanoeffects нет зависимостей и state уровня модуля: шина — объект, который вы создаёте сами. Две копии или версии пакета на одной странице дают две независимые шины. Глобально ничего не регистрируется, `Provider` не нужен, хост не выбирает версию за вас.
40
+
41
+ ## Что делает nanoeffects
42
+
43
+ - **Нет зависимостей и глобального state.** Её безопасно поставлять в SDK рядом с кодом хост-приложения. Запись `singleton` в конфиге Module Federation не нужна.
44
+ - **Помещается в жёсткий бюджет.** На весь сторонний код SDK часто дают несколько килобайт. `createBus` весит около 1,4 кБ в gzip (1,3 кБ в brotli). Политики, эффекты и actions — отдельные точки входа: импортируйте их, когда нужно. См. [Точки входа пакета](package.md).
45
+ - **Легко начать.** Вся модель — `on` и `emit`. Отмена, повторы и остальное — обычные функции поверх. [Руководство](guide.md) и [Переход с redux-saga](migrating-from-saga.md) сопоставляют знакомые имена.
46
+ - **`emit` может вернуть ответ.** [Action с обработчиком](actions.md) возвращает результат, и один модуль может спросить другой и дождаться ответа. Голые события так не умеют. Именно этого больше всего не хватало вариантам на Redux.
47
+ - **Легко отлаживать.** Событийный код читать трудно: виден эффект, а не вызвавшее его событие. Каждая задача знает своего родителя, поэтому [трассировка](tracing.md) показывает цепочку событий. Логгеры работают с консолью, панелью Performance и Redux DevTools.
@@ -0,0 +1,233 @@
1
+ # Превращение событий в Observable
2
+
3
+ > **[Уровень F](README.md#виды-проектов)**: платформа, SDK или долгоживущее приложение. Сначала прочитайте: [Руководство](guide.md#1-создание-шины-событий).
4
+
5
+ Для приложения, которое уже работает с Observable или RxJS и получает событие шины как поток. Если вы начинаете с нуля, берите `bus.on` с политиками: они отменяют работу и видны в трассировках.
6
+
7
+ В той же точке входа есть `events`: он передаёт событие в цикл `for await` и не требует библиотеки потоков. См. [Чтение события циклом `for await`](#чтение-события-циклом-for-await).
8
+
9
+ `createObserve(bus)` из `nanoeffects/observable` превращает событие шины в Observable по предложению TC39 (`subscribe`, `unsubscribe`, `Symbol.observable`). RxJS и другие библиотеки потоков принимают его как есть. Сама точка входа от них не зависит.
10
+
11
+ ## Событие как поток
12
+
13
+ Проблема: поле поиска отправляет `typed` на каждую клавишу. Нужно дождаться паузы и пропустить повторы знакомыми операторами.
14
+
15
+ <!-- docs-check: skip - нужен rxjs, от которого проект не зависит -->
16
+ ```js
17
+ import { createBus } from "nanoeffects";
18
+ import { createObserve } from "nanoeffects/observable";
19
+ import { from } from "rxjs";
20
+ import { debounceTime, distinctUntilChanged } from "rxjs/operators";
21
+
22
+ const bus = createBus(); // шина: события и обработчики
23
+ const observe = createObserve(bus); // observe(event) даёт Observable на имя события
24
+
25
+ const typed$ = from(observe("typed")); // RxJS оборачивает его через from()
26
+
27
+ const subscription = typed$
28
+ .pipe(debounceTime(300), distinctUntilChanged())
29
+ .subscribe((query) => bus.emit("search", query)); // обратно в шину
30
+ ```
31
+
32
+ - `observe(event)` возвращает Observable нагрузок этого события. Каждый `subscribe` — отдельный `bus.on`; `unsubscribe()` его снимает.
33
+ - `subscribe` принимает функцию или объект с `next`.
34
+ - Типы следуют карте событий: `observe("typed")` — это `Observable<string>`, когда `typed` содержит строку.
35
+
36
+ ## Отправка потока в шину
37
+
38
+ Проблема: `.subscribe((value) => bus.emit(action(value)))` повторяет шину и action в каждой подписке. `bus.emitter(action)` из `emitterPlugin` возвращает готовый callback для `subscribe`. Он подходит любому источнику с функцией `next`: потоку RxJS, callback store, DOM-обработчику.
39
+
40
+ ```js
41
+ // bus.js
42
+ import { createBus } from "nanoeffects";
43
+ import { emitterPlugin } from "nanoeffects/actions";
44
+
45
+ export const bus = createBus().use(emitterPlugin()); // добавляет bus.emitter
46
+ ```
47
+
48
+ ```js
49
+ // search.js
50
+ import { createAction } from "nanoeffects/actions";
51
+
52
+ export const queryChanged = createAction("search/queryChanged"); // обычный action creator
53
+ ```
54
+
55
+ <!-- docs-check: skip - нужен rxjs, от которого проект не зависит -->
56
+ ```js
57
+ import { fromEvent } from "rxjs";
58
+ import { debounceTime, distinctUntilChanged, map } from "rxjs/operators";
59
+ import { bus } from "./bus";
60
+ import { queryChanged } from "./search";
61
+
62
+ const input = document.querySelector("#search"); // поле поиска
63
+
64
+ const subscription = fromEvent(input, "input")
65
+ .pipe(
66
+ map((event) => event.target.value),
67
+ debounceTime(300),
68
+ distinctUntilChanged(),
69
+ )
70
+ .subscribe(bus.emitter(queryChanged)); // вызывает bus.emit(queryChanged(value))
71
+
72
+ bus.on(queryChanged, (query) => console.log("search for", query));
73
+ ```
74
+
75
+ Observable может быть откуда угодно, не только из `createObserve`. `subscription.unsubscribe()` останавливает его; шина о нём не знает. Передавайте callback в `subscribe` как есть и не вызывайте `bus.emitter(...)` внутри операторов на каждое значение.
76
+
77
+ ## Чтение и запись одного события через Subject
78
+
79
+ Проблема: `createObserve` только читает. Коду для потоков часто нужно ещё и отправлять значения в то же событие, а передавать для этого шину и имя события неудобно.
80
+
81
+ `Subject` — одно событие шины как один объект. `subscribe` слушает, `next` отправляет. В нём нет `complete`, `error` и повтора значений: событие шины не заканчивается, а шина значений не хранит.
82
+
83
+ ```js
84
+ import { createBus } from "nanoeffects";
85
+ import { Subject } from "nanoeffects/observable";
86
+
87
+ const bus = createBus(); // шина: события и обработчики
88
+
89
+ const typed = new Subject(bus, "typed"); // Subject — событие "typed" как поток, в который можно ещё и писать
90
+
91
+ const subscription = typed.subscribe((text) => console.log(text)); // как bus.on("typed", …)
92
+ typed.next("hello"); // как bus.emit("typed", "hello"): срабатывают и обработчики bus.on, и подписчики
93
+ subscription.unsubscribe(); // снимает только эту подписку
94
+ ```
95
+
96
+ - `next` работает и отдельно от subject, поэтому `input.addEventListener("input", (event) => typed.next(event.target.value))` и `store.subscribe(typed.next)` обёртки не требуют.
97
+ - Подписчик, добавленный позже, не получает значение, отправленное раньше. Если он должен получить значение, добавьте в RxJS `startWith` или `shareReplay`.
98
+ - RxJS принимает `Subject` так же, как `createObserve`: `from(typed)`. Это не `Subject` из RxJS (нет `error`, `complete` или `pipe`), поэтому он не подходит там, где нужен тот тип.
99
+
100
+ ### Расширение для именованного потока
101
+
102
+ Проблема: имя события повторяется везде, где создаётся поток. Подкласс фиксирует его один раз.
103
+
104
+ ```js
105
+ import { createBus } from "nanoeffects";
106
+ import { Subject } from "nanoeffects/observable";
107
+
108
+ class TypedText extends Subject {
109
+ constructor(bus) {
110
+ super(bus, "typed"); // событие закреплено классом
111
+ }
112
+
113
+ next(text) {
114
+ super.next(text.trim()); // метод подкласса заменяет метод Subject
115
+ }
116
+ }
117
+
118
+ const bus = createBus(); // шина: события и обработчики
119
+ const typed = new TypedText(bus);
120
+ typed.next(" hello "); // отправляет "typed" со значением "hello"
121
+ ```
122
+
123
+ ### Использование внутри класса с шиной
124
+
125
+ У класса, созданного через `extendBus`, есть свои `on` и `emit`. Передайте `this` как источник, и подписка принадлежит экземпляру, так что `dispose()` её завершает ([Классы с шиной](oop.md)).
126
+
127
+ ## Работа в обработчике вместо `switchMap`
128
+
129
+ Observable хорошо формирует значения. Запрос лучше выполнять в обработчике с политикой: у него есть `signal`, и трассировка его показывает. Разделите работу: RxJS заканчивается на значении, шина запускает запрос.
130
+
131
+ <!-- docs-check: skip - нужен rxjs, от которого проект не зависит -->
132
+ ```js
133
+ import { latest } from "nanoeffects/policies";
134
+
135
+ // RxJS: сформировать значения (debounce, пропуск повторов), затем передать их шине
136
+ from(observe("typed"))
137
+ .pipe(debounceTime(300), distinctUntilChanged())
138
+ .subscribe((query) => bus.emit("search", query));
139
+
140
+ // nanoeffects: один отменяемый запрос; новый "search" прерывает старый
141
+ bus.on(
142
+ "search",
143
+ latest(async (query, { signal }) => {
144
+ const items = await api.search(query, { signal });
145
+ bus.emit("results", items);
146
+ }),
147
+ );
148
+ ```
149
+
150
+ `switchMap` отбрасывает старый внутренний поток. `latest` прерывает старую задачу через `signal`, так что `fetch`, получивший сигнал, действительно останавливается.
151
+
152
+ ## Чтение события циклом `for await`
153
+
154
+ Проблема: задаче нужно обрабатывать payload одного события по порядку и остановиться, когда экран закроется. `take` ждёт одну payload, а в обработчике негде разместить цикл.
155
+
156
+ `events(source, event, options)` из `nanoeffects/observable` возвращает асинхронный итератор нагрузок. Он слушает с момента вызова, так что payload, пришедшая до первого чтения, сохраняется. `source` — шина или контекст задачи; с контекстом цикл заканчивается ещё и когда задача прерывается.
157
+
158
+ ```js
159
+ import { createBus } from "nanoeffects";
160
+ import { events } from "nanoeffects/observable";
161
+
162
+ const bus = createBus();
163
+ const controller = new AbortController();
164
+
165
+ // events — payload "query/changed" по одной; хранит последние 3, пока цикл занят
166
+ const queries = events(bus, "query/changed", {
167
+ signal: controller.signal,
168
+ buffer: 3,
169
+ });
170
+
171
+ async function watchQueries() {
172
+ for await (const query of queries) {
173
+ console.log("search for", query); // выполняется один раз на payload, по порядку
174
+ }
175
+ }
176
+
177
+ watchQueries().catch((reason) => console.log("stopped:", reason)); // прерывание отклоняет цикл
178
+ bus.emit("query/changed", "cat");
179
+ controller.abort("screen closed"); // цикл заканчивается с этой причиной
180
+ ```
181
+
182
+ | Параметр | Что делает | По умолчанию |
183
+ | -------- | ---------- | ------------ |
184
+ | `signal` | Завершает цикл. Ожидающее чтение отклоняется с причиной прерывания. | нет |
185
+ | `buffer` | Наибольшее число нагрузок, которые хранятся, пока цикл занят. | `Infinity` |
186
+ | `overflow` | Какая payload уходит, когда буфер полон: `"drop-oldest"` или `"drop-newest"`. | `"drop-oldest"` |
187
+
188
+ - `break`, `return` и выброс ошибки внутри цикла отписывают обработчик.
189
+ - Без `buffer` цикл, который никогда не читает, хранит каждую payload. Для медленного цикла задайте `buffer`.
190
+ - Событие шины не заканчивается, поэтому цикл заканчивается только по `break`, `signal` или задаче.
191
+ - Внутри задачи `events(ctx, "query/changed")` не нуждается в `signal`: окончание задачи и есть конец.
192
+
193
+ ## Преобразование обратно в поток или Promise
194
+
195
+ | Что нужно | Как писать |
196
+ | --------- | ---------- |
197
+ | Поток событий с операторами | `from(observe("event"))` |
198
+ | Следующее событие как Promise | `firstValueFrom(from(observe("event")))`; внутри задачи то же делает `take(ctx, "event")` и учитывает прерывание |
199
+ | События шины как класс RxJS `Observable` (для `instanceof` или `pipe` как метода) | `new Observable((subscriber) => observe("event").subscribe(subscriber))` |
200
+ | Прерывание задачи как поток | `fromEvent(ctx.signal, "abort")`, для `takeUntil` |
201
+ | Последнее значение для новых подписчиков | `from(observe("event")).pipe(shareReplay(1))`; Observable сам ничего не воспроизводит |
202
+
203
+ События, сработавшие до `subscribe`, теряются: Observable горячий, как `bus.on`. Добавьте `startWith(initial)` или `shareReplay`, когда позднему подписчику нужно значение.
204
+
205
+ ## Завершение подписки
206
+
207
+ Подписка из `observe` не привязана к задаче и сама не завершится. Выберите один способ:
208
+
209
+ <!-- docs-check: skip - нужен rxjs, от которого проект не зависит -->
210
+ ```js
211
+ import { fromEvent, from } from "rxjs";
212
+ import { takeUntil } from "rxjs/operators";
213
+ import { onAbort } from "nanoeffects/effects";
214
+
215
+ bus.on("screen-opened", (_, ctx) => {
216
+ // takeUntil: остановить поток, когда задача прервётся
217
+ from(observe("typed"))
218
+ .pipe(takeUntil(fromEvent(ctx.signal, "abort")))
219
+ .subscribe((query) => bus.emit("search", query));
220
+
221
+ // или: onAbort вызывает unsubscribe за вас
222
+ const subscription = observe("scrolled").subscribe(updatePosition);
223
+ onAbort(ctx, subscription.unsubscribe);
224
+ });
225
+ ```
226
+
227
+ Вне задачи вызовите `subscription.unsubscribe()` сами, когда экран исчезает, или используйте сигнал модуля.
228
+
229
+ ## Ограничения
230
+
231
+ - Поток никогда не завершается и не падает: приходят только события. Ошибку вашего подписчика шина обрабатывает, только если задан `onError` ([Ошибки](errors.md)).
232
+ - `observe` обращается к шине при вызове `subscribe`, а не при создании. Создайте его один раз и вызывайте сколько нужно.
233
+ - Операторы, хранящие state (`scan`, `buffer`, `window`), живут в RxJS. Шина значений не хранит. Полное разделение работы см. в [nanoeffects рядом с RxJS](migrating-from-rxjs.md).