React SSR 服务端渲染实践指南

年前由于工做缘由须要对原有 React 项目进行服务端渲染的改造,下面是我对以前工做经验的一些总结分享,但愿能够对你们有所帮助。javascript

适用场景

首先咱们来了解一下 SSR 能够作什么,能够解决什么问题,诞生的缘由又是什么。接下来是它到底长什么样子,最而后再是怎么作。须要提早明确的一点是 SSR 并非万能的,它有它的优缺点和具体的适用场景,咱们先来看一下它诞生的历史背景。css

产生的缘由

现现在 SPA 单页面应用已成为主流,相关的开发工具和 MVVM 框架为前端的开发带来了便利和无限的可能性。咱们在体验着 SPA 页面开发便捷性的同时,随着业务的发展下面的两个问题也会逐渐暴露出来,并且是在原有模式下很难解决的。html

发现的问题

  • 首屏白屏时间过长。 在常规 SPA 的页面渲染流程中,首先要加载 HTML 文件,以后要下载页面所需的 JavaScript 文件,而后 JavaScript 文件渲染生成页面, 若是有涉及到数据请求,那么这个耗时将会更加漫长,尤为是在弱网环境下,体验很是糟糕。前端

  • SEO 能力较弱。 由于目前大多数搜索引擎主要识别的内容仍是 HTML,对 JavaScript 文件内容的识别都还比较弱,因此很难在搜索引擎中有较好的排名。java

技术原理

SSR 技术随之应运而生,SSR 全称 Server Side Rendering 。以 React 为例,首先咱们让 React 代码在服务器端先执行一次,使得用户下载的 HTML 已经包含了全部的页面展现内容,同时,因为 HTML 中已经包含了网页的全部内容,因此网页的 SEO 效果也会变的很是好。以后,咱们让 React 代码在客户端再次执行,为 HTML 网页中的内容添加数据及事件的绑定,页面就具有了 React 的各类交互能力,可参考下图。node

image

核心 API:

服务端: renderToString() | ReactDOMServer.renderToNodeStream()react

生成带有标记的 html 文档结构webpack

客户端: ReactDOM.hydrate()git

根据服务端携带的标记更新 React 组件树,并附加事件响应es6

SSR 的劣势

技术改形成本相对较高,node 服务器端的资源前端不太好驾驭。

因此我我的的建议,是要慎重评估改造的成本和收益,不推荐在生产项目中直接使用

可用于替代的相关技术

  1. 骨架屏 Skeleton

  2. 预渲染 Pre-render

    这项技术主要用来解决 SEO 的问题,适用于短期内不会产生频繁变更的网页。可在服务器端判断 UA,针对爬虫单独返回提早手动抓取好的 html 内容。

  3. next.js (新的项目)

  4. Jquery (交互较少的页面)

项目结构

SSR Project
├─build
|  ├─client
|  ├─server
|  └assets.json
├─node_modules
├─public  //公共资源
├─components
├─webpack  //打包配置
|  ├─webpack.config.js
|  ├─webpack.client.config.js
|  └webpack.server.config.js
├─server  //服务端代码
|  ├─App.jsx
|  ├─router.js
|  └index.js
├─src  //客户端代码
|  ├─pages
|  ├─App.jsx
|  ├─router.js
|  └index.js
├─index.html
├─server.js  //服务端入口文件
├─package.json
复制代码

基础配置

如今咱们来从头搭建一个 React 服务端的渲染环境。先来看一下最终结果,下面是一份服务端返回 HTML 的 template 页面示例

<!DOCTYPE html>
<html lang="cn">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width,height=device-height,maximum-scale=1.0,user-scalable=no;" />
    <link href="/main.css" rel="stylesheet" />
  </head>
  <body>
    <div id="root">
      {serverContent}
    </div>
    <script type="text/javascript" src="/bundle.js"></script>
  </body>
</html>
复制代码

