Skip to content

yellowfrogCN/universal-react-doc

Repository files navigation

⚠️⚠️⚠️ This Is Experimental and Incomplete! ⚠️⚠️⚠️

  • 这并不是一个可以拿来当脚手架的项目,只是为了让大家对react同构(服务端渲染)有个更加直观的认识!

下载项目

安装

  • yarn install 或者 npm install

运行

  • yarn start 或者 npm start

打开网页

实现思路

参考

Step1

利用 NodeJs + Express 搭建入门级的react同构

Express 是为了更方便的搭建HTTP服务器

建一个server.js目录,利用 Express 起一个HTTP

npm init -y
yarn add express 或者 npm install express --save
创建文件server.js

// server.js
const express = require('express');
const server = express();

server.get('/', (request, response) => {
    const html = `<h1>universal - react<h1>`;
    response.send(html);
});

// 服务器端口
const port = 3001;

server.listen(port, (err) => {
    if (err) {
        console.error(err);
        return;
    }
    console.info(`Server running on http://localhost:${port}/`);
});
启动这个服务
node server.js
控制台可以看到
$ node server.js
Server running on http://localhost:3001/
打开网页就看到 universal - react

如果报express没有发现,全局安装express即可

加入react

yarn add react react-dom 或者 npm install react react-dom --save
创建 Root.js文件

// Root.js
const React = require('react');
// import React, { Component } from 'react';

class Root extends React.Component {
    constructor (props) {
        super(props);
        this._handleClick = this._handleClick.bind(this);
    }
    componentWillMount () {
        console.log('root 生命周期 willMount 触发了!');
    }
    componentDidMount () {
        console.log('root 生命周期 didMount 触发了!');
    }
    _handleClick () {
        alert('yf超帅的!');
    }
    render() {
        return (<div>
                <h1>Hello World!</h1>
                <button onClick={this._handleClick}>Click Me</button>
            </div>);
    }
}

module.exports = Root;
// export default Root;

修改server.js文件

// server.js
const express = require('express');
// 加入的
const Root = require('./Root.js');
const React = require('react');
const ReactDOMServer = require('react-dom/server');

const Server = express();

Server.get('/', function (request, response) {
    // 变动的
    const html = ReactDOMServer.renderToString(
        React.createElement(Root)
    );
    response.send(html);
});

// 服务器端口
const port = 3001;

Server.listen(port, (err) => {
    if (err) {
        console.error(err);
        return;
    }
    console.info(`Server running on http://localhost:${port}/`);
});

node server.js 启动, 你就会惊喜地发现,控制台报错了 - -
出现这个问题是因为服务端不识别ReactJs,所以我们要安装以下插件;
yarn add babel-preset-react babel-register 或者 npm install babel-preset-react babel-register --save
然后从server.js的头部插入以下代码

// server.js
require('babel-register')({
    presets: ['react']
});

node server.js 启动,打开网页 http://localhost:3001/ 正常了!
打开firebug会看到,元素上出现data-reactid的属性,这是 renderToString 这个方法产生的!还有另外一个方法 renderToStaticMarkup, 不过他们之间差异,我也不清楚,自行百度吧!

data-reactid

这时候点击按钮,发现什么都没发生; 而且控制台也只出现willMount没有出现didMount, 这是因为 ReactDOMServer 只是像字符串一样渲染出 html,换句话说只是在服务端渲染了,前端还没有接管代码,这时候还不算是同构;

为了达到同构的效果,我们需要加入/修改一些文件
修改 Root.js 把render return 里面的 节点 替换成 html 的形式

// Root.js
// ...
render() {
    return (
        <html>
            <head>
                <title>Universal React</title>
            </head>
            <body>
                <div>
                    <h1>Hello World!</h1>
                    <button onClick={this._handleClick}>Click Me</button>
                </div>
                {/* 
                因为会渲染成react的格式,所以<script></script>可以写成<script /> 
                */}
                <script src='/bundle.js' />
            </body>
        </html>
    );
}
  • 新增 public 文件夹,用于存放静态文件

mkdir public

  • 新增 Entry.js 文件, 用于处理 服务端渲染时,前端的同构

touch Entry.js

// Entry.js
const React = require('react');
const ReactDOM = require('react-dom');
const Root = require('./Root.js');

ReactDOM.render(
    React.createElement(Root),
    // document 可以理解为浏览器
    document
);
  • 安装 babel-loader webpack 依赖

yarn add webpack babel-loader babel-preset-es2015 或者 npm install webpack babel-loader babel-preset-es2015 --save

  • 新增 webpack.config.js 文件

touch webpack.config.js
本文主要是讲解同构,webpack详细的配置问题这边不做详细讲解

// webpack.config.js
const path = require('path');
const webpack = require('webpack');
module.exports = {
    entry: ['./Entry.js'],
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'public'),
    },
    module: {
        loaders: [
            {
                test: /\.js|x?$/,
                exclude: /node_modules/,
                loader: 'babel-loader',
                query: {
                    presets: ['react', 'es2015']
                }
            }
        ]
    }
};
  • 修改server.js,加入托管静态文件的功能
// ...
// 托管静态文件
Server.use(express.static('public'));
// ...
  • 修改package.json 的 "scripts"
"scripts": {
    "start": "webpack && node server.js"
},

npm start 重启服务器,看到控制台出现一下代码

bundle

打开网页 http://localhost:3001/ 可以看到按钮可以点击,还弹出来很符合实际的对话框,网页的console也出现生命周期里的console.log

step1 finish

  • 为了可以更好的理解同构,大家可以刷新多几遍 网页 http://localhost:3001/ 然后对比一下 后端控制台前端控制台

step1 finish

step1 finish

step1 finish

  • 这就是同构——后台与前端同时渲染Root.js

Root.js使用ES6语法----import/export (了解一下即可)

细心的人会发现,我们在服务端与前端同时运行的文件Root.js,里面的导入/导出是使用CommonJs的语法,并不是ES6的import/export语法,原因是服务端并没有经过webpack处理,服务端还是NodeJs的地盘,NodeJs是支持CommonJs,但仅支持部分ES6语法的,为了让服务端支持ES6语法的import/export,我们只要稍微修改下server.js即可!

// server.js
require('babel-register')({
    // presets: ['react']
    presets: ['react', 'es2015']
});
// ...

对,就是把babel-preset-es2015这个插件引入到这里就可以了!
然后我们修改一下Root.js与server.js

// Root.js

// const React = require('react');
import React from 'react';

//...

// module.exports = Root;
export default Root;

因为换成了 ES6 的export default,所以server.js里的也要相应的修改;
CommonJS ES6 AMD等的导入导出之间的关系,网上一搜一堆,这边不做细说;

// const Root = require('./Root.js');
const Root = require('./Root.js').default;

因为Entry.js是经过了webpack处理,webpack是默认支持CommonJS,再加上module里配置了支持ES6,所以Entry.js使用CommonJS语法或者ES6语法都是没问题的,有兴趣的可以自行去修改为ES6的import语法;

  • 到这里 Step1 入门级的同构完成,但离实际开发还有很大一段距离,接下来的 Step2 会是前端路由在同构中的应用

Step2

前端路由在同构中的应用

react社区的路由框架有好几种,其中最有名的就是react-router了!本次也是在react-router @3 版本基础上进行同构;

安装react-router @3
yarn add react-router@3 或者 npm install react-router@3 --save
mkdir routes 新建立routes文件夹,touch index.js 在routes里建立index.js,把server.js的路由代码移到index.js中

// server.js

// const Root = require('./Root.js').default;
// const React = require('react');
// const ReactDOMServer = require('react-dom/server');
// Server.get('/', function (request, response) {
//     const html = ReactDOMServer.renderToString(
//         React.createElement(Root)
//     );
//     response.send(html);
// });

// 使用路由
Server.use(require('./routes'));
// routes/index.js
// 正常使用 ES6 语法
import express from 'express';
const router = express.Router();
// const router = require('express').Router();
import Root from '../Root';
// const Root = require('../Root.js').default;
import React from 'react';
// const React = require('react');
import {
    renderToString
} from 'react-dom/server';
// const ReactDOMServer = require('react-dom/server');

