實現SSR服務端渲染

子慕大詩人發表於2019-03-18

前言

前段時間尋思做個個人網站,然後就立馬行動了。 個人網站如何實現選擇什麼技術方案,自己可以自由決定。 剛好之前有大致想過服務端渲染,載入速度快,還有 SEO 挺適合個人網站的。 所以就自己造了個輪子用 koa+react 來實現 SSR 服務端渲染。

什麼是SSR

最初聽說有單頁面的服務端渲染的時候,就理解為類似傳統的服務端路由+模板渲染,只是需要用單頁面應用的框架寫。後面尋思這樣好像有點傻,再一瞭解,原來只是在首次載入的時候,後端進行當前路徑頁面的元件渲染和資料請求,組裝成 HTML 返回給前端,使用者就能很快看到看到頁面,當 HTML 中的 JS 資源載入完成後,剩下執行和執行的就是一般的單頁面應用。 所以 SSR 是後端模板渲染和單頁面的組合。 

SSR 有兩種模式,單頁面和非單頁面模式,第一種是後端首次渲染的單頁面應用,第二種是完全使用後端路由的後端模版渲染模式。他們區別在於使用後端路由的程度。

優勢

SSR 的兩個明顯的優勢:首次載入快和 SEO。 

為什麼說首次載入快呢。 一個普通的單頁面應用,首次載入的時候需要把所有相關的靜態資源載入完畢,然後核心 JS 才會開始執行,這個過程就會消耗一定的時間,接著還會請求網路介面,最終才能完全渲染完成。

SSR 模式下,後端攔截到路由,找到對應元件,準備渲染元件,所有的 JS 資源在本地,排除了 JS 資源的網路載入時間,接著只需要對當前路由的元件進行渲染,而頁面的 ajax 請求,可能在同一臺伺服器上,如果是的話速度也會快很多。最後後端把渲染好的頁面反回給前端。 

注意:頁面能很快的展示出來,但是由於當前返回的只是單純展示的 DOM、CSS,其中的 JS 相關的事件等在客戶端其實並沒有繫結,所以最終還是需要 JS 載入完以後,對當前的頁面再進行一次渲染,稱為同構。 所以 SSR 就是更快的先展示出頁面的內容,先讓使用者能夠看到。

為什麼 SEO 友好呢,因為搜尋引擎爬蟲在爬取頁面資訊的時候,會傳送 HTTP 請求來獲取網頁內容,而我們服務端渲染首次的資料是後端返回的,返回的時候已經是渲染好了 title,內容等資訊,便於爬蟲抓取內容。


如何實現

大致對 SSR 有了一個瞭解,我們現在需要對實現整理一下大致實現思路和流程。
  1. 選擇一個單頁面框架(我目前選擇的是 react)
  2. 選擇 node 服務端框架(我目前選擇的是 koa2)
  3. 實現核心邏輯,讓 node 服務端能夠路由和渲染單頁面元件(這一點分為很多小實現點,後面說)
  4. 優化開發和釋出環境自動化構建工具(webpack)

開始實現之前建立一個 react-ssr 專案,專案下建立 client 和 server 目錄用於寫客戶端和服務端程式碼,webpack 目錄用於 weppack 檔案配置。

1.react應用

安裝 react 依賴,在 client 中建立好一個基礎的 react 資料夾結構,並寫好一個可以執行的有路由配置的應用,client 檔案目錄如下:

2.server應用

安裝 koa 和相關依賴,在 server 中建立好一個基礎的服務端資料夾結構,並寫好一個簡單的可執行的後端應用服務。server 資料夾如下:

3.核心實現

因為有倉庫程式碼就不對基礎程式碼做解釋,現在我們有一個可以單獨執行的 react 單頁面應用和一個後端應用,他們都有各自的路由。接下來我們做改造,實現 SSR 的單頁面模式(非單頁面模式僅僅是做部分調整,因此這裡只講實現單頁面模式)。


