React 中同構(SSR)原理脈絡梳理

alisecued發表於2018-10-18

隨著越來越多新型前端框架的推出,SSR 這個概念在前端開發領域的流行度越來越高,也有越來越多的專案採用這種技術方案進行了實現。SSR 產生的背景是什麼?適用的場景是什麼?實現的原理又是什麼?希望大家在這篇文章中能夠找到你想要的答案。

說到 SSR,很多人的第一反應是“伺服器端渲染”,但我更傾向於稱之為“同構”,所以首先我們來對“客戶端渲染”,“伺服器端渲染”,“同構”這三個概念簡單的做一個分析:

客戶端渲染:客戶端渲染,頁面初始載入的 HTML 頁面中無網頁展示內容,需要載入執行JavaScript 檔案中的 React 程式碼,通過 JavaScript 渲染生成頁面,同時,JavaScript 程式碼會完成頁面互動事件的繫結,詳細流程可參考下圖(圖片取材自 fullstackacademy.com):

client-side-rendering.jpg

伺服器端渲染:使用者請求伺服器,伺服器上直接生成 HTML 內容並返回給瀏覽器。伺服器端渲染來,頁面的內容是由 Server 端生成的。一般來說,伺服器端渲染的頁面互動能力有限,如果要實現複雜互動,還是要通過引入 JavaScript 檔案來輔助實現。伺服器端渲染這個概念,適用於任何後端語言。

server-side-rendering.jpg

同構:同構這個概念存在於 Vue,React 這些新型的前端框架中,同構實際上是客戶端渲染和伺服器端渲染的一個整合。我們把頁面的展示內容和互動寫在一起,讓程式碼執行兩次。在伺服器端執行一次,用於實現伺服器端渲染,在客戶端再執行一次,用於接管頁面互動,詳細流程可參考下圖(圖片取材自 fullstackacademy.com):

ssr.jpg

一般情況下,當我們使用 React 編寫程式碼時,頁面都是由客戶端執行 JavaScript 邏輯動態掛 DOM 生成的,也就是說這種普通的單頁面應用實際上採用的是客戶端渲染模式。在大多數情況下,客戶端渲染完全能夠滿足我們的業務需求,那為什麼我們還需要 SSR 這種同構技術呢?

使用 SSR 技術的主要因素:

  1. CSR 專案的 TTFP(Time To First Page)時間比較長,參考之前的圖例,在 CSR 的頁面渲染流程中,首先要載入 HTML 檔案,之後要下載頁面所需的 JavaScript 檔案,然後 JavaScript 檔案渲染生成頁面。在這個渲染過程中至少涉及到兩個 HTTP 請求週期,所以會有一定的耗時,這也是為什麼大家在低網速下訪問普通的 React 或者 Vue 應用時,初始頁面會有出現白屏的原因。

  2. CSR 專案的 SEO 能力極弱,在搜尋引擎中基本上不可能有好的排名。因為目前大多數搜尋引擎主要識別的內容還是 HTML,對 JavaScript 檔案內容的識別都還比較弱。如果一個專案的流量入口來自於搜尋引擎,這個時候你使用 CSR 進行開發,就非常不合適了。

SSR 的產生,主要就是為了解決上面所說的兩個問題。在 React 中使用 SSR 技術,我們讓 React 程式碼在伺服器端先執行一次,使得使用者下載的 HTML 已經包含了所有的頁面展示內容,這樣,頁面展示的過程只需要經歷一個 HTTP 請求週期,TTFP 時間得到一倍以上的縮減。

同時,由於 HTML 中已經包含了網頁的所有內容,所以網頁的 SEO 效果也會變的非常好。之後,我們讓 React 程式碼在客戶端再次執行,為 HTML 網頁中的內容新增資料及事件的繫結,頁面就具備了 React 的各種互動能力。

但是,SSR 這種理念的實現,並非易事。我們來看一下在 React 中實現 SSR 技術的架構圖:

ssr-framework.jpg

使用 SSR 這種技術,將使原本簡單的 React 專案變得非常複雜,專案的可維護性會降低,程式碼問題的追溯也會變得困難。

所以,使用 SSR 在解決問題的同時,也會帶來非常多的副作用,有的時候,這些副作用的傷害比起 SSR 技術帶來的優勢要大的多。從個人經驗上來說,我一般建議大家,除非你的專案特別依賴搜尋引擎流量,或者對首屏時間有特殊的要求,否則不建議使用 SSR。