router.get('/', function (request, response) {
    const html = renderToString(
        React.createElement(Root)
    );
    response.send(html);
});

module.exports = router;

npm start 打开网页 http://localhost:3001/, 正常!

// routes/index.js
import express from 'express';
import React from 'react';
import {
    renderToString
} from 'react-dom/server';
import Root from '../Root';
import {
    match, RouterContext
} from 'react-router';
import routes from './configureRoute';
const router = express.Router();


router.get('*', function (req, res) {
    // Note that req.url here should be the full URL path from
    // the original request, including the query string.
    match(
        {routes, location: req.url},
        (error, redirectLocation, renderProps) => {
            if (error) {
                res.status(500).send(error.message)
            } else if (redirectLocation) {
                res.redirect(302, redirectLocation.pathname + redirectLocation.search)
            } else if (renderProps) {
                // You can also check renderProps.components or renderProps.routes for
                // your "not found" component or route respectively, and send a 404 as
                // below, if you're using a catch-all route.
                const html = renderToString(
                    <RouterContext
                        {...renderProps}

                    />
                )
                res.status(200).send(html)
            } else {
                res.status(404).send('Not found')
            }
        }
    )
});

module.exports = router;

touch configureRoute.js 在routes中新建一个 configureRoute.js 文件

import React from 'react';
import {
    Router, Route, browserHistory
} from 'react-router';
import Root from '../Root';

export default (
    <Router history={browserHistory}>
        <Route path='/' component={Root}>
        </Route>
    </Router>
)

修改Entry.js文件

// Entry.js
// import React from 'react';
import ReactDOM from 'react-dom';
// import Root from './Root';
import routes from './routes/configureRoute';

ReactDOM.render(
    routes,
    // React.createElement(Root),
    // document 可以理解为浏览器
    document
);

npm start 打开网页 http://localhost:3001/, 显示正常 现在我们加入静态路由,试验一下

// Root.js
import { Link } from 'react-router';
// ...
<ul>
    <li>
        <Link to='/'>Index</Link>
    </li>
    <li>
        <Link to='/about'>About</Link>
    </li>
</ul>
{this.props.children}
// ...

新增container,在container目录下新建Index.js/About.js

// Index.js
import React, {Component} from 'react';

class Index extends Component {
    render () {
        return (
            <p>
                Current: <strong>Index</strong>
            </p>
        )
    }
}
export default Index;

// About.js
import React, {Component} from 'react';

class About extends Component {
    render () {
        return (
            <p>
                Current: <strong>About</strong>
            </p>
        )
    }
}
export default About;

在configureRoute.js里引入Index.js与About.js路由

// configureRoute.js

// ...
import Index from '../container/Index';
import About from '../container/About';

export default (
    <Router history={browserHistory}>
        <Route path='/' component={Root}>
            <IndexRoute component={Index} />
            <Route path='/about' component={About} />
        </Route>
    </Router>
)

npm start 打开网页 http://localhost:3001/ ,点击 正常 ! 我们在routes/index.js里面加上一个console.log('renderProps');

// routes/index.js
// ...
console.log('renderProps');
// ...

重启,打开网页,重点看后台控制台

step2 finish

我们发现,除非手动刷新页面,无论前端路由如何点击,renderProps都在只会出现后台控制台出现一次,说明,前端已经接管了路由,路由部分同构成功 - -.V

  • 在刚才的灵魂画师的图,我们在加入路由部分,更方便理解路由部分的前后端同构;

step2 finish

  • 到这里,Step2:react-router的同构算是基本完成,后续会在此基础上加上动态路由等,但接下来的Step3,重点是 redux 的同构方案!

Step3

redux 的同构#1

redux的同构,这边分成两步
第一步先把redux集成进同构中
第二步再在第一部的基础上,考虑在前端接管页面时,同时拿到页面的数据!也就是本页的异步请求,发生在服务端

  • 安装依赖
  • 小插曲,相信大家不断的npm start 已经烦死了吧!这边先用一个简单实用的方式来长时间启动服务器!

yarn add npm-run-all nodemon --dev 或者 npm install npm-run-all nodemon --save-dev 然后修改我们的 package.json 里的 srcipts

