DevGang
Авторизоваться

Узнайте, как создать быстрый и отзывчивый markdown редактор с помощью React, Firebase и SWR

Недавно я вступил в довольно трудное путешествие по созданию собственной CMS с нуля. Однако, работая над этим проектом, я обнаружил удивительный хук для извлечения данных, называемый useSWR, созданный замечательными людьми из Verce!, поэтому я хотел показать вам, ребята, как SWR значительно упрощает создание быстрых и удобных приложений. Это на удивление легко, так что давайте начнем. Поскольку показывать его вам без контекста было бы не очень интересно, мы собираемся создать markdown редактор, который использует Firebase для аутентификации и хранения наших данных. Итак, поехали...

Что такое SWR

SWR - это стратегия извлечения данных, обозначающая Stale While Revalidate. Это довольно популярная стратегия извлечения данных, но Vercel опубликовал пакет npm с перехватчиками React, которые облегчают использование этой стратегии в веб-приложениях. Основную идею хука useSWR можно объяснить, посмотрев на пример:

import useSWR from "swr";

const App = () => {
  const { data, error } = useSWR("STRING_KEY", doSomethingWithKey);

  if (error) return <div>Error while loading data!</div>;
  if (!data) return <div>Loading...</div>;
  return <div>We have {data}!</div>;
};

Как вы можете видеть, хук принимает 2 аргумента, первый из которых является строковым ключом, который должен быть уникальным идентификатором данных, обычно это будет URL вашего API. И второй аргумент - это функция, которая возвращает данные на основе этого ключа (обычно это какая-то функция считывания).

Итак, теперь, когда мы знаем основы SWR, давайте создадим приложение с ним. 

Необходимые компоненты

Убедитесь, что у вас установлены последние (или несколько недавние) версии Node и NPM, а также готов ваш любимый редактор кода, мы собираемся использовать его сегодня очень часто.

Настройка

Для нашего первого шага мы собираемся использовать create-react-app для начальной загрузки проекта React, а также установим несколько зависимостей:

  1. firebase наш "backend"
  2. react-with-firebase-auth специальная функция, которая делает аутентификацию с firebase очень простой
  3. rich-markdown-editor это markdown редактор, который мы будем использовать для этого приложения. Я выбрал именно этот, потому что у него очень удобный API для работы, а также очень удобный дизайн.
  4. @reach/router как наш алгоритм маршрутизации на стороне клиента, вы поймете, почему это понадобится нам очень скоро.

Запустите эти команды, чтобы создать приложение и установить указанные зависимости:

npx create-react-app markdown-editor

# Or on older versions of npm:
npm i -g create-react-app
create-react-app markdown-editor

cd markdown-editor
npm i firebase react-with-firebase-auth rich-markdown-editor @reach/router

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

Создание приложения Firebase

Чтобы иметь возможность использовать Firebase в нашем веб-приложении, нам нужно настроить проект Firebase, так что давайте сделаем это. Перейдите на https://firebase.google.com и войдите в свою учетную запись Google. Затем в консоли создайте новый проект:

Я предпочитаю не включать аналитику, но вы можете сделать это, если хотите.

Теперь, когда у нас есть проект, созданный в проекте, нажмите маленький веб-значок:

Скопируйте этот объект конфигурации, который он вам дает, и храните его там, где вам нравится (не волнуйтесь слишком сильно об этом, вы можете вернуться и просмотреть его позже на панели мониторинга):

Мы также собираемся настроить нашу аутентификацию, поэтому перейдите в раздел аутентификации и выберите поставщиков, которых вы хотели бы поддержать, и следуйте их инструкциям по настройке. Поставщик "Google" работает с 0 config, так что если вы просто хотите быстро начать, то это то, что я бы рекомендовал. Я также следовал документам и включил провайдера GitHub, но это ваше дело.

Модель

Прежде чем мы перейдем к коду, давайте структурируем приложение в нашей голове. Нам нужны в основном три разных представления: представление «Вход в систему», которое пользователь увидит, если они не вошли в систему, «Панель управления», которая покажет вошедшему в систему пользователю все его файлы, и, наконец, представление "Редактор", которое пользователь будет видеть, когда он редактирует файл. Теперь, когда мы все это спланировали в вашей голове, давайте продолжим.

