美文网首页菜鸟朱茱霞的前端搬砖史
复盘---大白话解释react-ssr

复盘---大白话解释react-ssr

作者: 朱珠霞 | 来源:发表于2019-01-23 20:14 被阅读0次

最近刚学了一些react-ssr的内容,刚好用这个来码了自己的个人简历网站。现在临近收尾工作,写一篇笔记来记录复盘一下。

源码查看

如果希望可以无痛理解,可能需要提前理解react相关技术栈,如react-redux、react-router等

react-redux思维导图

众所周知,一般的SPA会因为客户端需要先运行一遍渲染出DOM,因此一般会出现首屏加载白屏的现象,另外也会存在SEO较差的问题。SSR的出现就是为了解决这两个问题。(emm..我用这个码了一个个人简历网站,也是为了提高SEO...哈哈哈)

什么是同构

同一套react代码在服务端、客户端都运行一遍。当客户访问网页,react代码先在服务端运行一遍,返回一个html的字符串模板到客户端。客户端接收到html & JavaScript脚本。JavaScript脚本的react代码接管服务端返回的html模板。

//app.js
import React from 'react'
export default ()=>(
    <div>this is app</div>
)

// client-side
import ReactDOM from 'react-dom'
import App from './app.js'

ReactDOM.hydrate(App,document.getElementById('root'))

//server-side
import express from 'express'
import { renderToString } from 'react-dom/server'
import App from './app.js'

const app = express()
const html =(content)=>(`
    ...
<body><div id="root"> ${content} </div></body>
    ...
`)
app.get('*',(req,res)=>{
    const content = renderToString(App)
  res.send(html(content))
})

一个简单的客户端/服务端同构就实现了。react-dom中有个API renderToString可以将react生成的虚拟DOM转化为字符串。

简单知识点:

  1. server-side使用renderToString生成字符串模板后,返回给客户端。
  2. client-side使用hydrate接管server端返回的html模板,继续运行react代码。

hydrate相比于普通的render,免去了创建dom节点的工作,但仍然需要完成dom diff,和dom patch的工作,为相应的DOM节点绑定事件,运行react生命周期中componentDidMount后的工作。

这里需要注意的是:当客户端渲染的结果与服务端渲染的结果不一致的时候,将会出现error xxx does not match xxx

这时候就要回顾一下自己的代码哪里出错了。

但在实际项目中,react 同构我们需要考虑的问题还有很多:

  1. 服务端和客户端运行环境不一样,服务端运行在node环境下,客户端运行在浏览器下,因此服务端没有window这个全局对象。
  2. 服务端遵循CommonJS标准,客户端支持ES6模块。
  3. 客户端/服务端的数据如何统一,如果有些数据需要异步(请求别的服务端)来获取,如何控制?
  4. html模板渲染出来了,但没有提前准备CSS资源,用户体验也不好。
  5. 应用的路由如何控制,在服务端/客户端如何设置

带着这些问题,我们来搭建一个简单的react-SSR环境

react-SSR环境搭建

在开始之前,我先说一下这次项目我所使用的技术栈以及目录结构:

  • 前端: React 16.7 + Redux + immutable + thunk + React Router V4 + styled-component
  • 后端: Node + exporess
  • 第三方库: axios、leancloud、dayjs
  • 构建: webpack4
//目录结构
.
├─ build/                    # webpack配置目录
├─ dist/                     # server-side 编译后的代码
├─ public/                   # client-side 编译后的代码(静态资源)
├─ src/                      # 源码
│   ├─── App/                # React代码
│   │     ├─── components/   # 页面中的组件
│   │     ├─── pages/        # 页面组件
│   │     ├─── store/        # 状态管理 redux
│   │     ├─── styled/       # 所有styled-component
│   │     └─── index.jsx     # App
│   ├─── config/             # APP的一些配置文件,如Router、leancloud配置等
│   ├─── client/             # 客户端渲染代码
│   └─── server/             # 服务端代码
│         ├─── proxy.js      # 路由代理
│         ├─── render.js     # 负责生成html模板
│         └─── index.jsx     # node-server 入口
├─── .gitignore
└─── package.json

1. 搭建ssr环境第一步

使用webpack分别给客户端和服务端写一份打包配置

# webpack.client.js
const path = require('path')
const Merge = require('webpack-merge')
const baseConfig = require('./webpack.base')

