大前端時代,如何做好C 端業務下的React SSR?

React在中後臺業務裏已經很好落地了,但對於C端(給用戶使用的端,比如PC/H5)業務有其特殊性,對性能要求比較苛刻,且有SEO需求。另外團隊層面也希望能夠統一技術棧,小夥伴們希望成長,那麼如何能夠完成既要、也要、還要呢?

本次分享主要圍繞C端業務下得React SSR實踐,會講解各種SSR方案,包括Next.js同構開發,並以一次優化的過程作爲實例進行講解。其實這些鋪墊都是在工作中做的Web框架的設計而衍生出來的總結。這裏先賣個關子,自研框架基於Umi框架並支持SSR的相關內容留到廣州QCon上講,感興趣的同學可以來5月的QCon全球軟件開發大會廣州站聊。下面開始正題。

曾和小弟討論什麼是SSR?他開始以爲React SSR就是SSR,這是不完全對的,忽略了Server-side Render的本質。其實從早期的cgi,到PHP、ASP,jsp等server page,這些動態網頁技術都是服務器端渲染的。而React SSR更多是強調基於React技術棧進行的服務器端渲染,是服務器端渲染的分類之一,本文會以React SSR爲主進行講解。

1、爲什麼要上SSR?

對於SSR,大家的認知大概是以下3個方面。

•    SEO:強需求,被搜索引擎收錄是網站的基本能力。
  •    C端性能:至少要保證首屏渲染效率,如果秒開率都無法保證,那麼用戶體驗是極差的。
  •    統一技術棧:目前團隊以React爲主,無論從團隊成長,還是個人成長角度,統一技術棧的好處都是非常明顯的。

誠然,以上都是大家想用SSR的原因,但對筆者來說,SSR的意義遠不止如此。在技術架構升級的過程中,如果能夠同時帶給團隊和小夥伴成長,纔是兩全其美的選擇。目前我負責優酷PC/H5業務,在優酷落地Node.js,目前在做React SSR相關整合工作。玉伯曾講過在All in Mobile的時代的尷尬——對於多端來說是毀滅性的災難。押寶移動端在當時是正確的選擇,但在今天獲客成本過高,且移動端增速不足,最好的選擇就是多端在產品細節上做
PK,PC/H5業務的生機也正在於此。

然而歷史包袱如此的重,有幾方面原因。1)頁面年久失修;2)移動端在All in Mobile時代並沒有給多端提供技術支持,PC/H5是掉隊的,需要補齊App端的基本能力;3)技術棧老舊,很多頁面還是採用jQuery開發的,對於團隊來說,這纔是最痛苦的事兒。

其實所有公司都是類似的,都是用有限資源做事,希望最少的投入帶來最大化的產出。可以說,通過整合SSR一舉三得,將Node.js和React一同落地,順便將基礎框架也落地升級,這樣的投入產出是比較高的。

2、從CSR到SSR演進之路

SSR看起來很簡單,如果細分一下,還是略微負責的,下面和我一起看一下從CSR到SSR演進之路。

客戶端渲染 (CSR)

客戶端渲染是目前最簡單的開發方式,以React爲例,CSR裏所有邏輯,數據獲取、模板編譯、路由等都是在瀏覽器做的。

Webpack在工程化與構建方便提供了足夠多便利,除了提供Loader和Plugin機制外,還將所有構建相關步驟都進行了封裝,甚至連模塊按需加載都內置,還具備Tree-shaking等能力,外加Node cluster利用多核並行構建。很明顯這是非常方便的,對前端意義重大的。開發者只需要關注業務模塊即可。

常見做法是本地通過Webpack打包出bundle.js,嵌入到簡單的HTML模板裏,然後將HTML和bundle.js都發布到CDN上。這樣開發方式是目前最常見的,對於做一些內部使用的管理系統是夠的。

CSR缺點也是非常明顯的,首屏性能無法保障,畢竟React全家桶基礎庫就很大,外加業務模塊,縱使按需加載,依然很難保證秒開的。

爲了優化CSR性能,業界有很多最佳實踐。在2018年,筆者以爲React最成功的項目是CRA(create-react-app),支付寶開發的Umi其實也是類似的。他們通過內置Webpack和常見Webpack中間件,解決了Webpack過於分散的問題。通過約定目錄,統一開發者的開發習慣。

與此同時,也產生了很多與時俱進的最佳實踐。使用react-router結合react-loadable,更優雅的做dynamic import。在頁面中切換路由時按需加載,在Webpack中做過代碼分割,這是極好的實踐。

以前是打包bundle是非常大的,現在以路由爲切分標準,按需加載,效率自然是高的。

