服務端渲染(SSR: Server Side Rendering)在React項目中有著廣泛的應用場景 基於React虛擬DOM的特性,在瀏覽器端和服務端我們可以實現同構(可以使用同一份代碼來實現多端的功能) 服務端渲染的優點主要由三點 1. 利於SEO 2. 提高首屏渲染速度 3. 同構直出,使用 ...
服務端渲染(SSR: Server Side Rendering)在React項目中有著廣泛的應用場景
基於React虛擬DOM的特性,在瀏覽器端和服務端我們可以實現同構(可以使用同一份代碼來實現多端的功能)
服務端渲染的優點主要由三點
1. 利於SEO
2. 提高首屏渲染速度
3. 同構直出,使用同一份(JS)代碼實現,便於開發和維護
一起看看如何在實際的項目中實現服務端渲染
項目地址 ,歡迎圍觀!
使用 webpack 監聽編譯文件,nodemon 監聽伺服器文件變動
使用 redux-saga 處理非同步action,使用 express 處理頁面渲染
本項目包含四個頁面,四種組合,滿滿的乾貨,文字可能說不清楚,就去看代碼吧!
一、React
實現一個最基本的React組件,就能搞掂第一個頁面了
/** * 消息列表 */ class Message extends Component { constructor(props) { super(props); this.state = { msgs: [] }; } componentDidMount() { setTimeout(() => { this.setState({ msgs: [{ id: '1', content: '我是消息我是消息我是消息', time: '2018-11-23 12:33:44', userName: '王羲之' }, { id: '2', content: '我是消息我是消息我是消息2', time: '2018-11-23 12:33:45', userName: '王博之' }, { id: '3', content: '我是消息我是消息我是消息3', time: '2018-11-23 12:33:44', userName: '王安石' }, { id: '4', content: '我是消息我是消息我是消息45', time: '2018-11-23 12:33:45', userName: '王明' }] }); }, 1000); } // 消息已閱 msgRead(id, e) { let msgs = this.state.msgs; let itemIndex = msgs.findIndex(item => item.id === id); if (itemIndex !== -1) { msgs.splice(itemIndex, 1); this.setState({ msgs }); } } render() { return ( <div> <h4>消息列表</h4> <div className="msg-items"> { this.state.msgs.map(item => { return ( <div key={item.id} className="msg-item"> <p className="msg-item__header">{item.userName} - {item.time}</p> <p className="msg-item__content">{item.content}</p> <a href="javascript:;" className="msg-item__read" onClick={this.msgRead.bind(this, item.id)}>×</a> </div> ) }) } </div> </div> ) } } render(<Message />, document.getElementById('content'));
是不是很簡單,代碼比較簡單就不說了
來看看頁面效果
可以看到頁面白屏時間比較長
這裡有兩個白屏
1. 載入完JS後才初始化標題
2. 進行非同步請求數據,再將消息列表渲染
看起來是停頓地比較久的,那麼使用服務端渲染有什麼效果呢?
二. React + SSR
在講如何實現之前,先看看最終效果
可以看到頁面是直出的,沒有停頓
在React 15中,實現服務端渲染主要靠的是 ReactDOMServer 的 renderToString 和 renderToStaticMarkup方法。
let ReactDOMServer = require('react-dom/server'); ReactDOMServer.renderToString(<Message preloadState={preloadState} />) ReactDOMServer.renderToStaticMarkup(<Message preloadState={preloadState} />)
將組件直接在服務端處理為字元串,我們根據傳入的初始狀態值,在服務端進行組件的初始化
然後在Node環境中返回,比如在Express框架中,返回渲染一個模板文件
res.render('messageClient/message.html', { appHtml: appHtml, preloadState: JSON.stringify(preloadState).replace(/</g, '\\u003c') });
這裡設置了兩個變數傳遞給模板文件
appHtml 即為處理之後的組件字元串
preloadState 為伺服器中的初始狀態,瀏覽器的後續工作要基於這個初始狀態,所以需要將此變數傳遞給瀏覽器初始化
<div id="content"> <|- appHtml |> </div> <script id="preload-state"> var PRELOAD_STATE = <|- preloadState |> </script>
express框架返回之後即為在瀏覽器中看到的初始頁面
需要註意的是這裡的ejs模板進行了自定義分隔符,因為webpack在進行編譯時,HtmlWebpackPlugin 插件中自帶的ejs處理器可能會和這個模板中的ejs變數衝突
在express中自定義即可
// 自定義ejs模板 app.engine('html', ejs.__express); app.set('view engine', 'html'); ejs.delimiter = '|';
接下來,在瀏覽器環境的組件中(以下這個文件為公共文件,瀏覽器端和伺服器端共用),我們要按照 PRELOAD_STATE 這個初始狀態來初始化組件
class Message extends Component { constructor(props) { super(props); this.state = { msg: [] }; // 根據伺服器返回的初始狀態來初始化 if (typeof PRELOAD_STATE !== 'undefined') { this.state.msgs = PRELOAD_STATE; // 清除 PRELOAD_STATE = null; document.getElementById('preload-state').remove(); } // 此文件為公共文件,服務端調用此組件時會傳入初始的狀態preloadState else { this.state.msgs = this.props.preloadState; } console.log(this.state); } componentDidMount() { // 此處無需再發請求,由伺服器處理 } ...
核心就是這些了,這就完了麽?
哪有那麼快,還得知道如何編譯文件(JSX並不是原生支持的),服務端如何處理,瀏覽器端如何處理
接下來看看項目的文件結構
把註意力集中到紅框中
直接由webpack.config.js同時編譯瀏覽器端和服務端的JS模塊
module.exports = [
clientConfig,
serverConfig
];
瀏覽器端的配置使用 src 下的 client目錄,編譯到 dist 目錄中
服務端的配置使用 src 下的 server 目錄,編譯到 distSSR 目錄中。在服務端的配置中就不需要進行css文件提取等無關的處理的,關註編譯代碼初始化組件狀態即可
另外,服務端的配置的ibraryTarget記得使用 'commonjs2',才能為Node環境所識別
// 文件輸出配置 output: { // 輸出所在目錄 path: path.resolve(__dirname, '../public/static/distSSR/js/'), filename: '[name].js', library: 'node', libraryTarget: 'commonjs2' },
client和server只是入口,它們的公共部分在 common 目錄中
在client中,直接渲染導入的組件
import React, {Component} from 'react'; import {render, hydrate, findDOMNode} from 'react-dom'; import Message from '../common/message'; hydrate(<Message />, document.getElementById('content'));
這裡有個 render和hydrate的區別
在進行了服務端渲染之後,瀏覽器端使用render的話會按照狀態重新初始化一遍組件,可能會有抖動的情況;使用 hydrate則只進行組件事件的初始化,組件不會從頭初始化狀態
建議使用hydrate方法,在React17中 使用了服務端渲染之後,render將不再支持
在 server中,導出這個組件給 express框架調用
import Message from '../common/message'; let ReactDOMServer = require('react-dom/server'); /** * 提供給Node環境調用,傳入初始狀態 * @param {[type]} preloadState [description] * @return {[type]} [description] */ export function init(preloadState) { return ReactDOMServer.renderToString(<Message preloadState={preloadState} />); };
需要註意的是,這裡不能直接使用 module.exports = ... 因為webpack不支持ES6的 import 和這個混用
在 common中,處理一些瀏覽器端和伺服器端的差異,再導出
這裡的差異主要是變數的使用問題,在Node中沒有window document navigator 等對象,直接使用會報錯。且Node中的嚴格模式直接訪問未定義的變數也會報錯
所以需要用typeof 進行變數檢測,項目中引用的第三方插件組件有使用到了這些瀏覽器環境對象的,要註意做好相容,最簡便的方法是在 componentDidMount 中再引入這些插件組件
另外,webpack的style-loader也依賴了這些對象,在伺服器配置文件中需要將其移除
{ test: /\.css$/, loaders: [ // 'style-loader', 'happypack/loader?id=css' ] }
在Express的伺服器框架中,messageSSR 路由 渲染頁面之前做一些非同步操作獲取數據
// 編譯後的文件路徑 let distPath = '../../public/static/distSSR/js'; module.exports = function(req, res, next) { // 如果需要id let id = 'req.params.id'; console.log(id); getDefaultData(id); async function getDefaultData(id) { let appHtml = ''; let preloadState = await getData(id); console.log('preloadState', preloadState); try { // 獲取組件的值(字元串) appHtml = require(`${distPath}/message`).init(preloadState); } catch(e) { console.log(e); console.trace(); } res.render('messageClient/message.html', { appHtml: appHtml, preloadState: JSON.stringify(preloadState).replace(/</g, '\\u003c') }); } };
使用到Node來開啟服務,每次改了伺服器文件之後就得重啟比較麻煩
使用 nodemon工具來監聽文件修改自動更新伺服器,添加配置文件 nodemon.json
{ "restartable": "rs", "ignore": [ ".git", "node_modules/**/node_modules" ], "verbose": true, "execMap": { "js": "node --harmony" }, "watch": [ "server/", "public/static/distSSR" ], "env": { "NODE_ENV": "development" }, "ext": "js,json" }
當然,對於Node環境不支持JSX這個問題,除了使用webpack進行編譯之外,
還可以在Node中執行 babel-node 來即時地編譯文件,不過這種方式會導致每次編譯非常久(至少比webpack久)
在React16 中,ReactDOMServer 除了擁有 renderToString 和 renderToStaticMarkup這兩個方法之外,
還有 renderToNodeStream 和 renderToStaticNodeStream 兩個流的方法
它們不是返回一個字元串,而是返回一個可讀流,一個用於發送位元組流的對象的Node Stream類
渲染到流可以減少你的內容的第一個位元組(TTFB)的時間,在文檔的下一部分生成之前,將文檔的開頭至結尾發送到瀏覽器。 當內容從伺服器流式傳輸時,瀏覽器將開始解析HTML文檔
以下是使用實例,本文不展開
// using Express import { renderToNodeStream } from "react-dom/server" import MyPage from "./MyPage" app.get("/", (req, res) => { res.write("<!DOCTYPE html><html><head><title>My Page</title></head><body>"); res.write("<div id='content'>"); const stream = renderToNodeStream(<MyPage/>); stream.pipe(res, { end: false }); stream.on('end', () => { res.write("</div></body></html>"); res.end(); }); });
這便是在React中進行服務端渲染的流程了,說得有點泛泛,還是自己去看 項目代碼 吧
三、React + Redux
React的中的數據是單向流動的,即父組件狀態改變之後,可以通過props將屬性傳遞給子組件,但子組件並不能直接修改父級的組件。
一般需要通過調用父組件傳來的回調函數來間接地修改父級狀態,或者使用 Context ,使用 事件發佈訂閱機制等。
引入了Redux進行狀態管理之後,就方便一些了。不過會增加代碼複雜度,另外要註意的是,React 16的新的Context特性貌似給Redux帶來了不少衝擊
在React項目中使用Redux,當某個處理有比較多邏輯時,遵循胖action瘦reducer,比較通用的建議時將主要邏輯放在action中,在reducer中只進行更新state的等簡單的操作
一般還需要中間件來處理非同步的動作(action),比較常見的有四種 redux-thunk redux-saga redux-promise redux-observable ,它們的對比
這裡選用了 redux-saga,它比較優雅,管理非同步也很有優勢
來看看項目結構
我們將 home組件拆分出幾個子組件便於維護,也便於和Redux進行關聯
home.js 為入口文件
使用 Provider 包裝組件,傳入store狀態渲染組件
import React, {Component} from 'react'; import {render, findDOMNode} from 'react-dom'; import {Provider} from 'react-redux'; // 組件入口 import Home from './homeComponent/Home.jsx'; import store from './store'; /** * 組裝Redux應用 */ class App extends Component { render() { return ( <Provider store={store}> <Home /> </Provider> ) } } render(<App />, document.getElementById('content'));
store/index.js 中為狀態創建的過程
這裡為了方便,就把服務端渲染的部分也放在一起了,實際上它們的區別不是很大,僅僅是 defaultState初始狀態的不同而已
import {createStore, applyMiddleware, compose} from 'redux'; import createSagaMiddleware from 'redux-saga'; // import {thunk} from 'redux-thunk'; import reducers from './reducers'; import wordListSaga from './workListSaga'; import state from './state'; const sagaMiddleware = createSagaMiddleware(); const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; let defaultState = state; // 用於SSR // 根據伺服器返回的初始狀態來初始化 if (typeof PRELOAD_STATE !== 'undefined') { defaultState = Object.assign({}, defaultState, PRELOAD_STATE); // 清除 PRELOAD_STATE = null; document.getElementById('preload-state').remove(); } let store = createStore( reducers, defaultState, composeEnhancers( applyMiddleware(sagaMiddleware) )); sagaMiddleware.run(wordListSaga); export default store;
我們將一部分action(基本是非同步的)交給saga處理
在workListSaga.js中,
1 import {delay} from 'redux-saga'; 2 import {put, fork, takeEvery, takeLatest, call, all, select} from 'redux-saga/effects'; 3 4 import * as actionTypes from './types'; 5 6 /** 7 * 獲取用戶信息 8 * @yield {[type]} [description] 9 */ 10 function* getUserInfoHandle() { 11 let state = yield select(); 12 13 return yield new Promise((resolve, reject) => { 14 setTimeout(() => { 15 resolve({ 16 sex: 'male', 17 age: 18, 18 name: '王羲之', 19 avatar: '/public/static/imgs/avatar.png' 20 }); 21 }, 500); 22 }); 23 } 24 25 /** 26 * 獲取工作列表 27 * @yield {[type]} [description] 28 */ 29 function* getWorkListHandle() { 30 let state = yield select(); 31 32 return yield new Promise((resolve, reject) => { 33 setTimeout(() => { 34 resolve({ 35 todo: [{ 36 id: '1', 37 content: '跑步' 38 }, { 39 id: '2', 40 content: '游泳' 41 }], 42 43 done: [{ 44 id: '13', 45 content: '看書' 46 }, { 47 id: '24', 48 content: '寫代碼' 49 }] 50 }); 51 }, 1000); 52 }); 53 } 54 55 /** 56 * 獲取頁面數據,action.payload中如果為回調,可以處理一些非同步數據初始化之後的操作 57 * @param {[type]} action [description] 58 * @yield {[type]} [description] 59 */ 60 function* getPageInfoAsync(action) { 61 console.log(action); 62 63 let userInfo = yield call(getUserInfoHandle); 64 65 yield put({ 66 type: actionTypes.INIT_USER_INFO, 67 payload: userInfo 68 }); 69 70 let workList = yield call(getWorkListHandle); 71 72 yield put({ 73 type: actionTypes.INIT_WORK_LIST, 74 payload: workList 75 }); 76 77 console.log('saga done'); 78 79 typeof action.payload === 'function' && action.payload(); 80 } 81 82 /** 83 * 獲取頁面數據 84 * @yield {[type]} [description] 85 */ 86 export default function* getPageInfo() { 87 yield takeLatest(actionTypes.INIT_PAGE, getPageInfoAsync); 88 }View Code
監聽頁面的初始化action actionTypes.INIT_PAGE ,獲取數據之後再觸發一個action ,轉交給reducer即可
let userInfo = yield call(getUserInfoHandle);
yield put({
type: actionTypes.INIT_USER_INFO,
payload: userInfo
});
reducer中做的事主要是更新狀態,
import * as actionTypes from './types'; import defaultState from './state'; /** * 工作列表處理 * @param {[type]} state [description] * @param {[type]} action [description] * @return {[type]} [description] */ function workListReducer(state = defaultState, action) { switch (action.type) { // 初始化用戶信息 case actionTypes.INIT_USER_INFO: // 返回新的狀態 return Object.assign({}, state, { userInfo: action.payload }); // 初始化工作列表 case actionTypes.INIT_WORK_LIST: return Object.assign({}, state, { todo: action.payload.todo, done: action.payload.done }); // 添加任務 case actionTypes.ADD_WORK_TODO: return Object.assign({}, state, { todo: action.payload }); // 設置任務完成 case actionTypes.SET_WORK_DONE: return Object.assign({}, state, { todo: action.payload.todo, done: action.payload.done }); default: return state } }
在 action.js中可以定義一些常規的action,比如
export function addWorkTodo(todoList, content) { let id = Math.random(); let todo = [...todoList, { id, content }]; return { type: actionTypes.ADD_WORK_TODO, payload: todo } } /** * 初始化頁面信息 * 此action為redux-saga所監聽,將傳入saga中執行 */ export function initPage(cb) { console.log(122) return { type: actionTypes.INIT_PAGE, payload: cb }; }
回到剛纔的 home.js入口文件,在其引入的主模塊 home.jsx中,我們需要將redux的東西和這個 home.jsx綁定起來
import {connect} from 'react-redux'; // 子組件 import User from './user'; import WorkList from './workList'; import {getUrlParam} from '../util/util' import '../../scss/home.scss'; import { initPage } from '../store/actions.js'; /** * 將redux中的state通過props傳給react組件 * @param {[type]} state [description] * @return {[type]} [description] */ function mapStateToProps(state) { return { userInfo: state.userInfo, // 假如父組件Home也需要知悉子組件WorkList的這兩個狀態,則可以傳入這兩個屬性 todo: state.todo, done: state.done }; } /** * 將redux中的dispatch方法通過props傳給react組件 * @param {[type]} state [description] * @return {[type]} [description] */ function mapDispatchToProps(dispatch, ownProps) { return { // 通過props傳入initPage這個dispatch方法 initPage: (cb) => { dispatch(initPage(cb)); } }; }
...
class Home extends Component {
...
export default connect(mapStateToProps, mapDispatchToProps)(Home);
當然,並不是只能給store綁定一個組件
如果某個組件的狀態可以被其他組件共用,或者這個組件需要訪問store,按根組件一層一層通過props傳入很麻煩的話,也可以直接給這個組件綁定store
比如這裡的 workList.jsx 也進行了綁定,user.jsx這種只需要展示數據的組件,或者其他一些自治(狀態在內部管理,和外部無關)的組件,則不需要引入redux的store,也挺麻煩的
綁定之後,我們需要在 Home組件中調用action,開始獲取數據
/** * 初始獲取數據之後的某些操作 * @return {[type]} [description] */ afterInit() { console.log('afterInit'); } componentDidMount() { console.log('componentDidMount'); // 初始化發出 INIT_PAGE 操作 this.props.initPage(() => { this.afterInit(); }); }
這裡有個小技巧,如果在獲取非同步數據之後要接著進行其他操作,可以傳入 callback ,我們在action的payload中置入了這個 callback,方便調用
然後Home組件中的已經沒有多少state了,已經交由store管理,通過mapStateToProps傳入
所以可以根據props拿到這些屬性
<User {...this.props.userInfo} />
或者調用傳入的 reducer ,間接地派發一些action
// 執行 ADD_WORK_TODO this.props.addWorkTodo(this.props.todo, content.trim());
頁面呈現
四、React + Redux + SSR
可以看到上圖是有一些閃動的,因為數據不是一開始就存在
考慮加入SSR,先來看看最終頁面效果,功能差不多,但直接出來了,看起來很美好呀~
在Redux中加入SSR, 其實跟純粹的React組件是類似的。
官方給了一個簡單的例子
都是在伺服器端獲取初始狀態後處理組件為字元串,區別主要是React直接使用state, Redux直接使用store
瀏覽器中我們可以為多個頁面使用同一個store,但在伺服器端不行,我們需要為每一個請求創建一個store
再來看項目結構,Redux的SSR使用到了紅框中的文件
服務端路由homeSSR與messageSSR類似,都是返回數據
服務端入口文件 server中的home.js 則是創建一個新的 store, 然後傳入ReactDOMServer進行處理返回
import {createStore} from 'redux'; import reducers from '../store/reducers'; import App from '../common/home'; import defaultState from '../store/state'; let ReactDOMServer = require('react-dom/server'); export function init(preloadState) { // console.log(preloadState); let defaultState = Object.assign({}, defaultState, preloadState); // 伺服器需要為每個請求創建一份store,並將狀態初始化為preloadState let store = createStore( reducers, defaultState ); return ReactDOMServer.renderToString(<App store={store} />); };
同樣的,我們需要在common文件中處理 Node環境與瀏覽器環境的一些差異
比如在 home.jsx 中,加入
// 公共部分,在Node環境中無window document navigator 等對象 if (typeof window === 'undefined') { // 設置win變數方便在其他地方判斷環境 global.win = false; global.window = {}; global.document = {}; }
另外組件載入之後也不需要發請求獲取數據了
/** * 初始獲取數據之後的某些操作 * @return {[type]} [description] */ afterInit() { console.log('afterInit'); } componentDidMount() { console.log('componentDidMount'); // 初始化發出 INIT_PAGE 操作; // 已交由伺服器渲染 // this.props.initPage(() => { this.afterInit(); // }); }
common中的home.js入口文件用於給組件管理store, 與未用SSR的文件不同(js目錄下麵的home.js入口)
它需要同時為瀏覽器端和伺服器端服務,所以增加一些判斷,然後導出
if (module.hot) { module.hot.accept(); } import React, {Component} from 'react'; import {render, findDOMNode} from 'react-dom'; import Home from './homeComponent/home.jsx'; import {Provider} from 'react-redux'; import store from '../store'; class App extends Component { render() { // 如果為Node環境,則取由伺服器返回的store值,否則使用 ../store中返回的值 let st = global.win === false ? this.props.store : store; return ( <Provider store={st}> <Home /> </Provider> ) } } export default App;
瀏覽器端的入口文件 home.js 直接引用渲染即可
import React, {Component} from 'react'; import {render, hydrate, findDOMNode} from 'react-dom'; import App from '../common/home'; // render(<App />, document.getElementById('content')); hydrate(<App />, document.getElementById('content'));
這便是Redux 加上 SSR之後的流程了
其實還漏了一個Express的server.js服務文件,也就一點點代碼
1 const express = require('express'); 2 const path = require('path'); 3 const app = express(); 4 const ejs = require('ejs'); 5 6 // 常規路由頁面 7 let home = require('./routes/home'); 8 let message = require('./routes/message'); 9 10 // 用於SSR服務端渲染的頁面 11 let homeSSR = require('./routes/homeSSR'); 12 let messageSSR = require('./routes/messageSSR'); 13 14 app.use(express.static(path.join(__dirname, '../'))); 15 16 // 自定義ejs模板 17 app.engine('html', ejs.__express); 18 app.set('view engine', 'html'); 19 ejs.delimiter = '|'; 20 21 app.set('views', path.join(__dirname, '../views/')); 22 23 app.get('/home', home); 24 app.get('/message', message); 25 26 app.get('/ssr/home', homeSSR); 27 app.get('/ssr/message', messageSSR); 28 29 let port = 12345; 30 31 app.listen(port, function() { 32 console.log(`Server listening on ${port}`); 33 });View Code
文章說得錯錯亂亂的,可能沒那麼好理解,還是去看 項目文件 自己琢磨吧,自己弄下來編譯運行看看
五、其他
如果項目使用了其他伺服器語言的,比如PHP Yii框架 Smarty ,把服務端渲染整起來可能沒那麼容易
其一是 smarty的模板語法和ejs的不太搞得來
其二是Yii框架的路由和Express的長得不太一樣
在Nginx中配置Node的反向代理,配置一個 upstream ,然後在server中匹配 location ,進行代理配置
upstream connect_node { server localhost:54321; keepalive 64; } ... server { listen 80; ... location / { index index.php index.html index.htm; } location ~ (home|message)\/\d+$ { proxy_pass http://connect_node; } ...
想得頭大,乾脆就不想了,有用過Node進行中轉代理實現SSR的朋友,歡迎評論區分享哈~