详解React项目的服务端渲染改造(koa2+webpack3.11)

三、React + Redux

React的中的数据是单向流动的,即父组件状态改变之后,可以通过props将属性传递给子组件,但子组件并不能直接修改父级的组件。

一般需要通过调用父组件传来的回调函数来间接地修改父级状态,或者使用 Context ,使用 事件发布订阅机制等。

引入了Redux进行状态管理之后,就方便一些了。不过会增加代码复杂度,另外要注意的是,React 16的新的Context特性貌似给Redux带来了不少冲击

 

在React项目中使用Redux,当某个处理有比较多逻辑时,遵循胖action瘦reducer,比较通用的建议时将主要逻辑放在action中,在reducer中只进行更新state的等简单的操作

一般还需要中间件来处理异步的动作(action),比较常见的有四种 redux-thunk  redux-saga  redux-promise  redux-observable ,它们的对比

这里选用了 redux-saga,它比较优雅,管理异步也很有优势

 

来看看项目结构

图片 1

我们将 home组件拆分出几个子组件便于维护,也便于和Redux进行关联

home.js 为入口文件

使用 Provider 包装组件,传入store状态渲染组件

import React, {Component} from 'react';
import {render, findDOMNode} from 'react-dom';
import {Provider} from 'react-redux';

// 组件入口
import Home from './homeComponent/Home.jsx';
import store from './store';

/**
 * 组装Redux应用
 */
class App extends Component {
    render() {
        return (
            <Provider store={store}>
                <Home />
            </Provider>
        )
    }
}

render(<App />, document.getElementById('content'));

store/index.js 中为状态创建的过程

这里为了方便,就把服务端渲染的部分也放在一起了,实际上它们的区别不是很大,仅仅是 defaultState初始状态的不同而已

import {createStore, applyMiddleware, compose} from 'redux';
import createSagaMiddleware from 'redux-saga';
// import {thunk} from 'redux-thunk';

import reducers from './reducers';
import wordListSaga from './workListSaga';
import state from './state';

const sagaMiddleware = createSagaMiddleware();

const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;

let defaultState = state;

// 用于SSR
// 根据服务器返回的初始状态来初始化
if (typeof PRELOAD_STATE !== 'undefined') {
    defaultState = Object.assign({}, defaultState, PRELOAD_STATE);
    // 清除
    PRELOAD_STATE = null;
    document.getElementById('preload-state').remove();
}

let store = createStore(
    reducers,
    defaultState,
    composeEnhancers(
        applyMiddleware(sagaMiddleware)
    ));

sagaMiddleware.run(wordListSaga);

export default store;

我们将一部分action(基本是异步的)交给saga处理

在workListSaga.js中,

图片 2图片 3

 1 import {delay} from 'redux-saga';
 2 import {put, fork, takeEvery, takeLatest, call, all, select} from 'redux-saga/effects';
 3 
 4 import * as actionTypes from './types';
 5 
 6 /**
 7  * 获取用户信息
 8  * @yield {[type]} [description]
 9  */
10 function* getUserInfoHandle() {
11     let state = yield select();
12 
13     return yield new Promise((resolve, reject) => {
14         setTimeout(() => {
15             resolve({
16                 sex: 'male',
17                 age: 18,
18                 name: '王羲之',
19                 avatar: '/public/static/imgs/avatar.png'
20             });
21         }, 500);
22     });
23 }
24 
25 /**
26  * 获取工作列表
27  * @yield {[type]} [description]
28  */
29 function* getWorkListHandle() {
30     let state = yield select();
31 
32     return yield new Promise((resolve, reject) => {
33         setTimeout(() => {
34             resolve({
35                 todo: [{
36                     id: '1',
37                     content: '跑步'
38                 }, {
39                     id: '2',
40                     content: '游泳'
41                 }],
42 
43                 done: [{
44                     id: '13',
45                     content: '看书'
46                 }, {
47                     id: '24',
48                     content: '写代码'
49                 }]
50             });
51         }, 1000);
52     });
53 }
54 
55 /**
56  * 获取页面数据,action.payload中如果为回调,可以处理一些异步数据初始化之后的操作
57  * @param {[type]} action        [description]
58  * @yield {[type]} [description]
59  */
60 function* getPageInfoAsync(action) {
61     console.log(action);
62 
63     let userInfo = yield call(getUserInfoHandle);
64 
65     yield put({
66         type: actionTypes.INIT_USER_INFO,
67         payload: userInfo
68     });
69 
70     let workList = yield call(getWorkListHandle);
71 
72     yield put({
73         type: actionTypes.INIT_WORK_LIST,
74         payload: workList
75     });
76 
77     console.log('saga done');
78 
79     typeof action.payload === 'function' && action.payload();
80 }
81 
82 /**
83  * 获取页面数据
84  * @yield {[type]} [description]
85  */
86 export default function* getPageInfo() {
87     yield takeLatest(actionTypes.INIT_PAGE, getPageInfoAsync);
88 }

View Code

监听页面的初始化action actionTypes.INIT_PAGE ,获取数据之后再触发一个action ,转交给reducer即可

let userInfo = yield call(getUserInfoHandle);

    yield put({
        type: actionTypes.INIT_USER_INFO,
        payload: userInfo
    });

reducer中做的事主要是更新状态,

import * as actionTypes from './types';
import defaultState from './state';

/**
 * 工作列表处理
 * @param  {[type]} state  [description]
 * @param  {[type]} action [description]
 * @return {[type]}        [description]
 */
function workListReducer(state = defaultState, action) {
    switch (action.type) {
        // 初始化用户信息
        case actionTypes.INIT_USER_INFO:
            // 返回新的状态
            return Object.assign({}, state, {
                userInfo: action.payload
            });

        // 初始化工作列表
        case actionTypes.INIT_WORK_LIST:
            return Object.assign({}, state, {
                todo: action.payload.todo,
                done: action.payload.done
            });

        // 添加任务
        case actionTypes.ADD_WORK_TODO:
            return Object.assign({}, state, {
                todo: action.payload
            });

        // 设置任务完成
        case actionTypes.SET_WORK_DONE:
            return Object.assign({}, state, {
                todo: action.payload.todo,
                done: action.payload.done
            });

        default:
            return state
    }
}

在 action.js中可以定义一些常规的action,比如

export function addWorkTodo(todoList, content) {
    let id = Math.random();

    let todo = [...todoList, {
        id,
        content
    }];

    return {
        type: actionTypes.ADD_WORK_TODO,
        payload: todo
    }
}

/**
 * 初始化页面信息
 * 此action为redux-saga所监听,将传入saga中执行
 */
export function initPage(cb) {
    console.log(122)
    return {
        type: actionTypes.INIT_PAGE,
        payload: cb
    };
}

回到刚才的 home.js入口文件,在其引入的主模块 home.jsx中,我们需要将redux的东西和这个 home.jsx绑定起来

import {connect} from 'react-redux';

// 子组件
import User from './user';
import WorkList from './workList';

import  {getUrlParam} from '../util/util'
import '../../scss/home.scss';

import {
    initPage
} from '../store/actions.js';

/**
 * 将redux中的state通过props传给react组件
 * @param  {[type]} state [description]
 * @return {[type]}       [description]
 */
function mapStateToProps(state) {
    return {
        userInfo: state.userInfo,
        // 假如父组件Home也需要知悉子组件WorkList的这两个状态,则可以传入这两个属性
        todo: state.todo,
        done: state.done
    };
}

/**
 * 将redux中的dispatch方法通过props传给react组件
 * @param  {[type]} state [description]
 * @return {[type]}       [description]
 */
function mapDispatchToProps(dispatch, ownProps) {
    return {
        // 通过props传入initPage这个dispatch方法
        initPage: (cb) => {
            dispatch(initPage(cb));
        }
    };
}

...

class Home extends Component {
...

export default connect(mapStateToProps, mapDispatchToProps)(Home);

当然,并不是只能给store绑定一个组件

如果某个组件的状态可以被其他组件共享,或者这个组件需要访问store,按根组件一层一层通过props传入很麻烦的话,也可以直接给这个组件绑定store

比如这里的 workList.jsx 也进行了绑定,user.jsx这种只需要展示数据的组件,或者其他一些自治(状态在内部管理,和外部无关)的组件,则不需要引入redux的store,也挺麻烦的

 

绑定之后,我们需要在 Home组件中调用action,开始获取数据

   /**
     * 初始获取数据之后的某些操作
     * @return {[type]} [description]
     */
    afterInit() {
        console.log('afterInit');
    }

    componentDidMount() {
        console.log('componentDidMount');

        // 初始化发出 INIT_PAGE 操作
        this.props.initPage(() => {
            this.afterInit();
        });
    }

这里有个小技巧,如果在获取异步数据之后要接着进行其他操作,可以传入 callback ,我们在action的payload中置入了这个 callback,方便调用

然后Home组件中的已经没有多少state了,已经交由store管理,通过mapStateToProps传入

所以可以根据props拿到这些属性

<User {...this.props.userInfo} />

或者调用传入的 reducer ,间接地派发一些action

    // 执行 ADD_WORK_TODO
        this.props.addWorkTodo(this.props.todo, content.trim());

 

页面呈现

图片 4

 

  1. 本地开发使用webpack-dev-server,实现热更新,基本流程跟之前react开发类似,仍是浏览器端渲染,因此在编写代码时要考虑到一套逻辑,两种渲染环境的问题。
  2. 当前端页面渲染完成后,其Router跳转将不会对服务端进行请求,从而减轻服务端压力,从而页面的进入方式也是两种,还要考虑两种渲染环境下路由同构的问题。
  3. 生产环境要使用koa做后端服务器,实现按需加载,在服务端获取数据,并渲染出整个HTML,利用React16最新的能力来合并整个状态树,实现服务端渲染。

项目地址 ,欢迎围观!

接下来看以下src/app目录下的文件,index.js暴露了三个方法,这里面涉及的三个方法在服务端和浏览器端开发都会用到,这一部分主要讲其下的router文件里面的代码思路和createApp.js文件对路由的处理,这里是实现两端路由相互打通的关键点。

使用 redux-saga 处理异步action,使用 express 处理页面渲染

本地开发介绍

 

├── assets
│ └── index.css //放置一些全局的资源文件 可以是js 图片等
├── config
│ ├── webpack.config.dev.js 开发环境webpack打包设置
│ └── webpack.config.prod.js 生产环境webpack打包设置
├── package.json
├── README.md
├── server server端渲染文件,如果对不是很了解,建议参考[koa教程](http://wlxadyl.cn/2018/02/11/koa-learn/)
│ ├── app.js
│ ├── clientRouter.js // 在此文件中包含了把服务端路由匹配到react路由的逻辑
│ ├── ignore.js
│ └── index.js
└── src
 ├── app 此文件夹下主要用于放置浏览器和服务端通用逻辑
 │ ├── configureStore.js //redux-thunk设置
 │ ├── createApp.js  //根据渲染环境不同来设置不同的router模式
 │ ├── index.js
 │ └── router
 │  ├── index.js
 │  └── routes.js  //路由配置文件! 重要
 ├── assets
 │ ├── css    放置一些公共的样式文件
 │ │ ├── _base.scss  //很多项目都会用到的初始化css
 │ │ ├── index.scss
 │ │ └── my.scss
 │ └── img
 ├── components    放置一些公共的组件
 │ ├── FloatDownloadBtn 公共组件样例写法
 │ │ ├── FloatDownloadBtn.js
 │ │ ├── FloatDownloadBtn.scss
 │ │ └── index.js
 │ ├── Loading.js
 │ └── Model.js   函数式组件的写法
 │
 ├── favicon.ico
 ├── index.ejs    //渲染的模板 如果项目需要,可以放一些公共文件进去
 ├── index.js    //包括热更新的逻辑
 ├── pages     页面组件文件夹
 │ ├── home
 │ │ ├── components  // 用于放置页面组件,主要逻辑
 │ │ │ └── homePage.js
 │ │ ├── containers  // 使用connect来封装出高阶组件 注入全局state数据
 │ │ │ └── homeContainer.js
 │ │ ├── index.js  // 页面路由配置文件 注意thunk属性
 │ │ └── reducer
 │ │  └── index.js // 页面的reducer 这里暴露出来给store统一处理 注意写法
 │ └── user
 │  ├── components
 │  │ └── userPage.js
 │  ├── containers
 │  │ └── userContainer.js
 │  └── index.js
 └── store
  ├── actions   // 各action存放地
  │ ├── home.js
  │ └── thunk.js
  ├── constants.js  // 各action名称汇集处 防止重名
  └── reducers
   └── index.js  // 引用各页面的所有reducer 在此处统一combine处理

四、React + Redux + SSR

可以看到上图是有一些闪动的,因为数据不是一开始就存在

考虑加入SSR,先来看看最终页面效果,功能差不多,但直接出来了,看起来很美好呀~

图片 5

在Redux中加入SSR, 其实跟纯粹的React组件是类似的。

官方给了一个简单的例子

都是在服务器端获取初始状态后处理组件为字符串,区别主要是React直接使用state, Redux直接使用store

浏览器中我们可以为多个页面使用同一个store,但在服务器端不行,我们需要为每一个请求创建一个store

 

再来看项目结构,Redux的SSR使用到了红框中的文件

图片 6

服务端路由homeSSR与messageSSR类似,都是返回数据

服务端入口文件 server中的home.js 则是创建一个新的 store, 然后传入ReactDOMServer进行处理返回

import {createStore} from 'redux';
import reducers from '../store/reducers';
import App from '../common/home';
import defaultState from '../store/state';

let ReactDOMServer = require('react-dom/server');

export function init(preloadState) {
    // console.log(preloadState);

    let defaultState = Object.assign({}, defaultState, preloadState);

    // 服务器需要为每个请求创建一份store,并将状态初始化为preloadState
    let store = createStore(
        reducers,
        defaultState
    );

    return ReactDOMServer.renderToString(<App store={store} />);
};

同样的,我们需要在common文件中处理 Node环境与浏览器环境的一些差异

比如在 home.jsx 中,加入

// 公共部分,在Node环境中无window document navigator 等对象
if (typeof window === 'undefined') {
    // 设置win变量方便在其他地方判断环境
    global.win = false;
    global.window = {};
    global.document = {};
}

另外组件加载之后也不需要发请求获取数据了

/**
     * 初始获取数据之后的某些操作
     * @return {[type]} [description]
     */
    afterInit() {
        console.log('afterInit');
    }

    componentDidMount() {
        console.log('componentDidMount');

        // 初始化发出 INIT_PAGE 操作;
        // 已交由服务器渲染
        // this.props.initPage(() => {
            this.afterInit();
        // });
    }

common中的home.js入口文件用于给组件管理store, 与未用SSR的文件不同(js目录下面的home.js入口)

它需要同时为浏览器端和服务器端服务,所以增加一些判断,然后导出

if (module.hot) {
    module.hot.accept();
}

import React, {Component} from 'react';
import {render, findDOMNode} from 'react-dom';
import Home from './homeComponent/home.jsx';
import {Provider} from 'react-redux';
import store from '../store';

class App extends Component {
    render() {
        // 如果为Node环境,则取由服务器返回的store值,否则使用 ../store中返回的值
        let st = global.win === false ? this.props.store : store;

        return (
            <Provider store={st}>
                <Home />
            </Provider>
        )
    }
}

export default App;

浏览器端的入口文件 home.js 直接引用渲染即可

import React, {Component} from 'react';
import {render, hydrate, findDOMNode} from 'react-dom';
import App from '../common/home';

// render(<App />, document.getElementById('content'));
hydrate(<App />, document.getElementById('content'));

 

这便是Redux 加上 SSR之后的流程了

 

其实还漏了一个Express的server.js服务文件,也就一点点代码

图片 7图片 8

 1 const express = require('express');
 2 const path = require('path');
 3 const app = express();
 4 const ejs = require('ejs');
 5 
 6 // 常规路由页面
 7 let home = require('./routes/home');
 8 let message = require('./routes/message');
 9 
10 // 用于SSR服务端渲染的页面
11 let homeSSR = require('./routes/homeSSR');
12 let messageSSR = require('./routes/messageSSR');
13 
14 app.use(express.static(path.join(__dirname, '../')));
15 
16 // 自定义ejs模板
17 app.engine('html', ejs.__express);
18 app.set('view engine', 'html');
19 ejs.delimiter = '|';
20 
21 app.set('views', path.join(__dirname, '../views/'));
22 
23 app.get('/home', home);
24 app.get('/message', message);
25 
26 app.get('/ssr/home', homeSSR);
27 app.get('/ssr/message', messageSSR);
28 
29 let port = 12345;
30 
31 app.listen(port, function() {
32     console.log(`Server listening on ${port}`);
33 });

View Code

 

文章说得错错乱乱的,可能没那么好理解,还是去看 项目文件 自己琢磨吧,自己弄下来编译运行看看

 

图片 9

五、其他

如果项目使用了其他服务器语言的,比如PHP Yii框架 Smarty ,把服务端渲染整起来可能没那么容易

其一是 smarty的模板语法和ejs的不太搞得来

其二是Yii框架的路由和Express的长得不太一样

 

在Nginx中配置Node的反向代理,配置一个 upstream ,然后在server中匹配 location ,进行代理配置

upstream connect_node {
    server localhost:54321;
    keepalive 64;
}

...

server
{
    listen 80;
        ...

    location / {
        index index.php index.html index.htm;
    }

        location ~ (home|message)/d+$ {
            proxy_pass http://connect_node;
        }

    ...

更多配置

 

想得头大,干脆就不想了,有用过Node进行中转代理实现SSR的朋友,欢迎评论区分享哈~

 

项目目录介绍

  1. React
  2. React + SSR
  3. React + Redux
  4. React + Redux + SSR
  1. 浏览器端使用的注入ConnectedRouter中的history为:import createHistory from 'history/createBrowserHistory'
  2. 服务器端使用的history为import createHistory from 'history/createMemoryHistory'

 

项目的构建思路

本项目包含四个页面,四种组合,满满的干货,文字可能说不清楚,就去看代码吧!

  1. 服务端接收到请求,通过/home找到对应的路由配置
  2. 判断路由存在thunk方法,此时执行store/actions/thunk.js里面的暴露出的函数
  3. 异步获取的数据会注入到全局state中,此时的dispatch分发其实并不生效
  4. 要输出的HTML代码中会将获取到数据后的全局state放到window.__INITIAL_STATE__这个全局变量中,作为initState
  5. window.__INITIAL_STATE__将在react生命周期起作用前合并入全局state,此时react发现dom已经生成,不会再次触发render,并且数据状态得到同步

服务端渲染(SSR: Server Side Rendering)在React项目中有着广泛的应用场景

基于React虚拟DOM的特性,在浏览器端和服务端我们可以实现同构(可以使用同一份代码来实现多端的功能)

服务端渲染的优点主要由三点

  1. 利于SEO

  2. 提高首屏渲染速度

  3. 同构直出,使用同一份(JS)代码实现,便于开发和维护

基本的流程已经介绍结束,至于一些Reducer的函数式写法,还有actions的位置都是参考网上的一些分析来组织的,具体见仁见智,这个只要符合自己的理解,并且有助于团队开发就好。如果您符合我在文章一开始设定的读者背景,相信本文的讲述足够您点亮自己的服务端渲染技术点啦。如果对React了解偏少也没关系,可以参考这里来补充一些React的基础知识

一、React

实现一个最基本的React组件,就能搞掂第一个页面了

/**
 * 消息列表
 */
class Message extends Component {
    constructor(props) {
        super(props);

        this.state = {
            msgs: []
        };
    }

    componentDidMount() {
        setTimeout(() => {
            this.setState({
                msgs: [{
                    id: '1',
                    content: '我是消息我是消息我是消息',
                    time: '2018-11-23 12:33:44',
                    userName: '王羲之'
                }, {
                    id: '2',
                    content: '我是消息我是消息我是消息2',
                    time: '2018-11-23 12:33:45',
                    userName: '王博之'
                }, {
                    id: '3',
                    content: '我是消息我是消息我是消息3',
                    time: '2018-11-23 12:33:44',
                    userName: '王安石'
                }, {
                    id: '4',
                    content: '我是消息我是消息我是消息45',
                    time: '2018-11-23 12:33:45',
                    userName: '王明'
                }]
            });
        }, 1000);
    }

    // 消息已阅
    msgRead(id, e) {
        let msgs = this.state.msgs;
        let itemIndex = msgs.findIndex(item => item.id === id);

        if (itemIndex !== -1) {
            msgs.splice(itemIndex, 1);

            this.setState({
                msgs
            });
        }
    }

    render() {
        return (
            <div>
                <h4>消息列表</h4>
                <div className="msg-items">
                {
                    this.state.msgs.map(item => {
                        return (
                            <div key={item.id} className="msg-item">
                                <p className="msg-item__header">{item.userName} - {item.time}</p>
                                <p className="msg-item__content">{item.content}</p>
                                <a href="javascript:;" className="msg-item__read" onClick={this.msgRead.bind(this, item.id)}>&times;</a>
                            </div>
                        )
                    })
                }
                </div>
            </div>
        )
    }
}

render(<Message />, document.getElementById('content'));

是不是很简单,代码比较简单就不说了

来看看页面效果

图片 10

可以看到页面白屏时间比较长

这里有两个白屏

  1. 加载完JS后才初始化标题

  2. 进行异步请求数据,再将消息列表渲染

看起来是停顿地比较久的,那么使用服务端渲染有什么效果呢?

 

您可能感兴趣的文章:

有纯粹的 React,也有 Redux 作为状态管理

路由处理

二. React + SSR

在讲如何实现之前,先看看最终效果

可以看到页面是直出的,没有停顿

图片 11

 

在React 15中,实现服务端渲染主要靠的是 ReactDOMServer 的 renderToString 和 renderToStaticMarkup方法。

let ReactDOMServer = require('react-dom/server');

ReactDOMServer.renderToString(<Message preloadState={preloadState} />)

ReactDOMServer.renderToStaticMarkup(<Message preloadState={preloadState} />)

将组件直接在服务端处理为字符串,我们根据传入的初始状态值,在服务端进行组件的初始化

然后在Node环境中返回,比如在Express框架中,返回渲染一个模板文件

      res.render('messageClient/message.html', {
            appHtml: appHtml,
            preloadState: JSON.stringify(preloadState).replace(/</g, '\u003c')
        });

这里设置了两个变量传递给模板文件

appHtml 即为处理之后的组件字符串

preloadState 为服务器中的初始状态,浏览器的后续工作要基于这个初始状态,所以需要将此变量传递给浏览器初始化

        <div id="content">
            <|- appHtml |>
        </div>
        <script id="preload-state">
            var PRELOAD_STATE = <|- preloadState |>
        </script>

express框架返回之后即为在浏览器中看到的初始页面

需要注意的是这里的ejs模板进行了自定义分隔符,因为webpack在进行编译时,HtmlWebpackPlugin 插件中自带的ejs处理器可能会和这个模板中的ejs变量冲突

在express中自定义即可

// 自定义ejs模板
app.engine('html', ejs.__express);
app.set('view engine', 'html');
ejs.delimiter = '|';

接下来,在浏览器环境的组件中(以下这个文件为公共文件,浏览器端和服务器端共用),我们要按照 PRELOAD_STATE 这个初始状态来初始化组件

class Message extends Component {
    constructor(props) {
        super(props);

        this.state = {
            msg: []
        };

        // 根据服务器返回的初始状态来初始化
        if (typeof PRELOAD_STATE !== 'undefined') {
            this.state.msgs = PRELOAD_STATE;
            // 清除
            PRELOAD_STATE = null;
            document.getElementById('preload-state').remove();
        }
        // 此文件为公共文件,服务端调用此组件时会传入初始的状态preloadState
        else {
            this.state.msgs = this.props.preloadState;
        }

        console.log(this.state);
    }

    componentDidMount() {
        // 此处无需再发请求,由服务器处理
    }
...

核心就是这些了,这就完了么?

哪有那么快,还得知道如何编译文件(JSX并不是原生支持的),服务端如何处理,浏览器端如何处理

接下来看看项目的文件结构

图片 12   图片 13

把注意力集中到红框中

直接由webpack.config.js同时编译浏览器端和服务端的JS模块

module.exports = [
    clientConfig,
    serverConfig
];

浏览器端的配置使用 src 下的 client目录,编译到 dist 目录中

服务端的配置使用 src 下的 server 目录,编译到 distSSR 目录中。在服务端的配置中就不需要进行css文件提取等无关的处理的,关注编译代码初始化组件状态即可

另外,服务端的配置的ibraryTarget记得使用 'commonjs2',才能为Node环境所识别

// 文件输出配置
    output: {
        // 输出所在目录
        path: path.resolve(__dirname, '../public/static/distSSR/js/'),
        filename: '[name].js',
        library: 'node',
        libraryTarget: 'commonjs2'
    },

 

client和server只是入口,它们的公共部分在 common 目录中

在client中,直接渲染导入的组件  

import React, {Component} from 'react';
import {render, hydrate, findDOMNode} from 'react-dom';
import Message from '../common/message';

hydrate(<Message />, document.getElementById('content'));

这里有个 render和hydrate的区别

在进行了服务端渲染之后,浏览器端使用render的话会按照状态重新初始化一遍组件,可能会有抖动的情况;使用 hydrate则只进行组件事件的初始化,组件不会从头初始化状态

建议使用hydrate方法,在React17中 使用了服务端渲染之后,render将不再支持

在 server中,导出这个组件给 express框架调用

import Message from '../common/message';

let ReactDOMServer = require('react-dom/server');

/**
 * 提供给Node环境调用,传入初始状态
 * @param  {[type]} preloadState [description]
 * @return {[type]}              [description]
 */
export function init(preloadState) {
    return ReactDOMServer.renderToString(<Message preloadState={preloadState} />);
};

需要注意的是,这里不能直接使用 module.exports = ... 因为webpack不支持ES6的 import 和这个混用

在 common中,处理一些浏览器端和服务器端的差异,再导出

这里的差异主要是变量的使用问题,在Node中没有window document navigator 等对象,直接使用会报错。且Node中的严格模式直接访问未定义的变量也会报错

所以需要用typeof 进行变量检测,项目中引用的第三方插件组件有使用到了这些浏览器环境对象的,要注意做好兼容,最简便的方法是在 componentDidMount 中再引入这些插件组件

另外,webpack的style-loader也依赖了这些对象,在服务器配置文件中需要将其移除

 {
            test: /.css$/,
            loaders: [
                // 'style-loader',
                'happypack/loader?id=css'
            ]
        }

在Express的服务器框架中,messageSSR 路由 渲染页面之前做一些异步操作获取数据

// 编译后的文件路径
let distPath = '../../public/static/distSSR/js';

module.exports = function(req, res, next) {
    // 如果需要id
    let id = 'req.params.id';

    console.log(id);

    getDefaultData(id);

    async function getDefaultData(id) {
        let appHtml = '';
        let preloadState = await getData(id);

        console.log('preloadState', preloadState);

        try {
            // 获取组件的值(字符串)
            appHtml = require(`${distPath}/message`).init(preloadState);
        } catch(e) {
            console.log(e);
            console.trace();
        }

        res.render('messageClient/message.html', {
            appHtml: appHtml,
            preloadState: JSON.stringify(preloadState).replace(/</g, '\u003c')
        });
    }
};

使用到Node来开启服务,每次改了服务器文件之后就得重启比较麻烦

使用 nodemon工具来监听文件修改自动更新服务器,添加配置文件 nodemon.json

{
    "restartable": "rs",
    "ignore": [
        ".git",
        "node_modules/**/node_modules"
    ],
    "verbose": true,
    "execMap": {
        "js": "node --harmony"
    },
    "watch": [
        "server/",
        "public/static/distSSR"
    ],
    "env": {
        "NODE_ENV": "development"
    },
    "ext": "js,json"
}

当然,对于Node环境不支持JSX这个问题,除了使用webpack进行编译之外,

还可以在Node中执行 babel-node 来即时地编译文件,不过这种方式会导致每次编译非常久(至少比webpack久)

 

在React16 中,ReactDOMServer 除了拥有 renderToString 和 renderToStaticMarkup这两个方法之外,

还有 renderToNodeStream  和 renderToStaticNodeStream 两个流的方法

它们不是返回一个字符串,而是返回一个可读流,一个用于发送字节流的对象的Node Stream类

渲染到流可以减少你的内容的第一个字节(TTFB)的时间,在文档的下一部分生成之前,将文档的开头至结尾发送到浏览器。 当内容从服务器流式传输时,浏览器将开始解析HTML文档

以下是使用实例,本文不展开

// using Express
import { renderToNodeStream } from "react-dom/server"
import MyPage from "./MyPage"
app.get("/", (req, res) => {
  res.write("<!DOCTYPE html><html><head><title>My Page</title></head><body>");
  res.write("<div id='content'>"); 
  const stream = renderToNodeStream(<MyPage/>);
  stream.pipe(res, { end: false });
  stream.on('end', () => {
    res.write("</div></body></html>");
    res.end();
  });
});

 

这便是在React中进行服务端渲染的流程了,说得有点泛泛,还是自己去看 项目代码 吧

 

服务端直出HTML

使用 webpack 监听编译文件,nodemon 监听服务器文件变动

不过到具体项目中,往往会涉及到一些服务端参数的注入问题,但这块根据不同项目需求差异很大,并且不属于这个React服务端改造的一部分,没法统一分享,如果真是公司项目要用到对这块有需求咨询可以打赏后加我微信讨论。

一起看看如何在实际的项目中实现服务端渲染

/*configureStore.js*/
import {createStore, applyMiddleware,compose} from "redux";
import thunkMiddleware from "redux-thunk";
import createHistory from 'history/createMemoryHistory';
import { routerReducer, routerMiddleware } from 'react-router-redux'
import rootReducer from '../store/reducers/index.js';

const routerReducers=routerMiddleware(createHistory());//路由
const composeEnhancers = process.env.NODE_ENV=='development'?window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ : compose;

const middleware=[thunkMiddleware,routerReducers]; //把路由注入到reducer,可以从reducer中直接获取路由信息

let configureStore=(initialState)=>createStore(rootReducer,initialState,composeEnhancers(applyMiddleware(...middleware)));

export default configureStore;

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持脚本之家。

window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__这个变量是浏览器里面的Redux的开发者工具,开发React-redux应用时建议安装,否则会有报错提示。这里面大部分都是redux-thunk的示例代码,关于这部分如果看不懂建议看一下redux-thunk的官方文档,这里要注意的是configureStore这个方法要传入的initialState参数,这个渲染的具体思路是:在服务端判断路由的thunk方法,如果存在则需要执行这个获取数据逻辑,这是个阻塞过程,可以当作同步,获取后放到全局State中,在前端输出的HTML中注入window.__INITIAL_STATE__这个全局变量,当html载入完毕后,这个变量赋值已有数据的全局State作为initState提供给react应用,然后浏览器端的js加载完毕后会通过复用页面上已有的dom和初始的initState作为开始,合并到render后的生命周期中,从而在componentDidMount中已经可以从this.props中获取渲染所需数据。

const renderApp=()=>{
 let application=createApp({store,history});
 hydrate(application,document.getElementById('root'));
}
window.main = () => {
 Loadable.preloadReady().then(() => {
 renderApp()
 });
};

if(process.env.NODE_ENV==='development'){
 if(module.hot){
 module.hot.accept('./store/reducers/index.js',()=>{
  let newReducer=require('./store/reducers/index.js');
  store.replaceReducer(newReducer)
 })
 module.hot.accept('./app/index.js',()=>{
  let {createApp}=require('./app/index.js');
  let newReducer=require('./store/reducers/index.js');
  store.replaceReducer(newReducer)
  let application=createApp({store,history});
  hydrate(application,document.getElementById('root'));
 })
 }
}

因为对网页SEO的需要,要把之前的React项目改造为服务端渲染,经过一番调查和研究,查阅了大量互联网资料。成功踩坑。

import {ADD,GET_HOME_INFO} from '../constants'
export const add=(count)=>({type: ADD, count,})

export const getHomeInfo=(sendId=1)=>async(dispatch,getState)=>{
 let {name,age,id}=getState().HomeReducer.homeInfo;
 if (id === sendId) {
 return //是通过对请求id和已有数据的标识性id进行对比校验,避免重复获取数据。
 }
 console.log('footer'.includes('foo'))
 await new Promise(resolve=>{
 let homeInfo={name:'wd2010',age:'25',id:sendId}
 console.log('-----------请求getHomeInfo')
 setTimeout(()=>resolve(homeInfo),1000)
 }).then(homeInfo=>{
 dispatch({type:GET_HOME_INFO,data:{homeInfo}})
 })
}

本文由金沙官网线上发布于Web前端,转载请注明出处:详解React项目的服务端渲染改造(koa2+webpack3.11)

您可能还会对下面的文章感兴趣: