React 服务端渲染

图1

前言

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

  • 因为单页应用是一次性加载全部资源,因此首屏白屏时间会比较长
  • 因为数据经过异步请求加载,因此不利于SEO

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

目标

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

简单服务端渲染

服务端渲染,服务端将HTML以字符串的形式返回给前端,前端去渲染。老式服务端渲染像jsp php那样,每次请求则刷新页面。而如今服务端渲染是使用node中间层去代替客户端请求数据渲染HTML,再发送内容给客户端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搭建后台服务,处理请求node

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插件进行合并react

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,就能够监听并动态编译webpack

"dev:build:server": "webpack --config ./webpack.server.js --watch"
复制代码

至此,咱们npm run dev:build:server即可获得编译后的bundle.js,此时咱们的目录结构以下git

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

图3

client

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

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进行如下配置web

"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:项目地址

相关文章
相关标签/搜索