Skip to content

React + Express 服务端渲染采坑日记 #34

@YIngChenIt

Description

@YIngChenIt

React + Express 服务端渲染采坑日记

前言

最近在研究SSR相关的知识,然后自己手撸了一个简单版的React + Express服务端渲染,跑通了路由、redux全家桶、异步获取数据、404页面、css处理等,这里把遇到的坑做一下小小的记录

源码

点击查看源码

SSR的基本思路

1、服务器根据react代码渲染生成html

2、将html发送给客户端进行解析显示

3、加载css、js

4、执行js文件,完成hydrate操作

5、客户端接管页面

react16 和 react15 ssr的不同点

renderToNodeStream 和 renderToString

在react15的ssr中我们使用renderToString将react组件转化为字符串然后服务端返回这个字符串,但是在react16中我们可以使用
renderToNodeStream,顾名思义支持直接渲染节点流

::: tip
渲染流可以减小第一个字节(TTFB)渲染时间,在文档的下一个部分生成之前,将文档的开头向下发送到浏览器。所有主流浏览器都会在服务器以这种方式流出内容时开始解析和呈现文档。

从呈现流中获得的另一个很棒的东西是响应backpressure的能力。这意味着,在实践中如果网络支持,不能接受更多的字节,渲染得到的信号与停顿渲染到堵塞清理。这意味着服务器使用更少的内存,对I/O条件更敏感,这两种情况都可以帮助服务器在充满挑战的条件下保持正常工作。
:::

我们来看下renderToNodeStream大致是如何使用的

// server.js
import { renderToNodeStream } from 'react-dom/server'
let express = require('express')
let app = express()

app.get('*', () => {
    let stream = renderToNodeStream(<Home />)
    res.write(`<!DOCTYPE html><html><head><title>React SSR</title></head><body><div id='root'>`);
    stream.pipe(res, { end: false });
    stream.on('end', () => {
        res.write(`</div></body></html>`);
        res.end();
    });
})

app.listen(3000, () => {
    console.log('server start')
})

hydrate 和 render

render遵从客户端渲染虽然保证了客户端代码的一致性,但是其需要对整个应用做dom diff和dom patch,其花销仍然不小。在React16中,为了减小开销,和区分render的各种场景,其引入了新的api - hydrate。

hydrate的策略与render的策略不一样,其并不会对整个dom树做dom patch,其只会对text Content内容做patch,对于属性并不会做patch

所以在客户端client.js里面我们会使用hydrate

// client.js
import React from 'react'
import ReactDOM from 'react-dom'
import Home from './container/Home'
ReactDOM.hydrate(<Home/>, document.getElementById('root'))

配置webpack不打包node模块

我们在搭ssr的时候,需要将服务端代码进行打包,打包出来的代码运行在node环境下,所以是不需要将一些node的核心模块如fs模块进行打包,减少打包之后的包体积

我们需要安装一个模块

cnpm i webpack-node-externals -D

然后在webpack的配置文件里面使用它

// webpack.config.js
const nodeExterbal = require('webpack-node-externals')

module.exports = {
    target: 'node', //打包出来文件运行的环境
    entry: './src/server/index.js',
    output: {
        path: path.resolve('build'),
        filename: 'server.js'
    },
    externals: [nodeExterbal()], // 负责检测全部引入的node核心模块,不把核心模块进行打包
}

这样就可以实现不打包node核心模块啦

优雅的处理事件

我们知道node服务端是没有DOM操作的,那react代码里面的事件我们该如何处理呢?

回想一下ssr的基本思路,我们只需要写一份客户端代码,然后打包成client.js文件,在服务端返回的html结构里面引入就可以实现事件处理了

// server.js
app.get('*', () => {
    let stream = renderToNodeStream(<Home />)
    res.write(`<!DOCTYPE html><html><head><title>React SSR</title></head><body><div id='root'>`);
    stream.pipe(res, { end: false });
    stream.on('end', () => {
        res.write(`</div>
         <script src="/client.js"></script> // 新
        </body></html>`);
        res.end();
    });
})

client.js文件 和 server.js文件上午有所提及,这里主要记录一下webpack分包打包处理

首先我们抽离公用的webpack配置

// webpack.base.js
module.exports = {
    mode: 'development',
    module: {
        rules: [
            {
                test: /\.js$/,
                loader: 'babel-loader',
                exclude: /node_modules/,
                options: {
                    presets: [
                        '@babel/preset-env',
                        '@babel/preset-react'
                    ],
                    plugins: [
                        '@babel/plugin-proposal-class-properties'
                    ]
                }
            }
        ]
    }
}

服务端和客户端的webpack配置和基础配置进行合并,所以我们需要安装一个模块

cnpm i webpack-merge -D

然后我们可以进行配置合并了

