隨着前端技術棧和工具鏈的迭代成熟,前端工程化、模塊化的趨勢也愈發明顯,在這波前端技術浪潮中,涌現了諸如React、Vue、Angular等基於客戶端渲染的前端框架,這類框架所構建的單頁應用(SPA)具備渲染性能好、可維護性高等優勢。但也同時帶來了兩個缺陷:html
1.首屏加載時間過長
2.不利於SEO複製代碼
與傳統web項目直接獲取服務器端渲染好的HTML不一樣,單頁應用使用JavaScript在客戶端生成HTML來呈現內容,用戶須要等待JS解析執行完成才能看到頁面,這就使得首屏加載時間變長,影響用戶體驗。此外當搜索引擎爬取網站HTML文件時,單頁應用的HTML沒有內容,從而影響搜索排名。爲了解決這兩個缺陷,業界借鑑傳統的服務器端直出HTML方案,提出在服務器端執行前端框架(React/Vue/Angular)代碼生成HTML,而後將渲染好的HTML返回給客戶端,實現CSR前端框架的服務器端渲染。前端
本文經過一個簡單的demo,向讀者講解React服務器端渲染(SSR)的基本原理,在閱讀完本文後,讀者應該可以掌握:vue
服務器端渲染的基本概念和原理node
在SSR項目中渲染組件react
在SSR項目中使用路由webpack
在SSR項目中使用reduxios
咱們使用express啓動一個Node服務器來進行基本的服務端渲染。首先安裝初始化node項目和安裝expressweb
npm init
express
npm install express –save
npm
在根目錄中建立文件app.js,監聽3000端口的請求,當請求根目錄時,返回一些HTML
const express = require('express')
const app = express()
app.get('/', (req,res) => res.send(`
<html>
<head>
<title>ssr demo</title>
</head>
<body>
Hello world
</body>
</html>
`))
app.listen(3000, () => console.log('Exampleapp listening on port 3000!'))複製代碼
進入項目根目錄,運行node app.js啓動項目。
鼠標右鍵查看網頁源代碼,這就是服務器端直接返回的HTML,咱們已經完成了一個基本的服務端渲染。若是咱們打開一個react項目並查看網頁源代碼,會發現代碼中並無頁面內容對應的HTML,這是由於react所構建的SPA單頁應用是經過在客戶端執行JS動態地生成HTML,初始的HTML文件中並無對應的內容。
咱們已經啓動了一個Node服務器,下一步咱們須要在服務器上編寫React代碼,咱們建立一段這樣的React代碼並在app.js進行引用
import React from 'react'
const Home = () =>{
return <div>home</div>
}
export default Home複製代碼
然而這段代碼並不會運行成功,由於直接在服務器端運行React代碼是行不通的,緣由有如下幾個:
Node不能識別import和export,這兩者屬於esModule的語法,而Node遵循common.js規範
Node不能識別JSX語法,咱們須要使用webpack對項目進行打包轉換,使之成爲Node能識別的語法
爲了使代碼可以運行,咱們須要安裝webpack並進行配置
npm install webpack webpack-cli –save
安裝webpack和webpack-cli
根目錄下建立配置文件webpack.server.js並進行相關配置
const path = require('path') //node的path模塊
const nodeExternals = require('webpack-node-externals')
module.exports = {
target:'node',
mode:'development', //開發模式
entry:'./app.js', //入口
output: { //打包出口
filename:'bundle.js', //打包後的文件名
path:path.resolve(__dirname,'build') //存放到根目錄的build文件夾
},
externals: [nodeExternals()], //保持node中require的引用方式
module: {
rules: [{ //打包規則
test: /\.js?$/, //對全部js文件進行打包
loader:'babel-loader', //使用babel-loader進行打包
exclude: /node_modules/,//不打包node_modules中的js文件
options: {
presets: ['react','stage-0',['env', {
//loader時額外的打包規則,對react,JSX,ES6進行轉換
targets: {
browsers: ['last 2versions'] //對主流瀏覽器最近兩個版本進行兼容
}
}]]
}
}]
}
}複製代碼
3.安裝對應的babel
npm install babel-loaderbabel-core –save
npm install babel-preset-react –save
npm install babel-preset-stage-0 –save
npm install babel-preset-env –save
npm install webpack-node-externals –save
4.運行webpack --config webpack.server.js
5.啓動打包後的文件node ./build/bundle.js
關於webpack的使用,比較陌生的讀者能夠參考咱們公衆號這篇:《webpack入門》
通過webpack對JSX和ES6進行打包轉化後,咱們仍是沒法正確運行咱們的代碼,之前在客戶端渲染DOM時,咱們使用下面的代碼,但這段代碼沒法在服務端運行。
import Home from './src/containers/Home'
import ReactDom from 'react-dom'
ReactDom.render(<Home/>, document.getElementById('root')) //服務端沒有DOM複製代碼
咱們須要使用react-dom提供的renderToString方法,將組件渲染成爲字符串再插入返回給客戶端的HTML中
import express from 'express'
import React from 'react'//引入React以支持JSX的語法
import { renderToString } from 'react-dom/server'//引入renderToString方法
import Home from'./src/containers/Home'
const app= express()
const content = renderToString(<Home/>)
app.get('/',(req,res) => res.send(`
<html>
<head>
<title>ssr demo</title>
</head>
<body>
${content}
</body>
</html>
`))
app.listen(3001, () => console.log('Exampleapp listening on port 3001!'))複製代碼
從新打包並重啓服務器,咱們就能在頁面上看到服務器端渲染的組件
寫到這裏咱們對以前的Node和webpack的啓動方式作一個小優化,在這以前,咱們每次對項目的改動,都須要從新執行webpack--config webpack.server.js和node ./build/bundle.js來重啓項目,如今咱們對package.json文件中的script作一些改動,使得服務器可以自動重啓和打包
在webpack --config webpack.server.js後加上—watch就能實現webpack的自動監聽打包,當須要被打包的文件發生變化時,webpack就會自動從新打包
安裝nodemon,nodemon是nodemonitor的縮寫,nodemon可以幫咱們監聽文件的變化並自動重啓服務器,咱們須要運行 npm install nodemon –g
安裝nodemon,在package.json的script配置項中添加這兩句:
"scripts":{
"dev": "nodemon--watch build --exec node \"./build/bundle.js\"",
"build": "webpack--config webpack.server.js --watch"
},複製代碼
在進行了以上兩條配置後,咱們開啓兩個終端,分別運行npm run dev和npm run build就能完成項目的自動打包和服務器重啓
3.安裝npm-run-all進一步簡化流程:
運行npm install npm-run-all –g
安裝npm-run-all,並對package.json進行配置
"scripts": {
"dev": "npm-run-all--parallel dev:**",
"dev:start": "nodemon--watch build --exec node \"./build/bundle.js\"",
"dev:build": "webpack--config webpack.server.js --watch"
},複製代碼
咱們在原來的start和build加上dev前綴,表示這是開發環境所使用的命令,在線上環境時咱們並不須要執行這兩條命令去監聽。配置好之後,運行npm run dev,咱們就完成了自動打包和服務端啓動重啓,每次對代碼的更改只須要刷新頁面就能看到效果,沒必要像原來那樣手動從新打包和重啓服務器
咱們在上面的過程當中,已經將組件渲染到了頁面上,下面咱們爲組件綁定一個點擊事件。
import React from 'react'
const Home= () =>{
return (
<div>
<div>home</div>
<button onClick={()=>{alert('click')}}>click</button>
</div>)
}
export default Home複製代碼
運行代碼,刷新頁面,咱們會發現並無執行對應的點擊事件,這是因爲renderToString只渲染了組件的內容,而不會綁定事件,爲了可以給頁面上的組件綁定事件,咱們須要將React代碼在服務端執行一遍,在客戶端再執行一遍,這種服務器端和客戶端共用一套代碼的方式就稱之爲同構。
咱們經過<script>標籤爲頁面引入客戶端執行的React代碼,並經過express的static中間件爲js文件配置路由,修改原來的app.js
import express from 'express'
import React from 'react'//引入React以支持JSX的語法
import { renderToString } from'react-dom/server'//引入renderToString方法
import Home from './src/containers/Home'
const app = express()
app.use(express.static('public'));
//使用express提供的static中間件,中間件會將全部靜態文件的路由指向public文件夾
const content = renderToString(<Home/>)
app.get('/',(req,res)=>res.send(`
<html>
<head>
<title>ssr demo</title>
</head>
<body>
${content}
<script src="/index.js"></script>
</body>
</html>
`))
app.listen(3001, () =>console.log('Example app listening on port 3001!'))複製代碼
而後咱們須要編寫咱們的index.js(客戶端的React代碼),咱們嘗試在public文件夾下建立index.js並編寫React代碼,但這些React代碼將沒法運行,由於咱們一樣須要使用webpack對客戶端的React進行打包。
咱們先調整一下目錄結構,在src文件夾下新建client文件夾用來存放客戶端代碼,根目錄下新建webpack.client.js做爲客戶端React代碼的webpack配置文件,public文件夾將用來存放webpack打包後的客戶端代碼;新建server文件夾用來存放服務器端代碼,將原來app.js的內容移至server文件夾下的index.js,並修改webpack.server.js的入口。新建containers文件夾存放React代碼
下面咱們開始編寫客戶端webpack配置項,在webpack.client.js中編寫如下代碼:
const path = require('path') //node的path模塊
module.exports = {
mode:'development', //開發模式
entry:'./src/client/index.js', //入口
output: { //打包出口
filename:'index.js', //打包後的文件名
path:path.resolve(__dirname,'public') //存放到根目錄的build文件夾
},
module: {
rules: [{ //打包規則
test: /\.js?$/, //對全部js文件進行打包
loader:'babel-loader', //使用babel-loader進行打包
exclude: /node_modules/, //不打包node_modules中的js文件
options: {
presets: ['react','stage-0',['env', {
//loader時額外的打包規則,這裏對react,JSX進行轉換
targets: {
browsers: ['last 2versions'] //對主流瀏覽器最近兩個版本進行兼容
}
}]]
}
}]
}
}複製代碼
同時咱們對package.json中的script部分進行修改
"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"
},複製代碼
從新運行npm run dev,咱們就完成了服務端、客戶端代碼的自動打包,刷新頁面,能夠看到事件已經成功綁定
這裏報了個警告,緣由是在React 16中進行服務端渲染時,應該將render()方法替換爲hydrate()方法,雖然在React16中仍然可以使用render()渲染HTML,但爲了消除錯誤,最好替換成hydrate()
有關hydrate的更多內容,能夠看這個討論:https://www.wengbi.com/thread_50584_1.html
咱們在項目中編寫了兩個webpack配置文件,其實在這兩個配置文件當中存在不少共同的部分,咱們應該將共同的部分提取出來,減小代碼的冗餘性。咱們安裝webpack-merge模塊來幫助咱們提取公用的webpack配置項。
新建webpack.base.js文件,將webpack.server.js和webpack.client.js中共同的配置項移到這裏並經過module.exports進行導出
module.exports = {
module: {
rules: [{
test: /\.js?$/,
loader:'babel-loader',
exclude: /node_modules/,
options: {
presets: ['react','stage-0',['env', {
targets: {
browsers: ['last 2versions']
}
}]]
}
}]
}
}複製代碼
2.在webpack.server.js和webpack.client.js中經過merge方法將公用配置項和當前配置項進行合併導出。
//webpack.client.js配置
const path = require('path')
const merge = require('webpack-merge')
const config = require('./webpack.base.js')
const clientConfig = {
mode:'development',
entry:'./src/client/index.js',
output: {
filename:'index.js',
path:path.resolve(__dirname,'public')
},
}
module.exports = merge(config,clientConfig)複製代碼
//webpack.server.js配置
const path = require('path')
const nodeExternals = require('webpack-node-externals')
const merge = require('webpack-merge')
const config = require('./webpack.base.js')
const serverConfig = {
target:'node',
mode:'development',
entry:'./app.js',
output: {
filename:'bundle.js',
path:path.resolve(__dirname,'build')
},
externals: [nodeExternals()],
}
module.exports = merge(config,serverConfig)複製代碼
段落小結:
本小節介紹瞭如何在服務端進行基礎的組件的渲染和事件綁定,經過本小節的講解,讀者應該可以體會到React SSR的基本思路——同構,所謂同構,就是一套React代碼在服務器端執行生成HTML,客戶端再執行代碼接管頁面的操做,從而使得頁面兼具SSR和CSR的優勢。
總結一下服務端渲染組件的步驟:
創建一個node項目
編寫服務器端的React代碼並使用webpack進行打包編譯,使用renderToString方法將組件渲染成爲HTML。
編寫客戶端須要執行的React代碼,並使用webpack進行打包編譯,經過script標籤引入頁面,接管頁面的操做。
一樣的,在使用路由時,咱們須要在服務器端和客戶端各配置一遍路由,緣由會在下文中解釋。咱們首先進行客戶端的路由配置,安裝react-router。
npm install react-router-dom —save
而後咱們在src文件夾下建立Router.js存放路由條目
import React from 'react' //引入React以支持JSX
import { Route } from 'react-router-dom' //引入路由
import Home from './containers/Home' //引入Home組件
export default (
<div>
<Route path="/" exact component={Home}></Route>
</div>
)複製代碼
修改client文件夾下的index.js,使用BrowserRouter並引入路由條目
import React from 'react'
import ReactDom from 'react-dom'
import { BrowserRouter } from'react-router-dom'
import Router from'../Routers'
const App= () => {
return (
<BrowserRouter>
{Router}
</BrowserRouter>
)
}
ReactDom.hydrate(<App/>, document.getElementById('root'))複製代碼
運行代碼,刷新頁面,會發現控制檯報錯:
這是因爲咱們在Router.js使用路由時,外層須要套一個div,然而服務器端的HTML外層並無這個div,致使了客戶端渲染的頁面和服務端渲染的頁面內容不一樣,於是報錯,因此咱們須要在服務器端再配置一次路由,使得服務器端和客戶端渲染的內容一致(固然,若是直接在服務器返回的HTML里加多一個div是能夠暫時解決這個報錯的,但在服務器端不寫路由的話,在接下來的步驟中還會遇到其餘錯誤)
修改server文件夾下的index.js,在這裏引入服務器端路由。在服務器端咱們須要使用StaticRouter來替代BrowserRouter,StaticRouter 是 React-Router 針對服務器端渲染專門提供的一個路由組件,因爲StaticRouter不能像BrowserRouter同樣感知頁面當前頁面的url,因此咱們須要給StaticRouter傳入location={當前頁面url},另外使用 StaticRouter時必須傳遞一個context參數,用於服務端渲染時的參數傳遞。
import express from 'express'
import React from 'react'//引入React以支持JSX的語法
import { renderToString } from 'react-dom/server'//引入renderToString方法
import { StaticRouter } from 'react-router-dom'
import Router from '../Routers'
const app = express()
app.use(express.static('public'));
//使用express提供的static中間件,中間件會將全部靜態文件的路由指向public文件夾
app.get('/',(req,res)=>{
const content = renderToString((
//在服務端咱們須要使用StaticRouter來替代BrowserRouter
//傳入當前path
//context爲必填參數,用於服務端渲染參數傳遞
<StaticRouter location={req.path} context={{}}>
{Router}
</StaticRouter>
))
res.send(`
<html>
<head>
<title>ssr demo</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`)
})
app.listen(3001, () => console.log('Exampleapp listening on port 3001!'))複製代碼
這時再打開咱們的頁面,就不會出現錯誤了。
咱們建立一個Login組件
並在Routers.js中爲login組件添加路由
import React from'react' //引入React以支持JSX
import { Route } from'react-router-dom' //引入路由
import Home from'./containers/Home' //引入Home組件
import Login from'./containers/Login' //引入Login組件
exportdefault (
<div>
<Route path="/" exact component={Home}></Route>
<Route path="/login" exact component={Login}></Route>
</div>
)複製代碼
另外咱們須要將src/server/index.js中的路由從匹配‘/’改爲‘*’,不然當咱們訪問http://localhost:3001/login時將因爲匹配不到路由而提示404錯誤。
import express from 'express'
import React from 'react'//引入React以支持JSX的語法
import { renderToString } from 'react-dom/server'//引入renderToString方法
import { StaticRouter } from 'react-router-dom'
import Router from '../Routers'
const app= express()
app.use(express.static('public'));
//使用express提供的static中間件,中間件會將全部靜態文件的路由指向public文件夾
app.get('*',(req,res)=>{
const content = renderToString((
<StaticRouter location={req.path} context={{}}>
{Router}
</StaticRouter>
))
res.send(`
<html>
<head>
<title>ssr demo</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`)
})
app.listen(3001, () =>console.log('Exampleapp listening on port 3001!'))複製代碼
咱們能夠稍微抽離以上代碼中生成HTML的部分,server文件夾下新建utils.js文件,存放生成HTML的代碼
import React from 'react'//引入React以支持JSX的語法
import { renderToString } from 'react-dom/server'//引入renderToString方法
import { StaticRouter } from 'react-router-dom'
import Router from '../Routers'
export const render = (req) => {
const content = renderToString((
<StaticRouter location={req.path} context={{}}>
{Router}
</StaticRouter>
));
return`
<html>
<head>
<title>ssr demo</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`
}複製代碼
原來的server/index.js能夠改爲如下形式
import express from 'express'
import { render } from './utils'
const app = express()
app.use(express.static('public'));
//使用express提供的static中間件,中間件會將全部靜態文件的路由指向public文件夾
app.get('*',(req,res)=>{
res.send(render(req))
})
app.listen(3001, () => console.log('Exampleapp listening on port 3001!'))複製代碼
在進行了以上的步驟後,咱們使用Link標籤來實現一個導航功能,咱們須要建立一個導航欄組件並在home和login中引用這個導航欄組件,對於能夠複用的組件,咱們在src文件夾下建立component文件夾存放公共組件,並在component下建立header.js做爲咱們的導航欄組件
import React from 'react'
import { Link } from 'react-router-dom'
const Header = () => {
return (
<div>
<Link to='/'>Home </Link>
<Link to='/login'>Login</Link>
</div>
)
}
export default Header複製代碼
而後咱們分別在Home組件和Login組件中引用這個導航欄組件,保存代碼,刷新頁面,如今已經可以在頁面上進行路由跳轉了。
值得注意的是,只有在第一次進入頁面時,瀏覽器請求了頁面文件,以後切換路由的操做都不會從新請求頁面,由於這時頁面的路由跳轉已是客戶端React的路由跳轉了。
段落小結:
本小節介紹瞭如何在SSR項目中使用路由,咱們須要在服務器端和客戶端各配置路由才能正常實現頁面跳轉,對於配置兩次路由的緣由,筆者的理解是
1.服務端路由是爲了第一次進入頁面時可以找到對應的網頁文件
2.客戶端路由是爲了能讓React路由接管頁面實現無刷新跳轉
3.若是服務器端不寫路由的話,會致使頁面內容不統一而出現報錯
此外咱們須要注意到,只有在第一次進入頁面的時候,瀏覽器纔會使用服務器端路由請求網頁文件,當頁面渲染後,React的客戶端路由將接管頁面路由,實現無刷新跳轉。
本小節將講解如何在SSR項目中使用redux,這是項目中的一個難點,一樣的咱們須要在客戶端和服務器端各執行一次redux的代碼,緣由會在下文中解釋。
npm install redux –save
npm install react-redux–save
npm install redux-thunk–save
接下來咱們進行一系列常規操做,這裏再也不細講redux、redux-thunk、react-redux的使用。咱們在客戶端代碼(/client/index.js)裏使用redux建立store和reducer,配置中間件thunk,並將store傳遞給組件。
import React from 'react'
import ReactDom from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import Routers from '../Routers'
import { createStore,applyMiddleware } from 'react'
import { Provider } from 'react-redux'
import thunk from 'redux-thunk'
const reducer = (state,action) => {
return state
}
const store = createStore(reducer,applyMiddleware(thunk))
const App = () => {
return (
<Provider store={store}>
<BrowserRouter>
{Routers}
</BrowserRouter>
</Provider>
)
}
ReactDom.hydrate(<App/>,document.getElementById('root'))複製代碼
在子組件(Home)中咱們使用react-redux中的connect方法與store進行鏈接
import React from 'react'
import Header from '../../component/header'
import { connect } from 'react-redux'
const Home= () =>{
return (
<div>
<Header/>
<div>{props.name}</div>
<button onClick={()=>{alert('click')}}>click</button>
</div>)
}
const mapStateToProps = state => ({
name:state.name
})
export default connect(mapStateToProps,null)(Home)複製代碼
在寫完客戶端的redux代碼後,咱們能夠刷新頁面看看效果
能夠看到頁面上會報錯,這是因爲在訪問http://localhost:3001/時,首先會進入server文件夾下的index.js,index.js會去渲染Home組件,當Home組件去調用store裏的數據時,因爲此時尚未執行客戶端的redux代碼,致使Home組件找不到store而報錯,所以咱們須要在服務器端代碼(server/until.js)裏也建立一次store,並經過react-redux傳遞給組件
一樣的,咱們在服務器端的代碼中也引入Redux
import React from 'react'//引入React以支持JSX的語法
import { renderToString } from 'react-dom/server'//引入renderToString方法
import { StaticRouter } from 'react-router-dom'
import Router from '../Routers'
import { createStore,applyMiddleware } from 'redux'
import { Provider } from 'react-redux'
import thunk from 'redux-thunk'
export const render = (req) => {
const reducer = (state = { name:'CJW' },action) => {
return state
}
const store= createStore(reducer,applyMiddleware(thunk))
const content = renderToString((
<Provider store={store}>
<StaticRouter location={req.path} context={{}}>
{Router}
</StaticRouter>
</Provider>
));
return`
<html>
<head>
<title>ssr demo</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`
}複製代碼
然而在服務端這麼寫store是有坑的,createStore建立的store是單例的store,在服務器端這樣的寫法將致使全部用戶共享一個store,因此咱們將建立store這一步封裝成一個方法,每次調用都返回一個新的store。此外咱們能夠將這部分建立store的代碼抽離出來,在server和client分別引用,減小代碼的冗餘。
咱們在src目錄下建立一個store文件夾,store文件夾下建立index.js存放建立store的代碼
import { createStore,applyMiddleware } from'redux'
import thunk from 'redux-thunk'
const reducer = (state = { name:'CJW' }, action) => {
return state
}
const getStore = () => {
return createStore(reducer,applyMiddleware(thunk))
}
export default getStore複製代碼
在client/index.js和server/utils.js中都引入getStore方法,刪除原來建立store的代碼
import React from 'react'
import ReactDom from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import { Provider } from 'react-redux'
import Routers from '../Routers'
import getStore from '../store'
const App = () => {
return (
<Provider store={getStore()}>
<BrowserRouter>
{Routers}
</BrowserRouter>
</Provider>
)
}
ReactDom.hydrate(<App/>,document.getElementById('root'))複製代碼
這裏只是展現了一個簡單的store建立,在實際使用中,咱們須要建立一個規範的store,實現reducer、store和action的分離,但這裏做爲一個簡單的demo就不進行這些操做了。
咱們安裝axios來方便咱們的異步請求
npm install axios --save
因爲已經安裝了thunk,所以咱們能夠在action中發送異步請求,這一塊也是thunk的基礎內容,不作過多的講解。修改Home文件夾下的index.js,代碼以下(我這個axios請求的接口會返回一個列表,讀者能夠請求本身項目中的接口或請求各類公開的api)
import React from 'react'
import Header from '../../component/header'
import { connect } from 'react-redux'
import axios from 'axios'
class Home extends React.Component {
//在componentDidMount中發送異步請求
componentDidMount(){
this.props.getList()
}
render(){
console.log(this.props.list)
return (
<div>
<Header/>
{ this.props.list?
<div>
{this.props.list.map(item=>(
<div>{item.title}</div>
))}
</div>:''}
<button onClick={()=>{alert('click')}}>click</button>
</div>)
}
}
//使用redux-thunk,在action中寫axios並dispatch
const getData = () => {
return (dispatch) => {
//接收來自mapDispatchToProps的dispatch方法
axios.get('http://異步請求的接口) .then((res)=>{ const list = res.data.data dispatch({type:'CHANGE_LIST',list:list}) }) } } const mapStateToProps = state => ({ name:state.name, list:state.list }) const mapDispatchToProps = dispatch => ({ getList(){ //調用dispatch時會自動執行getData裏return的方法 dispatch(getData()) } }) export default connect(mapStateToProps , mapDispatchToProps)(Home)複製代碼
折騰了半天,咱們保存代碼,刷新頁面,能夠看到頁面上成功顯示了異步請求的內容,然而咱們右鍵檢查網頁源代碼,卻發現並無對應內容的HTML,這是因爲服務器端執行React代碼時,並不會觸發componentDidMount(),所以服務器端的store始終是空的
爲了使得返回客戶端的HTML包含異步請求的數據,實際上咱們須要根據不一樣的頁面,給當前的store填充數據,爲了實現這個目的,咱們須要知足如下兩個條件:
1.服務器端代碼在進入某個頁面時能匹配到對應組件裏的axios請求
2.被匹配的組件能將axios請求獲得數據傳遞給服務器端的store
對於這個問題,React-Router已經爲SSR提供了一些方法(參考官方文檔:https://reacttraining.com/react-router/web/guides/server-rendering),咱們須要進行如下幾個步驟:
修改路由條目(Router.js)從導出組件改成導出數組
import Home from './containers/Home' //引入Home組件
import Login from './containers/Login' //引入Login組件
export default [
{
path:'/',
component:Home, //渲染Home組件
exact:true, //嚴格匹配
loadData:Home.loadData, //傳入loadData方法
key:'Home' //用於後續循壞時提供key
},
{
path:'/login',
component:Login,
exact:true,
key:'login'
}
]複製代碼
2. 分別修改server和client文件夾下的util.js和index.js
import React from 'react'//引入React以支持JSX的語法
import { renderToString } from 'react-dom/server'//引入renderToString方法
import { StaticRouter,Route } from 'react-router-dom'
import { Provider } from 'react-redux'
import routers from '../Routers'
import getStore from '../store'
export const render = (req) => {
const store= getStore()
const content = renderToString((
<Provider store={store}>
<StaticRouter location={req.path} context={{}}>
<div>
{routers.map(router=> (
<Route{...router}/>
))}
</div>
</StaticRouter>
</Provider>
));
return`
<html>
<head>
<title>ssr demo</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`
}複製代碼
import React from 'react'
import ReactDom from 'react-dom'
import { BrowserRouter,Route } from 'react-router-dom'
import { Provider } from 'react-redux'
import routers from '../Routers'
import getStore from '../store'
const App = () => {
return (
<Provider store={getStore()}>
<BrowserRouter>
<div>
{routers.map(router=> (
<Route{...router}/>
))}
</div>
</BrowserRouter>
</Provider>
)
}
ReactDom.hydrate(<App/>,document.getElementById('root'))複製代碼
3.在server/index.js引入matchPath方法匹配當前頁面路由,並執行對應的loadData方法
import React from 'react'//引入React以支持JSX的語法
import { renderToString } from 'react-dom/server'//引入renderToString方法
import { StaticRouter,Route } from 'react-router-dom'
import { Provider } from 'react-redux'
import routers from '../Routers'
import getStore from '../store'
export const render = (req) => {
const matchRoutes = []
routers.some(route=> {
matchPath(req.path, route) ? matchRoutes.push(route) : ''
})
console.log(matchRoutes)
const store = getStore()
const content = renderToString((
<Provider store={store}>
<StaticRouter location={req.path} context={{}}>
<div>
{routers.map(router=> (
<Route{...router}/>
))}
</div>
</StaticRouter>
</Provider>
));
return`
<html>
<head>
<title>ssr demo</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`
}複製代碼
這三步須要結合着看,在這一步驟,咱們的目的是進入某個組件時,執行組件的loadData方法(方法將在下文實現),loadData方法將獲取axios請求獲得的數據,並將數據傳遞給服務器端的store。
咱們能夠打印一下匹配到的路由項,路由項的內容就是咱們在Router.js中配置的路由條目。
下面咱們來進行loadData方法的實現
import React from 'react'
import Header from '../../component/header'
import { connect } from 'react-redux'
import axios from 'axios'
class Home extends React.Component {
//在componentDidMount中發送異步請求
componentDidMount(){
this.props.getList()
}
render(){
console.log(this.props.list)
return (
<div>
<Header/>
{ this.props.list?
<div>
{this.props.list.map(item=>(
<div>{item.title}</div>
))}
</div>:''}
<button onClick={()=>{alert('click')}}>click</button>
</div>)
}
}
Home.loadData = (store) => {
store.dispatch(getData())
}
//使用redux-thunk,在action中寫axios並dispatch
const getData = () => {
return (dispatch) => {
//接收來自mapDispatchToProps的dispatch方法
axios.get('接口地址')
.then((res)=>{
const list = res.data.data
dispatch({type:'CHANGE_LIST',list:list})
})
}
}
const mapStateToProps = state => ({
name:state.name,
list:state.list
})
const mapDispatchToProps = dispatch => ({
getList(){
//調用dispatch時會自動執行getData裏return的方法
dispatch(getData())
}
})
export default connect(mapStateToProps , mapDispatchToProps)(Home)複製代碼
在loadData方法中,咱們直接接收服務器端的store,調用store的dispatch方法來更新store的數據,可是光是這麼寫,咱們仍是會獲得空的內容。這是由於axios請求是異步操做,服務器端渲染已經先於請求執行了,這裏咱們藉助promise來修正咱們的執行順序,因爲axios自己是一個promise對象,咱們能夠return axios對象,在loadData方法中,將dispatch也return出去,這樣咱們就能在server/util.js中獲得這個promise,並調用Promise.all方法,讓全部異步操做執行完後再渲染HTML,修改server/util.js,完成咱們的最後一步
import React from 'react'//引入React以支持JSX的語法
import { renderToString } from 'react-dom/server'//引入renderToString方法
import { StaticRouter,Route,matchPath } from 'react-router-dom'
import { Provider } from 'react-redux'
import routers from '../Routers'
import getStore from '../store'
export const render = (req,res) => {
//將res傳入以使用res.send()方法
const store = getStore()
const matchRoutes = []
const promises = []
routers.some(route=> {
matchPath(req.path, route) ? matchRoutes.push(route) : ''
})
matchRoutes.forEach( item=> {
promises.push(item.loadData(store))
})
Promise.all(promises).then(()=>{
//能夠console一下看到當前的store已經有數據
console.log(store.getState())
const content = renderToString((
<Provider store={store}>
<StaticRouter location={req.path} context={{}}>
<div>
{routers.map(router=> (
<Route{...router}/>
))}
</div>
</StaticRouter>
</Provider>
));
res.send(`
<html>
<head>
<title>ssrdemo</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`)
})
}複製代碼
代碼寫到這,咱們總算可以在服務器端正確地使用redux,在踩了無數坑以後,咱們終於可以搭建一個基本的SSR項目了。有關服務器端渲染的講解,到這裏也就告一段落了
段落小結:
本小節介紹瞭如何在SSR項目中使用Redux並使用axios異步獲取數據,和組件渲染、路由使用同樣,咱們須要在服務器端和客戶端各使用一次Redux,其緣由在於服務器端渲染只會走一遍生命週期,而且在第一次render後便會中止。異步數據請求放在componentDidMount函數中不會被觸發,即便咱們提早發起數據請求,因爲是異步返回,獲得的數據也沒法再次觸發render,因此返回給客戶端的HTML依然沒有異步請求的數據。對於這種狀況,咱們經過改造路由,使得每次進入對應頁面時可以匹配對應的loadData方法,爲服務器端的store注入數據,並將生成HTML的步驟放在異步請求完成以後。
經過本文的介紹,讀者應該對React服務端渲染有了初步的認識,可以在服務器端進行基本的組件渲染、路由跳轉和使用redux,對webpack和Node在React項目中所起的做用也有了更進一步的瞭解。對於SSR的取捨,業界存在不少討論,雖然SSR可以解決傳統SPA項目首屏加載時間過長和不利於SEO的缺陷,但SSR一樣帶來了服務器負擔重,實施難度大等諸多問題。若是對首屏加載時間和SEO沒有極致的追求,咱們能夠選擇更輕便的方案,如利用webpack和react-router分割代碼,減小首屏加載時間;利用prerender進行預渲染,優化項目搜索引擎排名,不必定須要對整個項目採用同構的方式進行服務器端渲染。也許目前爲止同構SSR並非一種完美的方案,但在廣大前端技術人員對性能和體驗永不止步的追求中,有關SSR的討論和迭代還將長久地持續下去。
參考文章:
《先後端渲染和同構渲染》:https://blog.csdn.net/qizhiqq/article/details/70904799
《Vue-SSR指南》:https://ssr.vuejs.org/zh/#
《next.js 的服務端渲染機制》:https://www.jianshu.com/p/a3bce57e7349
以上幾篇文章對SSR的原理和優缺點進行了深刻地剖析,幫助讀者認識SSR的應用情景和使用侷限。具備很高的參考價值
文章先後寫了近3個月,但願能給你們帶來幫助呀,本文總結自Dell Lee老師的React服務端渲染課程,Dell老師的課深刻淺出,各個細節也有顧及,很是值得一看。筆者在課程的基礎上進行歸納總結,並對課程中一些模糊的細節進行考據和思考,但願能對讀者有所啓發,另外也但願你們多多支持Dell Lee老師的課程,畢竟光讀一篇文章並不能完整掌握SSR。也但願讀者能給咱公司前端團隊的公衆號點個關注,會定時推送技術分享~