7 React项目架构 - Router和Store的服务端渲染

 

0x0 概述

加入了router和store,会对服务端渲染有影响:


0x1 store和router的服务端渲染基础配置

实现

server-entry.js

修改前

import React from 'react'
import App from './views/App'

export default <App />

修改后

import React from 'react'
import { StaticRouter } from 'react-router-dom' // #1
import { Provider, useStaticRendering } from 'mobx-react' // #2
import App from './views/App'

import { createStoreMap } from './store/store'

//  让mobx在服务端渲染的时候不会重复数据变换
useStaticRendering(true)  //  #3

//  传入对象格式:{appStore: xxx}
export default (store, routerContext, url) => ( // #5
    <Provider {...store}>  {/* #4 */}
      <StaticRouter context={routerContext} location={url}>  {/* #6 */}
        <App/>
      </StaticRouter>
    </Provider>
)

export { createStoreMap } // #7

说明:

  1. StaticRouter是专门做服务端渲染提供的组件
  2. Mobx提供的服务端渲染工具
  3. mobx会导致每一次数据变化,引起其他方法的调用
    • 如computed相关方法
    • 服务端渲染的时候,如果使用客户端的代码做,会有bug
      • 一次渲染会导致computed执行很多次
      • 而且改的变量多的时候,会造成重复引用、重复调用
      • 进而导致内存溢出
    • 这个方法就是解决这个问题的
  4. Provider - 需要接受Store的内容
    • store需要在服务端渲染的时候生成
    • 服务端渲染的时候会有很多请求进来(如列表页的请求、详情页的请求),不可能同一个store在不同的请求里使用
      • 因为一个store,在第一次请求的时候可能已经初始化过一些数据了
      • 第二次又使用这个store,又初始化另一部分数据,会造成数据的改来改去。所以每次都要重新创建一个store
      • 更而且,如果所有请求用同一个store,并发的时候怎么修改呢?
    • {...store}:
      • 使用解构的方式,指定store们
      • 相当于:appState=a对象 bppState=b对象
  5. 需要让store、routerContext从外界传入进来
    • 传入对象格式:{appStore: xxx}

  6. context和url
    • context - 会在静态渲染的时候,对这个对象进行一些操作,返回一些信息
      • 如:需要做redirect的时候,会在context中加一个url,告诉我们需要redirect到哪个地方,我们就在服务端302到那个地方
    • location - 就是当前页的url
  7. 为啥import进来又export出去

    • 因为server.js那边,只能拿到编译后的server-entry.js包
    • 想要调用client下的某个方法,这个方法必须得通过server-entry拿才行
    • 所以,这里需要「透传」出去


store.js

import AppStateClass from './app-state'

export const AppState = AppStateClass

export default {
  AppState,
}

export const createStoreMap = () => {
  return {
    appState: new AppState()
  }
}

说明:


修改开发阶段,服务端渲染的相关逻辑

dev-static.js

/* 旧方法
module.exports = function (app) {
    ...
    app.get('*', function (req, res) {
        getTemplate().then((index) => {
            const appString = ReactDomServer.renderToString(serverBundle);

            console.log('appString:' + appString);
            res.send(index.replace('<!--app-->', appString));
        })
    })
};
*/
let serverBundle, createStoreMap
serverCompiler.watch({}, (err, stats) => {
  ...
  const m = new Module()
  m._compile(serverBundleString, 'server-entry.js')
  serverBundle = m.exports.default
  createStoreMap = m.exports.createStoreMap // #1
});

const ReactDomServer = require('react-dom/server')