Мне лично не нравится способ create-react-app, поэтому я собираюсь немного перестроить код, но это то, как мне нравится это делать, и вам не нужно делать это таким образом. В сообществе React хорошо известно, что вы можете делать все, что хотите, если вам это удобно, поэтому делайте так, как вам нравится, но обязательно переводите пути, которые я использую.

Настройте Firebase в своем коде

Теперь когда мы сделали все наши приготовления мы можем наконец начать работать над кодом. Сначала давайте настроим firebase в нашем проекте, чтобы вы помнили, что объект конфигурации теперь создает файл в вашем проекте, который экспортирует этот объект:

/* src/lib/firebaseConfig.js */

export default {
  apiKey: "YOUR_API_KEY",
  authDomain: "YOUR_AUTH_DOMAIN",
  databaseURL: "YOUR_DATABASE_URL",
  projectId: "YOUR_PROJECT_ID",
  storageBucket: "YOUR_STORAGE_BUCKET",
  messagingSenderId: "YOUR_SENDER_ID",
  appId: "YOUR_APP_ID",
};

Вы можете быть обеспокоены тем, что это жестко закодировано в вашем коде, но это не такая уж большая проблема, если кто-то попадет в вашу конфигурацию, потому что мы собираемся установить правила аутентификации в вашей базе данных. Если вы все еще беспокоитесь, вы можете добавить все эти значения в файл '.env' и импортировать его таким образом.

Теперь, когда у нас есть эта конфигурация, мы создадим еще один файл, где мы инициализируем наше приложение firebase с помощью этой конфигурации, а затем мы экспортируем его, чтобы мы могли повторно использовать его в нашем коде:

import * as firebase from "firebase/app";
import "firebase/auth";

import firebaseConfig from "lib/firebaseConfig";

// Check if we have already initialized an app
const firebaseApp = !firebase.apps.length
  ? firebase.initializeApp(firebaseConfig)
  : firebase.app();

export const firebaseAppAuth = firebaseApp.auth();

export const providers = {
  googleProvider: new firebase.auth.GoogleAuthProvider(),
  githubProvider: new firebase.auth.GithubAuthProvider(), // <- This one is optional
};

Теперь, когда наше приложение Firebase настроено, давайте вернемся к мысленному образу, который мы создали для нашего приложения, вы помните это?

Базовая навигация

Мы собираемся реализовать это с помощью reach-router и нашей аутентификации firebase HOC:

/* src/components/App/App.js */

import React from "react";
import { Router, navigate } from "@reach/router";

import withFirebaseAuth from "react-with-firebase-auth";
import { firebaseAppAuth, providers } from "lib/firebase";

import { Dashboard, Editor, SignIn } from "components";
import "./App.css";

const createComponentWithAuth = withFirebaseAuth({
  providers,
  firebaseAppAuth,
});

const App = ({ signInWithGoogle, signInWithGithub, signOut, user }) => {
  console.log(user);
  return (
    <>
      <header>
        <h2>TypeMD</h2>
        {user && (
          <div>
            <a
              href="#log-out"
              onClick={() => {
                signOut();
                navigate("/");
              }}
            >
              Log Out
            </a>
            <img alt="Profile" src={user.photoURL} />
          </div>
        )}
      </header>
      <Router>
        <SignIn
          path="/"
          user={user}
          signIns={{ signInWithGithub, signInWithGoogle }}
        />
        <Dashboard path="user/:userId" />
        <Editor path="user/:userId/editor/:fileId" />
      </Router>
    </>
  );
};

export default createComponentWithAuth(App);

Да, я знаю, что это много кода, но потерпите меня. Таким образом, основная идея заключается в том, что у нас есть постоянный компонент Header, а затем ниже, что у нас есть различные маршруты. Поскольку мы обертываем наш компонент приложения HOC-аутентификацией Firebase, мы получаем доступ к нескольким реквизитам, таким как методы входа, выхода, а также текущему вошедшему в систему пользователю (если он есть). Мы передаем методы входа в наш компонент SignIn, а затем передаем метод выхода в наш заголовок, где у нас есть кнопка выхода из системы. Итак, как вы можете видеть, код довольно интуитивен по своим качествам.

