Тонкое исследование React SSR (рендеринг на стороне сервера)

React.js

Я смотрел это недавноReact SSRСвязанные вещи, запишите соответствующий контент здесь

Пример кода этой статьи был загружен наgithub, если интересно, см.Basic | SplitChunkV

Знакомство с React SSR

nodejsследитьcommonjsСпецификация, импорт и экспорт файлов следующие:

// 导出
module.exports = someModule
// 导入
const module = require('./someModule')

и мы обычно пишемreactкод следуетesModuleСтандартно импорт и экспорт файлов выглядит следующим образом:

// 导出
export default someModule
// 导入
import module from './someModule'

Так хочу, чтобыreactЕсли код совместим с серверной частью, то сначала нужно решить проблему совместимости этих двух спецификаций.reactможет быть напрямуюcommonjsСпецификации пишутся, например:

const React = require('react')

На первый взгляд кажется, что это преобразование метода письма, и в этом нет никакой проблемы, но на самом деле это решает только одну из проблем.reactОбщий код рендеринга в , т.е.jsx,nodeЯ не знаю, я должен скомпилировать его один раз

render () {
  // node是不认识 jsx的
  return <div>home</div>
}

компиляция на стороне клиентаreactНаиболее часто используемый кодwebpack, можно использовать и серверную часть, здесь используйтеwebpackимеет две роли:

  • Будуjsxкомпилируется вnodeоригиналjsкод
  • БудуexModuleкод компилируется вcommonjsиз

webpackПример файла конфигурации выглядит следующим образом:

// webpack.server.js
module.exports = {
  // 省略代码...
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          // 需要支持 react
          // 需要转换 stage-0
          presets: ['react', 'stage-0', ['env', {
            targets: {
              browsers: ['last 2 versions']
            }
          }]]
        }
      }
    ]
  }
}

С этим файлом конфигурации вы можете с удовольствием писать код

Первый — это копия, которую нужно вывести на клиентreactКод:

import React from 'react'

export default () => {
  return <div>home</div>
}

Этот код очень прост, обычныйreact statelessкомпоненты

Затем идет серверный код, отвечающий за вывод этого компонента на клиент:

// index.js
import http from 'http'
import React from 'react'
import { renderToString } from 'react-dom/server'
import Home from './containers/Home/index.js'

const container = renderToString(<Home />)

http.createServer((request, response) => {
  response.writeHead(200, {'Content-Type': 'text/html'})
  response.end(`
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <meta http-equiv="X-UA-Compatible" content="ie=edge">
      <title>Document</title>
    </head>
    <body>
      <div id="root">${container}</div>
    </body>
    </html>
  `)
}).listen(8888)

console.log('Server running at http://127.0.0.1:8888/')

Приведенный выше код запускаетnode httpсервер, ответилhtmlИсходный код страницы, но по сравнению с обычнымnodeС точки зрения кода на стороне сервера здесь также представленreactСвязанные библиотеки

что мы обычно пишемReactКод, чье действие для рендеринга страницы, на самом делеreactвызов, связанный с браузеромAPIв режиме реального времени, т.е. страница создаетсяjsМанипулировать браузеромDOM APIСобран, серверная часть не может вызывать браузерAPI, поэтому этот процесс не может быть выполнен, в это время вам нужно использоватьrenderToStringохватывать

renderToStringдаReactпредусмотрено дляReactКод преобразуется во что-то, что браузер может напрямую распознать.htmlнитьAPI, можно считать, что этоAPIДелайте то, что браузер должен делать заранее, прямо на стороне сервераDOMСтруна собрана и переданаnodeвывод в браузер

Переменные в приведенном выше кодеcontainer, что на самом деле выглядит следующим образомhtmlНить:

<div data-reactroot="">home</div>

так,nodeОтклик браузера нормальныйhtmlСтрока может отображаться непосредственно браузером, потому что браузеру не нужно загружатьreactкод, размер кода меньше, и нет необходимости в сплайсинге в реальном времениDOMString, просто выполните действие рендеринга страницы, чтобы скорость рендеринга на стороне сервера была выше.