好,如果你確實遇到了 React 專案中要使用 SSR 的場景並決定使用 SSR,那麼接下來我們就結合上面這張 SSR 架構圖,開啟 SSR 技術點的難點剖析。

在開始之前,我們先來分析下虛擬 DOM 和 SSR 的關係。

SSR 之所以能夠實現,本質上是因為虛擬 DOM 的存在

上面我們說過,SSR 的工程中,React 程式碼會在客戶端和伺服器端各執行一次。你可能會想,這沒什麼問題,都是 JavaScript 程式碼,既可以在瀏覽器上執行,又可以在 Node 環境下執行。但事實並非如此,如果你的 React 程式碼裡,存在直接操作 DOM 的程式碼,那麼就無法實現 SSR 這種技術了,因為在 Node 環境下,是沒有 DOM 這個概念存在的,所以這些程式碼在 Node 環境下是會報錯的。

好在 React 框架中引入了一個概念叫做虛擬 DOM,虛擬 DOM 是真實 DOM 的一個 JavaScript 物件對映,React 在做頁面操作時,實際上不是直接操作 DOM,而是操作虛擬 DOM,也就是操作普通的 JavaScript 物件,這就使得 SSR 成為了可能。在伺服器,我可以操作 JavaScript 物件,判斷環境是伺服器環境,我們把虛擬 DOM 對映成字串輸出;在客戶端,我也可以操作 JavaScript 物件,判斷環境是客戶端環境,我就直接將虛擬 DOM 對映成真實 DOM,完成頁面掛載。

其他的一些框架,比如 Vue,它能夠實現 SSR 也是因為引入了和 React 中一樣的虛擬 DOM 技術。

好,接下來我們回過頭看流程圖,前兩步不說了,伺服器端渲染肯定要先向 Node 伺服器傳送請求。重點是第 3 步,大家可以看到,伺服器端要根據請求的地址,判斷要展示什麼樣的頁面了,這一步叫做伺服器端路由。

我們再看第 10 步,當客戶端接收到 JavaScript 檔案後,要根據當前的路徑,在瀏覽器上再判斷當前要展示的元件,重新進行一次客戶端渲染,這個時候,還要經歷一次客戶端路由(前端路由)。

那麼,我們下面要說的就是伺服器端路由和客戶端路由的區別。

SSR 中客戶端渲染與伺服器端渲染路由程式碼的差異

實現 React 的 SSR 架構,我們需要讓相同的 React 程式碼在客戶端和伺服器端各執行一次。大家注意,這裡說的相同的 React 程式碼,指的是我們寫的各種元件程式碼,所以在同構中,只有元件的程式碼是可以公用的,而路由這樣的程式碼是沒有辦法公用的,大家思考下這是為什麼呢?其實原因很簡單,在伺服器端需要通過請求路徑,找到路由元件,而在客戶端需通過瀏覽器中的網址,找到路由元件,是完全不同的兩套機制,所以這部分程式碼是肯定無法公用。我們來看看在 SSR 中,前後端路由的實現程式碼:

客戶端路由:

const App = () => {
  return (
    <Provider store={store}>
      <BrowserRouter>
        <div>
          <Route path='/' component={Home}>
  		</div>
      </BrowserRouter>
    </Provider>
  )
}

ReactDom.render(<App/>, document.querySelector('#root'))
複製程式碼

客戶端路由程式碼非常簡單,大家一定很熟悉,BrowserRouter 會自動從瀏覽器地址中,匹配對應的路由元件顯示出來。

伺服器端路由程式碼:

const App = () => {
  return 
    <Provider store={store}>
      <StaticRouter location={req.path} context={context}>
        <div>
          <Route path='/' component={Home}>
        </div>
      </StaticRouter>
    </Provider>
}

Return ReactDom.renderToString(<App/>)
複製程式碼

伺服器端路由程式碼相對要複雜一點,需要你把 location(當前請求路徑)傳遞給 StaticRouter 元件,這樣 StaticRouter 才能根據路徑分析出當前所需要的元件是誰。(PS:StaticRouter 是 React-Router 針對伺服器端渲染專門提供的一個路由元件。)

