React應用同構能夠達到更好的用戶體驗
以及SEO
,可是過程也較爲繁雜,代碼難以解耦,至於項目是否須要同構我認爲就看對應的場景及投入和產出了,本文不對此作贅述css
關於同構(Isomorphic),網上說法頗多,我我的認爲是一份代碼,服務端進行渲染,拼接html及初始化數據,瀏覽器端拿到html及js後在瀏覽器插入初始化的數據和掛載相關監聽事件html
關於isomorphic,也有人認爲應該爲Universal Rendering(看自知乎),本文將均使用"同構"一詞。
前端
此文將記錄本人React應用———一個我的博客
進行同構的實踐、SSR效果,以及過程當中所遇到的問題及解決方案,此連接爲同構後上線的我的博客周立涵的博客
node
在參考大量的文章以後作出了這次改寫,React的生態確實繁榮,方案頗多,這次實踐採用的方案是同構
,同時寫了兩套webpack,即一套React代碼,兩套webpack配置(分別針對瀏覽器端與服務端),服務端使用express進行渲染,夾帶瀏覽器端js代碼返回前端
,本質上仍是一套代碼
因爲是首次改寫同構,有建議之處還請大佬指出
此文將按如下順序進展:react
爲單純對比性能,排除網絡環境影響,測試在本地開發環境下進行
同構前
webpack
FP | FCP | FMP | DCL | L |
---|---|---|---|---|
1766.5ms | 1766.5ms | 1950.1ms | 1706.1ms | 4400.2.5ms |
FP | FCP | FMP | DCL | L |
---|---|---|---|---|
403.8ms | 403.8ms | 403.8ms | 1343.5ms | 2442.5ms |
同構先後FMP下降了1546.3ms!!!
同時也肉眼可見的首屏速度有顯著的提高ios
在不一樣環境下測試的結果會有差別,但基本每次測試,同構後都比同構前的FMP下降了1000+ms!
git
React 同構簡單地說便是在服務端renderToString拼接HTML字符串,請求初始化數據,返回瀏覽器HTML及相關靜態文件(如js、css等等),在瀏覽器端執行React代碼,插入初始化數據及掛載相關的監聽事件(hydrate)
此處強烈推薦此文,很是清晰地闡述了SSRServer-Side Rendering with React, Redux, and React-Router,下圖也摘自文中
github
本博客應用使用了React + React Router + Ant Design + CSS Module
同構前的React應用結構以下web
│ .babelrc
│ package-lock.json
│ package.json
│ postcss.config.js
│ webpack.common.js
│ webpack.dev.js //開發環境webpack
│ webpack.prod.js //生產環境webpack
│
├─public
│ favicon.ico
│ index.html
└─src
│ App.css
│ App.jsx
│ App.module.css
│ index.css
│ index.js //瀏覽器端入口文件
│
├─components //組件目錄
│
├─container //應用視圖目錄
│
└─source //項目靜態資源
複製代碼
同構前經過src/index.js
進行瀏覽器端的掛載
配合express
進行同構後目錄以下
│ .babelrc
│ package-lock.json
│ package.json
│ postcss.config.js
│ server.js //編譯後的服務端代碼
│ webpack.common.js
│ webpack.dev.js
│ webpack.prod.js //瀏覽器端webpack
│ webpack.server.js //服務端webpack
│
├─dist //編譯後的客戶端代碼
│
│
└─src //項目源碼
├─browser //瀏覽器端入口文件
│ index.js
│
├─server //服務端入口文件
│ index.js
│
└─shared //共享目錄
│ App.css
│ App.jsx
│ App.module.css
│ index.css
│ routesConfig.js
│
├─components
│
├─container
│
└─source
複製代碼
同構後,瀏覽器訪問express
接口,express返回html字符串及編譯後的客戶端代碼(dist目錄下的靜態資源),瀏覽器端掛載事件及初始化數據
目錄結構的文件關係能夠以下表示:
由於每次訪問端口都依賴於靜態資源,因此每次修改代碼查看效果都須要webpack從新編譯,開發過程當中相對麻煩。
目前的想法是經過配置webpackDevServe在本地服務器上打通資源訪問,以支持熱更新,後續再研究
本應用中將dist做爲靜態資源目錄,其中包含webpack打包後的js、css,lessc編譯後的css文件等等
綜上,本項目使用了express + React + React Router + Ant Design + CSS Module,再加上一些簡單的邏輯代碼(涉及DOM的懶加載、數據請求)
我同構的步驟及解決的問題以下:
接下來將按照順序闡述步驟原因,及採用了什麼方案
js能夠跑在瀏覽器和node上,可是也存在些許的環境差別
(好比是否有DOM),因此須要針對服務端(即node)進行webpack的配置
本項目主要針對瞭如下內容進行配置
webpack target屬性——說明打包後的js運行環境爲node而不是瀏覽器
ESM——node(v10.15.3)原生不支持import及export
JSX
CSS Module——使得在node端完成對className的hash
前三項內容能夠說是必須項,最後一項得看對應項目技術棧
對於說明打包後的js運行環境爲node,直接在webpack配置中設置target
屬性便可:
...
entry: "./src/server/index.js",
target: "node",
...
複製代碼
對於ESM、JSX及CSS Module,node端同瀏覽器端同樣根據須要配置webpack module
咱們經過訪問express的API,返回對應的html,在此寫出server/index.js的基本結構
import express from "express";
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom";
import React from "react";
import App from "../shared/App";
const app = express();
app.use(
express.static("dist", {
index: false
})
);//指定端口使用的靜態資源目錄,dist目錄下放的是打包後的客戶端靜態資源;index爲false表示關閉index.**文件索引
app.get("路徑", (req,res)=>{
/** * 客戶端訪問接口,接口返回html **/
const reactComhtml = ...;//react元素獲取html字符串,reactComhtml變量值見下文
const theHtml = ...;//拼接成完整的html,theHtml變量值見下文
res.setHeader("Content-Type", "text/html");//設置響應頭
res.send(theHtml); //返回html
});
複製代碼
這裏介紹兩個API,renderToString與hydrate
renderToString在服務端將React元素渲染爲初始的HTML
// server/index.js
const reactComHtml = ReactDOMServer.renderToString(
<StaticRouter> <App reqPathname={req.path} /> </StaticRouter> ); const theHtml = ` <html> <head> ... </head> <body> <div id="App">${reactComHtml}</div> //拼接上html字符串 <script src="{...靜態資源目錄}/main.js" charset="utf-8"></script> //攜帶webpack打包後的瀏覽器端靜態資源,好比js文件 </body> </html> `; 複製代碼
hydrate在瀏覽器端對存在元素掛載監聽事件
。React但願服務端渲染的內容與瀏覽器渲染的內容一致。同時,React能夠對文字內容
的不一致進行修補,但咱們最好不要人爲地引入不一致,並且這一修補機制並不能保證徹底正確。在開發環境下,若發現內容不一致,React會發出警告,好比Warning: Expected server HTML to contain a matching <div> in <div>.
。固然,形成這一警告的緣由也有不少,好比本應用中使用了react-markdown
組件,對markdown文本進行了處理,使得服務端與客戶端部份內容不一致,開發環境下就拋出了警告,關於hydrate更多可查看官網說明hydrate
// brpwser/index.js
const app = document.getElementById("App");
app? ReactDOM.hydrate(
<BrowserRouter> <App /> </BrowserRouter>,app) : false;
複製代碼
本項目使用了Ant Design,而且配置了babel-plugin-import進行按需加載,因此若進行定製主題,則須要將babel-plugin-import的style
配置改成true,可是這就致使了antd樣式沒有做爲單獨的CSS文件抽取出來,而是經過js運行,在DOM中插入<style>
標籤造成樣式。
這在CSR中是沒有問題的,由於在插入<style>標籤以前用戶看不到網頁內容,可是如今用戶打開網頁第一眼就能看到完整的HTML內容,js運行後才插入<style>樣式,就會形成頁面樣式「突變」的效果。
因而我將babel-plugin-import中的style
的值設置爲false
,樣式將不會經過js生成style標籤插入。
對於css樣式,antd使用less
做爲開發語言,其中定義了一些樣式變量,能夠使用lessc生成.css文件
,做爲靜態資源使用
//好比此處修改了兩個樣式變量@primary-color與@link-hover-color
lessc --js --modify-var=@primary-color=#ffc34e --modify-var=@link-hover-color=#ffc34e ./node_modules/antd/dist/antd.less > dist/ant.css
複製代碼
其中dist爲靜態資源目錄
至此,自定義的樣式文件在返回的html中引用便可
const theHtml = ` <html> <head> <title>${title}</title> <link rel="shortcut icon" href="${...靜態資源目錄}/favicon.ico"> <link rel="stylesheet" type="text/css" href="${...靜態資源目錄}/ant.css"> //引用antd樣式文件 <link rel="stylesheet" type="text/css" href="${...靜態資源目錄}/main.css"> ... </head> <body> <div id="App">${reactComHtml}</div> <script src="${...靜態資源目錄}/main.js" charset="utf-8"></script> </body> </html> `;
複製代碼
在CSR下,路由交給前端控制,後端沒有對應的路由,因此若是前端進入了某個路徑,刷新了網頁,後臺會返回404。舉個例子:
某項目爲CSR,使用了react-router,主頁爲www.somebody.com,咱們瀏覽器進入了www.somebody.com/test,此時刷新,或者將此連接分享給好友,返回的都會是404,由於後臺對應路徑下並不存在html文件或者服務。StackOverflow上對此有多種解決方案
同構也能解決此問題。
在服務端,咱們使用進行路由,瀏覽器一樣是使用
// server/index.js
const reactComHtml = renderToString(
<StaticRouter location={req.url}> <App /> </StaticRouter>
);
const theHtml = ` <html> <head> <title>Home</title> <link rel="shortcut icon" href="${...靜態資源目錄}/favicon.ico"> <link rel="stylesheet" type="text/css" href="${...靜態資源目錄}/ant.css"> <link rel="stylesheet" type="text/css" href="${...靜態資源目錄}/main.css"> ... </head> <body> <div id="App">${reactComHtml}</div> <script src="/main.js" charset="utf-8"></script> </body> </html> `;
// browser/index.js
const app = document.getElementById("App");
app ? ReactDOM.hydrate(
<BrowserRouter> <App /> </BrowserRouter>,app): false;
複製代碼
經過StaticRouter的location
屬性填入路徑,咱們能得到對應的路由html,拼接成html返回給瀏覽器
先後端分離下前端基本經過ajax請求向後端拉取數據,而SSR下咱們但願返回的html包含初始數據。
那這裏有兩個問題:a.如何在服務端請求數據?b.如何將數據進行呈現?
一樣是經過Ajax向端口請求數據,可是要注意異步與環境問題。
對於異步,當須要請求多個接口
時,須要作好對異步的處理,保證能將請求的數據傳給html,好比可使用Promise進行處理
對於環境問題,要注意使用的Ajax庫必須能跑在node上,而不是隻能跑在瀏覽器環境上。我在這裏使用的是axios
app.get("路徑",(req, res)=>{
axios.get(url)//ajax請求接口
.then((response)=>{//獲取數據
const data = response.data;
const context = data;
//如何呈現見下文
const reactComHtml = ...;
const theHtml = ...;
res.setHeader("Content-Type", "text/html");
res.send(theHtml);
})
})
複製代碼
如何將數據進行呈現有很是多的方案,此處本人的方案是StaticRouter的context屬性 + 自定義window.__ROUTE_DATE__屬性
進行(這裏的__ROUTE_DATA__屬性是自定義的,你可使用任何與window對象不衝突的屬性)
補充上文的代碼
// server/index.js
const reactComHtml = renderToString(
<StaticRouter location={req.url} context={context}>
<App reqPathname={req.path} />
</StaticRouter>
);
const theHtml = `
<html>
<head>
<title>${title}</title>
<link rel="shortcut icon" href="${...靜態資源目錄}/favicon.ico">
<link rel="stylesheet" type="text/css" href="${...靜態資源目錄}/ant.css">
<link rel="stylesheet" type="text/css" href="${...靜態資源目錄}/main.css">
<script>window.__ROUTE_DATA__ = ${JSON.stringify(context)}</script> //以字符串的形式傳遞給html,在瀏覽器端使用此數據
</head>
<body>
<div id="App">${reactComHtml}</div>
<script src="${...靜態資源目錄}/main.js" charset="utf-8"></script>
</body>
</html>
`;
// 舉例:shared/container/Article/index.jsx /article/:articleId路由的對應組件
class Article extends Component {
constructor(props) {
super(props);
const staticContext = props.staticContext;
if (staticContext) {
//S端運行
const { title, content, time, tag } = staticContext;
this.state = {
title: title,
content: content,
time: time,
tag: tag
};
} else {
//B端運行
const data = window.__ROUTE_DATA__;
this.state = {
content: data.content,
title: data.title,
time: data.time,
tag: data.tag
};
delete window.__ROUTE_DATA__;
}
}
...
...
複製代碼
運行於服務端,props.staticContext也僅存於服務端
服務端上經過staticContext取值,renderToString輸出HTML字符串
瀏覽器端上經過window.__ROUTE_DATA__取值,hydrate對已存在的元素掛載監聽事件
因爲node上不存在DOM、Window、Storage對象,因此須要進行判斷處理,本應用舉例對DOM的處理
封裝函數判斷是否在瀏覽器環境
const isBrowser = ()=>{
return typeof window !== "undefined";
}
export default isBrowser;
複製代碼
在對應的涉及到DOM的代碼中,經過isBrowser判斷當前是否爲瀏覽器環境,是則執行相應操做,不是則不執行
constructor(props) {
super(props);
//舉例
if (isBrowser() === true) {
this.handleDom = this.handleDom.bind(this);
this.handleDom();
}
}
複製代碼
通過這次實踐後,對同構、服務端渲染、node、React、react router等有了更清楚的認識和了解,也着實體會到了SSR帶來的首屏速度提高。可是同構過程也相對繁瑣,本次改寫僅僅是一個很是簡單的博客應用,也暫時不存在性能問題,如果複雜應用則一定會是大工程。
SSR帶來的好處就是SEO
及首屏速度的提高
,對原應用進行同構的過程也着實繁瑣。現階段,需不須要將CSR改成SSR私覺得得看具體場景,得看投入和產出是否值得。
將來,隨着瀏覽器的升級、搜索引擎爬蟲的進步、網絡的提高,是否能直接解決CSR在SEO和首屏速度的問題呢?但是這將來會不會特別遠呢?會不會又有新的方案解決呢?將來可真是使人好奇
以上有不當之處請大佬指出。
本次學習參考了比較多的文章,不少實現方法各有不一樣,也存在着對應的能夠優化的問題。
什麼是前端同構——知乎
Server-Side Rendering with React, Redux, and React-Router(強烈推薦此文)
User-centric Performance Metrics(包含性能指標闡述)
Ant Design定製主題
react-router官網——Server Rendering
Stack Overflow React-router urls don't work when refreshing or writing manually
An Introduction to React Server-Side Rendering
Using React Router 4 with Server-Side Rendering(瀏覽器端上,數據初始化放在了不是特別恰當的生命週期)
YouTube——ReactCasts #12 - Server Side Rendering
React Server Side Rendering with Express(比較推薦的入門實踐文章,可是中間多了沒必要要的大鬍子語法)
Tips for server-side rendering with React