Теперь давайте посмотрим, как мы обрабатываем вход пользователя на нашей странице входа:

/* src/components/SignIn/SignIn.js */

import React from "react";
import { navigate } from "@reach/router";

const SignIn = ({ user, signIns: { signInWithGoogle, signInWithGithub } }) => {
  if (user) {
    navigate(`/user/${user.uid}`);
    return null;
  } else {
    return (
      <div className="sign-in-page">
        <h3>
          Welcome to TypeMD a simple &amp; beautiful online markdown editor
        </h3>
        <p>
          Sign in with your social accounts to have files that are synced
          accross devices
        </p>
        <div className="sign-in-buttons">
          <button onClick={signInWithGoogle}>Sign in with Google</button>
          <button onClick={signInWithGithub}>Sign in with GitHub</button>
        </div>
      </div>
    );
  }
};

export default SignIn;

Как вы можете видеть, эти методы, которые мы передали ему, используются при нажатии кнопок, а затем мы проверяем, есть ли вошедший в систему пользователь, и перенаправляем их на панель мониторинга, используя метод navigate, который предоставляет reach-router.

Настройка базы данных Firestore

Теперь, когда у нас настроена аутентификация, нам нужно настроить нашу базу данных, поэтому давайте снова перейдем к нашей консоли firebase и создадим базу данных firestore. В вашей консоли нажмите на базу данных на боковой панели и выберите «Cloud Firestore», если она еще не выбрана. Затем нажмите начать сбор:

Я собираюсь назвать коллекцию «users», потому что именно так мы будем управлять нашими данными:

Для первого документа я собираюсь просто добавить тестовый, потому что мы собираемся удалить его сразу после этого:

Теперь давайте удалим тестовый документ:

Если вы помните, я говорил вам ранее, что не имеет значения, утек ли ваш объект конфигурации, потому что мы собираемся перейти к разделу «rules» и настроить правило, чтобы аутентифицированный пользователь мог получить доступ только к своему файлу. Язык довольно понятен, так что вот правило:

rules_version = '2';
service cloud.firestore {
    match /databases/{database}/documents {
        // Allow only authenticated content owners access
        match /some_collection/{userId}/{documents=**} {
            allow read, write: if request.auth.uid == userId
        }
    }
}

Это правило работает из-за того, как мы собираемся структурировать наши данные. Мы делаем это так: как только пользователь входит в систему, мы проверяем, есть ли его идентификатор в базе данных, если это так, мы получаем вложенную коллекцию files пользователей и возвращаем ее, если их нет в базе данных, то мы создаем для них пустую запись, в которую они могут добавить файлы позже. Правило аутентификации просто гарантирует, что пользователь может получить доступ только к своим файлам и ни к чьим другим.

Теперь, если вы помните наш файл firebase.js, в который мы экспортировали наши приложения Firebase и провайдеров аутентификации, в этот же файл добавьте эти две строки, чтобы сделать нашу базу данных доступной для других файлов:

import "firebase/firestore";
export const db = firebaseApp.firestore();

Получение файлов из базы данных

Теперь мы можем импортировать это в нашу панель мониторинга и создать функцию, в которой мы проверим, существует ли пользователь с данным идентификатором в базе данных, если да, то мы вернем его данные, а если нет, создадим их, давайте вызовем getUserData:

import { db } from "lib/firebase";

const getUserFiles = async (userId) => {
  const doc = await db.collection("users").doc(userId).get();

  if (doc.exists) {
    console.log("User found in database");
    const snapshot = await db
      .collection("users")
      .doc(doc.id)
      .collection("files")
      .get();

    let userFiles = [];
    snapshot.forEach((file) => {
      let { name, content } = file.data();
      userFiles.push({ id: file.id, name: name, content: content });
    });
    return userFiles;
  } else {
    console.log("User not found in database, creating new entry...");
    db.collection("users").doc(userId).set({});
    return [];
  }
};

