隨着愈來愈多新型前端框架的推出,SSR 這個概念在前端開發領域的流行度愈來愈高,也有愈來愈多的項目採用這種技術方案進行了實現。SSR 產生的背景是什麼?適用的場景是什麼?實現的原理又是什麼?但願你們在這篇文章中可以找到你想要的答案。css
說到 SSR,不少人的第一反應是「服務器端渲染」,但我更傾向於稱之爲「同構」,因此首先咱們來對「客戶端渲染」,「服務器端渲染」,「同構」這三個概念簡單的作一個分析:前端
客戶端渲染:客戶端渲染,頁面初始加載的 HTML 頁面中無網頁展現內容,須要加載執行JavaScript 文件中的 React 代碼,經過 JavaScript 渲染生成頁面,同時,JavaScript 代碼會完成頁面交互事件的綁定,詳細流程可參考下圖(圖片取材自 fullstackacademy.com):node
服務器端渲染:用戶請求服務器,服務器上直接生成 HTML 內容並返回給瀏覽器。服務器端渲染來,頁面的內容是由 Server 端生成的。通常來講,服務器端渲染的頁面交互能力有限,若是要實現複雜交互,仍是要經過引入 JavaScript 文件來輔助實現。服務器端渲染這個概念,適用於任何後端語言。react
同構:同構這個概念存在於 Vue,React 這些新型的前端框架中,同構其實是客戶端渲染和服務器端渲染的一個整合。咱們把頁面的展現內容和交互寫在一塊兒,讓代碼執行兩次。在服務器端執行一次,用於實現服務器端渲染,在客戶端再執行一次,用於接管頁面交互,詳細流程可參考下圖(圖片取材自 fullstackacademy.com):webpack
通常狀況下,當咱們使用 React 編寫代碼時,頁面都是由客戶端執行 JavaScript 邏輯動態掛 DOM 生成的,也就是說這種普通的單頁面應用實際上採用的是客戶端渲染模式。在大多數狀況下,客戶端渲染徹底可以知足咱們的業務需求,那爲何咱們還須要 SSR 這種同構技術呢?web
CSR 項目的 TTFP(Time To First Page)時間比較長,參考以前的圖例,在 CSR 的頁面渲染流程中,首先要加載 HTML 文件,以後要下載頁面所需的 JavaScript 文件,而後 JavaScript 文件渲染生成頁面。在這個渲染過程當中至少涉及到兩個 HTTP 請求週期,因此會有必定的耗時,這也是爲何你們在低網速下訪問普通的 React 或者 Vue 應用時,初始頁面會有出現白屏的緣由。express
CSR 項目的 SEO 能力極弱,在搜索引擎中基本上不可能有好的排名。由於目前大多數搜索引擎主要識別的內容仍是 HTML,對 JavaScript 文件內容的識別都還比較弱。若是一個項目的流量入口來自於搜索引擎,這個時候你使用 CSR 進行開發,就很是不合適了。後端
SSR 的產生,主要就是爲了解決上面所說的兩個問題。在 React 中使用 SSR 技術,咱們讓 React 代碼在服務器端先執行一次,使得用戶下載的 HTML 已經包含了全部的頁面展現內容,這樣,頁面展現的過程只須要經歷一個 HTTP 請求週期,TTFP 時間獲得一倍以上的縮減。api
同時,因爲 HTML 中已經包含了網頁的全部內容,因此網頁的 SEO 效果也會變的很是好。以後,咱們讓 React 代碼在客戶端再次執行,爲 HTML 網頁中的內容添加數據及事件的綁定,頁面就具有了 React 的各類交互能力。跨域
可是,SSR 這種理念的實現,並不是易事。咱們來看一下在 React 中實現 SSR 技術的架構圖:
使用 SSR 這種技術,將使本來簡單的 React 項目變得很是複雜,項目的可維護性會下降,代碼問題的追溯也會變得困難。
因此,使用 SSR 在解決問題的同時,也會帶來很是多的反作用,有的時候,這些反作用的傷害比起 SSR 技術帶來的優點要大的多。從我的經驗上來講,我通常建議你們,除非你的項目特別依賴搜索引擎流量,或者對首屏時間有特殊的要求,不然不建議使用 SSR。
好,若是你確實遇到了 React 項目中要使用 SSR 的場景並決定使用 SSR,那麼接下來咱們就結合上面這張 SSR 架構圖,開啓 SSR 技術點的難點剖析。
在開始以前,咱們先來分析下虛擬 DOM 和 SSR 的關係。
上面咱們說過,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 文件後,要根據當前的路徑,在瀏覽器上再判斷當前要展現的組件,從新進行一次客戶端渲染,這個時候,還要經歷一次客戶端路由(前端路由)。
那麼,咱們下面要說的就是服務器端路由和客戶端路由的區別。
實現 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 應用中實現,就不是這麼簡單了。
客戶端渲染中,異步數據結合 Redux 的使用方式遵循下面的流程(對應圖中第 12 步):
而在服務器端,頁面一旦肯定內容,就沒有辦法 Rerender 了,這就要求組件顯示的時候,就要把 Store 的數據都準備好,因此服務器端異步數據結合 Redux 的使用方式,流程是下面的樣子(對應圖中第 4 步):
下面,咱們分析下服務器端渲染這部分的流程:
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 方法,就可以獲取到路由所需的全部數據內容了。
// 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 中的緣由,能夠避免服務器端獲取數據和客戶端獲取數據的衝突。
上一部分咱們說到了獲取數據的問題,在 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 框架,代碼放在這裏:
初學者結合上面的流程圖,一步步梳理流程圖中的邏輯,梳理結束後,回來再看一遍這篇文章,相信你們就豁然開朗了。
固然在真正實現 SSR 架構的過程當中,難點有時不是實現的思路,而是細節的處理。好比說如何針對不一樣頁面設置不一樣的 title 和 description 來提高 SEO 效果,這時候,咱們其實能夠用 react-helmet 這樣的工具幫咱們達成目標,這個工具對客戶端和服務器端渲染的效果都很棒,值得推薦。還有一些諸如工程目錄的設計,404,301 重定向狀況的處理等等,不過這些問題,咱們只須要在實踐中遇到的時候逐個攻破就能夠了。
好了,關於 SSR 的所有分享就到這裏,但願這篇文章可以或多或少幫助到你。
文章可隨意轉載,但請保留此 原文連接。 很是歡迎有激情的你加入 ES2049 Studio,簡歷請發送至 caijun.hcj(at)alibaba-inc.com 。