通過 BrowserRouter 我們能夠匹配到瀏覽器即將顯示的路由元件,對瀏覽器來說,我們需要把元件轉化成 DOM,所以需要我們使用 ReactDom.render 方法來進行 DOM 的掛載。而 StaticRouter 能夠在伺服器端匹配到將要顯示的元件,對伺服器端來說,我們要把元件轉化成字串,這時我們只需要呼叫 ReactDom 提供的 renderToString 方法,就可以得到 App 元件對應的 HTML 字串。

對於一個 React 應用來說,路由一般是整個程式的執行入口。在 SSR 中,伺服器端的路由和客戶端的路由不一樣,也就意味著伺服器端的入口程式碼和客戶端的入口程式碼是不同的。

我們知道, React 程式碼是要通過 Webpack 打包之後才能執行的,也就是第 3 步和第10 步執行的程式碼,實際上是原始碼打包過後生成的程式碼。上面也說到,伺服器端和客戶端渲染中的程式碼,只有一部分一致,其餘是有區別的。所以,針對程式碼執行環境的不同,要進行有區別的 Webpack 打包。

伺服器端程式碼和客戶端程式碼的打包差異

簡單寫兩個 Webpack 配置檔案作為 DEMO:

客戶端 Webpack 配置

{
  entry: './src/client/index.js',
  output: {
    filename: 'index.js',
    path: path.resolve(__dirname, 'public')
  },
  module: {
    rules: [{
      test: /\.js?$/,
      loader: 'babel-loader'
    },{
      test: /\.css?$/,
      use: ['style-loader', {
        loader: 'css-loader',
        options: {modules: true}
      }]
    },{
      test: /\.(png|jpeg|jpg|gif|svg)?$/,
      loader: 'url-loader',
      options: {
        limit: 8000,
        publicPath: '/'
      }
    }]
  }
}
複製程式碼

伺服器端 Webpack 配置:

{
  target: 'node',
  entry: './src/server/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'build')
  },
  externals: [nodeExternals()],
  module: {
    rules: [{
      test: /\.js?$/,
      loader: 'babel-loader'
    },{
      test: /\.css?$/,
      use: ['isomorphic-style-loader', {
        loader: 'css-loader',
        options: {modules: true}
      }]
    },{
      test: /\.(png|jpeg|jpg|gif|svg)?$/,
      loader: 'url-loader',
      options: {
        limit: 8000,
        outputPath: '../public/',
        publicPath: '/'
      }
    }]
  }
};
複製程式碼

上面我們說了,在 SSR 中,伺服器端渲染的程式碼和客戶端的程式碼的入口路由程式碼是有差異的,所以在 Webpack 中,Entry 的配置首先肯定是不同的。

在伺服器端執行的程式碼,有時我們需要引入 Node 中的一些核心模組,我們需要 Webpack 做打包的時候能夠識別出類似的核心模組,一旦發現是核心模組,不必把模組的程式碼合併到最終生成的程式碼中,解決這個問題的方法非常簡單,在伺服器端的 Webpack配置中,你只要加入 target: node 這個配置即可。

伺服器端渲染的程式碼,如果載入第三方模組,這些第三方模組也是不需要被打包到最終的原始碼中的,因為 Node 環境下通過 NPM 已經安裝了這些包,直接引用就可以,不需要額外再打包到程式碼裡。為了解決這個問題,我們可以使用 webpack-node-externals 這個外掛,程式碼中的 nodeExternals 指的就是這個外掛,通過這個外掛,我們就能解決這個問題。關於 Node 這裡的打包問題,可能看起來有些抽象,不是很明白的同學可以仔細讀一下 webpack-node-externals 相關的文章或文件,你就能很好的明白這裡存在的問題了。

接下來我們繼續分析,當我們的 React 程式碼中引入了一些 CSS 樣式程式碼時,伺服器端打包的過程會處理一遍 CSS,而客戶端又會處理一遍。檢視配置,我們可以看到,伺服器端打包時我們用了 isomorphic-style-loader,它處理 CSS 的時候,只在對應的 DOM 元素上生成 class 類名,然後返回生成的 CSS 樣式程式碼。

而在客戶端程式碼打包配置中,我們使用了 css-loader 和 style-loader,css-loader 不但會在 DOM 上生成 class 類名,解析好的 CSS 程式碼,還會通過 style-loader 把程式碼掛載到頁面上。不過這麼做,由於頁面上的樣式實際上最終是由客戶端渲染時新增上的,所以頁面可能會存在一開始沒有樣式的情況,為了解決這個問題, 我們可以在伺服器端渲染時,拿到 isomorphic-style-loader 返回的樣式程式碼,然後以字串的形式新增到伺服器端渲染的 HTML 之中。

而對於圖片等型別的檔案引入,url-loader 也會在伺服器端程式碼和客戶端程式碼打包的過程中分別進行打包,這裡,我偷了一個懶,無論伺服器端打包還是客戶端打包,我都讓打包生成的檔案儲存在 public 目錄下,這樣,雖然檔案會打包出來兩遍,但是後打包出來的檔案會覆蓋之前的檔案,所以看起來還是隻有一份檔案。

當然,這樣做的效能和優雅性並不高,只是給大家提供一個小的思路,如果想進行優化,你可以讓圖片的打包只進行一次,藉助一些 Webpack 的外掛,實現這個也並非難事,你甚至可以自己也寫一個 loader,來解決這樣的問題。

如果你的 React 應用中沒有非同步資料的獲取,單純的做一些靜態內容展示,經過上面的配置,你會發現一個簡單的 SSR 應用很快的就可以被實現出來了。但是,真正的一個 React 專案中,我們肯定要有非同步資料的獲取,絕大多數情況下,我們還要使用 Redux 管理資料。而如果想在 SSR 應用中實現,就不是這麼簡單了。

SSR 中非同步資料的獲取 + Redux 的使用

客戶端渲染中,非同步資料結合 Redux 的使用方式遵循下面的流程(對應圖中第 12 步):

  1. 建立 Store
  2. 根據路由顯示元件
  3. 派發 Action 獲取資料
  4. 更新 Store 中的資料
  5. 元件 Rerender

而在伺服器端,頁面一旦確定內容,就沒有辦法 Rerender 了,這就要求元件顯示的時候,就要把 Store 的資料都準備好,所以伺服器端非同步資料結合 Redux 的使用方式,流程是下面的樣子(對應圖中第 4 步):

  1. 建立 Store
  2. 根據路由分析 Store 中需要的資料
  3. 派發 Action 獲取資料
  4. 更新Store 中的資料
  5. 結合資料和元件生成 HTML,一次性返回

下面,我們分析下伺服器端渲染這部分的流程:

  1. 建立 Store:這一部分有坑,要注意避免,大家知道,客戶端渲染中,使用者的瀏覽器中永遠只存在一個 Store,所以程式碼上你可以這麼寫:
const store = createStore(reducer, defaultState)
export default store;
複製程式碼

然而在伺服器端,這麼寫就有問題了,因為伺服器端的 Store 是所有使用者都要用的,如果像上面這樣構建 Store,Store 變成了一個單例,所有使用者共享 Store,顯然就有問題了。所以在伺服器端渲染中,Store 的建立應該像下面這樣,返回一個函式,每個使用者訪問的時候,這個函式重新執行,為每個使用者提供一個獨立的 Store:

const getStore = (req) => {
  return createStore(reducer, defaultState);
}
export default getStore;
複製程式碼
  1. 根據路由分析 Store 中需要的資料: 要想實現這個步驟,在伺服器端,首先我們要分析當前出路由要載入的所有元件,這個時候我們可以藉助一些第三方的包,比如說 react-router-config, 具體這個包怎麼使用,不做過多說明,大家可以檢視文件,使用這個包,傳入伺服器請求路徑,它就會幫助你分析出這個路徑下要展示的所有元件。

  2. 派發 Action 獲取資料: 接下來,我們在每個元件上增加一個獲取資料的方法:

Home.loadData = (store) => {
  return store.dispatch(getHomeList())
}
複製程式碼

這個方法需要你把伺服器端渲染的 Store 傳遞進來,它的作用就是幫助伺服器端的 Store 獲取到這個元件所需的資料。 所以,元件上有了這樣的方法,同時我們也有當前路由所需要的所有元件,依次呼叫各個元件上的 loadData 方法,就能夠獲取到路由所需的所有資料內容了。

  1. 更新 Store 中的資料: 其實,當我們執行第三步的時候,已經在更新 Store 中的資料了,但是,我們要在生成 HTML 之前,保證所有的資料都獲取完畢,這怎麼處理呢?
// matchedRoutes 是當前路由對應的所有需要顯示的元件集合
matchedRoutes.forEach(item => {
  if (item.route.loadData) {
    const promise = new Promise((resolve, reject) => {
      item.route.loadData(store).then(resolve).catch(resolve);
    })
    promises.push(promise);
  }
})

Promise.all(promises).then(() => {
  // 生成 HTML 邏輯
})
複製程式碼

這裡,我們使用 Promise 來解決這個問題,我們構建一個 Promise 佇列,等待所有的 Promise 都執行結束後,也就是所有 store.dispatch 都執行完畢後,再去生成 HTML。這樣的話,我們就實現了結合 Redux 的 SSR 流程。

在上面,我們說到,伺服器端渲染時,頁面的資料是通過 loadData 函式來獲取的。而在客戶端,資料獲取依然要做,因為如果這個頁面是你訪問的第一個頁面,那麼你看到的內容是伺服器端渲染出來的,但是如果經過 react-router 路由跳轉道第二個頁面,那麼這個頁面就完全是客戶端渲染出來的了,所以客戶端也要去拿資料。

在客戶端獲取資料,使用的是我們最習慣的方式,通過 componentDidMount 進行資料的獲取。這裡要注意的是,componentDidMount 只在客戶端才會執行,在伺服器端這個生命週期函式是不會執行的。所以我們不必擔心 componentDidMount 和 loadData 會有衝突,放心使用即可。這也是為什麼資料的獲取應該放到 componentDidMount 這個生命週期函式中而不是 componentWillMount 中的原因,可以避免伺服器端獲取資料和客戶端獲取資料的衝突。

Node 只是一箇中間層

上一部分我們說到了獲取資料的問題,在 SSR 架構中,一般 Node 只是一箇中間層,用來做 React 程式碼的伺服器端渲染,而 Node 需要的資料通常由 API 伺服器單獨提供。

這樣做一是為了工程解耦,二也是為了規避 Node 伺服器的一些計算效能問題。

請大家關注圖中的第 4 步和第 12,13 步,我們接下來分析這幾個步驟。

伺服器端渲染時,直接請求 API 伺服器的介面獲取資料沒有任何問題。但是在客戶端,就有可能存在跨域的問題了,所以,這個時候,我們需要在伺服器端搭建 Proxy 代理功能,客戶端不直接請求 API 伺服器,而是請求 Node 伺服器,經過代理轉發,拿到 API 伺服器的資料。

這裡你可以通過 express-http-proxy 這樣的工具幫助你快速搭建 Proxy 代理功能,但是記得配置的時候,要讓代理伺服器不僅僅幫你轉發請求,還要把 cookie 攜帶上,這樣才不會有許可權校驗上的一些問題。

// Node 代理功能實現程式碼
app.use('/api', proxy('http://apiServer.com', {
  proxyReqPathResolver: function (req) {
    return '/ssr' + req.url;
  }
}));

複製程式碼

總結:

到這裡,整個 SSR 的流程體系中關鍵知識點的原理就串聯起來了,如果你之前適用過 SSR 框架,那麼這些知識點的整理我相信可以從原理層面很好的幫助到你。

當然,我也考慮到閱讀本篇文章的同學可能有很大一部分對 SSR 的基礎知識非常有限,看了文章可能會雲裡霧裡,這裡為了幫助這些同學,我編寫了一個非常簡單的 SSR 框架,程式碼放在這裡:

files.alicdn.com/tpsservice/…

初學者結合上面的流程圖,一步步梳理流程圖中的邏輯,梳理結束後,回來再看一遍這篇文章,相信大家就豁然開朗了。

當然在真正實現 SSR 架構的過程中,難點有時不是實現的思路,而是細節的處理。比如說如何針對不同頁面設定不同的 title 和 description 來提升 SEO 效果,這時候,我們其實可以用 react-helmet 這樣的工具幫我們達成目標,這個工具對客戶端和伺服器端渲染的效果都很棒,值得推薦。還有一些諸如工程目錄的設計,404,301 重定向情況的處理等等,不過這些問題,我們只需要在實踐中遇到的時候逐個攻破就可以了。

好了,關於 SSR 的全部分享就到這裡,希望這篇文章能夠或多或少幫助到你。

參考文件

文章可隨意轉載,但請保留此 原文連結。 非常歡迎有激情的你加入 ES2049 Studio,簡歷請傳送至 caijun.hcj(at)alibaba-inc.com

相關文章