Перед тем как начать собирать с нуля Modern Web App нужно разобраться, что такое — Современное Веб-Приложение?


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



Вот что будет в нашем MWA (советую использовать эту навигацию по статье):



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


Приступим!



1. Universal


Стандартные действия: создаем рабочую директорию и выполняем 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 = {
  // Файл, с которого начинается клиентская часть Universal 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.listen(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 template from './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>
  )
}

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


"scripts": {
    "start": "npm run pack && npm run startProd",
    "startProd": "NODE_ENV=production node 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 template from './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 - удалить
    typography: {
      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',
    },
  },
  typography: {
    useNextVariants: true,
  },
})

// Оборачиваем приложение созданной темой
hydrate(
  <MuiThemeProvider theme={theme}>
    <BrowserRouter>
      <App/>
    </BrowserRouter>
  </MuiThemeProvider>,
  document.querySelector('#app')
)

Теперь предлагаю добавить немного стильного дизайна в компонент Home. Все компоненты material-ui можете посмотреть на их официальном сайте, здесь же хватит Paper, Button, AppBar, Toolbar и Typography.


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 attempt the 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.listen(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
  }
}

Чтобы дать понять серверу, какие именно модули мы импортируем, нам нужно было бы прописать:


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)

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




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, ChromeOS с 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. За эту нереальную статью успели разобрать, как с нуля создать приложение, дающее фору большинству шаблонных. Теперь вам незачем искать в Гугле, как связать между собой SSR и Code Splitting, как в два шага сделать PWA и как передавать данные с сервера на клиент при серверном рендере.


Кстати, вот такую статистику по MWA выдает недавно созданный веб-сайт web.dev:



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


Кстати, MWA — opensource проект. Используйте, распространяйте, улучшайте!


Удачи!

Комментарии (8)


  1. altrus
    10.12.2018 13:59
    +1

    Что такое PWA Гугл дал определение
    Формальное и вообще
    А что такое MWA так и не ясно. Особенно фраза "MWA — opensource проект" загрузила.
    И в чем отличие MWA от PWA — непонятно


  1. IRT
    10.12.2018 14:22
    -1

    В демке кусок сжатой каши из JavaScript весом 230 кбайт не делает ровным счетом ничего. Значение счетчика не сохраняется, между открытыми вкладками не синхронизируется. Пример можно было бы и попрактичнее и понагляднее сделать.

    Плюс, даже в этом ничего не делающем приложении и то иногда проскакивает:
    «Please, click here or reload the page. New content is ready.» при нажатии на кнопку About.
    А если сайт будет чуть сложнее?

    Мне становится грустно от таких вот «чудесных» приложений. А уж на CSS без слез смотреть вообще нельзя.


    1. VanishMax Автор
      10.12.2018 14:39
      +1

      Спасибо за ваш отзыв!

      230кб получаются при использовании material-ui. Если использовать голый css — выйдет в разы меньше. Однако мы вынуждены «платить» килобайтами за возможность делать визуально приятный пользовательский интерфейс без привлечения дизайнеров.

      Насчет перезагрузки при новом контенте: в рамках статьи используется относительной простой service-worker, который лишь кэширует и таким образом обновляет страницу. Делать воркер гораздо сложнее (например, который вообще никак не будет мешать просмотру старого контента или автоперезагрузки страницу при новом контенте) не в рамках статьи для людей, только знакомящихся с веб-стандартами.


      1. mcferden
        10.12.2018 16:58

        А для продвинутых продолжение планируется? Потому что как правило различные сложности и тонкости всплывают тогда, когда приложение становится больше, а туториалов, где бы эти моменты освещались просто нет, зато с десяток разных статей про счетчики и «угадай число».


        1. VanishMax Автор
          10.12.2018 22:41

          Данная статья освещает достаточно тенденций современной веб разработки, поэтому, если и писать статьи для продвинутых, то нужно направить взор на что-то конкретное. Тем не менее, про service-worker'ы уже написано немало подробных статей, а SSR разжеван сотни раз.

          Можете заметить, что про MWA я несколько раз отметил — приложение направленно именно на масштабируемость, то есть внедрение других технологий должно проходить почти как по маслу. Конечно, будут возникать различные трудности и ошибки, но выделять на них статью нецелесообразно, для этого предназначен stackOverflow.


      1. biziwalker
        11.12.2018 11:32
        +1

        Спасибо за статью!

        Советую вам изучить другие направления, вне React экосистемы — вы слишком узко смотрите на современность во фронтенде. Есть ворох интересных решений на веб-компонентах к примеру. Запомните, что фреймворки появляются и исчезают, а платформа останется с нами на очень долгое время.


  1. rozhik
    10.12.2018 15:32
    -2

    Странная смесь разных ES****, зачем раутер зачем другие вещи. Как вообще можно что-либо показывать без комбайн редьюсеров — люди после таких примеров получают плохую практику. Как по мне — так такие статьи вредны для начинающих imho. Если уж взяли ткое название — то modern — ES как минимум 2017, если уж APP — оно должно быть немного осмысленным, иметь персстентное состояние… Жаль, что статья, на которую ушло автора хороша лишь как первая. Желаю автору прислушаться к коментам, и вторую написать лучше


  1. TimeCoder
    12.12.2018 04:55

    Мне, как back-end разработчику на это просто страшно смотреть. Въехать во всё это, и поддерживать знания на актуальном уровне — это как вторую профессию освоить. Я к тому, что быть по-настоящему Full-stack, способным запилить крутые алгоритмы, обернуть это правильным облачным rest API, а потом написать ещё вот это всё, это… ну возможно, наверное, только надо отказаться от всего и жить работой.