React + Koa реализует рендеринг на стороне сервера (SSR), часть II

React.js

Эй, ребята, я написал одинРендеринг на стороне сервера React + Koa SSRСтатья о полугодовой давности.Недавно пересматривал.Обнаружил что библиотека мейнстримного компонента ленивой загрузки устарела,а то статья перед SSR похоже имеет отношение к React-V16,особенно V16 новый Stream API, только что упомянутый в конце предыдущей статьи, поэтому добавьте эти новые функции в эту версию части 2

Why use [Part II]?: Go to play The Last of Us and wait for The Last of Us Part II🚸

🎉Основной контент:

  • ✂️ Заменаreact-loadable,использоватьloadable-components
  • 📉 Используйтеloadable-componentsДля реализации функций асинхронного компонента на стороне браузера и на стороне сервера
  • 🚰 Рендеринг на стороне сервера с использованием API потока реакции
  • 💾Добавить механизм кэширования для контента, отображаемого на сервере (html), подходящий для синхронного и потокового API.

✂️ Заменить реактивно-загружаемый

react-loadable не поддерживается в течение длительного времени, и он не совместим с последними версиями webpack4+ и babel7+, и будет предупреждение об устаревании, если вы используетеkoa-web-kitДля v2.8 и более ранних версий при сборке веб-пакета появится предупреждение, и в нем могут быть некоторые потенциальные неизвестные ямы, поэтому первое, что нам нужно сделать, это заменить его другой библиотекой и следовать новейшимReact.lazy|React SuspenseЭтот тип API полностью совместим,loadable-componentsЯвляетсяОфициальная рекомендацияБиблиотека, если мы хотим лениво загружать компоненты на стороне клиента, но также хотим реализовать SSR (React.lazySSR в настоящее время не поддерживается).

Сначала устанавливаем необходимые библиотеки:

# For `dependencies`:
npm i @loadable/component @loadable/server
# For `devDependencies`:
npm i -D @loadable/babel-plugin @loadable/webpack-plugin

Затем вы можете поместить его в соответствующий файл конфигурации webpack и файл конфигурации babel.react-loadable/webpackа такжеreact-loadable/babelудалить, заменить на@loadable/webpack-pluginа также@loadable/babel-plugin. Затем следующий шаг, который нам нужно, чтобы внести некоторые изменения в наш компонент Lazy загружены.

📉 Используйте загруженные компоненты для реализации функционального браузера асинхронного компонента и сервера

В месте, где вам нужно лениво загружать компоненты React:

// import Loadable from 'react-loadable';
import loadable from '@loadable/component';

const Loading = <h3>Loading...</h3>;
const HelloAsyncLoadable = loadable(
  () => import('components/Hello'),
  { fallback: Loading, }
);
//简单使用
export default MyComponent() {
  return (
    <div>
      <HelloAsyncLoadable />
    </div>
  )
}
//配合 react-router 使用
export default MyComponent() {
  return (
    <Router>
      <Route path="/hello" render={props => <HelloAsyncLoadable {...props}/>}/>
    </Router> 
  )
}

По сути, это похоже на предыдущее использование react-loadable: передать обратный вызов, вернуть динамический импорт и, при желании, передать компоненты, которые должны отображаться во время загрузки.

Затем нам нужно в файле вводаhydrateКонтент, отображаемый сервером, вsrc/index.js:

import React from 'react';
import ReactDOM from 'react-dom';
import { loadableReady } from '@loadable/component';
import App from './App';

loadableReady(() => {
  ReactDOM.hydrate(
    <App />,
    document.getElementById('app')
  );
});

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

При использовании react-loadable раньше нам нужно вызывать на стороне сервераLoadable.preloadAll()Для предзагрузки всех асинхронных компонентов, потому что нет необходимости асинхронно в реальном времени загружать компоненты на стороне сервера, все они могут быть загружены при инициализации, но при использовании loadable-components они уже не нужны, поэтому удалите вызов этого метода напрямую. Затем в нашем файле записи веб-пакета на стороне сервера:

