從零到一搭建React SSR工程架構(一)
react在客戶端執行,消耗客戶端效能。客戶端渲染,頁面初始載入的 HTML
頁面中無網頁展示內容,需要載入執行 JavaScript
檔案中的 React
程式碼,通過 JavaScript
渲染生成頁面,同時, JavaScript
程式碼會完成頁面互動事件的繫結,詳細流程可參考下圖
客戶端渲染流程
瀏覽器傳送請求–>伺服器返回 HTML
–>瀏覽器傳送 bundle.js
請求–>伺服器返回 bundle.js
–>瀏覽器執行 bundle.js
中的 react
程式碼完成渲染
1.2 什麼是服務端渲染
react
在服務端執行,消耗服務端效能。我們所說的服務端渲染,只是首次載入頁面的時候,後面都是通過前端路由進行客戶端渲染
- 使用者請求伺服器,伺服器上直接生成
HTML
內容並返回給瀏覽器。伺服器端渲染來,頁面的內容是由Server
端生成的。一般來說,伺服器端渲染的頁面互動能力有限,如果要實現複雜互動,還是要通過引入JavaScript
檔案來輔助實現
服務端渲染流程
瀏覽器傳送請求–>伺服器執行 React
程式碼生成頁面–>伺服器返回頁面
1.3 什麼是同構
一套react程式碼,在服務端執行一次,在客戶端也執行一次。在服務端執行同構 renderToString
只是返回介面展示,並不能繫結事件,需要在客戶端再次執行js程式碼繫結事件
伺服器執行 React
程式碼渲染出 HTML
–>傳送 HTML
給瀏覽器–>瀏覽器接收到內容展示–>瀏覽器載入js檔案–>Js中的 React
程式碼在瀏覽器端重新執行–>JS中的 React
程式碼接管頁面操作
路由同構
讓路由在服務端、客戶端各跑一遍
1.4 使用SSR優劣勢
一般情況下,當我們使用 React
編寫程式碼時,頁面都是由客戶端執行 JavaScript
邏輯動態掛 DOM
生成的,也就是說這種普通的單頁面應用實際上採用的是客戶端渲染模式。在大多數情況下,客戶端渲染完全能夠滿足我們的業務需求,那為什麼我們還需要 SSR
這種同構技術呢
-
CSR
專案的TTFP
(Time To First Page)時間比較長,參考之前的圖例,在CSR
的頁面渲染流程中,首先要載入HTML
檔案,之後要下載頁面所需的JavaScript
檔案,然後JavaScript
檔案渲染生成頁面。在這個渲染過程中至少涉及到兩個HTTP
請求週期,所以會有一定的耗時,這也是為什麼大家在低網速下訪問普通的React
或者Vue
應用時,初始頁面會有出現白屏的原因 -
CSR
專案的SEO
能力極弱,在搜尋引擎中基本上不可能有好的排名。因為目前大多數搜尋引擎主要識別的內容還是HTML
,對JavaScript
檔案內容的識別都還比較弱。如果一個專案的流量入口來自於搜尋引擎,這個時候你使用 CSR 進行開發,就非常不合適了
SSR
的產生,主要就是為了解決上面所說的兩個問題。在 React
中使用 SSR 技術,我們讓 React 程式碼在伺服器端先執行一次,使得使用者下載的 HTML 已經包含了所有的頁面展示內容,這樣,頁面展示的過程只需要經歷一個 HTTP
請求週期, TTFP
時間得到一倍以上的縮減
- 同時,由於
HTML
中已經包含了網頁的所有內容,所以網頁的SEO
效果也會變的非常好。之後,我們讓React
程式碼在客戶端再次執行,為HTML
網頁中的內容新增資料及事件的繫結,頁面就具備了React
的各種互動能力
但是, SSR
這種理念的實現,並非易事。我們來看一下在 React
中實現 SSR
技術的架構圖:
- 使用
SSR
這種技術,將使原本簡單的React
專案變得非常複雜,專案的可維護性會降低,程式碼問題的追溯也會變得困難 - 所以,使用
SSR
在解決問題的同時,也會帶來非常多的副作用,有的時候,這些副作用的傷害比起SSR
技術帶來的優勢要大的多。從個人經驗上來說,我一般建議大家,除非你的專案特別依賴搜尋引擎流量,或者對首屏時間有特殊的要求,否則不建議使用SSR
二、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,完成頁面掛載
三、SSR中伺服器端路由和客戶端路由區別
實現 React
的 SSR
架構,我們需要讓相同的 React
程式碼在客戶端和伺服器端各執行一次。大家注意,這裡說的相同的 React
程式碼,指的是我們寫的各種元件程式碼,所以在同構中,只有元件的程式碼是可以公用的,而路由這樣的程式碼是沒有辦法公用的,大家思考下這是為什麼呢?其實原因很簡單,在伺服器端需要通過請求路徑,找到路由元件,而在客戶端需通過瀏覽器中的網址,找到路由元件,是完全不同的兩套機制,所以這部分程式碼是肯定無法公用。我們來看看在 SSR 中,前後端路由的實現程式碼
3.1 客戶端路由
const App = () => { return ( <Provider store={store}> <BrowserRouter> <div> <Route path='/' component={Home}> </div> </BrowserRouter> </Provider> ) } ReactDom.render(<App/>, document.querySelector('#root'))
客戶端路由程式碼非常簡單,大家一定很熟悉, BrowserRouter
會自動從瀏覽器地址中,匹配對應的路由元件顯示出來
3.2 伺服器端路由
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
打包之後才能執行的,實際上是原始碼打包過後生成的程式碼。上面也說到,伺服器端和客戶端渲染中的程式碼,只有一部分一致,其餘是有區別的。所以,針對程式碼執行環境的不同,要進行有區別的Webpack
打包
四、服務端和客戶端打包配置
4.1 客戶端 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: '/' } }] } }
4.2 伺服器端 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 的使用方式遵循下面的流程
Store Action Store Rerender
而在伺服器端,頁面一旦確定內容,就沒有辦法 Rerender 了,這就要求元件顯示的時候,就要把 Store 的資料都準備好,所以伺服器端非同步資料結合 Redux 的使用方式
伺服器端非同步資料結合 Redux流程
Store Store Action Store HTML
5.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;
- 根據路由分析 Store 中需要的資料: 要想實現這個步驟,在伺服器端,首先我們要分析當前出路由要載入的所有元件,這個時候我們可以藉助一些第三方的包,比如說 react-router-config, 具體這個包怎麼使用,不做過多說明,大家可以檢視文件,使用這個包,傳入伺服器請求路徑,它就會幫助你分析出這個路徑下要展示的所有元件
- 派發 Action 獲取資料: 接下來,我們在每個元件上增加一個獲取資料的方法
Home.loadData = (store) => { return store.dispatch(getHomeList()) }
這個方法需要你把伺服器端渲染的 Store 傳遞進來,它的作用就是幫助伺服器端的 Store 獲取到這個元件所需的資料。 所以,元件上有了這樣的方法,同時我們也有當前路由所需要的所有元件,依次呼叫各個元件上的 loadData 方法,就能夠獲取到路由所需的所有資料內容了
更新 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
中的原因,可以避免伺服器端獲取資料和客戶端獲取資料的衝突
六、處理styled-components樣式
styled-components
暴露了 ServerStyleSheet
,它允許我們用 中的所有 styled
元件建立一個樣式表。這個樣式表稍後會傳入我們的 Html
模板
//render.js import React from 'react'; import {StaticRouter} from 'react-router-dom' import {renderToString} from 'react-dom/server' import renderHTML from './template' import { Provider } from 'react-redux' import { ServerStyleSheet } from 'styled-components'; export const render = (store,routes,req,context)=>{ const sheet = new ServerStyleSheet(); // <-- 建立樣式表 const content = renderToString( // 收集樣式 sheet.collectStyles( <Provider store={store}> <StaticRouter location={req.path} context={context}> {routes} </StaticRouter> </Provider> ) ) const styles = sheet.getStyleTags(); // <-- 從表中獲取所有標籤 return renderHTML(content,store,styles) }
將 styles
作為引數新增到我們的 Html
函式中,並將 $ {styles}
引數插入到我們的模板字串中
// template.js export default (content,store,styles)=>` <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no" /> <meta name="theme-color" content="#000000"> <meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-touch-fullscreen" content="yes"> <title>好物為您聚集大平臺的優惠商品,讓你更便捷的找到你想要的寶物</title> <link rel="shortcut icon" href="/favicon.ico"> <link rel="stylesheet" href="${buildPath['vendor.css']}" /> <link rel="stylesheet" href="${buildPath['main.css']}" /> ${styles} </head> <body> <div id="root">${content}</div> <script> window.context = { state: ${JSON.stringify(store.getState())} } </script> </body> </html> `
七、Node 只是一箇中間層
在 SSR
架構中,一般 Node 只是一箇中間層,用來做 React
程式碼的伺服器端渲染,而 Node
需要的資料通常由 API 伺服器單獨提供
伺服器端渲染時,直接請求 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; } }));