如何实现一个同构的React SSR模型

900 阅读7分钟

前言

客户端渲染(CSR)

  • 客户端渲染,页面初始加载的HTML页面中无网页展示内容,需要加载执行JavaScript文件中的代码,通过JavaScript渲染生成页面,同时JavaScript代码会完成页面的交汇事件的绑定。

服务端渲染(SSR)

  • 用户请求服务器,服务器上直接生成HTML内容并返回给浏览器。服务器端渲染来,页面的内容是由Server端生成的。一般来说,服务器渲染的页面交互能力有限,如果要实现复杂交互,还是要通过引入JavaScript文件来辅助实现。服务单渲染这个概念,适用于任何后端语言。

同构

  • 同构这个概念存在于Vue,React这些新型的前端框架中,同构实际上是客户端渲染和服务端渲染的一个整合。我们把页面的展示内容和交互写在一起,让代码执行两次。在服务器端执行一次,用于实现服务端渲染,在客户端再执行一次,用于接管页面交互。

  • 同构模型图

    同构模型.png

实现

客户端和服务端的同构

  1. 创建提供server打包的webpack配置

    const path = require('path')
    const nodeExternals = require('webpack-node-externals')
    // webpack-node-externals 所有节点模块将不再经过webpack转化,而是保留为requiremodule.exports = {
        target: 'node',
        mode: 'development',
        entry: './server/index.js',
        externals: [nodeExternals()],
        output: {
            filename: 'bundle.js',
            path: path.resolve(__dirname, 'build')
        },
        module: {
            rules: [
                {
                    test: /.js$/,
                    loader: 'babel-loader',
                    exclude: /node_modules/,
                    options: {
                        presets: [
                            '@babel/preset-react',
                            ['@babel/preset-env']
                        ]
                    }
                }
            ]
        }
    }
    
  2. 创建server/index.js目录,使用express启动服务。

    import React from "react";
    import { renderToString } from "react-dom/server";
    import express from "express";
    import App from "../src/App";
    ​
    const app = express();
    ​
    // 设置静态资源地址
    app.use(express.static("public"));
    ​
    // 匹配/路由
    app.get("/", (req, res) => {
      const Page = <App />;
      // 由于node不支持jsx,使用react-dom/server自带的renderToString进行转换
      const content = renderToString(Page);
    ​
      // 返回一个html结构,并添加需要渲染的组件,并引入client中的JS进行数据操作。同构触发。
      res.send(`
        <html>
            <head>
                <meta charset="utf-8" />
                <title>react ssr</title>
            </head>
            <body>
                <div id="root">${content}</div>
                <script src="/bundle.js"></script>
            </body>
        </html>    
        `);
    });
    ​
    app.listen(9093, () => {
      console.log("监听完毕");
    });
    
  3. 创建提供给client打包的webpack配置

    const path = require('path')
    ​
    module.exports = {
        mode: 'development',
        entry: './client/index.js',
        output: {
            filename: 'bundle.js',
            path: path.resolve(__dirname, 'public')
        },
        module: {
            rules: [
                {
                    test: /.js$/,
                    loader: 'babel-loader',
                    exclude: /node_modules/,
                    options: {
                        presets: [
                            '@babel/preset-react',
                            ['@babel/preset-env']
                        ]
                    }
                }
            ]
        }
    }
    
  4. client/index.js中给SSR的组件进行注水操作

    import React from "react";
    import ReactDom from "react-dom";
    import App from "../src/App";
    // 进行注水
    ReactDom.hydrate(<App />, document.getElementById("root"));
    
  5. 这样初步的React SSR应用基本搭建完成。

  6. 问题:React开发大部分都是单页面开发,路由不可能只是一个,那么怎么匹配路由进行页面的渲染呢?