// webpack.clinet.js
const path = require('path')
const merge = require('webpack-merge')
const base = require('./webpack.base')
module.exports = merge(base, {
    entry: './src/client/index.js',
    output: {
        path: path.resolve('public'),
        filename: 'client.js'
    },
})
// webpack.server.js
const path = require('path')
const nodeExterbal = require('webpack-node-externals')
const merge = require('webpack-merge')
const base = require('./webpack.base')
module.exports = merge(base, {
    target: 'node', //打包出来文件运行的环境
    entry: './src/server/index.js',
    output: {
        path: path.resolve('build'),
        filename: 'server.js'
    },
    externals: [nodeExterbal()], // 负责检测全部引入的node核心模块,不把核心模块进行打包
})

然后我们写2个脚本,对客户端代码和服务端代码进行分别打包

// pachage.json
  "scripts": {
    "dev:build:client": "webpack --config webpack.client.js --watch",
    "dev:build:server": "webpack --config webpack.server.js --watch"
  }

实现服务端客户端同时打包

如果用过nuxt的会知道打包编译的时候会出现2个进度条,分别是服务端打包和客户端打包,那我们是如何实现修改代码之后服务端和客户端同时打包的呢?

我们需要一个模块

cnpm i npm-run-all -g

然后我们配置一下脚本

// pachage.json
  "scripts": {
    "dev": "npm-run-all --parallel dev:**",
    "dev:start": "nodemon build/server.js",
    "dev:build:client": "webpack --config webpack.client.js --watch",
    "dev:build:server": "webpack --config webpack.server.js --watch"
  }

通过npm-run-all --parallel dev:**这个脚本我们可以实现代码发生修改,服务端和客户端同时打包和重启了

跑通redux全家桶

跑通redux全家桶(redux、react-redux、redux-logger、redux-thunk...)的时候我们需要提供2个仓库,分别是客户端仓库和服务端仓库,其余的步骤和在react普通项目里面使用redux全家桶一致

import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import logger from 'redux-logger'
import reducers from './reducers'

export function getServerStore() { // 提供获取服务端仓库的方法
    return createStore(reducers, applyMiddleware(thunk, logger))
}

export function getClientStore() { // 提供获取客户端仓库的方法
    return createStore(reducers, applyMiddleware(thunk, logger))
}

因为我们使用react-redux进行连接,所以clinet.jsserver.js分别调用对应的获取仓库的方法

// client.js
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import { getClientStore } from '../store'

let store = getClientStore()
ReactDOM.hydrate(
    <Provider store={store}>
        ...
    </Provider>
    , document.getElementById('root'))
// server.js
    import { Provider } from 'react-redux'
    import { getServerStore } from '../store'
    let store = getServerStore()
    let stream = renderToNodeStream(
        <Provider store={store}>
            ...
        </Provider>
    )

跑通路由

实现路由功能的时候我们需要注意的是,react-router给我们提供了2种路由组件,分别是BrowserRouterStaticRouter,其中前者用于客户端代码路由,后者用于服务端代码路由

首先我们需要写一个路由配置表

// routes.js
import Home from './containers/Home'
import Counter from './containers/Counter'
import App from './containers/App.js'

export default [
    {
        path: '/',
        component: App,
        components: [ // 子路由
            {
                path: '/',
                component: Home,
                exact: true,
                key: '/',
            },
            {
                path: '/counter',
                component: Counter,
                key: '/counter',
            },
        ]
    }
]

其中App是我们项目的根组件,HomeCounter为页面组件

然后我们看下服务端对路由的处理,首先我们需要一个模块

cnpm i react-router-config

通过这个模块可以根据我们的路由配置表渲染对应的组件

// server.js
import { renderToNodeStream } from 'react-dom/server'
import { StaticRouter } from "react-router-dom"
import routes from '../routes'
import { renderRoutes } from 'react-router-config'
import { getServerStore } from '../store'
import { Provider } from 'react-redux'

let express = require('express')
let app = express()

app.get('*', (req, res) => {
    let store = getServerStore()
    let context = {}
    let stream = renderToNodeStream(
        <Provider store={store}>
            <StaticRouter context={context} location={req.path}>
                {renderRoutes(routes)}
            </StaticRouter>
        </Provider>
    )
    res.write(`<!DOCTYPE html><html><head><title>React SSR</title></head><body><div id='root'>`);
    stream.pipe(res, { end: false });
    stream.on('end', () => {
        res.write(`</div></body></html>`);
        res.end();
    });
})

app.listen(3000, () => {
    console.log('server start')
})

::: tip
服务端代码使用StaticRouter的时候我们发现传入了一个context,我们可以在组件内通过this.props.staticContext来获取对应的属性进行读和写
:::

我们再看下客户端代码

// client.js
import React from 'react'
import ReactDOM from 'react-dom'
import routes from '../routes'
import { BrowserRouter } from 'react-router-dom'
import { Provider } from 'react-redux'
import { getClientStore } from '../store'
import { renderRoutes } from 'react-router-config'

let store = getClientStore()
ReactDOM.hydrate(
    <Provider store={store}>
        <BrowserRouter>
            {renderRoutes(routes)}
        </BrowserRouter>
    </Provider>
    , document.getElementById('root'))

