Ускоряем WordPress до максимума с использованием NextJS, ISR & JAMStack

На сегодня есть всего 1 технология которая позволяет делать супер быстрые сайты – это ISR. Она базируется на JAMStack. Давайте разбираться как это работает с NextJS (ReactJS) …

NextJS – современный фреймворк на базе ReactJS, который значительно набирает обороты среди разработчиков.

На его базе делают сайты разные современные команды типа Telegram, TON, Netflix, Disney …

Кроме того на его базе делают также различные сервисы с достаточно сложным функционалом типа Ads VK, Vercel Hosting, ProductHunt …

Все это было очень интересно и мы решили поэксперементировать с NextJS…

Demo + Code

Вы удивитесь как это быстро )

TL DR

  • собираем бэкенд на базе WordPress & Graph QL
  • собираем фронтенд на базе NextJS & ReactJS
  • деплой на Vercel
  • наслаждаемся современным деплоем в GitHub PRs
  • нюансы и специфика
  • идеи на будущее – если хватит сил

MVP подход

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

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

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

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

Бэкенд: WordPress + GraphQL

WordPress возможно самый простой способ сборки бэкенда. В нашем случае это заняло 15-30 минут. Сайт уже был. Просто поставили плагин GraphQL. И погнали…

Конфигурация сайта

Рассказывать про то как делать сайты на WordPress думаю тут не стоит. Это сегодня умеют делать почти все.

В общем при сборке сайта с нуля шаги такие:

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

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

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

  • зашли в админку, в раздел плагинов
  • нашли плагин WPGraphQL – установили
  • далее раздали доступ фронтам к билдеру запросов

Когда я про это рассказывал “программистам” – они спрашивали сколько это месяцев заняло? И они очень удивлялись узнав что на WordPress это занимает 5 минут )

Фронтенд: 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) и в правой секции видим результат запроса:

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

Если не указать параметр 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 укажем наш ендпоинт, 
  // который мы определили в настройках 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('<https://wpcraft.ru/graphql>', {
    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, CMS вернёт нам 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 рассматривать не будем.

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.

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

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

// wp-api.ts

export async function getPostBySlug(slug: string) {
  const data = await fetchData(`
  query getPostBySlug {
    post(
      id: "${slug}",
      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;
  }
...

Импортируем стили

// [slug].tsx

import styles from './slug.module.scss';

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

// [slug].tsx

...

<div 
	className={styles.content}
  dangerouslySetInnerHTML={{__html: post.content}} 
/>

...

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

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

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

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
    }
  }
}

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

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

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

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

Используем связку GitHub + 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 new → Project
  • В блоке 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:

Отправим изменение в репозиторий и сделаем PR:

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

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

Итого

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

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

  • очень быстрый сайт в сравнении с оригиналом на WordPress – страницы открываются мгновенно благодаря ISR NextJS
  • верстку через NextJS и компоненты ReactJS делать сильно удобней, приятней и эффективней, чем разбираться с PHP
  • SEO поддержка сильно лучше чем у чистого ReactJS
  • модно молодежно

Недостатки

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

Идеи на будущее

В данном проекте рассмотрели простые кейсы типа разработка лендинга и блога.

Но возможности NextJS сильно шире и потому в перспективе есть мысли попробовать что то посложнее:

  • улучшить интеграцию NextJS & Gutenberg (конструктор блоков WordPress на базе ReactJS), чтобы обеспечить изменения контента в реальном времени, без пересборки приложения
  • реализовать интернет магазин
  • сделать веб приложение или что то похожее на сервис с достаточно сложным интерфейсом
Фото аватара
Владимир

Опытный разработчик сайтов на базе JAMStack.

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

Также обладает навыками в области верстки (JavaScript, HTML и CSS) и оптимизации сайтов для улучшения пользовательского опыта.

Статей: 3

комментариев 6

  1. Да, обидно. Но можно описать на какой шаге есть проблема и что не понятно. Попробую детальней описать.

  2. Для полной картины не хватает рассмотрения Gatsby и сравнения его с Next.js – когда стоит тот или тот использовать и в чем разница. Оба React, оба актуальны и оба хорошо в связке с WordPress, но есть нюансы.

    • Не стоит трогать трупы. Я тоже изучал GatsbyJS лет 5 назад. Сегодня все что я могу сказать про него – это труп. Мертвая технология. Зачем ее ковырять?

  3. Добрый день, уперся в то что надо передавать стили/скрипты блоков gutenberg конкретного поста на фронт next js’a, может вы в курсе есть ли какое то готовое решение? для wp graphql я имею ввиду

    • готовых решений нет, но в нашем репо есть наметки

      в целом это сложная задача и надо думать )

Ответить

Ваш адрес email не будет опубликован. Обязательные поля помечены *