核心實現分為以下幾步:
  • 1) 後端攔截路由,根據路徑找到需要渲染的 react 頁面元件 X
  • 2)呼叫元件 X 初始化時需要請求的介面,同步獲取到資料後,使用 react 的renderToString 方法對元件進行渲染,使其渲染出節點字串。
  • 3)後端獲取基礎 HTML 檔案,把渲染出的節點字串插入到 body 之中,同時也可以操作其中的 title,script 等節點。返回完整的 HTML 給客戶端。
  • 4)客戶端獲取後端返回的 HTML,展示並載入其中的 JS,最後完成 react 同構。

1)我們在客戶端寫 react 的時候,router 常規的會定義一個陣列,存放元件和對應的 path,然後註冊路由,如下:

import Index from "../pages/index";
import List from "../pages/list";
const routers = [
  { exact: true, path: "/", component: Index },
  { exact: true, path: "/list", component: List }
];複製程式碼

上面說過,實現ssr就是實現單頁面應用+首次服務端渲染,所以我們本身就是做的一個單頁面應用。 現在實現了單頁面應用,需要實現首次服務端渲染。 服務端的應用啟動以後,接受到url請求,比如訪問 http://localhost:9999/, 後端服務獲取到當前的 path 為 /,這個時候我們就希望後端找到配置 path 為 ‘/’ 的上圖的 Index 元件,對其進行渲染。 

我們在 client 的 router 資料夾中建立兩個 JS 檔案 index 和 pages:

pages 裡配置路由路徑和元件的對映,程式碼大致如下,使其能被客戶端路由和服務端路由同時使用。

import Index from "../pages/index";
import List from "../pages/list";
const routers = [
  { exact: true, path: "/", component: Index },
  { exact: true, path: "/list", component: List }
];
//註冊頁面和引入元件,存在物件中,server路由匹配後渲染
export const clientPages = (() => {
  const pages = {};
  routers.forEach(route => {
    pages[route.path] = route.component;
  });
  return pages;
})();
export default routers;複製程式碼

在 server 路由中程式碼大致是這樣的,在服務端獲取到get請求以後,匹配路徑,如果路徑 path 是有對映頁面元件的,獲取到此元件並渲染,這就是我們的第一步:後端攔截路由,根據路徑找到需要渲染的 react 頁面元件。

import { clientPages } from "./../../client/router/pages";
router.get("*", (ctx, next) => {
  let component = clientPages[ctx.path];
  if (component) {
    const data = await component.getInitialProps();
    //因為component是變數,所以需要create
    const dom = renderToString(
      React.createElement(component, {
        ssrData: data
      })
    )
  }
})複製程式碼


2)如上圖,匹配到元件以後,執行了元件的 getInitialProps 方法(和 nextjs 的命名保持一致),此方法是一個封裝的靜態方法,主要用於獲取初始化所需要的ajax資料,在服務端會同步獲取,而後通過 ssrData 引數傳入元件 prorps 並執行元件渲染。 此方法在客戶端依然是非同步請求。 

這一步比較重要,為什麼我們需要一個靜態方法,而不是直接把請求寫在 willmount 中呢。 因為在服務端使用 renderToString 渲染元件時,生命週期只會執行到 willmount 之後第一次 render,在 willmount 內部,請求是非同步的,第一次 render 完成的時候,非同步的資料都沒有獲取到,這個時候 renderToString 就已經返回了。 那我們頁面的初始化資料就沒有了,返回的 HTML 不是我們所期望的。 因此定義了一個靜態方法,在元件例項化之前獲取到這個方法,同步執行,資料獲取完成後,通過 props 把資料傳入給元件進行渲染。 

那麼這個方法是如何實現的呢? 我們根據程式碼截圖來看 base.js:

