MetaFor — это современный VanillaJS фреймворк для создания real-time веб-приложений на основе контекстно-ориентированного конечного автомата с декларативным API, типобезопасностью и реактивностью. Работает как на клиенте, так и на сервере.
MetaFor("counter")
.context((types) => ({
count: types.number.required(0),
isLoading: types.boolean.required(false),
}))
.states({
idle: { loading: {} },
loading: {
success: { count: { gt: 0 } },
error: { isLoading: false },
},
success: { idle: {} },
error: { idle: {} },
})
.core()
.processes((process) => ({
loading: process()
.action(async ({ context }) => {
await new Promise((resolve) => setTimeout(resolve, 1000))
return { count: context.count + 1 }
})
.success(({ update, data }) => update({ count: data.count, isLoading: false }))
.error(({ update }) => update({ isLoading: false })),
}))
.reactions()
.view({
render: ({ context, html }) => html`
<div>
<h1>Счётчик: ${context.count}</h1>
<button ?disabled=${context.isLoading}>${context.isLoading ? "Загрузка..." : "Увеличить"}</button>
</div>
`,
})
MetaFor состоит из нескольких ключевых компонентов:
Контекст — это типизированное состояние компонента, которое автоматически обновляет UI при изменениях.
.context((types) => ({
// Обязательные поля
name: types.string.required("Anonymous"),
age: types.number.required(18),
isActive: types.boolean.required(false),
// Опциональные поля
email: types.string.optional(),
avatar: types.string.optional(),
// Массивы
tags: types.array.required([]),
// Enum
status: types.enum.required(["pending", "active", "blocked"]),
}))
Поддерживаемые типы:
string — строкиnumber — числаboolean — булевы значенияarray — массивыenum — перечисленияСостояния определяют возможные переходы автомата с условиями.
.states({
guest: {
// Переход в user при выполнении условий
user: {
name: { length: { min: 2 } },
email: { pattern: /@/ }
}
},
user: {
// Переход в admin при isAdmin: true
admin: { isAdmin: true },
// Переход в guest при logout: true
guest: { logout: true }
},
admin: {
user: { isAdmin: false }
}
})
Условия переходов:
Для строк:
name: {
eq: "admin", // равно
startsWith: "user", // начинается с
endsWith: "admin", // заканчивается на
include: "test", // содержит подстроку
pattern: /^[a-z]+$/, // регулярное выражение
length: { min: 3, max: 20 } // длина
}
Для чисел:
age: {
eq: 18, // равно
gt: 0, // больше
gte: 18, // больше или равно
lt: 100, // меньше
lte: 65, // меньше или равно
between: [18, 65] // диапазон
}
Для булевых значений:
isActive: {
eq: true, // равно
notEq: false // не равно
}
Для массивов:
tags: {
length: { min: 1 }, // длина
includes: "admin", // содержит элемент
isEmpty: false // не пустой
}
Процессы — это действия с обработкой успеха и ошибок. Они могут быть как асинхронными, так и синхронными.
.processes((process) => ({
login: process({
title: "Авторизация",
description: "Процесс входа пользователя"
})
.action(async ({ context }) => {
// Основная логика
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({
email: context.email,
password: context.password
})
})
if (!response.ok) {
throw new Error('Ошибка авторизации')
}
return await response.json()
})
.success(({ update, data }) => {
// Обработка успеха
update({
isAuthenticated: true,
user: data.user,
error: ""
})
})
.error(({ update, error }) => {
// Обработка ошибки
update({
error: error.message,
isAuthenticated: false
})
}),
logout: process()
.action(({ context }) => {
// Синхронное действие
localStorage.removeItem('token')
return { success: true }
})
.success(({ update }) => {
update({
isAuthenticated: false,
user: null,
error: ""
})
})
}))
Реакции позволяют обрабатывать внешние события через декларативные фильтры.
.reactions((reaction) => [
[
["idle", "loading"], // Состояния, в которых активна реакция
reaction({ title: "Обработка сообщений" })
.filter({
tag: "user", // Фильтр по тегу
op: "replace", // Фильтр по операции
path: "/context", // Фильтр по пути
value: { gt: 0 } // Фильтр по значению
})
.equal(({ update, context, meta, patch }) => {
// Обработка события
update({
lastMessage: patch.value,
messageCount: context.messageCount + 1
})
})
],
[
["idle", "loading", "success", "error"], // Все состояния
reaction()
.filter({ tag: "system" })
.equal(({ update }) => {
update({ systemNotification: true })
})
]
])
Фильтры реакций:
tag — фильтр по тегу сообщенияindex — фильтр по индексуtimestamp — фильтр по временной меткеop — фильтр по операции (replace, add, remove, test)path — фильтр по пути (/context, /state, /)value — фильтр по значению с поддержкой всех типов условийПредставление определяет UI компонента с использованием HTML template API.
.view({
render: ({ context, html, update }) => html`
<div class="user-profile">
<h1>${context.name}</h1>
${context.isLoading
? html`<div class="loading">Загрузка...</div>`
: html`
<form @submit=${(e) => {
e.preventDefault()
update({ isLoading: true })
}}>
<input
.value=${context.email}
@input=${(e) => update({ email: e.target.value })}
placeholder="Email"
/>
<button type="submit" ?disabled=${!context.email}>
Сохранить
</button>
</form>
`
}
${context.error
? html`<div class="error">${context.error}</div>`
: null
}
</div>
`,
style: ({ css }) => css`
.user-profile {
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
}
.loading {
color: #666;
font-style: italic;
}
.error {
color: red;
margin-top: 10px;
}
input {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
margin-right: 10px;
}
button {
padding: 8px 16px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
`
})
Директивы HTML:
@event — обработчики событий?attribute — булевы атрибуты.property — свойства элементов${ref()} — ссылки на элементы${when(condition, template)} — условный рендеринг${repeat(items, template)} — циклы${map(items, fn)} — преобразование массивовMetaFor поддерживает передачу контекста от родительского компонента к дочернему через специальный атрибут context.
// Родительский компонент
MetaFor("parent")
.context((types) => ({
parentMessage: types.string.required("Hello from parent"),
parentCount: types.number.required(42),
}))
.states({ idle: {} })
.core()
.processes()
.reactions()
.view({
render: ({ context, html }) => html`
<div>
<h1>Родитель: ${context.parentMessage}</h1>
<metafor-child
context=${{
message: context.parentMessage,
count: context.parentCount,
}}></metafor-child>
</div>
`,
})
// Дочерний компонент
MetaFor("child")
.context((types) => ({
message: types.string.required("default message"),
count: types.number.required(0),
}))
.states({ idle: {} })
.core()
.processes()
.reactions()
.view({
render: ({ context, html }) => html`
<div>
<p>Сообщение: ${context.message}</p>
<p>Счетчик: ${context.count}</p>
</div>
`,
})
Особенности передачи контекста:
context=${object}Создает новый экземпляр MetaFor с указанным тегом компонента.
const component = MetaFor("my-component")
MetaFor использует цепочку методов для конфигурации:
MetaFor("example")
.context(schema) // Схема контекста
.states(config) // Конфигурация состояний
.core({}) // Инициализация ядра
.processes(config) // Конфигурация процессов
.reactions(config) // Конфигурация реакций
.view(config) // Конфигурация представления
.context((types) => ({
// Обязательные поля
field: types.string.required(defaultValue),
field: types.number.required(defaultValue),
field: types.boolean.required(defaultValue),
field: types.array.required(defaultValue),
field: types.enum.required(values),
// Опциональные поля
field: types.string.optional(),
field: types.number.optional(),
field: types.boolean.optional(),
field: types.array.optional(),
field: types.enum.optional(values),
}))
.states({
stateName: {
nextState: conditions,
anotherState: conditions,
}
})
.processes((process) => ({
processName: process(config?)
.action(fn)
.success(handler)
.error(handler)
}))
.reactions((reaction) => [
[
["state1", "state2"],
reaction(config?)
.filter(conditions)
.equal(handler)
]
])
.view({
render: ({ context, html, update, ref }) => html`...`,
style: ({ css }) => css`...`
})
MetaFor("async-counter")
.context((types) => ({
count: types.number.required(0),
isLoading: types.boolean.required(false),
error: types.string.optional(),
}))
.states({
idle: { loading: {} },
loading: {
success: { count: { gt: 0 } },
error: { error: { notEq: "" } },
},
success: { idle: {} },
error: { idle: {} },
})
.core()
.processes((process) => ({
loading: process()
.action(async ({ context }) => {
await new Promise((resolve) => setTimeout(resolve, 1000))
if (Math.random() > 0.8) {
throw new Error("Случайная ошибка")
}
return { count: context.count + 1 }
})
.success(({ update, data }) => {
update({ count: data.count, isLoading: false, error: "" })
})
.error(({ update, error }) => {
update({ error: error.message, isLoading: false })
}),
}))
.reactions()
.view({
render: ({ context, html, update }) => html`
<div class="counter">
<h2>Счётчик: ${context.count}</h2>
<button @click=${() => update({ isLoading: true })} ?disabled=${context.isLoading}>
${context.isLoading ? "Загрузка..." : "Увеличить"}
</button>
${context.error ? html`<div class="error">${context.error}</div>` : null}
</div>
`,
style: ({ css }) => css`
.counter {
text-align: center;
padding: 20px;
}
.error {
color: red;
margin-top: 10px;
}
button:disabled {
opacity: 0.5;
}
`,
})
MetaFor("user-form")
.context((types) => ({
name: types.string.required(""),
email: types.string.required(""),
age: types.number.required(0),
errors: types.array.required([]),
isSubmitting: types.boolean.required(false),
}))
.states({
editing: {
submitting: {
name: { length: { min: 2 } },
email: { pattern: /@/ },
age: { gte: 18 },
},
},
submitting: {
success: { isSubmitting: false },
error: { errors: { length: { gt: 0 } } },
},
success: { editing: {} },
error: { editing: {} },
})
.core()
.processes((process) => ({
submitting: process()
.action(async ({ context }) => {
const errors = []
if (context.name.length < 2) {
errors.push("Имя должно содержать минимум 2 символа")
}
if (!context.email.includes("@")) {
errors.push("Некорректный email")
}
if (context.age < 18) {
errors.push("Возраст должен быть не менее 18 лет")
}
if (errors.length > 0) {
throw new Error(errors.join(", "))
}
// Имитация отправки на сервер
await new Promise((resolve) => setTimeout(resolve, 1000))
return { success: true }
})
.success(({ update }) => {
update({
isSubmitting: false,
errors: [],
name: "",
email: "",
age: 0,
})
})
.error(({ update, error }) => {
update({
isSubmitting: false,
errors: error.message.split(", "),
})
}),
}))
.reactions()
.view({
render: ({ context, html, update }) => html`
<form
@submit=${(e) => {
e.preventDefault()
update({ isSubmitting: true })
}}>
<div>
<label>Имя:</label>
<input .value=${context.name} @input=${(e) => update({ name: e.target.value })} placeholder="Введите имя" />
</div>
<div>
<label>Email:</label>
<input
.value=${context.email}
@input=${(e) => update({ email: e.target.value })}
placeholder="Введите email"
type="email" />
</div>
<div>
<label>Возраст:</label>
<input
.value=${context.age}
@input=${(e) => update({ age: parseInt(e.target.value) || 0 })}
type="number"
min="0" />
</div>
<button type="submit" ?disabled=${context.isSubmitting}>
${context.isSubmitting ? "Отправка..." : "Отправить"}
</button>
${context.errors.length > 0
? html` <div class="errors">${context.errors.map((error) => html`<div class="error">${error}</div>`)}</div> `
: null}
</form>
`,
style: ({ css }) => css`
form {
max-width: 400px;
margin: 0 auto;
padding: 20px;
}
div {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
width: 100%;
padding: 10px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.errors {
margin-top: 15px;
}
.error {
color: red;
margin-bottom: 5px;
}
`,
})
// Родительский компонент с динамическим обновлением
MetaFor("parent-dashboard")
.context((types) => ({
userMessage: types.string.required("Привет от родителя"),
userCount: types.number.required(0),
isLoading: types.boolean.required(false),
}))
.states({
idle: { loading: {} },
loading: { idle: {} },
})
.core()
.processes((process) => ({
loading: process()
.action(async ({ context }) => {
await new Promise((resolve) => setTimeout(resolve, 1000))
return {
userMessage: "Обновленное сообщение от родителя",
userCount: context.userCount + 1,
}
})
.success(({ update, data }) => {
update({
userMessage: data.userMessage,
userCount: data.userCount,
isLoading: false,
})
}),
}))
.reactions()
.view({
render: ({ context, html, update }) => html`
<div class="dashboard">
<h1>Родительский компонент</h1>
<p>Сообщение: ${context.userMessage}</p>
<p>Счетчик: ${context.userCount}</p>
<button @click=${() => update({ isLoading: true })} ?disabled=${context.isLoading}>
${context.isLoading ? "Обновление..." : "Обновить данные"}
</button>
<metafor-child-widget
context=${{
message: context.userMessage,
count: context.userCount,
}}></metafor-child-widget>
</div>
`,
style: ({ css }) => css`
.dashboard {
padding: 20px;
border: 2px solid #007bff;
border-radius: 8px;
margin: 20px;
}
button {
padding: 10px 20px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
margin: 10px 0;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
`,
})
// Дочерний компонент, получающий контекст
MetaFor("child-widget")
.context((types) => ({
message: types.string.required("Сообщение по умолчанию"),
count: types.number.required(0),
}))
.states({ idle: {} })
.core()
.processes()
.reactions()
.view({
render: ({ context, html }) => html`
<div class="widget">
<h3>Дочерний виджет</h3>
<p>Полученное сообщение: ${context.message}</p>
<p>Полученный счетчик: ${context.count}</p>
<div class="status">Статус: ${context.count > 0 ? "Активен" : "Неактивен"}</div>
</div>
`,
style: ({ css }) => css`
.widget {
padding: 15px;
border: 1px solid #28a745;
border-radius: 6px;
margin-top: 15px;
background: #f8f9fa;
}
.status {
margin-top: 10px;
padding: 5px 10px;
background: #28a745;
color: white;
border-radius: 4px;
text-align: center;
}
`,
})
MetaFor предоставляет встроенные инструменты отладки:
// Включение отладки
import { enableMetaForDebug } from "@zavx0z/metafor/debug/config"
enableMetaForDebug()
// Получение снапшота состояния
const element = document.querySelector("metafor-my-component")
const snapshot = element.getSnapshot()
console.log(snapshot)