module.exports = function(app) {
  ...
  app.get('*', function(req, res) {
    getTemplate().then((index) => {

      const routerContext = {}
      const app = serverBundle(createStoreMap(), routerContext, req.url)

      const appString = ReactDomServer.renderToString(app)
      console.log('appString:' + appString)
      res.send(index.replace('<!--app-->', appString))
    })
  })
};说明:
  1. 注意,想要导到createMapStore方法,需要从编译好的模块里导
    • 千万不要直接require('../../client/server-entry.js')这样
    • 楼上的,文件里用的是es6的语法,nodejs的server里是识别不了的
    • 只能识别编译后的文件的呀!
  2. 由于导出来的已经是一个方法了,需要传参进去调用,才能生成<App />
    1. createStoreMap是server-entry.js透传的,创建store的方法
    2. routerContext,传个空对象就好


结果

执行:

  1. dev:client
  2. dev:web:start


0x2 重定向的服务端渲染

问题:如上配置,当我们访问 view-source:localhost:2333/ 查看源码的时候,不会把重定向之后的内容展示出来。

方案:判断「上下文」有木有url - 说明是否重定向,有的话,丢给浏览器302,以便进行重定向

dev-static.js

...
module.exports = function(app) {
  ...
  app.get('*', function(req, res) {
    getTemplate().then((index) => {

      const routerContext = {}
      const app = serverBundle(createStoreMap(), routerContext, req.url)

      const appString = ReactDomServer.renderToString(app)
      if (routerContext.url) {
        res.status(302).setHeader('Location', routerContext.url)
        res.end()
        return
      }
  ...
    })
  })
};

说明:


0x3 服务端异步渲染

概述

场景:实际业务开发中,有许多需要「异步」处理的场景,如「请求服务端数据」等。我们希望,异步数据,可以在异步执行完毕之后,再进行服务端渲染 & 客户端内容生成。

相关commit

核心工具:

  1. react-async-bootstrapper
    • 安装:`npm i react-async-bootstrapper -S`
  2. ejs

案例:实现count的延时更改

服务端渲染 & 执行异步任务

client/views/topic-list/index.jsx

class TopicList extends React.Component {
  ...
  bootstrap() {
    return new Promise((resolve) => {
      setTimeout(() => {
        this.props.appState.count = 3
        resolve(true)
      })
    })
  }
  ...
}

说明:


server/util/dev-static.js

开发环境下,会使用如下逻辑

module.exports = function(app) {
  ...
  app.get('*', function(req, res) {
    getTemplate().then((template) => {
      ...
      const asyncBootstrapper = require('react-async-bootstrapper')
      asyncBootstrapper(app).then(() => {
        const appString = ReactDomServer.renderToString(app)
        ...
        res.send(index.replace('<!--app-->', appString))
      })
    })
  })
};

说明:


结果:查看源码,可以发现,服务端渲染已经把count渲染成3了

问题:页面展示内容中,count仍然为0

解决:使用ejs模板,将服务端渲染后的正确state传给前端展示

将服务端渲染后的state传给前端(ejs)

安装工具:


client/server.template.ejs

未来服务端渲染的内容,都以这个文件作为模板渲染。

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content='"width=device-width, user-scalable=no, initial-scale=1.0'>
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>由我来组成扁桃体٩(●˙▿˙●)۶…⋆ฺ</title>
</head>
<body>
  <div id="root"><%%- appString %></div> <!--#1-->
  <script>
    window.__INITIAL__STATE__ = <%%- initialState %> <!--#2-->
  </script>
</body>
</html>

说明

  1. 会把appString作为一个变量,在webpack编译的时候,把这个变量放到当前位置
    1. <%%这种写法:在webpack的HTMLPlagin在编译模板的时候,是可以识别这样的ejs的语法的
    2. 会自动通过ejs的一个loader,把编译出来的结果中,多出来的%去掉,输出一个完整的模板文件,服务端渲染的时候使用
  2. initialState变量,用于把服务端渲染后的state传进来
    1. __INITIAL__STATE__是个添加给window的自定义属性,前端展示时,可以通过这个属性,拿到服务端渲染好的State
      1. 命名方式是为了防止客户端定义的名称和这里的模板属性名称发生命名冲突,所以定义的比较怪异


指定ejs模板文件的loader

build/webpack.config.client.js

