react-ssr 萬字長文的服務端渲染
環境搭建
webpack webpack-cli babel-loader @babel/core | 打包前後端代碼並實時編譯 |
@babel/preset-env | 識別import語法,其他es6語法 |
@babel/preset-react | 識別jsx語法 |
webpack-merge | 合併webpack配置 |
webpack-node-externals | 跳過node_modules 打包 |
nodemon | 服務端代碼熱更新 |
express | Node.JS框架 |
axios | 異步數據請求 |
react react-dom react-router-dom redux react-redux redux-thunk react-router-config |
react生態依賴 |
npm-run-all | npm腳本批處理 |
isomorphic-style-loader | 服務端css處理 |
react-helment | seo相關 |
css-loader style-loader | webpack 識別 css |
babel-plugin-styled-components | 識別styled-components |
1. webpack.base.config.js
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react']
}
}
]
}
]
}
}
2. webpack.client.config.js
const path = require('path')
const webpackMerge = require("webpack-merge")
const baseConf = require("./webpack.base.config")
const clientConf = {
mode: 'development',
entry: './src/client/index.js',
output: {
filename: 'index.js',
path: path.resolve(__dirname, './public')
},
}
module.exports = webpackMerge(baseConf, clientConf)
3. webpack.server.config.js
const path = require('path')
const nodeExternals = require('webpack-node-externals');
const webpackMerge = require("webpack-merge")
const baseConf = require("./webpack.base.config")
const serverConf = {
mode: 'development',
target: 'node',
externals: [nodeExternals()],
entry: './src/server/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, './build')
},
}
module.exports = webpackMerge(baseConf, serverConf)
4. npm腳本
//運行 npm start
"scripts": {
"start": "npm-run-all --parallel dev:** ",
"dev:start": "nodemon ./build/bundle.js ",
"dev:build:client": "webpack --config webpack.client.config.js --watch",
"dev:build:server": "webpack --config webpack.server.config.js --watch"
},
hello world
import express from 'express'
const app = new express();
app.get("/", (req, res) => {
res.send(`
<html>
<head>
<title>react-ssr</title>
</head>
<body>
react ssr
</body>
</html>
`)
})
app.listen(3000, () => {
console.log('run server 3000')
})
服務端運行前端代碼
前端代碼轉字符串後服務端直出,但此時尚無法完成交互邏輯,如事件綁定
前端代碼
import React from 'react';
function App() {
return <h1 >hello ssr </h1>
}
export default App
服務端代碼
import express from 'express'
import {renderToString} from 'react-dom/server'
import React from 'react'
import App from '../client/home'
const app = new express();
app.get("/", (req, res) => {
const content=renderToString(<App/>)
res.send(`
<html>
<head>
<title>react-ssr</title>
</head>
<body>
${content}
</body>
</html>
`)
})
app.listen(3000, () => {
console.log('run server 3000')
})
同構
同構指的是一套代碼在服務端和客戶端運行,服務端直出html結構,客戶端接管頁面進行渲染。
前端使用hydrate
//src/client/index
import React from 'react';
import { hydrate } from 'react-dom'
import Home from './home'
hydrate(<Home />,document.getElementById("root"))
//src/client/home
import React from 'react';
const handleClick = () => {
alert('click')
}
function App() {
return <div onClick={handleClick}>hello ssr </div>
}
export default App
後端返回的html加載靜態資源開放的js腳本
import express from 'express'
import {renderToString} from 'react-dom/server'
import React from 'react'
import App from '../client/home'
const app = new express();
//這個也是webpack.client.config.js 的出口路徑
app.use(express.static('public'))
app.get("/", (req, res) => {
const content=renderToString(<App/>)
//返回的html要加一個container(root), 加載js腳本
res.send(`
<html>
<head>
<title>react-ssr</title>
</head>
<body>
<div id="root">${content}</div>
</body>
<script src="/index.js"></script>
</html>
`)
})
app.listen(3000, () => {
console.log('run server 3000')
})
路由
前端路由使用方式不變,後端使用靜態路由完成同構
- 首次訪問界面,服務端直出路由匹配到的組件
- 之後的路由跳轉皆由瀏覽器接管
src/routes.js
import React from 'react'
import { Route } from 'react-router-dom'
import Home from './client/home'
import List from './client/list'
export default (
<div>
<Route exact path="/" component={Home} />
<Route exact path="/list" component={List} />
</div>
)
src/client/index.js
import React from 'react';
import { hydrate } from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import Routes from '../routes'
function App() {
return (
<BrowserRouter>
{Routes}
</BrowserRouter>
)
}
hydrate(<App />, document.getElementById("root"))
src/client/header.js
import React from 'react';
import { Link } from 'react-router-dom'
function Header() {
return (
<div>
<Link to="/" >Home</Link>
<Link to="/list" >List</Link>
</div>
)
}
export default Header
src/client/home.js
import React from 'react';
import Header from './header'
const handleClick = () => {
alert('click')
}
function Home() {
return (
<div>
<Header/>
<div onClick={handleClick}> hello ssr </div>
</div>
)
}
export default Home
src/client/list.js
import React from 'react';
import Header from './header'
function List() {
return (
<div>
<Header />
<div> list</div>
</div>
)
}
export default List
服務端使用StaticRouter
//src/server/utils.js
import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'
import Routes from '../routes' //服務端加載路由
export const render = (req) => {
const content = renderToString(
<StaticRouter location={req.path} >
{Routes}
</StaticRouter>
)
return `
<html>
<head>
<title>react-ssr</title>
</head>
<body>
<div id="root">${content}</div>
</body>
<script src="/index.js"></script>
</html>
`
}
src/server/index.js
import express from 'express'
import {render} from './utils'
const app = new express();
app.use(express.static('public'))
app.get("*", (req, res) => {
res.send(render(req))
})
app.listen(3000, () => {
console.log('run server 3000')
})
效果圖
引入redux
前端redux使用方式不變,後端需要給靜態路由Provider一份store
0. 此時目錄結構
1. 全局store創建
//src/store/index.js
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk'
import reducer from './reducer'
const store = createStore(reducer, applyMiddleware(thunk));
export default store;
//src/store/reducer.js
import { combineReducers } from 'redux'
import { homeReducer } from '../client/home/store'
export default combineReducers({
home: homeReducer,
})
2. home組件store維護
//src/client/home/store/index.js
import homeReducer from './reducer';
import * as actionCreators from './actionCreators';
import * as actionTypes from './actionTypes';
export { homeReducer, actionCreators, actionTypes };
//src/client/home/store/reducer.js
import { CHANGE_LIST } from "./actionTypes";
const defaultState = {
list: []
}
export default (state = defaultState, action) => {
switch (action.type) {
case CHANGE_LIST:
return {
...state,
list:action.list
}
default:
return state;
}
}
//src/client/home/store/actionTypes.js
export const CHANGE_LIST = 'HOME/CHANGE_LIST';
//src/client/home/store/actionCreators.js
import axios from 'axios';
import { CHANGE_LIST } from "./actionTypes";
const changeList = list => ({ type: CHANGE_LIST, list });
export const getHomeList = () => {
return (dispatch) => {
axios.get('https://lengyuexin.github.io/json/text.json')
.then((res) => {
const list = res.data.list.slice(0, 10)
dispatch(changeList(list))
});
};
}
3. home組件數據獲取
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { actionCreators } from './store'
class Home extends Component {
constructor(props) {
super(props)
}
componentDidMount() {
this.props.getHomeList()
}
render() {
return this.props.list.map(item => <div key={item.id}>{item.text}</div>)
}
}
const mapStateToProps = state => ({
list: state.home.list,
})
const mapDispatchToProps = dispatch => ({
getHomeList() {
dispatch(actionCreators.getHomeList());
}
})
export default connect(mapStateToProps, mapDispatchToProps)(Home)
4. 前端路由傳遞store
//src/client/index.js
import React from 'react';
import { hydrate } from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import Routes from '../routes'
import { Provider } from 'react-redux'
import store from '../store'
function App() {
return (
<Provider store={store}>
<BrowserRouter>
{Routes}
</BrowserRouter>
</Provider>
)
}
hydrate(<App />, document.getElementById("root"))
5. 後端路由傳遞store
//src/server/index.js
import express from 'express'
import {render} from './utils'
const app = new express();
app.use(express.static('public'))
app.get("*", (req, res) => {
res.send(render(req))
})
app.listen(3000, () => {
console.log('run server 3000')
})
//src/server/utils
import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'
import Routes from '../routes'
import store from '../store'
import { Provider } from 'react-redux'
export const render = (req) => {
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.path} >
{Routes}
</StaticRouter>
</Provider>
)
return `
<html>
<head>
<title>react-ssr</title>
</head>
<body>
<div id="root">${content}</div>
</body>
<script src="/index.js"></script>
</html>
`
}
6. 效果圖
7. 現存問題
並非真正意義上的服務端渲染,因爲在後端無法執行組件的掛載方法請求數據
界面上顯示的數據來源前端加載js代碼後的異步請求,本質是客戶端渲染
查看源碼,數據爲空
數據預加載
- 爲組件本身定義一個數據預加載的函數loadData,該函數在服務端直出頁面前調用,填充服務端store
- 服務端匹配路由對應的組件,調用匹配到的組件的loadData函數,將服務端尚且爲空的store傳入
- 讓客戶端數據請求的action返回一個promise,這樣loadData也會返回一個promise
- 服務端使用Promise.all方法,等待所有異步結果執行完,服務端store數據已經填充完畢,直出帶數據的頁面
1. 路由配置調整
//src/routes.js
import Home from './client/home'
import Show from './client/show'
export default [
{
path: "/",
component: Home,
exact: true,
loadData: Home.loadData,//服務端獲取異步數據的函數
key: 'home'
},
{
path: '/show',
component: Show,
exact: true,
key: 'show'
}
];
2. 前端路由改造
//src/clict/index.js
import React from 'react';
import { hydrate } from 'react-dom'
import { BrowserRouter,Route } from 'react-router-dom'
import Routes from '../routes'
import { Provider } from 'react-redux'
import store from '../store'
function App() {
return (
<Provider store={store}>
<BrowserRouter>
<div>
{
// 將配置屬性逐一傳入
Routes.map(route => {
return <Route {...route} />
})
}
</div>
</BrowserRouter>
</Provider>
)
}
hydrate(<App />, document.getElementById("root"))
3. 後端路由改造
//src/server/utils.js
import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter,Route } from 'react-router-dom'
import Routes from '../routes'
import store from '../store'
import { Provider } from 'react-redux'
export const render = (req) => {
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.path} >
<div>
{
Routes.map(route => {
return <Route {...route} />
})
}
</div>
</StaticRouter>
</Provider>
)
return `
<html>
<head>
<title>react-ssr</title>
</head>
<body>
<div id="root">${content}</div>
</body>
<script src="/index.js"></script>
</html>
`
}
4. 讓前端數據請求的action返回promise
export const getHomeList = () => {
return (dispatch) => {
//注意這裏的return
return axios.get('https://lengyuexin.github.io/json/text.json')
.then((res) => {
const list = res.data.list.slice(0, 10)
dispatch(changeList(list))
});
};
}
5. 前端組件定義數據預加載的靜態方法
//入參爲服務端store,返回一個填充好數據的store,形式爲promise
Home.loadData=(store)=>{
return store.dispatch(getHomeList())
}
6. 服務端根據路由匹配對應的組件
//src/server/index.js
import express from 'express'
//這個方法用於匹配路由
import { matchRoutes } from 'react-router-config'
import { render } from './utils'
import routes from '../routes'
import store from '../store'
const app = new express();
app.use(express.static('public'))
app.get("*", (req, res) => {
const matchedRoutes = matchRoutes(routes, req.path);
const promises = [];
matchedRoutes.forEach(item => {
if (item.route.loadData) {
promises.push(item.route.loadData(store));
};
});
//等待所有異步結果執行完畢,服務端直出頁面
Promise.all(promises).then(_=>{
res.send(render({
req,
store,
routes
}))
})
})
app.listen(3000, () => {
console.log('run server 3000')
})
//src/server/utils.js
import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter, Route } from 'react-router-dom'
import { Provider } from 'react-redux'
export const render = ({req,store,routes}) => {
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.path} >
<div>
{
// 將配置屬性逐一傳入
routes.map(route => {
return <Route {...route} />
})
}
</div>
</StaticRouter>
</Provider>
)
return `
<html>
<head>
<title>react-ssr</title>
</head>
<body>
<div id="root">${content}</div>
</body>
<script src="/index.js"></script>
</html>
`
}
7. 效果圖
8. 現存問題
註釋掉客戶端組件掛載階段的數據請求,頁面無數據,查看源碼,數據已經存在
原因:客戶端會再度運行一次代碼,重置客戶端store爲空,這個store與已有數據的服務端store不同步
數據的注水與脫水
在服務端直出帶數據的頁面時,將store存儲在全局變量中,爲前端store數據獲取做準備的過程叫做數據注水。
<script>
window.context = {
state: ${JSON.stringify(store.getState())}
}
</script>
前端獲取來自全局變量中的數據並填充自身,用於頁面數據渲染的過程叫數據脫水。
//src/store/index.js
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk'
import reducer from './reducer'
const store = createStore(reducer, applyMiddleware(thunk));
//獲取客戶端store
export const getClientStore = () => {
const defaultState = window.context ? window.context.state : {};
return createStore(reducer, defaultState, applyMiddleware(thunk));
}
export default store;
//src/client/index.js
//...
import {getClientStore} from '../store'
function App() {
return (
<Provider store={getClientStore()}>
// ....
</Provider>
)
}
//...
通過數據的注水與脫水解決客戶端和服務端數據不同步的問題。
componentDidMount() {
//服務端只會在第一次路由匹配的時候進行直出
//後續路由由瀏覽器接管
//這意味着第一次訪問的頁面有可能是沒數據的,如先訪問/show,後訪問/
//所以這裏需要做一個判斷,不重複渲染,但如果服務端沒拿到數據,就是客戶端渲染
if (!this.props.list.length) {
this.props.getHomeList()
}
}
多級路由
src/client/layout.js
import React from 'react';
import { renderRoutes } from 'react-router-config';
import Header from './header';
function App(props) {
return (
<div>
<Header />
{renderRoutes(props.route.routes)}
</div>
)
}
export default App
src/routes.js
import Home from './client/home'
import Show from './client/show'
import Layout from './client/layout'
export default [{
path: '/',
component: Layout,
routes: [
{
path: "/",
component: Home,
exact: true,
loadData: Home.loadData,
key: 'home'
},
{
path: '/show',
component: Show,
exact: true,
key: 'show'
}
]
},
];
src/client/index.js
//...
<BrowserRouter>
<div>
{renderRoutes(routes)}
</div>
</BrowserRouter>
//...
src/server/utils.js
//...
<StaticRouter location={req.path} >
<div>
{renderRoutes(routes)}
</div>
</StaticRouter>
//...
打包css
安裝style-loader,css-loader和用於服務端css處理的isomorphic-style-loader。
客戶端引入css文件,在服務端渲染前通過staticContext將樣式數據傳遞到服務端。
服務端StaticRouter接收一個context參數,在renderToString結束,樣式獲取完畢。
服務端直出context中的css樣式數據,前端接管後渲染樣式。
webpack.client.config.js
const path = require('path')
const webpackMerge = require("webpack-merge")
const baseConf = require("./webpack.base.config")
const clientConf = {
mode: 'development',
entry: './src/client/index.js',
output: {
filename: 'index.js',
path: path.resolve(__dirname, './public')
},
//增加css處理loader
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader', {
loader:'css-loader',
options:{
modules:true
}
}]
}
]
}
}
module.exports = webpackMerge(baseConf, clientConf)
webpack.server.config.js
const path = require('path')
const nodeExternals = require('webpack-node-externals');
const webpackMerge = require("webpack-merge")
const baseConf = require("./webpack.base.config")
const serverConf = {
mode: 'development',
target: 'node',
externals: [nodeExternals()],
entry: './src/server/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, './build')
},
//配置服務端css處理loader
module: {
rules: [{
test: /\.css?$/,
use: ['isomorphic-style-loader', {
loader: 'css-loader',
options: {
modules: true
}
}]
}]
},
}
module.exports = webpackMerge(baseConf, serverConf)
前端填充context(staticContext)
import homeCss from './home.css';
//...
constructor(props) {
super(props)
if (this.props.staticContext) {
this.props.staticContext.css.push(styles._getCss())
}
}
//...
server端獲取context
let context={css:[]}//初始化
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.path} context={context} >
<div>
{renderRoutes(routes)}
</div>
</StaticRouter>
</Provider>
)
//renderToString後context已經獲取到樣式數據
const cssStr = context.css.length ? context.css.join('\n') : '';
//服務端直出
return `
<html>
<head>
<title>react-ssr</title>
<style>${cssStr}</style>
</head>
<body>
<div id="root">${content}</div>
</body>
<script src="/index.js"></script>
<script>
window.context = {
state: ${JSON.stringify(store.getState())}
}
</script>
</html>
`
代碼優化-高階組件
爲避免帶樣式的組件重複書寫constructor中的樣式注入代碼,可定義一個接收組件和樣式,並返回帶樣式組件的高階組件
//src/client/StyleHOC.js
import React, { Component } from 'react';
export default (Comp, styles) => {
return class CompWithStyle extends Component {
constructor(props) {
super(props)
if (this.props.staticContext) {
this.props.staticContext.css.push(styles._getCss())
}
}
render() {
return <Comp {...this.props} />
}
}
}
高階組件的數據預加載
應用高階組件後,數據預加載方法loadData要定義在高階組件上而不是原組件
//...
const HomeHOC = connect(mapStateToProps, mapDispatchToProps)(StyleHOC(Home, homeCss));
HomeHOC.loadData = (store) => {
return store.dispatch(getHomeList())
}
export default HomeHOC;
//...
SEO
使用react-helmet完成seo,需要前端編寫seo相關代碼,服務端獲取後直出
前端代碼
import { Helmet } from 'react-helmet';
//...
render(){
return (
//...
<Helmet>
<title>服務端渲染</title>
<meta name="description" content="react ssr" />
</Helmet>
//...
)
}
//...
後端代碼
//該方法放在renderToString之後
const helmet = Helmet.renderStatic();
//直出代碼
//...
`<head>
<title>react-ssr</title>
${helmet.title.toString()}
${helmet.meta.toString()}
</head>`
//...
引入styled-components
需要安裝babel插件,前端使用方法不變,後端要做一些同構處理
//src/server/utils
import { ServerStyleSheet,StyleSheetManager } from 'styled-components';
//樣式初始化
const sheet = new ServerStyleSheet();
const content = renderToString(
//收集樣式
<StyleSheetManager sheet={sheet.instance}>
<Provider store={store}>
<StaticRouter location={req.path} context={context} >
<div>
{renderRoutes(routes)}
</div>
</StaticRouter>
</Provider>
</StyleSheetManager>
)
//獲取樣式表 <style>...</style>
const styles = sheet.getStyleTags();
//... 直出時帶上,不需額外加style標籤
`<head>
<title>react-ssr</title>
${styles}
</head>`
//...