年前由於工做緣由須要對原有 React 項目進行服務端渲染的改造,下面是我對以前工做經驗的一些總結分享,但願能夠對你們有所幫助。javascript
首先咱們來了解一下 SSR 能夠作什麼,能夠解決什麼問題,誕生的緣由又是什麼。接下來是它到底長什麼樣子,最而後再是怎麼作。須要提早明確的一點是 SSR 並非萬能的,它有它的優缺點和具體的適用場景,咱們先來看一下它誕生的歷史背景。css
現現在 SPA 單頁面應用已成爲主流,相關的開發工具和 MVVM 框架爲前端的開發帶來了便利和無限的可能性。咱們在體驗着 SPA 頁面開發便捷性的同時,隨着業務的發展下面的兩個問題也會逐漸暴露出來,並且是在原有模式下很難解決的。html
首屏白屏時間過長。 在常規 SPA 的頁面渲染流程中,首先要加載 HTML 文件,以後要下載頁面所需的 JavaScript 文件,而後 JavaScript 文件渲染生成頁面, 若是有涉及到數據請求,那麼這個耗時將會更加漫長,尤爲是在弱網環境下,體驗很是糟糕。前端
SEO 能力較弱。 由於目前大多數搜索引擎主要識別的內容仍是 HTML,對 JavaScript 文件內容的識別都還比較弱,因此很難在搜索引擎中有較好的排名。java
SSR 技術隨之應運而生,SSR 全稱 Server Side Rendering 。以 React 爲例,首先咱們讓 React 代碼在服務器端先執行一次,使得用戶下載的 HTML 已經包含了全部的頁面展現內容,同時,因爲 HTML 中已經包含了網頁的全部內容,因此網頁的 SEO 效果也會變的很是好。以後,咱們讓 React 代碼在客戶端再次執行,爲 HTML 網頁中的內容添加數據及事件的綁定,頁面就具有了 React 的各類交互能力,可參考下圖。node
服務端: renderToString() | ReactDOMServer.renderToNodeStream()react
生成帶有標記的 html 文檔結構webpack
客戶端: ReactDOM.hydrate()git
根據服務端攜帶的標記更新 React 組件樹,並附加事件響應es6
技術改形成本相對較高,node 服務器端的資源前端不太好駕馭。
因此我我的的建議,是要慎重評估改造的成本和收益,不推薦在生產項目中直接使用
骨架屏 Skeleton
預渲染 Pre-render
這項技術主要用來解決 SEO 的問題,適用於短期內不會產生頻繁變更的網頁。可在服務器端判斷 UA,針對爬蟲單獨返回提早手動抓取好的 html 內容。
next.js (新的項目)
Jquery (交互較少的頁面)
SSR Project
├─build
| ├─client
| ├─server
| └assets.json
├─node_modules
├─public //公共資源
├─components
├─webpack //打包配置
| ├─webpack.config.js
| ├─webpack.client.config.js
| └webpack.server.config.js
├─server //服務端代碼
| ├─App.jsx
| ├─router.js
| └index.js
├─src //客戶端代碼
| ├─pages
| ├─App.jsx
| ├─router.js
| └index.js
├─index.html
├─server.js //服務端入口文件
├─package.json
複製代碼
如今咱們來從頭搭建一個 React 服務端的渲染環境。先來看一下最終結果,下面是一份服務端返回 HTML 的 template 頁面示例
<!DOCTYPE html>
<html lang="cn">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,height=device-height,maximum-scale=1.0,user-scalable=no;" />
<link href="/main.css" rel="stylesheet" />
</head>
<body>
<div id="root">
{serverContent}
</div>
<script type="text/javascript" src="/bundle.js"></script>
</body>
</html>
複製代碼
其實對比原有項目改動很簡單,就是把 root 節點內的 html 內容提早在服務端渲染成字符串拼接到模板內。咱們採用 express 搭建 node 服務。
server/index.js
import React from 'react'
import { renderToString } from 'reactDOM/server'
import express from 'express'
import App from './App'
const app = express()
app.get('/', (req, res, next) => {
const serverContent = renderToString(<App/>)
const html = mixin(template,serverContent)
res.send(html)
})
// 導出啓動服務的函數供入口文件調用
export default startServer() => {
app.listen('3000')
return app
}
複製代碼
server.js
var startServer = require("./build/server/index.js");
startServer();
複製代碼
接下來咱們要修改 webpack.server.config.js, 將 server 端代碼編譯成能夠被 commonjs 模塊系統識別的代碼
{
...
input: path.resolve(__dirname, '..', 'server/index.js'),
output: path.resolve(__dirname, '..', 'build/server,'),
target: 'node',
...
}
複製代碼
這樣服務端基礎代碼就完成了,但這樣是遠遠不夠的,咱們還須要作一些其餘的處理。
接下來咱們處理靜態資源。生產環境的 js bundle 和 css file 都將會附帶哈希值,若是按照如今這樣簡單地在服務端模板內引入"/bundle.js"
是找不到文件的,正確的引入路徑應該是"/bundle_[hash].js"
。那麼下面咱們來套路如何處理哈希同步的問題,其次圖片資源咱們也但願不要重複生成兩份哈希。
這裏推薦使用 universal-webpack, 它經過幫咱們修改 webpack 配置的方式,幫咱們解決上述的問題。插件在打包時會在 build 目錄下生成 assets.json 資源定位文件,服務端咱們引入這個文件處理便可。
assets.json
interface Chunks {
javascript: {
[scriptname: string]: string;
};
styles: {
[scriptname: string]: string;
};
}
複製代碼
webpack.client.config.js
import { clientConfiguration } from 'universal-webpack'
const webpackConfig = {...}
return clientConfiguration(webpackConfig, {
chunk_info_filename: 'assets.json'
}, {
useMiniCssExtractPlugin : true
})
複製代碼
webpack.server.config.js
import { serverConfiguration } from 'universal-webpack'
const webpackConfig = {...}
return serverConfiguration(webpackConfig, {
// 默認第三方模塊都不打包,這裏須要配置不支持commonjs的第三方模塊
excludeFromExternals: [
'lodash-es',
/^some-other-es6-only-module(\/.*)?$/
],
// 這裏配置不須要重複打包的文件
loadExternalModuleFileExtensions: [
'css',
'png',
'jpg',
'svg',
'xml'
]
})
複製代碼
改造 server/index.js
const assets = require("../assets.json");
const js = Object.values(assets.javascript)
.map(item => <link rel="stylesheet" href="${item}" />)
.join("\n");
const css = Object.values(assets.styles)
.map(item => `<script src="${item}"></script>`)
.join("\n");
const html = mixin(template, {
js,
css,
serverContent
});
複製代碼
咱們須要在服務端根據請求的 url 渲染對應的組件,這裏和客戶端稍微有一些不太同樣。react-router 提供了 StaticRouter 組件用於服務端渲染,咱們能夠手動傳入請求的的 url 來進行路由定位。
server/index.js
app.get("/", (req, res, next) => {
...
// @override
// const serverContent = renderToString(<App/>)
const url = req.url; // "/home"
const serverContent = renderToString(<App url={url} />); ... }); 複製代碼
server/App.jsx
import React from 'react'
import { StaticRouter, Switch, Route } from 'react-router-dom'
import routes from './router.js'
export default App(props) => {
<StaticRouter location={props.url} context={{}}>
<Switch> {routes.map((route) => { <Route {...route} /> })} </Swtich> </StaticRouter>
}
複製代碼
咱們通常在 componentDidMount 生命週期執行獲取數據的方法,可是在服務端環境中生命週期是不完整的,只會執行 ComponentWillMount 以前的方法,因此咱們必須在渲染前準備好數據,而後經過 props 注入到組件中。
這裏咱們爲路由組件定義了一個 loadData 的鉤子函數,經過 react-router 提供的 matchPath 方法,能夠判斷當前須要渲染的頁面組件,並執行相應的 loadData 方法獲取數據,該方法返回一個 Promise 對象,以便咱們在數據獲取成功後異步執行渲染邏輯。
server/router.js
// 這裏能夠經過客戶端路由文件改造, 添加須要的loadData方法便可
const routes = {
path: "/",
component: Home,
// return a Promise
loadData: () => getSomeData()
};
複製代碼
server/index.js
import { matchPath } from 'react-router-dom'
app.get('/', (req, res, next) => {
...
const promise = Promise.resolve()
routes.find(route => {
const match = matchPath(req.url, route)
if(match) promise.then(route.loadData)
return match
})
promise.then((data) => {
const serverData = formatData(data)
// @override
// const serverContent = renderToString(<App url={url} />);
const serverContent = renderToString(<App url={req.url} data={serverData}/>) const html = mixin(template, {js, css, serverContent}) res.send(html) }) }) 複製代碼
server/App.jsx
export default App(props) => {
<StaticRouter location={props.url} context={{}}>
<Switch> {routes.map((route) => { <Route {...route} render={() => { const Component = route.component <Component data={props.data}/> }}/> })} </Swtich> </StaticRouter> } 複製代碼
若是使用 redux 管理同構的數據則會方便許多,這裏注意每個請求都須要從新生成一個新的 store,不然的話用戶狀態則會混亂。
server/App.jsx
export default App(props) => {
<Provider store={props.store}>
<StaticRouter location={props.url} context={{}}> <Switch> {routes.map((route) => { <Route {...route} /> })} </Swtich> </StaticRouter> </Provider>
}
複製代碼
server/index.js
...
promise.then((data) => {
...
const preloadedState = mixin(initData, data)
const store = createStore(reducers, preloadedState)
// @override
// const serverContent = renderToString(<App url={req.url} data={serverData}/>)
const serverContent = renderToString(<App url={req.url} store={store}/>) ... }) ... 複製代碼
至此爲止,服務器端最終會輸出一個帶有數據狀態的完整頁面。可是客戶端這邊從新渲染的時候,首先會渲染一個沒有數據的框架,而後纔會在 componentDidMount 裏發起數據接口請求數據,這意味着在這個過程期間客戶端都爲空數據狀態,在用戶看來就是表現爲會執行重複地 loading 。
因此咱們但願客戶端能夠共享服務端已經獲取的數據,個人解決辦法是在服務端將數據注入到 HTML 中返回給客戶端脫水(Dehydrate)。在瀏覽器端,客戶端再也不本身發起請求獲取數據處理狀態,直接使用脫水數據來初始化 React 組件注水 (Hydrate)
HTML 模板...
</div>
<script type="text/javascript" src="/bundle_[hash].js"></script>
<script>window.__initState__ = ${JSON.stringfy(store)}</script>
</body></html>
複製代碼
server/index.js
// @override
// const html = mixin(template, {js, css, serverContent})
const html = mixin(template, {js, css, serverContent, store})
})
複製代碼
客戶端初始化數據
store.js
const defaultState = JSON.parse(window.__initState__);
const store = createStore(reducer, defaultState);
複製代碼
window
和 document
等宿主對象,且會執行組件的 constructor
,componentWillReceiveProps
,render
生命週期,因此務必避免代碼中的此類調用。能夠經過 typeof window
或 webpack.definePlugin
來對客戶端和服務端作區分按需加載
HMR
服務端性能監控&&調優