Перед вами — React Modern Web App

Перед тим як почати збирати з нуля Modern Web App потрібно розібратися, що таке — Сучасний Веб-Додаток?

Modern Web App (MWA) — програма, яка дотримується всіх сучасних веб-стандартів. Серед них Progressive Web App — можливість скачувати мобільний браузерную версію на телефон і використовувати як повноцінну програму. Так само це можливість гортати сайт в офлайні як з мобільного пристрою, так і з комп’ютера; сучасний матеріальний дизайн; ідеальна пошукова оптимізація; і природно — висока швидкість завантаження.

 

 

Ось що буде у нашому MWA (раджу використовувати цю навігацію по статті):

 

  • Універсальний Web App
  • Material-ui
  • Code Splitting
  • Приклад використання Redux
  • Мобільна версія
  • Progressive Web App
  • Babel 7, Webpack і багато іншого

 

Люди на Хабре ділові, тому відразу ловіть посилання на GitHub репозиторій, архів з кожної з стадій розробки та демо. Стаття розрахована на розроблювачів, знайомих з node.js і react. Вся необхідна теорія представлена в необхідному обсязі. Розширюйте кругозір, переходячи по посиланнях.

 

Приступимо!

 

1. Універсальний

 

Стандартні дії: створюємо робочу директорію і виконуємо git init. Відкриваємо package.json і додаємо пару рядків:

 

"dependencies": {
 "@babel/cli": "^7.1.5",
 "@babel/core": "^7.1.6",
 "@babel/preset-env": "^7.1.6",
 "@babel/preset-react": "^7.0.0",
 "@babel/register": "^7.0.0",
 "babel-loader": "^8.0.4",
 "babel-plugin-root-import": "^6.1.0",
 "express": "^4.16.4",
 "react": "^16.6.3",
 "react-dom": "^16.6.3",
 "react-helmet": "^5.2.0",
 "react-router": "^4.3.1",
 "react-router-dom": "^4.3.1",
 "webpack": "^4.26.1",
 "webpack-cli": "^3.1.2"
}

 

Виконуємо npm install і, поки встановлюється, розбираємося.

 

Оскільки ми знаходимося на межі 2018 і 2019 року, наш веб-додаток буде універсальним (або изоморфным), — як на бек-офісі, так і на фронті буде ECMAScript версії не нижче ES2017. Для цього index.js (вхідний файл програми) підключає babel/register, і весь ES-код, що йде за ним, babel на льоту перетворює в JavaScript, зрозумілий браузеру, з допомогою babel/preset-env і babel/preset-react. Для зручності розробки я зазвичай використовую плагін babel-plugin-root-import, з допомогою якого всі імпорти з кореневої директорії будуть виглядати як ‘~/’, а з src/ — ‘&/’. В якості альтернативи ви можете прописувати довгі шляхи або використовувати alias’и з webpack’а.

 

index.js

 

require("@babel/register")();
require("./app");

 

.babelrc

 

{
presets":
[
[
"@babel/preset-env",
{
"targets":
{
 "node": "current"
}
}
],
"@babel/preset-react"
],
 "plugins": [
 ["babel-plugin-root-import", {
 "paths": [{
 "rootPathPrefix": "~",
 "rootPathSuffix": ""
 }, {
 "rootPathPrefix": "&",
 "rootPathSuffix": "src/"
}]
}]
]
}

 

Час налаштовувати Webpack. Створюємо webpack.config.js і використовуємо код (тут і далі звертайте увагу на коментарі в коді).

 

const path = require('path');

module.exports = {
 // Файл, з якого починається клієнтська частина Універсальний web app
 entry: {
 client: './src/client.js'
},
 // Директорія, в якій буде лежати білд webpack'а
 output: {
 path: path.resolve(__dirname, 'public'),
 publicPath: '/'
},
 module: {
 // Використовуємо babel-loader для компіляції коду з ECMAScript в зрозумілий браузеру
 // JavaScript. Отримані файли будуть знаходитися в директорії /public
 rules: [
 { test: /.js$/, exclude: /node_modules/, loader: "babel-loader" }
]
}
}

 

З цього моменту починається найцікавіше. Пора розробити серверну частину програми. Server-side Rendering (SSR) — це технологія, покликана значно прискорити завантаження веб-додатки та вирішити вічний спір щодо пошукової оптимізації в Single Page Application (SEO в SPA). Для цього ми беремо HTML-шаблон, засовуємо в нього контент і відправляємо користувачеві. Сервер робить це дуже швидко — сторінка отрісовиваємих за лічені мілісекунди. Однак на сервері немає можливості маніпулювати DOM’ом, тому клієнтська частина додатка оновлює сторінку, і вона нарешті-те стає інтерактивною. Зрозуміло? Розробляємо!

 

app.js

 

import express from 'express'
import path from 'path'
import stateRoutes from './server/stateRoutes'

// Використовуємо фреймворк Express для швидкої розробки на Node.js
const app = express()

// Обробляємо статичні файли
app.use(express.static('public'))
app.use('/assets', express.static(path.resolve(__dirname, 'assets')))

// Слухаємо додаток на 3000 порте, якщо він не заданий процесом
const PORT = process.env.PORT || 3000
app.слухати(PORT, '0.0.0.0', () => {
 console.log(`The app is running in PORT ${PORT}`)
})

// Головний роутинг - обробляє GET-запити і віддає state програми - це
// може бути як константа, так і рядки таблиць БД.
stateRoutes(app)

 

server/stateRoutes.js

 

import ssr from './server'

export default function (app) {
 // Для будь-якого шляху відсилаємо шаблон за промовчанням
 // ssr - функція, що повертає створений HTML
 app.get('*', (req, res) => {
 const response = ssr(req.url)
res.send(response)
})
}

 

Файл server/server.js збирає контент, генерований react, і передає його в HTML-шаблон — /server/template.js. Варто уточнити, що на сервері використовується саме статичний роутер, тому що ми не хочемо змінювати url сторінки під час завантаження. А react-helmet — бібліотека, сильно спрощує роботу з метаданими (та і в цілому з тегами head).

 

server/server.js

 

import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'
import { Helmet } from 'react-helmet'

import App from '&/app/App'
import from template './template'

export default function render(url) {

 //Об'єкт, що зберігає в собі результат рендера
 const reactRouterContext = {}

 // Перетворюємо контент в рядок HTML
 let content = renderToString(
 <StaticRouter location={url} context={reactRouterContext}>
<App/>
</StaticRouter>
)

 // Дістаємо <head> з HTML-рядка
 const helmet = Helmet.renderStatic()

 //Передаємо контент в HTML-шаблон і повертаємо згенеровану сторінку
 return template(helmet, content)
}

 

В server/template.js в голові виводимо дані з helmet, підключаємо фавикон, стилі з статичної директорії /assets. В тілі — контент і webpack бандл client.js, що знаходиться в папці /public, але так як вона статична — звертаємося за адресою кореневої директорії — /client.js.

 

server/template.js

 

// HTML-шаблон
export default function template(helmet, content = ") {
 const scripts = `<script src="/client.js"></script>`

 const page = `<!DOCTYPE html>
 <html lang="en">
<head>
${helmet.title.toString()}
${helmet.meta.toString()}
${helmet.link.toString()}
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <meta name="theme-color" content="#810051">
 <link rel="shortcut icon" href="/assets/logos/favicon.ico" type="image/x-icon">
 <link rel="icon" href="/assets/logos/favicon.ico" type="image/x-icon">
 <link rel="stylesheet" href="/assets/global.css">
</head>
<body>
 <div class="content">
 <div id="app" class="wrap-inner">
 <!--- magic happens here --> ${content}
</div>
</div>
${scripts}
</body>
`
 return page
}

 

Переходимо до простого — клієнтська частина. Файл src/client.js відновлює згенерований сервером HTML, не оновлюючи DOM, і робить його інтерактивним. (Детальніше про це тут). Цим займається react-функція hydrate. І тепер нам не за чим робити статичний роутер. Використовуємо звичайний — BrowserRouter.

 

src/client.js

 

import React from 'react'
import { hydrate } from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import App from './app/App'

hydrate(
<BrowserRouter>
<App/>
</BrowserRouter>,
document.querySelector('#app')
)

 

Уже в двох файлах встиг засвітитися react-компонент App. Це головний компонент desktop-програми, що виконує роутинг. Код вельми банальна:

 

src/app/App.js

 

import React from 'react'
import { Switch, Route } from 'react-router'
import Home from './Home'

export default function App() {
return(
<Switch>
 <Route exact path="/" component={Home}/>
</Switch>
)
}

 

Ну і src/app/Home.js. Зауважте, як працює Helmet — звичайна обгортка тегу head.

 

import React from 'react'
import { Helmet } from 'react-helmet'

export default function Home() {
return(
<div>
<Helmet>
 <title>Universal Page</title>
 <meta name="description" content="Modern Web App - Home Page" />
</Helmet>
<h1>
 Welcome to the page of Universal Web App
</h1>
</div>
)
}

 

Читайте також  Головні SEO-тренди в Google

Вітаю! Ми розібрали першу частину розробки MWA! Залишилася лише пара штрихів для того, щоб все це справа протестувати. В ідеалі можете заповнити папку /assets файлами глобальних стилів і фавиконом відповідно шаблоном — server/template.js. Ще у нас немає команд запуску програми. Повернемося до package.json:

 

