Pull to refresh

Разработка быстрых и современных сайтов на базе Next.js, с использованием GraphQL & WordPress

Level of difficultyMedium
Reading time11 min
Views13K

Введение

Next.js - современный фреймворк на базе React.js, который значительно набирает обороты среди разработчиков и предоставляет инструменты для разных видов рендеринга страниц.

WordPress - популярная headless CMS, применяемая для различных проектов - от простых блогов до сложных приложений.

У нас был доступ к админке живого сайта на WordPress, шило в коде и непреодолимое желание поэксперементировать с Next.js.

Решение основано на статье (и шаблоне) Vercel Using Headless WordPress with Next.js and Vercel.

Получилось достаточно быстрое приложение с примерно небольшими трудозатратами.

TL DR

MVP подход

У нас была основная цель - эксперимент со стеком Next.js-GraphQL-WordPress для получения практики и лучшего понимания возможностей.

Потому все остальное решено было нагло стырить скопировать для экономии времени:

  • дизайн блога взяли у kod.ru (блог про Телеграм, ТОН и проекты Павла Дурова)

  • дизайн ленда взяли у ton.org (блокчейн с теми же корнями)

  • контент взяли у wpcraft.ru - потому что был доступ в админку

В итоге получился некий гибрид этих 3х проектов, который позволил нам с одной стороны получить эксперимент максимально близкий к реальности, с другой стороны обойтись без лишних затрат на дизайн и контент.

Бэкенд: WordPress + GraphQL

WordPress, возможно, самый простой способ сборки бэкенда, но в нашем случае мы взяли готовый работающий сайт, в котором уже был какой-то контент. И сразу перешли к настройке GraphQL.

Настройка GraphQL на базе WordPress

Процесс настройки займёт условных 5 минут:

  • зайти в админку, в раздел плагинов

  • найти плагин WPGraphQL - установили

  • указать GraphQL Endpoint, остальные настройки - по умолчанию

Фронтенд: Next.js + TypeScript

Создаём проект

Официальной документацией для Next.js приложения рекомендуется использовать create-next-app.

В наших примерах используем TypeScript и папку pages. Аналогично будет работать и с эксперементальной директорией app в т.ч. на чистом JavaScript.

npx create-next-app@latest --typescript

Выбираем нужные опции, дожидаемся установки всех зависимостей и получаем базовую структуру приложения.

Переходим в папку проекта (если ещё не) и в терминале выполняем npm run dev. Если нигде не промазали, должен запуститься сервер на порту 3000 (по умолчанию):

ready - started server on 0.0.0.0:3000, url: <http://localhost:3000>
event - compiled client and server successfully in 469 ms (170 modules)

Переходим по указанному адресу, убеждаемся что всё работает.

Теперь у нас есть простое Next.js приложение.

Формируем GraphQL запрос

Плагин WPGraphQL в CMS WordPress предоставляет IDE для формирования и тестирования запросов.

Открываем wp-admin и находим GraphQL.

Query Composer - это графический редактор запросов, он содержит древовидную стурктуру всех доступных полей CMS. Тут мы можем выбрать необходимые поля, задать условия выборки, сортировку и получить готовый запрос.

Например, мы хотим получить 5 последних добавленных постов категории “development”:

  • нажимаем Query Composer, находим в дереве posts, раскрываем

  • ставим галочку first и указываем значение 5

  • раскрываем where, выбираем categoryName и указываем “development”

  • далее раскрываем nodes и выбираем нужные поля: slug, title, excerpt, date

  • URL картинки лежит чуть глубже - featureImage/node/sourceUrl

Далее запускаем выполнение запроса кнопкой Execute Query (или Ctrl+Enter) и в правой секции видим результат запроса.

 Query Composer в админке WordPress
Query Composer в админке WordPress

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

Если не указать параметр first, то WPGraphQL вернёт 10 постов.

Максимальное возвращаемое количество постов - 100, даже если указать first: 1000.

