Skip to content

chris-envas/About-React-SSR

Repository files navigation

前言

本文探索的内容是如何将:将SPA应用通过同构的方式渲染到客户端

SPA应用的内容是通过JavaScript驱动的视图,因此人们发现,SPA应用虽然可以构建更加健壮、强大的web应用,但是存在两个缺点

  • SEO不友好
  • 首屏渲染较慢

为了解决这两个问题,人们想到将SPA应用在服务端渲染后下发,确切的说就是在服务端获取SPA应用快照,首屏渲染时采用服务端快照,后续应用逻辑则交由客户端代码接管,这种方式也被称之为同构

服务端渲染的知识点总体还是毕竟琐碎的

wNK6aQ.png

服务端渲染组件

创建组件

import React from 'react';
const Home = () => {
  return (
    <div>
      <div>服务端渲染</div>
    </div>
  )
}
export default Home

使用React提供的renderToString在服务端解析组件

import Express from "express"
import Home from "../components/Home.js"
import React from 'react'
import { renderToString } from "react-dom/server"

const app = Express()
const content = renderToString(<Home />)
app.get('*', (req, res) => {
    console.log(req.url)
    res.send(
        `<html>
        <head>
          <title>ssr</title>
        </head>
        <body>
          <div id="root">${content}</div>
        </body>
      </html>
        `
    )
})
app.listen(8080,() => {
    console.log('server port on 8080')
})

成功把React组件从服务端下发到客户端,可供爬虫爬取

wNM2wD.png

总结:掌握React提供的解析组件方法renderToString,服务端才可以下发SPA应用,这一点在Vue亦是同个道理

同构

所谓同构为何物?

React提供了renderToString方法用于在服务端渲染组件生成HTML供服务端渲染优化SEO,但是组件中的事件处理将无法被激发,因为在服务端中这根本意义,因此我们需要生成一份专门用于客户端的React逻辑代码(用于激发组件中的JS操作),这里称为bundle.js,用于激活React组件的事件处理能力,而这就是同构

<html>
    <head>
        <title>ssr</title>
    </head>
    <body>
        <div id="root"></div>
        <script src="/bundle.js" ></script>
    </body>
</html>

为此,我们需要先扩展服务端的静态资源处理能力,将webpack生成的bundle.js存放在public文件目录下

app.use(Express.static("public"))

那么你是否好奇,所谓的bundle.js是什么?其实不过就是你最熟悉的纯客户端渲染编写入口文件

import React from "react"
import ReactDom from "react-dom"
import Home from "../components/Home.js"

ReactDom.hydrate(<Home />, document.getElementById("root"))

对了,你肯定会好奇渲染时为什么不是ReactDOM.render()而是ReactDOM.hydrate()

这是因为服务端渲染的内容会存在标记,React可以在客户端识别到这一点,所以如果使用了ReactDOM.hydrate() 方法,React会保留服务端的内容,仅触发组件的事件处理能力,如果用一句话来说明,那就是:既然你已经帮我处理了20%,那么剩下的80%我来搞定

总结:同构是服务端渲染的核心之一,因为客户端的bundle.js是驱动SPA应用的重点!

服务端路由

SPA应用大部分会采用前端路由功能,客户端路由代码按照正常标准一般书写,采用BrowserRouter包裹路由组件

import React from "react"
import ReactDom from "react-dom"
import {BrowserRouter} from "react-router-dom"
import Routes from "../Routes"

const App = () => {
    return (
        <BrowserRouter>
            {Routes}
        </BrowserRouter>
    )
}

ReactDom.hydrate(<App />, document.getElementById("root"))

但存在一个问题,在客户端运行React应用程序时,可以自动感知当前的URL,进而按照不同的逻辑加载React路由组件,但在服务端,React应用程序无法自动感知URL,必须在每次用户请求时才能感知,因此不能采用BrowserRouter而是必须使用StaticRouter,并在每次请求时获取req.path当前请求路径,将之挂载到location属性上,StaticRouter方能找到对应逻辑组件进行加载