"scripts": {
 "start": "npm run pack && npm run startProd",
 "startProd": "NODE_ENV=node production index.js",
 "pack": "webpack --mode production --config webpack.config.js",
 "startDev": "npm run packDev && node index.js",
 "packDev": "webpack --mode development --config webpack.config.js"
 }

 

Можете помітити дві категорії команд — Prod і Dev. Відрізняються вони webpack v4 конфігурацією. Про --mode варто почитати тут.
Обов’язково випробуйте вийшло універсальне додаток за адресою localhost:3000

2. Material-ui

 

Ця частина туториала буде присвячена підключення до веб-застосунку з SSR бібліотеки material-ui. Чому саме вона? Все просто — бібліотека активно розвивається, підтримується, має широку документацію. З нею можна будувати гарний користувальницький інтерфейс як раз плюнути.

 

Сама схема підключення, відповідна нашого додатком, описана тут. Що ж, let’s do it.

 

Встановлюємо необхідні залежності:

 

npm i @material-ui/core jss react-jss

 

Далі нам належить внести зміни у вже існуючі файли. В server/server.js ми обертаємо наше додаток в JssProvider і MuiThemeProvider, які будуть надавати material-ui компоненти і, що дуже важливо, об’єкт sheetsRegistry — css, який необхідно помістити в HTML шаблон. На клієнтській стороні використовуємо тільки MuiThemeProvider, постачаючи його об’єктом теми.

server, template і client

server/server.js

 

import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'
import { Helmet } from 'react-helmet'

// Імпортуємо все необхідне для material-ui
import { SheetsRegistry } from 'react-jss/lib/jss'
import JssProvider from 'react-jss/lib/JssProvider'
import {
MuiThemeProvider,
createMuiTheme,
createGenerateClassName,
} from '@material-ui/core/styles'
import purple from '@material-ui/core/colors/purple'

import App from '&/app/App'
import from template './template'

export default function render(url) {

 const reactRouterContext = {}

 //Створюємо об'єкт sheetsRegistry - поки він порожній
 const sheetsRegistry = new SheetsRegistry()
 const sheetsManager = new Map()
 // Створюємо тему - можна налаштувати на будь-який смак і колір
 const theme = createMuiTheme({
 palette: {
 primary: purple,
 secondary: {
 main: '#f44336',
},
},
 // Це потрібно тільки для версій 3.*.*. Коли буде v4 - видалити
 друкарня: {
 useNextVariants: true,
},
})
 const generateClassName = createGenerateClassName()

 // Створюємо обгортку для додатка
 let content = renderToString(
 <StaticRouter location={url} context={reactRouterContext}>
 <JssProvider registry={sheetsRegistry} generateClassName={generateClassName}>
 <MuiThemeProvider theme={theme} sheetsManager={sheetsManager}>
<App/>
</MuiThemeProvider>
</JssProvider>
</StaticRouter>
)

 const helmet = Helmet.renderStatic()

 // Передаємо sheetsRegistry в шаблон для подальшого впровадження в серверний html
 return template(helmet, content, sheetsRegistry)
}

 

server/template.js

 

export default function template(helmet, content = ", sheetsRegistry) {

 const css = sheetsRegistry.toString()
 const scripts = `<script src="/client.js"></script>`

 const page = `<!DOCTYPE html>
 <html lang="en">
 <head> ... </head>
<body>
 <div class="content">...</div>
 <style id="jss-server-side">${css}</style>
${scripts}
</body>
`
 return page
}

 

src/client.js

 

...
import MuiThemeProvider from '@material-ui/core/styles/MuiThemeProvider'
import createMuiTheme from '@material-ui/core/styles/createMuiTheme'
import purple from '@material-ui/core/colors/purple'

// Тема на клієнта повинна бути такою ж, як і на сервері
// При бажанні можна навіть винести в окремий модуль
const theme = createMuiTheme({
 palette: {
 primary: purple,
 secondary: {
 main: '#f44336',
},
},
 друкарня: {
 useNextVariants: true,
},
})

// Обертаємо додаток створеної темою
hydrate(
 <MuiThemeProvider theme={theme}>
<BrowserRouter>
<App/>
</BrowserRouter>
</MuiThemeProvider>,
document.querySelector('#app')
)

 

Тепер пропоную додати трохи стильного дизайну в компонент Home. Всі компоненти material-ui можете подивитися на їх офіційному сайті, тут же вистачить Paper, Button, AppBar, Toolbar і Друкарня.

 

src/app/Home.js

 

import React from 'react'
import { Helmet } from 'react-helmet'

import Paper from '@material-ui/core/Paper'
import Typography from '@material-ui/core/Typography'
import Button from '@material-ui/core/Button'

import Header from './Header'

// Inline styles - одне із самих зручних рішень для css в react
const styles = {
 paper: {
 margin: "auto",
 marginTop: 200,
 width: "40%",
 padding: 15
},
 btn: {
 marginRight: 20
}
}

export default function Home() {
return(
<div>
<Helmet>
 <title>Universal Material Page</title>
</Helmet>
<Header/>
 <Paper elevation={4} style={styles.paper} align="center">
 <Typography variant="h5">Universal Web App with Material-ui</Typography>
<br/>
 <Button variant="contained" color="primary" style={styles.btn}>I like it!</Button>
</Paper>
</div>
)
}

 

src/app/Header.js

 

import React from 'react'
import AppBar from '@material-ui/core/AppBar'
import Toolbar from '@material-ui/core/Toolbar'
import Typography from '@material-ui/core/Typography'

export default function Header() {
 return (
 <AppBar position="static">
<Toolbar>
 <Typography variant="h5" color="inherit">
 Modern Web App
</Typography>
</Toolbar>
</AppBar>
)
}

 

Тепер повинно вийти щось схоже:

 

3. Code Splitting

 

Якщо ви плануєте писати щось більше, ніж TODO лист, то ваш додаток буде збільшуватися пропорційно бандлу client.js. Щоб уникнути довгого завантаження сторінок користувачів, які вже давно придуманий code splitting. Проте одного разу Ryan Florence, один з творців React-router, відлякав потенційних розробників своєю фразою:

Godspeed those who the attempt server-rendered, code-split apps.

Удачі всім, хто вирішить створити ssr програми з code splitting

 

Ми з вами відбиті — зробимо! Встановлюємо необхідне:

 

npm i @babel/plugin-syntax-dynamic-import babel-plugin-dynamic-import-node react-loadable

 

Проблема полягає в одній лише функції — import. Цю асинхронну функцію динамічного імпорту підтримує webpack, але величезною проблемою стане babel компіляція. На щастя, до 2018 року під’їхали бібліотеки, допомагають розібратися з цим. babel/plugin-syntax-dynamic-import і babel-plugin-dynamic-import-node позбавлять нас від помилки "Unexpected token when using import()". Чому ж дві бібліотеки для однієї задачі? dynamic-import-node потрібен саме для серверного рендеринга, і буде підхоплювати імпорти на сервері на льоту:

 

index.js

 

require("@babel/register")({
 plugins: ["@babel/plugin-syntax-dynamic-import", "dynamic-import-node"]
});
require("./app");

 

Одночасно з цим змінюємо глобальний файл babel-конфігурації .babelrc

 

"plugins": [
"@babel/plugin-syntax-dynamic-import",
"react-loadable/babel",
...
]

 

Тут з’явилася react-loadable. Це бібліотека з відмінною документацією збере всі розбиті імпортом webpack’а модулі на сервері, а клієнт з такою ж легкістю підхопить їх. Для цього потрібно сервера завантажити всі модулі:

 

app.js

 

import Loadable from 'react-loadable'
...
Loadable.preloadAll().then(() => app.слухати(PORT, '0.0.0.0', () => {
 console.log(`The app is running in PORT ${PORT}`)
}))
...

 

Самі ж модулі підключити дуже просто. Погляньте на код:

 

src/app/App.js

 

import React from 'react'
import { Switch, Route } from 'react-router'

import Loadable from 'react-loadable'
import Loading from '&/Loading'
const AsyncHome = Loadable({
 loader: () => import(/* webpackChunkName: "Home" */ './Home'),
 loading: Loading,
 delay: 300,
})

export default function App() {
return(
<Switch>
 <Route exact path="/" component={AsyncHome}/>
</Switch>
)
}

 

React-loadable асинхронно завантажує компонент Home, даючи зрозуміти webpack’, що він повинен називатися саме Home (так, це рідкісний випадок, коли коментарі несуть якийсь сенс). delay: 300 означає, що якщо через 300мс компонент все ще не завантажиться, потрібно показати, що завантаження все ж іде. Цим займається Loading:

 

src/Loading.js

 

import React from 'react'
import CircularProgress from '@material-ui/core/CircularProgress'

// Під час завантаження важливо не використовувати зовнішні стилі. Прописуємо свої
const styles = {
 div: {
 width: '20%',
 margin: 'auto',
 transition: 'margin 1s',
 backgroundColor: 'lightgreen',
 color: 'white',
 cursor: 'pointer',
 borderRadius: '3px'
}
}

export default function Loading(props) {
 if (props.error) {
 // Якщо при завантаженні сталася помилка (після розділу PWA стане зрозуміліше), то
 // виводимо блок, закликає виконати примусову перезавантаження сторінки
 return <div style={styles.div} onClick={ () => window.location.reload(true) } align="center">
<h3>
 Please, click here or reload the page. New content is ready.
</h3>
</div>
 } else if (props.pastDelay) {
 // Якщо час завантаження більше 300мс, виводимо грузящийся коло
 return <CircularProgress color="primary"/>
 } else {
 // Інакше не виводимо зовсім Loading
 return null
}
}

 

