helfy-create 0.0.3 → 0.0.5
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 +9 -0
- package/package.json +2 -4
- package/template/package-lock.json +1326 -1345
- package/template/package.json +5 -9
- package/template/src/App.tsx +2 -0
- package/template/src/context/ThemeContext.tsx +14 -16
- package/template/src/pages/AboutPage.tsx +6 -1
- package/template/src/pages/CounterPage.tsx +3 -3
- package/template/src/pages/HomePage.tsx +8 -1
- package/template/src/pages/layouts/MainLayout.tsx +12 -14
- package/template/src/styles.css +316 -20
- package/template/src/widgets/Header/Header.tsx +21 -5
- package/template/src/widgets/Navbar/Navbar.tsx +5 -18
- package/template/src/widgets/NotFound/NotFound.tsx +9 -15
- package/template/docs/README.md +0 -1300
- package/template/webpack.config.js +0 -81
package/template/docs/README.md
DELETED
|
@@ -1,1300 +0,0 @@
|
|
|
1
|
-
# Helfy
|
|
2
|
-
|
|
3
|
-
Helfy -- TypeScript UI-фреймворк с декоратор-ориентированным API, кастомным JSX и fine-grained reactivity.
|
|
4
|
-
|
|
5
|
-
Компоненты описываются классами с декоратором `@View`, состояние управляется через `@state` и `@observable`, а шаблоны используют расширенный JSX с директивами `@if`, `@for`, `@else`. Реактивность построена на signals (аналог SolidJS): `render()` вызывается один раз, а последующие обновления DOM происходят точечно — только для тех узлов, которые читают изменившийся signal.
|
|
6
|
-
|
|
7
|
-
## Содержание
|
|
8
|
-
|
|
9
|
-
- [Установка](#установка)
|
|
10
|
-
- [Компоненты](#компоненты)
|
|
11
|
-
- [Базовый компонент](#базовый-компонент)
|
|
12
|
-
- [Props](#props)
|
|
13
|
-
- [Локальное состояние (@state)](#локальное-состояние-state)
|
|
14
|
-
- [Вложенные компоненты](#вложенные-компоненты)
|
|
15
|
-
- [Монтирование в DOM](#монтирование-в-dom)
|
|
16
|
-
- [Привязка к DOM и компонентам (@ref)](#привязка-к-dom-и-компонентам-ref)
|
|
17
|
-
- [Двустороннее связывание (@bind)](#двустороннее-связывание-bind)
|
|
18
|
-
- [Формы (@Form, @field, @field в JSX)](#формы-form-field-field-в-jsx)
|
|
19
|
-
- [Шаблонные директивы](#шаблонные-директивы)
|
|
20
|
-
- [@if / @elseif / @else](#if--elseif--else)
|
|
21
|
-
- [@for](#for)
|
|
22
|
-
- [@empty](#empty)
|
|
23
|
-
- [State Management](#state-management)
|
|
24
|
-
- [Создание Store](#создание-store)
|
|
25
|
-
- [Подписка через @observe](#подписка-через-observe)
|
|
26
|
-
- [Контекст хранилищ](#контекст-хранилищ)
|
|
27
|
-
- [Логирование (@logger)](#логирование-logger)
|
|
28
|
-
- [Context & DI](#context--di)
|
|
29
|
-
- [Провайдер (@Context, @provide)](#провайдер-context-provide)
|
|
30
|
-
- [Потребитель (@ctx)](#потребитель-ctx)
|
|
31
|
-
- [Реактивные поля](#реактивные-поля)
|
|
32
|
-
- [Опциональная инжекция](#опциональная-инжекция)
|
|
33
|
-
- [DI Container](#di-container-конструкторный-инжект)
|
|
34
|
-
- [JSX](#jsx)
|
|
35
|
-
- [Атрибуты](#атрибуты)
|
|
36
|
-
- [События](#события)
|
|
37
|
-
- [Стили](#стили)
|
|
38
|
-
- [CSS-классы](#css-классы)
|
|
39
|
-
- [API](#api)
|
|
40
|
-
|
|
41
|
-
---
|
|
42
|
-
|
|
43
|
-
## Установка
|
|
44
|
-
|
|
45
|
-
```bash
|
|
46
|
-
npm install @helfy/helfy
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
или в `package.json`:
|
|
50
|
-
|
|
51
|
-
```json
|
|
52
|
-
{
|
|
53
|
-
"dependencies": {
|
|
54
|
-
"@helfy/helfy": "^0.0.1"
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
### Build setup
|
|
60
|
-
|
|
61
|
-
Babel-плагин автоматически запускает DI-сканер перед трансформацией (pre-скрипты в `package.json` не нужны). Сканер генерирует `.helfy/di-tokens.ts`, `.helfy/di-registry.ts`. Добавьте `.helfy` в `.gitignore`.
|
|
62
|
-
|
|
63
|
-
### Babel
|
|
64
|
-
|
|
65
|
-
В `.babelrc` достаточно одного пресета:
|
|
66
|
-
|
|
67
|
-
```json
|
|
68
|
-
{
|
|
69
|
-
"presets": ["@helfy/helfy/babel-preset"]
|
|
70
|
-
}
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
Пресет включает: JSX runtime, TypeScript, legacy-декораторы, class properties, babel-plugin-transform-typescript-metadata, **helfy-di** (compile-time DI для `@Injectable<IX>()`, автоинъекция регистрации при `createApp`, трансформация `@logger()` → `@logger("<ClassName>")`).
|
|
74
|
-
|
|
75
|
-
### Webpack
|
|
76
|
-
|
|
77
|
-
Для обработки директив (`@if`, `@for`, `@ref`, `@bind`, `@field`) в `.tsx` файлах подключить loader:
|
|
78
|
-
|
|
79
|
-
```javascript
|
|
80
|
-
{
|
|
81
|
-
test: /\.tsx$/,
|
|
82
|
-
use: [
|
|
83
|
-
'babel-loader',
|
|
84
|
-
require.resolve('@helfy/helfy/compiler/helfy-loader'),
|
|
85
|
-
],
|
|
86
|
-
}
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
### TypeScript
|
|
90
|
-
|
|
91
|
-
В `tsconfig.json` указать:
|
|
92
|
-
|
|
93
|
-
```json
|
|
94
|
-
{
|
|
95
|
-
"compilerOptions": {
|
|
96
|
-
"jsx": "preserve",
|
|
97
|
-
"experimentalDecorators": true,
|
|
98
|
-
"emitDecoratorMetadata": true,
|
|
99
|
-
"plugins": [{ "name": "@helfy/helfy/ts-plugin" }]
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
Плагин `@helfy/helfy/ts-plugin` даёт поддержку директив `@if`, `@for`, `@ref`, `@bind`, `@field` в IDE (автодополнение, типизация, переход к определению). Идёт в комплекте с пакетом helfy.
|
|
105
|
-
|
|
106
|
-
---
|
|
107
|
-
|
|
108
|
-
## Компоненты
|
|
109
|
-
|
|
110
|
-
### Базовый компонент
|
|
111
|
-
|
|
112
|
-
Компонент -- это класс с декоратором `@View` и методом `render()`, возвращающим JSX:
|
|
113
|
-
|
|
114
|
-
```tsx
|
|
115
|
-
import { View } from "@helfy/helfy";
|
|
116
|
-
|
|
117
|
-
@View
|
|
118
|
-
class Hello {
|
|
119
|
-
render() {
|
|
120
|
-
return (
|
|
121
|
-
<div>
|
|
122
|
-
<h1>Hello, Helfy!</h1>
|
|
123
|
-
</div>
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
Декоратор `@View` автоматически:
|
|
130
|
-
- вызывает `render()` **один раз** и монтирует результат в DOM-фрагмент
|
|
131
|
-
- добавляет свойство `view` (ссылка на корневой DOM-элемент)
|
|
132
|
-
- оборачивает `this.props` в реактивный Proxy (каждый prop — signal)
|
|
133
|
-
- настраивает fine-grained эффекты: при изменении signal обновляется только конкретный DOM-узел, а не весь компонент
|
|
134
|
-
|
|
135
|
-
> **Важно:** При наследовании `@View` нужно указывать и на дочернем классе. Без этого не будут работать `@ctx`, `scheduleUpdate` и обновление DOM.
|
|
136
|
-
|
|
137
|
-
### Props
|
|
138
|
-
|
|
139
|
-
Props передаются через конструктор. Декоратор `@View` оборачивает `this.props` в реактивный Proxy — каждый prop становится signal. Это означает, что при изменении props родителем обновляются только те DOM-узлы, которые их читают, без полного перерендера компонента.
|
|
140
|
-
|
|
141
|
-
> **Важно:** Не деструктуризируйте `this.props` в `render()`. Обращайтесь к полям напрямую через `this.props.field` — это обеспечивает корректную подписку на signals.
|
|
142
|
-
|
|
143
|
-
```tsx
|
|
144
|
-
import { View } from "@helfy/helfy";
|
|
145
|
-
|
|
146
|
-
interface ButtonProps {
|
|
147
|
-
label: string;
|
|
148
|
-
type?: "button" | "submit";
|
|
149
|
-
onClick?: () => void;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
@View
|
|
153
|
-
class Button {
|
|
154
|
-
constructor(private readonly props: ButtonProps) {}
|
|
155
|
-
|
|
156
|
-
render() {
|
|
157
|
-
return (
|
|
158
|
-
<button
|
|
159
|
-
type={this.props.type ?? "button"}
|
|
160
|
-
onclick={this.props.onClick}
|
|
161
|
-
>
|
|
162
|
-
{this.props.label}
|
|
163
|
-
</button>
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
Использование:
|
|
170
|
-
|
|
171
|
-
```tsx
|
|
172
|
-
<Button label="Нажми" onClick={() => console.log('clicked')} />
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
При изменении props родителем `updateProps()` обновляет соответствующие signals через `batch()`. Fine-grained эффекты, созданные Babel-плагином (`createReactiveChild`), отслеживают эти signals и точечно обновляют DOM.
|
|
176
|
-
|
|
177
|
-
### Локальное состояние (@state)
|
|
178
|
-
|
|
179
|
-
Декоратор `@state` делает поле реактивным — под капотом каждое поле — это signal из `createSignal()`.
|
|
180
|
-
При изменении значения обновляются только те DOM-узлы и эффекты, которые читают это поле (fine-grained). Полный перерендер компонента не происходит:
|
|
181
|
-
|
|
182
|
-
```tsx
|
|
183
|
-
import { View, state } from "@helfy/helfy";
|
|
184
|
-
|
|
185
|
-
@View
|
|
186
|
-
class Counter {
|
|
187
|
-
@state private count = 0;
|
|
188
|
-
|
|
189
|
-
increment() {
|
|
190
|
-
this.count++; // обновит только узлы, читающие count
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
decrement() {
|
|
194
|
-
this.count--;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
render() {
|
|
198
|
-
return (
|
|
199
|
-
<div>
|
|
200
|
-
<span>{this.count}</span>
|
|
201
|
-
<button onclick={this.increment.bind(this)}>+</button>
|
|
202
|
-
<button onclick={this.decrement.bind(this)}>-</button>
|
|
203
|
-
</div>
|
|
204
|
-
);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
Можно объявить несколько `@state`-полей. Каждое из них независимо триггерит обновление только тех узлов DOM, которые его читают.
|
|
210
|
-
|
|
211
|
-
Signals‑примитивы (`createSignal`, `createEffect`, `createComputed`, `batch`, `onCleanup`) также экспортируются из `helfy` и могут использоваться напрямую, но в большинстве случаев достаточно `@state`, `@computed` и `@effect`.
|
|
212
|
-
|
|
213
|
-
### Вложенные компоненты
|
|
214
|
-
|
|
215
|
-
Компоненты используются в JSX как теги. Props передаются как атрибуты:
|
|
216
|
-
|
|
217
|
-
```tsx
|
|
218
|
-
import { View } from "@helfy/helfy";
|
|
219
|
-
|
|
220
|
-
@View
|
|
221
|
-
class App {
|
|
222
|
-
render() {
|
|
223
|
-
return (
|
|
224
|
-
<div>
|
|
225
|
-
<Counter />
|
|
226
|
-
<Button label="Hello" onClick={() => {}} />
|
|
227
|
-
</div>
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
### Монтирование в DOM
|
|
234
|
-
|
|
235
|
-
Корневой компонент монтируется через `attach()`, чтобы вызвать хук `onAttached()` (после вставки в document):
|
|
236
|
-
|
|
237
|
-
```typescript
|
|
238
|
-
const app = new App();
|
|
239
|
-
app.attach(document.getElementById('root'));
|
|
240
|
-
```
|
|
241
|
-
|
|
242
|
-
### Привязка к DOM и компонентам (@ref)
|
|
243
|
-
|
|
244
|
-
Декоратор `@ref` помечает поле для получения ссылки на DOM-элемент или экземпляр компонента. В JSX используется директива `@ref(this.имяПоля)`. Доступ к ссылке возможен после `onMount()`. Для операций вроде `focus()`, требующих элемент в document, используйте `onAttached()`.
|
|
245
|
-
|
|
246
|
-
**Для компонентов** — родитель получает прокси с доступом только к методам, помеченным `@expose`. Это позволяет явно определить публичный API компонента.
|
|
247
|
-
|
|
248
|
-
```tsx
|
|
249
|
-
import { View, ref, expose } from "@helfy/helfy";
|
|
250
|
-
|
|
251
|
-
@View
|
|
252
|
-
class Input {
|
|
253
|
-
@ref private inputEl!: HTMLInputElement;
|
|
254
|
-
|
|
255
|
-
@expose focus() {
|
|
256
|
-
this.inputEl?.focus();
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
render() {
|
|
260
|
-
return <input @ref(this.inputEl) type="text" />;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
@View
|
|
265
|
-
class Form {
|
|
266
|
-
@ref private input!: Input;
|
|
267
|
-
|
|
268
|
-
onAttached() {
|
|
269
|
-
this.input.focus(); // доступен только помеченный метод
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
render() {
|
|
273
|
-
return <Input @ref(this.input) />;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
```
|
|
277
|
-
|
|
278
|
-
- DOM-элементы по `@ref` передаются как есть
|
|
279
|
-
- Компоненты с `@expose` — родитель видит только помеченные методы
|
|
280
|
-
- Компоненты без `@expose` — передаётся экземпляр целиком (обратная совместимость)
|
|
281
|
-
|
|
282
|
-
При условном рендере (`@if`) ссылка обнуляется при удалении элемента. Запрещено использовать `@ref` и `@state` на одном поле.
|
|
283
|
-
|
|
284
|
-
### Двустороннее связывание (@bind)
|
|
285
|
-
|
|
286
|
-
Директива `@bind` — синтаксический сахар для двусторонней привязки значения поля `@state` к элементу формы. Синтаксис: `@bind(expr)` — круглые скобки, как у `@ref`. Компилятор автоматически генерирует пару `value`/`checked` и обработчик события.
|
|
287
|
-
|
|
288
|
-
```tsx
|
|
289
|
-
import { View, state } from "@helfy/helfy";
|
|
290
|
-
|
|
291
|
-
@View
|
|
292
|
-
class LoginForm {
|
|
293
|
-
@state private username = '';
|
|
294
|
-
@state private email = '';
|
|
295
|
-
@state private isActive = true;
|
|
296
|
-
|
|
297
|
-
render() {
|
|
298
|
-
return (
|
|
299
|
-
<form>
|
|
300
|
-
<input @bind(this.username) type="text" />
|
|
301
|
-
<input @bind(this.email) type="email" />
|
|
302
|
-
<input @bind(this.isActive) type="checkbox" />
|
|
303
|
-
<select @bind(this.priority)>
|
|
304
|
-
<option value="low">Низкий</option>
|
|
305
|
-
<option value="high">Высокий</option>
|
|
306
|
-
</select>
|
|
307
|
-
</form>
|
|
308
|
-
);
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
```
|
|
312
|
-
|
|
313
|
-
Трансформация по типу элемента:
|
|
314
|
-
|
|
315
|
-
| Элемент | Атрибут | Событие |
|
|
316
|
-
|---------|---------|---------|
|
|
317
|
-
| `input[text|email|password|...]` | `value` | `oninput` |
|
|
318
|
-
| `input[checkbox|radio]` | `checked` | `onchange` |
|
|
319
|
-
| `select` | `value` | `onchange` |
|
|
320
|
-
| `textarea` | `value` | `oninput` |
|
|
321
|
-
|
|
322
|
-
Поле должно быть помечено `@state` для реактивного обновления UI. Для union-типов (например, `TodoPriority`) используется приведение `as typeof expr` — типобезопасность сохраняется.
|
|
323
|
-
|
|
324
|
-
**Кастомные компоненты:** для привязки к компоненту с пропом `value` используется именованный биндинг `@bind:value(expr)`:
|
|
325
|
-
|
|
326
|
-
```tsx
|
|
327
|
-
// Родитель
|
|
328
|
-
<Input @bind:value(this.title) placeholder="Заголовок" />
|
|
329
|
-
|
|
330
|
-
// Компонент Input с @binded("value")
|
|
331
|
-
@View
|
|
332
|
-
class Input {
|
|
333
|
-
@binded("value") private bindedVal!: string;
|
|
334
|
-
// ...
|
|
335
|
-
render() {
|
|
336
|
-
return <input @bind(this.bindedVal) type="text" />;
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
```
|
|
340
|
-
|
|
341
|
-
### Формы (@Form, @field, @field в JSX)
|
|
342
|
-
|
|
343
|
-
Централизованное управление формами с валидацией через контекст: `@Form` — класс контекста формы, `@field` — декоратор полей (создаёт `FieldState`), `@useForm` — инжект формы в компонент. JSX-директива `@field(expr)` подключает инпут к `FieldState` одной строкой.
|
|
344
|
-
|
|
345
|
-
**FormContext** — класс с `@Form` и полями `@field`:
|
|
346
|
-
|
|
347
|
-
```tsx
|
|
348
|
-
import { Form, field, logger, type FieldState, type ILogger } from "@helfy/helfy";
|
|
349
|
-
|
|
350
|
-
@Form
|
|
351
|
-
export class LoginFormContext {
|
|
352
|
-
@logger() private log!: ILogger;
|
|
353
|
-
|
|
354
|
-
@field({ defaultValue: "" })
|
|
355
|
-
email!: FieldState<string>;
|
|
356
|
-
|
|
357
|
-
@field({ defaultValue: "" })
|
|
358
|
-
password!: FieldState<string>;
|
|
359
|
-
|
|
360
|
-
@field({ defaultValue: false })
|
|
361
|
-
rememberMe!: FieldState<boolean>;
|
|
362
|
-
|
|
363
|
-
validateAll(): boolean {
|
|
364
|
-
// проверка полей, установка field.error
|
|
365
|
-
return true;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
submit() {
|
|
369
|
-
if (!this.validateAll()) {
|
|
370
|
-
this.log.warn("Submit validation failed");
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
this.log.info("Submit", { email: this.email.value });
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
```
|
|
377
|
-
|
|
378
|
-
**FieldState** содержит `value`, `isDirty`, `isTouched`, `error`, `isValid`. При изменении `value`/`error`/`isTouched` компоненты с `@useForm` перерендериваются.
|
|
379
|
-
|
|
380
|
-
**Компонент формы** — инжект через `@useForm`, рендер через провайдер:
|
|
381
|
-
|
|
382
|
-
```tsx
|
|
383
|
-
import { View, useForm } from "@helfy/helfy";
|
|
384
|
-
import { LoginFormContext } from "./LoginFormContext";
|
|
385
|
-
|
|
386
|
-
@View
|
|
387
|
-
export class LoginForm {
|
|
388
|
-
@useForm(LoginFormContext) private form!: LoginFormContext;
|
|
389
|
-
|
|
390
|
-
render() {
|
|
391
|
-
return (
|
|
392
|
-
<LoginFormContext>
|
|
393
|
-
<form onsubmit={(e: Event) => { e.preventDefault(); this.form.submit(); }}>
|
|
394
|
-
<input @field(this.form.email) type="email" class="input" />
|
|
395
|
-
{this.form.email.isTouched && this.form.email.error && (
|
|
396
|
-
<span class="error">{this.form.email.error}</span>
|
|
397
|
-
)}
|
|
398
|
-
<input @field(this.form.password) type="password" class="input" />
|
|
399
|
-
<input @field(this.form.rememberMe) type="checkbox" id="remember" />
|
|
400
|
-
<label for="remember">Запомнить меня</label>
|
|
401
|
-
<button type="submit">Войти</button>
|
|
402
|
-
</form>
|
|
403
|
-
</LoginFormContext>
|
|
404
|
-
);
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
```
|
|
408
|
-
|
|
409
|
-
**JSX-директива `@field(expr)`** — одна директива заменяет `@bind` + `onblur` + `class` для ошибок + `aria-invalid`. Компилятор генерирует:
|
|
410
|
-
|
|
411
|
-
- `value`/`checked` и `oninput`/`onchange`
|
|
412
|
-
- `onblur` → `expr.isTouched = true`
|
|
413
|
-
- `class` — объединение с существующим; при `expr.isTouched && expr.error` добавляется `input-error`
|
|
414
|
-
- `aria-invalid={expr.isTouched && expr.error ? "true" : "false"}`
|
|
415
|
-
|
|
416
|
-
Поддерживаются `input` (text, email, password, checkbox, radio), `select`, `textarea`. Класс `.input-error` можно задать в глобальных стилях (например, `@apply border-red-500` в Tailwind).
|
|
417
|
-
|
|
418
|
-
---
|
|
419
|
-
|
|
420
|
-
## Шаблонные директивы
|
|
421
|
-
|
|
422
|
-
Helfy расширяет JSX директивами `@if`, `@elseif`, `@else`, `@for`, `@empty`. Компилятор трансформирует их в вызовы `$._if()`, `$._ifelse()`, `$._forin()` на этапе сборки.
|
|
423
|
-
|
|
424
|
-
### @if / @elseif / @else
|
|
425
|
-
|
|
426
|
-
Условный рендеринг:
|
|
427
|
-
|
|
428
|
-
```tsx
|
|
429
|
-
render() {
|
|
430
|
-
return (
|
|
431
|
-
<div>
|
|
432
|
-
@if (this.count > 0) {
|
|
433
|
-
<span>Положительное: {this.count}</span>
|
|
434
|
-
}
|
|
435
|
-
</div>
|
|
436
|
-
);
|
|
437
|
-
}
|
|
438
|
-
```
|
|
439
|
-
|
|
440
|
-
Цепочка условий:
|
|
441
|
-
|
|
442
|
-
```tsx
|
|
443
|
-
render() {
|
|
444
|
-
return (
|
|
445
|
-
<div>
|
|
446
|
-
@if (this.status === 'loading') {
|
|
447
|
-
<span>Загрузка...</span>
|
|
448
|
-
} @elseif (this.status === 'error') {
|
|
449
|
-
<span>Ошибка!</span>
|
|
450
|
-
} @else {
|
|
451
|
-
<span>Данные загружены</span>
|
|
452
|
-
}
|
|
453
|
-
</div>
|
|
454
|
-
);
|
|
455
|
-
}
|
|
456
|
-
```
|
|
457
|
-
|
|
458
|
-
Вложенные условия:
|
|
459
|
-
|
|
460
|
-
```tsx
|
|
461
|
-
@if (this.isVisible) {
|
|
462
|
-
<div>
|
|
463
|
-
@if (this.count > 10) {
|
|
464
|
-
<span>Больше десяти</span>
|
|
465
|
-
}
|
|
466
|
-
</div>
|
|
467
|
-
}
|
|
468
|
-
```
|
|
469
|
-
|
|
470
|
-
### @for
|
|
471
|
-
|
|
472
|
-
Итерация по массиву. Синтаксис: `@for (item of array)` или `@for (item, index of array)`.
|
|
473
|
-
|
|
474
|
-
```tsx
|
|
475
|
-
@state private items = ['apple', 'banana', 'cherry'];
|
|
476
|
-
|
|
477
|
-
render() {
|
|
478
|
-
return (
|
|
479
|
-
<ul>
|
|
480
|
-
@for (item, i of this.items; track item) {
|
|
481
|
-
<li>{i}: {item}</li>
|
|
482
|
-
}
|
|
483
|
-
</ul>
|
|
484
|
-
);
|
|
485
|
-
}
|
|
486
|
-
```
|
|
487
|
-
|
|
488
|
-
`track` задает ключ для оптимизации DOM-диффинга (аналог `key` в React):
|
|
489
|
-
|
|
490
|
-
```tsx
|
|
491
|
-
@for (user of this.users; track user.id) {
|
|
492
|
-
<UserCard name={user.name} />
|
|
493
|
-
}
|
|
494
|
-
```
|
|
495
|
-
|
|
496
|
-
### @empty
|
|
497
|
-
|
|
498
|
-
Блок `@empty` после `@for` рендерится, когда массив пуст:
|
|
499
|
-
|
|
500
|
-
```tsx
|
|
501
|
-
@for (item of this.items; track item) {
|
|
502
|
-
<div>{item}</div>
|
|
503
|
-
} @empty {
|
|
504
|
-
<span>Список пуст</span>
|
|
505
|
-
}
|
|
506
|
-
```
|
|
507
|
-
|
|
508
|
-
---
|
|
509
|
-
|
|
510
|
-
## State Management
|
|
511
|
-
|
|
512
|
-
### Создание Store
|
|
513
|
-
|
|
514
|
-
Store -- глобальное реактивное хранилище. Создается классом с декоратором `@Store`, реактивные поля помечаются `@observable`:
|
|
515
|
-
|
|
516
|
-
```typescript
|
|
517
|
-
import { Store, observable } from "@helfy/helfy";
|
|
518
|
-
|
|
519
|
-
@Store
|
|
520
|
-
class UserStore {
|
|
521
|
-
@observable name = "Guest";
|
|
522
|
-
@observable isLoggedIn = false;
|
|
523
|
-
|
|
524
|
-
login(name: string) {
|
|
525
|
-
this.name = name;
|
|
526
|
-
this.isLoggedIn = true; // подписчики name и isLoggedIn уведомлены
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
logout() {
|
|
530
|
-
this.name = "Guest";
|
|
531
|
-
this.isLoggedIn = false;
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
export default UserStore;
|
|
536
|
-
```
|
|
537
|
-
|
|
538
|
-
Декоратор `@Store` добавляет классу:
|
|
539
|
-
- `subscribe(field, callback)` -- подписка на изменение поля, возвращает функцию отписки
|
|
540
|
-
- `unsubscribe(field, callback)` -- отписка
|
|
541
|
-
|
|
542
|
-
Декоратор `@observable` превращает поле в реактивное свойство с getter/setter. При записи нового значения все подписчики уведомляются.
|
|
543
|
-
|
|
544
|
-
### Подписка через @observe
|
|
545
|
-
|
|
546
|
-
Декоратор `@observe` связывает поле компонента с полем store. При изменении значения в store компонент автоматически перерендерится:
|
|
547
|
-
|
|
548
|
-
```tsx
|
|
549
|
-
import { View, observe } from "@helfy/helfy";
|
|
550
|
-
import Stores from "./StoreContext";
|
|
551
|
-
import UserStore from "./UserStore";
|
|
552
|
-
|
|
553
|
-
@View
|
|
554
|
-
class Header {
|
|
555
|
-
@observe(Stores.userStore, 'name')
|
|
556
|
-
private userName: UserStore['name'];
|
|
557
|
-
|
|
558
|
-
@observe(Stores.userStore, 'isLoggedIn')
|
|
559
|
-
private isLoggedIn: UserStore['isLoggedIn'];
|
|
560
|
-
|
|
561
|
-
render() {
|
|
562
|
-
return (
|
|
563
|
-
<header>
|
|
564
|
-
@if (this.isLoggedIn) {
|
|
565
|
-
<span>Привет, {this.userName}!</span>
|
|
566
|
-
} @else {
|
|
567
|
-
<span>Войдите в систему</span>
|
|
568
|
-
}
|
|
569
|
-
</header>
|
|
570
|
-
);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
```
|
|
574
|
-
|
|
575
|
-
Тип поля берется из store через lookup-тип (`UserStore['name']`), что дает полную типобезопасность.
|
|
576
|
-
|
|
577
|
-
### Контекст хранилищ
|
|
578
|
-
|
|
579
|
-
Рекомендуется создать единый контекст для всех store:
|
|
580
|
-
|
|
581
|
-
```typescript
|
|
582
|
-
import UserStore from "./UserStore";
|
|
583
|
-
import AppStore from "./AppStore";
|
|
584
|
-
|
|
585
|
-
class Stores {
|
|
586
|
-
static readonly userStore = new UserStore();
|
|
587
|
-
static readonly appStore = new AppStore();
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
export default Stores;
|
|
591
|
-
```
|
|
592
|
-
|
|
593
|
-
Использование в компонентах:
|
|
594
|
-
|
|
595
|
-
```tsx
|
|
596
|
-
import Stores from "./StoreContext";
|
|
597
|
-
|
|
598
|
-
// подписка через декоратор
|
|
599
|
-
@observe(Stores.userStore, 'name')
|
|
600
|
-
private userName: string;
|
|
601
|
-
|
|
602
|
-
// прямой вызов метода store
|
|
603
|
-
Stores.userStore.login("Alice");
|
|
604
|
-
```
|
|
605
|
-
|
|
606
|
-
---
|
|
607
|
-
|
|
608
|
-
## Логирование (@logger)
|
|
609
|
-
|
|
610
|
-
Декоратор `@logger` инжектирует логгер в классы View, Context, Store и Injectable. Имя класса подставляется на этапе компиляции (Babel-плагин), что сохраняет читаемые имена даже при минификации.
|
|
611
|
-
|
|
612
|
-
### Использование
|
|
613
|
-
|
|
614
|
-
```tsx
|
|
615
|
-
import { View, logger, type ILogger } from "@helfy/helfy";
|
|
616
|
-
|
|
617
|
-
@View
|
|
618
|
-
class TodoInput {
|
|
619
|
-
@logger() private log!: ILogger; // auto: <TodoInput>
|
|
620
|
-
|
|
621
|
-
onAttached() {
|
|
622
|
-
this.log.debug("todo input attached");
|
|
623
|
-
this.log.info("todo added", { title: this.title });
|
|
624
|
-
this.log.warn("validation failed", { error: result.error });
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
```
|
|
628
|
-
|
|
629
|
-
**Варианты:**
|
|
630
|
-
- `@logger()` — имя класса подставляется на compile-time как `<ClassName>`, формат и цвет зависят от типа класса
|
|
631
|
-
- `@logger("my-tag")` — кастомный тег, серый цвет
|
|
632
|
-
|
|
633
|
-
### Формат и цвет тегов по типу класса
|
|
634
|
-
|
|
635
|
-
| Тип | Формат | Цвет |
|
|
636
|
-
|-----|--------|------|
|
|
637
|
-
| View (компоненты) | `<TodoInput>` | skyblue |
|
|
638
|
-
| Injectable (сервисы) | `TodoValidateService()` | pink |
|
|
639
|
-
| Context | `{TodoContext}` | khaki |
|
|
640
|
-
| Form | `[LoginFormContext]` | bright blue |
|
|
641
|
-
| Store | `TodoStore[]` | lime green |
|
|
642
|
-
| Кастомный `@logger("...")` | как указано | gray |
|
|
643
|
-
|
|
644
|
-
### API логгера
|
|
645
|
-
|
|
646
|
-
```typescript
|
|
647
|
-
interface ILogger {
|
|
648
|
-
debug(message: string, meta?: Record<string, unknown>): void;
|
|
649
|
-
info(message: string, meta?: Record<string, unknown>): void;
|
|
650
|
-
warn(message: string, meta?: Record<string, unknown>): void;
|
|
651
|
-
error(messageOrError: string | Error, meta?: Record<string, unknown>): void;
|
|
652
|
-
withContext(ctx: string): ILogger; // дочерний логгер с доп. префиксом
|
|
653
|
-
}
|
|
654
|
-
```
|
|
655
|
-
|
|
656
|
-
### DI и транспорты
|
|
657
|
-
|
|
658
|
-
`LoggerService` регистрируется под `ILoggerToken` при `registerAllServices`. Можно подключить кастомные транспорты (Sentry, файл и т.д.), реализующие `ILogTransport`:
|
|
659
|
-
|
|
660
|
-
```typescript
|
|
661
|
-
import { LoggerService, ConsoleTransport, ILoggerToken } from "@helfy/helfy";
|
|
662
|
-
|
|
663
|
-
const transports = [new ConsoleTransport(), /* mySentryTransport */];
|
|
664
|
-
createApp({ root })
|
|
665
|
-
.configureContainer((c) => {
|
|
666
|
-
c.bind(ILoggerToken, { useClass: LoggerService, deps: [transports] });
|
|
667
|
-
})
|
|
668
|
-
.mount(App);
|
|
669
|
-
```
|
|
670
|
-
|
|
671
|
-
Без зарегистрированного логгера в контейнере используется fallback с выводом в `console`.
|
|
672
|
-
|
|
673
|
-
---
|
|
674
|
-
|
|
675
|
-
## Context & DI
|
|
676
|
-
|
|
677
|
-
Helfy поддерживает иерархический Context и Dependency Injection: провайдер оборачивает поддерево в JSX, а дочерние компоненты получают значения через `@ctx`. Это удобно для темы, форм, роутинга и других общих зависимостей.
|
|
678
|
-
|
|
679
|
-
### Провайдер (@Context, @provide)
|
|
680
|
-
|
|
681
|
-
Класс с декоратором `@Context` — не-рендерящий провайдер: он не имеет `render()` и рендерит только своих детей. Поля с `@provide` становятся доступны потребителям ниже по дереву.
|
|
682
|
-
|
|
683
|
-
```tsx
|
|
684
|
-
import { Context, provide } from "@helfy/helfy";
|
|
685
|
-
|
|
686
|
-
export type TThemeMode = "light" | "dark";
|
|
687
|
-
|
|
688
|
-
@Context
|
|
689
|
-
export class ThemeContext {
|
|
690
|
-
@provide({ reactive: true })
|
|
691
|
-
mode: TThemeMode = "dark";
|
|
692
|
-
|
|
693
|
-
@provide()
|
|
694
|
-
toggle = () => {
|
|
695
|
-
this.mode = this.mode === "dark" ? "light" : "dark";
|
|
696
|
-
};
|
|
697
|
-
}
|
|
698
|
-
```
|
|
699
|
-
|
|
700
|
-
Использование в JSX — обёртка вокруг поддерева:
|
|
701
|
-
|
|
702
|
-
```tsx
|
|
703
|
-
<ThemeContext>
|
|
704
|
-
<Header />
|
|
705
|
-
<Main />
|
|
706
|
-
</ThemeContext>
|
|
707
|
-
```
|
|
708
|
-
|
|
709
|
-
Фреймворк рендерит только детей `ThemeContext`; сам провайдер не создаёт DOM-узлов.
|
|
710
|
-
|
|
711
|
-
### Потребитель (@ctx)
|
|
712
|
-
|
|
713
|
-
Компонент с `@View` может получать значение контекста через `@ctx`:
|
|
714
|
-
|
|
715
|
-
```tsx
|
|
716
|
-
import { View, ctx } from "@helfy/helfy";
|
|
717
|
-
import { ThemeContext } from "./ThemeContext";
|
|
718
|
-
|
|
719
|
-
@View
|
|
720
|
-
class ThemeToggle {
|
|
721
|
-
@ctx(ThemeContext)
|
|
722
|
-
private theme!: ThemeContext;
|
|
723
|
-
|
|
724
|
-
render() {
|
|
725
|
-
return (
|
|
726
|
-
<button onclick={this.theme.toggle}>
|
|
727
|
-
{this.theme.mode === "dark" ? "Светлая тема" : "Тёмная тема"}
|
|
728
|
-
</button>
|
|
729
|
-
);
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
```
|
|
733
|
-
|
|
734
|
-
Можно инжектировать только поле контекста:
|
|
735
|
-
|
|
736
|
-
```tsx
|
|
737
|
-
@View
|
|
738
|
-
class ModeDisplay {
|
|
739
|
-
@ctx(ThemeContext, "mode")
|
|
740
|
-
private mode!: TThemeMode;
|
|
741
|
-
|
|
742
|
-
render() {
|
|
743
|
-
return <span>Текущая тема: {this.mode}</span>;
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
```
|
|
747
|
-
|
|
748
|
-
Поиск провайдера идёт вверх по дереву (`_parentView`); используется ближайший провайдер с нужным ключом.
|
|
749
|
-
|
|
750
|
-
### Реактивные поля
|
|
751
|
-
|
|
752
|
-
`@provide({ reactive: true })` делает поле реактивным: при изменении все потребители перерендериваются (под капотом используется `subscribe`). Для методов достаточно `@provide()` без `reactive`.
|
|
753
|
-
|
|
754
|
-
### Опциональная инжекция
|
|
755
|
-
|
|
756
|
-
Третий аргумент `@ctx` — опции `{ optional?, defaultValue? }`:
|
|
757
|
-
|
|
758
|
-
```tsx
|
|
759
|
-
@ctx(ThemeContext, { optional: true })
|
|
760
|
-
private theme?: ThemeContext;
|
|
761
|
-
|
|
762
|
-
@ctx(ThemeContext, "mode", { defaultValue: "light" })
|
|
763
|
-
private mode = "light";
|
|
764
|
-
```
|
|
765
|
-
|
|
766
|
-
Без провайдера в дереве `optional: true` даёт `undefined`; `defaultValue` используется, когда провайдера нет.
|
|
767
|
-
|
|
768
|
-
### DI Container (конструкторный инжект)
|
|
769
|
-
|
|
770
|
-
Helfy поддерживает DI-контейнер для глобальных сервисов с **compile-time** магией: сервисы помечаются `@Injectable<IX>()`, потребители указывают только тип в конструкторе — без `@inject` и явных токенов.
|
|
771
|
-
|
|
772
|
-
**Конвенция:** последний параметр конструктора — props от родителя; предшествующие параметры резолвятся из контейнера или из @Context по дереву.
|
|
773
|
-
|
|
774
|
-
**Сервис** — обязательно `@Injectable<IX>()` (интерфейс в generic):
|
|
775
|
-
|
|
776
|
-
```typescript
|
|
777
|
-
export interface ITodoValidateService {
|
|
778
|
-
validate(title: string): ValidationResult;
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
@Injectable<ITodoValidateService>()
|
|
782
|
-
export class TodoValidateService implements ITodoValidateService {
|
|
783
|
-
validate(title: string) { ... }
|
|
784
|
-
}
|
|
785
|
-
```
|
|
786
|
-
|
|
787
|
-
**Потребитель** — только тип в конструкторе (плагин подставляет токен):
|
|
788
|
-
|
|
789
|
-
```tsx
|
|
790
|
-
@View
|
|
791
|
-
class TodoInput {
|
|
792
|
-
constructor(
|
|
793
|
-
private readonly validateService: ITodoValidateService,
|
|
794
|
-
readonly props: Props
|
|
795
|
-
) {}
|
|
796
|
-
}
|
|
797
|
-
```
|
|
798
|
-
|
|
799
|
-
**Bootstrap** — регистрация DI подключается автоматически при `createApp` (плагин подставляет `.useDI(registerAllServices)` в цепочку):
|
|
800
|
-
|
|
801
|
-
```typescript
|
|
802
|
-
createApp({ root: document.getElementById("root")! })
|
|
803
|
-
.router({ routes })
|
|
804
|
-
.mount(App);
|
|
805
|
-
```
|
|
806
|
-
|
|
807
|
-
Сканер вызывается Babel-плагином перед трансформацией, находит все `@Injectable<IX>()`, генерирует `.helfy/di-tokens.ts` и `.helfy/di-registry.ts`.
|
|
808
|
-
|
|
809
|
-
**Fallback** — для ручной настройки доступны `.configureContainer()`, `@inject(token)`, `@Injectable(token)`:
|
|
810
|
-
|
|
811
|
-
```typescript
|
|
812
|
-
createApp({ root })
|
|
813
|
-
.configureContainer((c) => {
|
|
814
|
-
c.bind(HttpClient, { useClass: AxiosHttpClient });
|
|
815
|
-
c.autoRegister(SomeService);
|
|
816
|
-
})
|
|
817
|
-
.router({ routes })
|
|
818
|
-
.mount(App);
|
|
819
|
-
```
|
|
820
|
-
|
|
821
|
-
Опционально: передать свой контейнер через `.container(myContainer)` или `createApp({ root, container })`.
|
|
822
|
-
|
|
823
|
-
`@Context` и контейнер сосуществуют: Context — для tree-scoped значений (тема, роутер), Container — для глобальных сервисов.
|
|
824
|
-
|
|
825
|
-
---
|
|
826
|
-
|
|
827
|
-
## JSX
|
|
828
|
-
|
|
829
|
-
Helfy использует кастомный JSX runtime (`@helfy/helfy/jsx-runtime`). JSX транслируется в вызовы `jsx()` / `jsxs()`, которые строят виртуальное представление DOM.
|
|
830
|
-
|
|
831
|
-
### Атрибуты
|
|
832
|
-
|
|
833
|
-
Стандартные HTML-атрибуты передаются напрямую:
|
|
834
|
-
|
|
835
|
-
```tsx
|
|
836
|
-
<input type="text" placeholder="Введите имя" />
|
|
837
|
-
<img src="/logo.png" alt="Logo" />
|
|
838
|
-
<div id="container" class="wrapper"></div>
|
|
839
|
-
```
|
|
840
|
-
|
|
841
|
-
### События
|
|
842
|
-
|
|
843
|
-
Обработчики событий передаются через атрибуты в **нижнем регистре** (`onclick`, `oninput`, а не `onClick`):
|
|
844
|
-
|
|
845
|
-
```tsx
|
|
846
|
-
<button onclick={() => this.increment()}>+</button>
|
|
847
|
-
<input oninput={(e) => this.handleInput(e)} />
|
|
848
|
-
```
|
|
849
|
-
|
|
850
|
-
### Стили
|
|
851
|
-
|
|
852
|
-
Инлайн-стили передаются объектом с camelCase-ключами:
|
|
853
|
-
|
|
854
|
-
```tsx
|
|
855
|
-
<div style={{
|
|
856
|
-
backgroundColor: '#f0f0f0',
|
|
857
|
-
fontSize: '16px',
|
|
858
|
-
padding: '8px 16px',
|
|
859
|
-
borderRadius: '4px'
|
|
860
|
-
}}>
|
|
861
|
-
Стилизованный блок
|
|
862
|
-
</div>
|
|
863
|
-
```
|
|
864
|
-
|
|
865
|
-
### CSS-классы
|
|
866
|
-
|
|
867
|
-
Атрибут `class` принимает строку или массив с условными классами:
|
|
868
|
-
|
|
869
|
-
```tsx
|
|
870
|
-
// строка
|
|
871
|
-
<div class="container">...</div>
|
|
872
|
-
|
|
873
|
-
// CSS Modules
|
|
874
|
-
import styles from './App.module.css';
|
|
875
|
-
<div class={styles.wrapper}>...</div>
|
|
876
|
-
|
|
877
|
-
// условные классы через массив
|
|
878
|
-
<div class={[
|
|
879
|
-
styles.cell,
|
|
880
|
-
[styles.active, this.isActive], // применяется если this.isActive === true
|
|
881
|
-
[styles.disabled, this.isDisabled],
|
|
882
|
-
]}>...</div>
|
|
883
|
-
```
|
|
884
|
-
|
|
885
|
-
---
|
|
886
|
-
|
|
887
|
-
## API
|
|
888
|
-
|
|
889
|
-
### Декораторы
|
|
890
|
-
|
|
891
|
-
| Декоратор | Область | Описание |
|
|
892
|
-
|-----------|---------|----------|
|
|
893
|
-
| `@View` | Класс | Превращает класс в компонент с `render()`, `view`, `update()` |
|
|
894
|
-
| `@state` | Поле | Локальное состояние компонента на базе signals (запись обновляет только этот компонент) |
|
|
895
|
-
| `@Store` | Класс | Добавляет `subscribe`/`unsubscribe` и реактивность `@observable`-полей |
|
|
896
|
-
| `@observable` | Поле | Делает поле store реактивным -- запись уведомляет подписчиков |
|
|
897
|
-
| `@observe(store, field)` | Поле | Связывает поле компонента с полем store |
|
|
898
|
-
| `@Context` | Класс | Не-рендерящий провайдер контекста; рендерит только детей |
|
|
899
|
-
| `@provide()` / `@provide({ reactive: true })` | Поле | Помечает поле как доступное для `@ctx` (reactive — потребители перерендериваются) |
|
|
900
|
-
| `@ctx(ContextClass)` / `@ctx(ContextClass, field)` | Поле | Инжектирует контекст или поле контекста из ближайшего провайдера вверх по дереву |
|
|
901
|
-
| `@Injectable<IX>()` / `@Injectable<IX>('singleton' \| 'transient' \| 'scoped')` / `@Injectable(token)` | Класс | Помечает класс как injectable. Scope в (): `'singleton'` (по умолчанию), `'transient'`, `'scoped'`. Fallback: `@Injectable(token)` с Symbol/строкой |
|
|
902
|
-
| `@inject(token)` | Параметр конструктора | Задаёт токен для параметра (fallback; при `@Injectable<IX>()` плагин подставляет токен автоматически) |
|
|
903
|
-
| `@logger()` / `@logger("tag")` | Поле | Инжектирует ILogger. Без аргумента — compile-time имя класса и цвет по типу (View/Context/Store/Injectable). С аргументом — кастомный тег (серый) |
|
|
904
|
-
| `@ref` | Поле | Помечает поле для получения ссылки на DOM или компонент (использовать с `@ref(this.имяПоля)` в JSX) |
|
|
905
|
-
| `@binded(name)` | Поле | Связывает поле с биндингом `@bind:name` от родителя (для кастомных компонентов) |
|
|
906
|
-
| `@bind(expr)` / `@bind:name(expr)` | JSX | Двустороннее связывание с `@state` полем (value/checked + oninput/onchange). Для компонентов: `@bind:value(expr)` |
|
|
907
|
-
| `@Form` | Класс | Контекст формы с полями `@field` |
|
|
908
|
-
| `@field(options)` | Поле FormContext | Создаёт FieldState для поля формы (value, isTouched, error, isDirty) |
|
|
909
|
-
| `@useForm(FormContext)` | Поле | Инжектирует форму в компонент с подпиской на изменения полей |
|
|
910
|
-
| `@field(expr)` | JSX | Подключает инпут к FieldState: value/checked + onblur + error class + aria-invalid |
|
|
911
|
-
|
|
912
|
-
### Экспорты пакета
|
|
913
|
-
|
|
914
|
-
```typescript
|
|
915
|
-
import {
|
|
916
|
-
$, // утилиты рендеринга ($._if, $._ifelse, $._forin, $._slot, $._callSlot)
|
|
917
|
-
View, // декоратор @View
|
|
918
|
-
state, // декоратор @state (signal-backed локальное состояние)
|
|
919
|
-
Store, // декоратор @Store
|
|
920
|
-
observable, // декоратор @observable
|
|
921
|
-
observe, // декоратор @observe
|
|
922
|
-
computed, // @computed для Store/@Context/@View (getter-based)
|
|
923
|
-
effect, // @effect для реактивных сайд-эффектов во View
|
|
924
|
-
Context, // декоратор @Context
|
|
925
|
-
provide, // декоратор @provide
|
|
926
|
-
ctx, // декоратор @ctx (поле, Context)
|
|
927
|
-
Container, // DI-контейнер
|
|
928
|
-
Injectable, // декоратор @Injectable
|
|
929
|
-
inject, // декоратор @inject (параметр конструктора)
|
|
930
|
-
createViewInstance,
|
|
931
|
-
setRootContainer,
|
|
932
|
-
getRootContainer,
|
|
933
|
-
ref, // декоратор @ref
|
|
934
|
-
binded, // декоратор @binded (для компонентов с @bind:value)
|
|
935
|
-
Form, // декоратор @Form (контекст формы)
|
|
936
|
-
field, // декоратор @field (поле FormContext)
|
|
937
|
-
useForm, // декоратор @useForm (инжект формы)
|
|
938
|
-
type FieldState,
|
|
939
|
-
type FieldOptions,
|
|
940
|
-
StoreBase, // базовый класс Store (для расширения)
|
|
941
|
-
Subscriber, // тип callback-подписчика
|
|
942
|
-
Router, // ядро роутера (Router)
|
|
943
|
-
RouterContext, // контекст роутера для дерева компонентов
|
|
944
|
-
RouterView, // компонент, рендерящий текущий маршрут
|
|
945
|
-
Link, // SPA-ссылка
|
|
946
|
-
DefaultNotFound, // fallback-компонент 404 (используется RouterView при отсутствии слота)
|
|
947
|
-
path, // @path() — декоратор для pathname
|
|
948
|
-
search, // @search() — декоратор для query-параметров
|
|
949
|
-
params, // @params() — декоратор для route-параметров
|
|
950
|
-
router, // @router() — декоратор для API роутера
|
|
951
|
-
logger, // @logger() — декоратор для ILogger
|
|
952
|
-
LoggerService, // реализация ILogger (DI)
|
|
953
|
-
ConsoleTransport,
|
|
954
|
-
ILoggerToken,
|
|
955
|
-
// primitives для fine-grained reactivity
|
|
956
|
-
createSignal,
|
|
957
|
-
createEffect,
|
|
958
|
-
createComputed,
|
|
959
|
-
batch,
|
|
960
|
-
onCleanup,
|
|
961
|
-
type Signal,
|
|
962
|
-
type SignalGetter,
|
|
963
|
-
type SignalSetter,
|
|
964
|
-
type EffectDisposer,
|
|
965
|
-
type RouteConfig,
|
|
966
|
-
type RouterLocation,
|
|
967
|
-
type ILogger,
|
|
968
|
-
type ILogTransport,
|
|
969
|
-
type LogLevel,
|
|
970
|
-
} from "@helfy/helfy";
|
|
971
|
-
```
|
|
972
|
-
|
|
973
|
-
### Subpath-экспорты
|
|
974
|
-
|
|
975
|
-
| Путь | Назначение |
|
|
976
|
-
|------|------------|
|
|
977
|
-
| `helfy` | Основной API (декораторы, $, типы) |
|
|
978
|
-
| `@helfy/helfy/router` | Тот же API роутера, но отдельным entry (для tree-shaking) |
|
|
979
|
-
| `@helfy/helfy/jsx-runtime` | JSX runtime (`jsx`, `jsxs`) |
|
|
980
|
-
| `@helfy/helfy/compiler/helfy-loader` | Webpack-loader для директив `@if`/`@for`/`@ref`/`@bind`/`@field` |
|
|
981
|
-
| `@helfy/helfy/babel-preset` | Babel-пресет (JSX, TypeScript, декораторы) |
|
|
982
|
-
|
|
983
|
-
### Роутинг (SPA)
|
|
984
|
-
|
|
985
|
-
Helfy включает лёгкий SPA‑роутер, построенный на `Context & DI`:
|
|
986
|
-
|
|
987
|
-
```tsx
|
|
988
|
-
import {
|
|
989
|
-
View,
|
|
990
|
-
RouterContext,
|
|
991
|
-
RouterView,
|
|
992
|
-
Link,
|
|
993
|
-
type RouteConfig,
|
|
994
|
-
type RouterLocation,
|
|
995
|
-
ctx,
|
|
996
|
-
} from "@helfy/helfy";
|
|
997
|
-
|
|
998
|
-
// Конфиг маршрутов
|
|
999
|
-
const routes: RouteConfig[] = [
|
|
1000
|
-
{ path: "/", component: HomePage },
|
|
1001
|
-
{ path: "/analytics", component: AnalyticsPage },
|
|
1002
|
-
{ path: "/settings", component: SettingsPage },
|
|
1003
|
-
{ path: "/debug/:id", component: DebugPage },
|
|
1004
|
-
];
|
|
1005
|
-
|
|
1006
|
-
@View
|
|
1007
|
-
class App {
|
|
1008
|
-
render() {
|
|
1009
|
-
return (
|
|
1010
|
-
<RouterContext routes={routes}>
|
|
1011
|
-
<Layout>
|
|
1012
|
-
@slot.sidebar() {<Sidebar />}
|
|
1013
|
-
@slot.content() {<RouterView />}
|
|
1014
|
-
</Layout>
|
|
1015
|
-
</RouterContext>
|
|
1016
|
-
);
|
|
1017
|
-
}
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
|
-
@View
|
|
1021
|
-
class Sidebar {
|
|
1022
|
-
@ctx(RouterContext, "location")
|
|
1023
|
-
private location!: RouterLocation;
|
|
1024
|
-
|
|
1025
|
-
render() {
|
|
1026
|
-
const pathname = this.location.pathname;
|
|
1027
|
-
const isHome = pathname === "/";
|
|
1028
|
-
const isAnalytics = pathname.startsWith("/analytics");
|
|
1029
|
-
|
|
1030
|
-
return (
|
|
1031
|
-
<nav>
|
|
1032
|
-
<Link
|
|
1033
|
-
to="/"
|
|
1034
|
-
label="Главная"
|
|
1035
|
-
class={isHome ? "font-bold" : ""}
|
|
1036
|
-
/>
|
|
1037
|
-
<Link
|
|
1038
|
-
to="/analytics"
|
|
1039
|
-
label="Аналитика"
|
|
1040
|
-
class={isAnalytics ? "font-bold" : ""}
|
|
1041
|
-
/>
|
|
1042
|
-
</nav>
|
|
1043
|
-
);
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
```
|
|
1047
|
-
|
|
1048
|
-
Типичный компонент‑страница может использовать утилитарные декораторы роутера:
|
|
1049
|
-
|
|
1050
|
-
```tsx
|
|
1051
|
-
import { View, path, search, params, router, type RouterAPI } from "@helfy/helfy";
|
|
1052
|
-
|
|
1053
|
-
@View
|
|
1054
|
-
class DebugPage {
|
|
1055
|
-
@path()
|
|
1056
|
-
private pathname!: string;
|
|
1057
|
-
|
|
1058
|
-
@params()
|
|
1059
|
-
private routeParams!: Record<string, string>;
|
|
1060
|
-
|
|
1061
|
-
@search()
|
|
1062
|
-
private query!: Record<string, string>;
|
|
1063
|
-
|
|
1064
|
-
@router()
|
|
1065
|
-
private rtr!: RouterAPI;
|
|
1066
|
-
|
|
1067
|
-
render() {
|
|
1068
|
-
return (
|
|
1069
|
-
<section>
|
|
1070
|
-
<h1>Debug</h1>
|
|
1071
|
-
<pre>pathname: {this.pathname}</pre>
|
|
1072
|
-
<pre>params: {JSON.stringify(this.routeParams)}</pre>
|
|
1073
|
-
<pre>query: {JSON.stringify(this.query)}</pre>
|
|
1074
|
-
<button onclick={() => this.rtr.push("/analytics")}>
|
|
1075
|
-
Перейти в аналитику
|
|
1076
|
-
</button>
|
|
1077
|
-
</section>
|
|
1078
|
-
);
|
|
1079
|
-
}
|
|
1080
|
-
}
|
|
1081
|
-
```
|
|
1082
|
-
|
|
1083
|
-
Под капотом:
|
|
1084
|
-
|
|
1085
|
-
- `Router` использует `history.pushState/replaceState` и `popstate` для синхронизации с URL.
|
|
1086
|
-
- `RouterContext` хранит реактивное `location` (`@provide({ reactive: true })`), так что `@ctx(RouterContext, "location")` и декораторы `@path/@search/@params/@router` автоматически триггерят перерендер компонентов при смене маршрута.
|
|
1087
|
-
- `RouterView` сопоставляет текущий `pathname` с `RouteConfig` (в том числе с параметрами `:id` и вложенными маршрутами) и рендерит нужный `@View`‑класс. При отсутствии совпадения показывается `DefaultNotFound` или содержимое слота `@slot.notFound`.
|
|
1088
|
-
- `Link` перехватывает `click`, не перезагружает страницу и вызывает `router.push/replace`, сохраняя поведение обычных ссылок (`href` остаётся).
|
|
1089
|
-
|
|
1090
|
-
**Кастомная страница 404.** Оберните `RouterView` и переопределите слот `notFound`:
|
|
1091
|
-
|
|
1092
|
-
```tsx
|
|
1093
|
-
@View
|
|
1094
|
-
class AppRouter {
|
|
1095
|
-
render() {
|
|
1096
|
-
return (
|
|
1097
|
-
<RouterView>
|
|
1098
|
-
@slot.notFound({ pathname }) {
|
|
1099
|
-
<MyNotFound pathname={pathname} />
|
|
1100
|
-
}
|
|
1101
|
-
</RouterView>
|
|
1102
|
-
);
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
```
|
|
1106
|
-
|
|
1107
|
-
### Жизненный цикл компонента
|
|
1108
|
-
|
|
1109
|
-
1. `constructor(props)` -- создание экземпляра, `this.props` оборачивается в реактивный Proxy
|
|
1110
|
-
2. `render()` -- возврат JSX (**вызывается один раз** при монтировании; Babel-плагин оборачивает выражения в `createReactiveChild` для fine-grained обновлений)
|
|
1111
|
-
3. `mount()` -- первичный рендер JSX в DOM-фрагмент
|
|
1112
|
-
4. `onMount()` -- хук после первого монтирования (опциональный)
|
|
1113
|
-
5. `onAttached()` -- хук после вставки в document (опциональный). Вызывается при `attach(parent)`.
|
|
1114
|
-
6. `update()` -- структурное обновление (при смене корневого компонента, например при роутинге). Для одного и того же дочернего компонента обновляются только signals через `updateProps`
|
|
1115
|
-
7. `updateProps(newProps)` -- обновление prop-signals через `batch()` (fine-grained, без полного перерендера)
|
|
1116
|
-
|
|
1117
|
-
#### onMount vs onAttached
|
|
1118
|
-
|
|
1119
|
-
| | `onMount()` | `onAttached()` |
|
|
1120
|
-
|---|---|---|
|
|
1121
|
-
| **Когда** | Сразу после `mount()`, дерево построено, рефы назначены | После `attach(parent)`, элемент в document |
|
|
1122
|
-
| **Элемент в document** | Может быть ещё нет (корень в fragment) | Да |
|
|
1123
|
-
| **Рефы** | Доступны | Доступны |
|
|
1124
|
-
|
|
1125
|
-
**`onMount()`** — для инициализации, не требующей document:
|
|
1126
|
-
- Подписки на store/observable/сервисы
|
|
1127
|
-
- Настройка внутреннего состояния
|
|
1128
|
-
- Добавление обработчиков (работают и на detached-узлах)
|
|
1129
|
-
|
|
1130
|
-
**`onAttached()`** — для операций, требующих элемент в document:
|
|
1131
|
-
- `focus()`, `scrollIntoView()`
|
|
1132
|
-
- `getBoundingClientRect()`, замер layout
|
|
1133
|
-
- Любые DOM API, работающие только с подключённым узлом
|
|
1134
|
-
|
|
1135
|
-
```tsx
|
|
1136
|
-
@View
|
|
1137
|
-
class SearchInput {
|
|
1138
|
-
@ref private input!: HTMLInputElement;
|
|
1139
|
-
|
|
1140
|
-
onMount() {
|
|
1141
|
-
this.store.subscribe(this.handleChange); // подписка — document не нужен
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
onAttached() {
|
|
1145
|
-
this.input.focus(); // фокус — нужен document
|
|
1146
|
-
}
|
|
1147
|
-
}
|
|
1148
|
-
```
|
|
1149
|
-
|
|
1150
|
-
### Утилиты рендеринга ($)
|
|
1151
|
-
|
|
1152
|
-
Компилятор трансформирует директивы в вызовы `$`, но их можно использовать и напрямую.
|
|
1153
|
-
|
|
1154
|
-
**Статические** (условие вычисляется один раз):
|
|
1155
|
-
|
|
1156
|
-
```tsx
|
|
1157
|
-
import { $ } from "@helfy/helfy";
|
|
1158
|
-
|
|
1159
|
-
render() {
|
|
1160
|
-
return (
|
|
1161
|
-
<div>
|
|
1162
|
-
{$._if(this.visible, <span>Видно</span>)}
|
|
1163
|
-
{$._ifelse(this.count > 0,
|
|
1164
|
-
<span>Положительное</span>,
|
|
1165
|
-
<span>Отрицательное или ноль</span>
|
|
1166
|
-
)}
|
|
1167
|
-
{$._forin(this.items, (item, index) => (
|
|
1168
|
-
<div $_key={item.id}>{index}: {item.name}</div>
|
|
1169
|
-
))}
|
|
1170
|
-
</div>
|
|
1171
|
-
);
|
|
1172
|
-
}
|
|
1173
|
-
```
|
|
1174
|
-
|
|
1175
|
-
**Реактивные** (условие и ветки — thunks, пересчитываются при изменении signals):
|
|
1176
|
-
|
|
1177
|
-
```tsx
|
|
1178
|
-
{$._rIf(() => this.visible, () => <span>Видно</span>)}
|
|
1179
|
-
{$._rIfElse(() => this.count > 0,
|
|
1180
|
-
() => <span>Положительное</span>,
|
|
1181
|
-
() => <span>Отрицательное или ноль</span>
|
|
1182
|
-
)}
|
|
1183
|
-
{$._rForin(() => this.items, (item, index) => (
|
|
1184
|
-
<div $_key={item.id}>{index}: {item.name}</div>
|
|
1185
|
-
))}
|
|
1186
|
-
```
|
|
1187
|
-
|
|
1188
|
-
Компилятор автоматически выбирает реактивный вариант (`_rIf`, `_rIfElse`, `_rForin`) при генерации кода из директив `@if` / `@for`.
|
|
1189
|
-
|
|
1190
|
-
### Слоты (content projection)
|
|
1191
|
-
|
|
1192
|
-
Helfy поддерживает именованные слоты с fallback‑содержимым и переопределением в дочерних компонентах.
|
|
1193
|
-
|
|
1194
|
-
#### Провайдер слота (`@View`‑компонент)
|
|
1195
|
-
|
|
1196
|
-
Слоты объявляются прямо в JSX через директиву `@slot:имя(...)` внутри `render()`:
|
|
1197
|
-
|
|
1198
|
-
```tsx
|
|
1199
|
-
import { View } from "@helfy/helfy";
|
|
1200
|
-
|
|
1201
|
-
@View
|
|
1202
|
-
class AppLayout {
|
|
1203
|
-
render() {
|
|
1204
|
-
return (
|
|
1205
|
-
<section class="layout">
|
|
1206
|
-
{/* Именованный слот header с fallback-разметкой */}
|
|
1207
|
-
@slot:header({ title: "Список задач" }) fallback {
|
|
1208
|
-
<header class="mb-4">
|
|
1209
|
-
<h1 class="text-2xl font-bold text-gray-900">
|
|
1210
|
-
Список задач
|
|
1211
|
-
</h1>
|
|
1212
|
-
</header>
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
{/* Именованный слот content с fallback и директивой @if внутри */}
|
|
1216
|
-
@slot:content({ store: this.props.store, filtered: this.props.filtered, hasTodos: this.props.hasTodos }) fallback {
|
|
1217
|
-
@if (this.props.hasTodos) {
|
|
1218
|
-
<section class="pt-3 border-t border-gray-200 text-sm text-gray-600">
|
|
1219
|
-
<p class="mb-1">
|
|
1220
|
-
Всего задач: {this.props.store.todos.length}, активных: {this.props.store.activeCount},
|
|
1221
|
-
завершённых: {this.props.store.completedCount}
|
|
1222
|
-
</p>
|
|
1223
|
-
<p>Фильтрованных к показу: {this.props.filtered.length}</p>
|
|
1224
|
-
</section>
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
</section>
|
|
1228
|
-
);
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
```
|
|
1232
|
-
|
|
1233
|
-
Правила для провайдера:
|
|
1234
|
-
|
|
1235
|
-
- `@slot:header({ ... })` — объявляет именованный слот `header` и вызывает его.
|
|
1236
|
-
- Блок `fallback { ... }` (опциональный) задаёт разметку по умолчанию, если слот не переопределён.
|
|
1237
|
-
- Внутри `fallback` можно использовать директивы `@if`, `@for` и обычный JSX.
|
|
1238
|
-
|
|
1239
|
-
#### Потребитель слота (override в JSX)
|
|
1240
|
-
|
|
1241
|
-
Переопределение слота в дочернем компоненте делается через `@slot.имя(...) { ... }` внутри JSX‑детей:
|
|
1242
|
-
|
|
1243
|
-
```tsx
|
|
1244
|
-
@View
|
|
1245
|
-
class TodoApp {
|
|
1246
|
-
render() {
|
|
1247
|
-
const store = this.store;
|
|
1248
|
-
const filtered = this.filteredTodos;
|
|
1249
|
-
|
|
1250
|
-
return (
|
|
1251
|
-
<AppLayout
|
|
1252
|
-
user={store.user}
|
|
1253
|
-
store={store}
|
|
1254
|
-
filtered={filtered}
|
|
1255
|
-
hasTodos={this.hasTodos}
|
|
1256
|
-
>
|
|
1257
|
-
{/* Переопределяем слот header */}
|
|
1258
|
-
@slot.header({ title }) {
|
|
1259
|
-
<header class="mb-5">
|
|
1260
|
-
<h1 class="mb-4 text-2xl font-bold text-gray-900">
|
|
1261
|
-
Задачи ({title})
|
|
1262
|
-
</h1>
|
|
1263
|
-
<TodoInput
|
|
1264
|
-
placeholder="Добавить задачу…"
|
|
1265
|
-
onSubmit={(text) => store.add(text)}
|
|
1266
|
-
/>
|
|
1267
|
-
</header>
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
{/* Переопределяем слот content, внутри можно использовать @if/@for */}
|
|
1271
|
-
@slot.content({ store, filtered, hasTodos }) {
|
|
1272
|
-
@if (hasTodos) {
|
|
1273
|
-
<section class="pt-3 border-t border-gray-200">
|
|
1274
|
-
<TodoList
|
|
1275
|
-
todos={filtered}
|
|
1276
|
-
onToggle={(id) => store.toggle(id)}
|
|
1277
|
-
onRemove={(id) => store.remove(id)}
|
|
1278
|
-
/>
|
|
1279
|
-
<TodoFooter
|
|
1280
|
-
filter={this.filter}
|
|
1281
|
-
activeCount={store.activeCount}
|
|
1282
|
-
completedCount={store.completedCount}
|
|
1283
|
-
summary={store.filterSummary}
|
|
1284
|
-
onFilter={(f) => store.setFilter(f)}
|
|
1285
|
-
onClearCompleted={() => store.clearCompleted()}
|
|
1286
|
-
/>
|
|
1287
|
-
</section>
|
|
1288
|
-
}
|
|
1289
|
-
}
|
|
1290
|
-
</AppLayout>
|
|
1291
|
-
);
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
```
|
|
1295
|
-
|
|
1296
|
-
Кратко по синтаксису:
|
|
1297
|
-
|
|
1298
|
-
- `@slot:имя({ props }) fallback { FallbackJSX }` — объявление и вызов слота в провайдере.
|
|
1299
|
-
- `@slot.имя({ ctx }) { OverrideJSX }` — переопределение слота в потребителе.
|
|
1300
|
-
- Внутри `FallbackJSX` и `OverrideJSX` поддерживаются все директивы (`@if`, `@for`, `@empty`) и обычный JSX.
|