TL;DR
Я создал многопользовательское, сквозное, авторитетное для сервера чат-приложение в React, не написав ни единой строчки внутреннего кода.

👩💻Интересно как? Давайте начнем!
Я выбираю next.js для простоты, но он может работать с приложением create-react-app или любой другой средой реагирования.
npx create-next-app
Просто нажмите Enter несколько раз, чтобы создать проект.
Мне не нужен модный новый маршрутизатор приложений Next.JS, поэтому я буду использовать папку старых страниц, но не стесняйтесь делать это по-своему.
Шаг 1: Логика приложения
Мы начнем с создания файла редуктора, в котором будет жить логика чата, в src/components/chat/reducer.ts, а позже «подадим» его в хук useReducer, который позволит нам отправлять действия и прослушивать изменения его состояния.
Файл редуктора выглядит так:
// src/components/chat/reducer.ts
type Action<
TType extends string,
TPayload = undefined
> = TPayload extends undefined
? {
type: TType;
}
: {
type: TType;
payload: TPayload;
};
// PART 1: State Type and Initial State Value
export const userSlots = {
pink: true,
red: true,
blue: true,
yellow: true,
green: true,
orange: true,
};
export type UserSlot = keyof typeof userSlots;
export type ChatMsg = {
content: string;
atTimestamp: number;
userSlot: UserSlot;
};
export type ChatState = {
userSlots: {
[slot in UserSlot]: boolean;
};
messages: ChatMsg[];
};
export const initialChatState: ChatState = {
userSlots,
messages: [],
};
// PART 2: Action Types
export type ChatActions =
| Action<
'join',
{
userSlot: UserSlot;
}
>
| Action<
'leave',
{
userSlot: UserSlot;
}
>
| Action<
'submit',
{
userSlot: UserSlot;
content: string;
atTimestamp: number;
}
>;
// PART 3: The Reducer – This is where all the logic happens
export default (state = initialChatState, action: ChatActions): ChatState => {
// User Joins
if (action.type === 'join') {
return {
...state,
userSlots: {
...state.userSlots,
[action.payload.userSlot]: false,
},
};
}
// User Leaves
else if (action.type === 'leave') {
return {
...state,
userSlots: {
...state.userSlots,
[action.payload.userSlot]: true,
},
};
}
// Message gets submitted
else if (action.type === 'submit') {
const nextMsg = action.payload;
return {
...state,
messages: [...state.messages, nextMsg],
};
}
return state;
};
Что именно здесь происходит?
Напоминание о том, как работает React useReducer, можно найти в официальной Документации React.
Логика проста и понятна для этого урока. Каждый пользователь должен выбрать слот (например, цвет) перед входом в окно чата. Мы храним словарь слотов в поле userSlots и помечаем их true или false в зависимости от того, доступны они или нет. Доступный слот — true.
Поле messages хранит историю сообщений в порядке отправки.
На данный момент нам нужно только 3 действия: «присоединиться», «выйти» и «отправить». Первые два просто отвечают за пометку userSlot, а последний добавляет новое сообщение в историю (то есть поле messages).
Между прочим, это чистое функциональное программирование, использующее тот же API, что и Redux или useReducer.
Шаг 2: Пользовательский интерфейс
Для этого урока мы создадим довольно простой пользовательский интерфейс, улучшенный с помощью попутного ветра, но мы не должны тратить время на то, чтобы сосредоточиться на этом, поскольку здесь это не является частью области, поэтому не стесняйтесь просто копировать и вставлять это часть в соответствующем месте согласно «пути» в начале каждого нового файла компонента.
Мы добавим 3 компонента:
1. Компонент адаптации чата
Это простой компонент, который позволяет пользователям выбирать слот (цвет) перед входом в представление ChatBox.
// src/components/chat/ChatOnboarding.tsx
type Props = {
slots: string[];
onSubmit: (slot: string) => void;
};
export const ChatOnboarding: React.FC<Props> = ({ slots, onSubmit }) => {
return (
<div
className="fixed nohidden inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex text-slate-900"
id="my-modal"
>
<div className="relative p-8 bg-white w-full max-w-md m-auto flex-col flex rounded-lg">
<h2 className="text-xl font-bold">Pick a slot</h2>
<div className="flex flex-row justify-between pt-5">
{slots.map((slot) => (
<button
className="text-center group"
key={slot}
onClick={() => onSubmit(slot)}
>
<div
className="rounded-full"
style={{
backgroundColor: slot,
width: 50,
height: 50,
}}
/>
<span className="text-md invisible group-hover:visible">{slot}</span>
</button>
))}
</div>
</div>
</div>
);
};
2. Компонент окна чата
Это компонент, который отображает историю сообщений и отвечает за отправку новых сообщений.
Это довольно долго, но ничего особенного здесь не происходит, кроме красивого отображения html и вызова обработчика onSubmit при нажатии кнопки «Отправить».
Не стесняйтесь прочитать его или просто скопируйте и вставьте прямо сейчас!😉
// src/components/chat/ChatBox.tsx
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChatMsg, UserSlot } from './reducer';
import { useRouter } from 'next/router';
type Props = {
userSlot: UserSlot;
messages: ChatMsg[];
onSubmit: (msg: ChatMsg) => void;
};
export const ChatBox: React.FC<Props> = ({ userSlot, messages, onSubmit }) => {
const router = useRouter();
const [msg, setMsg] = useState<string>();
const submit = useCallback(() => {
if (msg?.length && msg.length > 0) {
onSubmit({
content: msg,
atTimestamp: new Date().getTime(),
userSlot,
});
setMsg('');
}
}, [msg, userSlot, onSubmit]);
const messagesInDescOrder = useMemo(
() => [...messages].sort((a, b) => b.atTimestamp - a.atTimestamp),
[messages]
);
// Invitation Copy logic
const [inviteCopied, setInviteCopied] = useState(false);
useEffect(() => {
if (inviteCopied === true) {
setTimeout(() => {
setInviteCopied(false);
}, 2000);
}
}, [inviteCopied]);
return (
<div className="flex text-slate-900">
<div
style={{
height: 600,
width: 300,
}}
>
<div className="text-right">
Me =
<span
style={{
color: userSlot,
}}
>
{' ' + userSlot}
</span>
</div>
<div
className="bg-slate-100 w-full mb-3 flex rounded-lg"
style={{
height: 'calc(100% - 60px + 1em)',
flexDirection: 'column-reverse',
overflowY: 'scroll',
scrollBehavior: 'smooth',
}}
>
{messagesInDescOrder.map((msg) => (
<div
key={msg.atTimestamp}
className={`p-3 pt-2 pb-2 border-solid border-t border-slate-300 last:border-none ${
msg.userSlot === userSlot && 'text-right'
}`}
>
<div>{msg.content}</div>
<i style={{ fontSize: '.8em', color: msg.userSlot }}>
by "{msg.userSlot}" at{' '}
{new Date(msg.atTimestamp).toLocaleString()}
</i>
</div>
))}
</div>
<textarea
value={msg}
onChange={(e) => setMsg(e.target.value)}
className="p-2 w-full rounded-lg"
style={{
height: '60px',
}}
/>
<div className="flex justify-between">
<button
className="bg-green-300 hover:bg-green-500 text-black font-bold py-2 px-4 rounded-lg"
onClick={() => {
const pathWithoutQuery = router.asPath.slice(
0,
router.asPath.indexOf('?')
);
navigator.clipboard.writeText(
window.location.origin + pathWithoutQuery
);
setInviteCopied(true);
}}
>
{inviteCopied ? 'Copied' : 'Invite Friend'}
</button>
<button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg"
disabled={!!(msg?.length && msg.length === 0)}
onClick={submit}
>
Submit
</button>
</div>
</div>
</div>
);
};
3. Компонент ChatBoxContainer
Это оболочка вокруг компонента ChatBox, которая отвечает за отправку правильного действия в нужное событие.
//path=src/components/chat/ChatBoxContainer.tsx
import { Dispatch, useEffect } from 'react';
import { ChatBox } from './ChatBox';
import { ChatActions, ChatState, UserSlot } from './reducer';
type Props = {
userSlot: UserSlot;
state: ChatState;
dispatch: Dispatch<ChatActions>;
};
export const ChatBoxContainer: React.FC<Props> = ({
dispatch,
state,
userSlot,
}) => {
// The user "joins" and "leaves" on mount and unmount
// for simplicity' sake but of course this can be
// changed based on other UX requirements.
useEffect(() => {
// Join as soon as the component mounts
dispatch({
type: 'join',
payload: {
userSlot,
},
});
return () => {
// Leave as soon as the component umounts
dispatch({
type: 'leave',
payload: {
userSlot,
},
});
};
}, [userSlot]);
return (
<ChatBox
messages={state.messages}
userSlot={userSlot}
onSubmit={(msg) => {
// Submit the message
dispatch({
type: 'submit',
payload: msg,
});
}}
/>
);
};
Интересно, почему этот компонент получает
stateиdispatchвprops, а не использует напрямуюuseReducer? Обязательно дочитайте статью до конца, чтобы узнать об этом.
Шаг 3: подключите пользовательский интерфейс к логике
Это оболочка вокруг компонента ChatBox, которая отвечает за отправку правильного действия в нужное событие.
// src/pages/index.tsx
import reducer, { UserSlot, initialChatState } from '@/components/chat/reducer';
import { ChatBoxContainer } from '@/components/chat/ChatBoxContainer';
import { useReducer } from 'react';
import { ChatOnboarding } from '@/components/chat/ChatOnboarding';
import { useRouter } from 'next/router';
const objectKeys = <O extends object>(o: O) =>
Object.keys(o) as (keyof O)[];
export default function () {
const router = useRouter();
const { slot } = router.query;
const [state, dispatch] = useReducer(reducer, initialChatState);
if (slot) {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24 bg-slate-600">
<ChatBoxContainer
userSlot={slot as UserSlot}
state={state}
dispatch={dispatch}
/>
</main>
);
}
// Filter out the taken User Slots
const availableUserSlots = objectKeys(state.userSlots).reduce(
(accum, nextSlot) =>
state.userSlots[nextSlot] ? [...accum, nextSlot] : accum,
[] as UserSlot[]
);
return (
<ChatOnboarding
slots={availableUserSlots}
onSubmit={(slot) => {
// Redirect to the same page with the selected "slot"
router.push({
pathname: router.asPath,
query: { slot },
});
}}
/>
);
}
Что здесь происходит?
Во-первых, мы ищем данный slot в параметрах запроса URL. Если он есть, просто визуализируйте ChatBoxContainer, в противном случае покажите компонент ChatOnboarding и позвольте пользователю выбрать слот.
Во-вторых, что более важно, мы передаем созданный выше редьюсер в хук useReducer, который дает нам возможность выполнять dispatch действий и использовать возвращенные state.
Если вы запустите свое приложение сейчас (yarn dev) и перейдете к http://localhost:3000, вы должны увидеть диалоговое окно выбора слота для подключения к чату 👇

и после выбора одного из них вы будете перенаправлены в представление ChatBox, где вы можете ввести свое сообщение, и история появится, как показано ниже.

Готово, верно?
Подождите, а как насчет многопользовательской части?
Ах, да! Я почти забыл. 🫢
Обычно это будет самая сложная часть, поскольку она включает в себя несколько важных решений, которые нужно принять, и несколько разных частей головоломки, которые нужно собрать вместе — хранилище данных (redis, postgres и т. д.), сетевая логика. и протоколы (веб-сокеты, остальные, p2p и т. д.), внутреннюю структуру, внутренний код и, конечно же, развертывание сервера и хостинг. Это довольно много, не так ли? 😓
К счастью, мы можем использовать Movex, который обрабатывает все это из коробки, а также управление состоянием во внешнем интерфейсе.

Что такое «Х» в Movex? 😅🧐
Movex — это «контейнер с предсказуемым состоянием*» для многопользовательских приложений.
Авторитетный сервер по своей природе. Нет проблем с сервером по дизайну. Он поставляется с синхронизацией в реальном времени и секретным состоянием по умолчанию.
Самое приятное то, что вам не нужно беспокоиться о серверной части. Действительно! Вы просто пишете код шрифта, используя любой из фреймворков JS/TS или игровых движков, а Movex без проблем позаботится о серверной части.
Подробнее о том, как это сделать, можно узнать на https://www.movex.dev.
Кроме того, Movex — это очень новая библиотека, и я пока единственный разработчик, поэтому я был бы очень признателен, если бы вы поставили ей звезду и дали мне знать, если вы найдете ее полезной, в комментариях ниже! 🙌 https://github.com/movesthatmatter/movex.
Шаг 4: Как добавить Movex в приложение React
yarn add movex movex-react movex-core-util; yarn add --dev movex-service
Добавьте файл movex.config
Это связывает редуктор чата с ресурсом Movex и позволяет ему запускать внутренний код без необходимости делать что-либо дополнительно.
//path=src/movex.config.ts
import chatReducer from './components/chat/reducer';
export default {
resources: {
chat: chatReducer,
},
};
Что здесь происходит?
Мы сообщаем Movex, что у нас есть resource под названием чат, и мы присваиваем ему редьюсер. Затем Movex запустит редьюсер как на стороне шрифта, так и на стороне сервера, и с помощью детерминированного распространения действий он сможет беспрепятственно синхронизировать состояние на всех клиентах. Та Даааа.🥳
Оберните приложение с помощью MovexProvider
Измените файл src/pages/_app.tsx, чтобы он выглядел следующим образом:
// pages/_app.tsx
import movexConfig from '@/movex.config';
import '@/styles/globals.css';
import { MovexProvider } from 'movex-react';
import type { AppProps } from 'next/app';
export default function App({ Component, pageProps }: AppProps) {
return (
<MovexProvider
movexDefinition={movexConfig}
endpointUrl="localhost:3333"
>
<Component {...pageProps} />
</MovexProvider>
);
}
Что здесь происходит?
Мы просто помещаем все приложение в <MoveProvider>, так как оно понадобится нескольким страницам. Это похоже на ReduxProvider, за исключением того, что мы даем ему endpointUrl, который является URL-адресом, по которому Movex запускает внутренний сервер.
Он также использует только что созданный файл "movex.config", чтобы иметь возможность создавать перехватчики для настроенных ресурсов на серверной части.
Добавьте Movex в индексный файл.
Измените индексный файл, чтобы он выглядел следующим образом:
// src/pages/index.tsx
import { useMovexResourceType } from 'movex-react';
import { initialChatState } from '@/components/chat/reducer';
import { toRidAsStr } from 'movex';
import { ChatOnboarding } from '@/components/chat/ChatOnboarding';
import { useRouter } from 'next/router';
import movexConfig from '@/movex.config';
export default function () {
const router = useRouter();
const chatResource = useMovexResourceType(movexConfig, 'chat');
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
{chatResource ? (
<ChatOnboarding
slots={Object.keys(initialChatState.userSlots)}
onSubmit={(slot) => {
chatResource.create(initialChatState).map((item) => {
router.push({
pathname: `/chat/${toRidAsStr(item.rid)}`,
query: { slot },
});
});
}}
/>
) : (
<div>waiting...</div>
)}
</main>
);
}
Что здесь происходит?
Во-первых, идея состоит в том, чтобы разделить UI/UX в предыдущей версии index.tsx на две отдельные страницы.
/pages/indexбудет отвечать за адаптацию чата.- Новый
/pages/chat/[rid]отобразит сам ChatBox и получит идентификатор ресурса чата (rid) и слот пользователя в параметрах запроса URL. Это страница чата.
Во-вторых, мы начинаем использовать хук useMovexResourceType в начале компонента, который дает нам MovexResource Object, а затем мы используем его позже для создания фактического ресурса чата, как только пользователь выбрал слот.
Оттуда мы используем идентификатор ресурса чата (он же rid) для перенаправления на /pages/chat/[rid].
Чат
Именно здесь на самом деле живет UI/UX чата, и он специфичен для идентификатора ресурса чата. Это означает, что каждый новый ресурс будет иметь свое собственное состояние (история сообщений и т. д.), и пользователи смогут получить доступ к нескольким чатам одновременно.
// src/pages/chat/[rid].tsx
import { MovexBoundResource } from 'movex-react';
import { ChatBoxContainer } from '@/components/chat/ChatBoxContainer';
import { useRouter } from 'next/router';
import { isRidOfType } from 'movex';
import { ChatOnboarding } from '@/components/chat/ChatOnboarding';
import { objectKeys } from 'movex-core-util';
import { UserSlot } from '@/components/chat/reducer';
import movexConfig from '@/movex.config';
export default function () {
const router = useRouter();
const { rid, slot } = router.query;
// If the given "rid" query param isn't an actual rid of type "chat"
if (!isRidOfType('chat', rid)) {
return <div>Error - Rid not valid</div>;
}
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24 bg-slate-600">
<MovexBoundResource
movexDefinition={movexConfig}
rid={rid}
render={({ boundResource: { state, dispatch } }) => {
// If there is a given slot just show the ChatBox
// Otherwise allow the User to pick one
if (slot) {
return (
<ChatBoxContainer
userSlot={slot as UserSlot}
state={state}
dispatch={dispatch}
/>
);
}
// Filter out the taken User Slots
const availableUserSlots = objectKeys(state.userSlots).reduce(
(accum, nextSlot) =>
state.userSlots[nextSlot] ? [...accum, nextSlot] : accum,
[] as UserSlot[]
);
return (
<ChatOnboarding
slots={availableUserSlots}
onSubmit={(slot) => {
// Redirect to the same page with the selected userSlot
router.push({
pathname: router.asPath,
query: { slot },
});
}}
/>
);
}}
/>
</main>
);
}
Что делает код?
Помимо проверки параметров запроса и повторного рендеринга компонента ChatOnboarding, если пользовательский слот отсутствует в параметрах запроса URL, мы используем специальный компонент <MovexBoundResource />, который принимает rid и отображает пользовательский интерфейс чата, предоставляя boundResource в параметры функции рендеринга.
Что такое BoundResource? 🤨
Вот что делает магию Movex возможной. Это связующее звено между пользовательским интерфейсом и изменениями состояния как во внешнем, так и во внутреннем интерфейсе. Он также предлагает API, аналогичный useReducer, позволяющий нам читать state и вызывать на нем dispatch(action).
И, наконец, запустите службу movex
npx movex dev
Это запускает сервер на основе файла movex.config по адресу localhost:3333.
Шаг 5. Давайте проверим результаты
Перейдя к localhost:3000 и выбрав слот сейчас, вы должны перейти к URL-адресу, похожему на http://localhost:3000/chat/chat:f02e10c5-ccfb-47b5-a71e-55bb44c56953?slot=pink.
Нажмите кнопку «Пригласить» и откройте ее в другой вкладке, чтобы также протестировать многопользовательский режим. Вы должны увидеть что-то вроде этого:

Ты сделал это!
Полный код находится здесь: https://github.com/GabrielCTroia/movex-next-chat
Кроме того, если вы хотите попробовать свои силы в этом, вы можете сделать еще один шаг и добавить логику «{user} печатает…» или отобразить список активных пользователей. Это потребует от вас создания и обработки новых действий.
гифка

Как насчет того, чтобы развернуть это где-нибудь, чтобы я мог общаться с моими настоящими друзьями?
Запустить Movex на Docker и развернуть его на Fly.io или AWS довольно просто, и я напишу туториал о том, как это сделать в будущем, а пока вы можете ознакомиться с документацией.
P.S. Можете ли вы мне помочь? ❤️
Я надеюсь, что вы узнали что-то полезное из этого урока, и мне очень любопытно посмотреть, вдохновил ли он вас на создание чего-то классного.
Пожалуйста, оставьте комментарий ниже и/или пометьте репозиторий Movex, если вы считаете, что проект достоин того, чтобы его знали другие или чтобы его развивали дальше.
https://github.com/movesthatmatter/movex