react服务端渲染实践2: 数据预渲染

929 阅读6分钟

前言

上一章节,我们了解了react 服务端渲染的工程化配置,这一章节讲解如何进行数据预渲染,这是服务端渲染的核心目的。

以下示例中的代码有部分删减,具体以 git 仓库中的代码为准

redux

无论是 react 的服务端渲染数据预取,还是 vue 的服务端渲染数据预取,都离不开状态管理工具,这里使用到的是 redux,如果不懂 redux 的使用,建议先学习后再阅读此文章,本篇不具体讲解 redux 的使用。

首先建立 store 目录,用于存放 redux 相关文件,目录结构如下:

image

使用了两个入口区分服务端以及客户端 store,其中文件内容如下:

// index.ts

import { combineReducers } from 'redux';
import indexPage from './modules/index'
import classificationsPage from './modules/classifications'
import goodsDetailPage from './modules/goods_detail'

export const rootReducer = combineReducers({
    indexPage,
    classificationsPage,
    goodsDetailPage
})

index.ts 文件将 redux 模块进行了合并。

// client.ts

import { createStore } from 'redux'
import { rootReducer } from './index'

export default createStore(rootReducer, window.INIT_REDUX_STORE || {})

client.ts 返回了一个 store 对象,其中初始化的数据来自 window.INIT_REDUX_STORE,这个后文会详细讲解。

// server.ts

import { createStore } from 'redux'
import { rootReducer } from './index'

export function createServerStore() {
    return createStore(rootReducer)
}

server.ts 返回了一个创建store的工厂函数,因为每次请求都需要一个全新的 store 存储数据,不能被之前的请求污染。

入口文件的变化

然后修改之前的入口文件如下:

// app.tsx

import * as React from 'react'
import { Switch } from 'react-router-dom'
import { renderRoutes } from 'react-router-config'
import Login from './src/view/login'
import Main from './src/view/main/router'
import GoodsDetail from './src/view/goods_detail/goods_detail'
import NotFoundPage from './src/view/404/404'

export const routes = [
    Main, 
{
    path: '/app/login',
    component: Login,
    exact: true
}, {
    path: '/app/goodsDetail/:id',
    component: GoodsDetail,
    exact: true
}, {
    component: NotFoundPage
}]

interface componentProps {
}

interface componentStates {
}

class App extends React.Component<componentProps, componentStates> {
    constructor(props) {
        super(props)
    }

    render() {
        return (
            <Switch>
                {renderRoutes(routes)}

            </Switch>
        )
    } 
}

export default App

这里单独将 routes 导出是因为服务端渲染需要用到这个 routes 配置,其余与之前相比无变化。

// client.entry.tsx

import * as React from 'react'
import { hydrate } from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import App from './app'
// import JssProvider from 'react-jss/lib/JssProvider';
import { Provider as ReduxProvider } from 'react-redux'
import store from '@src/store/client'

hydrate(<ReduxProvider store={store}>
                <BrowserRouter>
                    <App/>
                </BrowserRouter>
        </ReduxProvider>, document.getElementById('root'))

客户端入口与之前相比变化不大,添加了 ReduxProvider

// server.entry.tsx

import * as React from 'react'
const { renderToString } = require("react-dom/server")
import { matchRoutes } from 'react-router-config'
import { StaticRouter } from "react-router"
import { Provider as ReduxProvider } from 'react-redux'
import { createServerStore } from '@src/store/server'
import App, { routes } from './app'


export default async (req, context = {}) => {
    const store = createServerStore()
    const matchedRoute = matchRoutes(routes, req.url)
    return await Promise.all(
        matchedRoute
            .filter(value => (value.route.component as any).getAsyncData)
            .map(value => (value.route.component as any).getAsyncData(store, req))
    ).then(() => {
        const sheets = new ServerStyleSheets();
        return {
            store: store.getState(),
            html: renderToString(
                sheets.collect(
                    <ReduxProvider store={store}>
                            <StaticRouter location={req.url} context={context}>
                                <App/>
                            </StaticRouter>
                    </ReduxProvider>,
                  ),
            )
        }
    })
}

服务端入口代码修改较多,这里详细进行讲解。

路由配置

服务端渲染因为涉及到数据预取,所以无法像客户端渲染那样使用动态路由,只能使用静态路由进行配置,所以需要使用 react-router-config 配置一个路由对象:

// app.tsx
import Main from './src/view/main'
import Index from './src/view/index/index'
import Login from './src/view/login'
import Classifications from './src/view/classifications/classifications'
import Cart from './src/view/cart/cart'
import GoodsDetail from './src/view/goods_detail/goods_detail'
import NotFoundPage from './src/view/404/404'

export const routes = [
{
    path: '/app/main',
    component: Main,
    routes: [{
        path: '/app/main/index',
        component: Index
    }, {
        path: '/app/main/classifications',
        component: Classifications
    }, {
        path: '/app/main/cart',
        component: Cart
    }]
}, 
{
    path: '/app/login',
    component: Login,
    exact: true
}, {
    path: '/app/goodsDetail/:id',
    component: GoodsDetail,
    exact: true
}, {
    component: NotFoundPage
}]

在组件中需要使用 renderRoutes 函数渲染路由:

import { renderRoutes } from 'react-router-config'

render() {
    return (
        <div>
            {renderRoutes(this.props.route.routes)}
        </div>
    )
} 

请求数据

当静态路由配置完成,每次服务端接收到请求,都可以通过请求的url找到需要渲染的页面,这里用到的是 matchRoutes 函数,示例如下:

import { routes } from 'app.tsx'
import { matchRoutes } from 'react-router-config'
export default async (req, context = {}) => {
    const matchedRoute = matchRoutes(routes, req.url)
}

既然知道了哪个页面需要渲染,那么指定数据的获取就好办了。

在路由组件中定义静态函数 getAsyncData, 接收一个 store 对象,这个 store 就是 redux 的 store。返回一个 promise 函数,用于数据的请求,以及后续步骤的处理,在数据请求完成后,向 store 派发更新,将获取到的数据存储在 store 中。

class Classifications extends React.Component<IProps & RouteComponentProps, IStates> {
    static getAsyncData(store) {
        return fetch.getClassifications({
            page: 0,
            limit: 10000
        }, `
            id
            name
            products {
                id
                name
                thumbnail
                intro
                price
            }
        `).then(data => {
            store.dispatch({
                type: 'SET_CLASSIFICATIONS_DATA',
                value: data.classifications.rows
            })
        }).catch(e => {

        })
    }
}

那么在接收到请求时,是如何处理的呢?接下来将以下代码拆分进行讲解。

// server.entry.tsx

import * as React from 'react'
const { renderToString } = require("react-dom/server")
import { matchRoutes } from 'react-router-config'
import { StaticRouter } from "react-router"
import { Provider as ReduxProvider } from 'react-redux'
import { createServerStore } from '@src/store/server'
import App, { routes } from './app'


export default async (req, context = {}) => {
    const store = createServerStore()
    const matchedRoute = matchRoutes(routes, req.url)
    return await Promise.all(
        matchedRoute
            .filter(value => (value.route.component as any).getAsyncData)
            .map(value => (value.route.component as any).getAsyncData(store, req))
    ).then(() => {
        return {
            store: store.getState(),
            html: renderToString(
                    <ReduxProvider store={store}>
                            <StaticRouter location={req.url} context={context}>
                                <App/>
                            </StaticRouter>
                    </ReduxProvider>,
                  ),
        }
    })
}

首先通过 matchRoutes 找到与 url 匹配的路由组件。

const matchedRoute = matchRoutes(routes, req.url)

其次对于匹配的组件进行过滤,然后调用组件类的静态函数, 将创建的 store 作为参数传入 getAsyncData 函数中,用于 store 的更新。

const store = createServerStore()
Promise.all(
    matchedRoute
        .filter(value => (value.route.component as any).getAsyncData)
        .map(value => (value.route.component as any).getAsyncData(store, req))
)

当 Promise.all 中的请求都完成后,store 中的数据已经更新成需要渲染的数据。最终将 store 传入 ReduxProvider 进行渲染,得到填充完数据的 html 字符串返回给客户端。

return {
    store: store.getState(),
    html: renderToString(
            <ReduxProvider store={store}>
                    <StaticRouter location={req.url} context={context}>
                        <App/>
                    </StaticRouter>
            </ReduxProvider>,
            ),
}

至于组件中如何使用 store 数据这里不细说。

最后再看看服务端的代码:

const path = require('path')
const serverEntryBuild = require('./dist/server.entry.js').default
const ejs = require('ejs')
const express = require('express')

module.exports = function(app) {
    app.use(express.static(path.resolve(__dirname, './static')));
    app.use(async (req, res, next) => {
        const reactRenderResult = await serverEntryBuild(req)
        ejs.renderFile(path.resolve(process.cwd(), './app/server/dist/index.ejs'), {
            store: JSON.stringify(reactRenderResult.store),
            html: reactRenderResult.html
        }, {
            delimiter: '?',
            strict: false
        }, function(err, str){
            if (err) {
                console.log(err)
                res.status(500)
                res.send('渲染错误')
                return
            }
            res.end(str, 'utf-8')
        })
    });
}

这里在收到http请求后,调用服务端渲染函数,传入当前 request 对象,服务端入口根据 request url渲染对应页面,返回一个填充了数据的 html 字符串,以及 store 中的 state,接着使用 ejs 渲染这两个数据返回到客户端,完成一次服务端渲染,ejs模版 内容如下:

<!DOCTYPE html>
<html lang="zh">
    <head>
        <title>My App</title>
        <meta charset="UTF-8">
        <meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no">
        <script>
            window.INIT_REDUX_STORE = <?- store ?>
        </script>
        <link href="./static/main.e61e8228d12dd0cba063.css" rel="stylesheet"></head>
        <body>
        <div id="root"><?- html ?></div>
        <script type="text/javascript" src="./static/commons.d50e3adb1df381cdc21b.js"></script>
        <script type="text/javascript" src="./static/bundle.e61e8228d12dd0cba063.js"></script></body>
</html>

需要注意的是将 store 中的 state 也返回给了前端,在前端初始化时,将这份数据填充进客户端的 store 中供客户端使用:

<script>
    window.INIT_REDUX_STORE = <?- store ?>
</script>
// 客户端 store 入口
import { createStore } from 'redux'
import { rootReducer } from './index'

export default createStore(rootReducer, window.INIT_REDUX_STORE || {})

目的在于前端可以根据这些数据判断服务端渲染是否成功,以及是否需要再次请求数据,具体逻辑如下:

componentDidMount() {
    if (this.props.classifications.length === 0) { // 如果 store 中不存在相应的数据,那么请求数据,否则不请求
        this.getClassificationsData()
    }
}

到这里,react ssr 数据预渲染就完成了,以下仓库提供完整代码以供参考。

仓库地址: github.com/Richard-Cho…