Читайте також  Як оцінити рівень SEO-компанії, не укладаючи з нею договір

Щоб дати зрозуміти сервера, які саме модулі ми імпортуємо, нам потрібно було б прописати:

 

Loadable({
 loader: () => import('./Bar'),
 modules: ['./Bar'],
 webpack: () => [require.resolveWeak('./Bar')],
});

 

Але, щоб не повторювати один і той же код, існує react-loadable/babel плагін, який ми вже успішно підключили .babelrc. Тепер, коли сервер знає, що імпортувати, потрібно дізнатися, що ж буде отрендерено. Схема роботи трохи нагадує Helmet:

 

server/server.js

 

import Loadable from 'react-loadable'
import { getBundles } from 'react-loadable/webpack'
import stats from '~/public/react-loadable.json'

...
let modules = []

 // Збираємо отрендеренные модулі в масив modules
 let content = renderToString(
 <StaticRouter location={url} context={reactRouterContext}>
 <JssProvider registry={sheetsRegistry} generateClassName={generateClassName}>
 <MuiThemeProvider theme={theme} sheetsManager={sheetsManager}>
 <Loadable.Capture report={moduleName => modules.push(moduleName)}>
<App/>
</Loadable.Capture>
</MuiThemeProvider>
</JssProvider>
</StaticRouter>
)
...
 // Перетворюємо модулі в бандли (розказано далі)
 let bundles = getBundles(stats, modules)
 // І передаємо в HTML-шаблон
 return template(helmet, content, sheetsRegistry, bundles)

 

Щоб переконатися, що клієнт завантажує всі модулі, отрендеренные на сервері, нам потрібно співвіднести їх з бандлами, створеними by webpack. Для цього внесемо зміни в конфігурацію збирача. Плагін react-loadable/webpack виписує всі модулі в окремий файл. Ще нам варто сказати webpack’, щоб він правильно зберігав модулі після динамічного імпорту — в об’єкті output.

 

webpack.config.js

 

const ReactLoadablePlugin = require('react-loadable/webpack').ReactLoadablePlugin;
...
output: {
 path: path.resolve(__dirname, 'public'),
 publicPath: '/',
 chunkFilename: '[name].bundle.js',
 filename: "[name].js"
},
plugins: [
 new ReactLoadablePlugin({
 filename: './public/react-loadable.json',
})
 ]

 

Прописуємо модулі в шаблоні, завантажуючи їх по черзі:

 

server/template.js

 

export default function template(helmet, content = ", sheetsRegistry, bundles) {
...
 const page = `<!DOCTYPE html>
 <html lang="en">
<head>...</head>
<body>
 <div class="content">
 <div id="app" class="wrap-inner">
 <!--- magic happens here --> ${content}
</div>
 ${bundles.map(bundle => `<script src='/${bundle.file}'></script>`).join('n')}
</div>
 <style id="jss-server-side">${css}</style>
${scripts}
</body>
`
 return page
}

 

Залишилося лише обробити клієнтську частину. Метод Loadable.preloadReady() завантажує всі модулі, які заздалегідь віддав користувачеві сервер.

 

src/client.js

 

import Loadable from 'react-loadable'

Loadable.preloadReady().then(() => {
hydrate(
 <MuiThemeProvider theme={theme}>
<BrowserRouter>
<App/>
</BrowserRouter>
</MuiThemeProvider>,
document.querySelector('#app')
)
})

 

Готово! Запускаємо і дивимося на результат — у минулій частині бандлом був лише один файл — client.js вагою 265кб, а тепер — 3 файла, найбільший з яких важить 215кб. Чи варто говорити, що швидкість завантаження значно зросте при масштабуванні проекту?

 

4. Redux лічильник

 

Тепер ми приступимо до вирішення практичних завдань. Як вирішити дилему, коли сервер є дані (скажімо, з бази даних), треба вивести їх на екран для того, щоб пошукові роботи змогли знайти контент, а потім використовувати ці дані на клієнті.

 

Рішення є. Воно використовується майже в кожній статті за SSR, однак те, як це реалізовано там, далеко не завжди піддається хорошої масштабованості. Простими словами, слідуючи більшості туториалов, вам не вдасться зробити реальний сайт з SSR за принципом “Раз, два, і в продакшн”. Зараз спробую розставити крапки над i.

 

Нам знадобиться тільки redux. Справа в тому, що у redux є глобальний store, який ми можемо передавати від сервера клієнту по клацанню пальців.
Тепер важливе (!): у нас не дарма є файл server/stateRoutes. Він управляє об’єктом initialState, який там генерується, з нього створюється store, а потім передається в HTML-шаблон. Клієнт дістає цей об’єкт window.__STATE__, пересотворює store і все. Начебто нескладно.

 

Встановимо:

 

npm i redux react-redux

 

Виконаємо дії, описані вище. Тут здебільшого повторення раніше використаного коду.

Обробка сервером і клієнтом лічильника

server/stateRoutes.js:

 

import ssr from './server'

// Початковий стан - лічильник = 5
const initialState = {
 count: 5
}

export default function (app) {
 app.get('*', (req, res) => {
 // передаємо initialState далі
 const response = ssr(req.url, initialState)
res.send(response)
})
}

 

server/server.js:

 

import { Provider } from 'react-redux'
import configureStore from '&/redux/configureStore'
...
export default function render(url, initialState) {

// Створюємо стор
const store = configureStore(initialState)
...
 // Redux Provider забезпечує всі додаток стором.
 let content = renderToString(
 <StaticRouter location={url} context={reactRouterContext}>
 <Provider store={store} >
 <JssProvider registry={sheetsRegistry} generateClassName={generateClassName}>
 <MuiThemeProvider theme={theme} sheetsManager={sheetsManager}>
 <Loadable.Capture report={moduleName => modules.push(moduleName)}>
<App/>
</Loadable.Capture>
</MuiThemeProvider>
</JssProvider>
</Provider>
</StaticRouter>
)
...
 // Передаємо initialState в HTML-шаблон
 return template(helmet, content, sheetsRegistry, bundles, initialState)
}

 

server/template.js

 

export default function template(helmet, content = ", sheetsRegistry, bundles, initialState = {}) {
...
 // Робимо з initialState рядок і передаємо як глобальну змінну
 const scripts = `<script>
 window.__STATE__ = ${JSON.stringify(initialState)}
</script>
 <script src="/client.js"></script>`

 const page = `<!DOCTYPE html>
 <html lang="en">
<head>...</head>
<body>
...
${scripts}
</body>
`
 return page
}

 

Отримуємо store на клієнті. src/client.js

 

import Loadable from 'react-loadable'
import { Provider } from 'react-redux'
import configureStore from './redux/configureStore'
...
// Буквально витягуємо initialState з "вікна" і заново створюємо стор
const state = window.__STATE__
const store = configureStore(state)

Loadable.preloadReady().then(() => {
hydrate(
 <Provider store={store} >
 <MuiThemeProvider theme={theme}>
<BrowserRouter>
<App/>
</BrowserRouter>
</MuiThemeProvider>
</Provider>,
document.querySelector('#app')
)
})

 

Логіка redux в SSR закінчилася. Тепер звичайна робота з redux — створення стора, дій, редьюсеры, коннект та інше. Сподіваюся, що це буде зрозуміло без особливих пояснень. Якщо ні, почитайте документацію.

Весь Redux тут

src/redux/configureStore.js

 

import { createStore } from 'redux'
import rootReducer from './reducers'

export default function configureStore(preloadedState) {
 return createStore(
rootReducer,
preloadedState
)
}

 

src/redux/actions.js

 

// actions
export const INCREASE = 'INCREASE'
export const DECREASE = 'DECREASE'

// Створюємо action creators
export function increase() {
 return {
 type: INCREASE
}
}
export function decrease() {
 return {
 type: DECREASE
}
}

 

src/redux/reducers.js

 

import { INCREASE, DECREASE } from './actions'

export default function count(state, action) {
 switch (action.type) {
 case INCREASE:
 // Якщо наш action = INCREASE - збільшуємо state.count на 1
 return Object.assign({}, state, {
 count: state.count + 1
})
 case DECREASE:
 // Якщо DECREASE - зменшуємо на 1. Так виходить лічильник
 return Object.assign({}, state, {
 count: state.count - 1
})
default:
 // За замовчуванням повертаємо попередній стан
 return state
}
}

 

src/app/Home.js

 

import React from 'react'
import { Helmet } from 'react-helmet'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import * as Actions from '&/redux/actions'

import Header from './Header'
import Paper from '@material-ui/core/Paper'
import Typography from '@material-ui/core/Typography'
import Button from '@material-ui/core/Button'

const styles = {
 paper: {
 margin: 'auto',
 marginTop: '10%',
 width: '40%',
 padding: 15
},
 btn: {
 marginRight: 20
}
}

