React伺服器渲染SSR,你缺的不是教程,是產品級完整案例

wooline發表於2019-03-06

案例專案地址:react-coat-ssr-demo

你可能覺得本 Demo 中對路由封裝過於重度,以及不喜歡使用繼承的方式來組織 Model,沒關係,此處只是拋磚引玉,你可以酌情去掉這些邏輯。

本 Demo 的意義

網上已經有很多關於 React SSR 的文章和教程,但是它們...

  • 要麼只是教你原理與知識,沒有真正的產品化工程。
  • 要麼只是介紹某些核心環節,缺少完整性。
  • 要麼只是紙上談兵,連象樣的 Demo 都沒有。
  • 要麼就是一些過時的版本。

所以你缺的不是 SSR 教程,而是可以應用到生產環境的完整案例。

單頁同構 SSR

對於 React 的 Server-Side Rendering 也許你會說:這不已經有 next.js,還有 prerender 麼?可是親,你真的用過它們做過稍複雜一點的專案麼?而我們的目標要更進一步,不僅要 SSR,還要有 Single Page(單頁)的使用者體驗,和 isomorphic(同構)的工程化方案,所以我們給自已提 8 個要求:

  1. 瀏覽器與伺服器複用同一套程式碼與路由。
  2. 編譯出來的程式碼要便於部署,不要太多依賴。
  3. 瀏覽器載入的首屏由伺服器渲染完成,以提高載入速度和利於 SEO。
  4. 瀏覽器不再重複做伺服器已完成的渲染工作(包括不再重複的請求資料)。
  5. 首屏後不再整體重新整理,而是通過 ajax 區域性更新,帶來單頁的使用者體驗。
  6. 在互動過程中,隨時重新整理頁面,可以通過 URL 重現當前內容(包括開啟彈窗等動作)。
  7. 所有的路由跳轉 link 迴歸到原始的<a href="...">,方便讓搜尋引擎爬取。
  8. JS 攔截所有<a href="...">的瀏覽器跳轉行為,改用單頁方式開啟。

對於以上的最後兩點要求,可以用這種方法來驗證:

在某個 link 上用滑鼠左鍵點選,看是否是單頁的使用者體驗,用右鍵點選選擇在新視窗中開啟,看是否可以用多頁的方式跳轉。

本工程化亮點

腳手架完備,開箱即用

也許你也嘗試過搭建 SSR 工程腳手架,遇到過類似的問題:

  • SSR 需要生成瀏覽器執行程式碼和伺服器執行程式碼,所以需要兩套 webpack 編譯和部署。
  • 開發時除了 webpackDevSever 你還得啟動 ssrServer、mockServer
  • webpackDevSever 可以使用熱更新,但 ssrServer 能熱更新麼?

Ok,本工程腳手架已解決上述問題,你只需一行命令執行:

npm start

瀏覽器渲染?伺服器渲染?一鍵切換

開啟專案根下的./package.json,在"devServer"項中,將 ssr 設為 true 將啟用伺服器渲染,設為 false 僅使用瀏覽器渲染

"devServer": {
    "url": "http://localhost:7445",
    "ssr": true, // 是否啟用伺服器渲染
    "mock": true,
    "proxy": {
      "/ajax/**": {
        "target": "http://localhost:7446/",
        "secure": false,
        "changeOrigin": true
      }
    }
  }
複製程式碼

充分利用 Typescript 強大的型別檢查

本 Demo 不僅利用 TS 型別來定義各種資料結構,更重要的是將 module、model、view、action、router 全面聯絡起來,相互約束、相互 check,將 Typescript 充分轉換為生產力。

安裝

git clone https://github.com/wooline/react-coat-ssr-demo.git
npm install
複製程式碼

執行

  • npm start 以開發模式執行
  • npm run build 以產品模式編譯生成檔案
  • npm run prod-express-demo 以產品模式編譯生成檔案並啟用一個 express 做 demo
  • npm run gen-icon 自動生成 iconfont 檔案及 ts 型別