支持路由模式

  1. 改造server/index.js,使用react-router-dom提供的StaticRouter包裹入口组件。并将/改为*进行全路由匹配。

    import React from "react";
    import { renderToString } from "react-dom/server";
    import express from "express";
    import { StaticRouter } from "react-router-dom";
    import App from "../src/App";
    ​
    const app = express();
    ​
    // 设置静态资源地址
    app.use(express.static("public"));
    ​
    // 匹配所有路由
    app.get("*", (req, res) => {
      // 由于node不支持jsx,使用react-dom/server自带的renderToString进行转换
      const content = renderToString(<StaticRouter location={req.url}>{App}</StaticRouter>);
    ​
      // 返回一个html结构,并添加需要渲染的组件,并引入client中的JS进行数据操作。同构触发。
      res.send(`
        <html>
            <head>
                <meta charset="utf-8" />
                <title>react ssr</title>
            </head>
            <body>
                <div id="root">${content}</div>
                <script src="/bundle.js"></script>
            </body>
        </html>    
        `);
    });
    ​
    // 启动服务
    app.listen(9093, () => {
      console.log("监听完毕");
    });
    
  2. src目录下新增文件夹container目录,新建Index.js 和 About.js两个组件。并改造src/App.js。

    Index.js

    import React, { useState } from "react";
    ​
    function Index() {
      const [count, setCount] = useState(0);
      return (
        <div>
          <h1>first react ssr {count}</h1>
          <button onClick={() => setCount(count + 1)}>累加</button>
        </div>
      );
    }
    ​
    export default Index;
    

    About.js

    import React from "react";
    ​
    function About() {
      return <div>关于页面</div>;
    }
    ​
    export default About;
    

    App.js

    import React from "react";
    import { Route } from 'react-router-dom';
    import Index from './container/Index';
    import About from './container/About';
    ​
    export default (
      <div>
        <Route path='/' exact component={Index}></Route>
        <Route path='/about' exact component={About}></Route>
      </div>
    )
    
  3. client/index.js进行改造,使用react-router-dom中BrowserRouter包裹入口组件。

    import React from "react";
    import ReactDom from "react-dom";
    import { BrowserRouter } from "react-router-dom";
    import App from "../src/App";
    ​
    const Page = <BrowserRouter>{App}</BrowserRouter>;
    ​
    // 进行注水
    ReactDom.hydrate(Page, document.getElementById("root"));
    
  4. 这样就实现路由模式了。

