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

Создание и настройка проекта 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 autoprefixer@9.8.6 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 + Webpack с нуля до SSR

Берем тестовый 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.

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

Создадим в корне проекта отдельный 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)
Создание и настройка проекта React + Webpack с нуля до SSR

Вот тут могут быть «запуточки». Пояснение: когда запускаем наше приложение через 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

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

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

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

12
4 комментария