tyaff 0.0.1 → 0.0.3
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/LICENSE +21 -0
- package/README.md +1032 -2
- package/logo.svg +127 -0
- package/package.json +23 -4
- package/src/core.js +1278 -0
- package/index.js +0 -1
package/README.md
CHANGED
|
@@ -1,2 +1,1032 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
<center>
|
|
2
|
+
<img src="logo.svg" alt="Логотип" width="220">
|
|
3
|
+
</center>
|
|
4
|
+
|
|
5
|
+
# Tyaff — VDOM библиотека для JavaScript
|
|
6
|
+
|
|
7
|
+
Легковесная альтернатива React на чистом JavaScript (ES6+) с собственным виртуальным DOM и философией минимализма. Библиотека отходит от каноничной модели React там, где она кажется избыточной: вместо иммутабельности — прямая мутация состояния, вместо Provider/Consumer — прозрачный pull-based контекст, вместо чёрного ящика мемоизации — явные массивы зависимостей с точечным контролем над рендером.
|
|
8
|
+
|
|
9
|
+
**Ключевые отличия от React:**
|
|
10
|
+
|
|
11
|
+
- **`memo()` блокирует только текущий компонент** — принимает массив зависимостей и не выполняет `render()` только для себя. Дочерние компоненты продолжают свою цепочку обновлений независимо, что делает оптимизацию предсказуемой и не ломает работу контекста.
|
|
12
|
+
|
|
13
|
+
- **Мутабельные данные из любых источников** — компонент может читать глобальный store, singleton или `window` напрямую, без props drilling. Данные живут там, где им удобно, а компонент просто их потребляет.
|
|
14
|
+
|
|
15
|
+
- **Pull-based контекст без Provider/Consumer** — любой компонент объявляет себя провайдером через объект `context: { key() { ... } }`, а дети ниже по дереву запрашивают значения через `this.context(key)` по мере необходимости. Никаких обёрток и HOC.
|
|
16
|
+
|
|
17
|
+
- **Props первым аргументом** — сигнатуры `init(props)`, `memo(props)`, `render({ title, items })` позволяют деструктурировать props прямо в определении, делая код декларативным и компактным.
|
|
18
|
+
|
|
19
|
+
- **Ключи уникальны в пределах всего render** (а не только среди братьев, как в React) — это позволяет перемещать компоненты между разными родителями с сохранением instance и state.
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
## Основные возможности
|
|
23
|
+
|
|
24
|
+
### 🎯 Компактный и производительный
|
|
25
|
+
- Минимальный размер API
|
|
26
|
+
- Собственный diff/patch алгоритм
|
|
27
|
+
- Кеширование refs и обработчиков
|
|
28
|
+
- Batching обновлений через microtask
|
|
29
|
+
|
|
30
|
+
### 🔄 Динамическое дерево контекстов
|
|
31
|
+
- Иерархическая система провайдеров
|
|
32
|
+
- Методы `context()` и `contextSelf()`
|
|
33
|
+
- Автоматическая передача контекста через HTML-теги
|
|
34
|
+
- Защита от рекурсии
|
|
35
|
+
|
|
36
|
+
### 🏗️ Компоненты на основе фабрик
|
|
37
|
+
- Единый способ создания компонентов
|
|
38
|
+
- Автоматический биндинг пользовательских методов
|
|
39
|
+
- Локальное состояние и свойства
|
|
40
|
+
- Гибкий lifecycle
|
|
41
|
+
|
|
42
|
+
### 🚪 Порталы с отложенным монтированием
|
|
43
|
+
- Монтирование в произвольный DOM-контейнер
|
|
44
|
+
- Якорные текстовые узлы для стабильности
|
|
45
|
+
- Отложенная активация при появлении контейнера
|
|
46
|
+
- Автоматическая очистка при unmount
|
|
47
|
+
|
|
48
|
+
### 🔑 Система ключей (отличие от React)
|
|
49
|
+
- Пользовательские ключи уникальны в пределах всего render
|
|
50
|
+
- Сохранение instance при перемещении между разными родителями
|
|
51
|
+
- Автоматические ключи на основе пути
|
|
52
|
+
- Экранирование специальных символов
|
|
53
|
+
|
|
54
|
+
### 🧩 Fragment с key
|
|
55
|
+
- Группировка детей без обёртки (без key)
|
|
56
|
+
- Виртуальный instance для перемещения групп (с key)
|
|
57
|
+
- Сохранение состояния детей при перемещении группы
|
|
58
|
+
|
|
59
|
+
### 📦 Защита структуры детей
|
|
60
|
+
- Сохранение вложенности массивов
|
|
61
|
+
- Иерархическая генерация ключей
|
|
62
|
+
- Предотвращение сдвига индексов
|
|
63
|
+
- Стабильная идентификация элементов
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
## Установка
|
|
67
|
+
|
|
68
|
+
```javascript
|
|
69
|
+
import { h, Component, createPortal, Fragment, mount, refresh } from 'tyaff';
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
## Публичный API
|
|
74
|
+
|
|
75
|
+
### h(type, props, ...children)
|
|
76
|
+
Создание VDOM узла.
|
|
77
|
+
|
|
78
|
+
**Параметры:**
|
|
79
|
+
- `type` — строка (HTML-тег), функция (компонент) или Symbol (Fragment/Portal)
|
|
80
|
+
- `props` — объект свойств (может быть `null`)
|
|
81
|
+
- `children` — дочерние элементы
|
|
82
|
+
|
|
83
|
+
**Возвращает:** VDOM объект `{ tag, props, childs }`
|
|
84
|
+
|
|
85
|
+
**Пример:**
|
|
86
|
+
```javascript
|
|
87
|
+
h('div', { className: 'container' },
|
|
88
|
+
h('h1', null, 'Заголовок'),
|
|
89
|
+
h('p', null, 'Текст')
|
|
90
|
+
)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Component(definition)
|
|
94
|
+
Фабрика для создания компонентов.
|
|
95
|
+
|
|
96
|
+
**Параметры:**
|
|
97
|
+
- `definition` — объект с lifecycle методами, свойствами и пользовательскими методами
|
|
98
|
+
|
|
99
|
+
**Возвращает:** Конструктор компонента (с маркером `_definition`)
|
|
100
|
+
|
|
101
|
+
**Структура definition:**
|
|
102
|
+
```javascript
|
|
103
|
+
{
|
|
104
|
+
name: 'MyComponent', // опционально, для логов ошибок
|
|
105
|
+
|
|
106
|
+
// Lifecycle методы
|
|
107
|
+
init(props),
|
|
108
|
+
render(props),
|
|
109
|
+
props(incoming),
|
|
110
|
+
memo(props),
|
|
111
|
+
onMounted(),
|
|
112
|
+
onUpdated(),
|
|
113
|
+
onUnmounted(),
|
|
114
|
+
|
|
115
|
+
// Объект контекста
|
|
116
|
+
context: {
|
|
117
|
+
theme() { return 'light'; }
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
// Пользовательские методы (АВТОМАТИЧЕСКИ биндятся)
|
|
121
|
+
increment() { this.count++; },
|
|
122
|
+
|
|
123
|
+
// Пользовательские свойства (копируются на инстанс)
|
|
124
|
+
count: 0,
|
|
125
|
+
config: { theme: 'dark' }
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### createPortal(children, containerGetter)
|
|
130
|
+
Создание портала для монтирования в произвольный DOM-контейнер.
|
|
131
|
+
|
|
132
|
+
**Параметры:**
|
|
133
|
+
- `children` — VDOM дети
|
|
134
|
+
- `containerGetter` — функция, возвращающая DOM-элемент или `null`
|
|
135
|
+
|
|
136
|
+
**Возвращает:** VDOM узел с `tag: Symbol(Portal)`
|
|
137
|
+
|
|
138
|
+
**Пример:**
|
|
139
|
+
```javascript
|
|
140
|
+
createPortal(
|
|
141
|
+
h('div', { className: 'modal' }, 'Содержимое'),
|
|
142
|
+
() => document.getElementById('modal-root')
|
|
143
|
+
)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Fragment
|
|
147
|
+
Symbol для создания фрагментов. Поддерживает `key` prop для перемещения групп детей.
|
|
148
|
+
|
|
149
|
+
**Примеры:**
|
|
150
|
+
```javascript
|
|
151
|
+
// Простая группировка без overhead
|
|
152
|
+
h(Fragment, null,
|
|
153
|
+
h('li', null, 'Item 1'),
|
|
154
|
+
h('li', null, 'Item 2')
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
// Группа с key — можно перемещать, дети сохраняют instance
|
|
158
|
+
h(Fragment, { key: 'group-a' },
|
|
159
|
+
h(Item, { key: 'i1' }),
|
|
160
|
+
h(Item, { key: 'i2' })
|
|
161
|
+
)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### mount(input, container)
|
|
165
|
+
**Универсальная функция** — единая точка входа для mount, update и unmount.
|
|
166
|
+
|
|
167
|
+
**Поведение:**
|
|
168
|
+
- **Первый вызов** с vnode → создаёт DOM и вставляет в container
|
|
169
|
+
- **Повторный вызов** с vnode → выполняет diff, применяет изменения к существующему DOM
|
|
170
|
+
- **Вызов с `null`** → размонтирует дерево (выполняется `onUnmounted`, `ref(null)`)
|
|
171
|
+
|
|
172
|
+
**Поддерживаемые типы `input`:**
|
|
173
|
+
- **vnode** (объект с `tag`, `props`, `childs`)
|
|
174
|
+
- **Конструктор компонента** — оборачивается в `h(Component, {})`
|
|
175
|
+
- **Массив vnode** — оборачивается в `h(Fragment, {}, ...array)`
|
|
176
|
+
- **Строка/число** — оборачивается в текстовый узел
|
|
177
|
+
- **`null`/`undefined`** — размонтирует дерево
|
|
178
|
+
|
|
179
|
+
**Примеры:**
|
|
180
|
+
```javascript
|
|
181
|
+
// Первый mount
|
|
182
|
+
mount(App, container);
|
|
183
|
+
mount(h(App, { theme: 'dark' }), container);
|
|
184
|
+
|
|
185
|
+
// Массив
|
|
186
|
+
mount([h('div', null, 'A'), h('div', null, 'B')], container);
|
|
187
|
+
|
|
188
|
+
// Текст
|
|
189
|
+
mount('Hello World', container);
|
|
190
|
+
|
|
191
|
+
// Update
|
|
192
|
+
mount(h(App, { theme: 'light' }), container);
|
|
193
|
+
|
|
194
|
+
// Unmount
|
|
195
|
+
mount(null, container);
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
**Ограничения:**
|
|
199
|
+
- Один контейнер = одно дерево
|
|
200
|
+
- `onMounted()` выполняется только при первом mount
|
|
201
|
+
|
|
202
|
+
### refresh()
|
|
203
|
+
Глобальное асинхронное обновление всех примонтированных деревьев.
|
|
204
|
+
|
|
205
|
+
**Сигнатура:**
|
|
206
|
+
```javascript
|
|
207
|
+
const time = await refresh(); // Promise<number> — время в миллисекундах
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**Поведение:**
|
|
211
|
+
- Находит все корневые компоненты
|
|
212
|
+
- Выполняет `update()` у каждого
|
|
213
|
+
- Возвращает `Promise`, который разрешается после завершения всех обновлений
|
|
214
|
+
|
|
215
|
+
**Use cases:**
|
|
216
|
+
- Измерение производительности рендера
|
|
217
|
+
- Интеграция с глобальным state (store, singleton)
|
|
218
|
+
- Async тесты
|
|
219
|
+
- Профилирование slow renders
|
|
220
|
+
|
|
221
|
+
**Пример:**
|
|
222
|
+
```javascript
|
|
223
|
+
store.items = processData(bigData);
|
|
224
|
+
const time = await refresh();
|
|
225
|
+
console.log(`Render: ${time.toFixed(2)}ms`);
|
|
226
|
+
if (time > 16) console.warn('Slow render');
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
## Архитектура
|
|
231
|
+
|
|
232
|
+
### Виртуальный DOM
|
|
233
|
+
- Плоские объекты с `tag`, `props`, `childs`
|
|
234
|
+
- Сохранение вложенной структуры массивов
|
|
235
|
+
- Текстовые узлы оборачиваются в объекты `{ _text: '...' }`
|
|
236
|
+
- `null` в VDOM игнорируется при диффе
|
|
237
|
+
|
|
238
|
+
### Diff алгоритм
|
|
239
|
+
- Сравнение по `tag` и `key`
|
|
240
|
+
- Точечное обновление атрибутов
|
|
241
|
+
- Рекурсивная обработка детей
|
|
242
|
+
- Ключи для сохранения instance при перемещении
|
|
243
|
+
|
|
244
|
+
### Lifecycle
|
|
245
|
+
- `init(props)` — инициализация (один раз при создании instance)
|
|
246
|
+
- `props(incoming)` — нормализация пропсов (чистая функция)
|
|
247
|
+
- `memo(props)` — массив зависимостей для оптимизации
|
|
248
|
+
- `render(props)` — возврат VDOM
|
|
249
|
+
- `onMounted()` — после вставки в DOM
|
|
250
|
+
- `onUpdated()` — после обновления DOM (только если render выполнен)
|
|
251
|
+
- `onUnmounted()` — перед удалением
|
|
252
|
+
|
|
253
|
+
### Update Engine
|
|
254
|
+
- Batching через microtask — множественные `update()` объединяются в один render
|
|
255
|
+
- Защита от рекурсии и бесконечных циклов (лимит 50 итераций)
|
|
256
|
+
- Изоляция ошибок — падающий компонент не ломает другие
|
|
257
|
+
- `memo()` блокирует только текущий компонент, дети продолжают цепочку
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
## Обработка атрибутов
|
|
261
|
+
|
|
262
|
+
### HTML-атрибуты
|
|
263
|
+
```javascript
|
|
264
|
+
// camelCase → lowercase
|
|
265
|
+
{ className: 'box' } // → class="box"
|
|
266
|
+
{ htmlFor: 'input' } // → for="input"
|
|
267
|
+
{ tabIndex: 0 } // → tabindex="0"
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### SVG-атрибуты
|
|
271
|
+
```javascript
|
|
272
|
+
// camelCase остаются camelCase или конвертируются
|
|
273
|
+
{ viewBox: '0 0 100 100' } // → viewBox
|
|
274
|
+
{ xlinkHref: '#icon' } // → xlink:href (через setAttributeNS)
|
|
275
|
+
{ preserveAspectRatio: 'xMidYMid' } // → preserveAspectRatio
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Контролируемые формы
|
|
279
|
+
```javascript
|
|
280
|
+
// Используются DOM property, а не атрибуты
|
|
281
|
+
{ value: 'text' } // → element.value
|
|
282
|
+
{ checked: true } // → element.checked
|
|
283
|
+
{ selected: true } // → element.selected
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### Специальные атрибуты
|
|
287
|
+
```javascript
|
|
288
|
+
{
|
|
289
|
+
style: { fontSize: '16px' },
|
|
290
|
+
dangerouslySetInnerHTML: { __html: '<b>Bold</b>' },
|
|
291
|
+
ref: this.refs('element')
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### События
|
|
296
|
+
```javascript
|
|
297
|
+
{
|
|
298
|
+
onClick: (e) => console.log(e),
|
|
299
|
+
onChange: this.handleChange
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
## Производительность
|
|
305
|
+
|
|
306
|
+
### Оптимизации
|
|
307
|
+
- **Batching обновлений** — множественные `update()` в одной задаче объединяются
|
|
308
|
+
- **WeakMap** — автоматическая очистка памяти при удалении контейнеров
|
|
309
|
+
- **Кеширование refs** — одна функция на имя
|
|
310
|
+
- **Batch-вставка** — `prepend()` с чанками по 20 000 для больших деревьев
|
|
311
|
+
- **memo()** — блокировка ненужных ререндеров
|
|
312
|
+
|
|
313
|
+
### Рекомендации
|
|
314
|
+
- Используйте `memo()` для оптимизации компонентов
|
|
315
|
+
- Включайте контекстные значения в `memo()`, если компонент их использует
|
|
316
|
+
- Избегайте создания объектов в `render()`
|
|
317
|
+
- Используйте ключи для списков
|
|
318
|
+
- Минимизируйте вложенность компонентов
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
## Совместимость
|
|
322
|
+
|
|
323
|
+
- ES6+ (ES2015 и выше)
|
|
324
|
+
- Современные браузеры
|
|
325
|
+
- `WeakMap`, `Symbol`, `Promise`, `Array.from`
|
|
326
|
+
- Без внешних зависимостей
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
## Лицензия
|
|
330
|
+
|
|
331
|
+
MIT
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# Примеры использования
|
|
338
|
+
|
|
339
|
+
## 1. Простой компонент
|
|
340
|
+
|
|
341
|
+
```javascript
|
|
342
|
+
import { h, Component, mount } from './core.js';
|
|
343
|
+
|
|
344
|
+
const HelloWorld = Component({
|
|
345
|
+
render() {
|
|
346
|
+
return h('div', { className: 'hello' },
|
|
347
|
+
h('h1', null, 'Привет, мир!'),
|
|
348
|
+
h('p', null, 'Это tyaff')
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// Монтаж в DOM
|
|
354
|
+
mount(HelloWorld, document.body);
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
## 2. Компонент с состоянием
|
|
358
|
+
|
|
359
|
+
```javascript
|
|
360
|
+
const Counter = Component({
|
|
361
|
+
count: 0,
|
|
362
|
+
|
|
363
|
+
increment() {
|
|
364
|
+
this.update({ count: this.count + 1 });
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
decrement() {
|
|
368
|
+
this.update({ count: this.count - 1 });
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
render() {
|
|
372
|
+
return h('div', null,
|
|
373
|
+
h('span', null, 'Счётчик: ' + this.count),
|
|
374
|
+
h('button', { onClick: this.decrement }, '-'),
|
|
375
|
+
h('button', { onClick: this.increment }, '+')
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
mount(Counter, document.getElementById('app'));
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
## 3. Props первым аргументом
|
|
384
|
+
|
|
385
|
+
```javascript
|
|
386
|
+
const Button = Component({
|
|
387
|
+
// Нормализация пропсов
|
|
388
|
+
props(incoming) {
|
|
389
|
+
const { label, type = 'button', disabled = false, onClick } = incoming;
|
|
390
|
+
return { label, type, disabled, onClick };
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
// Деструктуризация прямо в сигнатуре
|
|
394
|
+
render({ label, type, disabled, onClick }) {
|
|
395
|
+
return h('button', { type, disabled, onClick }, label);
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// Использование
|
|
400
|
+
mount(
|
|
401
|
+
h(Button, {
|
|
402
|
+
label: 'Отправить',
|
|
403
|
+
onClick: () => alert('Клик!')
|
|
404
|
+
}),
|
|
405
|
+
document.body
|
|
406
|
+
);
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
## 4. Lifecycle методы
|
|
410
|
+
|
|
411
|
+
```javascript
|
|
412
|
+
const Timer = Component({
|
|
413
|
+
count: 0,
|
|
414
|
+
intervalId: null,
|
|
415
|
+
|
|
416
|
+
init(props) {
|
|
417
|
+
console.log('Инициализация компонента');
|
|
418
|
+
this.intervalId = setInterval(() => {
|
|
419
|
+
this.update({ count: this.count + 1 });
|
|
420
|
+
}, 1000);
|
|
421
|
+
},
|
|
422
|
+
|
|
423
|
+
onMounted() {
|
|
424
|
+
console.log('Компонент смонтирован, DOM доступен');
|
|
425
|
+
},
|
|
426
|
+
|
|
427
|
+
onUpdated() {
|
|
428
|
+
console.log('Компонент обновлён:', this.count);
|
|
429
|
+
},
|
|
430
|
+
|
|
431
|
+
onUnmounted() {
|
|
432
|
+
console.log('Компонент будет удалён');
|
|
433
|
+
clearInterval(this.intervalId);
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
render() {
|
|
437
|
+
return h('div', null, 'Таймер: ' + this.count);
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
## 5. memo() для оптимизации
|
|
443
|
+
|
|
444
|
+
```javascript
|
|
445
|
+
const ExpensiveComponent = Component({
|
|
446
|
+
props(incoming) {
|
|
447
|
+
return {
|
|
448
|
+
data: incoming.data || [],
|
|
449
|
+
multiplier: incoming.multiplier || 1
|
|
450
|
+
};
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
// Зависимости для мемоизации
|
|
454
|
+
memo(props) {
|
|
455
|
+
return [props.data.length, props.multiplier];
|
|
456
|
+
},
|
|
457
|
+
|
|
458
|
+
render(props) {
|
|
459
|
+
console.log('render() выполняется');
|
|
460
|
+
const result = props.data.reduce((sum, item) =>
|
|
461
|
+
sum + item * props.multiplier, 0
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
return h('div', null, 'Результат: ' + result);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// render() выполняется только при изменении длины массива или multiplier
|
|
469
|
+
// Если зависимости совпадают — render() блокируется
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
## 6. Context (провайдеры)
|
|
473
|
+
|
|
474
|
+
```javascript
|
|
475
|
+
// Провайдер темы
|
|
476
|
+
const ThemeProvider = Component({
|
|
477
|
+
theme: 'light',
|
|
478
|
+
|
|
479
|
+
context: {
|
|
480
|
+
theme() { return this.theme; },
|
|
481
|
+
toggleTheme() {
|
|
482
|
+
this.theme = this.theme === 'light' ? 'dark' : 'light';
|
|
483
|
+
this.update();
|
|
484
|
+
}
|
|
485
|
+
},
|
|
486
|
+
|
|
487
|
+
render() {
|
|
488
|
+
return h('div', { className: 'theme-provider' },
|
|
489
|
+
h('button',
|
|
490
|
+
{ onClick: () => this.contextSelf('toggleTheme') },
|
|
491
|
+
'Переключить тему'
|
|
492
|
+
),
|
|
493
|
+
this.props.children
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// Потребитель темы
|
|
499
|
+
const ThemedButton = Component({
|
|
500
|
+
memo(props) {
|
|
501
|
+
// ✅ Включаем контекст в зависимости
|
|
502
|
+
return [this.context('theme')];
|
|
503
|
+
},
|
|
504
|
+
|
|
505
|
+
render() {
|
|
506
|
+
const theme = this.context('theme');
|
|
507
|
+
return h('button',
|
|
508
|
+
{ className: 'btn-' + theme },
|
|
509
|
+
this.props.children
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
mount(
|
|
515
|
+
h(ThemeProvider, null,
|
|
516
|
+
h(ThemedButton, null, 'Кнопка с темой')
|
|
517
|
+
),
|
|
518
|
+
document.body
|
|
519
|
+
);
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
## 7. Вложенный Context
|
|
523
|
+
|
|
524
|
+
```javascript
|
|
525
|
+
const UserProvider = Component({
|
|
526
|
+
context: {
|
|
527
|
+
user() { return this.props.user || null; },
|
|
528
|
+
isAdmin() {
|
|
529
|
+
const user = this.context('user');
|
|
530
|
+
return user && user.role === 'admin';
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
|
|
534
|
+
render() {
|
|
535
|
+
return h('div', null, this.props.children);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
const AdminPanel = Component({
|
|
540
|
+
render() {
|
|
541
|
+
const isAdmin = this.contextSelf('isAdmin');
|
|
542
|
+
|
|
543
|
+
if (!isAdmin) {
|
|
544
|
+
return h('div', null, 'Доступ запрещён');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return h('div', { className: 'admin-panel' }, 'Админ-панель');
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
mount(
|
|
552
|
+
h(UserProvider, { user: { name: 'John', role: 'admin' } },
|
|
553
|
+
h(AdminPanel, null)
|
|
554
|
+
),
|
|
555
|
+
document.body
|
|
556
|
+
);
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
## 8. Refs (ссылки на DOM и компоненты)
|
|
560
|
+
|
|
561
|
+
```javascript
|
|
562
|
+
const InputFocus = Component({
|
|
563
|
+
onMounted() {
|
|
564
|
+
// DOM доступен после монтирования
|
|
565
|
+
if (this.refs.input) {
|
|
566
|
+
this.refs.input.focus();
|
|
567
|
+
}
|
|
568
|
+
},
|
|
569
|
+
|
|
570
|
+
handleClick() {
|
|
571
|
+
if (this.refs.input) {
|
|
572
|
+
this.refs.input.select();
|
|
573
|
+
}
|
|
574
|
+
},
|
|
575
|
+
|
|
576
|
+
render() {
|
|
577
|
+
return h('div', null,
|
|
578
|
+
h('input', {
|
|
579
|
+
ref: this.refs('input'),
|
|
580
|
+
type: 'text',
|
|
581
|
+
value: 'Кликни для выделения',
|
|
582
|
+
readOnly: true
|
|
583
|
+
}),
|
|
584
|
+
h('button', { onClick: this.handleClick }, 'Выделить')
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// Ref на компонент
|
|
590
|
+
const Parent = Component({
|
|
591
|
+
onMounted() {
|
|
592
|
+
this.refs.child.someMethod(); // вызов метода дочернего компонента
|
|
593
|
+
},
|
|
594
|
+
|
|
595
|
+
render() {
|
|
596
|
+
return h(Child, { ref: this.refs('child') });
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
## 9. Порталы
|
|
602
|
+
|
|
603
|
+
```javascript
|
|
604
|
+
const Modal = Component({
|
|
605
|
+
render() {
|
|
606
|
+
if (!this.props.visible) return null;
|
|
607
|
+
|
|
608
|
+
return createPortal(
|
|
609
|
+
h('div', { className: 'modal-overlay' },
|
|
610
|
+
h('div', { className: 'modal-content' },
|
|
611
|
+
h('h2', null, this.props.title),
|
|
612
|
+
h('p', null, this.props.children),
|
|
613
|
+
h('button', { onClick: this.props.onClose }, 'Закрыть')
|
|
614
|
+
)
|
|
615
|
+
),
|
|
616
|
+
() => document.getElementById('modal-root')
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
const App = Component({
|
|
622
|
+
showModal: false,
|
|
623
|
+
|
|
624
|
+
toggleModal() {
|
|
625
|
+
this.update({ showModal: !this.showModal });
|
|
626
|
+
},
|
|
627
|
+
|
|
628
|
+
render() {
|
|
629
|
+
return h('div', null,
|
|
630
|
+
h('button', { onClick: this.toggleModal }, 'Открыть модал'),
|
|
631
|
+
h(Modal, {
|
|
632
|
+
visible: this.showModal,
|
|
633
|
+
title: 'Привет!',
|
|
634
|
+
onClose: this.toggleModal
|
|
635
|
+
}, 'Содержимое модального окна')
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
## 10. Списки с ключами
|
|
642
|
+
|
|
643
|
+
```javascript
|
|
644
|
+
const TodoList = Component({
|
|
645
|
+
todos: [
|
|
646
|
+
{ id: 1, text: 'Изучить tyaff', done: false },
|
|
647
|
+
{ id: 2, text: 'Создать проект', done: false },
|
|
648
|
+
{ id: 3, text: 'Написать тесты', done: false }
|
|
649
|
+
],
|
|
650
|
+
|
|
651
|
+
toggleTodo(id) {
|
|
652
|
+
this.update({
|
|
653
|
+
todos: this.todos.map(todo =>
|
|
654
|
+
todo.id === id ? { ...todo, done: !todo.done } : todo
|
|
655
|
+
)
|
|
656
|
+
});
|
|
657
|
+
},
|
|
658
|
+
|
|
659
|
+
render() {
|
|
660
|
+
return h('ul', null,
|
|
661
|
+
this.todos.map(todo =>
|
|
662
|
+
h('li',
|
|
663
|
+
{
|
|
664
|
+
key: todo.id, // пользовательский ключ
|
|
665
|
+
onClick: () => this.toggleTodo(todo.id),
|
|
666
|
+
style: {
|
|
667
|
+
textDecoration: todo.done ? 'line-through' : 'none'
|
|
668
|
+
}
|
|
669
|
+
},
|
|
670
|
+
todo.text
|
|
671
|
+
)
|
|
672
|
+
)
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
## 11. Fragment
|
|
679
|
+
|
|
680
|
+
```javascript
|
|
681
|
+
// Простая группировка без обёртки
|
|
682
|
+
const TableRows = Component({
|
|
683
|
+
render() {
|
|
684
|
+
return h(Fragment, null,
|
|
685
|
+
h('tr', null,
|
|
686
|
+
h('td', null, 'Ячейка 1'),
|
|
687
|
+
h('td', null, 'Ячейка 2')
|
|
688
|
+
),
|
|
689
|
+
h('tr', null,
|
|
690
|
+
h('td', null, 'Ячейка 3'),
|
|
691
|
+
h('td', null, 'Ячейка 4')
|
|
692
|
+
)
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// Fragment с key — можно перемещать группу
|
|
698
|
+
const Tabs = Component({
|
|
699
|
+
activeTab: 'a',
|
|
700
|
+
|
|
701
|
+
switchTab(tab) {
|
|
702
|
+
this.update({ activeTab: tab });
|
|
703
|
+
},
|
|
704
|
+
|
|
705
|
+
render() {
|
|
706
|
+
return h('div', null,
|
|
707
|
+
this.activeTab === 'a'
|
|
708
|
+
? h(Fragment, { key: 'group-a' },
|
|
709
|
+
h(Item, { key: 'i1' }),
|
|
710
|
+
h(Item, { key: 'i2' })
|
|
711
|
+
)
|
|
712
|
+
: h(Fragment, { key: 'group-b' },
|
|
713
|
+
h(Item, { key: 'i3' }),
|
|
714
|
+
h(Item, { key: 'i4' })
|
|
715
|
+
)
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
## 12. Условный рендеринг
|
|
722
|
+
|
|
723
|
+
```javascript
|
|
724
|
+
const ConditionalRender = Component({
|
|
725
|
+
showDetails: false,
|
|
726
|
+
|
|
727
|
+
toggle() {
|
|
728
|
+
this.update({ showDetails: !this.showDetails });
|
|
729
|
+
},
|
|
730
|
+
|
|
731
|
+
render() {
|
|
732
|
+
return h('div', null,
|
|
733
|
+
h('button', { onClick: this.toggle }, 'Показать детали'),
|
|
734
|
+
|
|
735
|
+
this.showDetails
|
|
736
|
+
? h('div', { className: 'details' },
|
|
737
|
+
h('p', null, 'Это детализированная информация'),
|
|
738
|
+
h('p', null, 'Здесь больше контента')
|
|
739
|
+
)
|
|
740
|
+
: null
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
## 13. Контролируемые формы
|
|
747
|
+
|
|
748
|
+
```javascript
|
|
749
|
+
const Form = Component({
|
|
750
|
+
formData: { name: '', email: '' },
|
|
751
|
+
|
|
752
|
+
handleChange(field, value) {
|
|
753
|
+
this.update({
|
|
754
|
+
formData: { ...this.formData, [field]: value }
|
|
755
|
+
});
|
|
756
|
+
},
|
|
757
|
+
|
|
758
|
+
handleSubmit(e) {
|
|
759
|
+
e.preventDefault();
|
|
760
|
+
console.log('Отправлено:', this.formData);
|
|
761
|
+
},
|
|
762
|
+
|
|
763
|
+
render() {
|
|
764
|
+
return h('form', { onSubmit: this.handleSubmit },
|
|
765
|
+
h('div', null,
|
|
766
|
+
h('label', null, 'Имя:'),
|
|
767
|
+
h('input', {
|
|
768
|
+
type: 'text',
|
|
769
|
+
value: this.formData.name,
|
|
770
|
+
onChange: (e) => this.handleChange('name', e.target.value)
|
|
771
|
+
})
|
|
772
|
+
),
|
|
773
|
+
h('div', null,
|
|
774
|
+
h('label', null, 'Email:'),
|
|
775
|
+
h('input', {
|
|
776
|
+
type: 'email',
|
|
777
|
+
value: this.formData.email,
|
|
778
|
+
onChange: (e) => this.handleChange('email', e.target.value)
|
|
779
|
+
})
|
|
780
|
+
),
|
|
781
|
+
h('button', { type: 'submit' }, 'Отправить')
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
## 14. SVG компоненты
|
|
788
|
+
|
|
789
|
+
```javascript
|
|
790
|
+
const Icon = Component({
|
|
791
|
+
render({ color = 'currentColor' }) {
|
|
792
|
+
return h('svg',
|
|
793
|
+
{
|
|
794
|
+
viewBox: '0 0 24 24',
|
|
795
|
+
width: 24,
|
|
796
|
+
height: 24,
|
|
797
|
+
fill: color
|
|
798
|
+
},
|
|
799
|
+
h('path', {
|
|
800
|
+
d: 'M12 2L2 22h20L12 2z'
|
|
801
|
+
})
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
const Button = Component({
|
|
807
|
+
render() {
|
|
808
|
+
return h('button', { className: 'btn' },
|
|
809
|
+
h(Icon, { color: 'blue' }),
|
|
810
|
+
this.props.children
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
## 15. Global Store Pattern
|
|
817
|
+
|
|
818
|
+
```javascript
|
|
819
|
+
// store.js
|
|
820
|
+
export const store = { count: 0, user: null };
|
|
821
|
+
|
|
822
|
+
// App.js
|
|
823
|
+
import { h, Component, mount, refresh } from './core.js';
|
|
824
|
+
import { store } from './store.js';
|
|
825
|
+
|
|
826
|
+
const Counter = Component({
|
|
827
|
+
render() {
|
|
828
|
+
return h('div', null, 'Count: ', store.count);
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
const UserProfile = Component({
|
|
833
|
+
render() {
|
|
834
|
+
return store.user
|
|
835
|
+
? h('div', null, 'User: ', store.user.name)
|
|
836
|
+
: h('div', null, 'Not logged in');
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
mount(
|
|
841
|
+
h('div', null,
|
|
842
|
+
h(Counter),
|
|
843
|
+
h(UserProfile)
|
|
844
|
+
),
|
|
845
|
+
document.getElementById('app')
|
|
846
|
+
);
|
|
847
|
+
|
|
848
|
+
// Внешнее обновление
|
|
849
|
+
async function updateCount() {
|
|
850
|
+
store.count = 55;
|
|
851
|
+
await refresh(); // явный trigger, все компоненты перечитают store
|
|
852
|
+
}
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
## 16. Анимации через style
|
|
856
|
+
|
|
857
|
+
```javascript
|
|
858
|
+
const AnimatedBox = Component({
|
|
859
|
+
visible: true,
|
|
860
|
+
|
|
861
|
+
toggle() {
|
|
862
|
+
this.update({ visible: !this.visible });
|
|
863
|
+
},
|
|
864
|
+
|
|
865
|
+
render() {
|
|
866
|
+
return h('div', null,
|
|
867
|
+
h('button', { onClick: this.toggle }, 'Переключить'),
|
|
868
|
+
h('div', {
|
|
869
|
+
style: {
|
|
870
|
+
opacity: this.visible ? 1 : 0,
|
|
871
|
+
transition: 'opacity 0.3s ease',
|
|
872
|
+
width: '100px',
|
|
873
|
+
height: '100px',
|
|
874
|
+
backgroundColor: 'blue'
|
|
875
|
+
}
|
|
876
|
+
})
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
## 17. dangerouslySetInnerHTML
|
|
883
|
+
|
|
884
|
+
```javascript
|
|
885
|
+
const HTMLContent = Component({
|
|
886
|
+
render() {
|
|
887
|
+
const htmlString = '<strong>Жирный</strong> и <em>курсив</em>';
|
|
888
|
+
return h('div', {
|
|
889
|
+
dangerouslySetInnerHTML: { __html: htmlString }
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
## 18. Измерение производительности через refresh()
|
|
896
|
+
|
|
897
|
+
```javascript
|
|
898
|
+
async function loadData() {
|
|
899
|
+
const bigData = await fetch('/api/data').then(r => r.json());
|
|
900
|
+
store.items = processData(bigData);
|
|
901
|
+
|
|
902
|
+
const time = await refresh();
|
|
903
|
+
console.log(`Render time: ${time.toFixed(2)}ms`);
|
|
904
|
+
|
|
905
|
+
if (time > 16) {
|
|
906
|
+
console.warn('Slow render detected');
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
## 19. Тестирование с refresh()
|
|
912
|
+
|
|
913
|
+
```javascript
|
|
914
|
+
test('renders user name after store update', async () => {
|
|
915
|
+
mount(UserProfile, container);
|
|
916
|
+
|
|
917
|
+
store.user = { name: 'Alice' };
|
|
918
|
+
await refresh();
|
|
919
|
+
|
|
920
|
+
expect(container.textContent).toContain('Alice');
|
|
921
|
+
});
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
## 20. Update через mount()
|
|
925
|
+
|
|
926
|
+
```javascript
|
|
927
|
+
const App = Component({
|
|
928
|
+
count: 0,
|
|
929
|
+
increment() { this.update({ count: this.count + 1 }); },
|
|
930
|
+
render() {
|
|
931
|
+
return h('div', null,
|
|
932
|
+
h('h1', null, 'Счётчик: ' + this.count),
|
|
933
|
+
h('button', { onClick: this.increment }, '+')
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
// Первоначальный mount
|
|
939
|
+
mount(App, document.getElementById('app'));
|
|
940
|
+
|
|
941
|
+
// При повторном вызове mount() с тем же контейнером
|
|
942
|
+
// выполняется diff и обновление существующего дерева
|
|
943
|
+
mount(h(App, { initialCount: 10 }), document.getElementById('app'));
|
|
944
|
+
|
|
945
|
+
// Unmount
|
|
946
|
+
mount(null, document.getElementById('app'));
|
|
947
|
+
```
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
## Полноценный пример: Todo приложение
|
|
951
|
+
|
|
952
|
+
```javascript
|
|
953
|
+
const TodoApp = Component({
|
|
954
|
+
todos: [],
|
|
955
|
+
inputValue: '',
|
|
956
|
+
|
|
957
|
+
addTodo() {
|
|
958
|
+
if (!this.inputValue.trim()) return;
|
|
959
|
+
|
|
960
|
+
this.update({
|
|
961
|
+
todos: [...this.todos, {
|
|
962
|
+
id: Date.now(),
|
|
963
|
+
text: this.inputValue,
|
|
964
|
+
completed: false
|
|
965
|
+
}],
|
|
966
|
+
inputValue: ''
|
|
967
|
+
});
|
|
968
|
+
},
|
|
969
|
+
|
|
970
|
+
toggleTodo(id) {
|
|
971
|
+
this.update({
|
|
972
|
+
todos: this.todos.map(todo =>
|
|
973
|
+
todo.id === id
|
|
974
|
+
? { ...todo, completed: !todo.completed }
|
|
975
|
+
: todo
|
|
976
|
+
)
|
|
977
|
+
});
|
|
978
|
+
},
|
|
979
|
+
|
|
980
|
+
deleteTodo(id) {
|
|
981
|
+
this.update({
|
|
982
|
+
todos: this.todos.filter(todo => todo.id !== id)
|
|
983
|
+
});
|
|
984
|
+
},
|
|
985
|
+
|
|
986
|
+
render() {
|
|
987
|
+
return h('div', { className: 'todo-app' },
|
|
988
|
+
h('h1', null, 'Todo список'),
|
|
989
|
+
|
|
990
|
+
h('div', { className: 'todo-input' },
|
|
991
|
+
h('input', {
|
|
992
|
+
type: 'text',
|
|
993
|
+
value: this.inputValue,
|
|
994
|
+
onChange: (e) => this.update({ inputValue: e.target.value }),
|
|
995
|
+
onKeyPress: (e) => e.key === 'Enter' && this.addTodo()
|
|
996
|
+
}),
|
|
997
|
+
h('button', { onClick: this.addTodo }, 'Добавить')
|
|
998
|
+
),
|
|
999
|
+
|
|
1000
|
+
h('ul', { className: 'todo-list' },
|
|
1001
|
+
this.todos.map(todo =>
|
|
1002
|
+
h('li', {
|
|
1003
|
+
key: todo.id,
|
|
1004
|
+
className: todo.completed ? 'completed' : ''
|
|
1005
|
+
},
|
|
1006
|
+
h('input', {
|
|
1007
|
+
type: 'checkbox',
|
|
1008
|
+
checked: todo.completed,
|
|
1009
|
+
onChange: () => this.toggleTodo(todo.id)
|
|
1010
|
+
}),
|
|
1011
|
+
h('span', null, todo.text),
|
|
1012
|
+
h('button',
|
|
1013
|
+
{ onClick: () => this.deleteTodo(todo.id) },
|
|
1014
|
+
'Удалить'
|
|
1015
|
+
)
|
|
1016
|
+
)
|
|
1017
|
+
)
|
|
1018
|
+
),
|
|
1019
|
+
|
|
1020
|
+
h('div', { className: 'todo-stats' },
|
|
1021
|
+
'Всего: ' + this.todos.length + ', ',
|
|
1022
|
+
'Выполнено: ' + this.todos.filter(t => t.completed).length
|
|
1023
|
+
)
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
mount(TodoApp, document.getElementById('app'));
|
|
1029
|
+
```
|
|
1030
|
+
|
|
1031
|
+
Эти примеры демонстрируют основные возможности библиотеки tyaff и могут быть использованы как основа для ваших проектов.
|
|
1032
|
+
|