Кроме того, кромеrenderToStringПомимо,React v16.xЕсть и другой, более мощныйAPI:renderToNodeStream renderToNodeStreamПоддерживается прямой рендеринг в потоки узлов. Рендеринг в поток может уменьшить первый байт вашего контента (TTFB), отправить начало и конец документа в браузер до того, как будет сгенерирована следующая часть документа. Когда контент передается с сервера, браузер начинает анализироватьHTMLДокументация, некоторые статьи называют этоAPIСкорость рендерингаrenderToStringтри раза (сколько раз я не проверял, но это правда, что скорость рендеринга в целом будет выше)

Итак, если вы используетеReact v16.x, вы также можете написать:

import http from 'http'
import React from 'react'
// 这里使用了 renderToNodeStream
import { renderToNodeStream } from 'react-dom/server'
import Home from './containers/Home/index.js'

http.createServer((request, response) => {
  response.writeHead(200, {'Content-Type': 'text/html'})
  response.write(`
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <meta http-equiv="X-UA-Compatible" content="ie=edge">
      <title>Document</title>
    </head>
    <body>
      <div id="root">
  `)
  const container = renderToNodeStream(<Home />)
  // 这里使用到了 数据流的概念,所以需要以流的形式进行传送数据
  container.pipe(response, { end: false })
  container.on('end', () => {
    // 响应流结束
    response.end(`
      </div>
      </body>
      </html>
    `)
  })
}).listen(8888)

console.log('Server running at http://127.0.0.1:8888/')

Логический изоморфизм, связанный с BOM/DOM

имеютrenderToString / renderToNodeStreamПосле этого кажется, что рендеринг на стороне сервера находится в пределах досягаемости, но на самом деле это далеко не так, для следующегоreactКод:

const Home = () => {
  return <button onClick={() => { alert(123) }}>home</button>
}

Ожидается, что при нажатии кнопки в браузере появится подсказка123Всплывающее окно, но если вы просто выполните описанный выше процесс, это событие не сработает, причина в том, чтоrenderToStringбудет анализировать только основныеhtml DOMэлемент, и не будет анализировать события, прикрепленные к элементу, то есть он будет проигнорированonClickэтот инцидент

onClickявляется событием, в коде, который мы обычно пишем (т.е. неSSR),Reactвыполняется на элементеaddEventListenerЗарегистрироваться на мероприятие, то есть черезjsДля запуска события и вызова соответствующего метода, и серверная сторона явно не может выполнить эту операцию, кроме того, некоторые операции, связанные с браузером, не могут быть выполнены на стороне сервера.

Однако они не влияютSSR,SSRОдна из целей — позволить браузеру отображать страницу быстрее, а взаимодействие с исполняемым файлом пользователя не должно следовать за страницей.DOMЗавершается одновременно, поэтому мы можем упаковать эту часть кода выполнения, связанного с браузером, вjsФайл отправляется в браузер, и после отображения страницы в браузере он загружается и выполняется.js, вся страница, естественно, имеет исполняемый файл

Для упрощения работы на стороне сервера введено следующееKoa

Поскольку на стороне браузера также необходимо запустить сноваHomeкомпонент, то вам нужно подготовить еще одну копию для использования браузеромHomeФайл пакета:

// client
import React from 'react'
import ReactDOM from 'react-dom'

import Home from '../containers/Home'

ReactDOM.render(<Home />, document.getElementById('root'))

Обычно пишется на стороне браузераReactкод, поставитьHomeКомпонент снова упаковывается и отображается на узле страницы.

Кроме того, если вы используетеReact v16.x, последнее предложение приведенного выше кода предлагает написать:

// 省略代码...
ReactDOM.hydrate(<Home />, document.getElementById('root'))

