React SSR 技術摘要

單頁面應用(SPA)模式被愈來愈多的站點所採用,這種模式意味着使用 JavaScript 直接在瀏覽器中渲染頁面。全部邏輯,數據獲取,模板和路由都在客戶端處理,勢必面臨着首次有效繪製(FMP)耗時較長和不利於搜索引擎優化(SEO)的問題。css

「同構(Universal)」 是指一套代碼能夠在服務端和客戶端兩種環境下運行,經過用這種靈活性,能夠在服務端渲染初始內容輸出到頁面,後續工做交給客戶端來完成,最終來解決SEO的問題並提高性能。「同構應用」 就像是精靈,能夠遊刃有餘的穿梭在服務端與客戶端之間各盡其能。html

可是想駕馭 「同構應用」 每每會面臨一系列的問題,下面針對一個示例進行一些細節介紹。前端

示例代碼: https://github.com/xyyjk/reac...

構建配置

選擇一個靈活的腳手架爲項目後續的自定義功能及配置是十分有利的,Neutrino 提供了一些經常使用的 Webpack 預設配置,這些預設中包含了開發過程當中常見的一些插件及配置使初始化和構建項目的過程更加簡單。node

下面基於預設作一些自定義配置,你能夠隨時經過運行 node_modules/.bin/neutrino --inspect 來了解最終完整的 Webpack 配置。react

客戶端配置

這裏基於 @neutrinojs/react 預設作一些定義用於開發webpack

.neutrinorc.js
const isDev = process.env.NODE_ENV !== 'production';
const isSSR = process.argv.includes('--ssr');

module.exports = {
  use: [
    ['@neutrinojs/react', {
      devServer: {
        port: isSSR ? 3000 : 5000,
        host: '0.0.0.0',
        disableHostCheck: true,
        contentBase: `${__dirname}/src`,
        before(app) { if(isSSR) { require('./src/server')(app); } },
      },
      manifest: true,
      html: isSSR ? false: {},
      clean: { paths: ['./node_modules/.cache']},
    }],

    ({ config }) => {
      if (isDev) { return; }

      config
        .output
          .filename('assets/[name].[chunkhash].js')
          .chunkFilename('assets/chunk.[chunkhash].js')
          .end()
        .optimization
          .minimize(false)
          .end();
    },
  ],
};

爲了達到開發環境下能夠選擇 SSR(服務端渲染)、CSR(客戶端渲染) 任意一種渲染模式,經過定義變量 isDevisSSR 用以作差別配置:git

devServer.before 方法能夠在服務內部的全部其餘中間件以前,提供執行自定義中間件的功能。github

SSR 模式 下加入一箇中間件,稍後用於進行處理服務端組件內容渲染,同時很好的利用到了 devServer.hot 熱更新功能。web

SSR 模式 下使用動態定義 html 模板(src/server/template.js),這裏把底層使用的 html-webpack-plugin 去掉。npm

啓用 manifest 插件,打包後生成資源映射文件用於服務端渲染時模板中引入。

服務端配置

構建用於服務端運行的配置項稍有不一樣,因爲 SSR 模式 最終代碼要運行在 node 環境,這裏須要對配置再作一些調整:

  • target 調整爲 node,編譯爲類 Node 環境可用
  • libraryTarget 調整爲 commonjs2,使用 Node 風格導出模塊
  • @babel/preset-env 運行環境調整爲 node,編譯結果爲 ES6 代碼
  • 排除組件中 css/sass 資源的引用,生產環境直接使用經過 manifest 插件構建出的映射文件來讀取資源

在打包的時候經過 webpack-node-externals 排除 node_modules 依賴模塊,可使服務器構建速度更快,並生成較小的 bundle 文件。

webpack.server.config.js
const Neutrino = require('neutrino/Neutrino');
const nodeExternals = require('webpack-node-externals');
const NormalPlugin = require('webpack/lib/NormalModuleReplacementPlugin');
const babelMerge = require('babel-merge');
const config = require('./.neutrinorc');

const neutrino = new Neutrino();

neutrino.use(config);

neutrino.config
  .target('node')

  .entryPoints
    .delete('index')
    .end()

  .entry('server')
    .add(`${__dirname}/src/server`)
    .end()

  .output
    .path(`${__dirname}/build`)
    .filename('server.js')
    .libraryTarget('commonjs2')
    .end()

  .externals([nodeExternals()])

  .plugins
    .delete('clean')
    .delete('manifest')
    .end()

  .plugin('normal')
    .use(NormalPlugin, [/\.css$/, 'lodash/noop'])
    .end()

  .optimization
    .minimize(false)
    .runtimeChunk(false)
    .end()

  .module
    .rule('compile')
    .use('babel')
    .tap(options => babelMerge(options, {
      presets: [
        ['@babel/preset-env', {
          targets: { node: true },
        }],
      ],
    }));

module.exports = neutrino.config.toConfig();

環境差別

因爲運行環境和平臺 API 的差別,當運行在不一樣環境中時,咱們的代碼將不會徹底相同。

Webpack 全局對象中定義了 process.browser,能夠在開發環境中來判斷當前是客戶端仍是服務端。

自定義中間件

開發環境 SSR 模式 下,若是咱們在組件中引入了圖片或樣式資源,不通過 webpack-loader 進行編譯,Node 環境下是沒法直接運行的。在 Node 環境下,經過 ignore-styles 能夠把這些資源進行忽略。

此外,爲了讓 Node 環境下可以運行 ES6 模塊的組件,須要引入 @babel/register 來作一些轉換:

src/server/register.js
require('ignore-styles');

require('@babel/register')({
  presets: [
    ['@babel/preset-env', {
      targets: { node: true },
    }],
    '@babel/preset-react',
  ],
  plugins: [
    '@babel/plugin-proposal-class-properties',
  ],
});

若是 Webpack 中配置了 resolve.alias,與之對應的還須要增長 babel-plugin-module-resolver 插件來作解析。

清除模塊緩存

因爲 require() 引入方式模塊將會被緩存, 爲了使組件內的修改實時生效,經過 decache 模塊從 require() 緩存中刪除模塊後再次從新引用:

src/server/dev.js
require('./register');

const decache = require('decache');
const routes = require('./routes');
let render = require('./render');

const handler = async (req, res, next) => {
  decache('./render');
  render = require('./render');
  res.send(await render({ req, res }));
  next();
};

module.exports = (app) => {
  app.get(routes, handler);
};

服務端渲染

在服務端經過 ReactDOMServer.renderToString() 方法將組件渲染爲初始 HTML 字符串。

獲取數據每每須要從 querycookie 中取一些內容做爲接口參數,
Node 環境下沒有 windowdocument 這樣的瀏覽器對象,能夠藉助 Express 的 req 對象來拿到一些信息:

  • href: ${req.protocol}://${req.headers.host}${req.url}
  • cookie: req.headers.cookie
  • userAgent: req.headers['user-agent']
src/server/render.js
const React = require('react');
const { renderToString } = require('react-dom/server');

...

module.exports = async ({ req, res }) => {
  const locals = {
    data: await fetchData({ req, res }),
    href: `${req.protocol}://${req.headers.host}${req.url}`,
    url: req.url,
  };

  const markup = renderToString(<App locals={locals} />);
  const helmet = Helmet.renderStatic();

  return template({ markup, helmet, assets, locals });
};

入口文件

前端調用 ReactDOM.hydrate() 方法把服務端返回的靜態 HTML 與事件相融合綁定。

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

const renderMethod = ReactDOM[module.hot ? 'render' : 'hydrate'];
renderMethod(<App />, document.getElementById('root'));

根組件

在服務端使用 StaticRouter 組件,經過 location 屬性設置服務器收到的URL,並在 context 屬性中存入渲染期間所須要的數據。

src/App.jsx
import React from 'react';
import { BrowserRouter, StaticRouter, Route } from 'react-router-dom';
import { hot } from 'react-hot-loader/root';

...

const Router = process.browser ? BrowserRouter : StaticRouter;

const App = ({ locals = {} }) => (
  <Router location={locals.url} context={locals}>
    <Layout>
      <Route exact path="/" component={Home}/>
      <Route path="/about" component={About}/>
      <Route path="/contact" component={Contact}/>
      <Route path="/character/:key" component={Character}/>
    </Layout>
  </Router>
);

export default hot(App);

內容數據

經過 constructor 接收 StaticRouter 組件傳入的數據,客戶端 URL 與服務端請求地址相一致時直接使用傳入的數據,不然再進行客戶端數據請求。

src/comps/Content.jsx
import React from 'react';
import { withRouter } from 'react-router-dom';
import fetchData from '../utils/fetchData';

function isCurUrl() {
  if (!window.__INITIAL_DATA__) { return false; }
  return document.location.href === window.__INITIAL_DATA__.href;
}

class Content extends React.Component {
  constructor(props) {
    super(props);

    const { staticContext = {} } = props;
    let { data = {} } = staticContext;

    if (process.browser && isCurUrl()) {
      data = window.__INITIAL_DATA__.data;
    }

    this.state = { data };
  }

  async componentDidMount() {
    if (isCurUrl()) { return; }
    
    const { match } = this.props;
    const data = await fetchData({ match });

    this.setState({ data });
  }

  
  render() {
    return this.props.render(this.state);
  }
}

export default withRouter(Content);

自定義標記

一般在不一樣頁面中須要輸出不一樣的頁面標題、頁面描述,HTML 屬性等,能夠藉助 react-helmet 來處理此類問題:

模板設置

const markup = ReactDOMServer.renderToString(<Handler />);
const helmet = Helmet.renderStatic();

const template = `
<!DOCTYPE html>
<html ${helmet.htmlAttributes.toString()}>
  <head>
    <meta charset="UTF-8">
    ${helmet.title.toString()}
    ${helmet.meta.toString()}
    ${helmet.link.toString()}
  </head>
  <body ${helmet.bodyAttributes.toString()}>
    <div id="root">${markup}</div>
  </body>
</html>
`;

組件中的使用

import React from 'react';
import Helmet from 'react-helmet';

const Contact = () => (
  <>
    <h2>This is the contact page</h2>
    <Helmet>
      <title>Contact Page</title>
      <meta name="description" content="This is a proof of concept for React SSR" />
    </Helmet>
  </>
);

總結

想要作好 「同構應用」 並不簡單,須要瞭解很是多的概念。好消息是目前 React 社區有一些比較著名的同構方案 Next.jsRazzleElectrode 等,若是你想快速入手 React SSR 這些或許是不錯的選擇。若是面對複雜應用,自定義完整的體系將會更加靈活。

相關文章
相關標籤/搜索