Блог на Next.js со статьями в виде компонентов

Первый коммит в свой блог я сделал 8 января 2020 года. Тогда я продумал стек и начальную архитектуру. Основной выбор заключался в том, брать ли за основу какой-то готовый генератор статики, вроде 11ty, Gatsby, или же соорудить свой велосипед. Вторая задача, которую нужно было решить - хочу ли я писать посты на модном стеке markdown в сочетании с remark/rehype плагинами, либо же на стандартных react компонентах? Тогда я решил, что делаю блог в первую очередь не для постов, а для того, чтобы пробовать на нём новые технологии\подходы, а для этого мне нужен полный контроль. А так как это всего-лишь платформа для публикаций и, иногда, каких-то простых статических страниц, то я решил взять за основу экосистему Next.

Почему next, а не голый react или хотя бы angular?

Next создавался, как фреймворк, который позволяет писать страницы сайта, как reactкомпоненты и сразу видеть результат без какого-то громоздкого бойлерплейта. То есть, буквально создаёшь файл страницы и уже можешь видеть её в браузере, не заботясь о реализации роутинга, раздачи статики, оптимизации сборки и т.д. Более того, команда google активно контрибутит в next в направлении web vitals, что способствует лучшему SEO. Сам же react симпатизирует мне за простоту написания и сопровождения кода. Таким образом, базовый стек нашёл себя быстро.

Реализация

Пришло время реализации. Я хотел, чтобы мои посты лежали в отдельной папке и имели какой-то общий паттерн оформления (как на файловой системе, так и внешне). Здесь ничего сложного мудрить я не стал:

Файловая структура статей

Компонент Intro - общий для index (полная статья) и preview (та часть статьи, которая выводится в списке). Сделал так, чтобы не дублировать один и тот же текст в обоих файлах.

У next есть крутая возможность рендерить страницы в момент сборки, а не во время запроса страницы пользователем. Это позволяет экономить на вычислительных и временных ресурсах. Для этого необходимо внутри компонента страницы объявить и экспортировать функцию, которая вернёт набор пропсов, с которыми может рендерится эта страница. Условно говоря:

export async function getStaticPaths() {
  return {
    paths: [
      { params: { slug: 'post1' } },
      { params: { slug: 'post2' } },
      { params: { slug: 'post3' } },
    ],
  };
}

То есть ещё до того, как пользователь зайдёт на страницу https://domain.com/post1, next уже отрендерит её в момент сборки и просто отдаст готовый результат. В этом и есть сила статических генераторов сайтов. Только next является более низкоуровневым инструментом в этом плане, который позволяет в том числе написать свой 11ty или Gatsby.

А т.к. набор статей у нас известен к моменту сборки (мы можем прочитать их с файловой системы), то легко можно заранее собрать все страницы со статьями:

export const getStaticPaths = () => {
  const postRepository = new PostRepository();
  const slugs = postRepository.findAllSlugs();
  const paths = slugs.map(slug => ({
    params: {
      slug,
    },
  }));

  return {
    fallback: false,
    paths,
  };
};

И всё работает. Точнее, работало. А потом сломалось. А потом выяснилось, что когда оно сломалось, то это и был правильный вариант работы, задуманный разработчиком 😂. То есть, получилось так, что моя изначальная реализация работала отлично - я мог открыть статью из списка, вернуться назад на список и открыть новую статью. Но после одного из обновлений фреймворка всё пошло не по плану. И в этой цепочке действий стал происходить сбой, когда после возврата в список, я хотел открыть новую статью. Со стороны клиента никаких ошибок не было, просто не происходил переход на страницу, хоть url и менялся. И хуже всего то, что я это заметил только спустя несколько месяцев. В целом, не страшно, т.к. фикс ничего не стоил. Просто я долго не замечал проблему.

Сама же статья рендерится немного хитрее. Проблема в том, что пропсы должны быть сериализуемы между клиентом и сервером, а это значит, что они не могут быть функциями или цикличными объектами. А т.к. в моём случае статья является компонентом (функцией), то пришлось думать, как это реализовать. Если бы я выбрал путь написания статей вmarkdown, то легко мог бы читать файл на сервере, конвертировать его в html, отдавать в пропсы страницы и выводить в какой-нибудь div через dangerouslySetInnerHTML.