ReactDOM.renderа такжеReactDOM.hydrateОсновное различие между ними в том, что последний имеет меньшие накладные расходы на производительность (используется только для рендеринга на стороне сервера), более подробную информацию можно увидетьhydrate

Этот код нужно упаковать в кусокjsКод отправляется на сторону браузера, поэтому здесь необходимо выполнить аналогичный изоморфный код на стороне клиента.webpackКонфигурация:

// webpack.client.js
const path = require('path')

module.exports = {
  // 入口文件
  entry: './src/client/index.js',
  // 表示是开发环境还是生产环境的代码
  mode: 'development',
  // 输出信息
  output: {
    // 输出文件名
    filename: 'index.js',
    // 输出文件路径
    path: path.resolve(__dirname, 'public')
  },
  // ...
}

Этот файл конфигурации и файл конфигурации на стороне сервераwebpack.server.jsЭто почти то же самое, просто удалите некоторые настройки, связанные с серверной частью.

Этот файл конфигурации объявляет, чтоHomeкомпоненты упакованы вpublicкаталог, имя файлаindex.js, так что нам нужно только вывести на стороне сервераhtmlНа странице загрузите в него этот файл:

// server
// 省略无关代码...
app.use(ctx => {
  ctx.response.type = 'html'
  ctx.body = `
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <meta http-equiv="X-UA-Compatible" content="ie=edge">
        <title>Document</title>
      </head>
      <body>
        <div id="root">${container}</div>
        <!-- 引入同构代码 -->
        <script src="/index.js"></script>
      </body>
    </html>
  `
})
app.listen(3000)

дляHomeДля этого компонента он запускается один раз на стороне сервера, в основном черезrenderToString/renderToNodeStreamгенерировать чистыйhtmlЭлементы, но и запускаются на клиенте один раз, основные события были правильно зарегистрированы, комбинация обоих, вы можете интегрировать страницу из обычного взаимодействия, такой сервер и клиент запускают тот же набор операционного кода, также называемымизоморфизм

Изоморфизм маршрутизации (маршрутизатор)

решенные события и т. д.jsПосле изоморфности связанного кода маршрутизация также должна быть изоморфна.

Обычно вreactкод будет использоватьreact-routerДля управления маршрутизацией, здесь в изоморфном коде, передаваемом с сервера в браузер, можно еще следовать общей практике (HashRouter/BrowserRouter), здесь сBrowserRouterНапример

Определение маршрута:

import React, { Fragment } from 'React'
import { Route } from 'react-router-dom'

import Home from './containers/Home'
import Login from './containers/Login'

export default (
  <Fragment>
    <Route path='/' exact component={Home}></Route>
    <Route path='/login' exact component={Login}></Route>
  </Fragment>
)

Введение кода на стороне браузера:

import React from 'react'
import ReactDOM from 'react-dom'
// 这里以 BrowserRouter 为例,HashRouter也是可以的
import { BrowserRouter } from 'react-router-dom'
// 引入定义的路由
import Routes from '../Routes'
const App = () => {
  return (
    <BrowserRouter>
      {Routes}
    </BrowserRouter>
  )
}
ReactDOM.hydrate(<App />, document.getElementById('root'))

В основном при импорте маршрута на стороне сервера:

// 使用 StaticRouter
import { StaticRouter } from 'react-router-dom'
import Routes from '../Routes'
// ...
app.use(ctx => {
  const container = renderToNodeStream(
    <StaticRouter location={ctx.request.path} context={{}}>
      {Routes}
    </StaticRouter>
  )
  // ...
})

Маршрутизация на стороне сервера не имеет состояния, то есть некоторые операции маршрутизации не записываются, а изменения маршрутизации и статус маршрутизации на стороне браузера не могут быть автоматически изучены, потому что это все вещи браузера.React-router 4.xпредусмотрено для серверной частиStaticRouterДля управления маршрутизацией этоAPIчерез входящийlocationПараметры для пассивного получения маршрута текущего запроса, чтобы сопоставлять и перемещаться по маршруту, см. подробнееStaticRouter