"scripts": {
    "start": "npm-run-all --parallel watch:*",
    "watch:webpack": "webpack -w",
    "watch:server": "nodemon --ext js,jsx --ignore public/ server.js"
},

以后只需要npm start 然后 F5 刷新就可以修改代码!(热更新后续加入)

  • 加入redux

yarn add redux redux-logger redux-thunk react-redux redux-devtools-extension 或者 npm install redux redux-logger redux-thunk react-redux redux-devtools-extension --save

新建文件夹redux,在redux文件夹里建立configureStore.js与index.js

// configureStore.js
import { createStore, applyMiddleware } from 'redux';
import thunkMiddleware from 'redux-thunk';
import rootReducer from '../reducer';
import { createLogger } from 'redux-logger';
import { composeWithDevTools } from 'redux-devtools-extension';

const logger = createLogger({
  collapsed: true
})

const composeEnhancers = composeWithDevTools({
  // 后续如需配置参数,可在这里配置
});

const middleware = [thunkMiddleware, logger];

const configureStore = (preloadedState = {}) => {
    const store = createStore(
        rootReducer,
        preloadedState,
        composeEnhancers(
            applyMiddleware(...middleware)
        )
    );

    if (module.hot) {
        module.hot.accept('../reducer', () => {
            store.replaceReducer(rootReducer);
        });
    }

    return store;
}

export default configureStore;
// redux/configureStore.js
import configureStore from './configureStore.dev';
export default configureStore;

新建文件夹 contants/index 用于存放常量,主要是 action.type

// contants/index
export const GET_DAN_INFO = 'GET_DAN_INFO';
export const GET_DAN_INFO_SUCCESS = 'GET_DAN_INFO_SUCCESS';
export const GET_DAN_INFO_FAILED = 'GET_DAN_INFO_FAILED';

export const GET_TJ_INFO = 'GET_TJ_INFO';
export const GET_TJ_INFO_SUCCESS = 'GET_TJ_INFO_SUCCESS';
export const GET_TJ_INFO_FAILED = 'GET_TJ_INFO_FAILED'; 

新建文件夹 reducer,在 reducer 文件夹里分别建立 index.js aboutReducer.js(对应About组件的数据) indexReducer.js(对应Index组件的数据)

// reducer/indexReducer.js
import * as types from '../contants';

function indexReducer (
    state = {
        title: 'Redux 作者:Dan Abramov',
        list: {}
    },
    action
) {
    switch (action.type) {
        case types.GET_DAN_INFO_SUCCESS:
            return Object.assign({}, state, {
                list: action.payload
            })
        case types.GET_DAN_INFO_FAILED:
            return Object.assign({}, state, {
                list: {}
            }) 
        default:
            return state;
    }
}

export default indexReducer;
// reducer/aboutReducer.js
// reducer/aboutReducer.js
import * as types from '../contants';
function aboutReducer (
    state = {
        title: '编程界的杀马特、设计技术远超陈冠希的TJ大神',
        list: {}
    },
    action
) {
    switch (action.type) {
        case types.GET_TJ_INFO_SUCCESS:
            return Object.assign({}, state, {
                list: action.payload
            })
        case types.GET_TJ_INFO_FAILED:
            return Object.assign({}, state, {
                list: {}
            }) 
        default:
            return state;
    }
}

export default aboutReducer;
// reducer/index.js
import { combineReducers } from 'redux';
import index from './indexReducer';
import about from './aboutReducer';

export default combineReducers({
    index,
    about
})

routes/index.js 引入redux配置(后端引入redux)

// routes/index.js
// ...
import { Provider } from 'react-redux';
import configureStore from '../redux';

const store = configureStore();
// ...
// const html = renderToString(
//     <RouterContext
//         {...renderProps}
//     />
// )
const html = renderToString(
    <Provider store={store} >
        <RouterContext
            {...renderProps}
        />
    </Provider>
)
// ...

Entry.js 引入redux(前端引入redux)

// Entry.js
import React from 'react';
import ReactDOM from 'react-dom';
// import Root from './Root';
import routes from './routes/configureRoute';

import { Provider } from 'react-redux';
import configureStore from './redux';
const store = configureStore();