Umi基於react-router又進步增強了,約定頁面有佈局的概念。

export default {
  routes: [
    { path: '/', component: './a' },
    { path: '/list', component: './b', Routes: ['./routes/PrivateRoute.js'] },
    { path: '/users', component: './users/_layout',
      routes: [
        { path: '/users/detail', component: './users/detail' },
        { path: '/users/:id', component: './users/id' }
      ]
    },
  ],
};

這樣做的好處,就有點模板引擎中include類似的效果。佈局提效也是極其明顯的。爲了演示優化後的效果,這裏以Umi爲例。它首先會加載index頁面,找到index佈局,先加載佈局,然後再加載index頁面裏的組件。下圖加了斷點,你可以很清楚的看出加載過程。

在 create-react-app(cra)和Umi類似,都是通過約定,隱藏具體實現細節,讓開發者不需要關注構建。在未來,類似的封裝還會有更多的封裝,偏於應用層面。筆者以爲前端開發成本在降低,未來有可能規模化的,因爲框架一旦穩定,就有大量培訓跟進,導致規模化開發。這是把雙刃劍,能滿足企業開發和招人的問題,但也在創新探索領域上了枷鎖。

預渲染(Prerending)

SPA(單頁面應用)的主要內容都依賴於JavaScript(bundle.js)的執行,當首頁HTML下載下來的時候,並不是完整的頁面,而是瀏覽器里加載HTML並JavaScript文件才能完成渲染。用戶在訪問的時候體驗會很好,但是對於搜索引擎是不好收錄的,因爲它們不能執行JavaScript,這種場景下預渲染(Prerending)就派上用場了,它可以幫忙把頁面渲染完成之後再返回給爬蟲工具,我們的頁面也就能被解析到了。

CSR是由bundle.js來控制渲染的,所以它外層的HTML都很薄。對於首屏渲染來說,如果能夠先展示一部分佈局內容,然後在走CSR的其他加載,效果會更好。另外業內有太多類似的事件了,比如用less寫css,coffee寫js,用markdown寫博客,都是需要編譯一次才能使用的。比如Jekyll/Hexo等著名項目,它們都非常好用。那麼基於React技術,也必然會做預處理的,Gatsby/Next.js都有類似的功能。將React組建編譯成HTML,可以編譯全部,也可以只編譯佈局,對於頁面性能來說,預渲染是非常簡單的提升手段。其原理JSX模板和Webpack stats結合,進行預編譯。

•    編譯全部:純靜態頁面。
  •    只編譯佈局:對於SPA類項目是非常好,當然多頁應用也可以只編譯佈局的。

生成純HTML,可以直接放到CDN上,這是簡單的靜態渲染。如果不直接生成HTML,由Node.js來接管,那麼就可以轉換爲簡單的SSR。

無論CSR還是靜態渲染,都不得不面對數據獲取問題。如果bundle.js加載完成,Ajax再獲取的話,整個過程還要增加50ms以上的交互時間。如果預先能夠得到數據,肯定是更好的。

類似上圖中的數據,放在Node.js層去獲取,並注入到頁面,是服務器端渲染最常用的手段,當然,服務器端遠不止這麼簡單。

服務器端(SSR)

純服務器渲染其實很簡單,就是服務器向瀏覽器寫入HTML。典型的CGI或ASP、PHP、JSP這些都算,其核心原理就是模板+數據,最終編譯爲HTML並寫入到瀏覽器。

第一種方式是直接將HTML寫入到瀏覽器,具體如下。

上圖中的renderToString是react SSR的API,可以理解成將React組件編譯成HTML字符串,通俗點,可以理解React就是當模板使用。在服務器向瀏覽器寫入的第一個字節,就是TTFB時間,然後網絡傳輸時間,然後瀏覽器渲染,一般關注首屏渲染。如果一次將所有HTML寫入到瀏覽器,可能會比較大,在編譯React組件和網絡傳輸時間上會比較長,渲染時間也會拉長。

第二種方式是就採用Bigpipe進行分塊傳輸,雖然Bigpipe是一個相對比較”古老“的技術,但在實戰中還是非常好用的。在Node.js裏,默認res.write就支持分塊傳輸,所以使用Node.js做Bigpipe是非常合適的,在去哪兒的PC業務裏就大量使用這種方式。

以上2種方法都是服務器渲染,在沒有客戶端bundle.js助力的情況下,第一種情況除了首屏後懶加載外,客戶端能做的事兒不多。第二種情況下,還是有手段可以用的,比如在分塊裏寫入腳本,可以做的的事情還是很多的。

    res.write("<script>alert('something')</script>")

漸進混搭法(Progressive Rehydration)

