为了更深刻地理解服务端渲染,所以入手搭了一个react-ssr的服务端渲染我的项目,因为我的项目中很少用到,这篇文章次要是对实现过程中的一些总结笔记,更具体的介绍举荐看 从零开始,揭秘React服务端渲染核心技术

服务端和客户端的渲染区别

  • 客户端渲染react:ReactDOM.render(component,el)
  • 服务端渲染react:ReactDom.renderToString(component)

服务端并没有dom元素,须要应用renderToString办法将组件转成html字符串返回。

不同的编写标准

客户端编写应用es6 Module标准,服务端应用应用的commonjs标准

解决问题

应用webpack对服务端代码进行打包,和打包客户端代码不同的是,服务端打包须要增加target:"node" 配置项和webpack-node-externals这个库:

与客户端打包不同,这里服务端打包webpack有两个点要留神:

  • 增加target:"node" 配置项,不将node自带的诸如path、fs这类的包打进去
  • 新增webpack-node-externals,漠视node_modules文件夹
var nodeExternals = require('webpack-node-externals');...module.exports = {    ...    target: 'node', // in order to ignore built-in modules like path, fs, etc.    externals: [nodeExternals()], // in order to ignore all modules in node_modules folder    ...};

同构

renderToString办法返回的只是html字符串,js逻辑并没有失效,所以react组件在服务端实现html渲染后,也须要打包客户端须要的js交互代码:

import express from 'express';import React from 'react';import {renderToString} from 'react-dom/server';import App from  './src/app';const app = express();// 动态文件夹,webpack打包后的js文件搁置public下app.use(express.static("public"))app.get('/',function(req,res){  // 生成html字符串  const content = renderToString(<App/>);  res.send(`        <!doctype html>        <html>            <title>ssr</title>            <body>                <div id="root">${content}</div>                // 绑定生成后的js文件                <script src="/client.js"></script>            </body>         </html>    `);});app.listen(3000);
能够了解成,react代码在服务端生成html构造,在客户端执行js交互代码

同样在服务端也要编写一份同样App组件代码:

import React from 'react';import {render} from 'react-dom';import App from './app';render(<App/>,document.getElementById("root"));

不过在服务端曾经绑定好了元素在root节点,在客户端继续执行render办法,会清空曾经渲染好的子节点,又从新生成子节点,控制台也会抛出正告:

Warning: render(): Calling ReactDOM.render() to hydrate server-rendered markup will stop working in React v17. Replace the ReactDOM.render() call with ReactDOM.hydrate() if you want React to attach to the server HTML.

这里举荐用ReactDOM.hydrate()取代ReactDOM.render()在服务端的渲染,两者的区别是:

ReactDOM.render()会将挂载dom节点的所有子节点全副清空掉,再从新生成子节点。而ReactDOM.hydrate()则会复用挂载dom节点的子节点,并将其与react的virtualDom关联上。

路由

客户端渲染路由个别应用react-routerBrowserRouter或者HashRouter,两者别离会应用浏览器的window.location对象和window.history对象解决路由,然而在服务端并没有window对象,这里react-router在服务端提供了StaticRouter

  • 服务端渲染应用StaticRouter,提供locationcontext参数
import {StaticRouter,Route} from 'react-router';...module.exports = (req,res)=>{    const context = {} // 服务端才会有context,子组件通过props.staticContext获取    const content = renderToString(         <StaticRouter context={context} location={req.path}>             <Route to="/" component={Home}></Route>         </StaticRouter>              );}
  • 客户端渲染应用BrowserRouter
import {BrowserRouter,Route} from 'react-router';...ReactDom.hydrate(  <BrowserRouter>        <Route to="/" component={Home}></Route>  </BrowserRouter>  document.getElementById("root"))

前后端路由同构

前后端的路由基本相同,适宜应该写成一份代码进行保护,这里应用react-router-config将路由配置化。

  • routes/index.js
import Home from "../containers/Home";import App from "../containers/App";import Profile from "../containers/Profile";import NotFound from "../containers/NotFound";export default [  {    path: "/",    key: "/",    component: App,    routes: [      {        path: "/",        key: "/home",        exact: true,        component: Home,      },      {        path: "/profile",        key: "/profile",        component: Profile,      },      {        component: NotFound,      },    ],  },]
  • 客户端 client.js
import routes from "../routes"import { BrowserRouter } from "react-router-dom"import { renderRoutes } from "react-router-config"ReactDom.hydrate(    <BrowserRouter>{renderRoutes(routes)}</BrowserRouter>  document.getElementById("root"))
  • 服务端 server.js
const content = renderToString((    <StaticRouter context={context} location={req.path}>    {renderRoutes(routes)}    </StaticRouter>))

重定向302和404

  • 在应用<Redirect>重定向时,因为服务端渲染返回给客户端的状态码始终是200
  • 未匹配到路由,进入NotFound组件,给客户端返回的也是胜利状态码200

这两个问题须要在服务端拦挡解决,返回正确的状态码给客户端。

记得后面给服务端路由传入的context参数:

<StaticRouter context={context} location={req.path}>

当路由重定向时,会给props.staticContext退出{action:"REPLACE"}的信息,以此判断是否重定向:

// renderconst content = renderToString(<App />)// return 重定向到context的url地址if (context.action === "REPLACE") return res.redirect(302, context.url)

进入NotFound组件,判断是否有props.staticContext对象,有代表在服务端渲染,新增属性给服务端判断:

export default function (props) {  if (props.staticContext) {    // 新增 notFound 属性    props.staticContext.notFound = true;  }  return <div>NotFound</div>}

进入到

const content = renderToString(<App />);// 存在 notFound 属性,设置状态码if (context.notFound) res.status(404)

redux与数据注入

首先,服务端渲染的数据从数据服务器获取,客户端获取数据通过服务端中间层再去获取数据层数据。

客户端 ---> 代理服务 ---> 数据接口服务

服务端 ---> 数据接口服务

退出接口代理

客户端通过服务端调用接口数据,须要设置代理,这里用的express框架,所用应用了express-http-proxy:

const proxy = require("express-http-proxy");app.use(  "/api",  // 数据接口地址  proxy("http://localhost:3001", {    proxyReqPathResolver: function (req) {      return `/api${req.url}`;    },  }));

两种申请形式

因为申请形式不同,所以服务端和客户端须要各自保护一套申请办法。

  • 服务端request.js:
import axios from "axios";export default (req)=>{    // 服务层申请获取接口数据不会有跨域问题  return axios.create({    baseURL: "http://localhost:3001/",    // 须要带上 cookie    headers: {      cookie: req.get("cookie") || "",    },  })}
  • 客户端request.js:
import axios from "axios";export default axios.create({    baseURL:"/"})

创立 store

接着创立store文件夹,我这边的根本目录构造如下:

/-store    /- actions    /- reduces    - action-types.js    - index.js

为了让接口调用更加不便,这里引入了redux-thunk中间件,并利用withExtraArgument属性绑定了服务端和客户端申请:

import reducers from "./reducers";import {createStore,applyMiddleware} from 'redux'import clientRequest from "../client/request";import serverRequest from "../server/request";import thunk from "redux-thunk";// 服务端store,须要退出http的request参数,获取cookieexport function getServerStore(req) {  return createStore(    reducers,    applyMiddleware(thunk.withExtraArgument(serverRequest(req)))  )}export function getClientStore(){    return createStore(      reducers,      initState,      applyMiddleware(thunk.withExtraArgument(clientRequest))    );}

服务端渲染:

import { Provider } from "react-redux"import { getServerStore } from "../store"<Provider store={getServerStore(req)}>    <StaticRouter context={context} location={req.path}>    {renderRoutes(routes)}    </StaticRouter></Provider>

客户端渲染:

import { Provider } from "react-redux"import { getClientStore } from "../store"ReactDom.hydrate(  <Provider store={getClientStore()}>      <BrowserRouter>{renderRoutes(routes)}</BrowserRouter>  </Provider>,  document.getElementById("root"))

通过中间件redux-thunk能够在action外面调用接口:

import * as TYPES from "../action-types";export default {    getHomeList(){        // withExtraArgument办法让第三个参数变成axios的申请办法        return (dispatch,getState,request)=>{            return request.get("/api/users").then((result) => {                let list = result.data;                dispatch({                  type: TYPES.SET_HOME_LIST,                  payload: list,                });              });        }    }}

数据注入

如果数据通过store调用接口获取,那么服务端渲染前须要先初始化接口数据,期待接口调用实现,数据填充进store.state才去渲染dom。

给须要调用接口的组件新增静态方法loadData,在服务端渲染页背后,判断渲染的组件否有loadData静态方法,有则先执行,期待数据填充。

例如首页调用/api/users获取用户列表:

class Home extends Component {  static loadData = (store) => {    return store.dispatch(action.getHomeList());  }}

服务端渲染入口批改如下:

import { matchRoutes, renderRoutes } from "react-router-config"...async function render(req, res) {  const context = {}  const store = getServerStore(req)  const promiseAll = []  // matchRoutes判断以后匹配到的路由数组  matchRoutes(routes, req.path).forEach(({ route: { component = {} } }) => {    // 如果有 loadData 办法,加载    if (component.loadData) {        // 保障返回promise都是true,避免页面呈现卡死      let promise = new Promise((resolve) => {        return component.loadData(store).then(resolve, resolve)      })      promiseAll.push(promise)    }  })  // 期待数据加载实现  await Promise.all(promiseAll)  const content = renderToString(        <Provider store={store}>            <StaticRouter context={context} location={req.path}>                {renderRoutes(routes)}            </StaticRouter>        </Provider>  );  ...  res.send(`       <!DOCTYPE html>        <html>            <head>                <title>react-ssr</title>            </head>            <script>              // 将数据绑定到window              window.context={state:${JSON.stringify(store.getState())}}            </script>            <body>                <div id="root">${content}</div>                <script src="./client.js"></script>            </body>        </html>    `)

期待Promise.all加载实现后,所有须要加载的数据都通过loadData填充进store.state外面,
最初,在渲染页面将store.state的数据获取并绑定到window上。

因为数据曾经加载过一遍了,所以在客户端渲染时,把曾经初始化好的数据赋值到store.state外面:

export function getClientStore(){    let initState = window.context.state;    return createStore(      reducers,      initState,      applyMiddleware(thunk.withExtraArgument(clientRequest))    );}

退出css

解决款式能够应用style-loadercss-loader,然而style-loader最终是通过生成style标签插入到document外面的,服务端渲染并没有document,所以也须要离开保护两套webpack.config。

服务端渲染css应用isomorphic-style-loader,webpack配置如下:

  module: {    rules: [      {        test: /\.css$/,        use: [          "isomorphic-style-loader",          {            loader: "css-loader",            options: {              modules: true,            },          },        ],      },    ],  } 

客户端配置还是失常配置:

  module: {    rules: [      {        test: /\.css$/,        use: [          "style-loader",          {            loader: "css-loader",            options: {              modules: true,            },          },        ],      },    ],  }
这里 css-loader 举荐用@2的版本,最新版本在服务端isomorphic-style-loader取不到款式值

这里有个问题,因为款式css是js生成style标签动静插入到页面,所以服务端渲染好给到客户端的页面,期初是没有款式的,如果js脚本加载慢的话,用户还是能看到没有款式前的页面。

提取css

在服务端渲染前,提取css款式,isomorphic-style-loader也提供了很好的解决形式,这里通过写个高阶函数解决,在加载款式的页面,先提取css代码保留到context外面:

服务端渲染页面,定义context.csses数组保留款式:

const context = { csses:[] }

创立高阶函数 withStyles.js

import React from 'react'export default function withStyles(RenderComp,styles){    return function(props){        if(props.staticContext){            // 获取css款式保留进csses            props.staticContext.csses.push(styles._getCss())        }        return <RenderComp {...props}></RenderComp>    }}

应用:

import React, { Component } from "react";import { renderRoutes } from "react-router-config";import action from  "../store/actions/session"import style from "../style/style.css";import withStyle from "../withStyles";class App extends Component {  static loadData = (store) => {    return store.dispatch(action.getUserMsg())  }  render() {    return (        <div className={style.mt}>{renderRoutes(this.props.route.routes)}</div>    )  }}// 包裹组件export default withStyle(App,style)

渲染前提取css款式:

const cssStr = context.csses.join("\n")res.send(`    <!DOCTYPE html>    <html>        <head>            <title>react-ssr</title>            <style>${cssStr}</style>        </head>    </html>`)

seo优化

seo优化策略外面,肯定会往head外面退出title标签以及两个meta标签(keywordsdescription),
通过react-helmet能够在每个渲染组件头部定义不同的title和meta,十分不便,应用如下:

import { Helmet } from "react-helmet"...const helmet=Helmet.renderStatic();res.send(`    <!DOCTYPE html>    <html>        <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">            ${helmet.title.toString()}            ${helmet.meta.toString()}            <title>react-ssr</title>            <style>${cssStr}</style>        </head>    </html>`)

在须要插入title或者meta的组件中引入Helmet

import { Helmet } from "react-helmet"function Home(props){    return render() {    return (      <Fragment>        <Helmet>          <title>首页题目</title>          <meta name="keywords" content="首页关键词" />          <meta name="description" content="首页形容"></meta>        </Helmet>        <div>home</div>      </Fragment>    )}