Привет, хабр! Меня зовут Фёдор и я фронтенд-разработчик в KTS.
В начале 2017 года в KTS обратился давний друг компании Дмитрий Волошин с запросом сделать платформу для онлайн-образования Otus. Сейчас Otus довольно успешный и известный проект, набравший уже десятки тысяч учеников. А тогда он только начинался и состоял всего из одного курса Java разработчика, но планы уже были наполеоновские.
От нас требовалось как можно быстрее собрать портал, на котором можно было бы посмотреть информацию о курсах. В то время современные фронтенд фреймворки и библиотеки были мало распространены, а сделать MVP нужно было как можно быстрее. Поэтому мы решили использовать старую добрую Django с ее мощной админкой. Проект состоял из нескольких страниц, контент которых можно было редактировать внутри админки, а шаблоны джанги, отрендеренные на сервере, отвечали требованиям поисковых оптимизаций.
Но время шло, Otus вырос до гигантских масштабов с кучей внутренних заказчиков: от преподавателей и продюсеров курсов до маркетологов, которым нужна конфигурируемая сквозная аналитика.
Функционально проект состоит из двух модулей: личный кабинет и информационные страницы. В личном кабинете студент и преподаватель могут посмотреть расписание курсов, сдать или проверить домашнее задание, получить консультацию в специальном чате и многое другое.
Информационные страницы - это в основном лендинги курсов и рекламных кампаний, информация о преподавателях и о самом Отусе. Эти страницы важно продвигать в поисковиках.
Проект был реализован на Python + Django, а фронт писался на vanilla js + jquery, которые были распространены в то время. Со временем наша команда добавила на проект множество разных сервисов и технологий: некоторые микросервисы мы писали на Go, на фронт частично внедряли React, когда это уже стало стандартом для всей компании. Но проект разрастался вместе с самим Отусом и в конечном итоге превратился в космический корабль с зоопарком технологий, часть из которых уже потеряла актуальность.
Поэтому мы решили переработать архитектуру и переписать сервис. В этой статье я покажу, как засетапить монорепозиторий на React для разных модулей проекта на примере Otus. Надеюсь, что туториал будет полезен как начинающим, так и уже опытным разработчикам.
Ну что, поехали!
Подготовка
В проекте мы выделили 2 независимых раздела. Информационные страницы должны рендериться на сервере, чтобы обеспечить поисковую индексацию, поэтому предстояло выбрать фреймворк для SSR или сделать собственную сборку. Серверный рендеринг страниц личного кабинета (ЛК) необязателен, поэтому ЛК решили сделать в формате стандартного SPA. При этом важно вынести общие компоненты и логику для переиспользования в двух частях проекта.
Как можно организовать такой проект:
Разделить проект на три репозитория: shared - общий UI-компоненты, сторы с логикой; internal - личный кабинет; external - информационные лендинги. Этот вариант полностью соответствует требованиям, но в данном случае возникает проблема поддержки трех независимых репозиториев, из-за чего может замедлиться разработка.
Хранить все пакеты в одном монорепозитории. Этот вариант чуть сложнее в настройке, но зато на выходе мы получаем один репозиторий и можем быстро обновлять его подпроекты, чтобы эффективнее разрабатывать функционал.
В этом туториале я расскажу про сетап такого монорепозитория. Мы рассмотрим:
Роутинг внутри разделов и между ними
Работу с состоянием приложения
Организацию продакшна, которая бы позволяла внедрять переписанные страницы постепенно
Инструмент для управления монорепозиторием
В современном javascript-мире можно выделить два основных инструмента для управления монорепозиторием: lerna, yarn workspaces. Также их можно использовать вместе.
Lerna осуществляет управление с помощью npm или yarn и имеет большое количество утилит для удобной публикации, версионирования и запуска проектов. Yarn workspaces появился позднее, чем lerna, и реализует полный функционал управления несколькими пакетами и их зависимостями.
Мы будем использовать эти инструменты вместе, в этом случае управление зависимостями осуществляется yarn workspaces, и мы получаем удобный интерфейс запуска, публикации, версионирования пакетов от lerna, который понадобится в дальнейшей работе.
Скрытый текст
lerna.json:
{
"packages": [
"apps/*"
],
"version": "1.0.0",
"npmClient": "yarn",
"useWorkspaces": true
}
Корневой package.json:
{
"name": "otus",
"version": "1.0.0",
"workspaces": [
"apps/*"
],
"private": true,
"devDependencies": {
"lerna": "^3.22.1"
}
}
package.json отдельного пакета:
{
"name": "@otus/external",
"version": "1.0.0",
"private": true
}
Начальный сетап internal-пакета
Internal-пакет отвечает за личный кабинет и реализуется в формате SPA.
Для сборки и запуска мы использовали стандартные webpack + babel. Подробно останавливаться на настройке не будем, но если интересно, базовые конфиги приведены ниже.
Скрытый текст
В webpack.config.js подключаем babel-loader для транспиляции javascript и конфигурируем dev-сервер.
internal/webpack.config.js
module.exports = (opts, args) => {
return {
entry: './src/index.jsx',
output: {
path: buildPath,
filename: `js/[name]-[hash].js`,
publicPath: '/',
},
module: {
rules: [
{
test: /\\.jsx?$/,
exclude: /node_modules/,
loader: ‘babel-loader’
},
],
},
devServer: {
port: 9002,
host: 'localhost',
...
},
};
...
};
};
Для минимальной конфигурации babel достаточно подключить @babel/preset-env для удобной транспиляции в зависимости от targets и @babel/preset-react для парсинга jsx.
internal/babel.config.js:
module.exports = api => {
api.cache(() => process.env.NODE_ENV);
return {
presets: [
[
require('@babel/preset-env'),
{
targets: {
browsers: ['> 0.25%, not dead']
}
}
],
require('@babel/preset-react'),
],
};
};
Указываем команду для запуска dev-сервера в package.json
internal/package.json:
{
"scripts": {
"dev": "webpack serve --mode development",
},
...
}
External-пакет. Инструмент для серверного рендеринга
В external-пакете у нас будут информационные лендинги и страницы, которые должны индексироваться поисковиками. Поэтому рендерить контент нужно на сервере. В нашей компании для реализации серверного рендеринга React-приложений мы использовали как готовые решения (Gatsby, Next.js), так и самописную сборку с сервером для рендеринга на Node.js.
Gatsby
Из плюсов можно выделить работу c GraphQL "из коробки". Подходит для Static Site Generation (SSG). Кастомизировать сборку по ощущениям не очень удобно. Как показала практика, Gatsby удобно использовать при разработке небольших проектов, состоящих из статичного контента, например, лендингов.
Next.js
Умеет работать как в SSR, так и в SSG режиме. Поддерживает "из коробки" typescript, css-modules, можно использовать как api-сервер. По ощущениям, кастомизировать сборку проще, чем в Gatsby.
Собственная сборка под SSR с сервером на Node.js
Данное решение очень хорошо зарекомендовало себя в нашей компании, так как не приходится подстраиваться под жесткую архитектуру фреймворка и отсутствует проблема кастомизации настроек: исходя из задачи, мы сами выбираем и подключаем дополнительные инструменты. Кстати, скоро мы собираемся выложить статью про такую сборку.
Если бы мы реализовывали более сложную логику, то скорее всего остановились бы на этом варианте, но в данной ситуации мы решили, что для рендеринга информационных лендингов нам будет достаточно Next.js.
Теперь наш репозиторий выглядит следующим образом:
package.json external-пакета:
{
"scripts": {
"dev": "next dev -p 9001"
}
...
}
Скрипт запуска двух dev-серверов реализуем с помощью lerna:
{
"scripts": {
"dev": "lerna run --parallel dev"
},
...
}
Внедрение typescript
В internal-пакете компиляцию typescript будет выполнять babel. Это позволяет использовать babel-плагины вместе с ts. В Next.js typescript поддерживается "из коробки" и также компилируется с помощью babel.
Для монорепозитория необходимо вынести общие правила в отдельный конфиг tsconfig.base.json. Он должен лежать в корне проекта и называться не tsconfig.json, иначе typescript будет определять корневую директорию как typescript-проект. Каждый пакет должен содержать свой tsconfig.json, который наследует общий конфиг и дополняется специфичными правилами пакета.
Внедрение в internal
Добавляем расширение .ts/.tsx в правило babel-loader:
{
test: /\\.(ts|js)x?$/,
exclude: /node_modules/,
loader: 'babel-loader'
}
Добавляем новый пресет в babel.config.js:
module.exports = api => {
...
return {
presets: [
...
require('@babel/preset-typescript'),
],
};
};
Конфигурация
Корневой конфиг tsconfig.base.json:
{
"compilerOptions": {
"typeRoots": ["./node_modules/@types"],
...
}
}
Конфиг в internal:
{
"extends": "../../tsconfig.base.json",
"include": ["./src/**/*"],
"exclude": ["node_modules"]
}
При создании tsconfig.json внутри external, Next.js автоматически добавит next-env.d.ts файл в корень пакета, и также добавит соответствующую опцию в tsconfig.json.
Конфиг в external:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {...},
"include": [
"next-env.d.ts",
...
],
"exclude": [
"node_modules"
]
}
Подключаем eslint
Подключение eslint похоже на подключение typescript: создаем родительский конфиг, и затем наследуем его в каждом пакете.
При работе в WebStorm важно учесть, что WebStorm запускает процессы eslint во всех пакетах, где eslint указан в зависимостях. Поэтому все пакеты, в которых переопределен конфиг eslint-а должны иметь зависимость на него в package.json, а в корневом package.json этой зависимости быть не должно.
Скрытый текст
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
env: {
browser: true,
es6: true
},
extends: [
'eslint:recommended',
'prettier',
'prettier/react',
'plugin:import/errors',
'plugin:import/warnings',
'plugin:import/typescript',
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended'
],
parserOptions: {
ecmaFeatures: {
jsx: true
},
ecmaVersion: 2018,
sourceType: 'module',
project: './apps/**/tsconfig.json'
},
plugins: [...],
rules: {...},
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx']
},
'import/resolver': {
"typescript": {
"project": "tsconfig.json"
},
},
}
};
.eslintrc.js каждого пакета:
const path = require('path');
module.exports = {
extends: path.resolve('../../.eslintrc.js'),
...
};
Добавляем удобные импорты
Наша цель - удобно импортировать модули из разных пакетов. Например:
import Button from 'shared/components/Button';
будет импортировать кнопку из общего UIKit-а. Для этого нужно подключить алиасы.
Aliases в internal
Aliases в internal подключаем с помощью webpack. Это гарантирует правильную сборку проекта. Для корректной работы eslint применяется eslint-import-resolver-typescript, в каждом tsconfig.json пакета необходимо указать paths, которые будут использоваться eslint-ом в рамках данного пакета.
webpack.config.js:
module.exports = (opts, args) => {
return {
...
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx'],
alias: {
shared: path.join(appsPath, 'shared/src'),
}
},
...
};
};
tsconfig.base.json:
{
"compilerOptions": {
"baseUrl": "apps",
"paths": {
"shared/*": ["shared/src/*"],
"internal/*": ["internal/src/*"],
"external/*": ["external/src/*"]
},
...
}
}
Теперь в рамках internal мы можем использовать алиасы.
import * as React from 'react';
import { render } from 'react-dom';
import Button from 'shared/components/Button'; <--
render(
<div>
<Button />
</div>,
document.getElementById('root')
);
Aliases в external
Структура external-пакета:
Для использования aliases в external необходимо прописать paths в tsconfig.json.
При использовании alias на внутреннюю директорию или файл external-пакета проблем не возникает. Но если alias ведет на внешний пакет (в нашем случае на shared), то необходимо воспользоваться next-transpile-modules, с помощью него можно указать, какие еще пакеты Next.js должен транспилировать.
Для подключения необходимо создать конфиг next.config.js.
tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": "..",
"paths": {
"shared/*": ["shared/src/*"],
"components/*": ["external/components/*"]
},
...
}
next.config.js:
const withPlugins = require("next-compose-plugins");
const withTM = require('next-transpile-modules')(['shared']);
const plugins = [
[withTM],
];
module.exports = withPlugins(plugins);
Варианты подключения стилей в проект
Для работы со стилями мы используем scss. Необходимо было выбрать инструмент из следующих:
CSS-Модули (css-modules). В Next.js такой вариант поддерживается из коробки, необходимо просто именовать файлы в формате [name].module.css. Для internal-пакета такая настройка дублируется добавлением лоадера на /\.module\.s?css/
Подход react-css-modules с использованием стилей через атрибут styleName="style" подключается в Next.js "костыльно", да и к тому же пока не поддерживает Postcss 8, и приходится использовать более старые пакеты postcss-nested, postcss-scss.
Использование styled-components. Этот вариант не требует никаких дополнительных настроек сборки в internal-пакете, а для SSR необходимо установить styled-components плагин в babel и прокинуть стили в момент серверного рендеринга в тег head страницы.
Мы решили использовать styled-components. На наш взгляд css-in-js концепция очень удобна, требует минимальной конфигурации и увеличивает скорость разработки.
Для подключения styled-components в internal достаточно установить библиотеку.
При добавление в external необходимо:
Установить библиотеку
Создать кастомный babel.config.js
Подключить babel-плагин для обеспечения совпадения className в момент серверного рендеринга и rehydration.
В next.config.js подключить кастомный babel.config.js с помощью next-plugin-custom-babel-config
Переопределить Document-компонент для вставки сгенерированных style-тегов в тег head в момент рендеринга на сервере.
Скрытый текст
babel-config.js external пакета:
module.exports =function(api) {
api.cache(() => process.env.NODE_ENV);
const presets = ['next/babel'];
const plugins = [
[
'babel-plugin-styled-components',
{
'ssr':true,
'displayName':true,
}
]
];
return{
presets,
plugins
};
};
next.config.js:
const withPlugins= require('next-compose-plugins');
const withTM = require('next-transpile-modules')(['shared']);
const withCustomBabelConfig= require('next-plugin-custom-babel-config');
const path = require('path');
const plugins = [
[
withCustomBabelConfig,
{ babelConfigFile: path.resolve('./babel.config.js') },
],
[withTM],
];
module.exports = withPlugins(plugins);
_document.tsx:
import Document,
{
Head,
Main,
NextScript,
DocumentContext,
DocumentProps,
Html,
} from 'next/document';
import * as React from 'react';
import { ServerStyleSheet } from 'styled-components';
class MyDocument extends Document<DocumentProps & { styleTags:Array<React.ReactElement> }
> {
static async getInitialProps(ctx: DocumentContext) {
const initialProps = await Document.getInitialProps(ctx);
const sheet = new ServerStyleSheet();
const page = ctx.renderPage((App) => (props) =>
sheet.collectStyles(<App {...props} />)
);
const styleTags = sheet.getStyleElement();
return { ...initialProps, ...page, styleTags };
}
render() {
return(
<Html>
<Head>{this.props.styleTags}</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}
export default MyDocument;
Итоги
В итоге мы сделали функционирующий сетап:
подключили lerna + yarn workspaces для управления нашим монорепозиторием;
внедрили и, исходя из наших задач, кастомизировали Next.js;
подключили typescript;
наладили работу импортов вместе с eslint;
внедрили styled-components
Если данный материал вам показался интересным, в следующей статье я расскажу о том, как мы добавляли роутинг внутри каждого модуля и между модулями, подключали стейт-менеджер и опишу схему работы приложения в проде.
Весь код можно посмотреть здесь.
Другие статьи про frontend для начинающих:
Как работают браузеры: навигация и получение данных, парсинг и выполнение JS, деревья спец возможностей и рендеринга
Другие статьи про frontend для продвинутых: