半熟前端

軟體工程師 / 台灣人 / 在前端的路上一邊探索其他領域的可能性

前端

對於 SSR 的思考與使用場景

對於 SSR 的思考與使用場景

早期在前端尚未發展成熟,且互動上也沒有那麼要求時,通常頁面的渲染會以後端程式語言提供的樣板引擎(著名的像是 ejs pug erb thymeleaf 等),先在伺服器端渲染 HTML 回傳給瀏覽器之後,瀏覽器端再使用 JavaScript 來作各種互動。

雖然聽起來理所當然,不過隨著瀏覽器與時代演進也逐漸衍生出一些缺點:

  • 每次點擊其他頁面需要重新發送請求,代表 HTML、JavaScript、CSS 以及當下保留的狀態都需要再重新載入、執行一次(假設在沒有快取的情況下)
  • view 的靈活度往往受限於樣板引擎中提供的語法。
  • 有時會希望 view 與互動本身可以高度綁定在一起,兩者分開來顯得不方便
  • 在互動越來越多且越來越細膩的情況下,往往需要作 reactive 機制。

SPA 的演進讓我們可以將頁面上的互動與顯示全部移到 JavaScript 實作,並且透過元件化與 reactive 機制方便維護程式碼。但光靠 JavaScript 本身無法達到一些傳統伺服器渲染可以作到的事,像是:

  • 幫助 SEO 與搜尋引擎爬取頁面
  • 生成 Open Graph 的內容
  • 優化使用者體驗

接下來會針對每一點作說明。在進入本文之前,如果對 SPA 與 SSR 兩個概念不熟的讀者可以先參考 huli 的文章,跟著小明一起搞懂技術名詞:MVC、SPA 與 SSR。這邊主要會針對 SSR 本身帶來的效益與實際場景(以 React 為例)多作說明。

幫助 SEO 與搜尋引擎爬取頁面

在瀏覽器爬網頁的時候,爬蟲會將網頁的 HTML 內容爬過後產生內容,也會作一份快取在資料庫當中並且定時更新。也就是說如果沒有實作 SSR 的話,那麼 html 檔案本身會是一片空白,需要等到 main.js 被解析且執行後才能看到實際頁面

例如:

<html>
    <head>
        
    </head>
    <body>
        <div id="app"></div>
        <script src="main.js"></script>
    </body>
</html>

Google 雖然宣稱可以解析並執行 JavaScript,但我想效果還是有限,而且也沒辦法作到像 fetch 等需要發送請求的效果。

在這邊以 17 直播為例,在 Google 上搜尋 17 直播:

Screenshot from 2020-11-23 21-40-18

在搜尋結果下,搜尋引擎通常會顯示 <title><meta name="description" content="xxx"/>。這部份可以透過伺服器直接生成或是直接寫死在 html 檔案當中,點擊頁庫存檔後你會發現結果是一片空白:

進一步查看原始碼之後會發現裡頭是一個 body 為空的 HTML 檔案:

<!DOCTYPE html>
<html>

<head>
  <title>17LIVE - Live Streaming 直播互動娛樂平台</title>
  <meta charset="utf-8">
  <meta name="description" content="17LIVE 直播互動零距離。各式特色才藝直播主分享生活每一刻;多元節目內容免費線上看!" />
  ...
</head>

<body></body>

</html>

生成 Open Graph 內容 / 管理 <head>

像是 Facebook、Twitter、LINE 等等,在動態當中貼上連結的話,爬蟲會送出請求到連結網址,並且解析裡頭的 <meta> 來決定要如何顯示預覽畫面(詳細可以參考 Open Graph Protocol)。這些資料必須要透過伺服器回傳才行。不然爬蟲看到的仍然會是一片空白的內容。

React 當中要實作這種放在 <head> 的內容通常會用幾種方式達成:

  • react-helmet:可以透過元件的方式來管理 <head> 當中的內容,也有實作 Server 端的渲染。
  • next/head:next.js 內建也有管理 <head> 的方式。

優化使用者體驗

