TypeScript - это типизированное расширение JavaScript. Он стал популярным в последнее время благодаря преимуществам, которые он может принести. Если вы новичок в TypeScript, рекомендуется сначала ознакомиться с ним, прежде чем продолжить. Вы можете ознакомиться с документацей здесь.
Какие преимущества Typescript может принести вашему приложению:
- Безопасность типов для состояний, сторов и событий
- Простой рефакторинг типизированного кода
- Превосходный опыт разработчика в командной среде
Практический пример
Мы пройдемся по упрощенному приложению чата, чтобы продемонстрировать возможный подход к включению статической типизации. Это приложение для чата будет иметь API-модель, которая загружает и сохраняет данные из локального хранилища localStorage.
Полный исходный код можно посмотреть на github. Обратите внимание, что, следуя этому примеру самостоятельно, вы ощутите пользу от использования TypeScript.
Давайте создадим API-модель
Здесь будет использоваться структура каталогов на основе методологии feature-sliced.
Давайте определим простой тип, который наша импровизированная API будет возвращать.
// Файл: /src/shared/api/message.ts
interface Author {
id: string;
name: string;
}
export interface Message {
id: string;
author: Author;
text: string;
timestamp: number;
}
Наша API будет загружать и сохранять данные в localStorage
, и нам нужны некоторые функции для загрузки данных:
// Файл: /src/shared/api/message.ts
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));
}
Также нам надо создать несколько библиотек для генерации идентификатров и ожидания для имитации сетевых запросов.
// Файл: /src/shared/lib/oid.ts
export const createOid = () =>
((new Date().getTime() / 1000) | 0).toString(16) +
"xxxxxxxxxxxxxxxx".replace(/[x]/g, () => ((Math.random() * 16) | 0).toString(16)).toLowerCase();
// Файл: /src/shared/lib/wait.ts
export function wait(timeout = Math.random() * 1500) {
return new Promise((resolve) => setTimeout(resolve, timeout));
}
Отлично! Теперь мы можем создать эффекты, которые будут загружать сообщения.
// Файл: /src/shared/api/message.ts
// Здесь эффект определен со статическими типами. 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);
});
Отлично, теперь мы закончили с сообщениями, давайте создадим эффекты для управления сессией пользователя.
На самом деле я предпочитаю начинать написание кода с реализации интерфейсов:
// Файл: /src/shared/api/session.ts
// Это называется сессией, потому что описывает текущую сессию пользователя, а не Пользователя в целом.
export interface Session {
id: string;
name: string;
}
Кроме того, чтобы генерировать уникальные имена пользователей и не требовать от них ввода вручную, импортируйте unique-names-generator
:
// Файл: /src/shared/api/session.ts
import { uniqueNamesGenerator, Config, starWars } from "unique-names-generator";
const nameGenerator: Config = { dictionaries: [starWars] };
const createName = () => uniqueNamesGenerator(nameGenerator);
Создадим эффекты для управления сессией:
// Файл: /src/shared/api/session.ts
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.
// Файл: /src/shared/api/index.ts
export * as messageApi from "./message";
export * as sessionApi from "./session";
// Types reexports made just for convenience
export type { Message } from "./message";
export type { Session } from "./session";
Создадим страницу с логикой
Типичная структура страниц:
src/
pages/
<page-name>/
page.tsx — только View-слой (представление)
model.ts — код бизнес-логики (модель)
index.ts — реэкспорт, иногда здесь может быть связующий код
Я рекомендую писать код в слое представления сверху вниз, более общий код - сверху. Моделируем наш слой представления. На странице у нас будет два основных раздела: история сообщений и форма сообщения.
// Файл: /src/pages/chat/page.tsx
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>
);
}
Отлично. Теперь мы знаем, какую структуру мы имеем, и мы можем начать моделировать процессы бизнес-логики. Слой представления должен выполнять две задачи: отображать данные из хранилищ и сообщать события модели. Слой представления не знает, как загружаются данные, как их следует преобразовывать и отправлять обратно.
// Файл: /src/pages/chat/model.ts
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;
Теперь мы можем реализовать компоненты.
// Файл: /src/pages/chat/page.tsx
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
на разные компоненты, чтобы упростить код:
// Файл: /src/pages/chat/page.tsx
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) - это бизнес-юнит.
// Файл: /src/entities/session/index.ts
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, но я предпочитаю использовать явные события.
// Файл: /src/pages/chat/model.ts
// Просто добавьте новое событие
export const pageMounted = createEvent();
Просто добавте useEffect
и вызовите связанное событие внутри.
// Файл: /src/pages/chat/page.tsx
export function ChatPage() {
const handlePageMount = useUnit(model.pageMounted);
React.useEffect(() => {
handlePageMount();
}, [handlePageMount]);
return (
<div className="parent">
<ChatHistory />
<MessageForm />
</div>
);
}
Примечание: если вы не планируете писать тесты для кода эффектора и/или реализовывать SSR, вы можете опустить любое использование
useEvent
.
В данный момент мы можем загрузить сеанс и список сообщений.
Просто добавьте реакцию на событие, и любой другой код должен быть написан в хронологическом порядке после каждого события:
// Файл: /src/pages/chat/model.ts
// Не забудьте про 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
.
// Файл: /src/pages/chat/model.ts
// `.doneData` это сокращение для `.done`, поскольку `.done` returns `{ params, result }`
// Постарайтесь не называть свои аргументы как `state` или `payload`
// Используйте явные имена для содержимого
$messages.on(messageApi.messagesLoadFx.doneData, (_, messages) => messages);
$session.on(sessionApi.sessionLoadFx.doneData, (_, session) => session);
Отлично. Сессия и сообщения получены. Давайте позволим пользователям войти.
// Файл: /src/pages/chat/model.ts
// Когда пользователь нажимает кнопку входа, нам нужно создать новую сессию
sample({
clock: loginClicked,
target: sessionApi.sessionCreateFx,
});
// Когда сессия создана, просто положите его в хранилище сессий
sample({
clock: sessionApi.sessionCreateFx.doneData,
target: $session,
});
// Если создание сессии не удалось, просто сбросьте сессию
sample({
clock: sessionApi.sessionCreateFx.fail,
fn: () => null,
target: $session,
});
Давайте реализуем процесс выхода:
// Файл: /src/pages/chat/model.ts
// Когда пользователь нажал на кнопку выхода, нам нужно сбросить сессию и очистить наше хранилище
sample({
clock: logoutClicked,
target: sessionApi.sessionDeleteFx,
});
// В любом случае, успешно или нет, нам нужно сбросить хранилище сессий
sample({
clock: sessionApi.sessionDeleteFx.finally,
fn: () => null,
target: $session,
});
Примечание: большинство комментариев написано только для образовательных целей. В реальной жизни код приложения будет самодокументируемым
Но если мы запустим dev-сервер и попытаемся войти в систему, то мы ничего не увидим.
Это связано с тем, что мы создали стор $loggedIn
в модели, но не изменяем его. Давайте исправим:
// Файл: /src/pages/chat/model.ts
import { $isLogged, $session } from "entities/session";
// В данный момент есть только сырые данные без каких-либо знаний о том, как их загрузить
export const $loggedIn = $isLogged;
export const $userName = $session.map((session) => session?.name ?? "");
Здесь мы просто реэкспортировали наш собственный стор из сущности сессии, но слой представления не меняется.
Такая же ситуация и со стором $userName
. Просто перезагрузите страницу, и вы увидите, что сессия загружена правильно.
Отправка сообщений
Теперь мы можем войти в систему и выйти из нее. Думаю, что вы захотите отправить сообщение. Это довольно просто:
// Файл: /src/pages/chat/model.ts
$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
:
// Файл: /src/pages/chat/model.ts
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
не обновился. Мы можем написать универсальный код для этого случая.
Самый простой способ - вернуть отправленное сообщение из эффекта.
// Файл: /src/shared/api/message.ts
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;
});
Теперь мы можем просто добавить сообщение в конец списка:
// Файл: /src/pages/chat/model.ts
$messages.on(messageApi.messageSendFx.doneData, (messages, newMessage) => [
...messages,
newMessage,
]);
Но в данный момент отправленное сообщение все еще остается в поле ввода.
// Файл: /src/pages/chat/model.ts
$messageText.on(messageSendFx, () => "");
// Если отправка сообщения не удалась, просто восстановите сообщение
sample({
clock: messageSendFx.fail,
fn: ({ params }) => params.text,
target: $messageText,
});
Удаление сообщения
Это довольно просто.
// Файл: /src/pages/chat/model.ts
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
:
// Файл: /src/pages/chat/page.tsx
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. Перевод документации на другие языки осуществляется сообществом по мере наличия сил и желания.
Помните, что переведенные статьи могут быть неактуальными, поэтому для получения наиболее точной и актуальной информации рекомендуем использовать оригинальную англоязычную версию документации.