59. Valtio Proxies и TypeScript
TypeScript: Valtio Proxies и TypeScript
Заголовок раздела «TypeScript: Valtio Proxies и TypeScript»💡 Введение: Магия Прокси для Реактивного Состояния
Заголовок раздела «💡 Введение: Магия Прокси для Реактивного Состояния»Привет, кодер! Сегодня мы погрузимся в мир Valtio – это как ниндзя-библиотека для управления состоянием, которая использует силу нативных JavaScript Proxies. Забудьте о скучных бойлерплейтах и сложных редюсерах. Valtio делает реактивное состояние таким же простым, как работа с обычным JavaScript-объектом.
Представь, что твой объект состояния — это священная книга с ценными знаниями. Valtio создает волшебное зеркало (это и есть Proxy) этой книги. Когда ты меняешь что-то в оригинальной книге (твоем состоянии), зеркало немедленно показывает обновленное отражение. И все, кто смотрит в это зеркало (например, твои React-компоненты), мгновенно видят эти изменения и реагируют на них.
Зачем нам TypeScript в этой магии? Потому что даже самая мощная магия должна быть предсказуемой и безопасной. TypeScript — это твой верный щит и меч. Он гарантирует, что ты знаешь, какие главы есть в твоей книге, какой тип информации они содержат, и какие заклинания (действия) ты можешь над ними выполнять. Он не позволит тебе случайно поменять тип “счетчика” на “строку” или добавить свойство, которого нет в плане.
Давай же посмотрим, как эта магия работает на практике!
🧠 Базовая Теория: Создаем Прокси-Состояние
Заголовок раздела «🧠 Базовая Теория: Создаем Прокси-Состояние»В сердце Valtio лежит функция proxy(). Она берет обычный JavaScript-объект и возвращает его реактивную Proxy-версию. Когда ты изменяешь свойство этого прокси-объекта, Valtio уведомляет всех “подписчиков” (например, компоненты, использующие useSnapshot), что состояние изменилось, и они могут обновиться.
import { proxy } from 'valtio';
// Определяем интерфейс для нашего состояния.// Это хорошая практика для ясности и строгого контроля типов.interface CounterState { count: number; message: string; history: number[]; // Добавим что-то посложнее}
// Создаем прокси-объект. TypeScript автоматически выведет тип// на основе начального объекта, но явное указание через <CounterState>// делает код более читаемым и безопасным.const counterState = proxy<CounterState>({ count: 0, message: 'Привет, Valtio!', history: [0],});
console.log('Начальное состояние:', counterState.count, counterState.message); // 0 Привет, Valtio!
// Изменяем состояние напрямую - это ключевая особенность Valtio!// TypeScript здесь гарантирует, что мы меняем существующие свойства// и только на значения правильных типов.counterState.count++;counterState.message = 'Счетчик обновился!';counterState.history.push(counterState.count); // Добавляем текущее значение в историю
console.log('Обновленное состояние:', counterState.count, counterState.message); // 1 Счетчик обновился!console.log('История:', counterState.history); // [0, 1]
// Заметь: здесь нет вызовов `setState` или сложных редюсеров.// Просто прямое изменение объекта, как если бы это был обычный JS-объект.🚀 Практические Примеры: От Простого к Сложному
Заголовок раздела «🚀 Практические Примеры: От Простого к Сложному»Теперь, когда мы поняли основы, давайте углубимся в более практические сценарии.
### 1. Управление списком задач с интерфейсом
Заголовок раздела «### 1. Управление списком задач с интерфейсом»Покажем, как Valtio с TypeScript прекрасно справляется с массивами и вложенными объектами.
import { proxy } from 'valtio';
interface Todo { id: string; text: string; completed: boolean; dueDate?: string; // Опциональное поле}
interface TodoState { todos: Todo[]; filter: 'all' | 'active' | 'completed';}
const todoState = proxy<TodoState>({ todos: [], filter: 'all',});
// Функции-действия для изменения состоянияconst addTodo = (text: string, dueDate?: string) => { todoState.todos.push({ id: `todo-${todoState.todos.length + 1}-${Date.now()}`, // Уникальный ID text, completed: false, dueDate, });};
const toggleTodo = (id: string) => { const todo = todoState.todos.find(t => t.id === id); if (todo) { todo.completed = !todo.completed; // TypeScript знает, что todo.completed - boolean }};
const setFilter = (newFilter: TodoState['filter']) => { todoState.filter = newFilter; // TypeScript проверит, что newFilter - валидный тип};
// Добавляем задачиaddTodo('Изучить Valtio', '2023-12-31');addTodo('Написать классное приложение на TypeScript', '2024-01-15');addTodo('Поделиться знаниями с друзьями');
// Изменяем задачиtoggleTodo(todoState.todos[0].id); // Переключаем первую задачуsetFilter('active');
console.log('Все задачи:', JSON.stringify(todoState.todos, null, 2));console.log('Текущий фильтр:', todoState.filter); // activeconsole.log('Выполнена ли первая задача:', todoState.todos[0].completed); // true### 2. Использование useSnapshot для React-компонентов
Заголовок раздела «### 2. Использование useSnapshot для React-компонентов»Это критически важно для работы с Valtio в React. useSnapshot – это хук, который делает две вещи:
- Возвращает иммутабельный снимок текущего состояния прокси-объекта.
- Подписывает компонент на изменения этого прокси, вызывая его перерисовку при изменении.
Важно: Вы не должны мутировать снимок! Изменения всегда должны происходить через оригинальный прокси-объект. TypeScript поможет вам с этим, сделав тип снимка Readonly<T>.
import { proxy, useSnapshot } from 'valtio';// В реальном проекте useSnapshot импортируется из 'valtio/react' или 'valtio/utils'// Для примера просто представим, что useSnapshot доступен и работает как хук.
interface UserProfile { name: string; email: string; age: number; address: { street: string; city: string; zipCode: string; };}
const userProfileState = proxy<UserProfile>({ name: 'Яша Программист', age: 30, address: { street: 'Улица Кода, 101', city: 'TypeScriptville', zipCode: 'TS1 0TS', },});
// Действия для изменения профиляconst userProfileActions = { updateName: (newName: string) => { userProfileState.name = newName; }, updateEmail: (newEmail: string) => { userProfileState.email = newEmail; }, updateAddress: (newStreet: string, newCity: string, newZipCode: string) => { // TypeScript отлично работает с вложенными объектами userProfileState.address.street = newStreet; userProfileState.address.city = newCity; userProfileState.address.zipCode = newZipCode; },};
// --- Представьте, что это внутри React компонента ---// const UserDisplay = () => {// // useSnapshot возвращает Readonly<UserProfile>, предотвращая прямые мутации// const snapshot = useSnapshot(userProfileState);
// // snapshot.name = 'Новое Имя'; // ОШИБКА КОМПИЛЯЦИИ! Снимок Readonly.
// return (// <div>// <h3>Профиль пользователя: {snapshot.name}</h3>// <p>Email: {snapshot.email}</p>// <p>Возраст: {snapshot.age}</p>// <p>Адрес: {snapshot.address.street}, {snapshot.address.city}, {snapshot.address.zipCode}</p>// <button onClick={() => userProfileActions.updateName('Яков Цукерман')}>// Обновить имя// </button>// <button onClick={() => userProfileActions.updateAddress('Проспект Асинхронности, 42', 'JS-Сити', 'JS2 0JS')}>// Обновить адрес// </button>// </div>// );// };// console.log(<UserDisplay />); // Для демонстрации, в React это был бы рендерconsole.log('Имя до:', userProfileState.name); // Яша ПрограммистuserProfileActions.updateName('Яков Цукерман');console.log('Имя после:', userProfileState.name); // Яков Цукерман### 3. Вычисляемые свойства (Computed Properties)
Заголовок раздела «### 3. Вычисляемые свойства (Computed Properties)»Valtio позволяет использовать геттеры в объекте состояния. Они будут реактивными, и TypeScript будет корректно выводить их тип. Это отличный способ для создания производного состояния.
import { proxy } from 'valtio';
interface Product { id: string; name: string; price: number;}
interface CartItem extends Product { quantity: number;}
interface ShopState { products: Product[]; cart: CartItem[]; readonly totalItemsInCart: number; // Вычисляемое свойство readonly cartTotalPrice: number; // Вычисляемое свойство readonly hasItemsInCart: boolean; // Вычисляемое свойство}
const shopState = proxy<ShopState>({ products: [ { id: 'p1', name: 'Книга "TypeScript Deep Dive"', price: 49.99 }, { id: 'p2', name: 'Кружка "Я люблю TS"', price: 15.00 }, { id: 'p3', name: 'Футболка "Code is Art"', price: 29.99 }, ], cart: [], // Геттеры для вычисляемых свойств. // Valtio автоматически отслеживает зависимости (this.cart) get totalItemsInCart() { return this.cart.reduce((sum, item) => sum + item.quantity, 0); }, get cartTotalPrice() { return parseFloat(this.cart.reduce((sum, item) => sum + item.price * item.quantity, 0).toFixed(2)); }, get hasItemsInCart() { return this.cart.length > 0; }});
const shopActions = { addProductToCart: (productId: string) => { const product = shopState.products.find(p => p.id === productId); if (!product) return;
const existingCartItem = shopState.cart.find(item => item.id === productId); if (existingCartItem) { existingCartItem.quantity++; } else { shopState.cart.push({ ...product, quantity: 1 }); } }, removeProductFromCart: (productId: string) => { shopState.cart = shopState.cart.filter(item => item.id !== productId); }, clearCart: () => { shopState.cart = []; },};
shopActions.addProductToCart('p1');shopActions.addProductToCart('p2');shopActions.addProductToCart('p1'); // Добавляем еще одну книгу
console.log('Корзина:', JSON.stringify(shopState.cart, null, 2));console.log('Всего товаров в корзине:', shopState.totalItemsInCart); // 3 (1+1+1)console.log('Общая стоимость корзины:', shopState.cartTotalPrice); // (49.99 * 2) + (15.00 * 1) = 114.98console.log('Есть ли товары в корзине?', shopState.hasItemsInCart); // true
shopActions.clearCart();console.log('После очистки - всего товаров:', shopState.totalItemsInCart); // 0console.log('После очистки - общая стоимость:', shopState.cartTotalPrice); // 0console.log('После очистки - есть ли товары?', shopState.hasItemsInCart); // false🎓 Продвинутые Техники: Разделение Состояния
Заголовок раздела «🎓 Продвинутые Техники: Разделение Состояния»Для больших приложений удобно разделять состояние на логические “модули” или “слайсы”. Каждый такой модуль может быть своим собственным прокси-объектом, что улучшает организацию и типизацию.
import { proxy } from 'valtio';
// --- Модуль Аутентификации ---interface AuthState { isAuthenticated: boolean; user: { id: string; username: string; email: string } | null; token: string | null;}
const authState = proxy<AuthState>({ isAuthenticated: false, user: null, token: null,});
export const authActions = { login: (userData: Omit<AuthState['user'], 'id'>, token: string) => { authState.isAuthenticated = true; // В реальном приложении ID может прийти с сервера authState.user = { id: 'usr-123', ...userData }; authState.token = token; }, logout: () => { authState.isAuthenticated = false; authState.user = null; authState.token = null; },};
// --- Модуль Настроек Пользователя ---interface UserSettingsState { theme: 'light' | 'dark'; notificationsEnabled: boolean; language: 'en' | 'ru' | 'es';}
const userSettingsState = proxy<UserSettingsState>({ theme: 'light', notificationsEnabled: true, language: 'en',});
export const userSettingsActions = { toggleTheme: () => { userSettingsState.theme = userSettingsState.theme === 'light' ? 'dark' : 'light'; }, setLanguage: (lang: UserSettingsState['language']) => { userSettingsState.language = lang; },};
// --- Использование модулей ---console.log('До логина:', authState.isAuthenticated); // falseconsole.log('После логина:', authState.isAuthenticated, authState.user?.username); // true yasha_dev
console.log('Тема по умолчанию:', userSettingsState.theme); // lightuserSettingsActions.toggleTheme();console.log('Тема после переключения:', userSettingsState.theme); // darkuserSettingsActions.setLanguage('ru');console.log('Язык:', userSettingsState.language); // ru
authActions.logout();console.log('После логаута:', authState.isAuthenticated); // false🐞 Типичные Ошибки и Как Их Избежать
Заголовок раздела «🐞 Типичные Ошибки и Как Их Избежать»- Забыли
useSnapshotв React-компонентах:- Ошибка: Ваш компонент не перерисовывается при изменении состояния прокси.
- Решение: Всегда используйте
const snapshot = useSnapshot(myProxyState);внутри функциональных компонентов React, чтобы получить реактивный снимок и подписаться на обновления. - Как помогает TypeScript:
useSnapshotвозвращаетReadonly<T>, что является важным сигналом об иммутабельности.
- Попытка мутировать снимок (snapshot):
- Ошибка:
snapshot.prop = 'new value';приведет к ошибке компиляции в TypeScript (в строгом режиме) или runtime ошибке, потому что снимок иммутабелен. - Решение: Изменяйте только оригинальный прокси-объект, например, через специально созданные действия:
myProxyState.prop = 'new value';илиmyActions.updateProp('new value');.
- Ошибка:
- Недостаточная типизация вложенных объектов:
- Ошибка: При работе с глубоко вложенными структурами TypeScript может иногда “терять” точную информацию о типе, если вы не указали интерфейсы для всех уровней.
- Решение: Определяйте четкие интерфейсы или типы для всех частей вашего состояния, даже для вложенных объектов, чтобы обеспечить полную безопасность типов.
🎯 Практика
Заголовок раздела «🎯 Практика»Время применить полученные знания на практике! Создайте MDX-файл с решением каждой задачи.
1. Симулятор Магазина
Заголовок раздела «1. Симулятор Магазина»- Создайте состояние для магазина с двумя прокси:
productsState: массив объектовProduct(сid,name,price). Заполните его несколькими продуктами.cartState: массив объектовCartItem(сidпродукта,name,price,quantity).
- Реализуйте действия:
addProductToCart(productId: string): добавляет продукт в корзину. Если продукт уже есть, увеличивает количество.removeProductFromCart(productId: string): полностью удаляет продукт из корзины.decreaseProductQuantity(productId: string): уменьшает количество продукта на 1. Если количество становится 0, удаляет продукт.clearCart(): очищает корзину.
- В
cartStateдобавьте вычисляемые свойства:totalItemsInCart(общее количество штук всех товаров).cartTotalPrice(общая стоимость всех товаров в корзине, округленная до двух знаков после запятой).
- Обеспечьте полную типизацию всего состояния и действий. Продемонстрируйте работу действий, выводя состояние в консоль.
2. Редактор Профиля Пользователя
Заголовок раздела «2. Редактор Профиля Пользователя»- Создайте состояние
userProfileStateдля хранения информации о пользователе:id: stringusername: stringemail: stringsettings: вложенный объект сtheme: 'light' | 'dark',newsletterSubscription: boolean.bio?: string(опциональное поле).
- Реализуйте действия для обновления:
updateUsername(newUsername: string)updateEmail(newEmail: string)toggleTheme(): переключает тему между ‘light’ и ‘dark’.toggleNewsletterSubscription()updateBio(newBio: string)clearBio(): удаляет полеbioили устанавливает его вundefined.
- При обновлении
themeиспользуйте Union Type. Убедитесь, что все поля правильно типизированы, включая опциональные. Продемонстрируйте работу.
3. Список Дел с Приоритетами
Заголовок раздела «3. Список Дел с Приоритетами»- Создайте состояние
taskListStateс массивом объектовTask. КаждыйTaskдолжен иметь:id: stringdescription: stringcompleted: booleanpriority: 'low' | 'medium' | 'high'createdAt: Date
- Реализуйте действия:
addTask(description: string, priority: Task['priority']): создает новую задачу.toggleTaskCompletion(id: string)removeTask(id: string)setTaskPriority(id: string, newPriority: Task['priority'])
- Добавьте вычисляемое свойство
pendingTasksCount(количество незавершенных задач). - Продемонстрируйте работу всех действий и вычисляемого свойства.
💡 Совет
Заголовок раздела «💡 Совет»- Маленькие и сфокусированные прокси: Вместо одного огромного прокси для всего приложения, создавайте несколько маленьких, логически связанных прокси (как мы сделали с
authStateиuserSettingsState). Это улучшает модульность, облегчает тестирование и делает типизацию более управляемой. - Действия как “фасады”: Всегда инкапсулируйте логику изменения состояния в функциях (действиях). Это делает ваш код более предсказуемым, тестируемым и легким для понимания. Компоненты должны вызывать действия, а не напрямую мутировать прокси-объект.
- Иммутабельность для снимков: Помните, что
useSnapshotвозвращает иммутабельный объект. Любые изменения должны происходить только через оригинальный прокси. TypeScript, благодаряReadonly<T>, активно поможет вам это контролировать. - Явные типы для ясности: Хотя Valtio часто может вывести типы, явное указание интерфейсов для вашего состояния (особенно для сложных вложенных структур) значительно улучшает читаемость и предсказуемость кода, а также помогает TypeScript дать вам более точные подсказки и ошибки.
- Производительность: Для очень больших массивов или объектов, которые часто обновляются, будьте внимательны к тому, какие части вы делаете реактивными. Valtio обычно очень быстр, но понимание, как Proxy отслеживает изменения, всегда полезно.