Блог на 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,
},
});
Годится? Конечно! Таким образом, у меня получилось сохранить возможность делать контент интерактивным, а сами страницы по-прежнему генерируются в момент сборки.
В этой статье не так много конкретных деталей реализации, а, скорее, решение той боли, которая меня преследовала с момента программирования этого блога. Поэтому, если у вас остались какие-то вопросы, можете смело писать мне на почту.