React 服务端渲染

2,386 阅读5分钟

图1

前言

前端崛起后,Vue,React等框架大受欢迎,但是他们构建的单页应用有以下缺点

  • 由于单页应用是一次性加载所有资源,所以首屏白屏时间会比较长
  • 由于数据通过异步请求加载,所以不利于SEO

为了解决这些问题,我们可以采用服务端渲染的方式。使用服务端渲染,我们不能走回老路,所以产生了Vue的next.js和React的next.js等框架。但是,所谓“授人以鱼不如授人以渔”,我们不仅要学会使用第三方框架,还要学习其中的原理!

目标

  1. 简单服务端渲染
  2. 路由同构
  3. store同构
  4. css样式处理
  5. 404错误处理

简单服务端渲染

服务端渲染,服务端将HTML以字符串的形式返回给前端,前端去渲染。老式服务端渲染像jsp php那样,每次请求则刷新页面。而现在服务端渲染是使用node中间层去代替客户端请求数据渲染HTML,再发送内容给客户端

server

这里我们可以使用renderToString,这是由react-dom提供的方法,它存在react-dom/server下,它将组件以字符串形式返回。与renderToStaticMarkup不同的是,renderToString返回的HTML会带有data-reactid,而renderToStaticMarkup没有。但在React16开始,为了HTML更加简洁,取消了所有标记,所以跟正常HTML相同

import React from 'react';
import { renderToString } from 'react-dom/server';
import Header from '../components/Header';

export default () => {
  return `
  <!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>Document</title>
</head>
<body>
  <div id="app">${renderToString(<Header />)}</div>
</body>
</html>
  `
}

然后使用express搭建后台服务,处理请求

import express from 'express';
import render from './render';

const app = new express();

app.get('*', (req, res) => {
  const html = render();
  res.send(html)
})

app.listen(3000, () => {
  console.log('server is running on port 3000');
})

webpack

从上图可以看出,webpack配置分为服务端客户端,这里我们先配置服务端,同时把两者相同部分抽离到webpack.base.js,使用webpack-merge插件进行合并

const path = require('path');
const webpackMerge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');
const baseConfig = require('./webpack.base.js');
const serverConfig = {
  target: 'node', // 排除node内置模块,fs、path
  mode: 'development',
  entry: './src/server/index.js',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'build')
  },
  externals: [nodeExternals()] // 排除node_modules模块
}
module.exports = webpackMerge(baseConfig, serverConfig)

另外,配置一下.babelrcpackage.json。为pakage.json加上以下scripts,就可以监听并动态编译

"dev:build:server": "webpack --config ./webpack.server.js --watch"

至此,我们npm run dev:build:server便可得到编译后的bundle.js,此时我们的目录结构如下

图2
进入build目录node bundle.js启动项目,客户端访问3000端口,可以看到结果,但是点击按钮控制台并没有输出结果

图3

client

后端无法处理事件绑定,这需要由客户端来处理。我们使用React16新提出的hydrate来完成这项任务,此方法由react-dom提供。他能代替之前的render方法,复用服务端传来内容,并绑定好事件

import React from 'react';
import ReactDom from 'react-dom';
import Header from '../components/Header';

const App = function() {
  return (
      <Header />
  )
}

ReactDom.hydrate(<App />, document.getElementById('app'));

然后添加客户端的webpack配置,通过webpack编译可以得到public文件夹及内部index.js。这里为了能够实时编译和编译后及时重启服务器,我们需要对package.json进行以下配置

  "scripts": {
    "dev": "npm-run-all --parallel dev:**",
    "dev:start": "nodemon --watch build --exec node \"./build/bundle.js\"",
    "dev:build:server": "webpack --config ./webpack.server.js --watch",
    "dev:build:client": "webpack --config ./webpack.client.js --watch"
  },

为了客户端能实现功能,我们需要在server/render.js内通过脚本引用客户端编译好的index.js,以及让服务端响应静态资源请求

<script src="/index.js"></script>
app.use(express.static('public'));

至此,我们npm run dev便可并行编译及开启服务,请求3000端口,点击按钮就可以看到输出结果了!

图4

路由同构

这里我们采用配置的方式构建路由

export default [
  {
    path: '/',
    component: App,
    routes: [
      {
        path: '/',
        component: Home,
        exact: true // 默认路由配置
      },
      {
        path: '/login',
        component: Login
      }
    ]
  }
]

