TypeScript - это типизированное расширение JavaScript. Он стал популярным в последнее время благодаря преимуществам, которые он может принести. Если вы новичок в TypeScript, рекомендуется сначала ознакомиться с ним, прежде чем продолжить. Вы можете ознакомиться с документацей здесь.
Какие преимущества Typescript может принести вашему приложению:
- Безопасность типов для состояний, сторов и событий
- Простой рефакторинг типизированного кода
- Превосходный опыт разработчика в командной среде
Практический пример
Мы пройдемся по упрощенному приложению чата, чтобы продемонстрировать возможный подход к включению статической типизации. Это приложение для чата будет иметь API-модель, которая загружает и сохраняет данные из локального хранилища localStorage.
Полный исходный код можно посмотреть на github. Обратите внимание, что, следуя этому примеру самостоятельно, вы ощутите пользу от использования TypeScript.
Давайте создадим API-модель
Здесь будет использоваться структура каталогов на основе методологии feature-sliced.
Давайте определим простой тип, который наша импровизированная API будет возвращать.
interface Author { id: string; name: string;}
export interface Message { id: string; author: Author; text: string; timestamp: number;}
Наша API будет загружать и сохранять данные в localStorage
, и нам нужны некоторые функции для загрузки данных:
const LocalStorageKey = "effector-example-history";
function loadHistory(): Message[] | void { const source = localStorage.getItem(LocalStorageKey); if (source) { return JSON.parse(source); } return undefined;}function saveHistory(messages: Message[]) { localStorage.setItem(LocalStorageKey, JSON.stringify(messages));}
Также нам надо создать несколько библиотек для генерации идентификатров и ожидания для имитации сетевых запросов.
export const createOid = () => ((new Date().getTime() / 1000) | 0).toString(16) + "xxxxxxxxxxxxxxxx".replace(/[x]/g, () => ((Math.random() * 16) | 0).toString(16)).toLowerCase();
export function wait(timeout = Math.random() * 1500) { return new Promise((resolve) => setTimeout(resolve, timeout));}
Отлично! Теперь мы можем создать эффекты, которые будут загружать сообщения.
// Здесь эффект определен со статическими типами. Void определяет отсутствие аргументов.// Второй аргумент в типе определяет тип успешного результата.// Третий аргумент является необязательным и определяет тип неудачного результата.export const messagesLoadFx = createEffect<void, Message[], Error>(async () => { const history = loadHistory(); await wait(); return history ?? [];});
interface SendMessage { text: string; author: Author;}
// Но мы можем использовать вывод типов и задавать типы аргументов в определении обработчика.// Наведите курсор на `messagesLoadFx`, чтобы увидеть выведенные типы:// `Effect<{ text: string; authorId: string; authorName: string }, void, Error>`export const messageSendFx = createEffect(async ({ text, author }: SendMessage) => { const message: Message = { id: createOid(), author, timestamp: Date.now(), text, }; const history = await messagesLoadFx(); saveHistory([...history, message]); await wait();});
// Пожалуйста, обратите внимание, что мы будем использовать `wait()` для `messagesLoadFx` и `wait()` в текущем эффекте// Также, обратите внимание, что `saveHistory` и `loadHistory` могут выбрасывать исключения,// в этом случае эффект вызовет событие `messageDeleteFx.fail`.export const messageDeleteFx = createEffect(async (message: Message) => { const history = await messagesLoadFx(); const updated = history.filter((found) => found.id !== message.id); await wait(); saveHistory(updated);});
Отлично, теперь мы закончили с сообщениями, давайте создадим эффекты для управления сессией пользователя.
На самом деле я предпочитаю начинать написание кода с реализации интерфейсов:
// Это называется сессией, потому что описывает текущую сессию пользователя, а не Пользователя в целом.export interface Session { id: string; name: string;}
Кроме того, чтобы генерировать уникальные имена пользователей и не требовать от них ввода вручную, импортируйте unique-names-generator
:
import { uniqueNamesGenerator, Config, starWars } from "unique-names-generator";
const nameGenerator: Config = { dictionaries: [starWars] };const createName = () => uniqueNamesGenerator(nameGenerator);
Создадим эффекты для управления сессией:
const LocalStorageKey = "effector-example-session";
// Обратите внимание, что в этом случае требуется явное определение типов, поскольку `JSON.parse()` возвращает `any`export const sessionLoadFx = createEffect<void, Session | null>(async () => { const source = localStorage.getItem(LocalStorageKey); await wait(); if (!source) { return null; } return JSON.parse(source);});
// По умолчанияю, если нет аргументов, не предоставлены явные аргументы типа и нет оператора `return`,// эффект будет иметь тип: `Effect<void, void, Error>`export const sessionDeleteFx = createEffect(async () => { localStorage.removeItem(LocalStorageKey); await wait();});
// Взгляните на тип переменной `sessionCreateFx`.// Там будет `Effect<void, Session, Error>` потому что TypeScript может вывести тип из переменной `session`export const sessionCreateFx = createEffect(async () => { // Я явно установил тип для следующей переменной, это позволит TypeScript помочь мне // Если я забуду установить свойство, то я увижу ошибку в месте определения // Это также позволяет IDE автоматически дополнять и завершать имена свойств const session: Session = { id: createOid(), name: createName(), }; localStorage.setItem(LocalStorageKey, JSON.stringify(session)); return session;});
Как нам нужно импортировать эти эффекты?
Я настоятельно рекомендую писать короткие импорты и использовать реэкспорты.
Это позволяет безопасно рефакторить структуру кода внутри shared/api
и тех же слайсов,
и не беспокоиться о рефакторинге других импортов и ненужных изменениях в истории git.
export * as messageApi from "./message";export * as sessionApi from "./session";
// Types reexports made just for convenienceexport type { Message } from "./message";export type { Session } from "./session";
Создадим страницу с логикой
Типичная структура страниц:
src/ pages/ <page-name>/ page.tsx — только View-слой (представление) model.ts — код бизнес-логики (модель) index.ts — реэкспорт, иногда здесь может быть связующий код
Я рекомендую писать код в слое представления сверху вниз, более общий код - сверху. Моделируем наш слой представления. На странице у нас будет два основных раздела: история сообщений и форма сообщения.
export function ChatPage() { return ( <div className="parent"> <ChatHistory /> <MessageForm /> </div> );}
function ChatHistory() { return ( <div className="chat-history"> <div>Тут будет список сообщений</div> </div> );}
function MessageForm() { return ( <div className="message-form"> <div>Тут будет форма сообщения</div> </div> );}
Отлично. Теперь мы знаем, какую структуру мы имеем, и мы можем начать моделировать процессы бизнес-логики. Слой представления должен выполнять две задачи: отображать данные из хранилищ и сообщать события модели. Слой представления не знает, как загружаются данные, как их следует преобразовывать и отправлять обратно.
import { createEvent, createStore } from "effector";
// События просто сообщают о том, что что-то произошлоexport const messageDeleteClicked = createEvent<Message>();export const messageSendClicked = createEvent();export const messageEnterPressed = createEvent();export const messageTextChanged = createEvent<string>();export const loginClicked = createEvent();export const logoutClicked = createEvent();
// В данный момент есть только сырые данные без каких-либо знаний о том, как их загрузить.export const $loggedIn = createStore<boolean>(false);export const $userName = createStore("");export const $messages = createStore<Message[]>([]);export const $messageText = createStore("");
// Страница НЕ должна знать, откуда пришли данные.// Поэтому мы просто реэкспортируем их.// Мы можем переписать этот код с использованием `combine` или оставить независимые хранилища,// страница НЕ должна меняться, просто потому что мы изменили реализациюexport const $messageDeleting = messageApi.messageDeleteFx.pending;export const $messageSending = messageApi.messageSendFx.pending;
Теперь мы можем реализовать компоненты.
import { useList, useUnit } from "effector-react";import * as model from "./model";
// export function ChatPage { ... }
function ChatHistory() { const [messageDeleting, onMessageDelete] = useUnit([ model.$messageDeleting, model.messageDeleteClicked, ]);
// Хук `useList` позволяет React не перерендерить сообщения, которые действительно не изменились. const messages = useList(model.$messages, (message) => ( <div className="message-item" key={message.timestamp}> <h3>From: {message.author.name}</h3> <p>{message.text}</p> <button onClick={() => onMessageDelete(message)} disabled={messageDeleting}> {messageDeleting ? "Deleting" : "Delete"} </button> </div> )); // Здесь не нужен `useCallback` потому что мы передаем функцию в HTML-элемент, а не в кастомный компонент
return <div className="chat-history">{messages}</div>;}
Я разделил MessageForm
на разные компоненты, чтобы упростить код:
function MessageForm() { const isLogged = useUnit(model.$loggedIn); return isLogged ? <SendMessage /> : <LoginForm />;}
function SendMessage() { const [userName, messageText, messageSending] = useUnit([ model.$userName, model.$messageText, model.$messageSending, ]);
const [handleLogout, handleTextChange, handleEnterPress, handleSendClick] = useUnit([ model.logoutClicked, model.messageTextChanged, model.messageEnterPressed, model.messageSendClicked, ]);
const handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => { if (event.key === "Enter") { handleEnterPress(); } };
return ( <div className="message-form"> <h3>{userName}</h3> <input value={messageText} onChange={(event) => handleTextChange(event.target.value)} onKeyPress={handleKeyPress} className="chat-input" placeholder="Type a message..." /> <button onClick={() => handleSendClick()} disabled={messageSending}> {messageSending ? "Sending..." : "Send"} </button> <button onClick={() => handleLogout()}>Log out</button> </div> );}
function LoginForm() { const handleLogin = useUnit(model.loginClicked);
return ( <div className="message-form"> <div>Please, log in to be able to send messages</div> <button onClick={() => handleLogin()}>Login as a random user</button> </div> );}
Управляем сессией пользователя как Про
Создадим сущность сессии. Сущность (entity) - это бизнес-юнит.
import { Session } from "shared/api";import { createStore } from "effector";
// Сущность просто хранит сессию и некоторую внутреннюю информацию о нейexport const $session = createStore<Session | null>(null);// Когда стор `$session` обновляется, то стор `$isLogged` тоже будет обновлен// Они синхронизированы. Производный стор зависит от данных из исходногоexport const $isLogged = $session.map((session) => session !== null);
Теперь мы можем реализовать функции входа в систему или выхода на странице. Почему не здесь?
Если мы разместим логику входа здесь, у нас будет очень неявная ситуация,
когда вы вызываете sessionCreateFx
вы не увидите код, который вызывается после эффекта.
Но последствия будут видны в DevTools и поведении приложения.
Попробуйте написать код таким очевидным способом в одном файле, чтобы вы и любой член команды могли отследить последовательность выполнения.
Реализуем логику
Отлично. Теперь мы можем загрузить сеанс пользователя и список сообщений на странице. Но у нас нет никакого события, когда мы можем начать это делать. Давайте исправим это.
Вы можете использовать Gate, но я предпочитаю использовать явные события.
// Просто добавьте новое событиеexport const pageMounted = createEvent();
Просто добавте useEffect
и вызовите связанное событие внутри.
export function ChatPage() { const handlePageMount = useUnit(model.pageMounted);
React.useEffect(() => { handlePageMount(); }, [handlePageMount]);
return ( <div className="parent"> <ChatHistory /> <MessageForm /> </div> );}
Примечание: если вы не планируете писать тесты для кода эффектора и/или реализовывать SSR, вы можете опустить любое использование
useEvent
.
В данный момент мы можем загрузить сеанс и список сообщений.
Просто добавьте реакцию на событие, и любой другой код должен быть написан в хронологическом порядке после каждого события:
// Не забудьте про import { sample } from "effector"import { Message, messageApi, sessionApi } from "shared/api";import { $session } from "entities/session";
// export stores// export events
// Здесь место для логики
// Вы можете прочитать этот код так:// При загрузке страницы, одновременно вызываются загрузка сообщений и сессия пользователяsample({ clock: pageMounted, target: [messageApi.messagesLoadFx, sessionApi.sessionLoadFx],});
После этого нужно определить реакции на messagesLoadFx.done
и messagesLoadFx.fail
, а также то же самое для sessionLoadFx
.
// `.doneData` это сокращение для `.done`, поскольку `.done` returns `{ params, result }`// Постарайтесь не называть свои аргументы как `state` или `payload`// Используйте явные имена для содержимого$messages.on(messageApi.messagesLoadFx.doneData, (_, messages) => messages);
$session.on(sessionApi.sessionLoadFx.doneData, (_, session) => session);
Отлично. Сессия и сообщения получены. Давайте позволим пользователям войти.
// Когда пользователь нажимает кнопку входа, нам нужно создать новую сессиюsample({ clock: loginClicked, target: sessionApi.sessionCreateFx,});// Когда сессия создана, просто положите его в хранилище сессийsample({ clock: sessionApi.sessionCreateFx.doneData, target: $session,});// Если создание сессии не удалось, просто сбросьте сессиюsample({ clock: sessionApi.sessionCreateFx.fail, fn: () => null, target: $session,});
Давайте реализуем процесс выхода:
// Когда пользователь нажал на кнопку выхода, нам нужно сбросить сессию и очистить наше хранилищеsample({ clock: logoutClicked, target: sessionApi.sessionDeleteFx,});// В любом случае, успешно или нет, нам нужно сбросить хранилище сессийsample({ clock: sessionApi.sessionDeleteFx.finally, fn: () => null, target: $session,});
Примечание: большинство комментариев написано только для образовательных целей. В реальной жизни код приложения будет самодокументируемым
Но если мы запустим dev-сервер и попытаемся войти в систему, то мы ничего не увидим.
Это связано с тем, что мы создали стор $loggedIn
в модели, но не изменяем его. Давайте исправим:
import { $isLogged, $session } from "entities/session";
// В данный момент есть только сырые данные без каких-либо знаний о том, как их загрузитьexport const $loggedIn = $isLogged;export const $userName = $session.map((session) => session?.name ?? "");
Здесь мы просто реэкспортировали наш собственный стор из сущности сессии, но слой представления не меняется.
Такая же ситуация и со стором $userName
. Просто перезагрузите страницу, и вы увидите, что сессия загружена правильно.
Отправка сообщений
Теперь мы можем войти в систему и выйти из нее. Думаю, что вы захотите отправить сообщение. Это довольно просто:
$messageText.on(messageTextChanged, (_, text) => text);
// У нас есть два разных события для отправки сообщения// Пусть событие `messageSend` реагирует на любое из нихconst messageSend = merge([messageEnterPressed, messageSendClicked]);
// Нам нужно взять текст сообщения и информацию об авторе, а затем отправить ее в эффектsample({ clock: messageSend, source: { author: $session, text: $messageText }, target: messageApi.messageSendFx,});
Но если в файле tsconfig.json
вы установите "strictNullChecks": true
, вы получите ошибку.
Это связано с тем, что стор $session
содержит Session | null
, а messageSendFx
хочет Author
в аргументах.
Author
и Session
совместимы, но не должны быть null
.
Чтобы исправить странное поведение, нам нужно использовать filter
:
sample({ clock: messageSend, source: { author: $session, text: $messageText }, filter: (form): form is { author: Session; text: string } => { return form.author !== null; }, target: messageApi.messageSendFx,});
Я хочу обратить ваше внимание на тип возвращаемого значения form is {author: Session; text: string}
.
Эта функция называется type guard
и позволяет TypeScript сузить тип Session | null
до более конкретного Session
через условие внутри функции.
Теперь мы можем прочитать это так: когда сообщение должно быть отправлено, возьмите сессию и текст сообщения, проверьте, существует ли сессия, и отправьте его.
Отлично. Теперь мы можем отправить новое сообщение на сервер.
Но если мы не вызовем messagesLoadFx
снова, мы не увидим никаких изменений,
потому что стор $messages
не обновился. Мы можем написать универсальный код для этого случая.
Самый простой способ - вернуть отправленное сообщение из эффекта.
export const messageSendFx = createEffect(async ({ text, author }: SendMessage) => { const message: Message = { id: createOid(), author, timestamp: Date.now(), text, }; const history = await messagesLoadFx(); await wait(); saveHistory([...history, message]); return message;});
Теперь мы можем просто добавить сообщение в конец списка:
$messages.on(messageApi.messageSendFx.doneData, (messages, newMessage) => [ ...messages, newMessage,]);
Но в данный момент отправленное сообщение все еще остается в поле ввода.
$messageText.on(messageSendFx, () => "");
// Если отправка сообщения не удалась, просто восстановите сообщениеsample({ clock: messageSendFx.fail, fn: ({ params }) => params.text, target: $messageText,});
Удаление сообщения
Это довольно просто.
sample({ clock: messageDeleteClicked, target: messageApi.messageDeleteFx,});
$messages.on(messageApi.messageDeleteFx.done, (messages, { params: toDelete }) => messages.filter((message) => message.id !== toDelete.id),);
Но вы можете заметить ошибку, когда состояние “Deleting” не отклчено.
Это связано с тем, что useList
кэширует рендеры, и не знает о зависимости от состояния messageDeleting
.
Чтобы исправить это, нам нужно предоставить keys
:
const messages = useList(model.$messages, { keys: [messageDeleting], fn: (message) => ( <div className="message-item" key={message.timestamp}> <h3>From: {message.author.name}</h3> <p>{message.text}</p> <button onClick={() => handleMessageDelete(message)} disabled={messageDeleting}> {messageDeleting ? "Deleting" : "Delete"} </button> </div> ),});
Заключение
Это простой пример приложения на эффекторе с использованием React и TypeScript.
Вы можете склонировать себе репозиторий effector/examples/react-and-ts и запустить пример самостоятельно на собственном компьютере.
Документация на английском языке - самая актуальная, поскольку её пишет и обновляет команда effector. Перевод документации на другие языки осуществляется сообществом по мере наличия сил и желания.
Помните, что переведенные статьи могут быть неактуальными, поэтому для получения наиболее точной и актуальной информации рекомендуем использовать оригинальную англоязычную версию документации.