React 服務端渲染從入門到精通

前言

這篇文章是我本身在搭建我的網站的過程當中,用到了服務端渲染,看了一些教程,踩了一些坑。想把這個過程分享出來。 我會盡力把每一個步驟講明白,將我理解的所有講出來。css

文中的示例代碼來自於這個倉庫,也是我正在搭建的我的網站,你們能夠一塊兒交流一下。示例代碼由於簡化,因此與倉庫代碼有些許出入。另外,本身使用服務端渲染過程當中遇到的一些坑我也都記錄在README裏了。html

本文中用到的技術 React V16 | React-Router v4 | Redux | Redux-thunk | expressreact

React 服務端渲染

服務端渲染的基本套路就是用戶請求過來的時候,在服務端生成一個咱們但願看到的網頁內容的HTML字符串,返回給瀏覽器去展現。 瀏覽器拿到了這個HTML以後,渲染出頁面,可是並無事件交互,這時候瀏覽器發現HTML中加載了一些js文件(也就是瀏覽器端渲染的js),就直接去加載。 加載好並執行完之後,事件就會被綁定上了。這時候頁面被瀏覽器端接管了。也就是到了咱們熟悉的js渲染頁面的過程。git

須要實現的目標:

  • React組件服務端渲染
  • 路由的服務端渲染
  • 保證服務端和瀏覽器的數據惟一
  • css的服務端渲染(樣式直出)

通常的渲染方式

  • 服務端渲染:服務端生成html字符串,發送給瀏覽器進行渲染。
  • 瀏覽器端渲染:服務端返回空的html文件,內部加載js,徹底由js完成頁面的渲染

優勢與缺點

服務端渲染解決了首屏加載速度慢以及seo不友好的缺點(Google已經能夠檢索到瀏覽器渲染的網頁,但不是全部搜索引擎均可以) 但增長了項目的複雜程度,提升維護成本。github

若是非必須,儘可能不要用服務端渲染express

總體思路

須要兩個端:服務端、瀏覽器端(瀏覽器渲染的部分) 第一: 打包瀏覽器端代碼 第二: 打包服務端代碼並啓動服務 第三: 用戶訪問,服務端讀取瀏覽器端打包好的index.html文件爲字符串,將渲染好的組件、樣式、數據塞入html字符串,返回給瀏覽器 第四: 瀏覽器直接渲染接收到的html內容,而且加載打包好的瀏覽器端js文件,進行事件綁定,初始化狀態數據,完成同構redux

React組件的服務端渲染

讓咱們來看一個最簡單的React服務端渲染的過程。 要進行服務端渲染的話那必然得須要一個根組件,來負責生成HTML結構數組

import React from 'react';
import ReactDOM from 'react-dom';

ReactDOM.hydrate(<Container />, document.getElementById('root'));
複製代碼

固然這裏用ReactDOM.render也是能夠的,只不過hydrate會盡可能複用接收到的服務端返回的內容, 來補充事件綁定和瀏覽器端其餘特有的過程promise

引入瀏覽器端須要渲染的根組件,利用react的 renderToString API進行渲染瀏覽器

import { renderToString } from 'react-dom/server'
import Container from '../containers'
// 產生html
const content = renderToString(<Container/>)
const html = `
    <html>
      <body>${content}</body>
    </html>
`
res.send(html)
複製代碼

在這裏,renderToString也能夠替換成renderToNodeStream,區別在於前者是同步地產生HTML,也就是若是生成HTML用了1000毫秒, 那麼就會在1000毫秒以後纔將內容返回給瀏覽器,顯然耗時過長。然後者則是以流的形式,將渲染結果塞給response對象,就是出來多少就 返回給瀏覽器多少,能夠相對減小耗時

路由的服務端渲染

通常場景下,咱們的應用不可能只有一個頁面,確定會有路由跳轉。咱們通常這麼用:

import { BrowserRouter, Route } from 'react-router-dom'
const App = () => (
    <BrowserRouter>
        {/*...Routes*/}
    <BrowserRouter/>
)
複製代碼

但這是瀏覽器端渲染時候的用法。在作服務端渲染時,須要使用將BrowserRouter 替換爲 StaticRouter 區別在於,BrowserRouter 會經過HTML5 提供的 history API來保持頁面與URL的同步,而StaticRouter 則不會改變URL

import { createServer } from 'http'
import { StaticRouter } from 'react-router-dom'
createServer((req, res) => {
    const html = renderToString(
        <StaticRouter
            location={req.url}
            context={{}}
        >
            <Container />
        <StaticRouter/>)

})
複製代碼

這裏,StaticRouter要接收兩個屬性:

  • location: StaticRouter 會根據這個屬性,自動匹配對應的React組件,因此纔會實現刷新頁面,服務端返回的對應路由的組與瀏覽器端保持一致
  • context: 通常用來傳遞一些數據,至關於一個載體,以後講到樣式的服務端渲染的時候會用到

Redux同構

數據的預獲取以及脫水與注水我認爲是服務端渲染的難點。

