乐趣区

关于react.js:reactSSR实践总结

为了更深刻地理解服务端渲染,所以入手搭了一个 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"} 的信息,以此判断是否重定向:

// render
const 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 参数,获取 cookie
export 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>
    )
}
退出移动版