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')
})

路由

前端路由使用方式不變,後端使用靜態路由完成同構

  1. 首次訪問界面,服務端直出路由匹配到的組件
  2. 之後的路由跳轉皆由瀏覽器接管

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. 效果圖

接入redux獲取數據

7. 現存問題

並非真正意義上的服務端渲染,因爲在後端無法執行組件的掛載方法請求數據
界面上顯示的數據來源前端加載js代碼後的異步請求,本質是客戶端渲染
查看源碼,數據爲空

現存問題

數據預加載

  1. 爲組件本身定義一個數據預加載的函數loadData,該函數在服務端直出頁面前調用,填充服務端store
  2. 服務端匹配路由對應的組件,調用匹配到的組件的loadData函數,將服務端尚且爲空的store傳入
  3. 讓客戶端數據請求的action返回一個promise,這樣loadData也會返回一個promise
  4. 服務端使用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>`
    //...

源碼

參考

React服務器渲染原理解析與實踐

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章