案例專案地址:react-coat-ssr-demo
你可能覺得本 Demo 中對路由封裝過於重度,以及不喜歡使用繼承的方式來組織 Model,沒關係,此處只是拋磚引玉,你可以酌情去掉這些邏輯。
本 Demo 的意義
網上已經有很多關於 React SSR 的文章和教程,但是它們...
- 要麼只是教你原理與知識,沒有真正的產品化工程。
- 要麼只是介紹某些核心環節,缺少完整性。
- 要麼只是紙上談兵,連象樣的 Demo 都沒有。
- 要麼就是一些過時的版本。
所以你缺的不是 SSR 教程,而是可以應用到生產環境的完整案例。
單頁同構 SSR
對於 React 的 Server-Side Rendering 也許你會說:這不已經有 next.js,還有 prerender 麼?可是親,你真的用過它們做過稍複雜一點的專案麼?而我們的目標要更進一步,不僅要 SSR,還要有 Single Page(單頁)的使用者體驗,和 isomorphic(同構)的工程化方案,所以我們給自已提 8 個要求:
- 瀏覽器與伺服器複用同一套程式碼與路由。
- 編譯出來的程式碼要便於部署,不要太多依賴。
- 瀏覽器載入的首屏由伺服器渲染完成,以提高載入速度和利於 SEO。
- 瀏覽器不再重複做伺服器已完成的渲染工作(包括不再重複的請求資料)。
- 首屏後不再整體重新整理,而是通過 ajax 區域性更新,帶來單頁的使用者體驗。
- 在互動過程中,隨時重新整理頁面,可以通過 URL 重現當前內容(包括開啟彈窗等動作)。
- 所有的路由跳轉 link 迴歸到原始的<a href="...">,方便讓搜尋引擎爬取。
- 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 做 demonpm run gen-icon
自動生成 iconfont 檔案及 ts 型別
檢視線上 Demo
點選檢視線上 Demo,並留意以下行為:
- 隨便點選一個 link,開啟一個新頁面,重新整理一下瀏覽器,看是否能保持當前頁面內容。
- 在某個 link 上用滑鼠左鍵點選,看是否是單頁的使用者體驗,用右鍵點選選擇在
新視窗中開啟
,看是否可以用多頁的方式跳轉。 - 檢視網頁原始碼,看伺服器是否輸出靜態的 Html。
開始動工
首先,你需要一款同構框架
開發 React 單頁 SPA 應用時,也許你用過類似 DvaJS、Rematch 之類的上層框架,覺得相比原生 React+Redux 要爽太多,那能不能在伺服器渲染上也同樣使用它們呢?
- 不能,伺服器渲染和瀏覽器渲染儘管都是執行 JS,但原理還是有很大差別的,以上框架也只能用在瀏覽器中。
那難道就沒有能同時執行在瀏覽器和伺服器的同構框架麼?
- 有,React-coat:點選先了解一下它
暫時忘掉你是在做 SSR
React-coat 支援伺服器和瀏覽器同構,所以你可以暫時忘掉你是在做 SSR,先用做單頁 SPA 應用的那一套邏輯來構建,包括怎麼設計 Store、Model、規劃路由、劃分模組、按需載入等。
所以你可以先看前 2 個 Demo:
改裝為 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 也能執行。
所以...伺服器渲染的流程比較純粹:
- 首先 Build model
- 然後 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,裡面有註釋,有問題歡迎共同探討。