const config = webpackMerge(baseConfig,{
    ...
    plugins: [
        ...
        new HTMLPlugin({
            template: '!!ejs-compiled-loader!' + path.join(__dirname, '../client/server.template.ejs'), // #1
            filename: 'server.ejs' // #2
        })
    ],
    ...
});

说明:

  1. 指定模板loader的方法
  2. 指定编译后的文件名,方便其他地方获取这个文件


修改server端渲染template


const getTemplate = () => {
  return new Promise((resolve, reject) => {
    axios.get('http://localhost:8787/public/server.ejs') // #1
      .then((res) => {
        resolve(res.data)
      })
      .catch(reject)
  })
};
...
let serverBundle, createStoreMap
serverCompiler.watch({}, (err, stats) => {
  ...
  const m = new Module()
  m._compile(serverBundleString, 'server-entry.js')
  serverBundle = m.exports.default
  createStoreMap = m.exports.createStoreMap
});

const getStoreState = (stores) => {
  return Object.keys(stores).reduce((result, storeName) => {
    result[storeName] = stores[storeName].toJson()
    return result
  }, {})
} // #2

const ReactDomServer = require('react-dom/server')
module.exports = function(app) {
  ...
  app.get('*', function(req, res) {
    getTemplate().then((template) => {

      const routerContext = {}
      const stores = createStoreMap()
      const app = serverBundle(stores, routerContext, req.url)

      const asyncBootstrapper = require('react-async-bootstrapper')
      asyncBootstrapper(app).then(() => {
        const appString = ReactDomServer.renderToString(app)
        ...
        const state = getStoreState(stores) // #3

        const ejs = require('ejs')
        const serialize = require('serialize-javascript') // #4
        const html = ejs.render(template, { // #5-1
          appString: appString,
          initialState: serialize(state), // #5-2
        })

        console.log('appString:' + appString)
        console.log(stores.appState.count)

        res.send(html)  // #6
      })
    })
  })
};

说明:

  1. 改用编译好的server.ejs模板文件
  2. 创建将stores转成state的方法
  3. 获得新的state
  4. 用于把js对象转为string的库
    1. 安装`npm i serialize-javascript -S`
  5. 把ejs模板和要插入其中的变量结合,输出最终的html文件
    1. 指定ejs模板
    2. initialState需要是一个字符串才行


client/store/app-state.js

class AppState {
  ...
  toJson() { // #1
    return {
      count: this.count,
      name: this.name,
    }
  }
}

export { AppState as default }

说明:

  1. toJson方法:让AppState这个实例,可以以Json的格式获取到


将State展示到前端

client/store/app-state.js

class AppState {
  constructor({ count, name } = { count: 0, name: 'Azen' }) { // #1
    this.count = count
    this.name = name
  }

  @observable count
  @observable name
  @computed get msg() {
    return `${this.name} say count is ${this.count}`
  }

  @action add() {
    this.count += 1
  }

  @action changeName(name) {
    this.name = name
  }

  toJson() {
    return {
      count: this.count,
      name: this.name,
    }
  }
}

export { AppState as default }

说明:

  1. 定义state的构造方法,允许通过传入对象构造State


app.js

const initialState = window.__INITIAL__STATE__ || {} // eslint-disable-line // #1

const root = document.getElementById('root')

const render = (Component) => {
  ReactDOM.hydrate(
    <AppContainer>
      <Provider appState={new AppState(initialState.appState)}> // #2
        <BrowserRouter>
          <Component />
        </BrowserRouter>
      </Provider>
    </AppContainer>, root,
  )
};

render(App)


...

说明:

  1. 通过window.__INITIAL__STATE__拿到服务端传来的渲染后的state
    1. 防止eslint因为方法名而报错
  2. 使用渲染后的state,生成页面展示需要的state


SEO标签添加

待完善

目前只做到了客户端生效,服务端未能生效...

相关工具:helmet

相关提交

开发环境代码优化 & 逻辑抽取

相关commit

生产环境服务端渲染逻辑调整

相关commit