Что мешает сделать так в случае, когда статья является компонентом? В принципе, ничего. ReactDOMServer.renderToStaticMarkup(element) позволяет отрендерить компонент в html строку. Годится? Нет. Почему? Одним из моих аргументов при выборе написания статьи в стиле компонентов был полный контроль. Это значит, что я могу захотеть добавить в какую-нибудь статью, допустим, счётчик дней до нового года или какой-нибудь интерактив. А это значит, что... он не будет работать. Любой useEffect или componentDidUpdate не отработают на клиенте, потому что компонент статьи вставляется, как html. Беда.

Next не поддерживает частичную гидрацию и мне не хватило мозгов, как решить эту проблему. Но! Я с ней жил долгое время, т.к. опять же не сразу её заметил. В моих постах не было интерактива до тех пор, пока я не добавил самописный компонент для ленивой загрузки изображений. Там использовался useEffect, чтобы манипулировать свойством src. И до меня очень долго доходило, почему он не отрабатывал на клиенте.

"Nextjs blog with components"

Гугл в этом вопросе ничем не помог и тогда я расширил запрос до примерно "Nextjs blog with components". Звучит странно, но мне хотелось найти примеры блога, где статьи были бы написаны на компонентах, а не в markdown. Попробуйте сами и вы тоже ничего не найдёте. Но мне попалась на глаза интересная библиотека mdx. Это как jsx, только для markdown. То есть она позволяет миксовать синтаксис компонентов и markdown:

import LazyImage from 'Components/LazyImage';

## Мой первый пост с картинкой

<LazyImage src="https://picsum.photos/200/300" width="300" height="200" />

Клёво, правда? В ней ещё куча всяких крутых штук, типа поддержки плагинов remark/rehype, удобная работа с кастомным маркдаун синтаксисом и даже можно задавать, какие компоненты будут рендерится для стандартного синтаксиса. Мой мозг взорвался от этой идеи и я кинулся переписывать свои статьи, миксуя оба синтаксиса. Мне удалось избавиться от унылого оформления статьи с оборачиванием текста в примитивные теги. Было:

<h2>Заголовок</h2>
<p>
  Какой-то текст, ещё текст с <code>кодом</code>
</p>

Стало:

## Заголовок

Какой-то текст, ещё текст с `кодом`

Годится? Нет. Почему? Мне нравится работать в WebStorm и у него даже есть плагин для работы с mdx, который поддерживает все фишки из-за которых мы любим WebStorm. mdx позволяет внутри файлов делать импорт не только компонентов, но и других документов. Но! Автокомплит для документов как раз и не работает в WebStorm 😭. И это просто фаталити. Я знаю, что с постами работать приходится чаще всего и такое ограничение является большим препятствием при работе с mdx. Поэтому, пришлось отказать и от этой реализации.

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

require.context to the rescue!

Пока я гуглил примеры с реализациями блога на next, заметил, что один программист не передаёт контент через пропсы в страницу. А использует напрямую require для рендера самой статьи и require.context - для списка статей. И самое прекрасное, что это работает, как на сервере, так и на клиенте.

Например, так я получаю список имён всех статей в моём блоге:

export const getAllSlugs = () => {
  const context = require.context('Content/posts/', true, /index\.js$/);

  return context.keys().map(fileName => (
    fileName.substr(2).replace(/\/index\.js$/, '')
  ));
};

А вот так я формирую массив из объектов статей и их компонентов:

export const getAllPosts = () => {
  const posts = getFormattedPosts();

  return posts.map(post => ({
    Component: getPostOrPreviewComponent(post.slug),
    post,
  }));
};

И затем вывожу их прямо на самой странице, минуя передачу через пропсы:

const PostsPage = () => (
  <Layout pageTitle="Почитать">
    {getAllPosts().map(postData => (
      <BlogPostPreview key={postData.post.slug} post={postData.post}>
        <postData.Component />
      </BlogPostPreview>
    ))}
  </Layout>
);

Страница самой статьи ничем не сложнее списка:

const PostPage = ({ slug }) => {
  const postData = getPost(slug);

  return (
    <Layout pageTitle="Статья">
      <BlogPost post={postData.post}>
        <postData.Component />
      </BlogPost>
    </Layout>
  );
};

export const getStaticPaths = () => {
  const slugs = getAllSlugs();
  const paths = slugs.map(slug => ({
    params: {
      slug,
    },
  }));

  return {
    fallback: false,
    paths,
  };
};

export const getStaticProps = ({ params: { slug } }) => ({
  props: {
    slug,
  },
});

Годится? Конечно! Таким образом, у меня получилось сохранить возможность делать контент интерактивным, а сами страницы по-прежнему генерируются в момент сборки.

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