網頁在解析時一定是接收到 HTML 之後再解析 JavaScript,在 JavaScript 執行完之前無法看到內容。雖然以主流電腦設備(CPU i5 以上)下差距不大,但以下是幾個考量點:

  • 使用者不一定會使用電腦或手機做瀏覽:也有可能使用 IoT 設備、電子閱讀器(如 Kindle)、PS4、電視等瀏覽網頁。這些設備往往沒辦法解析 JavaScript,並且效能也有限。
  • 不一定每個使用者都會開啟 JavaScript(雖然相對少數)。使用者看到頁面空白後可能就直接離開頁面,流失潛在用戶
  • 透過 SSR 的幫助,在 JavaScript 執行時框架可以匹配內容,來節省調用 document.appendChild 等 DOM API 的效能。

與傳統模板引擎的不同之處

SSR

透過 React, Vue 等前端框架進行 SSR,與傳統使用樣板引擎的幾個不同之處在於:

  1. 傳統的樣板引擎渲染的結果是純靜態的 HTML 字串,變數則是透過伺服器注入;前端框架的話在伺服器上除了會渲染 HTML 字串之外,在 client 端則是動態呼叫 DOM API 到 HTML 上,在 SSR 渲染後還會注入對應的事件監聽器(click, change 等等)、並且執行對應的生命週期方法。
  2. 傳統樣板引擎並沒有 Reactive 的概念,不會因為變數改變而自動更新 DOM
  3. 因為渲染出來的 HTML 需要跟前端的程式碼匹配,因此前後端程式碼需要共用(在渲染 HTML 時),所以前端框架的 SSR 需要搭配 node.js 一起使用;傳統的樣板引擎不一定,可以依照後端的程式語言決定。

前端框架是如何作到 SSR 的?

這邊不會提到特定框架的實作方式。主要會介紹主流前端框架中是如何作到 SSR 的。

前端框架要作到 SSR,首先要考慮的是狀態。給定相同狀態,渲染出來的畫面相同這個假設下,如果要作到前後端渲染出來的畫面相同(初始畫面),我們就必須要讓前後端達成相同的狀態(在渲染 HTML 時)。

一般來說我們會在伺服器渲染時,準備一個 global 的 store:

route.get('/', (req, res) => {
  const store = {
    posts: [],
    user: {
      name: 'kalan',
      ...
    },
  };
      
  const html = ReactDOMServer.renderToString(<App />);
  res.render('index', {
    html: html,
    store: JSON.stringify(store);
  })
});

然後將 store 的資料放在全域變數裡頭:

<div id="app">
 <%- html %>
</div>
<script>
  window.GLOBAL_STORE = store;
</script>

最後在 app.js 當中這樣寫:

ReactDOM.hydrate(<App store={window.GLOBAL_STORE} />, document.getElementById('app'));

在這邊使用了 hydrate 這個 API 來告訴 React 我們已經事先將內容從伺服器端渲染好了,React 會省略 DOM API 的過程,開始幫 HTML 加上對應的事件監聽器跟執行對應的生命週期事件與 useEffect 等等。在這邊要注意的是,所謂的 SSR 都只有一開始的渲染(也就是發出請求後收到的 HTML)而已。

在 App 變得複雜之後,自己準備一個全域變數似乎不是一個好主意,這時候就可以開始考慮像 next.js 的框架來幫助簡化 SSR 時需要考慮的問題與實作的複雜度。

常見誤區:動態載入(dynamic import)與 ajax

在 App 規模逐漸擴大時,由於 SPA 的特性是將 view 與互動邏輯全部放在前端當中,因此 bundle size 容易達到臨界點,進而開始影響初始載入的效能,這時候就可以透過 dynamic import 的機制,將一些比較不重要的元件或是頁面拆成額外的檔案,等到有需要時再做請求。

目前 React 提供的 React.lazy 並沒有辦法支援 SSR,可以參考官方提供的 SSR 指南(使用 loadable-components)