檢視線上 Demo

點選檢視線上 Demo,並留意以下行為:

  • 隨便點選一個 link,開啟一個新頁面,重新整理一下瀏覽器,看是否能保持當前頁面內容。
  • 在某個 link 上用滑鼠左鍵點選,看是否是單頁的使用者體驗,用右鍵點選選擇在新視窗中開啟,看是否可以用多頁的方式跳轉。
  • 檢視網頁原始碼,看伺服器是否輸出靜態的 Html。

開始動工

首先,你需要一款同構框架

開發 React 單頁 SPA 應用時,也許你用過類似 DvaJS、Rematch 之類的上層框架,覺得相比原生 React+Redux 要爽太多,那能不能在伺服器渲染上也同樣使用它們呢?

  • 不能,伺服器渲染和瀏覽器渲染儘管都是執行 JS,但原理還是有很大差別的,以上框架也只能用在瀏覽器中。

那難道就沒有能同時執行在瀏覽器和伺服器的同構框架麼?

暫時忘掉你是在做 SSR

React-coat 支援伺服器和瀏覽器同構,所以你可以暫時忘掉你是在做 SSR,先用做單頁 SPA 應用的那一套邏輯來構建,包括怎麼設計 Store、Model、規劃路由、劃分模組、按需載入等。

所以你可以先看前 2 個 Demo:

SPA 單頁應用入手:Helloworld

SPA 單頁應用進階:優化和重用

改裝為 SSR

一套程式碼、兩個入口、兩套輸出

瀏覽器和伺服器程式碼 99% 是共用的,除了入口檔案稍有不同。我們在/src/下分別為其建立不同的入口檔案。

  • client.tsx 原瀏覽器端入口檔案,使用 buildApp()方法建立應用
  • server.tsx 新增伺服器端入口檔案,使用 renderApp()方法建立應用

瀏覽器渲染可以使用 AMD、ES、非同步 import 等模組化方案,而伺服器渲染一般使用 commonJS,非同步按需載入也沒什麼意義,而且沒必要編譯成 es5 了,所以我們使用兩套 webpack 配置來把這兩個入口分別 build 成 client 和 server 輸出:

npm run build

  • /build/client 輸出成瀏覽器執行的程式碼,JS 會按模組做程式碼分割,生成多個 bundle 以按需載入。
  • /build/server 輸出成伺服器執行的程式碼,伺服器端執行不需要程式碼分割,所以僅生成一個 main.js 檔案,簡單又方便。

瀏覽器端部屬執行

我們生成了/build/client這個目錄,裡面是瀏覽器執行所需的 Html、Js、Css、Imgs 等,是純靜態的資源,所以你只需將整個目錄上傳到 nginx 釋出目錄中即可。

服務端部屬執行

我們生成了/build/server/main.js這個伺服器端執行檔案,它包含了應用的伺服器渲染邏輯,你只需要將它 copy 到你的 web server 框架中執行,比如 express 為例:

const mainModule = require("./server/main.js");// build生成的 main.js
const app = express();

app.use((req, res, next)=>{
  const errorHandler = (err) => {
      if (err.code === "301" || err.code === "302") {
          // 伺服器路由跳轉還得靠 express
          res.redirect(parseInt(err.code, 10), err.detail);
      } else {
          res.send(err.message || "伺服器錯誤!");
      }
  };
  try {
      mainModule.default(req.url).then(result => {
          const { ssrInitStoreKey, data, html } = result;
          // html 渲染出的 html 字串
          // data 脫水資料,也就是 redux store 的 state
          // ssrInitStoreKey 脫水資料的 key
          ...

      }).catch(errorHandler);
  }catch (err) {
      errorHandler(err);
  }
});
app.listen(3000);

複製程式碼

簡單吧?執行 main.js 就能拿到 ssrInitStoreKey, data, html 這三筆資料,而拿到它們之後,你想怎麼玩都行,屬於 express 的事了,可以看看 Demo。

路由短路設計

我們原本在單頁中使用 React-router,因為奉行路由皆元件的理念,常常會這樣寫:

<Redirect exact path="/" to="/list" />
複製程式碼

這樣 React 渲染到此的時候,如果路徑匹配會做路由跳轉。但渲染到此時才跳轉,那之前的執行消耗不是白費了?Server 端可是對執行效率有高要求的。所以,在 SSR 時,對於某些靜態的 Redirect,我們最好提前判斷執行,甚至在 node.js 之前就執行,比如直接配在 nginx 裡。Demo 中為了減少對第三方的依賴,所以還是使用 node.js 自已處理,不過,這一切都放在初始化應用之前,我們可以理解為路由的短路設計。

const rootRouter = advanceRouter(path);
if (typeof rootRouter === "string") {
  throw new RedirectError("301", rootRouter);
} else {
  return renderApp(moduleGetter, ModuleNames.app, [path], {initData: {router: rootRouter}});
}
複製程式碼

單向資料流

在伺服器渲染時,React 不會 Rerender,資料流一定是單向的,從 Redux Store->React,不要企圖 Store->React->Store->React,也就是在渲染 React 之前,我們得把所有資料都準備好,嚴格執行 UI(State) 純函式,而不能依賴 React 生命週期勾子去取資料。

正好 React-coat 已經把資料邏輯全部都封裝在 Model 裡面。而且自始自終強調 Model 的獨立性,不要依賴 View,甚至脫離 View,Model 也能執行。

所以...伺服器渲染的流程比較純粹:

  1. 首先 Build model
  2. 然後 Render view

兩個渲染階段

開啟 SSR 渲染之後,應用渲染過程類似於一個寶寶的誕生,分兩階段:

  • 十月懷胎,在娘肚子中先發育成人形。(伺服器中先渲染一部分)
  • 一朝分娩,出生後繼續自已發育。(瀏覽器接著伺服器基礎上再進一步渲染)

具體在娘肚子發育到什麼階段才出生呢?這個因人而異,有的寶寶出生就有快 10 斤,有的寶寶出生不到 4 斤呢,@○@,所以你願意在伺服器端多做些事情,那瀏覽器就少做些事情羅。

我們知道,在 React-coat 框架的 model 中,每個模組的初始化都會派發 moduleName/INIT 這個 action,我們可以 handle 這個 action,去做一些請求資料和初始化的工作。

因此我們規定,在 SSR 時 Model 只執行完主模組 INITActionHandler 後就要出生。換個說法,主模組 INITActionHandler 就是孃胎,想要在伺服器執行的邏輯,都得寫在這個 actionHandler 中。

  • 所以:改裝成 SSR 的重要工作就是寫好 Model 的 INITActionHandler:
@effect()
protected async [ModuleNames.app + "/INIT"]() {
  ...
}
複製程式碼

模組初始化的差異

上面說道 SSR 時只執行主模組的 INITActionHandler,那其它模組的初始化怎麼辦?畢竟應用不可能就一個主模組吧?

我們在做 SPA 單頁時,render 一個 View 時,框架會自動匯入並初始化它的 Model,這樣省時省力。但是在 SSR 時,我們上面強調過單向資料流,所有 model 都必須在 view render 之前準備好,所以不能依賴 view 來自動匯入了。

  • 所以在 SSR 時,如果一個 Model 的初始化需要另一個 Model 參與,需要手動 loadModel。例如:
@effect()
protected async [ModuleNames.app + "/INIT"]() {
  const { views } = this.rootState.router; //當前展示了哪些 Views
  //如果 photos 被展示,就要手動載入 photosModel 並初始化
  if (views.photos) {
    await loadModel(moduleGetter.photos).then(subModel => subModel(this.store));
  }
}

複製程式碼

提取路由邏輯