redux支持

  1. 注册redux,并创建indexReducer 和 userReducer对应Index和User组件。

    import { createStore, applyMiddleware, combineReducers } from 'redux'
    import thunk from 'redux-thunk'
    import indexReducer from './index'
    import userReducer from './user'const reducer = combineReducers({
        index: indexReducer,
        user: userReducer
    })
    ​
    const store = createStore(reducer, applyMiddleware(thunk))
    ​
    export default store
    
  2. indexReducer中redux内容,发送/api/course/list获取数据。

    const GET_LIST = 'INDEX/GET_LIST'
    ​
       const changeList = list => ({
           type: GET_LIST,
           list
       })
    ​
       export const getIndexList = server => {
          // $axios由于CSR和SSR都会发送请求,所以利用了redux中间件的第二个参数,具体内容看下面的axios代理实现
           return (dispatch, getState, $axios) => {
               return $axios.get('/api/course/list').then(res => {
                   const { list } = res.data
                   dispatch(changeList(list)) 
               })
           }
       }
    ​
       const defaultState = {
           list: []
       }
    ​
       export default (state = defaultState, action) => {
           switch (action.type) {
               case GET_LIST:
                  const newState = {
                      ...state,
                      list: action.list
                  }
                  return newState
               default:
                   return state
           }
       }
    
  3. 改造Index组件,加入loadData方法供服务端调用。

    import React, { useState, useEffect } from "react";
    import { connect } from "react-redux";
    import { getIndexList } from "../store/index";
    import styles from "./Index.css";
    ​
    function Index(props) {
      const [count, setCount] = useState(0);
    ​
      useEffect(() => {
        // 如果服务端没返回数据,客户端发送接口请求
        if (!props.list.length) {
          props.getIndexList();
        }
      }, []);
    ​
      return (
        <div className={styles.container}>
          <h1 className={styles.title}>first react ssr {count}</h1>
          <button onClick={() => setCount(count + 1)}>累加</button>
          <hr />
          <ul>
            {props.list.map((item) => {
              return <li key={item.id}>{item.name}</li>;
            })}
          </ul>
        </div>
      );
    }
    ​
    Index.loadData = (store) => {
      return store.dispatch(getIndexList());
    };
    ​
    export default connect((state) => ({ list: state.index.list }), {
      getIndexList,
    })(Index);
    
  4. 服务端改造,根据路由渲染出的组件,并且拿到loadData方法,获取数据。

    import React from "react";
    import { renderToString } from "react-dom/server";
    import express from "express";
    import { StaticRouter, matchPath, Route, Switch } from "react-router-dom";
    import routes from "../src/App";
    import { Provider } from "react-redux";
    import { getServerStore } from "../src/store/store";
    ​
    const app = express();
    ​
    // 设置静态资源地址
    app.use(express.static("public"));
    ​
    const store = getServerStore();
    ​
    // 匹配所有路由
    app.get("*", (req, res) => {
      // 根据路由渲染出的组件,并且拿到loadData方法,获取数据
      // 存储网络请求
      const promises = [];
      routes.map((route) => {
        const match = matchPath(req.path, route);
        if (match) {
          const { loadData } = route.component;
          if (loadData) {
            const promise = new Promise((resolve, reject) => {
              loadData(store).then(resolve).catch(resolve);
            });
            promises.push(promise);
            // promises.push(loadData(store));
          }
        }
      });
    ​
      // allSettled
      Promise.all(promises)
        .then(() => {
          const context = {
            css: []
          };
          // 由于node不支持jsx,使用react-dom/server自带的renderToString进行转换
          const content = renderToString(
            <Provider store={store}>
              <StaticRouter location={req.url} context={context}>
                <Header></Header>
                <Switch>
                  {routes.map((route) => (
                    <Route {...route}></Route>
                  ))}
                </Switch>
              </StaticRouter>
            </Provider>
          );
    ​
          if (context.statusCode) {
            res.status(context.statusCode)
          }
    ​
          if (context.action === 'REPLACE') {
            res.redirect(301, context.url)
          }
    ​
          const css = context.css.join('\n')
          // 返回一个html结构,并添加需要渲染的组件,并引入client中的JS进行数据操作。同构触发。
          res.send(`
        <html>
            <head>
                <meta charset="utf-8" />
                <title>react ssr</title>
                <style>
                  ${css}
                </style>
            </head>
            <body>
                <div id="root">${content}</div>
                <script>
                  window.__context = ${JSON.stringify(store.getState())}
                </script>
                <script src="/bundle.js"></script>
            </body>
        </html>    
        `);
        })
        .catch(() => {
          res.send("报错了500");
        });
    });
    ​
    // 启动服务
    app.listen(9093, () => {
      console.log("监听完毕");
    });
    
  5. 这样基本实现了SSR中Redux的数据渲染,但是前面提到的axios发送数据请求,CSR和SSR接口是不一样,所以我们接下来改造axios。

  6. Redux官网

