Конкурс инструкций

Создание и настройка проекта React + Webpack с нуля до SSR

Хочу вам показать наглядный пример и инструкцию того, как можно самому с «чистого листа» сконфигурировать Webpack для React и Server Side Render'а без каких-либо бойлерплейтов, вроде create-react-app.

Некоторое время назад я начал заниматься созданием сайтов на сей чудесном view-фреймворке и по ходу дела сталкивался со многими проблемами, начиная от того, как правильно и практично реализовать конфиг Webpack'а и как готовить SSR совместно с TypeScript. Было прочитано кучу мануалов, пользовательских решений в гитхабе и прочего, что новичка (да даже иногда и опытных разработчиков) может ввести в ступор. Конечно, можно использовать create-react-app и потом «костылить» eject'ы (расширение базовой конфигурации), обмазывая все готовыми плагинами, но ведь мы хотим держать весь проект под своим видением, не так ли? Да и просто будет полезно понять весь принцип «приготовления» приложения «от» и «до».

Маленькое предисловие:

1) На клиенте используем Node JS 13й версии. К сожалению, на момент написания статьи (сентябрь 2020 г.) на >=14 версии не работает Webpack Dev Server (далее WDS) (никак не обновят в нем Chokidar).

2) В проекте будем использовать: Webpack, TypeScript (далее TS), React, React Router, Stylus, ExpressJS.

3) Постараюсь описать каждый установленный npm-пакет и мало мальски важные, на мой взгляд, параметры в Webpack'е.

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

Проект основан почти на «голом» React'е, без всяких Redux'ов, MobX'ов, Helmet'ов и тэдэ. Я хочу продемонстрировать принцип работы сборки и запуска проекта на сервере. Все остальное, надеюсь, сможете отредактировать в дальнейшем под себя.

Итак, начнем с инита npm:

npm init -y

Можете и без ключа yes, чтобы предварительно занести нужную информацию в package.json.

Установим все необходимое для Webpack'a и чтобы он мог собирать TS:

npm i webpack webpack-cli webpack-dev-server webpack-notifier typescript ts-loader clean-webpack-plugin uglifyjs-webpack-plugin --save-dev
  • webpack — сборщик собственной персоной
  • webpack-cli — позволяет работать с Webpack'ом из консоли
  • webpack-dev-server — небольшой сервер для разработки
  • webpack-notifier — будет показывать нам уведомления, если вдруг что-то где-то сломаем
  • typescript — компилятор TS-кода
  • ts-loader — инжектор TS'а в Webpack
  • clean-webpack-plugin — при каждой сборке автоматически очищает папку для итоговых файлов
  • uglifyjs-webpack-plugin — для продовой версии будет сжимать и минифицировать JS-файлы

Для тех, кто не так давно работает с npm: с параметром --save-dev устанавливаем пакеты, которые нам нужны именно для разработки (webpack, typescript, например), с --save пакеты которые пойдут в сборку (react, react-dom, fontawesome).

Установим React с роутером:

npm i react react-dom react-router-dom --save
  • react — view-фреймворк
  • react-dom — плагин, который будет конвертировать React DOM в html-структуру
  • react-router-dom — роутер, который интегрируется через React DOM

Для TS нам надо установить типизацию React, чтобы компилятор понимал с какими данными работает и было удобнее писать код в редакторе:

npm i @types/react @types/react-dom @types/react-router-dom --save-dev

Устанавливаем CSS-препроцессор (я использую Stylus), с которым будем взаимодействовать и пара плагинов, которые наши стили «доведут до ума» перед продом:

npm i stylus stylus-loader css-loader style-loader [email protected] postcss-csso postcss-loader --save-dev
  • stylus — препроцессор
  • stylus-loader — инжектор Stylus'а в Webpack
  • css-loader — позволит нам работать в стилях с @import как import/require в JS
  • style-loader — встраивает наши стили в DOM
  • autoprefixer@9.8.6 — в релизной сборке будет добавлять префиксы к некоторым стилям (например, -webkit-transition). 10я версия в данном кейсе пока что не работает.
  • postcss-csso — очень крутой плагин, который минифицирует CSS
  • postcss-loader — инжектор PostCSS'а в Webpack

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

Создадим в корне проекта папку src со всеми исходниками:

|--src |--App // Энтрипоинт нашего приложения >--index.styl >--index.tsx |--Common // Директория для вспомогательных элементов, сюда же можно в дальнейшем и изображения класть, например |--Styles >--reset.styl // Сброс дефолтных стилей браузера >--variables.styl // Переменные для стилей |--Html >--Browser.html // Html-шаблон для разработки, в который будут подключаться наши итоговые JS-файлы |--Pages // Страницы нашего приложения |--Content >--index.styl >--index.tsx |--Home >--index.styl >--index.tsx >--Routes.ts // Конфигурация роутинга >--Client.tsx // Рендерим энтрипоинт в HTML-документ