这种形式生成路由需要借助react-router-config提供的renderRoutes方法,此方法最终会将路由配置文件转为以下形式

<Switch>
    <Route path="/" component={App} />
    const App = () => {
        <div>
            <Route exact path="/" component={Home} />
            <Route path="/login" component={Login} />
        </div>
    }
</Switch>

React中,一般客户端渲染时使用BrowserRouter,而服务端渲染,我们需要使用react-router-dom提供的无状态的StaticRouter。BrowserRouter会根据url来保持页面同步,而StaticRouter只会传入服务器提供的url,以便路由匹配

  const App = (
    <StaticRouter location={req.path}>
      <div>
        {renderRoutes(routes)}
      </div>
    </StaticRouter>
  )

当然,服务端修改了,为了达到hydrate复用效果,那么客户端应该保持一致

const App = function() {
  return (
    <BrowserRouter>
      <div>
        { renderRoutes(routes) }
      </div>
    </BrowserRouter>
  )
}

到此,我们路由同构完成,客户端访问http://127.0.0.1:3000/login,可以看到以下结果

图5

store同构

为了实现的SEO功能,服务端需要返回带有数据HTML字符串。首先,我们先按老套路,构建好store

图6
与以往不同的是,服务端渲染嘛,那我们就需要生成两个store了,分别是客户端的store和服务端的store。而且,我们不能直接export出构建好的store,而需要对其再包一层,这样就不会是单例模式了。

export const getClientStore = () => {
  return createStore(
    reducer,
    applyMiddleware(thunk)
  )
}

export const getServerStore = () => {
  return createStore(
    reducer,
    applyMiddleware(thunk)
  )
}

然后,将clientStoreserverStore分别通过Provider传给客户端和服务端的子组件。接着通过connect将容器组件与Home展示组件连接。npm run dev后得到如下结果

图7
从结果可以看出列表虽然渲染出来了,但是这是来自前台请求的结果。服务端返回的HTML并没有数据,因为Home组件的componentDidMount生命周期在服务端并没有执行。所以我们需要手动去触发dispatch,去给予serverStore数据。这里我们通过将loadData变量挂载到Home组件上,loadData方法返回的都是Promise对象

Home.loadData = function(store) {
  return store.dispatch(getCommentList())
}

可是,这需要怎么去触发此方法呢?我们可以在接收到相应的请求时去触发,那就把他放到路由配置上吧

  {
    path: '/',
    component: Home,
    loadData: Home.loadData,
    exact: true // 默认路由配置
  }

接着,我们需要根据路由去触发loadData。这里我们需要使用到react-router-config提供的matchRoutes方法。此方法可以根据请求路径,配置到相应的路由,需要注意的是此处使用的是req.path而不是req.url,因为req.url会带有query参数。然后,我们使用Promise.all去执行所有请求,所有请求结束后,此时store已经有数据了,再响应HTML给客户端

app.get('*', (req, res) => {
  const store = getServerStore()
  const matchedRoutes = matchRoutes(routes, req.path)
  const promises = []
  matchedRoutes.forEach(mRouter => {
    if(mRouter.route.loadData) {
      promises.push(mRouter.route.loadData(store))
    }
  })
  Promise.all(promises)
    .then(resArr => {
      const html = render(req,store);
      return res.send(html)
    })
    .catch(err => {
      console.log('服务端出错:', err)
    })
})

此时,我们可以看到服务端响应HTML中已经存在列表数据了

图8
但是,我们可以看到列表显示过程为有数据 -> 空白 -> 有数据。为了解决它,我们需要初始化clientStore。首先,我们在HTML字符串中埋好数据

<script>
    window.__context__ = {state: ${JSON.stringify(store.getState())}}
</script>

然后在getClientStore时,初始化store。createStore可以传入三个参数,第二个参数用于初始化state,在使用了combineReducers时,其结构要和reducer结构一致

export const getClientStore = () => {
  const defaultStore = window.__context__ || {}
  return createStore(
    reducer,
    defaultStore.state,
    applyMiddleware(thunk)
  )
}

OK,这样就不会存在空白闪烁间隔了。

css样式处理

webpack配置