axios代理实现

  1. redux-thunk支持接受参数并在使用的时候放入到第三个参数。

    redux-thunk.png

  2. 由于axios在CSR和SSR发送请求的地址不一样,可以使用redux-thunk第三个参数进行修改。拆分store入口函数,氛围Client调用和Server调用的函数

    import axios from 'axios'
    import { createStore, applyMiddleware, combineReducers } from 'redux'
    import thunk from 'redux-thunk'
    import indexReducer from './index'
    import userReducer from './user'const reducer = combineReducers({
        index: indexReducer,
        user: userReducer
    })
    ​
    const serverAxios = axios.create({
        baseURL: 'http://localhost:9090'
    })
    ​
    const clientAxios = axios.create({
        baseURL: '/'
    })
    ​
    // const store = createStore(reducer, applyMiddleware(thunk))// export default store// 服务端使用
    export const getServerStore = () => {
        return createStore(reducer, applyMiddleware(thunk.withExtraArgument(serverAxios)))
    }
    ​
    // 客户端使用
    export const getClientStore = () => {
        const defaultStore = window.__context ? window.__context : {}
        return createStore(reducer, defaultStore, applyMiddleware(thunk.withExtraArgument(clientAxios)))
    }
    
  3. 使用的时候修改indexReducer修改。

    const GET_LIST = 'INDEX/GET_LIST'const changeList = list => ({
        type: GET_LIST,
        list
    })
    ​
    export const getIndexList = server => {
       // 不使用全局的axios,使用第三个参数$axios
        return (dispatch, getState, $axios) => {
            return $axios.get('/api/course/list').then(res => {
                const { list } = res.data
                dispatch(changeList(list)) 
            })
        }
    }
    ​
    const defaultState = {
        list: []
    }
    ​
    export default (state = defaultState, action) => {
        switch (action.type) {
            case GET_LIST:
               const newState = {
                   ...state,
                   list: action.list
               }
               return newState
            default:
                return state
        }
    }
    
  4. Client改造,调用store中的getClientStore实例化store。

    import React from "react";
    import ReactDom from "react-dom";
    import { BrowserRouter, Route, Switch } from "react-router-dom";
    import routes from "../src/App";
    import { Provider } from "react-redux";
    import { getClientStore } from "../src/store/store";
    import Header from "../src/component/Header";
    ​
    const Page = (
      <Provider store={getClientStore()}>
        <BrowserRouter>
          <Header></Header>
          <Switch>
            {routes.map((route) => (
              <Route {...route}></Route>
            ))}
          </Switch>
        </BrowserRouter>
      </Provider>
    );
    ​
    if (window.__context) {
      // 进行注水
      ReactDom.hydrate(Page, document.getElementById("root"));
    } else {
      // 进行render
      ReactDom.render(Page, document.getElementById("root"));
    }
    
  5. Server改造,调用store中的getServerStore实例化store。

    import { getServerStore } from "../src/store/store";
    ​
    const store = getServerStore();
    ​
    const content = renderToString(
      <Provider store={store}>
        <StaticRouter location={req.url} context={context}>
          <Header></Header>
        <Switch>
          {routes.map((route) => (
            <Route {...route}></Route>
          ))}
        </Switch>
      </StaticRouter>
      </Provider>
    );
    
  6. 这样的axios就可以在CSR和SSR调用不同地址的接口了。

