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.
- package/README.md +6 -5
- package/dist/{action-creator-jeEqIKgK.d.ts → action-creator-6rlBTmH9.d.ts} +27 -2
- package/dist/actions.d.ts +3 -3
- package/dist/actions.js +1 -1
- package/dist/{bus-0uYn_kRj.js → bus-DFlmNJ6h.js} +1 -1
- package/dist/context-B5Z2RpJt.js +1 -0
- package/dist/{context-types-Bme1mzkn.d.ts → context-types-DKNc5mkS.d.ts} +1 -1
- package/dist/context.d.ts +2 -2
- package/dist/dev.d.ts +49 -3
- package/dist/dev.js +2 -1
- package/dist/effects.d.ts +2 -2
- package/dist/effects.js +1 -1
- package/dist/emit-key-C6FL7Ewn.js +1 -0
- package/dist/guard.d.ts +59 -0
- package/dist/guard.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/match.d.ts +1 -1
- package/dist/{module-BWMW83qk.d.ts → module-G39MaVOt.d.ts} +2 -2
- package/dist/notrace/{action-creator-xpQ_I2h8.d.ts → action-creator-BrMSO3RP.d.ts} +1 -0
- package/dist/notrace/actions.d.ts +2 -2
- package/dist/notrace/effects.d.ts +2 -2
- package/dist/notrace/index.d.ts +1 -1
- package/dist/notrace/{plugin-6LhNx0th.d.ts → plugin-7GUL2EW5.d.ts} +1 -1
- package/dist/notrace/policies.d.ts +1 -1
- package/dist/notrace/redux.d.ts +1 -1
- package/dist/observable.d.ts +1 -1
- package/dist/oop.d.ts +1 -1
- package/dist/oop.js +1 -1
- package/dist/{plugin-BaErevLy.d.ts → plugin-BWNP23hr.d.ts} +2 -2
- package/dist/policies.d.ts +2 -2
- package/dist/policies.js +1 -1
- package/dist/production/dev.js +1 -1
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/redux.d.ts +1 -1
- package/dist/scope.d.ts +2 -2
- package/dist/signal.d.ts +22 -2
- package/dist/signal.js +1 -1
- package/dist/site-DLvUoXs3.js +1 -0
- package/dist/svelte.d.ts +1 -1
- package/dist/trace.d.ts +151 -2
- package/dist/trace.js +3 -1
- package/dist/{types-D5xsR37j.d.ts → types-N_cdRr1r.d.ts} +2 -2
- package/dist/vue.d.ts +1 -1
- package/dist/{with-policy-C6BIiPIi.js → with-policy-CeM1rtES.js} +1 -1
- package/dist/{with-policy-BZVRBUOm.d.ts → with-policy-CthiBp_z.d.ts} +1 -1
- package/docs/README.md +62 -31
- package/docs/actions.md +44 -27
- package/docs/cancel-stale-requests.md +2 -0
- package/docs/chat-example.md +3 -1
- package/docs/chat-widget.md +2 -0
- package/docs/context.md +322 -9
- package/docs/debounce-and-abort.md +3 -1
- package/docs/debugging-loops.md +318 -0
- package/docs/effects.md +255 -138
- package/docs/errors.md +4 -2
- package/docs/examples.md +60 -0
- package/docs/guide.md +39 -22
- package/docs/hmr.md +35 -1
- package/docs/loop-guard.md +109 -0
- package/docs/migrating-from-effector.md +4 -2
- package/docs/migrating-from-mitt.md +6 -4
- package/docs/migrating-from-rxjs.md +2 -0
- package/docs/migrating-from-saga.md +2 -0
- package/docs/motivation.md +14 -21
- package/docs/observable.md +2 -0
- package/docs/oop.md +3 -1
- package/docs/package.md +4 -1
- package/docs/pass-the-signal.md +346 -0
- package/docs/plugins.md +22 -5
- package/docs/policies.md +73 -46
- package/docs/prevent-double-submit.md +3 -1
- package/docs/query-libraries.md +3 -1
- package/docs/react.md +42 -13
- package/docs/recipes.md +528 -0
- package/docs/ru/README.md +62 -0
- package/docs/ru/actions.md +412 -0
- package/docs/ru/cancel-stale-requests.md +106 -0
- package/docs/ru/chat-example.md +259 -0
- package/docs/ru/chat-widget.md +131 -0
- package/docs/ru/context.md +402 -0
- package/docs/ru/debounce-and-abort.md +105 -0
- package/docs/ru/debugging-loops.md +318 -0
- package/docs/ru/effects.md +538 -0
- package/docs/ru/errors.md +36 -0
- package/docs/ru/examples.md +60 -0
- package/docs/ru/guide.md +530 -0
- package/docs/ru/hmr.md +83 -0
- package/docs/ru/loop-guard.md +109 -0
- package/docs/ru/migrating-from-effector.md +339 -0
- package/docs/ru/migrating-from-mitt.md +99 -0
- package/docs/ru/migrating-from-rxjs.md +197 -0
- package/docs/ru/migrating-from-saga.md +97 -0
- package/docs/ru/motivation.md +47 -0
- package/docs/ru/observable.md +233 -0
- package/docs/ru/oop.md +431 -0
- package/docs/ru/package.md +42 -0
- package/docs/ru/pass-the-signal.md +346 -0
- package/docs/ru/plugins.md +398 -0
- package/docs/ru/policies.md +201 -0
- package/docs/ru/prevent-double-submit.md +122 -0
- package/docs/ru/query-libraries.md +392 -0
- package/docs/ru/react.md +309 -0
- package/docs/ru/recipes.md +528 -0
- package/docs/ru/semantics.md +19 -0
- package/docs/ru/server.md +204 -0
- package/docs/ru/starter.md +118 -0
- package/docs/ru/stores.md +673 -0
- package/docs/ru/svelte.md +78 -0
- package/docs/ru/tracing.md +466 -0
- package/docs/ru/typescript.md +170 -0
- package/docs/ru/vue.md +92 -0
- package/docs/semantics.md +2 -0
- package/docs/server.md +2 -0
- package/docs/starter.md +2 -0
- package/docs/stores.md +2 -0
- package/docs/svelte.md +3 -1
- package/docs/tracing.md +58 -1
- package/docs/typescript.md +2 -0
- package/docs/vue.md +3 -1
- package/llms-full.txt +2087 -418
- package/llms.txt +11 -8
- package/package.json +23 -6
- package/dist/context-DtUnzC-1.js +0 -1
- package/docs/scope.md +0 -278
- package/docs/stability.md +0 -90
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Документация
|
|
2
|
+
|
|
3
|
+
nanoeffects выносит бизнес-логику и побочные эффекты из компонентов в обычные `async`-функции. Политики (`latest`, `debounce`, `queue`) и эффекты (`retry`, `timeout`, `poll`) решают гонки, повторы и отмену запросов.
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
import { createBus } from "nanoeffects";
|
|
7
|
+
import { latest } from "nanoeffects/policies";
|
|
8
|
+
|
|
9
|
+
const bus = createBus(); // шина событий
|
|
10
|
+
|
|
11
|
+
// Логика поиска живёт вне компонента; latest прерывает предыдущий запрос
|
|
12
|
+
bus.on(
|
|
13
|
+
"search",
|
|
14
|
+
latest(async (query, { signal }) => {
|
|
15
|
+
const items = await api.search(query, { signal });
|
|
16
|
+
bus.emit("found", items);
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
// Компонент только слушает результат и отправляет события
|
|
21
|
+
bus.on("found", (items) => render(items));
|
|
22
|
+
bus.emit("search", "nano");
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Тот же пример с UI-фреймворком: [React](react.md), [Vue](vue.md), [Svelte](svelte.md). Готовые проекты: [Проект из шаблона](starter.md).
|
|
26
|
+
|
|
27
|
+
## Установка
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install nanoeffects
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Новый проект из шаблона ([Проект из шаблона](starter.md)):
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm create nanoeffects@latest my-app
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## С чего начать
|
|
40
|
+
|
|
41
|
+
1. Пройдите [Руководство](guide.md#1-создание-шины-событий): от первой шины до модулей.
|
|
42
|
+
2. Типичные задачи async UI (устаревшие ответы, двойные клики, повторы запросов) разобраны в [Типовых задачах](recipes.md).
|
|
43
|
+
|
|
44
|
+
## Виды проектов
|
|
45
|
+
|
|
46
|
+
| Уровень | Проект | Читать |
|
|
47
|
+
| ------- | ------ | ------ |
|
|
48
|
+
| **A** | Небольшое приложение, виджет или MVP | [Руководство](guide.md#1-создание-шины-событий) |
|
|
49
|
+
| **B** | SPA с async UI: поиск, отправка сообщений, размонтирование компонентов | [Политики](policies.md), [Руководство](guide.md#3-прерывание-предыдущих-запусков) |
|
|
50
|
+
| **C** | Сложная бизнес-логика | [Эффекты](effects.md), [Руководство](guide.md#5-retry-и-timeout) |
|
|
51
|
+
| **D** | Доменная логика вне UI | [Actions, jobs и модули](actions.md), [Руководство](guide.md#7-способы-отправки-событий) |
|
|
52
|
+
| **E** | Готовое приложение на Redux, Zustand, MobX или Pinia, либо библиотека данных | [State managers](stores.md), [Библиотеки запросов](query-libraries.md) |
|
|
53
|
+
| **F** | Платформа, SDK или долгоживущее приложение | [Трассировка](tracing.md), [Hot reload](hmr.md), [Семантика](semantics.md) |
|
|
54
|
+
|
|
55
|
+
## Справочник
|
|
56
|
+
|
|
57
|
+
- [Точки входа пакета](package.md)
|
|
58
|
+
- [Плагины](plugins.md): что даёт каждый плагин шины и в каком порядке их подключать
|
|
59
|
+
- [Семантика](semantics.md): точные правила `emit`, отмены и порядка
|
|
60
|
+
- [Трассировка](tracing.md): логгеры, Redux DevTools, `notrace`
|
|
61
|
+
- [Observable](observable.md): события как Observable стандарта TC39
|
|
62
|
+
- [Hot reload и диагностика при разработке](hmr.md)
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# Actions, jobs и модули
|
|
2
|
+
|
|
3
|
+
> **[Уровень D](README.md#виды-проектов)**: доменная логика вне UI. Сначала прочитайте: [Руководство](guide.md#1-создание-шины-событий).
|
|
4
|
+
|
|
5
|
+
Когда в проекте становится много бизнес-логики, её нужно разделить. Один из способов — разделить по доменам, или сущностям. Например, в магазине доменами могут быть товары и категории товаров. Тогда код с actions одного домена лежит в одном файле (`product.js`), его обработчики — в другом, а модуль, который их подключает, — в третьем. Домен можно подключить, заменить или отключить, не трогая остальные. Для магазина файлы могли бы выглядеть так:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
src/
|
|
9
|
+
bus.js
|
|
10
|
+
product/
|
|
11
|
+
product.js
|
|
12
|
+
product-handlers.js
|
|
13
|
+
product-module.js
|
|
14
|
+
category/
|
|
15
|
+
category.js
|
|
16
|
+
category-handlers.js
|
|
17
|
+
category-module.js
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Содержимое таких файлов показывают примеры ниже.
|
|
21
|
+
|
|
22
|
+
Если вам нужны только `on` и `emit` или политики, оставайтесь в [Руководстве](guide.md): сюда стоит идти, когда нужен результат `emit` или подключаемый домен.
|
|
23
|
+
|
|
24
|
+
Рецепт без UI-фреймворка: [`examples/modules.ts`](../../examples/modules.ts).
|
|
25
|
+
|
|
26
|
+
Чат с двумя сущностями и тремя фичами, jobs под политиками и эффектами и поток, привязанный к модулю: [Чат с сущностями, фичами, модулями и небольшим backend](chat-example.md).
|
|
27
|
+
|
|
28
|
+
Целый проект с двумя доменами, job и React как тонким слоем: `npm create nanoeffects@latest my-app -- --template domains-ts` (см. [Проект из шаблона](starter.md#начало-с-доменами)).
|
|
29
|
+
|
|
30
|
+
## Action, job и модуль
|
|
31
|
+
|
|
32
|
+
Четыре формы, у каждой одна идея. Берите первую подходящую строку.
|
|
33
|
+
|
|
34
|
+
| Форма | Что это | Когда использовать |
|
|
35
|
+
| ----- | ------- | ------------------ |
|
|
36
|
+
| `createAction(type)` | Action без обработчика: имя и payload. | UI или обработчик сообщает, что что-то произошло. |
|
|
37
|
+
| `createAction(type, handler)` | Action с обработчиком: `emit` запускает обработчик и возвращает его результат. | Нужно получить результат обработчика из `emit`. |
|
|
38
|
+
| `createJob(type, handler)` | Action с обработчиком, результат которого приходит как actions `success` и `fail`; кроме них job отправляет `pending` и `cancel`. | Нужно получить результат работы обработчика и, если понадобится, подписаться на state этой работы. |
|
|
39
|
+
| `createModule(name, setup)` | Группа actions и jobs с их обработчиками, которая подключается и отключается одним вызовом. | Один домен должен запускаться и останавливаться как единое целое. |
|
|
40
|
+
|
|
41
|
+
Начинайте с actions. Добавьте обработчик, когда нужен результат, job — когда нужен статус, модуль — когда нужно единое целое.
|
|
42
|
+
|
|
43
|
+
## На этой странице
|
|
44
|
+
|
|
45
|
+
- [Action, job и модуль](#action-job-и-модуль)
|
|
46
|
+
- [Получение результата `emit`](#получение-результата-emit)
|
|
47
|
+
- [Actions, handlers и jobs](#actions-handlers-и-jobs)
|
|
48
|
+
- [Actions](#actions)
|
|
49
|
+
- [Привязка обработчика позже](#привязка-обработчика-позже)
|
|
50
|
+
- [Тип результата привязанного обработчика](#тип-результата-привязанного-обработчика)
|
|
51
|
+
- [Jobs](#jobs)
|
|
52
|
+
- [State работы job](#state-работы-job)
|
|
53
|
+
- [Модули](#модули)
|
|
54
|
+
- [Расширение контекста actions и модулей](#расширение-контекста-actions-и-модулей)
|
|
55
|
+
|
|
56
|
+
## Получение результата `emit`
|
|
57
|
+
|
|
58
|
+
Когда при вызове `emit` нужен ответ от обработчика, передайте обработчик в `createAction`. Обработчик — функция `(payload, ctx) => result`, которая выполняется, когда action отправлен. `emit` запустит её и вернёт результат. Создатель один и для action без обработчика, и для action с ним; разница только в обработчике.
|
|
59
|
+
|
|
60
|
+
Шина живёт в одном файле, а её actions — в другом, так что любой модуль может импортировать оба без цикла.
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
// bus.js
|
|
64
|
+
import { createBus } from "nanoeffects";
|
|
65
|
+
|
|
66
|
+
// createBus — одна шина на приложение
|
|
67
|
+
export const bus = createBus();
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
// math.js
|
|
72
|
+
import { createAction } from "nanoeffects/actions";
|
|
73
|
+
|
|
74
|
+
// createAction с обработчиком — emit запускает обработчик и возвращает его результат
|
|
75
|
+
export const double = createAction("math/double", (value) => value * 2);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
import { bus } from "./bus";
|
|
80
|
+
import { double } from "./math";
|
|
81
|
+
|
|
82
|
+
bus.emit(double(21)); // 42
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Actions, handlers и jobs
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
// user.js
|
|
89
|
+
import { createAction, createJob } from "nanoeffects/actions";
|
|
90
|
+
|
|
91
|
+
// createAction — action с payload (как createAction в RTK)
|
|
92
|
+
export const userLoaded = createAction("user/loaded");
|
|
93
|
+
export const loadFailed = createAction("user/failed");
|
|
94
|
+
|
|
95
|
+
// createAction с обработчиком — emit возвращает то, что вернул обработчик
|
|
96
|
+
export const getUser = createAction("user/get", (id, { signal }) => {
|
|
97
|
+
return fetchUser(id, { signal });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// createJob — action с обработчиком, который ещё и отправляет actions success / fail
|
|
101
|
+
export const loadUser = createJob("user/load", fetchUser, {
|
|
102
|
+
success: userLoaded,
|
|
103
|
+
fail: "user/failed",
|
|
104
|
+
});
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Это actions домена user в отдельном файле (`user.js`). Шина создаётся в другом файле (`bus.js` ниже), и ни один не импортирует другой.
|
|
108
|
+
|
|
109
|
+
### Actions
|
|
110
|
+
|
|
111
|
+
`createAction(type)` возвращает action creator. Без обобщений он сохраняет литеральный тип; шина проверяет payload по своей карте событий. Без карты событий укажите тип payload: `createAction<User>("user/loaded")`.
|
|
112
|
+
|
|
113
|
+
- `userLoaded(user)` → `{ type, payload }`
|
|
114
|
+
- `userLoaded.type` — строка типа
|
|
115
|
+
- `userLoaded.match(action)` — сужает тип action
|
|
116
|
+
- `createAction(type, handler)` — функция вторым аргументом — это обработчик; `bus.emit(action(payload))` сообщает подписчикам, запускает обработчик как задачу и возвращает его результат
|
|
117
|
+
- `createAction(type, { prepare })` — собрать payload из нескольких аргументов
|
|
118
|
+
- `createAction(type, { handle, prepare, ...scope })` — всё в одном объекте: `handle` и `prepare` извлекаются, а каждое остальное поле — это [scope](context.md#scope-поля-для-action-модуля-и-вызова)
|
|
119
|
+
- Работает как событие своего типа: `bus.emit(userLoaded(user))`, `bus.on(userLoaded, fn)`
|
|
120
|
+
|
|
121
|
+
Scope идёт после обработчика последним аргументом, `createAction(type, handler, scope)`; `prepare` вместе с обработчиком идёт в этот последний объект: `createAction(type, handler, { prepare, ...scope })`.
|
|
122
|
+
|
|
123
|
+
- Оберните обработчик в политику: `createAction("search", latest(fn))`.
|
|
124
|
+
|
|
125
|
+
Action без обработчика — это событие. Его отправка не выбрасывает ошибку, а `onUnhandled` узнаёт о нём, когда никто не слушает.
|
|
126
|
+
|
|
127
|
+
### Привязка обработчика позже
|
|
128
|
+
|
|
129
|
+
**Проблема.** Работа принадлежит другому слою, или тест должен её подменить. Если положить обработчик в `createAction`, файл события окажется связан с работой.
|
|
130
|
+
|
|
131
|
+
**Когда.** Используйте реестр, когда обработчик неизвестен там, где создаётся action. У action может не быть обработчика вовсе; ему отвечает обработчик, привязанный к его типу. У каждой шины (каждого теста) свой реестр.
|
|
132
|
+
|
|
133
|
+
```js
|
|
134
|
+
// mail.js
|
|
135
|
+
import { createAction } from "nanoeffects/actions";
|
|
136
|
+
|
|
137
|
+
// createAction без обработчика — событие; работа привязывается в другом месте
|
|
138
|
+
export const sendMail = createAction("mail/send");
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
// bus.js
|
|
143
|
+
import { createBus } from "nanoeffects";
|
|
144
|
+
import { actionsPlugin, setHandler } from "nanoeffects/actions";
|
|
145
|
+
import { sendMail } from "./mail";
|
|
146
|
+
|
|
147
|
+
export const bus = createBus();
|
|
148
|
+
|
|
149
|
+
// actionsPlugin — реестр обработчиков этой шины; setHandler связывает action с его работой
|
|
150
|
+
export const actions = bus.use(
|
|
151
|
+
actionsPlugin([setHandler(sendMail, smtpSend)]),
|
|
152
|
+
);
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
import { actions } from "./bus";
|
|
157
|
+
import { getUser } from "./user";
|
|
158
|
+
|
|
159
|
+
// позже, например в тесте — побеждает последняя привязка, а привязанный здесь обработчик главнее того, что в createAction
|
|
160
|
+
const unbind = actions.handle(getUser, async () => fakeUser);
|
|
161
|
+
unbind();
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Обработчик, привязанный в реестре, заставляет `bus.emit(sendMail(mail))` запустить его и вернуть его результат, как и для action со своим обработчиком. Подписки на этот тип получают сообщение первыми. Если обработчик не привязан, `sendMail` снова просто событие.
|
|
165
|
+
|
|
166
|
+
Реестр живёт в `nanoeffects/actions`, поэтому шина без него этого кода не содержит. Без реестра `emit` запускает собственный обработчик action. `bus.use(actionsPlugin())` задаёт `bus.actions`. Второй `actionsPlugin` привязывает свой список в том же реестре и возвращает его. В `createBus` нет параметра `actions`. Собственный исполнитель назначьте через `bus.actions = { run }`, а добавив ему `bound(type)`, вы сможете отвечать за actions без собственного обработчика.
|
|
167
|
+
|
|
168
|
+
### Тип результата привязанного обработчика
|
|
169
|
+
|
|
170
|
+
TypeScript не видит, какой обработчик лежит в реестре, поэтому тип `emit` для action без обработчика — `void`. Скажите ему, что возвращает привязанный обработчик, через `Call<Payload, Result>`. Это ваше обещание: ничто не сверяет его с обработчиком.
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
// mail.ts
|
|
174
|
+
import type { Call } from "nanoeffects";
|
|
175
|
+
import { createAction } from "nanoeffects/actions";
|
|
176
|
+
|
|
177
|
+
// Call — payload Mail, привязанный обработчик отвечает булевым значением (только для проверки типов; ничего не работает иначе)
|
|
178
|
+
type Mail = { to: string };
|
|
179
|
+
export const sendMail = createAction<Call<Mail, boolean>>("mail/send");
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
В карте событий тот же тип решает задачу для строковых событий, и `emit` возвращает `boolean`:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
// events.ts
|
|
186
|
+
import type { Call } from "nanoeffects";
|
|
187
|
+
|
|
188
|
+
// Call в карте — payload проверяется, emit возвращает результат (только для проверки типов)
|
|
189
|
+
export type Events = {
|
|
190
|
+
"user/getId": Call<void, string>;
|
|
191
|
+
"user/loaded": { id: string };
|
|
192
|
+
};
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
На шине без карты событий это можно сказать в месте вызова: `const id = bus.emit<string>("user/getId")`. Шина с картой такой подсказки не принимает, поэтому её имена и payload остаются проверяемыми.
|
|
196
|
+
|
|
197
|
+
### Jobs
|
|
198
|
+
|
|
199
|
+
`createJob(type, handler, { pending, success, fail, cancel })` создаёт job: action с обработчиком, который ещё и отправляет свой исход как actions, когда никто не ждёт результат. Он возвращает action с `loadUser.pending`, `loadUser.success`, `loadUser.fail` и `loadUser.cancel` на нём.
|
|
200
|
+
|
|
201
|
+
- Когда обработчик стартует, отправляет `loadUser.pending(payload)`.
|
|
202
|
+
- При успехе отправляет `loadUser.success(result)`.
|
|
203
|
+
- При ошибке отправляет `loadUser.fail(error)`.
|
|
204
|
+
- Когда job прерван до завершения обработчика, отправляет `loadUser.cancel(reason)` вместо `success` или `fail`. После `pending` следует ровно одно из трёх.
|
|
205
|
+
- `pending` / `success` / `fail` / `cancel` — это строка (job создаёт action creator) или уже существующий action creator. Отсутствующие становятся `<type>/pending`, `<type>/success`, `<type>/fail` или `<type>/cancel`.
|
|
206
|
+
- `bus.emit(loadUser(id))` возвращает Promise результата. Ошибки обработчика всегда обрабатываются: они уходят в `fail`, а Promise завершается значением `undefined`. Для результата, который должен отклоняться, передайте обработчик в `createAction`.
|
|
207
|
+
- Исходы-actions отправляются от имени задачи, которая запустила job (и для обработчика, привязанного через `actions.handle`). `success` и `fail` после прерывания не отправляются; `cancel` отправляется в момент прерывания.
|
|
208
|
+
- Job без обработчика — не исход: `emit` выбрасывает `No handler for action "..."` и ничего не отправляется. Action без обработчика — событие и ошибку не выбрасывает.
|
|
209
|
+
- Вызов job лишь строит `{ type, payload }`; обработчик запускается на `emit`.
|
|
210
|
+
- Исходы — такие же события, как все остальные: если на шине задан `onUnhandled`, он вызывается для `pending` или `cancel`, которые никто не слушает.
|
|
211
|
+
|
|
212
|
+
## State работы job
|
|
213
|
+
|
|
214
|
+
**Проблема.** Спиннеру нужен флаг, который равен `true`, пока идёт запрос. Выставлять его вручную в обработчике (`loading = true`, затем `try` / `finally`) ломается, когда задача прерывается: `ctx.emit` после прерывания ничего не делает, поэтому флаг, сбрасываемый через шину, остаётся `true`.
|
|
215
|
+
|
|
216
|
+
**Когда.** UI показывает state загрузки для запроса, а запрос можно прервать (страницу покинули, вызван `ctx.abort()`). Пропустите это, если state ничего не показывает.
|
|
217
|
+
|
|
218
|
+
Job отправляет один action, когда обработчик стартует, и один, когда он заканчивается, как бы ни закончился. Store выставляет флаг на `pending` и сбрасывает на `success`, `fail` и `cancel`.
|
|
219
|
+
|
|
220
|
+
```js
|
|
221
|
+
// bus.js
|
|
222
|
+
import { createBus } from "nanoeffects";
|
|
223
|
+
|
|
224
|
+
// createBus — шина, общая для приложения
|
|
225
|
+
export const bus = createBus();
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
```js
|
|
229
|
+
// user.js
|
|
230
|
+
import { createJob } from "nanoeffects/actions";
|
|
231
|
+
|
|
232
|
+
// createJob — action с обработчиком, который ещё и отправляет pending, success, fail и cancel
|
|
233
|
+
export const loadUser = createJob("user/load", fetchUser);
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
```js
|
|
237
|
+
// user-store.js
|
|
238
|
+
import { bus } from "./bus";
|
|
239
|
+
import { loadUser } from "./user";
|
|
240
|
+
|
|
241
|
+
export const state = { loading: false, user: null };
|
|
242
|
+
|
|
243
|
+
// pending — обработчик стартует: показать спиннер
|
|
244
|
+
bus.on(loadUser.pending, () => {
|
|
245
|
+
state.loading = true;
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// success — обработчик вернул пользователя
|
|
249
|
+
bus.on(loadUser.success, (user) => {
|
|
250
|
+
state.loading = false;
|
|
251
|
+
state.user = user;
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// fail — обработчик выбросил ошибку
|
|
255
|
+
bus.on(loadUser.fail, () => {
|
|
256
|
+
state.loading = false;
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// cancel — задача прервана: единственный способ сбросить флаг в этом случае
|
|
260
|
+
bus.on(loadUser.cancel, () => {
|
|
261
|
+
state.loading = false;
|
|
262
|
+
});
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
```js
|
|
266
|
+
// page.js
|
|
267
|
+
import { bus } from "./bus";
|
|
268
|
+
import { loadUser } from "./user";
|
|
269
|
+
|
|
270
|
+
// emit — запускает задачу; state.loading равен true, как только он вернулся
|
|
271
|
+
bus.emit(loadUser("1"));
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
`cancel` отправляется в момент прерывания, с причиной прерывания. Обработчик, который игнорирует сигнал и заканчивает позже, больше ничего не отправляет, так что флаг уже `false`.
|
|
275
|
+
|
|
276
|
+
**С политикой.** Политика вроде `latest`, `leading`, `debounce`, `throttle` или `queue` вокруг обработчика сообщает job, что она делает с каждым вызовом:
|
|
277
|
+
|
|
278
|
+
- `pending` отправляется, когда обработчик действительно стартует: сразу для `leading`, после ожидания для `debounce`, когда подойдёт очередь для `queue`.
|
|
279
|
+
- Вызов, который политика отбрасывает до старта, ничего не отправляет, даже `pending`. Второй клик, который `leading` игнорирует, не сбрасывает флаг первого.
|
|
280
|
+
- Запуск, который заменяет `latest`, отправляет `cancel` раньше, чем следующий вызов отправит `pending`.
|
|
281
|
+
- За каждым `pending` следует ровно одно из `success`, `fail` и `cancel`, и без `pending` ничего не отправляется.
|
|
282
|
+
|
|
283
|
+
Флаг точен, когда вызовы не пересекаются или когда их заменяет `latest`. Если одновременно могут идти несколько запусков (`queue` с `concurrency` больше 1 или job без политики, вызванный много раз), ведите счётчик: прибавляйте единицу на `pending` и убавляйте на `success`, `fail` и `cancel`. Политика, которую вы написали сами или спрятали внутри собственной функции, job не сообщает; тогда `pending` отправляется в момент вызова, как для job без политики.
|
|
284
|
+
|
|
285
|
+
## Модули
|
|
286
|
+
|
|
287
|
+
**Проблема.** Домену нужно несколько подписок и привязок обработчиков. С обычными `bus.on` / `actions.handle` приходится помнить каждую отписку.
|
|
288
|
+
|
|
289
|
+
**Когда.** Домен нужно подключать и отключать одним вызовом. Пока хватает нескольких `bus.on` верхнего уровня, модули не нужны.
|
|
290
|
+
|
|
291
|
+
`bus.use(modulePlugin(modules))` собирает приложение из модулей и возвращает `mount`, `unmount`, `isMounted`, `dispose`, `actions` и `createModule`: то же, что `createModule` ниже, но с типизированными полями контекста шины (см. [TypeScript](typescript.md)).
|
|
292
|
+
|
|
293
|
+
```js
|
|
294
|
+
// user.js
|
|
295
|
+
import { createAction } from "nanoeffects/actions";
|
|
296
|
+
|
|
297
|
+
export const getUser = createAction("user/get");
|
|
298
|
+
export const saveUser = createAction("user/save", persistUser);
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
```js
|
|
302
|
+
// user-module.js
|
|
303
|
+
import { createModule } from "nanoeffects/actions";
|
|
304
|
+
import { getUser, saveUser } from "./user";
|
|
305
|
+
|
|
306
|
+
// createModule — один домен; unmount отменяет всё ниже
|
|
307
|
+
export const userModule = createModule("user", (m) => {
|
|
308
|
+
m.handle(getUser, fetchUser);
|
|
309
|
+
m.use(saveUser, authModule);
|
|
310
|
+
m.on("login", (user, ctx) => {
|
|
311
|
+
ctx.emit("loaded", user);
|
|
312
|
+
});
|
|
313
|
+
m.spawn(async (_, ctx) => {
|
|
314
|
+
/* фоновая работа; заканчивается, когда модуль размонтируется */
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
```js
|
|
320
|
+
// bus.js
|
|
321
|
+
import { createBus } from "nanoeffects";
|
|
322
|
+
import { modulePlugin } from "nanoeffects/actions";
|
|
323
|
+
import { userModule } from "./user-module";
|
|
324
|
+
|
|
325
|
+
export const bus = createBus();
|
|
326
|
+
// modulePlugin — подключает модули к шине
|
|
327
|
+
export const app = bus.use(modulePlugin([userModule]));
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
```js
|
|
331
|
+
import { app } from "./bus";
|
|
332
|
+
import { getUser } from "./user";
|
|
333
|
+
|
|
334
|
+
const unmount = app.mount(orderModule); // позже, например после ленивого импорта
|
|
335
|
+
app.actions.handle(getUser, fakeUser); // в тесте
|
|
336
|
+
app.dispose(); // отключает каждый модуль
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
`mount` и `unmount` принимают любое число модулей. Они запускают домены приложения в выбранный вами момент и в заданном порядке, если вы не передаёте модули в `modulePlugin`:
|
|
340
|
+
|
|
341
|
+
```js
|
|
342
|
+
import { createModule } from "nanoeffects/actions";
|
|
343
|
+
import { app } from "./bus";
|
|
344
|
+
|
|
345
|
+
const userModule = createModule("user", (m) => {
|
|
346
|
+
m.on("user/loaded", (user) => console.log(user));
|
|
347
|
+
});
|
|
348
|
+
const orderModule = createModule("order", (m) => {
|
|
349
|
+
m.on("order/placed", (order) => console.log(order));
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// mount — запустить оба модуля, слева направо
|
|
353
|
+
const stop = app.mount(userModule, orderModule);
|
|
354
|
+
|
|
355
|
+
// возвращённая функция снова останавливает их, последний подключённый первым (orderModule, затем userModule)
|
|
356
|
+
stop();
|
|
357
|
+
|
|
358
|
+
// то же для модулей, которые у вас уже лежат в массиве: разверните его
|
|
359
|
+
const modules = [userModule, orderModule];
|
|
360
|
+
app.mount(...modules);
|
|
361
|
+
app.unmount(...modules); // тоже в обратном порядке
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Если `setup` одного модуля выбрасывает ошибку, модули этого вызова, которые уже подключились, отключаются обратно, а ошибка выбрасывается, так что вызов подключает все свои модули или ни одного. Модуль, который уже подключён, пропускается.
|
|
365
|
+
|
|
366
|
+
- **Отключение отменяет всё**, что сделал модуль: подписки, привязанные обработчики, подмодули и фоновые задачи (прерываются).
|
|
367
|
+
- **Модуль известен по имени.** Подключение модуля с занятым именем заменяет подключённый (см. [Hot reload](hmr.md)).
|
|
368
|
+
- **Реестр.** Если первым выполнился `bus.use(actionsPlugin())`, используется тот реестр; иначе плагин модулей подключает свой собственный. В любом случае это `bus.actions` и `app.actions`.
|
|
369
|
+
- Приложение после `dispose` не может подключать модули.
|
|
370
|
+
- **Переданные модули подключаются в конце блока, создавшего приложение**, либо при первом использовании приложения или шины, если оно случится раньше (первый `bus.emit` или вызов `mount`, `unmount`, `isMounted`, `dispose` либо чтение `actions`). Приложение использовать не обязательно: чтобы запустить модули, хватит `bus.use(modulePlugin([a, b]))`. Плагины, которые вы подключаете после `modulePlugin` в том же блоке, всё равно до них доходят, потому что модули ждут окончания этого блока. Плагин, подключённый после `await`, может опоздать: подключайте каждый плагин до первого `await`. Вызовы `bus.on`, сделанные в промежутке, удерживаются и выполняются сразу после, так что подписки модуля остаются впереди них. См. [плагины в любом порядке](plugins.md#порядок-подключения).
|
|
371
|
+
- **Чтобы подключать вручную**, не передавайте модули: `modulePlugin()` ничего не запускает, а `app.mount(...)` запускает модули, когда вам нужно.
|
|
372
|
+
- Контекст модуля = контекст задачи плюс `handle`, `use` и `spawn`:
|
|
373
|
+
- `handle` — дать action обработчик (тому, у кого его нет, или переопределить).
|
|
374
|
+
- `use` — подключить подмодули и actions, несущие обработчик; возвращённая функция отменяет этот вызов.
|
|
375
|
+
- `spawn` — фоновая задача, прерывается при отключении.
|
|
376
|
+
- Action, несущий обработчик, продолжает отвечать и после отключения (`use` лишь отслеживает регистрацию). Для обработчика, который должен жить только пока жив модуль, используйте `handle`.
|
|
377
|
+
|
|
378
|
+
## Расширение контекста actions и модулей
|
|
379
|
+
|
|
380
|
+
Action с обработчиком может понадобиться свой клиент, модулю — свой логгер. Передайте scope — обычный объект, ключи которого попадают на `ctx` (`$effects` привязывает функции к задаче), последним аргументом `createAction`, `createModule` и `createJob`, и включите его через `scopePlugin`:
|
|
381
|
+
|
|
382
|
+
```js
|
|
383
|
+
// bus.js
|
|
384
|
+
import { createBus } from "nanoeffects";
|
|
385
|
+
import { scopePlugin } from "nanoeffects/scope";
|
|
386
|
+
|
|
387
|
+
// scopePlugin — включает scope action на этой шине
|
|
388
|
+
export const bus = createBus().use(scopePlugin());
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
```js
|
|
392
|
+
// user.js
|
|
393
|
+
import { createAction } from "nanoeffects/actions";
|
|
394
|
+
|
|
395
|
+
// третий аргумент — поля только этого action
|
|
396
|
+
export const getUser = createAction(
|
|
397
|
+
"user/get",
|
|
398
|
+
(id, { api }) => api.getUser(id),
|
|
399
|
+
{ api },
|
|
400
|
+
);
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
```js
|
|
404
|
+
import { bus } from "./bus";
|
|
405
|
+
import { getUser } from "./user";
|
|
406
|
+
|
|
407
|
+
// тест подменяет их для одного вызова
|
|
408
|
+
const fakeApi = { getUser: async (id) => ({ id, name: "test" }) };
|
|
409
|
+
bus.emit(getUser("1"), { api: fakeApi });
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Обработчик получает поля с типами. Без `scopePlugin` scope игнорируется. Полная картина, с задачами, actions, модулями и тем, какое значение побеждает, описана в [областях (scope)](context.md#scope-поля-для-action-модуля-и-вызова).
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Отмена устаревших запросов и гонки
|
|
2
|
+
|
|
3
|
+
> **[Уровень B](README.md#виды-проектов)**: SPA с async UI. Сначала прочитайте: [Руководство](guide.md#1-создание-шины-событий).
|
|
4
|
+
|
|
5
|
+
Старый ответ затирает новый: поле поиска показывает результаты предыдущего запроса, фильтр мигает, форма отправляется дважды. Причина, исправление через `latest`, пауза, повтор и таймаут. Нужна только шина: [Руководство](guide.md#1-создание-шины-событий), шаг 1.
|
|
6
|
+
|
|
7
|
+
## Проблема
|
|
8
|
+
|
|
9
|
+
Пользователь печатает `ab`, затем `abc`. Стартуют два запроса. Если запрос для `ab` медленнее, он завершается последним, и его результаты заменяют правильные.
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import { createBus } from "nanoeffects";
|
|
13
|
+
|
|
14
|
+
const bus = createBus(); // шина: события и обработчики
|
|
15
|
+
|
|
16
|
+
// Без политики каждый "search" запускает свой запрос, и побеждает самый медленный.
|
|
17
|
+
bus.on("search", async (query) => {
|
|
18
|
+
const items = await api.search(query);
|
|
19
|
+
bus.emit("results", items);
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Ручная проверка счётчика (`if (id !== lastId) return`) скрывает старый результат, но старый запрос всё равно выполняется. `controller.abort()` можно вызывать вручную, но контроллер придётся где-то хранить и прерывать перед каждым новым вызовом.
|
|
24
|
+
|
|
25
|
+
## Только последний запрос
|
|
26
|
+
|
|
27
|
+
Оберните обработчик в `latest`. Новый `"search"` прерывает задачу предыдущего, а прерванная задача результата не выдаёт.
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
import { createBus } from "nanoeffects";
|
|
31
|
+
import { latest } from "nanoeffects/policies";
|
|
32
|
+
|
|
33
|
+
const bus = createBus(); // шина: события и обработчики
|
|
34
|
+
|
|
35
|
+
// latest: новый "search" прерывает старый запуск
|
|
36
|
+
bus.on(
|
|
37
|
+
"search",
|
|
38
|
+
latest(async (query, { signal }) => {
|
|
39
|
+
// signal: передайте его дальше, чтобы браузер отменил запрос, а не только отбросил результат
|
|
40
|
+
const items = await api.search(query, { signal });
|
|
41
|
+
bus.emit("results", items);
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
bus.on("results", (items) => render(items));
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Что заменить: `api.search` — ваша функция запроса. Она должна принимать `{ signal }` и передавать его в `fetch`. Без этого `latest` по-прежнему игнорирует старый результат, но запрос продолжает выполняться.
|
|
49
|
+
|
|
50
|
+
Результат: после `emit("search", "ab")` и `emit("search", "abc")` вы увидите только результаты для `abc`, а запрос для `ab` отменится.
|
|
51
|
+
|
|
52
|
+
## Пауза в наборе текста
|
|
53
|
+
|
|
54
|
+
Добавьте `debounce` снаружи `latest`. Поиск стартует через 300 мс без новых нажатий.
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
import { createBus } from "nanoeffects";
|
|
58
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
59
|
+
|
|
60
|
+
const bus = createBus(); // шина: события и обработчики
|
|
61
|
+
|
|
62
|
+
// latest: новый поиск прерывает запрос в полёте
|
|
63
|
+
const searchLatest = latest(async (query, { signal }) => {
|
|
64
|
+
const items = await api.search(query, { signal });
|
|
65
|
+
render(items);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// debounce: запуск после 300 мс без нового "typed"
|
|
69
|
+
bus.on("typed", debounce(300, searchLatest));
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Повтор упавшего запроса и ограничение времени
|
|
73
|
+
|
|
74
|
+
Оберните запрос в `retry` и `timeout`. Пауза растёт с каждой попыткой.
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
import { createBus } from "nanoeffects";
|
|
78
|
+
import { latest } from "nanoeffects/policies";
|
|
79
|
+
import { backoff, retry, timeout } from "nanoeffects/effects";
|
|
80
|
+
|
|
81
|
+
const bus = createBus(); // шина: события и обработчики
|
|
82
|
+
|
|
83
|
+
// timeout: прервать одну попытку через 5 с
|
|
84
|
+
const searchOnce = timeout(5000, (query, { signal }) => api.search(query, { signal }));
|
|
85
|
+
|
|
86
|
+
// retry: до 4 попыток, backoff с каждым разом удлиняет паузу
|
|
87
|
+
const searchWithRetry = retry(searchOnce, {
|
|
88
|
+
times: 4,
|
|
89
|
+
delay: backoff({
|
|
90
|
+
base: 200,
|
|
91
|
+
max: 5000,
|
|
92
|
+
jitter: true,
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// latest: новый поиск прерывает всю цепочку повторов
|
|
97
|
+
bus.on(
|
|
98
|
+
"search",
|
|
99
|
+
latest(async (query, ctx) => {
|
|
100
|
+
const items = await ctx.run(searchWithRetry, query); // ctx.run: дочерняя задача, прерывается вместе с этой
|
|
101
|
+
ctx.emit("results", items);
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Другие политики: [Политики](policies.md).
|