异步获取数据

在正常的项目中,我们一般通过调接口异步获取数据,那我们的ssr项目是如果实现异步获取数据的呢?

假设我们需要在首页获取异步数据,首先我们需要在路由配置表添加一个属性

// routes.js
    {
        path: '/',
        component: App,
        components: [ 
            {
                path: '/',
                component: Home,
                exact: true,
                key: '/',
                loadData: Home.loadData, //如果有这个配置项,表示需要异步加载数据
            },
        ]
    }

我们增加了loadData属性,他是一个组件内部提供的获取数据的方法,我们来看下这个方法

// Home/index.js
class Home extends Component {
    ...
}
Home.loadData = function (store) { // 加载数据的方法
    // diapatch方法的返回值就是派发的action,最终返回一个promise
    return store.dispatch(actions.getHomeList())
}

我们再来看下actions里面的getHomeList方法

import * as types from '../actions-types'
import axios from 'axios'
export default {
    getHomeList() {
        return function(dispatch, getState) {
            return axios.get('http://localhost:4000/api/users')
                .then((res) =>{
                    const list = res.data
                    dispatch({
                        type: types.SET_HOME_LIST,
                        payload: list,
                    })
                })
        }
    }
}

那现在获取异步数据的方法有了,我们服务端该怎么处理呢?

// server.js
    import { renderRoutes, matchRoutes } from 'react-router-config'
    // matchRoutes 方法可以拿到匹配当前url的路由配置
    let matchedRoutes = matchRoutes(routes, req.path)
    let promises = []
    matchedRoutes[0].route.components.forEach(route => {
        if (route.loadData) { // 需要加载数据
            promises.push(route.loadData(store))
        }
    })
    Promise.all(promises).then(() => {
        ...
        // 执行返回html给客户端的逻辑
    })

也就是我们需要当前页面所需的全部数据下来之后才会返回html给客户端,这样就解决了异步问题了

但是如果是用Promise.all,那如果有一个接口挂掉我们页面就显示不出来了,我们也有办法解决

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

    let matchedRoutes = matchRoutes(routes, req.path)
    let promises = []
    matchedRoutes[0].route.components.forEach(route => {
        if (route.loadData) { 
            promises.push(new Promise(function(resolve) {
                return route.loadData(store).then(resolve, resolve) // 不管成功和失败,都调用resolve,所以Promise.all永远会成功
            }))
        }
    })
    Promise.all(promises).then(() => {
        ...
        // 执行返回html给客户端的逻辑
    })

处理404页面

在项目里面每当我们访问不存在的路由的时候,应该自动跳转到404页面,我们来实现一下吧

// routes.js
import NotFound from './containers/NotFound'
import App from './containers/App.js'
export default [
    {
        path: '/',
        component: App,
        components: [ 
            {
                component: NotFound, // 添加404路由
                key: '/notFound',
            },
        ]
    }
]

这样的话就可以实现访问不存在的路由的时候自动跳转到404页面了,但是还有一个小小的缺陷,我们请求的html文件还是返回的200状态码,应该是返回404才合理

实现这样的效果需要我们在组件和服务端做下处理

// NotFound.js
import React, {Component} from 'react'
class NotFound extends Component {
    componentDidMount() {
        if (this.props.staticContext) {
            this.props.staticContext.notFound = true
        }
    }
    render() {
        return (
            <div>404</div>
        )
    }
}
export default NotFound

在上文介绍中我们知道,服务端使用StaticRouter,传入了一个参数context,那么在组件中就可以读和写这个参数

    <StaticRouter context={context} location={req.path}>
        {renderRoutes(routes)}
    </StaticRouter>

那我们现在在404页面组件往context上写了一个属性notFound,在服务端通过这个参数判断是否修改状态码为404就好了,重定向也是一样的原理

// server.js
    if (context.notFound) {
        res.statusCode = 404
    }

处理css样式

我们需要这样使用css样式

// index.css
.name {
    color: red
}
// Home.js
import styles from './index.css'
<div className={styles.name}>chenying</div>

但是单单这样使用是不行的,我们需要配置相关的loader

// webpack.client.js
    module: {
        rules: [
            {
                test: /\.css$/,
                use: [
                    'style-loader',
                    {
                        loader: 'css-loader',
                        options: {
                            modules: true
                        }
                    }
                ]
            }
        ]
    }

对于客户端来说我们还是按照正常的loader配置就好了

// webpack.server.js
    module: {
        rules: [
            {
                test: /\.css$/,
                use: [
                    'isomorphic-style-loader',
                    {
                        loader: 'css-loader',
                        options: {
                            modules: true
                        }
                    }
                ]
            }
        ]
    }

对于服务端来说是不可以使用style-loader的,因为style-loader的原理是创建一个style插入进去,这个时候我们需要使用isomorphic-style-loader替换掉style-loader就好了

总结

坑还有很多要填,一步一脚印

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions