Изоморфизм React Server Rendering (SSR) с нуля 😏 (на основе Koa)

React.js
Изоморфизм React Server Rendering (SSR) с нуля 😏 (на основе Koa)

предисловие

Из внешнего интерфейса (React,Vue,Angelar) с момента своего появления каждый фреймворк несет в себе разную идею и делится на три лагеря, использовавшиеся ранееJQueryПрошли те времена, когда каждая страница былаHTML, ввести соответствующиеJS,CSS, пока вHTMLнаписано наDOM. Из-за этого каждый раз, когда пользователь заходит, из-заHTMLимеютDOMСуществование , по чувству реакции пользователя не очень медленно.

Но так как используется фреймворк, сколько бы в нем ни было страниц, это одна-единственная страница, т. е.SPA.HTMLвсе вDOMэлемент, который необходимо загрузить на клиентеjsПосле этого выполните, вызвавReact.render()Его можно только отрендерить, поэтому есть много сайтов, которые долго заходят.loadingанимация.

Для того, чтобы решить эту не очень дружественную проблему, в сообществе было предложено множество решений, таких как预渲染,SSR,同构.

Конечно, в этой статье в основном рассказывается о построении нуля.Изоморфизм рендеринга сервера React.

Опции

Вариант 1. Используйте выбранный сообществом фреймворк Next.js.

Next.jsлегкийReactКаркас приложения рендеринга на стороне сервера. Желающие могут пройтиNext.jsИсследование на официальном сайте.

Схема 2 Изоморфизм

Есть две схемы изоморфизма:

Выполнить после экранирования кода на стороне узла и кода React через Babel

let app = express();
app.get('/todo', (req, res) => {
     let html = renderToString(
     <Route path="/" component={ IComponent } >
        <Route path="/todo" component={ AComponent }>
        </Route>
    </Route>)
     res.send( indexPage(html) )
    }
})  

Здесь нужно решить две проблемы:

  • Nodeпередняя часть не поддерживаетсяimportсинтаксис, который необходимо ввестиbabelслужба поддержки.
  • NodeНе удалось проанализировать синтаксис тега.

так выполнитьNode, вам нужно использоватьbabelЧтобы сбежать, если есть ошибка, нет возможности ее проверить.Лично это делать не рекомендуется.

Так вот второй вариант

вебпак для компиляции

использоватьwebpackУпакуйте два кода, один дляNodeВыполняется рендеринг сервера, а один используется для рендеринга браузера.

Ниже приводится подробное описание.

Создайте сервер узла

Из-за привычек использования часто используетсяEggкадр, иKoaдаEggБазовая структура, поэтому здесь мы используемKoaКаркас для сервисного строительства.

Создайте самый простойNodeСлужить.

const Koa = require('koa');
const app = new Koa();

app.listen(3000, () => {
  console.log("服务器已启动,请访问http://127.0.0.1:3000")
});

настроить веб-пакет

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

webpackпередавать один и тот же код через разныеwebpackконфигурации соответственноserverConfigа такжеclientConfig, упакованный в виде двух кодов.

конфигурация serverConfig и clientConfig

пройти черезwebpackИз документации мы знаем, что webpack может компилировать не только веб-код, но и другой контент.

Здесь мы будемtargetустановить какnode.

Настройте файл входа и место выхода:

const serverConfig = {
  target: 'node',
  entry: {
    page1: './web/render/serverRouter.js',
  },
  resolve,
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, './app/build'),
    libraryTarget: 'commonjs'
  }
 }

Внимание ⚠

Конфигурация сервера должна быть настроенаlibraryTarget,настраиватьcommonjsилиumd, для серверной частиrequireцитата, иначеrequireзначение{}.

Здесь нет разницы между конфигурацией клиента и сервера, настройка не требуетсяtarget(По умолчаниюwebсреда), другие входные и выходные файлы несовместимы.

const clientConfig = {
  entry: {
    page1: './web/render/clientRouter.js'
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, './public')
  }
}

настроить бабел

Так как пакетReactкод, поэтому также необходимо настроитьbabel.
новый.babelrcдокумент.

{
  "presets": ["@babel/preset-react",
    ["@babel/preset-env",{
      "targets": {
        "browsers": [
          "ie >= 9",
          "ff >= 30",
          "chrome >= 34",
          "safari >= 7",
          "opera >= 23",
          "bb >= 10"
        ]
      }
    }]
  ],
  "plugins": [
    [
      "import",
      { "libraryName": "antd", "style": true }
    ] 
  ]
}

Эта конфигурация используется сервером и клиентом для обработки.ReactИ сбежал какES5и проблемы совместимости браузера.

Обработка проблем со ссылками на стороне сервера

использование сервераCommonJSСпецификации, а серверный код собирать не нужно, поэтому зависимости в node_modules не нужно упаковывать, поэтому с помощьюwebpackсторонние модулиwebpack-node-externalsПосле такой обработки размер двух построенных файлов уже сильно отличается.

обрабатывать css

Разница между сервером и клиентом может заключаться в обработке по умолчанию, необходимостиCSSИзвлекается отдельно как файл и обрабатываетсяCSSприставка.

Конфигурация сервера

  {
    test: /\.(css|less)$/,
    use: [
      {
        loader: 'css-loader',
        options: {
          importLoaders: 1
        }
      },
      {
        loader: 'less-loader',
      }
    ]
  }

Конфигурация клиента

  {
    test: /\.(css|less)$/,
    use: [
      {
        loader: MiniCssExtractPlugin.loader,
      },
      {
        loader: 'css-loader'
      },
      {
        loader: 'postcss-loader',
        options: {
          plugins: [
            require('precss'),
            require('autoprefixer')
          ],
        }
      },
      {
        loader: 'less-loader',
        options: {
          javascriptEnabled: true,
          // modifyVars: theme   //antd默认主题样式
        }
      }
    ],
  }

Различия между клиентской рендерингом и кодом маршрутизации бокового рендеринга сервера в SSR

выполнитьReactизSSRАрхитектура, нам нужно выполнить один и тот же код на стороне клиента и на стороне сервера, но здесь каждый выполняет его один раз, исключая код на стороне маршрутизации, причина этого в основном в том, что клиент использует адресную строку для отображения разных компоненты , и сервер отображает компонент через путь запроса.
Поэтому на стороне клиента мы используемBrowserRouterДля настройки маршрутизации используйте на стороне сервераStaticRouterдля настройки маршрутизации.

Конфигурация клиента

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from "react-router-dom";
import Router from '../router';

function ClientRender() {
  return (
      <BrowserRouter >
        <Router />
      </BrowserRouter>
  )
}

Конфигурация сервера

import React from 'react';
import { StaticRouter } from 'react-router'
import Router from '../router.js';

function ServerRender(req, initStore) {

  return (props, context) => {
    return (
        <StaticRouter location={req.url} context={context} >
          <Router />  
        </StaticRouter>
    )
  }
}

export default ServerRender;

Снова настройте Node для серверного рендеринга

Настроенный выше сервер просто запускает службу без дополнительной настройки.

Представляем ReactDOMServer


const Koa = require('koa');
const app = new Koa();
const path = require('path');
const React = require('react');
const ReactDOMServer = require('react-dom/server');
const koaStatic = require('koa-static');
const router = new KoaRouter();

const routerManagement = require('./app/router');
const manifest = require('./public/manifest.json');
/**
 * 处理链接
 * @param {*要进行服务器渲染的文件名默认是build文件夹下的文件} fileName 
 */
function handleLink(fileName, req, defineParams) {
  let obj = {};
  fileName = fileName.indexOf('.') !== -1 ? fileName.split('.')[0] : fileName;

  try {
    obj.script = `<script src="${manifest[`${fileName}.js`]}"></script>`;
  } catch (error) {
    console.error(new Error(error));
  }
  try {
    obj.link = `<link rel="stylesheet" href="${manifest[`${fileName}.css`]}"/>`;
    
  } catch (error) {
    console.error(new Error(error));
  }
  //服务器渲染
  const dom = require(path.join(process.cwd(),`app/build/${fileName}.js`)).default;
  let element = React.createElement(dom(req, defineParams));
  obj.html = ReactDOMServer.renderToString(element);

  return obj;
}

/**
 * 设置静态资源
 */
app.use(koaStatic(path.resolve(__dirname, './public'), {
  maxage: 0, //浏览器缓存max-age(以毫秒为单位)
  hidden: false, //允许传输隐藏文件
  index: 'index.html', // 默认文件名,默认为'index.html'
  defer: false, //如果为true,则使用后return next(),允许任何下游中间件首先响应。
  gzip: true, //当客户端支持gzip时,如果存在扩展名为.gz的请求文件,请尝试自动提供文件的gzip压缩版本。默认为true。
}));

/**
* 处理响应
* 
* **/
app.use((ctx) => {
    let obj = handleLink('page1', ctx.req, {});
    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>koa-React服务器渲染</title>
          ${obj.link}
        </head>
        
        <body>
          <div id='app'>
             ${obj.html}
          </div>
        </body>
        ${obj.script}
        </html>
    `
})

app.listen(3000, () => {
  console.log("服务器已启动,请访问http://127.0.0.1:3000")
});

Это включает в себяmanifestфайл, этот файлwebpackплагинwebpack-manifest-pluginСгенерировано, в котором содержится компиляционные адреса и файлы. Вероятно, структура такова:

{
  "page1.css": "page1.css",
  "page1.js": "page1.js"
}

мы знакомим его сclientConfig, добавьте следующую конфигурацию:

...
plugins: [
    // 提取样式,生成单独文件
    new MiniCssExtractPlugin({
        filename: `[name].css`,
        chunkFilename: `[name].chunk.css`
    }),
    new ManifestPlugin()
]

В приведенном выше коде на стороне сервера мы имеемServerRender.jsКаррирование делается для того, чтобы мыServerRender, используя идентифицируемый на стороне сервераStaticRouterи настроитьlocationпараметры, при этомlocationобязательный параметрURL.
Поэтому нам нужноrenderToStringпройти вreq, чтобы сервер мог корректно разобрать компонент React.

  let element = React.createElement(dom(req, defineParams));
  obj.html = ReactDOMServer.renderToString(element);

пройти черезhandleLink, мы можем получитьobj, который содержит три параметра,link(cssСвязь),script(JSссылка) иhtml(генерироватьDomэлемент).

пройти черезctx.bodyоказыватьhtml.

renderToString()

БудуReactЭлемент рендерится в исходноеHTMLсередина. Эту функцию следует использовать только на сервере.ReactвернетHTMLнить. Вы можете использовать этот метод для генерации на сервереHTML, и отправьте разметку по первоначальному запросу, чтобы ускорить загрузку страницы и позволить поисковым системам сканировать вашу страницу дляSEOЦель.

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

renderToStaticMarkup()

похожий наrenderToString, Кроме этого не создаетReactдополнительно для внутреннего пользованияDOMтакие свойства, какdata-reactroot. если вы хотите использоватьReactКак простой генератор статических страниц, это полезно, потому что удаление дополнительных атрибутов может сэкономить несколько байтов.

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

renderToNodeStream()

БудуReactЭлемент отображается в исходном виде.HTMLсередина. возвращает читаемый поток (stream), то есть выходHTMLнить. этот поток(stream) выходHTMLточно эквивалентноReactDOMServer.renderToStringчто будет возвращено.

Мы также можем использовать вышеуказанноеrenderToNodeSteamПревратить его вниз:

  let element = React.createElement(dom(req, defineParams));
  
  ctx.res.write('
  <html>
      <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>koa-React服务器渲染</title>
      </head><body><div id="app">');
  
  // 把组件渲染成流,并且给Response
  const stream = ReactDOMServer.renderToNodeStream(element);
  stream.pipe(ctx.res, { end: 'false' });
  
  // 当React渲染结束后,发送剩余的HTML部分给浏览器
  stream.on('end', () => {
    ctx.res.end('</div></body></html>');
  });

renderToStaticNodeStream()

похожий наrenderToNodeStream, за исключением того, что это не создастReactдополнительно для внутреннего пользованияDOMтакие свойства, какdata-reactroot. если вы хотите использоватьReactКак простой генератор статических страниц, это полезно, потому что удаление дополнительных атрибутов может сэкономить несколько байтов.

этот поток(stream) выходHTMLточно эквивалентноReactDOMServer.renderToStaticMarkupчто будет возвращено.

добавить редукцию управления состоянием

Вышеупомянутое разрабатывает статический веб-сайт или уже относительно простой проект.OKНо для сложных проектов этого далеко не достаточно.Тут к этому добавим глобальное управление состояниемRedux.

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

  • Получите данные заранее
  • инициализировать магазин
  • Отображение компонентов на основе маршрутов
  • Объединяйте данные и компоненты для создания HTML и возвращайте его одновременно

Для клиента добавитьreduxи регулярныйreduxНе очень разница, просто дляstoreдобавлен инициалwindow.__INIT_STORE__.

let initStore = window.__INIT_STORE__;
let store = configStore(initStore);

function ClientRender() {
  return (
    <Provider store={store}>
      <BrowserRouter >
        <Router />
      </BrowserRouter>
    </Provider>

  )
}

Для сервера после завершения начального сбора данных можно использоватьPromise.all()Чтобы сделать параллельные запросы, когда запрос заканчивается, заполните данные дляscriptвнутри этикетки, названнойwindow.__INIT_STORE__.

`<script>window.__INIT_STORE__ = ${JSON.stringify(initStore)}</script>`

Затем на стороне сервераstoreПереконфигурировать.

function ServerRender(req, initStore) {
  let store = CreateStore(JSON.parse(initStore.store));

  return (props, context) => {
    return (
      <Provider store={store}>
        <StaticRouter location={req.url} context={context} >
          <Router />  
        </StaticRouter>
      </Provider>
    )
  }
}

Организовать Коа

Учитывая удобство дальнейшей разработки, добавить следующие функции:

  • Функция маршрутизатора
  • HTML-шаблон

Добавить Коа-маршрутизатор

/**
 * 注册路由
 */
const router = new KoaRouter();
const routerManagement = require('./app/router');
...
routerManagement(router);
app.use(router.routes()).use(router.allowedMethods());

Для того, чтобы при разработке интерфейс был штатным, все маршруты упоминаются в новом файле для записи. И гарантировать следующий формат:

/**
 *
 * @param {router 实例化对象} router
 */

const home = require('./controller/home');

module.exports = (router) => {
  router.get('/',home.renderHtml);
  router.get('/page2',home.renderHtml);
  router.get('/favicon.ico',home.favicon);
  router.get('/test',home.test);
}

Шаблоны процессов

БудуhtmlВставлять его в код не очень дружелюбно, поэтому здесь также представлен шаблон сервиса.koa-nunjucks-2.

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

...
const koaNunjucks = require('koa-nunjucks-2');
...
/**
 * 服务器渲染,渲染HTML,渲染模板
 * @param {*} ctx 
 */
function renderServer(ctx) {
  return (fileName, defineParams) => {
    let obj = handleLink(fileName, ctx.req, defineParams);
    // 处理自定义参数
    defineParams = String(defineParams) === "[object Object]" ? defineParams : {};
    obj = Object.assign(obj, defineParams);
    ctx.render('index', obj);
  }
}

...

/**
 * 模板渲染
 */
app.use(koaNunjucks({
  ext: 'html',
  path: path.join(process.cwd(), 'app/view'),
  nunjucksConfig: {
    trimBlocks: true
  }
}));

/**
 * 渲染Html
 */
app.use(async (ctx, next) => {
  ctx.renderServer = renderServer(ctx);
  await next();
});

Когда пользователь обращается к серверу, вызываяrenderServerфункция, обработка цепочки, выполнение до конца, вызовctx.renderЗавершить рендеринг.


/**
 * 渲染react页面
 */

 exports.renderHtml = async (ctx) => {
    let initState = ctx.query.state ? JSON.parse(ctx.query.state) : null;
    ctx.renderServer("page1", {store: JSON.stringify(initState ? initState : { counter: 1 }) });
 }
 exports.favicon = (ctx) => {
   ctx.body = null;
 }

 exports.test = (ctx) => {
   ctx.body = {
     data: `测试数据`
   }
 }

оkoa-nunjucks-2в рендерингеHTML, будут< >Поэтому для обеспечения безопасности нам также необходимо фильтровать данные, которые мы передаем.

<!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>koa-React服务器渲染</title>
  {{ link | safe }}
</head>

<body>
  <div id='app'>
    {{ html | safe }}
  </div>
</body>
<script>
  window.__INIT_STORE__ = {{ store | safe }}
</script>
{{ script | safe }}
</html>

структура документа

├── README.md
├── app  //node端业务代码
│   ├── build
│   │   ├── page1.js
│   │   └── page2.js
│   ├── controller
│   │   └── home.js
│   ├── router.js
│   └── view
│       └── index.html
├── index.js
├── package.json
├── public //前端静态资源
│   ├── manifest.json
│   ├── page1.css
│   ├── page1.js
│   ├── page2.css
│   └── page2.js
├── web  //前端源码
│   ├── action //redux -action
│   │   └── count.js
│   ├── components  //组件
│   │   └── layout
│   │       └── index.jsx
│   ├── pages //主页面
│   │   ├── page
│   │   │   ├── index.jsx
│   │   │   └── index.less
│   │   └── page2
│   │       ├── index.jsx
│   │       └── index.less
│   ├── reducer //redux -reducer
│   │   ├── counter.js
│   │   └── index.js
│   ├── render  //webpack入口文件
│   │   ├── clientRouter.js
│   │   └── serverRouter.js
│   ├── router.js //前端路由
│   └── store //store
│       └── index.js
└── webpack.config.js

наконец

В настоящее время эту архитектуру можно запустить только вручную.Koaобслуживание и запускwebpack.

Если вам нужно запустить Koa и webpack вместе, здесь затронута другая тема, вы можете проверить статью, которую я написал в начале, здесь.

"Сао Ниан, а как насчет Коа и Вебпака?

Если вам нужно понять, что функционирует полный сервер, проверьте мою предыдущую статью.

"Как создать надежный и стабильный веб-сервер

наконецGITHUBАдрес следующий:

Рендеринг сервера React на основе koa

Использованная литература:

"Реагировать на китайскую документацию
"Документация Webpack на китайском языке
"Принцип изоморфизма (SSR) в React
"Redux