其实对比原有项目改动很简单,就是把 root 节点内的 html 内容提早在服务端渲染成字符串拼接到模板内。咱们采用 express 搭建 node 服务。

server/index.js

import React from 'react'
import { renderToString } from 'reactDOM/server'
import express from 'express'
import App from './App'
const app = express()

app.get('/', (req, res, next) => {
  const serverContent = renderToString(<App/>)
  const html = mixin(template,serverContent)
  res.send(html)
})

// 导出启动服务的函数供入口文件调用
export default startServer() => {
  app.listen('3000')
  return app
}
复制代码

server.js

var startServer = require("./build/server/index.js");
startServer();
复制代码

接下来咱们要修改 webpack.server.config.js, 将 server 端代码编译成能够被 commonjs 模块系统识别的代码

{
  ...
  input: path.resolve(__dirname, '..', 'server/index.js'),
  output: path.resolve(__dirname, '..', 'build/server,'),
  target: 'node',
  ...
}
复制代码

这样服务端基础代码就完成了,但这样是远远不够的,咱们还须要作一些其余的处理。

静态资源

接下来咱们处理静态资源。生产环境的 js bundle 和 css file 都将会附带哈希值,若是按照如今这样简单地在服务端模板内引入"/bundle.js"是找不到文件的,正确的引入路径应该是"/bundle_[hash].js"。那么下面咱们来套路如何处理哈希同步的问题,其次图片资源咱们也但愿不要重复生成两份哈希。

这里推荐使用 universal-webpack, 它经过帮咱们修改 webpack 配置的方式,帮咱们解决上述的问题。插件在打包时会在 build 目录下生成 assets.json 资源定位文件,服务端咱们引入这个文件处理便可。

详细项目文档

assets.json

interface Chunks {
  javascript: {
    [scriptname: string]: string;
  };
  styles: {
    [scriptname: string]: string;
  };
}
复制代码

webpack.client.config.js

import { clientConfiguration } from 'universal-webpack'
const webpackConfig = {...}

return clientConfiguration(webpackConfig, {
    chunk_info_filename: 'assets.json'
}, {
  useMiniCssExtractPlugin : true
})
复制代码

webpack.server.config.js

import { serverConfiguration } from 'universal-webpack'
const webpackConfig = {...}

return serverConfiguration(webpackConfig, {
  // 默认第三方模块都不打包,这里须要配置不支持commonjs的第三方模块
  excludeFromExternals: [
    'lodash-es',
    /^some-other-es6-only-module(\/.*)?$/
  ],

  // 这里配置不须要重复打包的文件
  loadExternalModuleFileExtensions: [
    'css',
    'png',
    'jpg',
    'svg',
    'xml'
  ]
})
复制代码

改造 server/index.js

const assets = require("../assets.json");
const js = Object.values(assets.javascript)
  .map(item => <link rel="stylesheet" href="${item}" />)
  .join("\n");
const css = Object.values(assets.styles)
  .map(item => `<script src="${item}"></script>`)
  .join("\n");

const html = mixin(template, {
  js,
  css,
  serverContent
});
复制代码

路由处理

咱们须要在服务端根据请求的 url 渲染对应的组件,这里和客户端稍微有一些不太同样。react-router 提供了 StaticRouter 组件用于服务端渲染,咱们能够手动传入请求的的 url 来进行路由定位。

server/index.js

app.get("/", (req, res, next) => {
  ...
  // @override
  // const serverContent = renderToString(<App/>)
  const url = req.url; // "/home"
  const serverContent = renderToString(<App url={url} />); ... }); 复制代码

server/App.jsx

import React from 'react'
import { StaticRouter, Switch, Route } from 'react-router-dom'
import routes from './router.js'

export default App(props)  => {
  <StaticRouter location={props.url} context={{}}>
     <Switch> {routes.map((route) => { <Route {...route} /> })} </Swtich> </StaticRouter>
}
复制代码

数据获取