class Home extends React.Component{
constructor(){
super()
 this.increase = this.increase.bind(this)
 this.decrease = this.decrease.bind(this)
}
 // Функції викликають dispatch на дії increase або decrease
increase(){
this.props.actions.increase()
}
decrease(){
this.props.actions.decrease()
}
render(){
 return (
<div>
<Helmet>
 <title>MWA - Home</title>
 <meta name="description" content="Modern Web App - Home Page" />
</Helmet>
<Header/>
 <Paper elevation={4} style={styles.paper} align="center">
 <Typography variant="h5">Redux-Counter</Typography>
 <Typography variant="subtitle1">Counter: {this.props.count}</Typography>
<br/>
 <Button variant="contained" color="primary" onClick={this.increase} style={styles.btn}>Increase</Button>
 <Button variant="contained" color="primary" onClick={this.decrease}>Decrease</Button>
</Paper>
</div>
)
}
}

// Додаємо в props лічильник
const mapStateToProps = (state) => ({
 count: state.count
})
// Додаємо actions до this.props
const mapDispatchToProps = (dispatch) => ({
 actions: bindActionCreators(Actions, dispatch)
})

// Використовуємо react-redux connect для підключення до стору
export default connect(
mapStateToProps,
mapDispatchToProps
)(Home)

 

Читайте також  Основи безпеки IoT

Такий результат копіткої роботи:

 

5. Мобільна версія

 

Тепер ми будемо розробляти те, що потрібно кожному сучасному сайту в обов’язковому порядку — мобільну версію. Робиться це навіть простіше ніж могло здаватися. Нам потрібно на сервері визначити пристрій користувача і, залежно від цього, передавати йому потрібну версію програми за допомогою initialState, який ми створили в минулому розділі.

 

Встановимо останню за статтю залежність:

 

npm i mobile-detect

 

mobile detect визначає браузер юзера по заголовка user-agent, видає null на десктопи і детальну інформацію про устрій та браузері у разі мобільного пристрою.

 

Працюємо з сервером:

 

server/stateRoutes.js

 

import ssr from './server'
import MobileDetect from 'mobile-detect'

const initialState = {
 count: 5,
 mobile: null
}

export default function (app) {
 app.get('*', (req, res) => {
 // md == null, якщо компуктер, інакше мобільний пристрій
 const md = new MobileDetect(req.headers['user-agent'])
 const response = ssr(req.url, initialState, md.mobile())
res.send(response)
})
}

 

Щоб сотий раз не повторювати один і той же файл — лізь всередину:

Обробка сервером і клієнтом мобільної версії

server/server.js

 

...
import App from '&/app/App'
import MobileApp from '&/mobileApp/App'

export default function render(url, initialState, mobile) {

 // Важливий момент тут - видача потрібної версії
 let content = renderToString(
 <StaticRouter location={url} context={reactRouterContext}>
 <Provider store={store} >
 <JssProvider registry={sheetsRegistry} generateClassName={generateClassName}>
 <MuiThemeProvider theme={theme} sheetsManager={sheetsManager}>
 <Loadable.Capture report={moduleName => modules.push(moduleName)}>
 {mobile === null ? <App/> : <MobileApp/> }
</Loadable.Capture>
</MuiThemeProvider>
</JssProvider>
</Provider>
</StaticRouter>
)

 // Передаємо клієнту інформацію про пристрої користувача
 initialState.mobile = mobile

 return template(helmet, content, sheetsRegistry, bundles, initialState)
}

 

src/client.js

 

...
const state = window.__STATE__
const store = configureStore(state)

// Беремо дані про пристрої з глобального state
Loadable.preloadReady().then(() => {
hydrate(
 <Provider store={store} >
 <MuiThemeProvider theme={theme}>
<BrowserRouter>
 {state.mobile === null ? <App/> : <MobileApp/> }
</BrowserRouter>
</MuiThemeProvider>
</Provider>,
document.querySelector('#app')
)
})

 

Тепер робота залишилася лише для верстальника або ледачого react-розробника, любителя вставляти готові красиві компоненти. Щоб було трохи цікавіше, додав в мобільну версію роутинг. Подивитися цей код можете в директорії src/mobileApp тут.

6. Прогресивне додаток

 

Progressive Web App (PWA), за словами Google — це привабливі, швидкі і надійні програми, що встановлюються на пристрої користувача, керовані в офлайні.

 

Щодо пристроїв користувача потрібно внести ясність. На девайсах з андроїдом у вас не буде проблем: сучасні Chrome, Opera і Samsung Internet самі запропонують вам встановити додаток, якщо воно відповідає вимогам. На iOS ви можете додати додаток на головний екран тільки якщо зайдете в нетрі Safari, однак це ще не гарантує якісної роботи. Як розробнику, вам потрібно буде врахувати деякі фактори. На десктопах вже можна встановити PWA: Windows з Chrome v70, Linux з v70, ос chrome з v67. Очікуємо PWA на macOS — попередньо така можливість стане доступна в першій половині 2019 року з приходом Chrome v72.

 

Розробникам потрібно не так вже багато зробити: PWA можна інтегрувати на будь-який сайт без особливих витрат. Тільки постарайтеся, щоб ваш сайт мав мобільну версію або, принаймні, адаптивний дизайн.

 