css支持

  1. CSS支持需要开启css module进行开发。webpack配置css-loader中的options对象的modules: true开启css module。

    rules: [
          {
            test: /.css$/,
            use: [
              "style-loader",
              {
                loader: "css-loader",
                options: {
                  modules: true,
                },
              },
            ],
          },
        ],
    
  1. React中的props有staticContext字段,可用与SSR的数据传递。

  2. 使用css modules的_getCss拿到css对象并进行序列化。并放入到SSR中进行数据的渲染并放入到模版中。

    About.js

    import React from "react";
    import styles from './About.css'function About(props) {
      if (props.staticContext) {
        props.staticContext.css.push(styles._getCss())
      }
      return <div className={styles.title}>关于页面</div>;
    }
    ​
    export default About;
    

    Server/index.js

    import React from "react";
    import { renderToString } from "react-dom/server";
    import express from "express";
    import proxy from "http-proxy-middleware";
    import { StaticRouter, matchPath, Route, Switch } from "react-router-dom";
    import routes from "../src/App";
    import { Provider } from "react-redux";
    import { getServerStore } from "../src/store/store";
    import Header from "../src/component/Header";
    import path from 'path'
    import fs from 'fs'const app = express();
    ​
    // 设置静态资源地址
    app.use(express.static("public"));
    ​
    const store = getServerStore();
    ​
    app.use(
      "/api",
      proxy({
        target: "http://localhost:9090",
        changOrigin: true,
      })
    );
    ​
    function csrRender(res) {
      // 读取文件返回
      const filename = path.resolve(process.cwd(), 'public/src/index.csr.html')
      const html = fs.readFileSync(filename, 'utf-8')
      res.send(html)
    }
    // 匹配所有路由
    app.get("*", (req, res) => {
      if (req.query._mode == 'csr') {
        console.log('url参数开启csr降级');
        return csrRender(res)
      }
      // 转发
      // if (req.url.startsWith('/api/')) {
      //   // 不渲染页面,使用axios进行转发
      // }
    ​
      // 根据路由渲染出的组件,并且拿到loadData方法,获取数据
      // 存储网络请求
      const promises = [];
      routes.map((route) => {
        const match = matchPath(req.path, route);
        if (match) {
          const { loadData } = route.component;
          if (loadData) {
            const promise = new Promise((resolve, reject) => {
              loadData(store).then(resolve).catch(resolve);
            });
            promises.push(promise);
            // promises.push(loadData(store));
          }
        }
      });
    ​
      // allSettled
      Promise.all(promises)
        .then(() => {
          const context = {
            css: []
          };
          // 由于node不支持jsx,使用react-dom/server自带的renderToString进行转换
          const content = renderToString(
            <Provider store={store}>
              <StaticRouter location={req.url} context={context}>
                <Header></Header>
                <Switch>
                  {routes.map((route) => (
                    <Route {...route}></Route>
                  ))}
                </Switch>
              </StaticRouter>
            </Provider>
          );
    ​
          if (context.statusCode) {
            res.status(context.statusCode)
          }
    ​
          if (context.action === 'REPLACE') {
            res.redirect(301, context.url)
          }
    ​
          const css = context.css.join('\n')
          // 返回一个html结构,并添加需要渲染的组件,并引入client中的JS进行数据操作。同构触发。
          res.send(`
        <html>
            <head>
                <meta charset="utf-8" />
                <title>react ssr</title>
                <style>
                  ${css}
                </style>
            </head>
            <body>
                <div id="root">${content}</div>
                <script>
                  window.__context = ${JSON.stringify(store.getState())}
                </script>
                <script src="/bundle.js"></script>
            </body>
        </html>    
        `);
        })
        .catch(() => {
          res.send("报错了500");
        });
    });
    
  3. 这样的话在SSR渲染的时候就支持CSS了。