import React from "react";
export default class Base extends React.Component {
  //override 獲取需要服務端首次渲染的非同步資料
  static async getInitialProps() {
    return null;
  }
  static title = "react ssr";
  //page元件中不要重寫constructor
  constructor(props) {
    super(props);
    //如果定義了靜態state,按照生命週期,state應該優先於ssrData 
   if (this.constructor.state) {
      this.state = {
        ...this.constructor.state
      };
    }
    //如果是首次渲染,會拿到ssrData
    if (props.ssrData) {
      if (this.state) {
        this.state = {
          ...this.state,
          ...props.ssrData
        };
      } else {
        this.state = {
          ...props.ssrData
        };
      }
    }
  }
  async componentWillMount() {
    //客戶端執行時
    if (typeof window != "undefined") {
      if (!this.props.ssrData) {
        //非首次渲染,也就是單頁面路由狀態改變,直接呼叫靜態方法
        //我們不確定有沒有非同步程式碼,如果getInitialProps直接返回一個初始化state,這樣會造成本身應該同步執行的,因為await沒有同步執行,造成狀態混亂
        //所以建議初始化state需要寫在class屬性中,用static靜態方法定義,constructor時會將其合併到例項中。
        //為什麼不直接寫state屬性而要加static,因為預設屬性會執行在constructor之後,這樣會覆蓋constructor定義的state
        const data = await this.constructor.getInitialProps(); //靜態方法,通過建構函式獲取
        if (data) {
          this.setState({ ...data });
        }
      }
      //設定標題
      document.title = this.constructor.title;
    }
  }
}複製程式碼


首先在 client 的 pages 裡新建一個 base 元件,base 繼承 React.Component,所有 pages 裡的頁面元件都需要繼承這個 base,base 有一個靜態方法 getInitialProps,此方法主要是返回元件初始化需要的非同步資料。 如果有初始化的 ajax 請求,就應該重寫在此方法裡,並且 return 資料物件。 

constructor 判斷了頁面元件是否有初始化定義的 state 靜態屬性,有的話傳遞給元件例項化的 state 物件,如果 props 有傳入 ssrData,把 ssrData 傳遞值給元件 state 物件。 

base 中的 componentWillMount 會判斷是否還需要去執行 getInitialProps 方法,如果在服務端渲染的時候,資料已經在元件例項化之前同步獲取並傳入了 props,所以忽略。 

如果在客戶端環境,分兩種情況。 

第一種:使用者第一次進到頁面,這時候是服務端去請求的資料,服務端獲取到資料後在服務端渲染元件,同時也會把資料存放在 HTML 的 script 程式碼中,定義一個全域性變數 ssrData,如下圖,react 在註冊單頁面應用並且同構的時候會把全域性 ssrData 傳遞給頁面元件,這個時候頁面元件在客戶端同構渲染的時候,就可以延續使用服務端之前的資料,這樣也保持了同構的一致性,也避免了一次重複請求。 

第二種情況:就是當前使用者在單頁面之中切換路由,這樣就沒有服務端渲染,那麼就執行 getInitialProps 方法,把資料直接返回給 state,幾乎等同於在 willmount 中執行請求。 這樣封裝我們就可以用一套程式碼相容服務端渲染和單頁面渲染。

client/app.js

import React from "react";
import { hydrate } from "react-dom";
import Router from "./router";
class App extends React.Component {
  render() {
    return <Router ssrData={this.props.ssrData} ssrPath={this.props.ssrPath} />;
  }
}
hydrate(
  <App ssrData={window.ssrData} ssrPath={window.ssrPath} />,
  document.getElementById("root")
);複製程式碼


再看看如何寫頁面元件,下面是頁面元件 Index 的截圖,Index 繼承 Base,定義了靜態 state,元件 constructor 方法會把此物件傳遞給元件例項化的 state 物件中,之所以用靜態方法來寫預設資料,是想保證定義的預設 state 先傳遞給例項物件的 state,介面請求傳遞的props 資料後傳遞給例項物件的 state。 

為什麼不直接寫 state 屬性而要加 static,因為 state 屬性會執行在 constructor 之後,這樣會覆蓋 constructor 定義的 state,也就是會覆蓋我們 getInitialProps 返回的資料。

export default class Index extends Base {
  //注意看看:base關於getInitialProps的註釋
  static state = {
    desc: "Hello world~"
  };
  //替代componentWillMount
  static async getInitialProps() {
    let data;
    const res = await request.get("/api/getData");
    if (!res.errCode) data = res.data;
    return {
      data
    };
  }
}複製程式碼