2 файлу — manifest.json і service-worker.js — наша необхідність. Маніфест — це json файл, який пояснює, як додаток повинен вести себе, коли встановлено. Service-worker робить все інше: управляє хешем і push-повідомленнями, перехоплює і модифікує мережеві запити і багато іншого.

 

Почнемо з маніфесту. Опис всіх директив почитайте за посиланнями, тут же буде найважливіше:

 

public/manifest.json:

 

{
 "short_name": "MWA",
 "name": "Modern Web App",
 "description": "Modern app built with React SSR, PWA, material-ui, code splitting and much more",
 "icons": [
{
 "src": "/assets/logos/yellow 192.png",
 "sizes": "192x192",
 "type": "image/png"
},
{
 "src": "/assets/logos/yellow 512.png",
 "sizes": "512x512",
 "type": "image/png"
}
],
 "start_url": ".",
 "display": "standalone",
 "theme_color": "#810051",
 "background_color": "#FFFFFF"
}

 

Раджу ознайомитися з відмінним туториалом з написання service-worker’а, тому що це справа не з найлегших. Дивіться уважно на код, що підтримує установку, кешування та оновлення:

 

public/service-worker.js

 

// Назва кеша для кожної зміни коду варто перейменовувати
var CACHE = 'cache'

// Відловлюємо подія установки воркера
self.addEventListener. ('install', function(evt) {
evt.waitUntil(precache())
})

// На подію fetch використовуємо кеш, але оновлюємо при появі нового контенту
self.addEventListener. ('fetch', function(evt) {
 console.log('The service worker is serving the asset.')
evt.respondWith(fromCache(evt.request))
evt.waitUntil(update(evt.request))
})

// Записуємо, що конкретно нам потрібно кешувати
function precache() {
 return caches.open(CACHE).then(function (cache) {
 return cache.addAll([
'./',
'/assets/MWA.png',
'/assets/global.css',
'/assets/logos/favicon.ico',
 '/assets/logos/yellow 192.png',
 '/assets/logos/yellow 512.png',
'/robots.txt'
])
})
}

// При запиті перевіряємо, чи є в кеші потрібний ресурс. Якщо так, віддаємо кеш
function fromCache(request) {
 return caches.open(CACHE).then(function (cache) {
 return cache.match(request).then(function (matching) {
 return matching || null
})
})
}

// Оновлення складається з відкриття кешу, обробки мережевих запитів
// та збереження нових даних
function update(request) {
 return caches.open(CACHE).then(function (cache) {
 return fetch(request).then(function (response) {
 return cache.put(request, response)
})
})
}

 

Щоб остаточно змусити PWA працювати, потрібно підключити маніфест і реєстрацію сервіс-воркера до html-шаблону:

 

server/template.js

 

export default function template(helmet, content = ", sheetsRegistry, bundles, initialState = {}) {

 const scripts = `...
<script>
 // Якщо браузер підтримує service-worker - реєструємо
 if ('serviceWorker' in navigator) {
 window.addEventListener. ('load', () => {
navigator.serviceWorker.register('/service-worker.js')
 .then(registration => {
 console.log('Service Worker is registered! ');
})
 .catch(err => {
 console.log('Registration failed ', err);
});
});
}
</script>`

 const page = `<!DOCTYPE html>
 <html lang="en">
<head>
...
 <link rel="manifest" href="/manifest.json">
</head>
<body>
...
${scripts}
</body>
`
 return page
}

 

Готово! Якщо підключити до додатку https, то браузер запропонує встановити додаток, як це показано на gif вище у випадку з demo.

 

7. Кінець

 

На цьому закінчується розповідь про розробку чудесного MWA. За цю нереальну статтю встигли розібрати, як з нуля створити додаток, що дає фору більшості шаблонних. Тепер вам нема чого шукати в Гуглі, як зв’язати між собою і Code SSR Splitting, як у два кроки зробити PWA і як передавати дані з сервера на клієнт при серверному рендері.

 

До речі, ось таку статистику за MWA видає нещодавно створений веб-сайт web.dev:

 

 

Якщо ти уважно прочитав цю статтю, ти — монстр. Можеш плюсануть, підтримати зірочкою на гітхабі, хоча найкраща підтримка — це максимальна експлуатація мого коду.

До речі, MWA — opensource проект. Використовуйте, поширюйте, покращуйте!

Степан Лютий

Обожнюю технології в сучасному світі. Хоча частенько і замислююся над тим, як далеко вони нас заведуть. Не те, щоб я прям і знаюся на ядрах, пікселях, коллайдерах і інших парсеках. Просто приходжу в захват від того, що може в творчому пориві вигадати людський розум.

You may also like...

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *