前端崛起後,Vue,React等框架大受歡迎,可是他們構建的單頁應用有如下缺點php
爲了解決這些問題,咱們能夠採用服務端渲染的方式。使用服務端渲染,咱們不能走回老路,因此產生了Vue的next.js
和React的next.js
等框架。可是,所謂「授人以魚不如授人以漁」,咱們不只要學會使用第三方框架,還要學習其中的原理!css
服務端渲染,服務端將HTML以字符串的形式返回給前端,前端去渲染。老式服務端渲染像jsp
php
那樣,每次請求則刷新頁面。而如今服務端渲染是使用node中間層去代替客戶端請求數據渲染HTML,再發送內容給客戶端html
這裏咱們可使用renderToString,這是由react-dom
提供的方法,它存在react-dom/server
下,它將組件以字符串形式返回。與renderToStaticMarkup
不一樣的是,renderToString
返回的HTML會帶有data-reactid
,而renderToStaticMarkup
沒有。但在React16開始,爲了HTML更加簡潔,取消了全部標記,因此跟正常HTML相同前端
import React from 'react';
import { renderToString } from 'react-dom/server';
import Header from '../components/Header';
export default () => {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div id="app">${renderToString(<Header />)}</div>
</body>
</html>
`
}
複製代碼
而後使用express
搭建後臺服務,處理請求node
import express from 'express';
import render from './render';
const app = new express();
app.get('*', (req, res) => {
const html = render();
res.send(html)
})
app.listen(3000, () => {
console.log('server is running on port 3000');
})
複製代碼
從上圖能夠看出,webpack配置分爲服務端
和客戶端
,這裏咱們先配置服務端,同時把二者相同部分抽離到webpack.base.js
,使用webpack-merge
插件進行合併react
const path = require('path');
const webpackMerge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');
const baseConfig = require('./webpack.base.js');
const serverConfig = {
target: 'node', // 排除node內置模塊,fs、path
mode: 'development',
entry: './src/server/index.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'build')
},
externals: [nodeExternals()] // 排除node_modules模塊
}
module.exports = webpackMerge(baseConfig, serverConfig)
複製代碼
另外,配置一下.babelrc
和package.json
。爲pakage.json加上如下scripts
,就能夠監聽並動態編譯webpack
"dev:build:server": "webpack --config ./webpack.server.js --watch"
複製代碼
至此,咱們npm run dev:build:server
即可獲得編譯後的bundle.js,此時咱們的目錄結構以下git
node bundle.js
啓動項目,客戶端訪問3000端口,能夠看到結果,可是點擊按鈕控制檯並無輸出結果
後端沒法處理事件綁定,這須要由客戶端來處理。咱們使用React16新提出的hydrate來完成這項任務,此方法由react-dom
提供。他能代替以前的render
方法,複用服務端傳來內容,並綁定好事件github
import React from 'react';
import ReactDom from 'react-dom';
import Header from '../components/Header';
const App = function() {
return (
<Header /> ) } ReactDom.hydrate(<App />, document.getElementById('app')); 複製代碼
而後添加客戶端的webpack配置,經過webpack編譯能夠獲得public
文件夾及內部index.js
。這裏爲了可以實時編譯和編譯後及時重啓服務器,咱們須要對package.json
進行如下配置web
"scripts": {
"dev": "npm-run-all --parallel dev:**",
"dev:start": "nodemon --watch build --exec node \"./build/bundle.js\"",
"dev:build:server": "webpack --config ./webpack.server.js --watch",
"dev:build:client": "webpack --config ./webpack.client.js --watch"
},
複製代碼
爲了客戶端能實現功能,咱們須要在server/render.js
內經過腳本引用客戶端編譯好的index.js
,以及讓服務端響應靜態資源請求
<script src="/index.js"></script>
複製代碼
app.use(express.static('public'));
複製代碼
至此,咱們npm run dev
即可並行編譯及開啓服務,請求3000端口,點擊按鈕就能夠看到輸出結果了!
這裏咱們採用配置的方式構建路由
export default [
{
path: '/',
component: App,
routes: [
{
path: '/',
component: Home,
exact: true // 默認路由配置
},
{
path: '/login',
component: Login
}
]
}
]
複製代碼
這種形式生成路由須要藉助react-router-config
提供的renderRoutes方法,此方法最終會將路由配置文件轉爲如下形式
<Switch>
<Route path="/" component={App} />
const App = () => {
<div>
<Route exact path="/" component={Home} />
<Route path="/login" component={Login} />
</div>
}
</Switch>
複製代碼
React中,通常客戶端渲染時使用BrowserRouter,而服務端渲染,咱們須要使用react-router-dom
提供的無狀態的StaticRouter。BrowserRouter會根據url來保持頁面同步,而StaticRouter只會傳入服務器提供的url,以便路由匹配
const App = (
<StaticRouter location={req.path}> <div> {renderRoutes(routes)} </div> </StaticRouter>
)
複製代碼
固然,服務端修改了,爲了達到hydrate
複用效果,那麼客戶端應該保持一致
const App = function() {
return (
<BrowserRouter> <div> { renderRoutes(routes) } </div> </BrowserRouter>
)
}
複製代碼
到此,咱們路由同構完成,客戶端訪問http://127.0.0.1:3000/login
,能夠看到如下結果
爲了實現的SEO功能,服務端須要返回帶有數據HTML字符串。首先,咱們先按老套路,構建好store
export
出構建好的store,而須要對其再包一層,這樣就
不會是單例模式了。
export const getClientStore = () => {
return createStore(
reducer,
applyMiddleware(thunk)
)
}
export const getServerStore = () => {
return createStore(
reducer,
applyMiddleware(thunk)
)
}
複製代碼
而後,將clientStore
與serverStore
分別經過Provider傳給客戶端和服務端的子組件。接着經過connect將容器組件與Home展現組件鏈接。npm run dev
後獲得以下結果
componentDidMount
生命週期在服務端並無執行。因此咱們須要手動去觸發
dispatch
,去給予
serverStore
數據。這裏咱們經過將
loadData變量掛載到Home組件上,loadData方法返回的都是
Promise對象
Home.loadData = function(store) {
return store.dispatch(getCommentList())
}
複製代碼
但是,這須要怎麼去觸發此方法呢?咱們能夠在接收到相應的請求時去觸發,那就把他放到路由配置上吧
{
path: '/',
component: Home,
loadData: Home.loadData,
exact: true // 默認路由配置
}
複製代碼
接着,咱們須要根據路由去觸發loadData
。這裏咱們須要使用到react-router-config
提供的matchRoutes方法。此方法能夠根據請求路徑,配置到相應的路由,須要注意的是此處使用的是req.path而不是req.url
,由於req.url會帶有query
參數。而後,咱們使用Promise.all去執行全部請求,全部請求結束後,此時store已經有數據了,再響應HTML給客戶端
app.get('*', (req, res) => {
const store = getServerStore()
const matchedRoutes = matchRoutes(routes, req.path)
const promises = []
matchedRoutes.forEach(mRouter => {
if(mRouter.route.loadData) {
promises.push(mRouter.route.loadData(store))
}
})
Promise.all(promises)
.then(resArr => {
const html = render(req,store);
return res.send(html)
})
.catch(err => {
console.log('服務端出錯:', err)
})
})
複製代碼
此時,咱們能夠看到服務端響應HTML中已經存在列表數據了
有數據 -> 空白 -> 有數據
。爲了解決它,咱們須要
初始化clientStore。首先,咱們在HTML字符串中埋好數據
<script>
window.__context__ = {state: ${JSON.stringify(store.getState())}}
</script>
複製代碼
而後在getClientStore
時,初始化store。createStore
能夠傳入三個參數,第二個參數用於初始化state,在使用了combineReducers時,其結構要和reducer結構一致
export const getClientStore = () => {
const defaultStore = window.__context__ || {}
return createStore(
reducer,
defaultStore.state,
applyMiddleware(thunk)
)
}
複製代碼
OK,這樣就不會存在空白閃爍間隔了。
通常咱們處理css樣式,須要使用的插件是style-loader
,可是此插件在服務端的node環境是沒法愉快玩耍的。咱們須要使用一個專門爲服務端渲染而生的插件,即isomorphic-style-loader,具體用法可參見其官方文檔。首先配置webpack.client.js
和webpack.server.js
,注意:此處須要開啓CSS Modules
module:{
rules:[{
test:/\.css$/,
use: [
'isomorphic-style-loader',
{
loader: 'css-loader',
options: {
modules: true // 開啓css模塊化
}
}]
}]
}
複製代碼
而後,修改一下render.js
,第一步引入StyleContext
import StyleContext from 'isomorphic-style-loader/StyleContext';
複製代碼
第二步使用StyleContext
包裹住App,StyleContext.Provider
的value屬性接收一個包含insertCss
的上下文對象,它主要是提供給後面所提到的Withstyles
const css = new Set()
const insertCss = (...styles) => styles.forEach(style => css.add(style._getCss()))
const context = { insertCss }
const App = (
<StyleContext.Provider value={context}> <Provider store={store}> <StaticRouter location={req.path}> <div> {renderRoutes(routes)} </div> </StaticRouter> </Provider> </StyleContext.Provider> ) 複製代碼
第三步,須要將css樣式插入返回的HTML模板字符串
<style>${[...css].join('')}</style>
複製代碼
既然服務端修改了,那麼客戶端也要跟上,咱們修改一下client/index.jsx
。此處的insertCss
與服務端的有點不一樣,node環境下只能使用_getCss
方法,而此處使用的是_insertCss
,它相似於style.loader
的addStylesToDom
import StyleContext from 'isomorphic-style-loader/StyleContext';
const App = function() {
const insertCss = (...styles) => {
const removeCss = styles.map(style => style._insertCss())
return () => removeCss.forEach(dispose => dispose())
}
const context = { insertCss }
return (
<StyleContext.Provider value={context}> <Provider store={getClientStore()}> <BrowserRouter> <div> { renderRoutes(routes) } </div> </BrowserRouter> </Provider> </StyleContext.Provider> ) } 複製代碼
全部配置完成,咱們能夠開始使用了!首先,咱們引入withStyles
,這是一個高階組件,內部有上文提到的_insertCss
方法
import withStyles from 'isomorphic-style-loader/withStyles';
複製代碼
而後,引入css樣式
並使用,須要注意的是此處不是直接import './Home.css'
,而是以模塊的形式引入,這就是上文爲什麼要指明css須要開啓模塊化的緣由
import style from './Home.css';
<h3 className={style.title}>Home</h3>
複製代碼
接着,咱們使用withStyles
包裹一下Home
組件,此處以柯里化的形式,第一個參數能夠傳入style序列
,第二參數傳入組件
export default connect(mapStateToProps,
mapDispatchToProps)(withStyles(style)(Home));
複製代碼
至此,咱們能夠獲得以下結果,能夠看到Home title變爲了紅色
前面,咱們同構好了路由,可是當咱們訪問/home
時,子頁面爲空白,並且響應狀態是200,這就不對了!咱們並無設置/home
路由,雖然在/
時會出現Home頁面內容,但路由是/
。因此,咱們須要處理一下這個問題,當沒有路由匹配時,須要響應404
並返回404 not found
提示內容。
那麼如何判斷請求頁面不存在呢?這時,咱們須要藉助StaticRouter
的context屬性。傳入的context
能夠在路由組件內獲取到,咱們須要將404
頁面放到最後,當路由匹配到此,咱們將NOT_FOUND變量掛載到context
。因此,咱們就能夠經過context
上是否有NOT_FOUND
變量來判斷請求頁面是否存在
首先,配置404
頁面,在路由最後位置添加
{
path: '*',
render: ({staticContext}) => {
if (staticContext) staticContext.NOT_FOUND = true
return <div>404 not found</div>
}
}
複製代碼
而後,給render.js
內的StaticRouter
傳入context
<StaticRouter location={req.path} context={ctx}>
<div>
{renderRoutes(routes)}
</div>
</StaticRouter>
複製代碼
接着,在server/index.js
根據是否有NOT_FOUND
變量來判斷是否響應404錯誤
const context = {}
const html = render(req, store, context);
if (context.NOT_FOUND) res.status(404)
return res.send(html)
複製代碼
最後,咱們請求http://127.0.0.1:3000/home
能夠看到頁面顯示以下
服務端渲染雖然能優化首屏加載速度,但若是數據請求時間較長也不會有顯著效果。所以,是否採用服務端渲染還須要根據實際應用考慮。通常服務端渲染用在注重SEO的網站,或者增改刪查等業務場景較多的後臺管理系統等。
ps:項目地址