const config = {
  entry: path.resolve(__dirname,'../src/client'),
  output:{
    filename:'index.js',
    path: path.resolve(__dirname,'../public')
  }
}
module.exports = Merge(config,baseConfig)

# webpack.server.js
const path = require('path')
const Merge = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseConfig = require('./webpack.base')

const config = {
  target: 'node',
  entry: path.resolve(__dirname, '../src/server'),
  output: {
    filename: 'server.js',
    path: path.resolve(__dirname, '../dist'),
    libraryTarget: 'commonjs2'
  },
  externals:[nodeExternals()]
}
modules.exports = Merge(config,baseConfig)

# webpack.base.js
const path = require('path')
module.exports = {
  mode: process.env.NODE_ENV ||'production',
  resolve: {
    extensions: [ '.js', '.jsx' ]
  },
  module:{
    rules:[{
      test: /\.(js|jsx)?$/,
      loader:'babel-loader',
      exclude:[
        path.resolve(__dirname, '../node_modules')
      ],
      options:{
        presets:['react','stage-0',['env',{
          target:{
            browser:['last 2 versions']
          }
        }]
        ]
      }
    }
}

# npm script
"build:server":"webpack --config ./build/webpack.server.js --watch" 
"build:client":"webpack --config ./build/webpack.client.js --watch" 
"dev:start": "node ./dist/server.js"

第二步:配置css文件

这次我使用的是styled-component

  • 在webpack上配置一下styled-components的plugin
# webpack.base.js
// babel-loader options
plugins:['babel-plugin-styled-components']
  • 服务端收集相应css选择器&css样式
//server-side
import { ServerStyleSheet } from 'styled-components'

 const sheet = new ServerStyleSheet()
 const content = renderToString(sheet.collectStyles(App)) //App---root component
 const style = sheet.getStyleTags() //获取css样式,插入在html模板中
# html
`
<html lang="zh-Hans">
<head>
    ${ style }
</head>
<body>
  <div id="root">${content}</div>
  <script src="./index.js"></script>
</body>
</html>
`

第三步:App的数据管理

  • 涉及到数据管理,我们需要理解两个概念:脱水、注水
    1. 脱水:服务端、客户端生成的store应该是两个不同的对象。不然在开发过程中,两端会共用一个store。
    2. 注水:服务端运行react代码得到的数据应该带到客户端,并且赋值到客户端的store
  • 数据的异步获取我们需要用到中间件 redux-thunk
# store
import {createStore,compose,applyMiddleware } from 'redux'
import reducer from './reducer'
import thunk from 'redux-thunk'
import {fromJS} from 'immutable'

let enhances,defaultState

if(typeof window === 'object'){ //client-side
  const componentEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
  enhances = componentEnhancers(applyMiddleware(thunk))
  defaultState = fromJS(window.INITIAL_STATE)  // 注水第2步(第1步看最下面)
} else { //server-side
  enhances = compose(applyMiddleware(thunk))
}
// 脱水
const getStore = defaultState ? 
  createStore(reducer,defaultState,enhances) :
  createStore(reducer,enhances) 

export default getStore

# server-side
import getStore from './store'
const store = getStore()
const initState = store.getState() 
# html
//注水第1步,将服务端的store数据传到window全局对象的__INITIAL_STATE__中
`<script>window.__INITIAL_STATE__ = ${JSON.stringify(initState)}</script> `
  • 异步数据的获取
    关于客户端有没有必要获取异步数据,答案是肯定的。
    在实际项目中,有的页面需要异步数据加载(page A),有的不需要(page B)。
    当我们从page A 进入网站时,客户端当然不需要重复获取数据(因为服务端已经帮我们拿到了数据并渲染到页面了,注意,这里需要我们判断数据是否存在再异步获取 );但是当我们从page B进入网站,再跳转到page A,此时客户端应该需要获取异步数据并渲染到页面中。

  • 客户端获取数据

    在一般的ssr架构中,node应该充当一个中间层的角色。底层的后端服务器如c#、java等用来操作数据,因为它们的性能相对于node来说更高。
    因此,为了保持这样的架构设计,客户端获取数据的接口应该设计在node服务器上,再由node代理到底层服务器
    由于我们后端使用express,我们可以用express-http-proxy直接将url映射到底层服务器:

    import proxy from 'express-http-proxy'
    
    const app = express()
    app.use(' /api ',proxy('你的底层server url', {
      proxyReqPathResolver(req) {
        return '你的底层server url'+ req.url
      }
    }))
    /**
     比如你的node server url为 localhost:3000,底层服务器为 localhost:8888
    当客户端访问 localhost:3000/api/xxx 时,node服务器会映射到localhost:8888/xxx
    **/
    
  • 服务端获取异步数据

    关于服务端获取异步数据我们需要关注两点:

    1. 在上面我们已经将客户端获取数据的接口放在服务端上,但是服务端获取数据并不需要代理,它直接在底层服务器上获取就可以了。

    2. 两端所使用的store并不共用,我们如何确保获取的数据是相应的一端?
      第1点里,两者所请求的接口不一致,我们可以使用axios的一个APIaxios.create()事先定义好请求接口,然后在createstore时,用thunk的APIthunk.withExtraArgument()将它们当作额外参数传到各自的store中

      # axiosInstance.js
      import axios from 'axios'
      const  baseClientURL='xxx', baseServerURL='yyy'
      
      export const clientInstance = axios.create({
        baseURL: baseClientURL
      })
      
      export const serverInstance = axios.create({
        baseURL: baseServerURL
      })
      
      # store.js
      import {clientInstance , serverInstance } from './axiosInstance'
      //client-side
      enhances = componentEnhancers(applyMiddleware(thunk.withExtraArgument(clientInstance )))
      // server-side
      enhances = componentEnhancers(applyMiddleware(thunk.withExtraArgument(serverInstance )))
      
      
      # action.js
      // thunk中间件的 函式 action
      const  url={
         // ... other url
         homelist:'/homelist'
       }
      export const getHomeList =()=>((dispatch,getState,axiosInstance)=>{
        return axiosInstance.get(url.homelist).then( response=>{
          const action = {
            type:GETHOMELIST,
            list:response.data.list
          }
          dispatch(action)
        })
      })
      

      这样的设计就能保证在服务端或客户端获取异步数据的时候,它们所请求的接口不会出错。
      但是如何保证当dispatch一个action时,它的store是对应的那个呢?
      答案是:我们利用react-router让服务端在路由渲染成功之前,先异步获取数据。详细怎么做在第四步有详细说明。

第四步:路由设置

关于react-router,跟平常的react App差不多,但是在服务端里,由于没有DOM,因此我们不能用<BrowserRouter>来包裹routes,react-router-dom 提供了<staticRouter>用于node端。

# Router config
import App from 'app.jsx'
import IndexPage from 'indexPage.jsx'
import LoginPage from 'loginPage.jsx'

export default [{
  path: '/',
  component: App,
  routes:[{
    path: '/',
    exact: true,
    component:IndexPage,
    loadData: IndexPage.loadData
  },{
    path: '/login',
    component: LoginPage
  }]
}]

# server-side
import Router from './Router.js'
import { renderRouter } from 'react-router-config'
import { staticRouter } from 'react-router-dom '
app.get('x',(req,res)=>{
  const App= (
    <StaticRouter location ={req.path} context={context}>
        { renderRoutes(Router) }
    </StaticRouter>)
const content = renderTostring(App)
})

# client-side
const App =(
     <BrowserRouter>
      { renderRoutes(Router) }
    </BrowserRouter>
)

ReactDOM.hydrate(App,document.getElementById('root'))
  • 利用loadData实现服务端异步获取数据
    以上是一个简单的router配置。仔细看过代码应该可以发现,有一个route上有个属性loadData
    这个loadData就是我在第三步中提到的服务端获取异步数据所用到的函数。
    思路: 我们可以利用 matchRoutes匹配到当前路由下所有routes,并提取每个routes的loadData,并且在服务端先执行成功在返回html。
# server-side
import { matchRoutes } from 'react-router-config'
import getStore from './store'

// ...
const store = getStore()
const currentRoutes = matchRoutes(Router, req.path)
currentRoutes .map( matchItem=>{
    if(!!matchItem.route.loadData){
      const newPromise = new Promise((resolve, reject)=>{
        matchItem.route.loadData(store).then(resolve).catch(reject) //这里将服务端的store传给App
      })
      promises.push(newPromise)
    }
  })
  Promise.all(promises).then(()=>{
    const html = render()
    res.send(html)
  }).catch((err)=>{
    res.status(500).end('sorry request error')
  })

# app component
IndexPage.loadData =({dispatch})=>{
  return dispatch(action.getHomeList())
}

# action.js
// thunk中间件的 函式 action
const  url={
   // ... other url
   homelist:'/homelist'
 }
export const getHomeList =()=>((dispatch,getState,axiosInstance)=>{
  return axiosInstance.get(url.homelist).then( response=>{
    const action = {
      type:GETHOMELIST,
      list:response.data.list
    }
    dispatch(action)
  })
})
  • 利用staticRoutercontext实现404页面 & 301重定向
  1. 实现404页面
    首先在路由配置Router.js 中设置路由:当访问路径不满足指定路径时,跳转到NotFound组件
export default [{
  path: '/',
  component: App,
  routes:[{
    path: '/',
    exact: true,
    component:IndexPage,
    loadData: IndexPage.loadData
  },{
    path: '/login',
    component: LoginPage
  }]
},{
  component:NotFound 
}]

NotFound组件里,对context进行修改:

# NotFound component
componentwillMount(){
  this.props.staticContext && (this.props.staticContext.NotFound = true)
}

在服务端对context进行判断
context.NotFound ? res.status(404).send(html) :res.send(html)

  1. 实现301重定向
    当组件里有Redirect组件的时候,客户端进行重定向,同时也会向服务端的stacticContext发送一段信息:
{
  action:' REPLACE ',
  location: xxx,//
  url: //重定向的路由
}

可以借助这个特性实现重定向。

总结

以上是关于react-ssr的一些概念。我这个项目的架构相对来说还是比较简单,对于第一次接触react-ssr的人,可能会比较好理解,有兴趣可以clone下来学习一下。
对于这个项目架构的设计其实对我来说并不完美。如果说打分勉强算 5、6分吧。它还有很多需要优化的地方:比如没有eslint的检查配置,比如开发体验还不是最优的,每次更新代码不能自动刷新页面更新...等等
anyway, learn by doing. 大家渣油啊。

相关文章

  • 复盘---大白话解释react-ssr

    最近刚学了一些react-ssr的内容,刚好用这个来码了自己的个人简历网站。现在临近收尾工作,写一篇笔记来记录复盘...

  • 看似简单的东西,也没那么简单

    #复盘#反思「49/49」 「见」 古人云:每日三省吾身。 现在很多大佬都说:人要进步就要不断的复盘。 大白话:多...

  • 感恩的习惯二: 复盘

    一、什么是复盘 搜索了多种解释,觉得下面的解释最客观精彩。 很多人把复盘理解为总结,确实,复盘与总结有诸多相似之处...

  • 如何更好地借助复盘,实现自我精进与成长?

    之前,有专门地分享过关于复盘的文章,里面也提及了对“复盘”这个词的解释,以及复盘的一个重要性《个人品牌打造与复盘》...

  • 复盘,为了更好前行

    复盘,百度的解释是,围棋术语,指对局结束后,复演该盘棋的记录,已检查该盘棋的优劣与得失关键。 现在的复盘,不仅仅指...

  • 遇见一群人,学会许多事儿

    参加这次复盘营,就感觉是完全颠覆自己对复盘的理解。自己对复盘的理解永远停留在百度给予的解释中。所以我一直在思考,到...

  • 2018-05-02 六期D26:偿债能力小结

    一、概念 今天我们对“偿债能力”模块进行小结和复盘。公司的“偿债能力”,用大白话说就是“你欠我的,能还吗?能快速还...

  • 什么是反思复盘?

    永澄老师关于反思复盘的解释汇总: 复盘是一个非常重要的方法,如果你只掌握一个技能就可以把所有技能串联起来,那就是复...

  • 复盘方法论

    什么是复盘?股票市场、棋类竞技、个人管理中对此均有不同解释。 《柳传志:我的复盘方法论》中提到,“君子博学而日参省...

  • 抄来的复盘解释

    可能有的伙伴对复盘没有思路,在这里和大家分享复盘的思路,方便大家真正理解复盘,并能够运用这个方法,更好地提升自己 ...

网友评论

    本文标题:复盘---大白话解释react-ssr

    本文链接:https://www.haomeiwen.com/subject/mbijjqtx.html