這是什麼意思呢?也就是說首屏渲染的網頁通常要去請求外部數據,咱們但願在生成HTML以前,去獲取到這個頁面須要的全部數據, 而後塞到頁面中去,這個過程,叫作「脫水」(Dehydrate),生成HTML返回給瀏覽器。瀏覽器拿到帶着數據的HTML, 去請求瀏覽器端js,接管頁面,用這個數據來初始化組件。這個過程叫「注水」(Hydrate)。完成服務端與瀏覽器端數據的統一。

爲何要這麼作呢?試想一下,假設沒有數據的預獲取,直接返回一個沒有數據,只有固定內容的HTML結構,會有什麼結果呢?

第一:因爲頁面內沒有有效信息,不利於SEO。

第二:因爲返回的頁面沒有內容,但瀏覽器端JS接管頁面後回去請求數據、渲染數據,頁面會閃一下,用戶體驗很差。

咱們使用Redux來管理狀態,由於有服務端代碼和瀏覽器端代碼,那麼就分別須要兩個store來管理服務端和瀏覽器端的數據。

組件的配置

組件要在服務端渲染的時候去請求數據,能夠在組件上掛載一個專門發異步請求的方法,這裏叫作loadData,接收服務端的store做爲參數, 而後store.dispatch去擴充服務端的store。

class Home extends React.Component {
    componentDidMount() {
        this.props.callApi()
    }
    render() {
        return <div>{this.props.state.name}</div>
    }
}
Home.loadData = store => {
  return store.dispatch(callApi())
}
const mapState = state => state
const mapDispatch = {callApi}
export default connect(mapState, mapDispatch)(Home)
複製代碼

路由的改造

由於服務端要根據路由判斷當前渲染哪一個組件,能夠在這個時候發送異步請求。因此路由也須要配置一下來支持loadData方法。服務端渲染的時候, 路由的渲染可使用react-router-config這個庫,用法以下(重點關注在路由上掛載loadData方法):

import { BrowserRouter } from 'react-router-dom'
import { renderRoutes } from 'react-router-config'
import Home from './Home'
export const routes = [
  {
    path: '/',
    component: Home,
    loadData: Home.loadData,
    exact: true,
  }
]
const Routers = <BrowserRouter>
    {renderRoutes(routes)}
<BrowserRouter/>
複製代碼

服務端獲取數據

到了服務端,須要判斷匹配的路由內的全部組件各自都有沒有loadData方法,有就去調用, 傳入服務端的store,去擴充服務端的store。*同時還要注意到,一個頁面多是由多個組件組成的,*會發各自的請求,也就意味着咱們要等全部的請求都發完,再去返回HTML。

import express from 'express'
import serverRender from './render'
import { matchRoutes } from 'react-router-config'
import { routes } from '../routes'
import serverStore from "../store/serverStore"

const app = express()
app.get('*', (req, res) => {
  const context = {css: []}
  const store = serverStore()
  // 用matchRoutes方法獲取匹配到的路由對應的組件數組
  const matchedRoutes = matchRoutes(routes, req.path)
  const promises = []
  for (const item of matchedRoutes) {
    if (item.route.loadData) {
      const promise = new Promise((resolve, reject) => {
        item.route.loadData(store).then(resolve).catch(resolve)
      })
      promises.push(promise)
    }
  }
  // 全部請求響應完畢,將被HTML內容發送給瀏覽器
  Promise.all(promises).then(() => {
    // 將生成html內容的邏輯封裝成了一個函數,接收req, store, context
    res.send(serverRender(req, store, context))
  })
})
複製代碼

細心的同窗可能注意到了上邊我把每一個loadData都包了一個promise。

const promise = new Promise((resolve, reject) => {
  item.route.loadData(store).then(resolve).catch(resolve)
  console.log(item.route.loadData(store));
})
promises.push(promise)

複製代碼

這是爲了容錯,一旦有一個請求出錯,那麼下邊Promise.all方法則不會執行,因此包一層promise的目的是即便請求出錯,也會resolve,不會影響到Promise.all方法, 也就是說只有請求出錯的組件會沒數據,而其餘組件不會受影響。

注入數據

咱們請求已經發出去了,而且在組件的loadData方法中也擴充了服務端的store,那麼能夠從服務端的數據取出來注入到要返回給瀏覽器的HTML中了。 來看 serverRender 方法

const serverRender = (req, store, context) => {
  // 讀取客戶端生成的HTML
  const template = fs.readFileSync(process.cwd() + '/public/static/index.html', 'utf8')
  const content = renderToString(
    <Provider store={store}>
      <StaticRouter location={req.path} context={context}>
        <Container/>
      </StaticRouter>
    </Provider>
  )
  // 注入數據
  const initialState = `<script>
    window.context = {
      INITIAL_STATE: ${JSON.stringify(store.getState())}
    }
</script>`
  return template.replace('<!--app-->', content)
    .replace('<!--initial-state-->', initialState)
}
複製代碼