А если нужно получить больше 100 постов? Автор плагина WPGraphQL говорит, что большие запросы могут привести к проблемам с производительностью клиента и сервера и предлагает использовать пагинацию.

Добавляем интерфейс

Теперь мы знаем структуру возвращаемых данных и можем описать интерфейсы:

  • IPostPreview без контента для списка постов

  • IPost с контентом для страницы поста

// types.ts

export interface IPostPreview {
  slug: string;
  title: string;
  excertp: string;
  featuredImage: {
    node: {
      sourceUrl: string;
    }
  }
  date: string;
}

export interface IPost extends IPostPreview {
  content: string;
}

Получаем данные из CMS WordPress

Всё готово для получения данных на стороне клиента.

Добавим функцию getPosts, использующую метод fetch:

// wp-api.ts 

export async function getPosts() {

  // определяем Content-Type для JSON
  const headers = { 'Content-Type': 'application/json' };

  // формируем GraphQL запрос
  const query = `
    query FavoriteBlogs { 
      posts {
        nodes {
          slug
          title
          excerpt
          date
          featuredImage {
            node {
              sourceUrl
            }
          }
        }
      }
    }  
  `;

  // Первым аргументом метода fetch указываем GraphQL ендпоинт,
  // который мы определили в настройках CMS.
  // Второй аргумент - объект запроса.
  const res = await fetch('<https://wpcraft.ru/graphql>', {
    headers,
    method: 'POST',
    body: JSON.stringify({
      query,
    }),
  });

  // получаем JSON из объекта Promise<Response>
  const json = await res.json();

  // возвращаем посты
  return json.data?.posts.nodes;
}

Заголовки и обработка ответа будут нужны во всех запросах, поэтому имеет смысл вынести этот код в функцию-обёртку fetchData, которая будет принимать текст запроса и возвращать данные:

// wp-api.ts 

async function fetchData(query: string) {
  const headers = { 'Content-Type': 'application/json' };

  const res = await fetch('&lt;https://wpcraft.ru/graphql&gt;', {
    headers,
    method: 'POST',
    body: JSON.stringify({
      query,
    }),
  });
  const json = await res.json();
  
  return json.data;
}

export async function getPosts() {
  const data = await fetchData(`
    query getPosts{
      posts {
        nodes {
          slug
          title
          excerpt
          date
          featuredImage {
            node {
              sourceUrl
            }
          }
        }
      }
    } 	
  `);
  return data.posts.nodes as IPostPreview[];
}

И т.к. мы не указали параметр first, WPGraphQL вернёт нам 10 постов.

Структура и маршрутизация

Мы хотим сделать страницу, на которой будет список постов.

При клике на пост должна открываться страница с контентом поста.

Каждая страница с постом будет иметь свой уникальный URL, который будет формироваться динамически используя slug.

Сейчас в директории pages у нас есть файл index.tsx - это главная страница, которая открывается по адресу http://localhost:3000/. Роут - /.

В директорию pages добавляем файл [slug].tsx - тут мы будем отрисовывать каждый отдельный пост. Роут - /some-meaningful-post-title

Если на главной странице мы хотим разместить, допустим, лендинг, а список постов отображать на другом роуте, скажем, через префикс /blog, мы можем создать внутри pages директорию blog, в неё добавить index.tsx для списока постов (роут - /blog) и [slug].tsx для каждого поста (роут - /blog/uniq-post-slug).

Вот тут подробней про роутинг в Next.js.

Варианты генерации страниц в Next.js

Next.js поддерживает разные способы генерации страниц, рассмотрим SSR и SSG.

ISG отличается от SSG парой параметров внутри тех же самых функций, поэтому в данной статье ISG рассматривать не будем.

// [slug].tsx