注意:在服務端渲染環境下,執行 renderToString 的時候,元件會被例項化,並且返回字串形式的 DOM,這個過程 react 元件的生命週期只會執行到 willmount 之後的 render。


3)我們寫好一個 HTML 檔案,大致如下。 當前已經渲染出了相應的節點字串,後端需要返回 HTML 文字,內容應該包含標題,節點和最後需要載入的打包好的 JS,依次去替換 HTML 佔位部分。

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>/*title*/</title>
  </head>
  <body>
    <div id="root">$$$$</div>
    <script>
      /*getInitialProps*/
    </script>
    <script src="/*app*/"></script>
    <script src="/*vendor*/"></script>
  </body>
</html>複製程式碼

server/router.js

indexHtml = indexHtml.replace("/*title*/", component.title);
indexHtml = indexHtml.replace(
  "/*getInitialProps*/",
  `window.ssrData=${JSON.stringify(data)};window.ssrPath='${ctx.path}'`
);
indexHtml = indexHtml.replace("/*app*/", bundles.app);
indexHtml = indexHtml.replace("/*vendor*/", bundles.vendor);
ctx.response.body = indexHtml;
next();複製程式碼


4)最後客戶端 JS 載入完成後,會執行 react,並且執行同構方法 ReactDOM.hydrate,而不是平時用的 ReactDOM.render。

import React from "react";
import { hydrate } from "react-dom";
import Router from "./router";
class App extends React.Component {
  render() {
    return <Router ssrData={this.props.ssrData} ssrPath={this.props.ssrPath} />;
  }}
hydrate(
  <App ssrData={window.ssrData} ssrPath={window.ssrPath} />,
  document.getElementById("root")
);複製程式碼

以下是首次渲染過程大致流程圖,點選檢視大圖

CSS處理

現在我們已經完成了最核心的邏輯,但是有一個問題。 我發現在後端渲染元件的時候,style-loader 會報錯,style-loader 會找到元件依賴的 CSS,並在元件載入時,把 style 載入到 HTML header 中,但是我們在服務端渲染的時候,沒有 window 物件,因此 style-loader 內部程式碼會報錯。 

服務端 webpack 需要移除 style-loader,用其他方法代替,後來我把樣式賦值給元件靜態變數,然後通過服務端渲染一併返回給前端,但是有個問題,我只能拿到當前元件的樣式,子元件的樣式沒辦法拿到,如果要給子元件再新增靜態方法,再想辦法去取,那就太麻煩了。 

後來我找到了一個庫 isomorphic-style-loader 可以支援我們想要的功能,看了下它的原始碼和使用方法,通過高階函式把樣式賦值給元件,然後利用 react 的 Context,拿到當前需要渲染的所有元件的樣式,最後把 style 插入到 HTML 中,這樣解決了子元件樣式無法匯入的問題。 但是我覺得有點麻煩,首先需要定義所有元件的高階函式和引入這個庫,然後在 router 之中需要寫相關程式碼收集 style,最後插入到 HTML 中。  

之後我定義了一個 ProcessSsrStyle 方法,入參是 style 檔案,邏輯是判斷環境,如果是服務端把 style 載入到當前元件的 DOM 中,如果是客戶端就不處理(因為客戶端有style-loader)。 實現和使用非常簡單,如下:

ProcessSsrStyle.js

import React from "react";
export default style => {
  if (typeof window != "undefined") {
    //客戶端
    return;
  }
  return <style>{style}</style>;
};複製程式碼

使用:

render() {
    return (
      <div className="index">
        {ProcessSsrStyle(style)}
      </div>
    );
}複製程式碼

服務端返回 HTML 的內容如下,使用者馬上能夠看到完整的頁面樣式,而當客戶端 react 同構完成後,DOM 會被替換為純 DOM,因為 ProcessSsrStyle 方法在客戶端不會輸出 style,最終style-loader 執行後 header 中也會有樣式,,頁面不會出現不一致的變化,對於使用者來說這一切都是無感的。

至此,最核心的功能已經實現,但是在後來的開發中,我發現事情還並沒有那麼簡單,因為開發環境似乎太不友好了,開發效率低,需要手動重啟。

開發環境

先說說最初的開發環境如何工作:

  • npm run dev啟動開發環境
  • webpack.client-dev.js 打包服務端程式碼,程式碼會被打包到dist/server中
  • webpack.server-dev.js 打包客戶端程式碼,程式碼會被打包到dist/client中
  • 啟動服務端應用,埠9999
  • 啟動webpack-dev-server, 埠8888

webpack 打包後,啟動了兩個服務,一個是服務端的 app 應用、埠為 9999,一個是客戶端的 dev-server、埠為 8888,dev-server 會監聽和打包 client 程式碼,可以在客戶端程式碼更新的時候,實時熱更新前端程式碼。

當訪問 localhost:9999時,server 會返回 HTML,我們的 server 返回的 HTML 中的 JS 指令碼路徑是指向的 dev-serve 埠的地址,如下圖。 也就是說,客戶端的程式和服務端的程式被分別打包,並且執行兩個不同的埠服務。

在生產環境下,因為不需要 dev-server 去監聽和熱更新,因此只一個服務就足夠, 如下圖,服務端註冊靜態資原始檔夾:

server/app.js

  app.use(
    staticCache("dist/client", {
      cacheControl: "no-cache,public",
      gzip: true
    })
  );複製程式碼

目前的構建系統,區分了生產環境和開發環境,現在的開發環境構建是沒有什麼問題的。 但是開發環境問題就比較明顯,存在的最大問題是服務端沒有熱更新或者重新打包重啟。 這樣會導致很多問題,最嚴重的就是前端已經更新了元件,但是服務端並沒有更新,所以在同構的時候會出現不一致,就會導致報錯,有些報錯會影響執行,解決辦法只有重啟。 這樣的開發體驗是無法忍受的。 後來我開始考慮做服務端的熱更新。

監聽、打包、重啟

最初我的方法是監聽修改,打包然後重啟應用。 還記得我們的 client/router/pages.js 檔案嗎,客戶端和服務端的路由都引入了這個檔案,所以服務端和客戶端的打包依賴都有pages.js,因此所有 pages 的元件相關的依賴都可以被客戶端和服務端監聽,當一個元件更新了,dev-server 已經幫助我們監聽和熱更新了客戶端程式碼,現在我們要自己來處理以下如何更新和重啟服務端程式碼。 

其實方法很簡單,就是在服務端打包配置裡開啟監聽,然後在外掛配置中,寫一個重啟的外掛,外掛程式碼如下:

  plugins: [
    new function() {
      this.apply = compiler => {
        //自定義註冊鉤子函式,watch監聽修改並編譯完成後,done被觸發,callback必須執行,否則不會執行後續流程
        compiler.hooks.done.tap(
          "recomplie_complete",
          (compilation, callback) => {
            if (serverChildProcess) {
              console.log("server recomplie completed");
              serverChildProcess.kill();
            }
            serverChildProcess = child_process.spawn("node", [
              path.resolve(cwd, "dist/server/bundle.js"),
              "dev"
            ]);
            serverChildProcess.stdout.on("data", data => {
              console.log(`server out: ${data}`);
            });
            serverChildProcess.stderr.on("data", data => {
              console.log(`server err: ${data}`);
            });
            callback && callback();
          }
        );
      };
    }()
  ]複製程式碼

當 webpack 首次執行之後,外掛會啟動一個子程式,執行 app.js,當檔案發生變動後,再次編譯,判斷是否有子程式,如果有殺掉子程式,然後重啟子程式,這樣就實現了自動重啟。 因為客戶端和服務端是兩個不同的打包服務和配置,當檔案被修改,他們同時會重新編譯,為了保證編譯後執行符合預期,要保證服務端先編譯完成,客戶端後編譯完成,所以在客戶端的 watch 配置裡,增加一點延遲,如下圖,預設是 300 毫秒,所以服務端是 300 毫秒後執行編譯,而客戶端是 1000 毫秒後執行編譯。

watchOptions: {
  ignored: ["node_modules"],
  aggregateTimeout: 1000 //優化,儘量保證後端重新打包先執行完
}複製程式碼