Как вы можете видеть из приведенного выше кода, firebase проделал потрясающую работу по созданию читаемых запросов, которые я очень ценю, особенно при отладке.

Это довольно здорово, но у нас нет файлов для просмотра. Итак, давайте также сделаем метод для создания файла на основе идентификатора пользователя и имени файла:

const createFile = async (userId, fileName) => {
  let res = await db.collection("users").doc(userId).collection("files").add({
    name: fileName,
    content: "",
  });
  return res;
};

Довольно просто, верно? В этой функции мы находим нашего пользователя в коллекции пользователей и в вложенной коллекции файлов этого пользователя мы добавляем новый файл. Теперь мы используем функцию add вместо set, как мы использовали раньше, чтобы firebase могла случайным образом генерировать идентификатор для нашего файла. Это позволяет пользователям иметь несколько файлов с одинаковым именем без проблем.

Базовый интерфейс Dahsboard

Теперь мы можем начать с пользовательского интерфейса для нашей панели инструментов, поэтому давайте просто составим простой список, где каждый элемент будет использовать Link-router для перехода пользователя на страницу редактора:

/* src/components/Dashboard/Dashboard.js */

const Dashboard = ({ userId }) => {
  const [nameValue, setNameValue] = useState("");
  const { data, error } = useSWR(userId, getUserFiles);

  if (error) return <p>Error loading data!</p>;
  else if (!data) return <p>Loading...</p>;
  else {
    return (
      <div>
        <form
          onSubmit={(e) => {
            e.preventDefault();
            if (nameValue) {
              setNameValue("");
              createFile(userId, nameValue);
              mutate(userId);
            }
          }}
          className="new-file-form"
        >
          <input
            type="text"
            placeholder="Your new files name..."
            value={nameValue}
            onChange={(e) => setNameValue(e.target.value)}
          />
          <button type="submit" className="add-button">
            Create
          </button>
        </form>
        <ul className="files-list">
          {data.map((file) => {
            return (
              <li key={file.id} className="file">
                <Link to={`/user/${userId}/editor/${file.id}`} className="link">
                  {file.name}
                </Link>
              </li>
            );
          })}
        </ul>
      </div>
    );
  }
};

Опять же, у нас много кода, но это в основном только пользовательский интерфейс. Однако это первый раз, когда мы используем хук useSWR и передаем ему идентификатор пользователя в качестве ключа, а затем для его функции извлечения данных мы передаем ему метод getUserData, который мы создали ранее. Затем мы используем тот же шаблон, который я показал вам в первом примере, чтобы проверить наличие ошибок и загрузки и, наконец, если у нас есть данные, мы проходим через цикл и показываем их в списке. Мы также используем хуки для отслеживания формы ввода создания файла, но я надеюсь, что вы уже знакомы с тем, как их использовать.

Это здорово, но сейчас наши ссылки, идущие в редактор, довольно бесполезны, потому что у нас пока нет компонента Editor, так как мы сейчас это сделаем.

Редактор

Как я упоминал ранее, мы используем удивительный редактор с открытым исходным кодом rich-markdown-editor, который мы будем импортировать, а затем использовать defaultValue, чтобы показать нам наш сохраненный контент:

/* src/components/Editor/Editor.js */

import React, { useState, useEffect } from "react";
import useSWR, { mutate } from "swr";
import { db } from "lib/firebase";
import { Link, navigate } from "@reach/router";
import MarkdownEditor from "rich-markdown-editor";

const getFile = async (userId, fileId) => {
  const doc = await db
    .collection("users")
    .doc(userId)
    .collection("files")
    .doc(fileId)
    .get();

  return doc.data();
};