loadable-components 目前的作法是將目前 <App/> 元件中有使用到 dynamic import 的地方先蒐集起來生成一個 manifest 檔案後,再將這些檔案用 <script src="xxx.js"> 的方式載入。如果頁面當中的元件都是以動態載入的方式渲染的話,很有可能初始渲染的結果仍是一片空白(因為檔案還是要透過 <script> 載入)。

另外,如果在前端當中獲取資料的方式是以 ajax ,如果沒有在伺服器獲取資料的話,初始化的渲染也會是一片空白,或是 loading 畫面。假設有一個簡單的元件長這樣:

const App = () => {
  const [data, setData] = useState([]);
  useEffect(() => {
    fetch('/api/data').then(res => res.json())
      	.then(data => setData(data));
  }, [])
  
  if (data.length === 0) {
      return <span>loading</span>
  }
    
  return <div>
    {renderData()}
  </div>
}

React 在做 SSR 的時候並不會執行 useEffect 當中的程式碼,只是單純將伺服器的資料塞入元件並渲染 markup,實際發送請求要等到瀏覽器實際渲染頁面之後才會執行。所以渲染出來的程式碼會像這樣:

<span data-reactroot="">loading</span>

對於 SSR 來說,這樣子的頁面或多或少可以減少一點初始渲染的效能負擔,但對於 SEO 與使用者體驗來說並不好,畢竟我們想要實現 SSR 的目的之一就是為了更好的使用者體驗(不用等 loading 頁面)以及更好的 SEO(瀏覽器看到的頁面會是一坨 loading)。比較好的作法是透過 global store 將伺服器的資料送到前端,或是透過 next.js 裡頭 getStaticProps() 的機制輔助。

有沒有 SSR,真的差很多嗎?

這一點需要看你從哪些角度看 SSR,也要依據使用場景作決定。以上述 17 直播的例子來說,裡頭的頁面多以動態的影片居多,如果要做成 SSR 的效益不大,或是轉換成本相對比較高,這些都是考量的點之一。

另外像是在做後台系統開發時,由於使用者多數都是公司內部人員,不需要考量 SEO 的狀況,也可以假設大部分使用者設備效能良好,因此不需要特別實作 SSR 也沒有關係。在實作 SSR 時其實要考慮的事情也蠻多的,如果當初沒有考量 SSR 的話,越到後期轉換的成本會越高,因此儘可能在開發初期就先評估是否需要引入 SSR 機制並且越早準備越好。如果是開發部落格、電商、文章瀏覽、首頁等網站這些對於 SEO 比較要求的應用,SSR 應該是必須考量的要點。

但我更希望的是比起辯論 SSR 的需求與否,不妨直接從使用者的需求角度出發才能有更好的討論空間,畢竟任何技術與產品最重要的就是要服務人類。

例如:

  • 使用者可能透過電子閱讀器、IoT 設備、效能較差的設備觀看我們的網站,因此需要盡可能地減少不必要的效能浪費。
  • 使用者可能為了要完成某種目的而使用這個服務,那麼要優化的可能是流程、可能是設計,並且盡可能透過瀏覽器與 JavaScript 輔助加強體驗

其他方案與思考

在這邊我們先不去看所謂的 best practice,單從實際的場景與限制下可以怎麼作應用。

  • 假設現在的開發情形直接引入 SSR 不合成本效益,或許可以讓後端針對爬蟲給一個簡化版本的 view,等使用者瀏覽網頁的時候再用 JavaScript 渲染的機制。雖然可能需要維護兩份不同的 view,但有時候或許這樣子比較簡單。
  • 如果每次內容都相同,可以直接生成一個純靜態的 HTML。
  • 如果轉換成本很高,可以使用 puppeteer 之類的 headless Chrome 直接瀏覽網頁,然後把渲染好的 HTML 快取起來後存在伺服器端。可以透過排程定期作更新。
  • 使用 SPA 往往代表前端 JavaScript 的複雜度與 bundle size 都會無可避免的變大,有時選擇使用傳統的渲染方式加上 prefetch 機制或許效能跟體驗更好也說不定。