現在解決了重啟問題,但是我覺得還不夠,因為在開發的大部分時間裡 pages.js 中元件,也就是展示端的程式碼更新頻率會很高,如果老是去重啟編譯後端的程式碼,我覺得效率太低。 因此我覺得再做一次優化。

抽離client/router/pages單獨打包

流程應該是這樣的,增加一個 webpack.server-dev-pages.js 配置檔案,單獨監聽和打包出 dist/pages,服務端程式碼判斷如果是開發環境,在路由監聽方法中每次執行都重新獲取dist/pages 包,服務端監聽配置忽略 client 資料夾。 

看起來有點懵逼,其實最終的效果就是當 pages 中依賴的元件發生了更新,webpack.server-dev-pages.js 重新編譯並打包到 dist/pages中,服務端app不編譯和重啟,只需要在服務端app路由中重新獲取最新的 dist/pages 包,就保證了服務應用更新了所有客戶端元件,而服務端應用並不會編譯和重啟。 當服務端本身的程式碼發生了修改,還是會自動編譯和重啟。 

所以最終我們的開發環境需要啟動3個打包配置

  • webpack.server-dev-pages
  • webpack.server-dev
  • webpack.client-dev

server/router,如何清除和更新 pages 包

const path = require("path");
const cwd = process.cwd();
delete __non_webpack_require__.cache[
  __non_webpack_require__.resolve(
      path.resolve(cwd, "dist/pages/pages.js")
  )];
component = __non_webpack_require__(
  path.resolve(cwd, "dist/pages/pages.js")
).clientPages[ctx.path];複製程式碼

至此,比較滿意的開發環境基本實現了。 後來又覺得每次更新 CSS 都需要去重新打包後端的pages 也沒有必要,加上同構的時候 CSS 不一致,僅僅只有警告,沒有實質影響,因此我在server-dev-pages 中忽略了 less 檔案(因為我用的 less)。 這樣會導致一個問題,因為沒有更新pages,所以頁面會重新整理時會先展示舊的樣式,然後同構完成又立馬變成新樣式,在開發環境中這一瞬間是可以接受的,也不影響什麼。 但是避免了無謂的編譯。

watchOptions: {
  ignored: ["**/*.less", "node_modules"] //忽略less,樣式修改並不會影響同構
}複製程式碼

沒有做的事情

  • 封裝成一個更有包裹性的三方腳手架
  • CSS 作用域控制
  • 封裝性更強的 webpack 配置
  • 開發環境下,圖片路徑會出現不一致

最初做自己小站的目的是學習,加上自己使用,因此有太多個性的東西。 從自己的小站中抽離了出來,已經刪去了很多包和程式碼,只為了讓他人更能快速理解其中的核心程式碼。 程式碼中有很多註釋都能幫助他人理解,如果大家想使用當前庫開發一個自己的小站,是完全可以的,也可以幫助大家更好的理解它。 如果是用於商業專案,推薦 nextjs。 

CSS 沒有做作用域控制,因此如果想隔離作用域,手動新增上層 CSS 隔離,比如 .index{ ..... } 包裹一層,或者嘗試自己引入三方包。 

webpack 通用的配置可以封裝成一個檔案,然後在每個檔案裡引入,再個性修改。 但是之前看其他程式碼的時候發現,這種方法,會增加閱讀難度,加上本身配置內容不多,所以不做封裝,看起來更直觀。 

開發環境下,圖片路徑會出現不一致,比如客戶端地址請求地址是 localhost...assets/xx.jpg,而服務端是 assets/xx.jpg,可能會有警告,但是不影響。 因為只是一個是絕對路徑,一個是相對路徑。

最後

對於這次的 SSR 服務端渲染的實現還是挺滿意的,也花費了挺多時間。 感受下載入速度吧,歡迎訪問大詩人小站,dashiren.cn/ 。 部分頁面有介面請求,比如dashiren.cn/space,載入速度依然很快。

倉庫已經準備好,下載下來試試吧,安裝依賴後,執行命令即可。github.com/zimv/react-…

碼字不易,點個贊吧~


實現SSR服務端渲染

關注大詩人公眾號,第一時間獲取最新文章。


相關文章