Изоморфизм состояний (состояние)

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

Код сервера используется для всех пользователей, все пользователи должны открыть независимое состояние данных, в противном случае все пользователи используют одно и то же состояние.

// 这种写法在客户端可取,但在服务器端会导致所有用户共用了同一个状态
// export default createStore(reducer, applyMiddleware(thunk))
export default () => createStore(reducer, applyMiddleware(thunk))

Обратите внимание, что приведенный выше код экспортирует функцию вместоstoreобъект, хотите получитьstoreПросто выполните эту функцию:

import getStore from '../store'
// ...
<Provider store={getStore()}>
  <StaticRouter location={ctx.request.path} context={context}>
    {Routes}
  </StaticRouter>
</Provider>

Это гарантирует, что сервер будет регенерировать новый каждый раз, когда он получает запрос.store, что эквивалентно тому, что каждый запрос получает новое независимое состояние

Вышеизложенное как раз решает проблему государственной независимости, ноSSRКлючевым моментом синхронизации состояний является синхронизация асинхронных данных, таких как вызов общих интерфейсов данных, что является асинхронной операцией, если вы используете ее в клиенте.reduxДля выполнения асинхронных операций сделайте то же самое на стороне сервера.Хотя проект не сообщит об ошибке и страница может нормально отрисовываться, на самом деле эта часть асинхронно полученных данных отсутствует на странице, отрендеренной сервером.

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

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

На самом деле здесь две проблемы:

  • Вам нужно знать, какая страница в данный момент запрашивается, потому что данные, требуемые разными страницами, как правило, отличаются, и запрашиваемый интерфейс и логика обработки данных также различаются.
  • Необходимо убедиться, что сервер получил данные от интерфейса до ответа на страницу, то есть обработанное состояние (store)

На первый вопрос,react-routerНа самом деле ужеSSRРешение дается в терминахнастроить маршрут/маршрут-конфигурациякомбинироватьmatchPath, найдите на странице метод интерфейса запроса, требуемый соответствующим компонентом, и выполните:

Кроме того,react-routerкоторый предоставилmatchPathМожет быть распознана только маршрутизация первого уровня.Для многоуровневой маршрутизации может быть идентифицирована только маршрутизация верхнего уровня, а маршрутизация подуровня будет игнорироваться, поэтому, если в проекте нет многоуровневой маршрутизации или сбора данных и обработка состояния выполняется в маршрутизации верхнего уровня, затем используйтеmatchPathПроблем нет, иначе может быть проблема потери данных страницы при дочерней маршрутизации

По этому вопросу,react-routerтакже даетрешение, которым пользуется сам разработчикreact-router-configпредоставлено вmatchRoutesзаменитьmatchPath

Что касается второго вопроса, это на самом деле намного проще, т.jsСинхронизация общих асинхронных операций в коде, наиболее часто используемыхPromiseилиasync/awaitможет решить эту проблему

const store = getStore()
const promises = []
// 匹配的路由
const mtRoutes = matchRoutes(routes, ctx.request.path)
mtRoutes.forEach(item => {
  if (item.route.loadData) {
    promises.push(item.route.loadData(store))
  }
})
// 这里服务器请求数据接口,获取当前页面所需的数据,填充到 store中用于渲染页面
await Promise.all(promises)
// 服务器端输出页面
await render(ctx, store, routes)

Однако после решения этой проблемы возникла другая проблема.

Я сказал раньше,SSRПроцесс, обеспечивающий согласованность состояния данных на стороне сервера и на стороне клиента.Согласно описанному выше процессу, на стороне сервера в конечном итоге будет выведена полная страница со статусом данных, но логика кода на стороне клиента чтобы сначала отобразить состояние без данных. полка страницы дляcomponentDidMountВ таких функциях ловушек инициируется запрос интерфейса данных для получения данных, выполняется обработка состояния, и конечная полученная страница согласуется с выводом на стороне сервера.

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