從上面初始化差異看出,因為 SSR 需要單向資料流,所有 model 都必須在 view render 之前準備好。而某些 model 的初始化邏輯又依賴於路由的邏輯。而我們在單頁 SPA 時往往把路由邏輯分散寫在各個 Component 中,因為路由皆元件嘛,所以...

  • SSR 時,我們得把一部分必需的路由邏輯從 view 回收 到 model 中。
  • 其實本質上,路由邏輯也應當是 model 資料邏輯的一部分。

當然,如果你事先知道你是要做 SSR,一開始就可以直接放到 model 中。

提取路由不等於集中配置

我們剛說把一部分路由邏輯從 view 回收到 model 中執行,但並不等於集中配置路由。路由邏輯依然是分散在各個 model 中,依然是對外封裝的,父模組只與子模組打交道,而不會參與子模組內部路由邏輯。這樣非常有利於解耦和模組化。

現在絕大多數 SSR 方案是把路由集中配置,然後還把獲取資料(ajax) 的邏輯與路由繫結在一起,導致可讀性、可維護性、可重用性大大降低。相比之下,React-coat 的路由方案更勝一籌。

生成靜態的 Link Url

在單頁 SPA 應用中,我們點選一個 link 跳轉路由,通常會這樣寫:

onItemClick = (id:string) => {
  const url = generateUrl(id);
  this.props.dispatch(routerActions.push(url))
}
render(){
  ...
  <a href="#" onClick={() => this.onItemClick(item.id)}>檢視列表</a>
  ...
}
複製程式碼
  • 在點選 link 時,會先計算出 url,再切換路由。如果不點選的話 url 是不會計算的。
  • 但在 SSR 時,為了能讓搜尋引擎爬取到連結,我們必須提前計算出 url 並放入 href 屬性中。
onItemClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
  e.preventDefault();
  const href = e.currentTarget.getAttribute("href") as string;
  this.props.dispatch(routerActions.push(href));
}
render(){
  ...
  <a href={generateUrl(id)} onClick={this.onItemClick}>檢視列表</a>
  ...
}
複製程式碼

錯誤處理

在瀏覽器執行環境中,React-coat 監聽了 window.onerror,一旦有 uncatched 的 error,都會 dispatch 一個 ErrorAction,你可以在 model 中兼聽此 action 並處理,例如:

@effect(null)
protected async ["@@framework/ERROR"](error: CustomError) {
  if (error.code === "401") {
    this.dispatch(this.actions.putShowLoginPop(true));
  } else if (error.code === "404") {
    this.dispatch(this.actions.putShowNotFoundPop(true));
  } else if (error.code === "301" || error.code === "302") {
    this.dispatch(this.routerActions.replace(error.detail));
  } else {
    Toast.fail(error.message);
    await settingsService.api.reportError(error);
  }
}
複製程式碼

在伺服器渲染中,這個 ErrorActionHandler 依然有效,但因為單向資料流,model 必須在 view 之前完成的,所以它只能 handle model 執行中的 error,而之後 render view 過程中的 error 此處是 handle 不到的,如果你需要 handle,請在應用之上層 try catch,比如在 express 中。

使用 Transfer-Encoding: chunked

使用 SSR,意味著首屏你看到的是需要先經過伺服器運算後返回的,為了減少白屏等待時間你可以使用 Http 的 Transfer-Encoding: chunked,先讓伺服器返回一個靜態的 Loading 頁面,然後再開始伺服器渲染。

但是這樣一來,如果後伺服器運算出需要 Redirect 重定向,而此時你的 Http 頭已經輸出了,不能再利用 301 跳轉,所以你只能繼續輸出一段 JS 來讓瀏覽器執行跳轉,例如:

if (err.code === "301" || err.code === "302") {
    if (res.headersSent) {
        res.write(`
        <span>跳轉中。。。</span></body>
        <script>window.location.href="${err.detail}"</script>
        </html>`);
        res.end();
    } else {
        res.redirect(parseInt(err.code, 10), err.detail);
    }
}
複製程式碼

後記

以上羅列出個人覺得比較重要的點,其它還有很多實用的技巧可以直接看 Demo,裡面有註釋,有問題歡迎共同探討。

相關文章