import styles from './slug.module.scss';
  1. SSR (генерация на стороне сервера)

    Страница со списком постов

    Получаем список постов с помощью getServerSideProps, передаём через пропсы в компонент страницы Home:

    // pages/index.ts
    
    // опишем явно какие пропсы ожидаем в Home
    interface IHomeProps {
      posts: IPostPreview[];
    }
    
    export default function Home({ posts }: IHomeProps) {
    
      return (
        <main>
          {posts.map((post) => (
            // используем Link из 'next/link'
            <Link key={post.slug} href={/${post.slug}}>
              {post.title}
            </Link>
          ))}
        </main>
      );
    }
    
    // тип GetServerSideProps экспортируем из 'next'
    export const getServerSideProps: GetServerSideProps = async () => {
      // тип IPostPreview[] переменной posts можно не указывать,
      // т.к. мы явно указали в getPost какого типа данные мы возвращаем
      const posts: IPostPreview[] = await getPosts();
    
      return {
        props: {
          posts,
        },
      };
    }
    

    Теперь на страницу выводится кликабельный список заголовков постов, клик на пост открывает страницу [slug].tsx, URL меняется на http://localhost:3000/[slug].

    Страница контента поста

    Добавим функцию getPostBySlug, с её помощью мы будем получать пост с контентом для отображения на странице поста.

    // wp-api.ts
    
    export async function getPostBySlug(slug: string) {
      const data = await fetchData(`
        query getPostBySlug {
          post(id: &quot;${slug}&quot;, idType: SLUG) {
            title
            content
            excerpt
            slug
            featuredImage {
              node {
                sourceUrl
              }
            }
            date
          }   
      `});
      return data.post as IPost;
    }

    Теперь всё готово для получения поста и генерации страницы:

    // [slug].ts
    
    // опишем явно какие пропсы ожидаем в Post
    interface IPostProps {
      post: IPost;
    }
    
    export default function Post({ post }: IPostProps) {
    
      return (
        <>
          {post && (
            <article>
              <h1>{post.title}</h1>
              <div dangerouslySetInnerHTML={{__html: post.content}} />
            </article>
          )}
        </>
      );
    }
    
    export const getServerSideProps: GetServerSideProps = async (context) => {
      // params может быть undefined, slug может быть string | string[] | undefined
      // поэтому укажем явно какой тип мы передаём в slug
      // это немного "костыль", но пока так
      const slug = context.params?.slug as string;
    
      // тип для переменной post не указываем, т.к. в getPostBySlug указали 
      // какого типа данные возвращаем
      const post = await getPostBySlug(slug);
    
      return {
        props: {
          post
        }
      }
    }

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

    Стилизуем контент:

    • добавляем файл slug.module.scss

    • определяем стили для основных тегов

    // slug.module.scss
    
    .content {
      p,
      ul,
      ol,
      blockquote {
        margin: 1.5rem 0;
      }
      a {
        cursor: pointer;
        text-decoration: underline;
      }
      ul,
      ol {
        padding: 0 0 0 1rem;
      }
      ul {
        list-style-type: disc;
      }
      ol {
        list-style-type: decimal;
      }
      pre {
        white-space: pre;
        overflow-x: auto;
        padding: 1rem;
        font-size: 1.25rem;
        line-height: 1.25;
        border: 1px solid rgb(156 163 175);
        background-color: rgb(243 244 246);
      }
      code {
        font-size: 0.875rem;
        line-height: 1.25rem;
      }
      figcaption {
        text-align: center;
        font-size: 0.875rem;
        line-height: 1.25rem;
      }
    ...
    • импортируем стили

    • добавляем стили к блоку с контентом с помощью className:

    // [slug].tsx
    
    ...
    <div
      className={styles.content}
      dangerouslySetInnerHTML={{__html: post.content}}
    />
    ...

    Мы пока оставляем за скобками вопрос о пробросе стилей сформированных в WordPress с помощью Gutenberg, т.к. автор ещё сам не разобрался как это сделать 🙂

    Таким образом,

    на данном этапе у нас есть список постов, клик на пост открывает страницу с контентом поста. Генерация всех страниц происходит на стороне веб-сервера в момент обращения к веб-серверу.

  2. SSG (статическая генерация)

    Страница со списком постов

    Меняем getServerSideProps на getStaticProps (с соответствующим типом) и всё готово.

    Здорово, да? 🙂

    // pages/index.ts
    
    // опишем явно какие пропсы ожидаем в Home
    interface IHomeProps {
      posts: IPostPreview[];
    }
    
    export default function Home({ posts }: IHomeProps) {
    
      return (
        <main>
          {posts.map((post) => (
            // используем Link из 'next/link'
            <Link key={post.slug} href={/${post.slug}}>
              {post.title}
            </Link>
          ))}
        </main>
      );
    }
    
    // тип GetStaticProps экспортируем из 'next'
    export const getStaticProps: GetStaticProps = async () => {
      const posts = await getPosts();
        
      return {
        props: { posts },
      };
    };

    Страница поста

    Тут чуть сложнее. Т.к. URL страницы зависит конкретного slug каждого поста мы должны заранее, во время сборки приложения, определить все возможные URL.

    Для этого используется функция getStaticPaths.Получение данных для статической генерации контента осуществляем с помощью уже знакомой функции getStaticProps.

    // [slug].ts
    
    // опишем явно какие пропсы ожидаем в Post
    interface IPostProps {
      post: IPost;
    }
    
    export default function Post({ post }: IPostProps) {
    
      return (
        <>
          {post && (
            <article>
              <h1>{post.title}</h1>
              <div dangerouslySetInnerHTML={{__html: post.content}} />
            </article>
          )}
        </>
      );
    }
    
    export const getStaticPaths: GetStaticPaths = async () => {
      const posts = await getPosts();
    
      // создаём массив путей для каждого поста
      const paths = posts.map((post) => ({
        params: { slug: post.slug },
      }));
    
      return {
        // возвращаем массив путей
        paths,
        // fallback может быть true, false или 'blocking'
        // подробней тут: <https://nextjs.org/docs/api-reference/data-fetching/get-static-paths>
        fallback: false,
      };
    }
    
    export const getStaticProps: GetStaticProps = async (context) => {
      const slug = context.params?.slug as string;
      const post = await getPostBySlug(slug);
    
      return {
        props: {
          post
        }
      }
    }

    Теперь во время сборки у нас генерируется главная страница со списком постов и все страницы с постами. Если в WordPress 200 постов, то веб-сервер сгенерирует 201 страницу.

Публикуем приложение на Vercel

У нас есть страница с постами, можно открыть каждый пост и посмотреть контент, а значит можно поделиться нашим приложением с остальным миром.

Репозиторий GitHub

Если на данном этапе репозиторий уже заведён, то просто пушим код и идём дальше.

Если ещё нет, то:

  • создаём репозиторий на GitHub

  • открываем терминал (из IDE или как удобно), переходим в папку проекта

  • выполняем команды:

    git init
    // user-name - имя пользователя github
    // repo-name - название репозитория
    // проще всего эту ссылку взять в созданном репозитории github
    git remote add origin <https://github.com/[user-name]/[repo-name].git>
    git branch -M main
    git add .
    // название коммита выбираем на свой вкус
    git commit -m 'initial project'
    git push -u origin main

    Если нигде не промазали, то в терминале должны увидеть что-то вроде этого:

    Enumerating objects: 26, done.
    Counting objects: 100% (26/26), done.
    Delta compression using up to 8 threads
    Compressing objects: 100% (22/22), done.
    Writing objects: 100% (26/26), 73.76 KiB | 8.20 MiB/s, done.
    Total 26 (delta 0), reused 0 (delta 0), pack-reused 0
    To <https://github.com/[user-name]/[repo-name].git>
     * [new branch]      main -> main
    branch 'main' set up to track 'origin/main'.

    А на странице https://github.com/[user-name]/[repo-name] должны появиться файлы, например такие:

    Теперь после внесения изменений в код мы можем отправлять эти изменения в репозиторий:

    git add .
    git commit -m 'feat: add feature'
    git push

Деплой на Vercel

  • Регистрируемся на vercel.com

  • Находим кнопку Add newProject

  • В блоке Import Git Repository нажимаем Adjust GitHub App Permissions

  • В открывшемся окне вводим пароль от github аккаунта для подтверждения доступа

  • После аутентификации в этом же окне откроются настройки доступа приложений

  • Видим Vercel, листаем вниз до Repository access, выбираем Only select repositories

  • Нажимаем Select repositories, находим нужный репозиторий и нажимаем Save

  • Как только увидели надпись GitHub Installation Completed - закрываем окно. Или не закрываем и оно само закроется

  • Возвращаемся на страницу vercel.com/new, напротив появившегося репозитория нажимаем Import

  • В блоке Configure Project нажимаем Deploy и ждём.

  • В течении минуты приложение собирается, если всё ок - появляется надпись Congratulations!

Далее можно перейти по ссылке и откроется страница с приложением, доступная всем пользователям интернета.

Предпросмотр изменений (preview, pre-deploy)

Магия на этом не заканчивается 🙂

  • Идём в репозиторий на github и создаём новую ветку от main, например, dev

  • В терминале IDE переключаемся на созданную ветку, вносим изменения в код, пушим их в гитхаб.

  • Создаём PR (Pull request → New pull request, выбираем base: main ← compare: dev и нажимаем Create pull request)

  • На странице Open a pull request вводим название, описание (опционально) и нажимаем Create pull request

  • После создания пулл реквеста запускается vercel bot, собирает новую версию приложения с учётом всех коммитов пулл реквеста

  • Если в процессе сборки возникли какие-то ошибки, мы увидим сообщение об этом

    Давайте сломаем GraphQL запрос - случайно удалим slug:

    Отправим изменение в репозиторий (git add, git commit, git push) и сделаем PR:

    Мы увидим ошибку, а в Details можно посмотреть логи сборки.

    Устраняем ошибку, пушим новый коммит и внутри этого же PR видим как vercel bot пересобрал preview с новым коммитом и теперь галочки зелёные и есть ссылка на preview деплоя:

  • Теперь можем открыть preview, выполнить ручное тестирование, и если всё ок - Merge pull request

  • Если что-то не так, можно внести изменения в код, запушить новый коммит и в этом же PR посмотреть собрался ли новый билд и как выглядит preview

  • И так можно много раз

  • После Merge pull request приложение по основному адресу получит все обновления пулл реквеста

Итого

Плюсы и минусы

Преимущества:

  • очень быстрый сайт в сравнении с оригиналом на WordPress - страницы открываются мгновенно благодаря ISR Next.js

  • верстку через Next.js и компоненты React.js делать сильно удобней, приятней и эффективней, чем разбираться с PHP

  • SEO поддержка сильно лучше чем у чистого React.js

  • современно, модно, молодежно

Недостатки

  • затраты на разработку такого сайта заметно выше чем у простого WordPress с готовыми темами

  • в WordPress с Gutenberg мы можем поменять контент в реальном времени, без кода и программистов, тут же мы чаще упираемся в код и программистов - что для некоторых проектов может быть критично

Послесловие

В планах есть подготовить вторую часть, если тематика окажется интересной сообществу. В ней рассмотрим авторизацию, мутирующие методы (создание, редактирование и удаление постов), комментарии и социальность, проброс стилей Gutenberg.

Пожалуйста, пишите в комментариях что можно улучшить, какие аспекты рассмотреть, возникшие вопросы и т.п. Учитывая, что у нас есть две версии сайта - можем провести замеры по производительности, если более опытные товарищи подскажут методику и/или инструментарий.

Код можно использовать в качестве шаблона для своих экспериментов, можно создавать issue с запросами на изменения и добавление функционала.

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

Tags:
Hubs:
Total votes 10: ↑9 and ↓1+8
Comments4

Articles