import path from 'path';
import { StaticRouter } from 'react-router-dom';
import ReactDOMServer from 'react-dom/server';
import { ChunkExtractor } from '@loadable/server';
import AppRoutes from 'src/AppRoutes';
//...可能还一下其他的库

function render(url, initialData = {}) {
  const extractor = new ChunkExtractor({ statsFile: path.resolve('../dist/loadable-stats.json') });
  const jsx = extractor.collectChunks(
    <StaticRouter location={url}>
      <AppRoutes initialData={data} />
    </StaticRouter>
  );
  const html = ReactDOMServer.renderToString(jsx);
  const renderedScriptTags = extractor.getScriptTags();
  const renderedLinkTags = extractor.getLinkTags();
  const renderedStyleTags = extractor.getStyleTags();
  return `
      <!DOCTYPE html>
        <html lang="en">
        <head>
          <meta charset="UTF-8">
          <title>React App</title>
          ${renderedLinkTags}
          ${renderedStyleTags}
        </head>
        <body>
          <div id="app">${html}</div>
          <script type="text/javascript">window.__INITIAL_DATA__ = ${JSON.stringify(
            initialData
          )}</script>
          ${renderedScriptTags}
        </body>
      </html>
    `;
}

На самом деле этоrenderToStringБыли внесены некоторые изменения в соседний фрагмент, а некоторые методы записи были изменены в соответствии с новой библиотекой, что в принципе нормально для синхронного рендеринга 😀.

🚰 Рендеринг на стороне сервера с использованием React Stream API

В React v16+ команда React добавила Stream API.renderToNodeStreamДля повышения производительности рендеринга больших приложений React из-за однопоточной функции JS частые синхронные вызовыrenderToStringЭто затормозит цикл обработки событий, заставив другие http-запросы/задачи ждать долгое время, что повлияет на производительность, поэтому далее мы используем потоковый API для повышения производительности рендеринга.

В качестве примера возьмем маршрут коа:

router.get('/index', async ctx => {
  //防止koa自动处理response, 我们要直接把react stream pipe到ctx.res
  ctx.respond = false;
  //见下面render方法
  const {htmlStream, extractor} = render(ctx.url);
  const before = `
        <!DOCTYPE html>
          <html lang="en">
          <head>
            <meta charset="UTF-8">
            ${extractor.getStyleTags()}
          </head>
          <body><div id="app">`;
  //先往res里html 头部信息,包括div容器的一半          
  ctx.res.write(before);
  //把react放回的stream pipe进res, 并且传入`end:false`关闭流的自动关闭,因为我们还有下面一半的html没有写进去
  htmlStream.pipe(
    ctx.res,
    { end: false }
  );
  //监听react stream的结束,然后把后面剩下的html写进html document
  htmlStream.on('end', () => {
    const after = `</div>
        <script type="text/javascript">window.__INITIAL_DATA__ = ${JSON.stringify(
          extra.initialData || {}
        )}</script>
          ${extractor.getScriptTags()}
        </body>
      </html>`;
    ctx.res.write(after);
    //全部写完后,结束掉http response
    ctx.res.end();
  });
});
function render(url){
  //...
  //替换renderToString 为 renderToNodeStream,返回一个ReadableStream,其他都差不多
  const htmlStream = ReactDOMServer.renderToNodeStream(jsx);
  return {
    htmlStream,
    extractor,
  }
  //...
}

Вышеприведенный код аннотирован для объяснения функции каждой строки. Он в основном разделен на три части. Сначала мы пишем html, относящийся к заголовку ответа, а затем направляем readableStream, возвращаемый реакцией на ответ, отслеживаем конец поток реакции, а потом писать остальное.Под общий html, а потом вручную вызыватьres.end()Завершить поток ответов, потому что мы отключили автоматическое закрытие потока ответов выше, поэтому мы должны завершить его здесь вручную, иначе браузер всегда будет находиться в состоянии ожидания.

После использования Stream API в порядке, у нас все еще есть общая проблема в производственной среде: для каждого входящего запроса, особенно некоторых статических страниц, нам фактически не нужно повторно отображать приложение один раз, в этом случае и синхронный рендеринг, и рендеринг потока будет иметь большее или меньшее влияние, особенно когда приложение очень большое, поэтому для решения этой проблемы нам нужно добавить слой кеша посередине, мы можем хранить его в памяти, файле или базе данных, в зависимости на фактической ситуации вашего состояния проекта.

💾Добавить механизм кэширования для рендеринга на стороне сервера, подходящий для синхронного и потокового API.

если мы используемrenderToStringНа самом деле это очень просто, просто возьмите html и сохраните его где-нибудь в соответствии с ключом (url или другим), но это может быть сложно для потокового рендеринга. Поскольку мы передали поток реакции непосредственно в ответ, здесь мы используем 2 типа потока:ReadableStream(Reactom.rendertonOdestream) иWritableStream(ctx.res), но на самом деле в узле есть и другие типы потоков, среди которыхTransformStreamТип может помочь нам решить вышеуказанную проблему с потоком, мы можем направить react readableStream на TransformStream, а затем направить этот TransformStream в res в процессе преобразования (на самом деле мы не изменяли здесь никаких данных, просто чтобы получить все данные html ), мы можем получить весь контент, отображаемый с помощью react, а затем объединить все фрагменты, полученные в конце преобразования, чтобы сформировать полный html, а затем кэшировать его, как метод синхронного рендеринга 🦄

Ладно, без ерунды, сразу к коду:

const { Transform } = require('stream');
//这里简单用Map作为缓存的地方
const cache = new Map();
//临时的数组用来把react stream每次拿到的数据块存起来
const bufferedChunks = [];
//创建一个transform Stream来获取所有的chunk
const cacheStream = new Transform({
  //每次从react stream拿到数据后,会调用此方法,存到bufferedChunks里面,然后原封不动的扔给res
  transform(data, enc, cb) {
    bufferedChunks.push(data);
    cb(null, data);
  },

  //等全部结束后会调用flush
  flush(cb) {
    //把bufferedChunks组合起来,转成html字符串,set到cache中
    cache.set(key, Buffer.concat(bufferedChunks).toString() );
    cb();
  },
});

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

//假设上面的代码已经封装到createCacheStream方法里了,key可以为当前的url,或者其他的
const cacheStream = createCacheStream(key);
//cacheStream现在会pipe到res
cacheStream.pipe(
  res,
  { end: false }
);
//这里只显示部分html
const before = ` <!DOCTYPE html> <html lang="en"> <head>...`;
//现在是往cacheStream里直接写html
cacheStream.write(before);
// res.write(before);
//react stream pipe到cacheStream
htmlStream.pipe(
  cacheStream,
  { end: false }
);
//同上监听react渲染结束
htmlStream.on('end', () => {
  const after = `</div>
      <script type="text/javascript">window.__INITIAL_DATA__ = ${JSON.stringify( {} )}</script>
        ${extractor.getScriptTags()}
      </body>
    </html>`;
  cacheStream.write(after);
  console.log('streaming rest html content done!');
  //结束http response
  res.end();
  //结束cacheStream
  cacheStream.end();
});

Выше мы кидаем htmlStream в cacheStream через пайплайн, чтобы cacheStream мог получить html, отрендеренный React, и кэшировать его, а затем в следующий раз, когда придет тот же запрос url, мы можем проверить его по ключу (например:cache.has(key)) Является ли текущий URL-адрес уже отрендеренным html, и если да, то передать его прямо в браузер без повторного рендеринга.

Ну, вышеизложенное является основным содержанием этого обновления SSR.

💖koa-web-kit😀

В заключение

Основное содержание части II приведено выше.Мы в основном заменили загружаемые файлы, которые больше не поддерживаются, а затем использовали потоковый API для повышения производительности рендеринга больших приложений React, а также дополнительно улучшили скорость отклика, добавив слой кеша. 🎉. Выше могут быть некоторые связанные с потоками API, которые должны быть поняты незнакомыми учащимися в первую очередь.node streamСвязанный контент, если вы хотите проверить базовую конфигурацию SSR, вы также можете просмотретьпервая частьСодержание.

🙌Ждите третью часть🙌

English Version: React Server Side Rendering with Koa Part II