漸進混搭法是將CSR和SSR一起使用的方式。SSR負責接口請求和首屏渲染,並客戶端準備數據或配合完成某些生命週期的操作。

首先,在服務器端生成佈局文件,用於首屏渲染,在佈局文件裏會嵌入bundle.js。當頁面加載bundle.js成功後,客戶端渲染就開始了。通常客戶端渲染過程都會在domReady之前,所以優化效果是極其明顯的。

Bigpipe可以使用在分塊裏寫入腳本,在React SSR裏也可以使用renderToNodeStream搞定。React 16現在支持直接渲染到節點流。渲染到流可以減少你的內容的第一個字節(TTFB)的時間,在文檔的下一部分生成之前,將文檔的開頭至結尾發送到瀏覽器。當內容從服務器流式傳輸時,瀏覽器將開始解析HTML文檔。渲染到流的另一個好處是能夠響應。 實際上,這意味着如果網絡被備份並且不能接受更多的字節,則渲染器會獲得信號並暫停渲染,直到堵塞清除。這意味着您的服務器使用更少的內存,並更加適應I / O條件,這兩者都可以幫助您的服務器處於具有挑戰性的條件。

最簡單的示例,你只需要stream.pipe(res, { end: false })。

// 服務器端
// using Express
import { renderToNodeStream } from "react-dom/server"
import MyPage from "./MyPage"
app.get("/", (req, res) => {
  res.write("<!DOCTYPE HTML><HTML><head><title>My Page</title></head><body>");
  res.write("<div id='content'>"); 
  const stream = renderToNodeStream(<MyPage/>);
  stream.pipe(res, { end: false });
  stream.on('end', () => {
    res.write("</div></body></HTML>");
    res.end();
  });
});

當MyPage組件的HTML片段寫到瀏覽器裏,你需要通過hydrate進行綁定。

// 瀏覽器端
import { hydrate } from "react-dom"
import MyPage from "./MyPage"
hydrate(<MyPage/>, document.getElementById("content"))

至此,你大概能夠了解React SSR的原理了。服務器編譯後的組件更多的是偏於HTML模板,而具體事件和vdom操作需要依賴前端bundle.js做,即前端hydrate時需要做的事兒。
可是,如果有多個組件,需要寫入多次流呢?使用renderToString就簡單很多,普通模板的方式,流卻使得這種玩法變得很麻煩。

React SSR裏還有一個新增API:renderToNodeStream,結合Stream也能實現Bigpipe一樣的效果,而且可以有效的提高TTFB時間。

僞代碼

const stream1 = renderToNodeStream(<MyPage/>);
const stream2 = renderToNodeStream(<MyTab/>);

res.write(stream1)
res.write(stream2)
res.end()

如果每個React組件都用renderToNodeStream編譯,並寫入瀏覽器,那麼流的優勢就極其明顯了,邊讀邊寫,都是內存操作,效率非常高。後端寫入一個React組件,前端就hydrate綁定一下,如此循環往復,其做法和Bigpipe如出一轍。

Next.js同構開發

Node.js成熟的標誌是以MEAN架構開始替換LAMP。在MEAN之後,很多關於同構的探索層出不窮,比如Meteor,將同構進程的非常徹底,使用JavaScript搞定前後端,開創性的提出了Realtime、Date on the Wire、Database Everywhere、Latency Compensation,零部署等特性,其核心還是圍繞Full Stack Reactivity做的,這裏不展開。簡言之,當數據發生改變的時候,所有依賴該數據的地方自動發生相應的改變。本身這些概念是很牛的,參與的開發者也都很牛,但問題是過於超前了。熟悉Node.js又熟悉前端的人那時候還沒那麼多,所以前期開發是非常快的,但一旦遇到問題,調試和解決的成本高,過程是非常難受的。所以至今發佈了Meteor 1.8也是不溫不火的情況。

Next.js是一個輕量級的React應用框架。這裏需要強調一下,它不只是React服務端渲染框架。它幾乎覆蓋了CSR和SSR的絕大部分場景。Next.js自己實現的路由,然後react-loadable進行按照路由進行代碼分割,整體效果是非常不錯的。Next.js約定組件寫法,在React組件上,增加靜態的getInitialProps方法,用於API請求處理之用。這樣做,相當於將API和渲染分開,API獲得的結果作爲props傳給React組件,可以說,這種設計確實很贊,可圈可點。

Nextjs式的一鍵開啓CSR和SSR,比如下面這段代碼。

import React from 'react'
import Link from 'next/link'
import 'isomorphic-unfetch'