import Express from "express"
import React from 'react'
import { renderToString } from "react-dom/server"
import {StaticRouter} from "react-router-dom"
import Routes from "../Routes.js"

const app = Express()
app.use(Express.static("public"))
app.get('*', (req, res) => {
   const content = renderToString((
      <StaticRouter location={req.path} context={{}}>
        {Routes}
      </StaticRouter>
    ))
    const content = renderToString((
      <StaticRouter location={req.path} context={{}}>
        {Routes}
      </StaticRouter>
    ))
    res.send( 
        `<html>
        <head>
          <title>ssr</title>
        </head>
        <body>
          <div id="root">${content}</div>
          <script src="./index.js" ></script>
        </body>
      </html>
        `
    )
})

优化:多路由中的公共组件复用

假设在Home组件与Login组件中都需要用到Header组件,那么需要在组件中引入两次,如果有N个路由将需要重复引入N次

对此,可以使用react-router-config提供的renderRoutes方法解决这个问题

首先需要调整路由文件的结构,采用数组嵌套的形式

...
export default [{
    path: '/',
    component: App,
    routes: [
        {
            path: '/',
            exact: true,
            component: Home,
            loadData: Home.loadData,
            key: 'home'
        },
        {
            path: '/login',
            exact: true,
            component: Login,
            key: 'login'
        }
    ]
}]

通过renderRoutes识别修改后的路由文件

// client/index.js
import Routes from "../Routes"
import { renderRoutes } from "react-router-config"
...
const App = () => {
    return (
        <Provider store={getClientStore()}>
            <BrowserRouter>
               {renderRoutes(Routes)}
            </BrowserRouter>
        </Provider>
    )
}

// server/util.js
const content = renderToString((
        <Provider store={store}>
             <StaticRouter location={req.path} context={{}}>
                {renderRoutes(routes)}
            </StaticRouter>
        </Provider>
  ))

最后修改一级路由/路径对应的组件,同样使用renderRoutes方法,此时可以通过props属性获取路由文件中的嵌套路由,作为参数调用,方可正确加载我们需要的组件

// App.js
import React from "react"
import Header from "./common/header"

import { renderRoutes } from "react-router-config"

const App = (props) => {
    return (
        <div>
            <Header />
            {renderRoutes(props.route.routes)}
        </div>
    )
}

export default App

数据同步

数据同步是服务端渲染的另一核心,因为服务端与客户端必须维持一套相同数据激发的视图,否则在客户端会发生重绘甚至出错

因此需要借助redux的能力,为组件提供数据仓库store,下面先将组件与redux连接在一起

//store.js
import { createStore, applyMiddleware } from "redux"
import thunk from 'redux-thunk'

const reducer = (state = {name: 'de'}, action) => {
    return state
}

// 每次返回新的Store
const getStore = () => {
    return createStore(reducer, applyMiddleware(thunk))
}

export default getStore

在服务端与客户端逻辑中,一致通过Provider进行store挂载

// client/index.js
...
import Routes from "../Routes"
import getStore from "../store/index.js"

const App = () => {
    return (
        <Provider store={getStore()}>
            <BrowserRouter>
                {Routes}
            </BrowserRouter>
        </Provider>
    )
}
...
// server/index.js
...
import { Provider } from "react-redux"
import getStore from "../store/index.js"

export const render = (req) => {
    const content = renderToString((
        <Provider store={getStore()}>
             <StaticRouter location={req.path} context={{}}>
                {Routes}
            </StaticRouter>
        </Provider>
    ))
...
}

在组件中利用react-redux提供的connect方法连接数据,mapStateToPropsmapDispatchToProps分别用于映射数据与方法

// Home.js
...
import { connect } from "react-redux"
import { getHomeList } from "./store/actions"

const Home = (props) => {
  useEffect(() => {
    props.getHomeList()
  },[])
  return (
    <div>
      <Header />
      <div>服务端渲染:{props.name}</div>
      <button onClick={() => {alert(1)}}>click me</button>
    </div>
  )
}
// 将state映射到props
const mapStateToProps = state => ({
  name: state.home.name
})
// 将action方法映射到props
const mapDispatchToProps = dispatch => ({
    getHomeList() {
      dispatch(getHomeList())
    }
})

export default connect(mapStateToProps, mapDispatchToProps)(Home)

更多关于redux的使用方法

上述代码,在编写纯客户端渲染时,你已经非常了熟悉了,你会轻易的发现上述代码的逻辑

  • 1、组件渲染即将挂载时,调用props.getHomeList()
  • 2、触发store内部的方法发起异步请求,获取数据渲染数据列表

遗憾的是在服务端环境中props.getHomeList()压根不会被执行,众所周知useEffect在这里相当于componentDidMount 生命周期,在服务端渲染时,是不会触发该生命周期的,那么异步请求更无从发起!

React团队当然想到这个问题,因此提供了解决方案:改造路由

通常来讲,编写Reaact应用的路由应该是如下所示

export default (
    <div>
        <Route path="/" exact component={Home}></Route>
        <Route path="/login" exact component={Login}></Route>
    </div>
)

但是为了服务端渲染,我们需要进行重构,改写成如下的数组格式

export default [
    {
        path: '/',
        exact: true,
        component: Home,
        loadData: Home.loadData, // 服务端初始化数据
        key: 'home'
    },
    {
        path: '/login',
        exact: true,
        component: Login,
        key: 'login'
    }
]

可以看到改写的路由中包含loadData属性

喂!这个就是重点了,loadData将用于服务端渲染时提前执行,完成异步数据的获取,你也许好奇在Home组件如何编写loadData,实际上非常简单

// Home.js
...
// 将state映射到props
const mapStateToProps = state => ({
  name: state.home.name
})
// 将action方法映射到props
const mapDispatchToProps = dispatch => ({
    getHomeList() {
      dispatch(getHomeList())
    }
})

+ Home.loadData = (store) => {
+  return store.dispatch(getHomeList())
+ }

export default connect(mapStateToProps, mapDispatchToProps)(Home)

由于返回的路由不再是JSX的形式,因此需要循环遍历如下所示

// 服务端
<StaticRouter location={req.path} context={{}}>
     {routes.map(route => (
      <Route {...route} />
     ))}
</StaticRouter>

// 客户端
 <BrowserRouter>
    {Routes.map(route => (
     <Route {...route} />
    ))}
</BrowserRouter>

服务端预加载数据阶段比较特殊,我们需要监听每次请求的req.path来判断需要调用哪个组件的loadData方法

react-router-dom提供了matchPath 用来根据req.path找到具体的路由组件,如下所示

import { matchPath }  from  "react-router-dom"
const promises = [];
routes.some(route => {
  const match = matchPath(req.path, route)
  if (match) promises.push(route.loadData(match))
  return match
})
Promise.all(promises).then(data => {
  // 异步请求已完成 可以渲染组件 下发到客户端
});

**注意:**如果你的路由包含了嵌套路由,也就是多层级路由,如下所示,matchPath方法将无法识别

const routes = [
  {
    component: Root,
    routes: [
      {
        path: "/",
        exact: true,
        component: Home
      },
      {
        path: "/child/:id",
        component: Child,
        routes: [
          {
            path: "/child/:id/grand-child",
            component: GrandChild
          }
        ]
      }
    ]
  }
];

因此需要额外使用第三方库react-router-config提供的matchRoutes 方可识别多层级的嵌套路由,具体使用方法请移步react-router-config

const match = matchRoutes(routes, req.path);
// using the routes shown earlier, this returns
// [
//   routes[0],
//   routes[0].routes[1]
// ]

到目前为止,我们解决了首屏渲染时服务器初始化数据这一“心腹大患”,解决了它,意味着我们的WEB应用将正常支持SEO

但是还存在一个小小的问题,客户端未能同步到服务端的数据

仔细 回想一下,当我们在服务端初始化数据后,数据将会被存储在store中,但是在客户端应用是无法获取到这份数据,因为在客户端会重新创建新的store实例,毕竟二者处于不同的上下文环境中,由于二者的数据不一致,在渲染组件时,React会发现服务端渲染的内容与当前客户端的数据逻辑并不一致,往往控制台报诸如以下的错误,实际上就是内部的diff发现二者的vnode结构前后不一致

Warning: Expected server HTML to contain a matching <button> in <div>.
Warning: Did not expect server HTML to contain a <div> in <div>.

为此,我们需要将服务端获取的数据同步给客户端,答案就是在服务端下发的HTML增加以下内容

  <script>
      window.context = {
     	state: ${JSON.stringify(store.getState())}
	  }
 </script>

并修改客户端创建store实例的代码,默认获取检查当前全局对象window中是否存在context

export const getClientStore = () => {
    const defaultState = window.context ? window.context.state : {};
    return createStore(reducer, defaultState, applyMiddleware(thunk));
}

如此一来,就完成了服务端与客户端的数据同步,一个基本的SSR工程就已经初具雏形!

总结:服务端数据的预加载是重中之重,实现起来其实并不麻烦,本质是都是利用redux充当数据层,Next.js也提供了类似的机制,其方法为getInitialProps,详情在这里

请求代理

使用Node.js服务端渲染时,会产生多渠道请求的问题,负责接口服务的A服务器需要接受来自Node.js服务器与浏览器客户端的请求,在定位问题时将会变得复杂许多,并且可能还会衍生出其他问题,因此可以将客户端请求通过代理的方式交给Node.js服务器进行转发

  • 将请求都集中于Node.js服务器上便于日后请求问题排查
  • 更充分的使用了Node.js的能力,毕竟仅仅用来做组件渲染有点浪费
  • 减轻了接口服务器的压力,方便日后业务的调整

使用代理非常简单

npm i express-http-proxy --save

这里以本地服务器为例(虚构)

客户端请求http://localhost/api/**会被转发为http://136.152.12.5/**

app.use('/api', proxy('http://admin.kuwanfront.cn', {
  proxyReqPathResolver(req) {
    console.log(req.url)
    return req.url
  }
}))

区分客户端/服务端发送请求的根路径,利用axios提供的instance能力,配置不同的请求实例,将客户端的请求根路径baseURL设置为采用相对路径的形式

// /client/request.js
import axios from "axios"

const instance = axios.create({
    baseURL: '/'
})

export default instance
// /server/request.js
import axios from "axios"

const instance = axios.create({
    baseURL: 'http://admin.kuwanfront.cn'
})

export default instance

现在面临的问题就是:如何在客户端与服务端请求时区分不同的Axios实例,通常来讲可以通过控制变量的形式来进行区分,但这种方式不仅使代码量增多,耦合且提高了项目维护的难度,因此我们可以需要寻求其他的方法优化工程代码

得益于redux-thunk中间件提供的withExtraArgument方法,允许我们传入自定义参数,所以我们可以将客户端与服务端的axios实例以参数的形式传入,从根本上区分了实例的请求方法,并且代码逻辑清晰,不干涉请求方法内部逻辑,此为优解

// sotre/index.js
import { createStore, applyMiddleware, combineReducers } from "redux"
import thunk from 'redux-thunk'
import { reducer as homeReducer } from "../components/Home/store/"
import clinetRequest from "../client/request"
import serverRequest from "../server/request"
...

export const getStore = () => {
    return createStore(reducer, applyMiddleware(thunk.withExtraArgument(serverRequest)))
}

export const getClientStore = () => {
    const defaultState = window.context ? window.context.state : {}
    return createStore(reducer, defaultState, applyMiddleware(thunk.withExtraArgument(clinetRequest)))
}