const Editor = ({ userId, fileId }) => {
  const { data: file, error } = useSWR([userId, fileId], getFile);
  const [value, setValue] = useState(null);

  useEffect(() => {
    if (file !== undefined && value === null) {
      console.log("Set initial content");
      setValue(file.content);
    }
  }, [file, value]);

  const saveChanges = () => {
    db.collection("users").doc(userId).collection("files").doc(fileId).update({
      content: value,
    });
    mutate([userId, fileId]);
  };

  if (error) return <p>We had an issue while getting the data</p>;
  else if (!file) return <p>Loading...</p>;
  else {
    return (
      <div>
        <header className="editor-header">
          <Link className="back-button" to={`/user/${userId}`}>
            &lt;
          </Link>
          <h3>{file.name}</h3>
          <button
            disabled={file.content === value}
            onClick={saveChanges}
            className="save-button"
          >
            Save Changes
          </button>
        </header>
        <div className="editor">
          <MarkdownEditor
            defaultValue={file.content}
            onChange={(getValue) => {
              setValue(getValue());
            }}
          />
        </div>
      </div>
    );
  }
};

export default Editor;

Как и прежде, мы используем тот же шаблон, где у нас есть метод, который получает данные, а затем мы используем useSWR с нашим ключом. В этом случае мы используем массив ключей, чтобы мы могли передать и идентификатор пользователя, и идентификатор файла в функцию сборщика (который является getFile()). Мы также используем хуки useState() для отслеживания состояния редакторов, обычно мы обновляем значение редактора с помощью нашего значения состояния, но здесь нам это делать не нужно. Когда наши данные станут доступны, мы просто передадим их как defaultValue нашему редактору, а затем отследим изменения, используя предоставленный метод onChange.

Возможно, вы заметили useEffect() в верхней части функции. Мы используем это, чтобы фактически установить начальное значение нашей переменной stateful value, что помогает нам отслеживать, есть ли у пользователя несохраненные изменения или нет.

Посмотрите на нас сейчас! У нас есть простой, но работающий редактор, так что же нам теперь делать? Есть много (и я имею в виду много) вещей, чтобы добавить к этому, и я расскажу о некоторых из них в разделе улучшений. Но сейчас у нас есть еще две важные функции, которые мы могли бы добавить, и одну из них гораздо сложнее реализовать, чем другую. Итак, начнем с простого:

Удаление файлов

Довольно маленькая, но важная вещь, которую нужно добавить в наш компонент Dashboard. Для этого мы будем использовать метод ref.delete, предоставляемый firebase, вот наша функция deleteFile:

const deleteFile = async (userId, fileId) => {
  let res = await db
    .collection("users")
    .doc(userId)
    .collection("files")
    .doc(fileId)
    .delete();
  return res;
};

Теперь мы можем вызвать это, когда нажата кнопка:

{...}
      <button
        onClick={() => {
          deleteFile(userId, file.id).then(() => mutate(userId));
        }}
        className="delete-button"
      >
        x
      </button>
    {...}

Теперь давайте перейдем к более сложной функции:

Загрузка изображений

Редактор, который мы используем, rich-markdown-editor имеет вызываемую поддержку, uploadImage которая ожидает обещание, которое преобразуется в строковый URL загруженного изображения. Для этого обратного вызова он предоставит изображение в виде объекта JavaScript-файла. Для этого нам нужно будет настроить хранилище в firebase. Так что давайте вернемся к консоли и нажмем на кнопку Storage на боковой панели. Нажмите кнопку «Get Started» и создайте свой контейнер, используя любое местоположение. Как только вы войдете, мы снова собираемся изменить наши правила безопасности, но на этот раз мы будем разрешать чтение от кого угодно, но только от проверенных пользователей. Вот правила для этого:

rules_version = '2';
service firebase.storage {
    match /b/{bucket}/o {
        match /users/{userId}/{allImages=**} {
            allow read;
            allow write: if request.auth.uid == userId;
        }
    }
}

Как мы делали это ранее с firestore, нам нужно создать ссылку на нашу корзину хранения с помощью нашего инициализированного приложения firebase, поэтому давайте вернемся к firebase.js и сделаем это:

import "firebase/storage";
export const store = firebaseApp.storage();

Теперь мы можем импортировать эту ссылку в наш код и использовать ее для чтения или записи в магазин. Итак, давайте создадим функцию, которая берет объект File и загружает его в хранилище:

