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 на две отдельные страницы.

  1. /pages/index будет отвечать за адаптацию чата.
  2. Новый /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