ReactDOM.render(
    <Provider store={store}>
        {routes}
    </Provider>,
    // React.createElement(Root),
    // document 可以理解为浏览器
    document
);

加入action,创建action文件夹,文件夹里创建indexAction.js、aboutAction.js

// action/indexAction.js
import request from '../utils';
import * as types from '../contants';

export const getDan = () => {
    return (dispatch, getState) => {
        dispatch({
            type: types.GET_DAN_INFO
        })
        // https://api.github.com/users/tj
        return request('https://api.github.com/users/gaearon').then(res => {
            console.log(res);
            dispatch({
                type: types.GET_DAN_INFO_SUCCESS,
                payload: res
            })
        }).catch(error => {
            dispatch({
                type: types.GET_DAN_INFO_FAILED,
                payload: error
            })
        })
    }
}
// action/aboutAction/js
import request from '../utils';
import * as types from '../contants';

export const getTJ = () => {
    return (dispatch, getState) => {
        dispatch({
            type: types.GET_TJ_INFO
        })
        return request('https://api.github.com/users/tj').then(res => {
            console.log(res);
            dispatch({
                type: types.GET_TJ_INFO_SUCCESS,
                payload: res
            })
        }).catch(error => {
            dispatch({
                type: types.GET_TJ_INFO_FAILED,
                payload: error
            })
        })
    }
}

容器组件(Index, About)里面调用redux的数据,修改Index、About;

// Index.js
import React, {Component} from 'react';
import { connect } from 'react-redux';
import {
    getDan
} from '../action/indexAction';

class Index extends Component {
    componentDidMount () {
        console.log('调用 Index 组件!', this.props);
        const { getDan } = this.props;
        getDan();
    }
    render () {
        const {data: { title, list }} = this.props;
        return (
            <div>
                <div>Current: <strong>{title}</strong></div>
                {
                    list.avatar_url ? (<div>
                        <img src={list.avatar_url} />
                    </div>) : null
                }
            </div>
        )
    }
}

export default connect(
    state => {
        return { data: state.index }
    },
    {
        getDan
    }
)(Index);
// About.js
// About.js
import React, {Component} from 'react';
import { connect } from 'react-redux';
import { getTJ } from '../action/aboutAction';

class About extends Component {
    componentDidMount () {
        console.log('调用 About 组件!', this.props);
        const { getTJ } = this.props;
        getTJ();
    }
    render () {
        const {data: { title, list }} = this.props;
        return (
            <div>
                <div>Current: <strong>{title}</strong></div>
                {
                    list.avatar_url ? (<div>
                        <img src={list.avatar_url} />
                    </div>) : null
                }
            </div>
        )
    }
}
export default connect(
    state => {
        return { data: state.about }
    },
    {
        getTJ
    }
)(About);

redux

到这一步,tep3: redux 的同构#1 算是完成,这一篇虽然内容繁杂,相对于非同构的react,也就是在后端引了一下redux而已,其他的也跟正常的一样调用redux,可以说是没什么技术含量;但对于基础薄弱的人来说,这一步还是有很多可以借鉴的地方的 - -!

Step4

redux 的同构#2 ---- 服务端获取当页的异步数据

  • 所谓同构首页, 并不是是指第一张页面, 有可能你直接输入网址的页面, 也可以理解为首页;
  • 那么如何要在 首页 html加载出来时,就已经加载好异步数据?且同构时,前端与后端的redux如何保持一致?带着问题来看一下吧!

根据redux官方文档推荐思路(当页最底下), 我们可以在每个容器组件写上一个静态方法, 然后再在route的match的回调函数的第三个参数(renderProps)里面,找到当前加载页的的Components,最后在Components里面去寻找刚才添加的静态方法, 找到静态方法后, 调用它,调用结束后 => 存进redux => 再渲染页面!
分别给容器组件 Index.js About.js 添加 静态方法

// Index.js
// ...
static readyOnActions(dispatch) {
    return Promise.all([
        // 你可以用mapDispatchToProps也行
        // 直接用dispatch调用也行
        dispatch(getDan())
    ]);
}
// ...
// About.js
// ...
static readyOnActions(dispatch) {
    return Promise.all([
        // 你可以用mapDispatchToProps也行
        // 直接用dispatch调用也行
        dispatch(getTJ())
    ]);
}
// ...

