使用Medux改造單頁應用(SPA)爲服務器同構渲染(SSR)

服務器渲染(Server-Side Rendering)並非一個複雜的技術,而 服務器渲染服務器同構渲染 則是2個不一樣的概念,重點在於:同構,要作到一套代碼完美的運行在瀏覽器與服務器之上不是一件簡單的事情,目前業界也沒有特別滿意的方案,都須要或多或少的對不一樣的環境作差別化處理。javascript

同構渲染的目標與意義

一般同構渲染主要是爲了:css

  • 利於 SEO 搜索引擎收錄
  • 加快首屏呈現時間
  • 同時擁有單頁(SPA)多頁路由的用戶體驗

一般同構渲染須要作到:html

  • 瀏覽器與服務器複用同一套代碼。
  • 用戶訪問的第一個頁面(首屏)由服務器渲染輸出,以利於 SEO 和加快呈現速度。
  • 首屏由服務器渲染輸出以後,瀏覽器在其基礎上進一步渲染,但再也不作重複工做,包括再也不重複請求數據。
  • 以後用戶訪問的其它頁面都再也不通過服務器渲染,以減小服務器壓力和達到單頁(SPA)的用戶體驗。
  • 在以後的交互過程當中刷新瀏覽器,須要保持當前頁面並從新由服務器渲染,以實現多頁路由的用戶體驗。

同構渲染的難點與金鑰匙

獲取初始化數據

同構渲染的主要難點在於 Client 端渲染時組件生命週期鉤子承載了太多的職能與反作用,好比:獲取數據、路由、按需加載、模塊化等等,這些邏輯被分散在各個組件中隨着組件的渲染動態執行,而它們的執行又再次引發組件的從新渲染。簡單來講就是:java

Render -> Hooks -> Effects -> ReRender -> Hooks -> Effects...node

這樣的渲染流程在 Server 端是不行的,由於一般 Sever 端不會 ReRender,所以必須把全部反作用都提早執行,然後在一次性 Render,簡單來講就是:react

Effects -> State -> Renderwebpack

那麼解決方案就是將這些反作用盡可能的與組件的生命週期鉤子脫離,並引入獨立的狀態管理機制來管理它們,讓 UI 渲染變成簡單純粹的 PrueRender,而這正是@medux 所倡導的狀態驅動理念。nginx

異步按需加載

在 Client 端渲染時,爲了提高加載速度咱們一般對代碼進行 chunk 分包、並使用異步按需加載來優化用戶體驗。而在 Server 端渲染時這變得徹底不必,反而會拖慢加載速度。如何在 server 端中替換異步代碼爲同步代碼呢?正好@medux將模塊加載視爲一種配置策略,它能夠很輕鬆的讓將模塊加載在同步和異步之間切換。git

運行 Demo

本項目 fork 自medux-react-admin,這是一個使用 Medux+React+Antd4+Hooks+Typescript 開發的 WEB 單頁應用,你能夠從本項目中看到如何將一個 SinglePage(單頁應用) 快速轉換爲支持 SEO 的多頁應用。github

項目地址medux-react-ssr

打開如下頁面,使用鼠標右鍵點擊「查看網頁源碼」,看是否輸出了 Html

安裝

// 注意一下,由於本項目風格檢查要求以 LF 爲換行符
// 因此請先關閉 Git 配置中 autocrlf
git config --global core.autocrlf false
git clone https://github.com/wooline/medux-react-ssr.git
cd medux-react-ssr
yarn install
複製代碼

以開發模式運行

  • 運行 yarn start,會自動啓動一個開發服務器。
  • 開發模式時 React 熱更新使用最新的 React Fast Refresh 方案,須要安裝最新的 React Developer Tools。

以產品模式運行

  • 首先運行 yarn build-local,會將代碼編譯到 /dist/local 目錄
  • 而後進入 /dist/local 目錄下,運行 node start.js,會啓動一個產品服務器 Demo,可是真正線上運行建議使用 Nginx,輸出目錄中有 Nginx 配置樣例可供參考

主要改造步驟說明

肯定目標與任務

這是一個典型的後臺管理系統,頁面主要分爲 2 類:

咱們之因此要使用 SSR 改造它主要是爲了讓第二類頁面能被搜索引發收錄(SEO),而對於第一類頁面,由於須要用戶登陸,因此對於搜索引擎也沒什麼意義,咱們依然沿用純瀏覽器渲染就好。

兩個入口,一套代碼,兩套輸出

流程示意圖

區分啓動入口

既然是同構,咱們固然不但願爲 2 端平臺作太多的差別化處理,可是仍是會有少量的定製代碼。好比啓動入口,原來是./src/index.ts,如今咱們須要將其區分爲:

  • client.ts 原瀏覽器端入口文件,使用 buildApp()方法建立應用
  • server.ts 新增服務器端入口文件,使用 buildSSR()方法建立應用

利用這 2 個不一樣的入口,咱們集中構建一些 shim,抹平一些平臺的差別化。

區分 webpack 編譯配置

運行在 Sever 端的代碼無需異步按需加載、無需處理 CSS、無需處理圖片等等,因此咱們使用 2 套 webpack 配置來進行編譯打包並分別輸出在 dist/client/dist/server/ 目錄下。

對於 Sever 端的輸出其實就只有一個 main.js 文件。

編譯與運行

怎麼部署和運行編譯後輸出的代碼?本項目編寫了一個 express 的簡單樣例可供參考,目錄結構大體爲這樣:

dist
├── package.json // 運行須要的依賴
├── start.js     // nodejs啓動入口
├── pm2.json     // pm2部署配置
├── nginx.conf   // nginx配置樣例
├── env.json     // 運行環境變量配置
├── 404.html     // 404錯誤頁面
├── 50x.html     // 500錯誤頁面
├── index.html   // SSR模版
├── mock         // mock假數據目錄
├── html         // 生成的純靜態化頁面目錄
│     ├── login.html
│     ├── register.html
│     └── article
├── server        // Server端輸出目錄
│     └── main.js // SSR主程代碼
├── client        // Client端輸出目錄
│     ├── css     // 生成的CSS文件目錄
│     ├── imgs    // 未經工程化處理的圖片目錄
│     ├── media   // 經webpack處理過的圖片目錄
│     └── js      // 瀏覽器運行JS目錄
複製代碼
  • 對於 dist/client 就是一個靜態目錄,你可使用 Nginx 部署

  • 對於 dist/server 其實就是一個 JS Module 文件 main.js,它只有一個default export的方法:

    export default function render( location: string ): Promise<{
      html: string | ReadableStream<any>;
      data: any;
      ssrInitStoreKey: string;
    }>;
    複製代碼

    你可使用任意 node 服務器(如 express)來執行它,並獲得渲染後的 data、html、已及脫水數據的key。至於你要如何讓服務器輸出這些結果,以及如何處理執行過程出現的異常和錯誤,你能夠自由發揮,例如:

處理初始化數據

前面咱們說過應用在 Server 端運行的流程是:Effects -> State -> Render,也就是說:先獲取數據,再渲染組件

在 medux 框架中數據處理是封裝在 model 中的,而初始化數據一般是在 model 中經過監聽 module.Init 這個 Action 來執行 Effect,從而處理數據並轉化爲 moduleState。當一個 module 被加載時,不論 Client 端仍是 Server 端都會觸發這個 Action,因此在這個 ActionHandler 中咱們要注意的是:若是 Server 端已經作過的工做,Client 端不必再重複作了。能夠經過moduleState.isHydrate來判斷當前的 moduleState 是否已是服務器處理過,例如:

// src/modules/app/model.ts

@effect(null)
protected async ['this.Init']() {
  if (this.state.isHydrate) {
    //若是已經通過SSR服務器渲染,那麼getProjectConfig()無需執行了
    const curUser = await api.getCurUser();
    this.dispatch(this.actions.putCurUser(curUser));
    if (curUser.hasLogin) {
      this.getNoticeTimer();
      this.checkLoginRedirect();
    }
  } else {
    //若是是初次渲染,可能運行在client端也可能運行在server端
    const projectConfig = await api.getProjectConfig();
    this.updateState({projectConfig});
    //服務端都是遊客,無需獲取用戶信息
    if (!isServer()) {
      const curUser = await api.getCurUser();
      this.dispatch(this.actions.putCurUser(curUser));
      if (curUser.hasLogin) {
        this.getNoticeTimer();
        this.checkLoginRedirect();
      }
    }
  }
}
複製代碼

處理用戶登陸

咱們只對無需用戶登陸的頁面進行 SSR,因此在 Server 端中用戶假定都是遊客。在全局的錯誤處理 Handler 中,遇到須要登陸的錯誤時:

  • 若是當前是 Client 端,則路由到登陸頁或者彈出登陸彈窗
  • 若是當前是 Server 端,則直接終止渲染,拋出 303 錯誤便可。(咱們能夠在服務器中 catch 303 錯誤,直接發送統一的 index.html)
// src/modules/app/model.ts

@effect(null)
protected async [ActionTypes.Error](error: CustomError) {
  if (isServer()) {
    //服務器中間件會catch 301錯誤,跳轉URL
    if (error.code === CommonErrorCode.redirect) {
      throw {code: '301', detail: error.detail};
    } else {
      //服務器直接終止渲染,改成client端渲染
      //服務器中間件會catch 303錯誤,直接發送統一的 index.html
      throw {code: '303'};
    }
  }
  ...
}
複製代碼

處理異步按需加載

前面說過咱們必須在 Server 端代碼中將模塊異步按需加載端代碼替換成同步。medux 中控制模塊同步或異步加載是在src/modules/index.ts 中:

// 異步加載
export const moduleGetter = {
  app: () => {
    return import(/* webpackChunkName: "app" */ 'modules/app');
  },
  adminLayout: () => {
    return import(/* webpackChunkName: "adminLayout" */ 'modules/admin/adminLayout');
  },
  ...
};

// 替換爲同步加載
export const moduleGetter = {
  app: () => {
    return require('modules/app');
  },
  adminLayout: () => {
    return require('modules/admin/adminLayout');
  },
  ...
};
複製代碼

只須要將import替換爲require便可,固然這個簡單的替換工做你可使用本項目提供的一個簡單的 webpack-loader 來完成:

@medux/dev-utils/dist/webpack-loader/server-replace-async

它還支持用參數指定部分 module 替換,以減小 server 端 js 文件的大小,如:

// build/webpack.config.js

{
  test: /\.(tsx|ts)?$/,
  use: [
    {
      loader: require.resolve('@medux/dev-utils/dist/webpack-loader/server-replace-async'),
      options: {modules: ['app', 'adminLayout', 'articleLayout', 'articleHome', 'articleAbout', 'articleService']},
    },
    {loader: 'babel-loader', options: {cacheDirectory: true, caller: {runtime: 'server'}}},
  ],
},
複製代碼

其它處理

使用不少細節你們直接看源碼吧,有問題能夠問我,歡迎共同探討。

相關文章
相關標籤/搜索