性能优化

  1. 错误页面状态码支持

    • 设计Notfound组件,如果找不到进入Notfound组件。

      路由改造

      export default [
        {
          path: '/',
          component: Index,
          // loadData: Index.loadData,
          exact: true,
          key: 'index'
        },
        {
          path: '/userinfo',
          component: User,
          exact: true,
          key: 'userinfo'
        },
        {
          path: '/about',
          component: About,
          exact: true,
          key: 'about'
        },
        {
          component: Notfound, // 找不见对应的路由进入
          key: 'notfound'
        }
      ]
      

      Notfound.js

      import React from 'react'
      import { Route } from 'react-router-dom'function Notfound(props) {
          return  <h1>error Notfound</h1>
      }
      ​
      export default Notfound
      
    • Notfound进行改造,指定错误页面的状态码。

      import React from 'react'
      import { Route } from 'react-router-dom'function Status({ code, children }) {
          return <Route render={({ staticContext }) => {
              if (staticContext) {
                  staticContext.statusCode = code
              }
              return children
          }}></Route>
      }
      ​
      function Notfound(props) {
          return <Status code={404}>
              <h1>error Notfound</h1>
          </Status>
      }
      ​
      export default Notfound
      
    • 使用staticContext进行SSR通信,并返回状态。

      if (context.statusCode) {
        res.status(context.statusCode)
      }
      
  2. 放弃SEO的降级渲染实现优化,创建CSR的模版文件,如果是服务端压力过大等一些问题,不经过SSR之后返货CSR模版。

    • 创建index.csr.html

      <!DOCTYPE html>
      <html lang="en">
        <head>
          <meta charset="UTF-8" />
          <meta http-equiv="X-UA-Compatible" content="IE=edge" />
          <meta name="viewport" content="width=device-width, initial-scale=1.0" />
          <title>react csr</title>
        </head>
        <body>
          <div id="root"></div>
        </body>
      </html>
      
    • webpack.client.js使用html-webpack-plugin进行改造。

      const HtmlWebpackPlugin = require("html-webpack-plugin");
      ​
      ...
      ​
      // 添加plugins
      plugins: [
          new HtmlWebpackPlugin({
            filename: "src/index.csr.html",
            template: "src/index.csr.html",
            inject: true,
          }),
        ],
      
    • Server改造。完成一个切换CSR和SSR工程。

      // 创建渲染CSR模版函数
      function csrRender(res) {
        // 读取文件返回
        const filename = path.resolve(process.cwd(), 'public/src/index.csr.html')
        const html = fs.readFileSync(filename, 'utf-8')
        res.send(html)
      }
      ​
      app.get("*", (req, res) => {
        // 这个_mode的判断可切换任何,比较服务端压力大开启,前端手动开启等
        if (req.query._mode == 'csr') {
          console.log('url参数开启csr降级');
          return csrRender(res)
        }
      }
      
  3. 高阶组件优化css

    • 由于css不能在每一个组件都加入判断去判断SSR还是CSR,使用高阶组件进行封装。

      import React from "react";
      import styles from './About.css'function About(props) {
        // 每个组件进行判断过于繁琐
        if (props.staticContext) {
          props.staticContext.css.push(styles._getCss())
        }
        return <div className={styles.title}>关于页面</div>;
      }
      ​
      export default About;
      
      • withStyle

        import React from "react";
        // 将非反应特定的静态数据从子组件复制到父组件。类似于Object.assign,但将 React 静态关键字列入黑名单,以免被覆盖。
        import hoistNonReactStatic from 'hoist-non-react-statics';
        ​
        function whitStyle(Comp, styles) {
          function NewComp (props) {
            if (props.staticContext) {
              props.staticContext.css.push(styles._getCss());
            }
            return <Comp {...props} />;
          };
          hoistNonReactStatic(NewComp, Comp)
          return NewComp
        }
        ​
        export default whitStyle;
        
      • 改造使用组件

        import React from "react";
        import styles from './About.css'
        import withStyle from "../withStyle";
        ​
        function About(props) {
          return <div className={styles.title}>关于页面</div>;
        }
        ​
        export default withStyle(About, styles);
        

完整代码地址:github.com/lowKeyXiaoD…

结语

服务端渲染在React和Vue中都有完整的框架实现,比如Vue的nuxt和React的next都是比较成熟的服务端渲染的框架,现在的实现只是为了大家更好的认识的什么SSR,什么是同构,更好的掌握SSR框架。

其实SSR的实现还有其他的思路,如果项目工程过大,SSR后期加入,只是为了方便SEO查找可以使用puppeteer进行SSR改造。监听请求地址,抓取对应的html进行渲染。这样就会造成一种SSR的假象也是可以进行SEO的。

const express = require("express");
const puppeteer = require("puppeteer");
const app = express();
​
app.get("*", async (req, res) => {
if (req.url === "/favicon.ico") {
return res.send({
  code: 0,
});
  }
​
const url = 'http://localhost:9093' + req.url
const brower = await puppeteer.launch()
const page = await brower.newPage()
await page.goto(url, {
  waitUntil: ['networkidle0']
})
const html = await page.content()
res.send(html);
});
​
app.listen(8081, () => {
console.log("ssr server");
});