修改 router/index.js 让页面在加载前已经获取到异步数据

// router/index.js
// ...

// 核心方法
function handleRoute(res, renderProps) {
    const status = routeIsUnmatched(renderProps) ? 404 : 200;
    // 找寻组件中是否存在 readyOnActions 这个静态方法,如果存在,则返回出来给Promise.ALL调用
    const readyOnAllActions = renderProps.components
      .filter(component => {
          return component && component.readyOnActions
      })
      .map(component => component.readyOnActions(store.dispatch, renderProps.params));
    
    // 调用 readyOnAllActions, 完成后在then里面渲染html(服务端)
    console.log(31, readyOnAllActions);
    Promise
      .all(readyOnAllActions)
      .then(() => {
        const html = renderToString(
            <Provider store={store} >
                <RouterContext
                    {...renderProps}
                />
            </Provider>
        )
        return res.status(status).send(html)
      });
}
// ...
else if (renderProps) {
    // 核心方法
    handleRoute(res, renderProps)
}
// ...

现在我们刷新 http://localhost:3001/看下能否实现服务端加载数据
页面正常,但是前端控制台报错了

后台报错

通过错误提示,可以看到,是前后端在同构时数据不匹配造成的!

  • 后端:是在加载数据后渲染html, redux是存在异步加载的数据的
  • 前端: 我们看下,是在不知道后端加载数据的情况下,渲染页面的
// Entry.js
// ...
const store = configureStore();
<Provider store={store}>
// ...
  • 所以, 这是数据不一致时造成的,用张图表示就是

数据不一致

  • 那么,如何才能保持在同构时的数据一致呢? 以下是一种思路常用的思路
  • 1、Root.js通过connect获取到state数据
  • 2、通过 dangerouslySetInnerHTML 注入进react
  • 3、前端渲染的时候,利用redux万年用不着的createStore的第二个参数,来保证同构时数据一致
// Root.js
// ...
<script dangerouslySetInnerHTML={{
    __html: 'window.PROPS=' + JSON.stringify(this.props.custom)
}} />
// module.exports = Root;
export default connect(state => {
    return {
        custom: state
    }
})(Root);
// Entry.js
// 保证前后端同构时数据一致
const store = configureStore(window.PROPS);

刷新 http://localhost:3001/ 看到 console.log的错误消失了, 通过Elements可以看到redux里的数据注入近了html;

数据不一致

为了更好的验证服务端渲染,我们把容器组件里的 Index.js About.js 里 的 componentDidMount 异步请求去掉,静态方法里面各自添加全部异步请求, 也就是说,前端不进行任何异步请求,看下还能否看到 Dan大神与TJ杀马特的靓照!

Index.js 与 About.js 都加入下面的代码

// ...
import { getDan } from '../action/indexAction';
import { getTJ } from '../action/aboutAction.js';
static readyOnActions(dispatch) {
    return Promise.all([
        // 你可以用mapDispatchToProps也行
        // 直接用dispatch调用也行
        dispatch(getDan()),
        dispatch(getTJ())
    ]);
} 
// ...

分别注释掉 componentDidMount 里的异步请求

// Index.readyOnActions(dispatch)
// About.readyOnActions(dispatch);

最后再查看 刷新 http://localhost:3001/, 通过 Network 可以验证,没有任何异步请求的请求的情况下,照样可以看到 Dan大神与TJ杀马特 - -。V

数据不一致

虽然我并不知道XSS攻击是什么鬼,但是 dangerouslySetInnerHTML 注入进html的方式会带来这方面的安全隐患,关于这方面的知识,日后后后....有机会再去细究深化

  • 到这里,react的同构算是基本完成了,这里面包括了与 router、redux、以及 服务端异步请求 等 技术的融合;后续有机会的话,再进行下面的技术深化,当然,有兴趣的人,也可以fork下来,自行升级改造

  • 开发环境的热更新,提升开发体验;

  • 生产环境与开发环境的分离与完善(目前的webpack就是个'hello world');

  • React-Router @3 || @4 的动态加载

  • 部署方式优化

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published