export default class Index extends React.Component {
  static async getInitialProps () {
    // eslint-disable-next-line no-undef
    const res = await fetch('https://api.github.com/repos/zeit/next.js')
    const json = await res.json()
    return { stars: json.stargazers_count }
  }

  render () {
    return (

      <div>
        <p>Next.js has {this.props.stars} </p>
        <Link prefetch href='/preact'>
          <a>How about preact?</a>
        </Link>
      </div>

    )
  }
}


在scr/pages/*.js都是遵守文件名即path的做法。內部使用react-router封裝。在執行過程中

•    loadGetInitialProps(),獲得執行getInitialProps靜態方法的返回值props
  •    將props傳給src/pages/*.js裏標準react組件的props

優點

  1. 靜態方法,不用創建對象即可直接執行。
  2. 利用組建自身的props傳值,與狀態無關,簡單方便。
  3. SSR和CSR代碼是一份,便於維護

Next.js的做法成爲行業最佳實踐並不爲過,通過簡單的用法,可有效的提高首屏渲染,但對於複雜度較高的情況是很難覆蓋的。畢竟頁面裏用到的API不會那麼理想,後端支持力度也是有限的,另外前端自己組合API並不是每個團隊都有這樣的能力,那麼要解此種情況就只有2個選擇:1)在SSR裏實現,2)自建API中間層。

自建API中間層是最好的方式,但如果不方便,集成在SSR裏也是可以的。利用Bigpipe和React做好SSR組合,能夠完成更強大的能力。限於篇幅,具體實踐留在QCon全球軟件開發大會(廣州站)上分享吧。

3、性能問題

用SSR最大的問題是場景區分,如果區分不好,還是非常容易有性能問題的。上面5種渲染方式裏,預渲染裏可以使用服務器端路由,此時無任何問題,就當普通的靜態託管服務就好,如果在遞進一點,你可以把它理解成是Web模板渲染。這裏重點講一下混搭法和純SSR。

混搭法通常只有簡單請求,能玩的事情有限。一般是Node.js請求接口,然後渲染首屏,在正常情況性能很好的,TTFB很好,整體rt也很短,使用於簡單的場景。此時最怕API組裝,如果是幾個API組合在一起,然後在返回首屏,就會導致rt很長,性能下降的非常明顯。當然,也可以解,你需要加緩存策略,減少不必要的網絡請求,將結果放到Redis裏。另外將一些個性化需求,比如千人千面的推薦放到頁面中做懶加載。

如果是純服務器渲染,那麼要求會更加苛刻,有時rt有10幾秒,甚至更長,此時要保證QPS還是有很大難度的。除了合併接口,對接口進行緩存,還能做的就是對頁面模塊進行分級處理,從佈局,核心展示模塊,以及其他模塊。

除了上面這些業務方法外,剩下的就是Node.js自身的性能調優了。比如內存溢出,耗時函數定位等,cpu採樣等,推薦使用成熟的alinode和node-clinic。畢竟Node.js專項性能調優模塊過多,不如直接用這種套裝方案。

4、未來

Node.js在大前端佈局裏意義重大,除了基本構建和Web服務外,這裏我還想講2點。首先它打破了原有的前端邊界,之前應用開發只分前端和API開發。但通過引入Node.js做BFF這樣的API Proxy中間層,使API開發也成了前端的工作範圍,讓後端同學專注於開發RPC服務,很明顯這樣明確的分工是極好的。其次,在前端開發過程中,有很多問題不依賴服務器端是做不到的,比如場景的性能優化,在使用React後,導致bundle過大,首屏渲染時間過長,而且存在SEO問題,這時候使用Node.js做SSR就是非常好的。

當然,前端開發使用Node.js還是存在一些成本,要了解運維等技能,會略微複雜一些,不過也有解決方案,比如Servlerless就可以降級運維成本,又能完成前端開發。直白點講,在已有Node.js拓展的邊界內,降級運維成本,提高開發的靈活性,這一定會是一個大趨勢。

未來,API Proxy層和SSR都真正的落在Servlerless,對於前端的演進會更上一層樓。向前是SSR渲染,先後是API包裝,攻防兼備,提效利器,自然是趨勢。

作者簡介

狼叔(網名i5ting),現爲阿里巴巴前端技術專家,Node.js 技術佈道者,Node全棧公衆號運營者,曾就職於去哪兒、新浪、網秦,做過前端、後端、數據分析,是一名全棧技術的實踐者。目前負責BU的Node.js和基礎框架開發,即將出版Node.js《狼書》3卷。同時,狼叔將作爲QCon全球軟件開發大會(廣州站)的講師,分享「C端服務端渲染(SSR)和性能優化實踐」,感興趣的同學可以關注下。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章