reactSSR實踐總結

爲了更深刻地瞭解服務端渲染,因此動手搭了一個react-ssr的服務端渲染項目,由於項目中不多用到,這篇文章主要是對實現過程當中的一些總結筆記,更詳細的介紹推薦看 從零開始,揭祕React服務端渲染核心技術css

服務端和客戶端的渲染區別

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

服務端並無dom元素,須要使用renderToString方法將組件轉成html字符串返回。html

不一樣的編寫規範

客戶端編寫使用es6 Module規範,服務端使用使用的commonjs規範node

解決問題

使用webpack對服務端代碼進行打包,和打包客戶端代碼不一樣的是,服務端打包須要添加target:"node" 配置項和webpack-node-externals這個庫:react

與客戶端打包不一樣,這裏服務端打包webpack有兩個點要注意: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交互代碼:ios

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組件代碼:git

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

不過在服務端已經綁定好了元素在root節點,在客戶端繼續執行render方法,會清空已經渲染好的子節點,又從新生成子節點,控制檯也會拋出警告:es6

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()在服務端的渲染,二者的區別是:github

ReactDOM.render()會將掛載dom節點的全部子節點所有清空掉,再從新生成子節點。而ReactDOM.hydrate()則會複用掛載dom節點的子節點,並將其與react的virtualDom關聯上。

路由

客戶端渲染路由通常使用react-routerBrowserRouter或者HashRouter,二者分別會使用瀏覽器的window.location對象和window.history對象處理路由,可是在服務端並無window對象,這裏react-router在服務端提供了StaticRouterweb

  • 服務端渲染使用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>
    )
}
相關文章
相關標籤/搜索