打造可降級的React服務端同構框架

YJNldm發表於2019-03-02

什麼是可降級的同構框架

這裡指的可降級與伺服器降級意思相近(主邏輯失敗採用備用邏輯的過程),考慮到服務端渲染併發及高負載的問題,在主伺服器無法提供正常服務的時候可降級為"低功耗模式",採用客戶端渲染方式來減輕伺服器負載。

框架選型

next.js

我看了一下next.js,他們的開發方式很新穎,尤其是路由配置,完全不用自己配,按照他的規則來做就行了。但我這個人比較求穩,這種約定大於配置的方式對於我來說總覺得會有坑,無法自己掌控,找不到解決辦法還得看原始碼才知道。

還是求穩的我,畢竟服務端渲染是需要消耗伺服器效能的,尤其是高併發和記憶體溢位,可能會導致伺服器響應變慢甚至掛掉,所以我希望我的框架還能降級,這個降級與伺服器降級類似,在伺服器高負載的時候,切換另一臺機器,啟動"低功耗模式"(也就是客戶端渲染的單頁應用)來繼續運作,我查了next.js好像沒有這個功能(如果有請在評論告知)。

cra-ssr

實在找不到既能服務端渲染又能客戶端渲染的框架,最後我就找了這個專案https://github.com/cereallarceny/cra-ssr 進行改造,改造還是蠻成功的,過程遇到很多坑,在這裡分享一些過程步驟。

改造之旅

其實cra-ssr這個專案本身已經做好了服務端同構渲染,在改造之前最好先理解cra-ssr實現同構渲染的流程原理再看下面的步驟,下面是cra-ssr實現同構渲染的流程圖

同構渲染流程圖

下圖是降級改造後的流程圖:

服務端同構降級改造流程圖

上圖裡黃色區域的frontload是用在服務端渲染時預先處理的介面請求預取資料,然後放入準備好的global_state,global_state是一個store,作用是存放預取資料再connect給元件渲染,同時也可以作為服務端與客戶端都可用的全域性變數;

藍色節點是伺服器環境,橙色節點是客戶端環境,當伺服器渲染響應給客戶端之後,使用者進行了路由跳轉,這時前端無重新整理跳轉到新頁面,再走黃色區域取到頁面資料,交給client_render渲染出新頁面

環境區分?

同構框架由於是同一套程式碼,但環境不同有些物件是不自帶的,我們要加以區分避免出現問題,這個專案有提供一個isServer變數用來判斷是服務端環境還是客戶端環境,而我們做降級了就還要加多一個環境判斷是否是客戶端渲染的模式來區分,後面就知道為什麼要做區分了。

我們需要做3個環境的區分

1.服務端渲染環境;

2.服務端渲染完成後的客戶端環境;

3.客戶端渲染環境;

我是這麼做環境區分的:

// 通過node環境獨有的process和具體的環境變數區分isServer
export const isServer = !!process&&!!process.env&&(process.env.RENDER_ENV == 'server');
// 只要不是服務端渲染環境都屬於isClient
export const isClient = !isServer;
// 區分降級後的客戶端渲染isCSR,CSR是沒有服務端渲染過後填充的資料__PRELOADED_STATE__的
export const isCSR = !isServer && !window.__PRELOADED_STATE__;
複製程式碼

首屏介面資料預取?

cra-ssr是使用react-frontload來做首屏非同步處理的,用法看這個檔案src/app/routes/profile/index.js,在服務端通過預取資料交給狀態管理redux的store,插入到window.__PRELOADED_STATE__作為初始store,在客戶端拿window.__PRELOADED_STATE__作為初始store,connect元件得到資料填充,

而客戶端渲染是沒有預取資料window.__PRELOADED_STATE__的,所以frontload要同時滿足下面3個條件:

①frontload在服務端渲染完之後到客戶端首屏不能重複執行發請求

②降級後客戶端渲染模式能執行frontload發請求拉取資料並保證跟服務端同樣的寫法

③服務端渲染完之後到客戶端經過使用者點選路由跳轉又要能像客戶端一樣請求拉取資料

為了保證同樣的寫法,服務端渲染與客戶端渲染必須統一用狀態管理的store,需要封裝一個特定的global_state用來傳首屏資料的store,再封裝一下frontload,寫了個Adapter:

import { frontloadConnect } from 'react-frontload';
import {isServer, isCSR} from 'xxx/xxx';
import {change_state, get_state} from 'xxx/global_state';
import {connect} from 'dva';

export const frontload = (frontload_fn) => {
  return (Target) => {
    // 為元件預設注入global_state
    return connect(({global}) => ({global}))(frontloadConnect(async (props) => {
      // get_state('client_load') => 在客戶端觸發路由跳轉的時候為true
      if (get_state('client_load') || isServer || isCSR) {
        await frontload_fn(props)
      }
    })(Target))
  }
}
複製程式碼

用這個frontload來代替原來的frontloadConnect函式,不僅簡化了寫法,還保持了同一套程式碼滿足上面三個條件。

在元件裡可以這麼寫:

//(本專案引入了dva、Typescript、scss和react-css-modules)
import * as React from 'react';
import { isCSR } from 'xxx/xxx';
const styles = require('./index.scss');
const CSSModules = require('react-css-modules');
import { frontload } from 'xxx/adapter'; 
import { API } from 'xxx/http';
import {config} from 'src/utils/config';


@frontload(async (props) => {
    const res = await API.get('/getNews');
    // 通過更新store的global達到統一服務端與客戶端的寫法。
    props.dispatch({type: 'global/set', payload: {data_list: res.data}})
})
@CSSModules(styles)
export default class News extends React.PureComponent<any,any>{
    constructor(props){
        super(props);
        this.state = {
            news_list: [], 
        }
    }
    componentDidMount() {
        this.setState({
            // 通過frontload預取資料填充了global資料,並自動connect當前元件
            // 所以元件不需要再connect就可以訪問global資料
            news_list: this.props.global.data_list,
        })
    }
    render() {
        const news_list = this.props.global.data_list;
        return (
          news_list.map((item) => {
            return <section>
              <p>標題:{item.title}</p>
              <p>摘要:{item.abstract}</p>
            </section>
          })
          
        )
    }
}
複製程式碼

新增客戶端渲染啟動伺服器?

還要新增一個啟動客戶端渲染的伺服器檔案,比如csr_server.js

var path = require('path')
var express = require('express')
var compression = require('compression')
var app = express()
var data = require('./data_config.json');
app.use(compression());
// 構建後的資原始檔夾
app.use(express.static(path.join(__dirname, '../dist/')));

port = 8080
app.listen(port, function () {
  console.log('The app server is working at ' + port)
})
複製程式碼

這就是我改造服務端同構框架降級的一些步驟,具體還是要自己好好理解才行,根據自身專案需求調整(比如我的專案還引入了dva、typescript之類的),會遇到很多坑的?

這篇文章只是在前端的角度講降級的,至於具體在伺服器上怎麼降級,就得從運維角度看怎樣的規則適合降級,怎麼降級這些問題了,就不在此文講述了。

相關文章