Настраиваем TS. Особо сильно в конфигурацию упарываться не будем, пока просто укажем стоковые параметры для компиляции React со строгими правилами. За всей документацией можно сходить на официальный сайт: https://www.typescriptlang.org/docs

В корне проекта создаем файл tsconfig.json:

{ "compilerOptions": { "target": "es5", "jsx": "react", "noEmitOnError": true, "noUnusedLocals": true, "removeComments": true, "noImplicitAny": true, "esModuleInterop": true, "baseUrl": "./", "paths": { // Указываем ссылки до директорий в проекте, чтобы каждый раз в импортах не писать относительные пути "App": ["src/App"], "Pages/*": ["src/Pages/*"], } }, "exclude": [ "node_modules" ] }

Сделаем внешний конфиг-файл с модулями и правилами для Webpack. Внешний нужен для того, что мы будем использовать его для 2х конфиг-файлов Webpack'a: клиент и сервер, но это чуть позднее. Документация https://webpack.js.org/concepts/

В корне проекта создаем webpack.config.js:

const path = require("path") module.exports = (env) => { const modules = { js: { test: /\.ts(x?)$/, exclude: /node_modules/, use: [ { loader: "ts-loader", }, ], }, stylus: { test: /\.styl$/, use: [ { loader: "style-loader", }, { loader: "css-loader", }, { loader: "stylus-loader", options: { import: [ // Тут для Stylus'а можем объявить глобальные переменные или функции, чтобы каждый раз их не импортировать path.resolve(__dirname, 'src/Common/Styles/variables.styl'), ], } }, ], }, } if (env === 'production') { modules.stylus.use.splice(2, 0, { loader: "postcss-loader" }) } const resolve = { extensions: [".ts", ".tsx", ".js", ".jsx"], alias: { // Тут тот же момент, что и в tsconfig.json, чтобы Webpack смог понять ссылки на директории App: path.resolve(__dirname, 'src/App/'), Pages: path.resolve(__dirname, 'src/Pages/'), }, } return { modules, resolve, } }

Создаем в корне проекта файл клиентской конфигурации для Webpack'а webpack.client.js:

const path = require("path") const HtmlWebpackPlugin = require('html-webpack-plugin') const WebpackNotifierPlugin = require('webpack-notifier') const UglifyJsPlugin = require('uglifyjs-webpack-plugin') const { CleanWebpackPlugin } = require('clean-webpack-plugin') const webpackConfig = require('./webpack.config') module.exports = (env, argv) => { const watchMode = argv.liveReload || false const modeEnv = argv.mode || 'development' const isProd = modeEnv === 'production' const config = webpackConfig(modeEnv) const optimizations = { splitChunks: { // Чанки для нашего приложения. Все наши npm-пакеты вынесем в отдельный файл с определенным хешем, чтобы клиент каждый раз при изменениях не выкачивал все по-новой cacheGroups: { vendors: { name: 'vendors', test: /node_modules/, chunks: 'all', enforce: true, }, }, }, minimizer: [], } if (isProd) { optimizations.minimizer.push(new UglifyJsPlugin()) } return { devServer: { contentBase: path.join(__dirname, "dist"), compress: true, port: 4200, watchContentBase: true, progress: true, hot: true, open: true, historyApiFallback: true, // Не забудьте про этот параметр, ибо со значением false WDS будет «прямолинейно» обрабатывать ссылки для React Router'а. Т.е. он будет по путь->директория искать index.html, а он у нас один и в корне. }, resolve: config.resolve, module: { rules: [ config.modules.js, config.modules.stylus, ], }, plugins: [ new CleanWebpackPlugin(), new HtmlWebpackPlugin({ template: './src/Html/Browser.html', // Скармливаем наш HTML-темплейт }), new WebpackNotifierPlugin({ alwaysNotify: false }), ], entry: { main: './src/Client.tsx', // Энтрипоинт-файл, с которого и начнется наша сборка }, output: { filename: watchMode ? 'assets/[name].[hash].js' : 'assets/[name].[chunkhash].js', // небольшое условие, т.к. WDS не будет работать с chunkhash path: path.resolve(__dirname, 'dist'), // Весь наш результат складываем в папку dist publicPath: '/', }, performance: { hints: false, }, optimization: optimizations, } }

Осталось немного. Добавим в корень проекта конфиг PostCSS postcss.config.js:

module.exports = { plugins: [ require('autoprefixer'), require('postcss-csso'), ] }

И добавим скрипты для сборки в package.json с донастройкой PostCSS.

В секцию scripts добавляем команды:

"watch": "webpack-dev-server --config webpack.client.js --mode development" // Запуск нашего дев-сервера и пересборка при изменении файлов "build": "webpack --config webpack.client.js --mode production" // Билд проекта для продовой версии "dev": "webpack --config webpack.client.js --mode development" // Билд версия с возможностью отладки

Для Autoprefixer'а в корень json'а добавим параметр, для каких браузеров добавлять префиксы:

"browserslist": [ "last 2 versions" ]

Супер! Наш сборщик полностью готов собирать проект. Наполним src/Client.tsx тестовым контентом:

import React from 'react' import ReactDOM from 'react-dom' ReactDOM.render(<h1>Woohoo!</h1>, document.getElementById('root'))

Не забываем, что у нас еще есть index.html для WDS src/Html/Browser.html

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, minimum-scale=1.0, maximum-scale=1.0, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>React Starter Pack</title> </head> <body> <div id="root"></div> </body> </html>

Выполняем в консолях npm run watch и видим результат

Берем тестовый React-контент с GitHub'а, где я сделал страницы с роутингом и стили. Кстати, заметьте, стили я подключаю через require в самом компоненте (в методе render для классовых компонентов). Это позволяет в head добавлять стили только отображаемых компонентов, что снижает нагрузку на парсинг и рендер.

Представим, что наше приложение полностью написано и готово к релизу. Приступим к Server Side Render части.

Для работы SSR нам нужны следующие npm-пакеты:

  • express — Node JS сервер
  • @types/express — TS тип для ExpressJS
  • mini-css-extract-plugin — плагин, который выгрузит стили не в JS-файлы, а отдельный CSS. Если понадобится, вы можете вывести его в серверную часть.
  • webpack-node-externals — плагин, позволяющий исключать из сборки node_modules по дефолту

Выполняем

npm i express @types/express mini-css-extract-plugin webpack-node-externals --save-dev

В webpack.config.js добавим:

const MiniCssExtractPlugin = require('mini-css-extract-plugin')

В массив модулей добавляем новый объект для компиляции отдельного изоморфного CSS-файла:

stylusIsomorph: { test: /\.styl$/, use: [ { loader: MiniCssExtractPlugin.loader, }, { loader: "css-loader", }, { loader: "stylus-loader", options: { import: [ path.resolve(__dirname, './src/Common/Styles/variables.styl'), ], } }, ], }

И также добавим для него PostCSS для production-режима:

if (env === 'production') { ... modules.stylusIsomorph.use.splice(2, 0, { loader: "postcss-loader" }) }

Казалось, мы могли бы все это запустить напрямую в ExpressJS, но нам надо все приложение прогнать через TS.

Создадим в корне проекта отдельный Webpack-конфиг для компиляции серверной части webpack.server.js:

const path = require("path") const UglifyJsPlugin = require('uglifyjs-webpack-plugin') const nodeExternals = require('webpack-node-externals') const { CleanWebpackPlugin } = require('clean-webpack-plugin') const MiniCssExtractPlugin = require('mini-css-extract-plugin') const webpackConfig = require('./webpack.config') module.exports = (env, argv) => { const modeEnv = argv.mode || 'development' const config = webpackConfig(modeEnv) const optimizations = { minimizer: [ new UglifyJsPlugin(), ], } return { plugins: [ new CleanWebpackPlugin(), new MiniCssExtractPlugin(), // Подключаем плагин для CSS ], resolve: config.resolve, module: { rules: [ config.modules.js, config.modules.stylusIsomorph, ], }, entry: { main: './src/Server.tsx', // Тут уже энтрипоинт сервера, который сделаем далее }, output: { filename: '[name].js', path: path.resolve(__dirname, 'server'), // Все компилируем в папку server }, performance: { hints: false, }, optimization: optimizations, target: 'node', // обязательно указываем режим сборки для node js, а не браузера externals: [nodeExternals()], // исключаем node_modules } }

Создадим наш энтрипоинт для сборки сервера src/Server.tsx:

import fs from 'fs' import express from 'express' import React from 'react' import ReactDOMServer from 'react-dom/server' import { StaticRouter } from 'react-router' import { App } from 'App' import { Html } from './Html/Server' const port = 3000 const server = express() const jsFiles: Array<string> = [] fs.readdirSync('./dist/assets').forEach(file => { if (file.split('.').pop() === 'js') jsFiles.push('/assets/' + file) }) server.use('/assets', express.static('./dist/assets')) server.get('*', async (req, res) => { ReactDOMServer.renderToNodeStream(<Html scripts={jsFiles}> <StaticRouter location={req.url} context={{}}> <App /> </StaticRouter> </Html>).pipe(res) }) server.listen(port, () => console.log(`Listening on port ${port}`))

1) «Обертку» HTML'а теперь генерируем динамически.

2) Обратите внимание на StaticRouter. ExpressJS будет автоматически генерировать роутер для отдачи статичного HTML с сервера.

3) В тело документа напрямую «опрокидываем» наш энтрипоинт приложения.

4) GET-метод реализуем через Node JS Streams.

Создадим для начала HTML в src/Html/Server.tsx:

import React from 'react' interface Html { scripts: Array<string> } export function Html({ children, scripts }: React.PropsWithChildren<Html>) { return ( <html> <head> <meta charSet="UTF-8" /> <meta name="viewport" content="width=device-width,minimum-scale=1,maximum-scale=1,initial-scale=1" /> <meta httpEquiv="X-UA-Compatible" content="ie=edge" /> <title>React Starter Pack</title> </head> <body> <div id="root">{children}</div> {scripts.map((script, index) => <script src={script} key={index} />)} </body> </html> ) }

У компонента есть аргумент scripts. ExpressJS собирает из /dist/assets все JS-файлы и явно указывает на них.

Кстати, все ваши meta-теги, ссылки на фавиконки надо указывать в этом файле. Browser.html нужен только для WDS'а.

Нам осталось только немного модифицировать src/Client.tsx для условного определения методов рендера:
https://ru.reactjs.org/docs/react-dom.html#render
https://ru.reactjs.org/docs/react-dom.html#hydrate

import React from 'react' import ReactDOM from 'react-dom' import { BrowserRouter } from 'react-router-dom' import { App } from 'App' const entryBlock = document.getElementById('root') const renderFunction: ReactDOM.Renderer = entryBlock.hasChildNodes() ? ReactDOM.hydrate : ReactDOM.render renderFunction(<BrowserRouter> <App /> </BrowserRouter>, entryBlock)

Вот тут могут быть «запуточки». Пояснение: когда запускаем наше приложение через WDS, сначала входим в src/Html/Browser.html, потом подгружаем JS и отрабатывает метод render, т.к. в div#root ничего нет. Инитим BrowserRouter и далее App. Когда же запускается React c ExpressJS сервера, в div#root уже присутствует контент: определенная страница или 404я — отрабатывает метод hydrate.

С ExpressJS алгоритм такой:
Строим основное тело HTML-документа из файла src/Html/Server.tsx ->
Определяем StaticRouter с нужными параметрами и контекстом ->
Рендерим главный компонент App ->
Отдаем страницу

Когда запускаем React на клиентской стороне с ExpressJS сервера:
Тело HTML уже получили ->
Рендерим динамический контент через метод hydrate

Добавим в package.json команду для сборки и запуска сервера:

"start": "webpack --config webpack.server.js --mode production && node ./server/main.js"

Все, теперь наше приложение готово к запуску. Предварительно поставьте нужный порт для сервера в src/Server.tsx (в примере 3000).

npm run build npm run start

Сервер стартовал и можем протестировать работу по адресу http://localhost:3000

Также вместо node вы можете использовать nodemon.

Надеюсь, данная статья помогла вам понять, как правильно и удобно сплитовать конфиги Webpack и как работает SSR в связке с React'ом, в какой последовательности и принципе.

0
4 комментария
Андрей Шуйский

Cannot find module 'webpack-cli/bin/config-yargs'

Ответить
Развернуть ветку
Евгений Баранников

like

Ответить
Развернуть ветку
Евгений Финогеев

А можно пояснить, как обрабатывается css на сервере? Есть файл main.css, но его нет в head документа. Его содержимое вставляется в тегах style в head документа.

Ответить
Развернуть ветку
Сергей Лутов
Автор

main.css вы можете вставить в head-документа (/src/Html/Server.tsx), чтобы поисковые боты могли для себя проанализировать страницу. При открытии сайта на стороне клиента стиль удалить из head, т.к. будет динамическая подгрузка стилей от активных компонентов. Делается это через ванильные методы JS для работы с DOM.
Метод топорный, но рабочий.
Если углубляться в ускорение загрузки страниц, надо делать динамику стилей и на стороне сервера в зависимости от компонентов, которые на запрашиваемой странице.

Ответить
Развернуть ветку
1 комментарий
Раскрывать всегда