Процесс решения этой задачи на самом деле представляет собой данныеобезвоживаниеа такжезакачка воды

На стороне сервера, когда сторона сервера запрашивает интерфейс для получения данных и обработки состояния данных (например,storeПосле обновления) сохраняйте это состояние и отвечайте на страницу на стороне сервераHTMLКогда состояние передается в браузер, этот процесс вызываетсяобезвоживание(Dehydrate); на стороне браузера просто возьмите это напрямуюобезвоживаниеданные для инициализацииReactкомпонент, то есть клиенту не нужно инициировать запрос на получение статуса обработки данных, потому что это уже сделал сервер, и статус обработки можно получить непосредственно с сервера.Этот процесс называетсязакачка воды(Hydrate)

А серверная сторона объединяет состояние сhtmlСпособ отправки его на сторону браузера обычно осуществляется через глобальные переменные:

ctx.body = `
  <!DOCTYPE html>
  <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <meta httpquiv="X-UA-Compatible" content="ie=edge">
      <title>Document</title>
    </head>
    <body>
      <div id="root">${data.toString()}</div>
      <!-- 从服务器端拿到脱水的数据状态 -->
      <script>
        window.context = {
          state: ${JSON.stringify(store.getState())}
        }
      </script>
      <!-- 引入同构代码 -->
      <script src="/index.js"></script>
    </body>
  </html>
`

Затем, после того, как сторона браузера получит страницу, отправленную со стороны сервера, она может получить прямой доступ кwindowСостояние получается на объекте, а затем используется это состояние для обновления состояния самого браузера:

export const getClientStore = () => {
  // 从服务器端输出的页面上拿到脱水的数据
  const defaultState = window.context.state
  // 当做 store的初始数据(即注水)
  return createStore(reducer, defaultState, applyMiddleware(thunk))
}

стиль импорта

Введение стилей относительно просто и может рассматриваться с двух точек зрения:

  • вывод на стороне сервераhtmlпри документированииhtmlдобавить<style>Тег, строка стиля записывается внутри этого тега и отправляется клиенту вместе
  • вывод на стороне сервераhtmlпри документированииhtmlдобавить<link>этикетка, эта этикеткаhrefУказывает на файл стиля, этот файл стиля является файлом стиля страницы.

Общие идеи этих двух операций схожи, и они аналогичны процессу введения стилей при рендеринге на стороне клиента.webpack,пройти черезloaderплагин будетreactСтиль, написанный в компоненте, извлекается, и связанный с нимloaderПлагины обычно имеютcss-loader,style-loader,extract-text-webpack-plugin / mini-css-extract-plugin, если использоватьcssпостпроцессор, то вам также может понадобитьсяsass-loaderилиless-loaderд., эти сложные ситуации здесь не рассматриваются, только самые основныеcssпредставлять

встроенный стиль

Для первого использования встроенного стиля, непосредственного встраивания стиля в страницу, вам необходимо использоватьcss--loaderа такжеstyle-loader,css-loaderможно продолжать использовать, ноstyle-loaderИз-за некоторой логики, связанной с браузером, его нельзя использовать на стороне сервера, но, к счастью, уже давно существует альтернативный плагин.isomorphic-style-loader, использование этого плагина такое же, какstyle-loaderАналогично, но также поддерживает использование на стороне сервера

isomorphic-style-loaderбудет импортироватьcssФайл преобразуется в объект для использования компонентом, некоторые свойства являются именем класса, а значением свойства является соответствующий класс.cssстили, так что вы можете напрямую вводить стили в компоненты на основе этих свойств.Кроме того, существует несколько методов,SSRнужно позвонить в_getCssметод получения строки стиля для передачи клиенту

Учитывая описанный выше процесс (скороcssСуммирование стилей и преобразование в строки) — это общий процесс, поэтому этот проект подключаемого модуля активно предоставляет метод для упрощения этого процесса.HOCКомпоненты:withStyles.js

То, что делает этот компонент, также очень просто, в основном дляisomorphic-style-loaderдва метода в:__insertCssа также_getCssпредоставляет интерфейс дляContextВ качестве среды стили, на которые ссылается каждый компонент, передаются и, наконец, суммируются на сервере и клиенте, чтобы стили могли быть выведены на сервер и клиент.

Сервер:

import StyleContext from 'isomorphic-style-loader/StyleContext'
// ...
const css = new Set()
const insertCss = (...styles) => styles.forEach(style => css.add(style._getCss()))
const container = renderToNodeStream(
  <Provider store={store}>
    <StaticRouter location={ctx.request.path} context={context}>
      <StyleContext.Provider value={{ insertCss }}>
        {renderRoutes(routes)}
      </StyleContext.Provider>
    </StaticRouter>
  </Provider>
)

Клиент:

import StyleContext from 'isomorphic-style-loader/StyleContext'
// ...
const insertCss = (...styles) => {
  const removeCss = styles.map(style => style._insertCss())
  return () => removeCss.forEach(dispose => dispose())
}

const App = () => {
  return (
    <Provider store={store}>
      <BrowserRouter>
        <StyleContext.Provider value={{ insertCss }}>
          {renderRoutes(routes)}
        </StyleContext.Provider>
      </BrowserRouter>
    </Provider>
  )
}

использование этого компонента более высокого порядка,isomorphic-style-loaderизREADME.mdВыше ясно сказано, в основномContext(isomorphic-style-loader@5.0.1Предыдущая версия является старой версиейContext API,5.0.1а потом новая версияContext API) и компоненты более высокого порядкаHOCиспользование

Стиль контура

Как правило, в большинстве производственных сред используется внешний стиль, использующий<link>Ярлык может быть импортирован на страницу с помощью файла стиля, который фактически представлен вышеописанным охватом.jsПодход представляет собой ту же логику обработки, что и встроенное введение.CSSПроще и понятнее, поток обработки сервера и клиента в основном одинаков.

mini-css-extract-pluginэто часто используемый стиль компонента извлеченияwebpackПодключаемый модуль, поскольку этот подключаемый модуль по существу извлекает строку стиля из каждого компонента и интегрирует ее в файл стиля, простоJS Coreоперации, поэтому нет аргументов на стороне сервера и на стороне браузера, поэтому нет необходимости в изоморфизме.Как использовать этот плагин на чистом клиенте раньше, как использовать сейчасSSRиспользуется в, тут особо нечего сказать

разделение кода

SSRВажной целью является ускорение рендеринга первого экрана, поэтому меры по оптимизации исходного рендеринга на стороне клиента также должны бытьSSRОдним из ключевых моментов является разделение кода

ReactЕсть много разделений кода, таких какbabel-plugin-syntax-dynamic-import,react-loadable,loadable-componentsЖдать

Библиотеки, которые обычно используются,react-loadable, но я столкнулся с некоторыми проблемами при его использовании, я хочу проверитьissuesКогда я узнал, что этот проект на самом делеissuesОн был закрыт, поэтому я забросил его и переключился на болееmodernизloadable-components, эта библиотекаДокументацияполной и с учетомSSRи поддерживаетrenderToNodeStreamОтобразите страницу в виде , просто следуйте документацииokТеперь очень легко начать. Я не буду здесь много говорить. Подробности см.SplitChunkV

Суммировать

SSRПо-прежнему проблематично настраивать не только конфигурацию на уровне внешнего интерфейса, но и вещи, связанные с внутренней программой, такие как состояние входа в систему, высокий уровень параллелизма, балансировка нагрузки, управление памятью и т. д.Winterвыражено дляSSRНе очень оптимистично, в основном используется дляSEO, не рекомендуется для рендеринга на стороне сервера, его можно использовать в нескольких сценах, а стоимость слишком высока.

Поэтому для реальной разработки я лично рекомендую использовать относительно зрелые колеса в отрасли напрямую, такие какReactизNext.js,VueизNuxt.js