一般我们处理css样式,需要使用的插件是style-loader,但是此插件在服务端的node环境是无法愉快玩耍的。我们需要使用一个专门为服务端渲染而生的插件,即isomorphic-style-loader,具体用法可参见其官方文档。首先配置webpack.client.jswebpack.server.js,注意:此处需要开启CSS Modules

  module:{
    rules:[{
      test:/\.css$/,
      use: [
        'isomorphic-style-loader',
        {
            loader: 'css-loader',
            options: {
              modules: true // 开启css模块化
            }
      }]
    }]
  }

服务端

然后,修改一下render.js,第一步引入StyleContext

import StyleContext from 'isomorphic-style-loader/StyleContext';

第二步使用StyleContext包裹住App,StyleContext.Provider的value属性接收一个包含insertCss的上下文对象,它主要是提供给后面所提到的Withstyles

  const css = new Set()
  const insertCss = (...styles) => styles.forEach(style => css.add(style._getCss()))
  const context = { insertCss }
  const App = (
    <StyleContext.Provider value={context}>
      <Provider store={store}>
        <StaticRouter location={req.path}>
          <div>
            {renderRoutes(routes)}
          </div>
        </StaticRouter>
      </Provider>
    </StyleContext.Provider>
  )

第三步,需要将css样式插入返回的HTML模板字符串

<style>${[...css].join('')}</style>

客户端

既然服务端修改了,那么客户端也要跟上,我们修改一下client/index.jsx。此处的insertCss与服务端的有点不同,node环境下只能使用_getCss方法,而此处使用的是_insertCss,它类似于style.loaderaddStylesToDom

import StyleContext from 'isomorphic-style-loader/StyleContext';

const App = function() {
  const insertCss = (...styles) => {
    const removeCss = styles.map(style => style._insertCss())
    return () => removeCss.forEach(dispose => dispose())
  }
  const context = { insertCss }
  return (
    <StyleContext.Provider value={context}>
      <Provider store={getClientStore()}>
        <BrowserRouter>
          <div>
            { renderRoutes(routes) }
          </div>
        </BrowserRouter>
      </Provider>
    </StyleContext.Provider>
  )
}

组件使用

所有配置完成,我们可以开始使用了!首先,我们引入withStyles,这是一个高阶组件,内部有上文提到的_insertCss方法

import withStyles from 'isomorphic-style-loader/withStyles';

然后,引入css样式并使用,需要注意的是此处不是直接import './Home.css',而是以模块的形式引入,这就是上文为何要指明css需要开启模块化的原因

import style from './Home.css';

<h3 className={style.title}>Home</h3>

接着,我们使用withStyles包裹一下Home组件,此处以柯里化的形式,第一个参数可以传入style序列,第二参数传入组件

export default connect(mapStateToProps,
  mapDispatchToProps)(withStyles(style)(Home));

至此,我们可以得到如下结果,可以看到Home title变为了红色

图9

404错误处理

前面,我们同构好了路由,但是当我们访问/home时,子页面为空白,而且响应状态是200,这就不对了!我们并没有设置/home路由,虽然在/时会出现Home页面内容,但路由是/。所以,我们需要处理一下这个问题,当没有路由匹配时,需要响应404并返回404 not found提示内容。
那么如何判断请求页面不存在呢?这时,我们需要借助StaticRoutercontext属性。传入的context可以在路由组件内获取到,我们需要将404页面放到最后,当路由匹配到此,我们将NOT_FOUND变量挂载到context。所以,我们就可以通过context上是否有NOT_FOUND变量来判断请求页面是否存在
首先,配置404页面,在路由最后位置添加

  {
    path: '*',
    render: ({staticContext}) => {
      if (staticContext) staticContext.NOT_FOUND = true
      return <div>404 not found</div>
    }
  }

然后,给render.js内的StaticRouter传入context

<StaticRouter location={req.path} context={ctx}>
  <div>
    {renderRoutes(routes)}
  </div>
</StaticRouter>

接着,在server/index.js根据是否有NOT_FOUND变量来判断是否响应404错误

  const context = {}
  const html = render(req, store, context);
  if (context.NOT_FOUND) res.status(404)
  return res.send(html)

最后,我们请求http://127.0.0.1:3000/home可以看到页面显示如下

图10

图11

结语

服务端渲染虽然能优化首屏加载速度,但如果数据请求时间较长也不会有显著效果。因此,是否采用服务端渲染还需要根据实际应用考虑。一般服务端渲染用在注重SEO的网站,或者增改删查等业务场景较多的后台管理系统等。
ps:项目地址