并在触发Redux的Aaction时,获取该实例axiosInstance调用

// action.js
...
export const getHomeList = (server) => {
    return (dispatch,getState,axiosInstance) => {
        return axiosInstance.get('default/getArticleList')
            .then(res => {
                dispatch(changeList(res.data.data))
            }
        )
    }
}

如此一来就合理的处理了客户端/服务端请求的baseURL不同的问题

异常与重定向

接下来完善服务端的404和301重定向功能

服务端返回404状态码

新建NotFound组件

// NotFound.js
import React from "react"

const NotFound = (props) => {
    return (
        <div>404</div>
    )
}

export default NotFound

修改路由,在识别不到具体路径时,调用该NotFound组件

// Routes.js
...
import NotFound from "./components/NotFound/index.js"


export default [{
    path: '/',
    component: App,
    routes: [
       ...
        {
            component: NotFound
        }
    ]
}]

服务端设置传入context字段

// server/index.js
...
app.get('*', (req, res) => {
  ...
  Promise.all(promises).then(() => {
    let context = {}
    const html = render(store, routes, req, context)
  })
})

得益于StaticRouter组件提供的功能,我们可以在NotFound组件接收到context并创建NOTFOUND字段作为服务端识别404的状态

...
export const render = (store, routes, req, context) => {
    const content = renderToString((
        <Provider store={store}>
             <StaticRouter location={req.path} context={context}>
                {renderRoutes(routes)}
            </StaticRouter>
        </Provider>
    ))
    const html = `<html>
        <head>
          <title>ssr</title>
        </head>
        <body>
          <div id="root">${content}</div>
          <script>
            window.context = {
              state: ${JSON.stringify(store.getState())}
            }
          </script>
          <script src="/index.js" ></script>
        </body>
      </html>
        `
    return html
}
// NotFound.js
import React from "react"

const NotFound = (props) => {
    if(props.staticContext) {
        props.staticContext.NOTFOUND = true
    }
    return (
        <div>404</div>
    )
}

export default NotFound

服务端返回404状态码

// server/index.js
...
app.get('*', (req, res) => {
  ...
  Promise.all(promises).then(() => {
    let context = {}
    const html = render(store, routes, req, context)
    if(context.NOTFOUND) {
      res.status(404)
      res.send(html)
    }else{
      res.status(200)
      res.send(html)
    }
  })
})

301重定向比较特殊,服务端的路由组件我们采用的是 StaticRouter组件进行嵌套处理

因此当某个组件出现react-router-dom提供的Redirect组件时,StaticRouter组件会自动往context注入内容如下所示

import { Redirect } from "react-router-dom"

const Loign = () => {
  const test = false
  console.log(test)
  return (
    test ? 
    <div>
      <div>Loign</div>
    </div>
    :
    <Redirect 
      to="/"
    />
  )
}
export default Loign

在服务端中打印此时context

...
app.get('*', (req, res) => {
  ...
  Promise.all(promises).then(() => {
    let context = {}
    const html = render(store, routes, req, context)
    console.log(context)
    if(context.NOTFOUND) {
      res.status(404)
      res.send(html)
    }else{
      res.status(200)
      res.send(html)
    }
  })
})

输出context内容,注意再强调一遍,这个操作是StaticRouter路由组件自动完成的,我们只需要坐享其成

{
  action: 'REPLACE',
  location: { pathname: '/', search: '', hash: '', state: undefined },
  url: '/'
}
...
app.get('*', (req, res) => {
  ...
  Promise.all(promises).then(() => {
    let context = {}
    const html = render(store, routes, req, context)
   
    if(context.NOTFOUND) {
      res.status(404)
      res.send(html)
    }else{
      res.status(200)
      res.send(html)
    }
  })
})

可以知道,服务端组件在StaticRouter路由组件渲染时会被动态注入staticContext属性,凭借该属性我们可以设法完成特定的需求

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published