const uploadImage = async (file) => {
  const doc = await db
    .collection("users")
    .doc(userId)
    .collection("images")
    .add({
      name: file.name,
    });

  const uploadTask = await store
    .ref()
    .child(`users/${userId}/${doc.id}-${file.name}`)
    .put(file);

  return uploadTask.ref.getDownloadURL();
};

Итак, поскольку в хранилище Firebase нет возможности загружать файлы со случайным уникальным именем, мы собираемся создать подколлекцию для каждого пользователя с именем images, а затем каждый раз, когда мы загружаем изображение, мы добавляем его туда. После этого мы берем этот идентификатор и добавляем к нему дефис и исходное имя файла, а затем загружаем его, используя метод ref.put, предоставляемый хранилищем firebase. После того, как задача загрузки завершена, мы возвращаем ее URL, используя метод getDownloadURL.

Теперь все, что нам нужно сделать, это предоставить этот метод в качестве опоры для нашего редактора:

{...}
    <MarkdownEditor
        defaultValue={file.content}
        onChange={(getValue) => {
        setValue(getValue());
        }}
        uploadImage={uploadImage}
    />
{...}

Посмотрите на нас, мы зашли так далеко. У нас есть полуприличный markdown редактор, добавьте несколько сотен строк CSS, и вы получите полноценный сторонний проект. Но есть несколько вещей, которые мы можем легко добавить для улучшения общего пользовательского опыта, поэтому давайте перейдем к ним.

Общие улучшения

Таким образом, есть много вещей, которые нужно улучшить, но первое, что я хотел решить, это то, что если вы не вошли в систему и не посетили ни одну из страниц, это просто выдаст ошибку. Поэтому я добавил хук useEffect, который перенаправит вас обратно на домашнюю страницу:

useEffect(() => {
  if (!user) {
    navigate("/");
  }
}, [user]);

Как только это было сделано, я также хотел дать обратную связь пользователю, когда у него были несохраненные изменения и он попытался покинуть страницу. Это достигается с помощью другого хука useEffect, чтобы мы могли добавить слушателя к событию beforeunload:  

const onUnload = (event) => {
  event.preventDefault();
  event.returnValue = "You have unsaved changes!";
  return "You have unsaved changes!";
};

useEffect(() => {
  if (file && !(file.content === value)) {
    console.log("Added listener");
    window.addEventListener("beforeunload", onUnload);
  } else {
    window.removeEventListener("beforeunload", onUnload);
  }

  return () => window.removeEventListener("beforeunload", onUnload);
});

Довольно просто, но, на мой взгляд, имеет существенное значение. Я также добавил тосты, используя удивительные пакеты react-toastify, чтобы позволить пользователю видеть, когда его изменения были сохранены или когда происходит ошибка:  

import { ToastContainer, toast } from "react-toastify";

const saveChanges = () => {
    {...}
    toast.success("🎉 Your changes have been saved!");
};

{...}
    <div>
        <div className="editor">
        <MarkdownEditor
            defaultValue={file.content}
            onChange={(getValue) => {
            setValue(getValue());
            }}
            uploadImage={uploadImage}
            onShowToast={(message) => toast(message)}
        />
        </div>
        <ToastContainer />
    </div>
{...}

Вывод

Надеюсь, вы смогли узнать, насколько удивителен этот стек для веб-приложений. Использование SWR и Firebase с React обеспечивает потрясающий опыт разработчиков, а также (из-за кэширования) дает пользователям невероятно быстрый пользовательский опыт.

Источник:

#React #Open source
Комментарии 3
Anri 10.09.2020 в 06:28

markdown - редактор снижения? Вы серьезно? Вы хотя бы просматриваете перевод после того, как прогнали его через переводчик?

LegGnom 10.09.2020 в 13:50

Спасибо, исправили. Подобный казус скорее исключение, приносим свои извинения

Anri 07.10.2020 в 13:14

Да тут и кроме этого ошибок полно, например у вас - "Теперь все, что нам нужно сделать, это предоставить этот метод в качестве опоры для нашего редактора". Не опоры, а "пропсы" (props). Пропсы - это в терминологии реакта свойства компонента

Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу