阅读 547

React服务端渲染二三事

为何使用

传统SSR

SSR服务端渲染(英语:server side render)。服务端渲染把数据的初始请求放在了服务端,服务端收到请求后,把数据填充到模板形成完整的页面,由服务端把渲染的完整的页面返回给客户端。

SSR 优缺点

服务端直出HTML会让首屏较快展现,且利于SEO。但所有页面的加载都需向服务端请求,如果访问量较大,会对服务器造成压力。此外,页面之间的跳转,页面局部内容的变动都会引起页面刷新,体验不够友好。

CSR

CSR客户端渲染(英文:Client Side Rendering)。它是目前 Web 应用中主流的渲染模式,一般由 Server 端返回初始 HTML 内容,然后再由JS 去异步加载数据,再完成页面的渲染。客户端渲染模式中最流行的开发模式当属SPA(单页应用)。这种模式下服务端只会返回一个页面的框架和js 脚本资源,而不会返回具体的数据。

CSR(SPA) 优缺点

只有首次进入或刷新时需要请求服务器,页面之间的跳转由JS脚本完成,响应较快。但由于服务端只返回一个空节点的HTML,页面内容的呈现需等待JS脚本加载执行完毕,首屏时间较长,对SEO也不友好。

React SSR

相比于客户端渲染SPA应用

由于首次进入或刷新页面时,服务端直接将有内容的页面返回给客户端,大大降低了白屏时间,同样也便于做SEO

相比于传统的SSR应用

不必跳转到不同页面都需要刷新一次浏览器,只在第一次访问的时候服务端直出HTML,后续的页面跳转走CSR(客户端渲染)

如何使用

组件同构

服务端

对于一个React应用,想在首次进入或刷新页面服务端就直接返回完整的HTML,需要在服务端将当前页面需渲染的组件转换成HTML,React为此提供了相应方法。

import { renderToString } from 'react-dom/server'

const html = renderToString(
  <App />
)
复制代码

renderToString方法外,Reeact也提供了ReactDom.renderToNodeStream方法,返回一个可输出HTML 字符串的可读流。

客户端

虽然服务端已经直出了需要渲染的HTML,但一些事件绑定的操作还是需要客户端JS脚本来完成。如果客户端依旧执行ReactDOM.render方法,会在首次调用时将容器节点下的所有DOM元素替换,这显然不是我们想要的结果,好在React提供了ReactDom.hydrate方法。

import ReactDom from 'react-dom'

ReactDom.hydrate(
  <App />,
  document.getElementById('root')
)
复制代码

与 render() 相同,但它用于在 ReactDOMServer 渲染的容器中对 HTML 的内容进行 hydrate 操作。React 会尝试在已有标记上绑定事件监听器。

在客户端渲染时,hydrate方法会比较双端渲染结果是否一致,如一致则保留服务端渲染的结果,如不一致则使用客户端渲染的结果。

路由同构

SPA应用的路由完全由客户端控制,跳转到不同路径时JS脚本会替换组件使之呈现出不同内容。

而对React SSR应用来说,只要用户首次进入或刷新页面时服务端能渲染正确的组件即可,后续的路由切换则完全由客户端JS脚本控制。react-router提供StaticRouter组件可根据当前请求路径匹配渲染不同组件。

import { StaticRouter } from 'react-router'

// path为请求路径
<StaticRouter location={path}>
  <App />
</StaticRouter>
复制代码

数据同构

有时需要在服务端请求数据,直接渲染出带数据的HTML页面。而由于客户端无此数据,渲染内容就会和服务端不一致。那如何将服务端请求的数据“注入”客户端呢?

数据注水

服务端可以控制直出的HTML内容,既然如此就可以在直出的HTML内容中插入一段脚本。next.js便是如此实现的。

数据脱水

服务端已将数据写入script标签中,客户端渲染时便可直接用该数据进行渲染。

SEO TDK支持

如果只是要做SEO支持,可以全部放在服务端,根据不同页面路径直出不同的TDK数据。

但为保证体验和提高可维护性,最好是能将TDK写在页面组件里。这需要服务端渲染时能获取到当前页面的TDK数据,直出到HTML,客户端渲染时能够比较TDK数据,做DOM操作用新页面的TDK数据替换掉老页面的。

react-helmet对此提供了较好的支持

服务端示例(Koa)

import React from 'react'
import { renderToString } from 'react-dom/server'
import { Helmet } from 'react-helmet'


export default async (ctx, next) => {
  const html = renderToString(
    <App />
  )

  const helmet = Helmet.renderStatic()

  ctx.body = `<!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8">
          ${helmet.title.toString()}
          ${helmet.meta.toString()}
      </head>
      <body>
        <div id="root">${html}</div>
      </body>
    </html>
  `
 
  return next();
}

复制代码

客户端示例

import { Helmet } from 'react-helmet'
import tempData from './data'

const Index = () => {
  return (
    <>
      <Helmet>
        <title>index title</title>
        <meta name="description" content="index description"></meta>
        <meta name="keyword" content="index keyword"></meta>
      </Helmet>
      <div>Index</div>
    </>
  )
}

export default Index

复制代码

CSS同构

目前next.jsegg-react-ssr都是将css代码最终打包到一个文件内作为资源进行加载。

按这种方式,服务端直出的HTML结果应包含css文件的link标签,而客户端JS脚本无需插入link标签。

客户端

客户端使用mini-css-extract-plugin插件提取css文件,如果使用了按需加载还需将所有css提取为单个文件

const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  optimization: {
    splitChunks: {
      cacheGroups: {
        styles: {
          name: 'styles',
          type: 'css/mini-extract',
          // For webpack@4
          // test: /\.css$/,
          chunks: 'all',
          enforce: true,
        },
      },
    },
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].css',
    }),
  ],
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader'],
      },
    ],
  },
};

复制代码

在打包时一般静态资源文件名会带上hash值,这时为保证服务端能获取到正确的路径还需使用webpack-manifest-plugin插件生成文件名和路径的映射文件,方便服务端获取正确的路径。

如果使用react-loadable做按需加载,则可使用其提供的react-loadable-ssr-addon插件生成映射文件。

服务端

如果没有启用css模块化,客户端打包时已将用到的css打包到了一个文件,服务端就无需处理css文件。

webpack中可使用ignore-loader忽略css文件的处理

module.exports = {
  // other configurations
  module: {
    loaders: [
      { test: /\.css$/, loader: 'ignore-loader' }
    ]
  }
};
复制代码

如果启用了css模块化,css-loader会生成标识符映射,服务端也需要生成标识符以保证双端渲染结果一致。css-loadermodule.exportOnlyLocals选项提供了仅导出标识符映射,而不嵌入css的功能

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/i,
        loader: "css-loader",
        options: {
          modules: {
            exportOnlyLocals: true,
          },
        },
      },
    ],
  },
};

复制代码

性能优化

按需加载(代码分割)

对一个较大项目来说,我们访问其中的一个页面时,只需加载当前页面的代码,其他页面的代码只要在访问的时候再加载即可。这可以有效减少访问一个页面时需加载的js文件体积,提高页面响应速度。

在一个SPA应用中实现按需加载,只需使用dynamic import语法,webpack支持该特性。使用动态import语法导入的模块会被单独打包到一个文件。

React SSR实现按需加载的一些坑

  1. 服务端渲染组件时,无法将按需加载的组件渲染成HTML
  2. 即使服务端能够直出按需加载组件的HTML,客户端接管后由于异步JS代码尚未加载,会先展示中间状态(一般中间状态会先渲染loading),这样双端的初次渲染结果就不一致了
  3. 既然采取了服务端渲染,在服务端渲染时直出的HTML最后能包含当前页面需异步加载的JS代码,即script标签,无需客户端动态创建

针对第一点,其实服务端无需按需加载,应直接渲染出按需加载的组件;

第二点,为保证双端初次渲染结果一致,客户端应该等待当前页面按需加载的异步JS代码下载后再进行渲染;

第三点,为使服务端渲染的HTML能够包含按需加载的JS代码的script,需要获取到当前页面按需加载的组件名,和组件名对应JS路径名的映射。在直出HTML时将当前页面按需加载的script标签拼接进去。

react-loadable提供了上述问题的解决方案

react-loadable实现

组件

按需加载的组件使用Loadable包裹,其中moduleswebpack选项标识组件加载的是哪个模块,这样服务端就能根据渲染的组件获取到需加载的模块。

如果使用babel插件react-loadable/babel,便无需使用moduleswebpack选项。

import Loadable from 'react-loadable';

Loadable({
  loader: () => import('./Bar'),
  modules: ['./Bar'],
  webpack: () => [require.resolveWeak('./Bar')],
});
复制代码

客户端

使用preloadReady方法,等待按需加载的script脚本加载完毕后再渲染。window.main方法将在服务端直出的script脚本加载后调用。

import Loadable from 'react-loadable'

window.main = () => {
  Loadable.preloadReady().then(() => {
    ReactDom.render(
      <App />
      document.getElementById('root')
    )
  })
}
复制代码

生成模块映射(webpack)

生成加载的模块与webpack打包后的bundles的映射,服务端可据此判断应直出的scirpt

const ReactLoadableSSRAddon = require('react-loadable-ssr-addon');

module.exports = {
  entry: {
    // ...
  },
  output: {
    // ...
  },
  module: {
    // ...
  },
  plugins: [
    new ReactLoadableSSRAddon({
      filename: 'react-loadable.json',
    }),
  ],
};
复制代码

服务端(Koa)

将渲染的组件用Loadable.Capture包裹,它提供一个回调report方法,可以记录当前页面按需加载的模块名,根据生成的模块映射可获取到webpack打包后的bundles,由此直出当前页面需按需加载的scirpt,无需客户端动态创建。

mport React from 'react';
import { renderToString } from 'react-dom/server';
import Loadable from 'react-loadable'
import { getBundles } from 'react-loadable-ssr-addon';
import manifest from '@dist/server/react-loadable.json';

export default async (ctx, next) => {

  const modules = new Set();

  const html = renderToString(
    <Loadable.Capture report={moduleName => {
      modules.add(moduleName)
    }}>
      <App />
    </Loadable.Capture>
  );

  const modulesToBeLoaded = [...manifest.entrypoints, ...Array.from(modules)];
  const bundles = getBundles(manifest, modulesToBeLoaded);

  ctx.body = `<!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        ${bundle.css.join('\n')}
    </head>
    <body>
        <div id="root">${html}</div>
    </body>
    </html>
    ${assets.js.join('\n')}
    <script type="text/javascript">
        window.main()
    </script>
`;

  return next();
}

复制代码

参考文档

文章分类
前端
文章标签