咱们通常在 componentDidMount 生命周期执行获取数据的方法,可是在服务端环境中生命周期是不完整的,只会执行 ComponentWillMount 以前的方法,因此咱们必须在渲染前准备好数据,而后经过 props 注入到组件中。

这里咱们为路由组件定义了一个 loadData 的钩子函数,经过 react-router 提供的 matchPath 方法,能够判断当前须要渲染的页面组件,并执行相应的 loadData 方法获取数据,该方法返回一个 Promise 对象,以便咱们在数据获取成功后异步执行渲染逻辑。

server/router.js

// 这里能够经过客户端路由文件改造, 添加须要的loadData方法便可
const routes = {
  path: "/",
  component: Home,
  // return a Promise
  loadData: () => getSomeData()
};
复制代码

server/index.js

import { matchPath } from 'react-router-dom'

app.get('/', (req, res, next) => {
  ...
  const promise = Promise.resolve()
  routes.find(route => {
    const match = matchPath(req.url, route)
    if(match) promise.then(route.loadData)
    return match
  })
  promise.then((data) => {
      const serverData = formatData(data)
      // @override
      // const serverContent = renderToString(<App url={url} />);
      const serverContent = renderToString(<App url={req.url} data={serverData}/>) const html = mixin(template, {js, css, serverContent}) res.send(html) }) }) 复制代码

server/App.jsx

export default App(props)  => {
  <StaticRouter location={props.url} context={{}}>
     <Switch> {routes.map((route) => { <Route {...route} render={() => { const Component = route.component <Component data={props.data}/> }}/> })} </Swtich> </StaticRouter> } 复制代码

使用 Redux

若是使用 redux 管理同构的数据则会方便许多,这里注意每个请求都须要从新生成一个新的 store,不然的话用户状态则会混乱。

server/App.jsx

export default App(props)  => {
   <Provider store={props.store}>
    <StaticRouter location={props.url} context={{}}> <Switch> {routes.map((route) => { <Route {...route} /> })} </Swtich> </StaticRouter> </Provider>
}
复制代码

server/index.js

...
promise.then((data) => {
  ...
  const preloadedState = mixin(initData, data)
  const store = createStore(reducers, preloadedState)
  // @override
  // const serverContent = renderToString(<App url={req.url} data={serverData}/>)
  const serverContent = renderToString(<App url={req.url} store={store}/>) ... }) ... 复制代码

客户端数据同步

至此为止,服务器端最终会输出一个带有数据状态的完整页面。可是客户端这边从新渲染的时候,首先会渲染一个没有数据的框架,而后才会在 componentDidMount 里发起数据接口请求数据,这意味着在这个过程期间客户端都为空数据状态,在用户看来就是表现为会执行重复地 loading 。

因此咱们但愿客户端能够共享服务端已经获取的数据,个人解决办法是在服务端将数据注入到 HTML 中返回给客户端脱水(Dehydrate)。在浏览器端,客户端再也不本身发起请求获取数据处理状态,直接使用脱水数据来初始化 React 组件注水 (Hydrate)

image
HTML 模板

...
    </div>
    <script type="text/javascript" src="/bundle_[hash].js"></script>
    <script>window.__initState__ = ${JSON.stringfy(store)}</script>
  </body></html>
复制代码

server/index.js

// @override
  // const html = mixin(template, {js, css, serverContent})
  const html = mixin(template, {js, css, serverContent, store})
})
复制代码

客户端初始化数据

store.js

const defaultState = JSON.parse(window.__initState__);
const store = createStore(reducer, defaultState);
复制代码

注意事项

  1. 服务端执行环境没有 windowdocument 等宿主对象,且会执行组件的 constructorcomponentWillReceivePropsrender 生命周期,因此务必避免代码中的此类调用。能够经过 typeof windowwebpack.definePlugin 来对客户端和服务端作区分

继续探索

  • 按需加载

  • HMR

  • 服务端性能监控&&调优

相关文章
相关标签/搜索