瀏覽器端用服務端獲取到的數據初始化store

通過上邊的過程,咱們已經能夠從window.context中拿到服務端預獲取的數據了,此時須要作的事就是用這份數據去初始化瀏覽器端的store。保證兩端數據的統一。

import { createStore, applyMiddleware, compose } from 'redux'
import thunk from 'redux-thunk'
import rootReducer from '../reducers'

const defaultStore = window.context && window.context.INITIAL_STATE
const clientStore = createStore(
  rootReducer,
  defaultStore,// 利用服務端的數據初始化瀏覽器端的store
  compose(
    applyMiddleware(thunk),
    window.devToolsExtension ? window.devToolsExtension() : f=>f
  )
)
複製代碼

至此,服務端渲染的數據統一問題就解決了,再來回顧一下整個流程:

  • 用戶訪問路由,服務端根據路由匹配出對應路由內的組件數組
  • 循環數組,調用組件上掛載的loadData方法,發送請求,擴充服務端store
  • 全部請求完成後,經過store.getState,獲取到服務端預獲取的數據,注入到window.context中
  • 瀏覽器渲染返回的HTML,加載瀏覽器端js,從window.context中取數據來初始化瀏覽器端的store,渲染組件

這裏還有個點,也就是當咱們從路由進入到其餘頁面的時候,組件內的loadData方法並不會執行,它只會在刷新,服務端渲染路由的時候執行。 這時候會沒有數據。因此咱們還須要在componentDidMount中去發請求,來解決這個問題。由於componentDidMount不會在服務端渲染執行, 因此不用擔憂請求重複發送。

樣式的服務端渲染

以上咱們所作的事情只是讓網頁的內容通過了服務端的渲染,可是樣式要在瀏覽器加載css後纔會加上,因此最開始返回的網頁內容沒有樣式,頁面依然會閃一下。爲了解決這個問題,咱們須要讓樣式也一併在服務端渲染的時候返回。

首先,服務端渲染的時候,解析css文件,不能使用style-loader了,要使用isomorphic-style-loader。

{
    test: /\.css$/,
    use: [
        'isomorphic-style-loader',
        'css-loader',
        'postcss-loader'
    ],
}

複製代碼

可是,如何在服務端獲取到當前路由內的組件樣式呢?回想一下,咱們在作路由的服務端渲染時,用到了StaticRouter,它會接收一個context對象,這個context對象能夠做爲一個載體來傳遞一些信息。咱們就用它!

思路就是在渲染組件的時候,在組件內接收context對象,獲取組件樣式,放到context中,服務端拿到樣式,插入到返回的HTML中的style標籤。

來看看組件是如何讀取樣式的吧:

import style from './style/index.css'
class Index extends React.Component {
    componentWillMount() {
      if (this.props.staticContext) {
        const css = style._getCss()
        this.props.staticContext.css.push(css)
      }
    }
}
複製代碼

在路由內的組件能夠在props裏接收到staticContext,也就是經過StaticRouter傳遞過來的context, isomorphic-style-loader 提供了一個 _getCss() 方法,讓咱們能讀取到css樣式,而後放到staticContext裏。 不在路由以內的組件,能夠經過父級組件,傳遞props的方法,或者用react-router的withRouter包裹一下

其實這部分提取css的邏輯能夠寫成高階組件,這樣就能夠作到複用了

import React, { Component } from 'react'

export default (DecoratedComponent, styles) => {
  return class NewComponent extends Component {
    componentWillMount() {
      if (this.props.staticContext) {
        const css = styles._getCss()
        this.props.staticContext.css.push(css)
      }
    }
    render() {
      return <DecoratedComponent {...this.props}/>
    }
  }
}
複製代碼

在服務端,通過組件的渲染以後,context中已經有內容了,咱們這時候把樣式處理一下,返回給瀏覽器,就能夠作到樣式的服務端渲染了

const serverRender = (req, store) => {
  const context = {css: []}
  const template = fs.readFileSync(process.cwd() + '/public/static/index.html', 'utf8')
  const content = renderToString(
    <Provider store={store}>
      <StaticRouter location={req.path} context={context}>
        <Container/>
      </StaticRouter>
    </Provider>
  )
  // 通過渲染以後,context.css內已經有了樣式
  const cssStr = context.css.length ? context.css.join('\n') : ''
  const initialState = `<script>
    window.context = {
      INITIAL_STATE: ${JSON.stringify(store.getState())}
    }
</script>`
  return template.replace('<!--app-->', content)
    .replace('server-render-css', cssStr)
    .replace('<!--initial-state-->', initialState)
}
複製代碼

至此,服務端渲染就所有完成了。

總結

React的服務端渲染,最好的解決方案就是Next.js。若是你的應用沒有SEO優化的需求,又或者不太注重首屏渲染的速度,那麼儘可能就不要用服務端渲染。 由於會讓項目變得複雜。此外,除了服務端渲染,SEO優化的辦法還有不少,好比預渲染(pre-render)。

相關文章
相關標籤/搜索