让中间层承担数据获取职责

之前我们说过浏览器和服务端通信的时候,node作为中间层负责渲染页面,数据从真正的数据服务器中获取。

我们这里来分析一下我们前面的代码是否实现了中间层的概念。我们前面存储的src/public/index.js就是我们客户端要运行的代码,可以发现这里请求服务的接口请求的是java接口,这就违背了中间层的概念,这里请求的接口也应该是中间层的接口,这方便我们排查错误。

我们这里只需要让我们的node-server变成一个代理服务器就可以了,也就是一个proxy的功能,这里我们依赖一个express-http-proxy。

npm install express-http-proxy --save

src/server/index.js修改store获取方式

import express from 'express';
import proxy from 'express-http-proxy';
import { matchRoute } from 'react-router-config';
import { render } from './utils';
import { getStore } from '../store'; // 使用store
import routes from '../Routes';

const app = express();
app.use(express.static('public'));

app.use('/api', proxy('xx.xx.xx.xx', {
    proxyReqPathResolver: (req) => { // 转发到哪个路径
        return req.url;
    }
}))

app.get('*', function(req, res) {
    const store = getStore();
    const matchedRoutes = matchRoute(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(store, routes, req)); 
    })
})
var server = app.listen(3000);

src/components/Home/store/actions.js, 删除请求的域名。

import axios from 'axios';
import { CHANGE_LIST } from './constants';

const changeList = (list) => {
    type: CHANGE_LIST,
    list
}

export const getHomeList = (server) => {
    let url = '';
    if (server) { // 服务端环境使用真实地址
        url = 'xx.xx.xx.xx/api/getlist'
    } else { // 浏览器环境使用相对地址,做转发
        url = '/api/getlist'
    }
    return (dispatch) => {
        return axios.get(url).then(res => {
            const list = res.data.data;
            dispatch(changeList(list));
        })
    }
}

src/components/Home/index.js

import React, { Component } from 'react';
import Header from '../Header';
import { connect } from 'react-redux';
import { getHomeList } from './store/actions';

class Home extends Component {

    getList() {
        const { list } = this.props;
        return this.props.list.map(item => <div key={item.id}>{item.title}</div>)
    }
    render() {
        return <div>
            <Header>
            <div>Home</div>
            {this.getList()}
            <button onClick={() => { alert('click1'); }>按钮</button>
        </div>
    }

    componentDidMount() {
        if (!this.props.list.length) {
            this.props.getHomeList();
        }
    }
}

Home.loadData = (store) => {
    // 执行action,扩充store。
    return store.dispatch(getHomeList(false));
}

const mapStatetoProps = state => ({
    list: state.home.newsList
});

const mapDispatchToProps = dispatch => ({
    getHomeList() {
        dispatch(getHomeList(true));
    }
})

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

withExtraArgument

上面的代码我们通过传递布尔值来确定请求路径还是比较麻烦的,我们使用withExtraArgument整理一下。

src/components/Home/index.js

import React, { Component } from 'react';
import Header from '../Header';
import { connect } from 'react-redux';
import { getHomeList } from './store/actions';

class Home extends Component {

    getList() {
        const { list } = this.props;
        return this.props.list.map(item => <div key={item.id}>{item.title}</div>)
    }
    render() {
        return <div>
            <Header>
            <div>Home</div>
            {this.getList()}
            <button onClick={() => { alert('click1'); }>按钮</button>
        </div>
    }

    componentDidMount() {
        if (!this.props.list.length) {
            this.props.getHomeList();
        }
    }
}

Home.loadData = (store) => {
    // 执行action,扩充store。
    return store.dispatch(getHomeList());
}

const mapStatetoProps = state => ({
    list: state.home.newsList
});

const mapDispatchToProps = dispatch => ({
    getHomeList() {
        dispatch(getHomeList());
    }
})

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

src/components/Home/store/actions.js

import { CHANGE_LIST } from './constants';

const changeList = (list) => {
    type: CHANGE_LIST,
    list
}

export const getHomeList = (server) => {
    return (dispatch, getState, axiosInstance) => {
        return axiosInstance.get(url).then(res => {
            const list = res.data.data;
            dispatch(changeList(list));
        })
    }
}

src/store/index.js

import { createStore, applyMiddleware, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import { reducer as homeReducer} from '../components/Home/store';
import clientAxios from '../client/request';
import serverAxios from '../server/request';

const reducer = combineReducers({
    home: homeReducer
});

export const getStore = () => {
    return createStore(reducer, applyMiddleware(thunk.withExtraArgument(serverAxios)));
}

export const getClientStore = () => {
    const defaultState = window.context.state;
    // defaultState作为默认值
    return createStore(reducer, defaultState, applyMiddleware(thunk.withExtraArgument(clientAxios)));
}

src/client/request.js

import axios from 'axios';

const instance = axios.create({
    baseURL: '/'
})

src/server/request.js

import axios from 'axios';

const instance = axios.create({
    baseURL: 'xx.xx.xx.xx'
})

renderRoutes

src/App.js

import React from 'react';
import Header from './component/Header';
import { renderRoutes } from 'react-router-config';

const App = (props) => {
    return (<div>
        <Header />
        {renderRoutes(props.route.routes)}
    </div>)
}

export default App;

我们希望用户无论如何访问都显示App组件。

src/Routes.js

import React from 'react';
import App from './App';
import Home from './components/Home';
import Login from './components/Login';

export default [{
    path: '/',
    component: App,
    routes: [
        {
            path: '/',
            component: Home,
            exact: true,
            key: 'home',
            loadData: Home.loadData
        },
        {
            path: '/login',
            component: Login,
            key: 'login',
            exact: true
        }
    ]
}]

这里我们构建了一个二级路由,当用户访问跟目录的时候我们可以匹配到App组件,当访问/login路径的时候, 我们会匹配到App和Login两个组件。

src/server/utils.js

import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter, Route } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import { Provider } from 'react-redux';

export const render = (store, routes, req) => {
    const content = renderToString((
        <Provider store={store}>
            <StaticRouter location={req.path} context={{}}>
                <div>
                {renderRoutes(routes)}
                </div>
            </StaticRouter>
        </Provider>
    ));
    return `
        <html>
            <body>
                <div id="root">${content}</div>
                <script>
                    window.context = {
                        state: ${JSON.stringfiy(store.getState())}
                    }
                </script>
                <script src="/index.js"></script>
            </body>
        </html>
    `;
}

src/components/Home/index.js

import React, { Component } from 'react';
import { connect } from 'react-redux';
import { getHomeList } from './store/actions';

class Home extends Component {

    getList() {
        const { list } = this.props;
        return this.props.list.map(item => <div key={item.id}>{item.title}</div>)
    }
    render() {
        return <div>
            <div>Home</div>
            {this.getList()}
            <button onClick={() => { alert('click1'); }>按钮</button>
        </div>
    }

    componentDidMount() {
        if (!this.props.list.length) {
            this.props.getHomeList();
        }
    }
}

Home.loadData = (store) => {
    // 执行action,扩充store。
    return store.dispatch(getHomeList());
}

const mapStatetoProps = state => ({
    list: state.home.newsList
});

const mapDispatchToProps = dispatch => ({
    getHomeList() {
        dispatch(getHomeList());
    }
})

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

src/components/Login/index.js

import React from 'react';

const Login = () => {
    return <div>Login</div>
}

export default Login;

src/client/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Route } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import routes from '../Routes';
import { getClientStore } from '../store'; // 使用store
import { Provider } from 'react-redux';

const store = getClientStore();
const App = () => {
    return (
        <Provider store={store}>
            <BrowserRouter>
                <div>
                    {renderRoutes(routes)}
                </div>
            </BrowserRouter>
        </Provider>
    )
}

ReactDOM.hydrate(<App />, document.getElementById('root'));

请求失败处理

如果我们action中的请求失败了,会触发catch而不会触发then,这样会导致网站卡住,不会响应。因为server/index.js中的promise集合会失败,永远也不会返回成功。

Promise.all(promises).then(() => {
    res.send(render(store, routes, req)); 
})

所以我们可以在这里面加一个catch。

Promise.all(promises).then(() => {
    res.send(render(store, routes, req)); 
}).catch(() => {
    res.end('sorry');
})

这样页面可以展示出来,但是问题是我们并不知道哪里出了问题,或者说当我们有多个组件渲染时,我们希望接口正常的组件可以正常返回。

我们可以在loadData外层包裹一层新的Promise, 无论loadData成功还是失败,我们都调用resolve,这样就可以确保所有请求都完成。Promise.all就可以正常执行了。

src/server/index.js

import express from 'express';
import proxy from 'express-http-proxy';
import { matchRoute } from 'react-router-config';
import { render } from './utils';
import { getStore } from '../store'; // 使用store
import routes from '../Routes';

const app = express();
app.use(express.static('public'));

app.use('/api', proxy('xx.xx.xx.xx', {
    proxyReqPathResolver: (req) => { // 转发到哪个路径
        return req.url;
    }
}))

app.get('*', function(req, res) {
    const store = getStore();
    const matchedRoutes = matchRoute(routes, req,path);
    const promises = [];
    matchedRoutes.forEach(item => {
        if (item.route.loadData) {
            const promise = new Promise((resolve, reject) => {
                item.route.loadData(store).then(resolve).catch(resolve);
            })
            promises.push(promise);
        }
    });
    Promise.all(promises).then(() => {
        res.send(render(store, routes, req)); 
    })
})
var server = app.listen(3000);

如何支持CSS样式修饰

首先我们需要webpack编译css文件。

webpack.server.js服务端要使用isomorphic-style-loader替代客户端的style-loader。

const Path = require('path');
const NodeExternals = require('webpack-node-externals'); // 服务端运行webpack需要运行NodeExternals, 他的作用是将express这类node模块不被打包到js里。

const merge = require('webpack-merge');
const config = require('./webpack.base.js');

const serverConfig = {
    target: 'node',
    mode: 'development',
    entry: './src/server/index.js',
    output: {
        filename: 'bundle.js',
        path: Path.resolve(__dirname, 'build')
    },
    externals: [NodeExternals()],
    module: {
        rules: [
            {
                test: /\.css?$/,
                use: ['isomorphic-style-loader', {
                    loader: 'css-loader',
                    options: {
                        importLoaders: 1,
                        modules: true,
                        localIdentName: '[name]_[local]_[hase:base64:5]'
                    }
                }]
            }
        ]
    }
}

module.exports = merge(config, serverConfig);

webpack.client.js客户端使用style-loader加载。

const Path = require('path');
const merge = require('webpack-merge');
const config = require('./webpack.base.js');

const clientConfig = {
    mode: 'development',
    entry: './src/client/index.js',
    output: {
        filename: 'index.js',
        path: Path.resolve(__dirname, 'public')
    },
    module: {
        rules: [
            {
                test: /\.css?$/,
                use: ['style-loader', {
                    loader: 'css-loader',
                    options: {
                        importLoaders: 1,
                        modules: true,
                        localIdentName: '[name]_[local]_[hase:base64:5]'
                    }
                }]
            }
        ]
    }
};

module.exports = merge(config, clientConfig);

src/components/Home/style.css

body {
    background: green;
}
.test {
    background: red;
}

src/components/Home/index.js

import React, { Component } from 'react';
import { connect } from 'react-redux';
import { getHomeList } from './store/actions';
import styles from './style.css';

class Home extends Component {
    componentWillMount() { // 处理样式
        if (this.props.staticContext) { // 服务端运行存在,客户端运行不存在。所以客户端不要执行。将样式存储在context中。
            this.props.staticContext.css = styles._getCss();
        }
    }

    getList() {
        const { list } = this.props;
        return this.props.list.map(item => <div key={item.id}>{item.title}</div>)
    }
    render() {
        return <div className={styles.test}>
            <div>Home</div>
            {this.getList()}
            <button onClick={() => { alert('click1'); }>按钮</button>
        </div>
    }

    componentDidMount() {
        if (!this.props.list.length) {
            this.props.getHomeList();
        }
    }
}

Home.loadData = (store) => {
    // 执行action,扩充store。
    return store.dispatch(getHomeList());
}

const mapStatetoProps = state => ({
    list: state.home.newsList
});

const mapDispatchToProps = dispatch => ({
    getHomeList() {
        dispatch(getHomeList());
    }
})

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

src/server/index.js在render方法里面对样式进行处理。

import express from 'express';
import proxy from 'express-http-proxy';
import { matchRoute } from 'react-router-config';
import { render } from './utils';
import { getStore } from '../store'; // 使用store
import routes from '../Routes';

const app = express();
app.use(express.static('public'));

app.use('/api', proxy('xx.xx.xx.xx', {
    proxyReqPathResolver: (req) => { // 转发到哪个路径
        return req.url;
    }
}))

app.get('*', function(req, res) {
    const store = getStore();
    const matchedRoutes = matchRoute(routes, req,path);
    const promises = [];
    matchedRoutes.forEach(item => {
        if (item.route.loadData) {
            const promise = new Promise((resolve, reject) => {
                item.route.loadData(store).then(resolve).catch(resolve);
            })
            promises.push(promise);
        }
    });
    Promise.all(promises).then(() => {
        const html = render(store, routes, req, context)
        res.send(html); 
    })
})
var server = app.listen(3000);

src/server/utils.js

import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter, Route } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import { Provider } from 'react-redux';

export const render = (store, routes, req, context) => {
    const content = renderToString((
        <Provider store={store}>
            <StaticRouter location={req.path} context={{}}>
                <div>
                {renderRoutes(routes)}
                </div>
            </StaticRouter>
        </Provider>
    ));

    const cssStr = context.css ? context.css : '';
    return `
        <html>
            <head>
                <style>${cssStr}</style>
            </head>
            <body>
                <div id="root">${content}</div>
                <script>
                    window.context = {
                        state: ${JSON.stringfiy(store.getState())}
                    }
                </script>
                <script src="/index.js"></script>
            </body>
        </html>
    `;
}

多个组件的样式如何整合。我们可以使用一个数组来存储css样式。

src/server/utils.js

import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter, Route } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import { Provider } from 'react-redux';

export const render = (store, routes, req, context) => {
    const content = renderToString((
        <Provider store={store}>
            <StaticRouter location={req.path} context={{}}>
                <div>
                {renderRoutes(routes)}
                </div>
            </StaticRouter>
        </Provider>
    ));

    const cssStr = context.css.length ? context.css.join('\n') : '';
    return `
        <html>
            <head>
                <style>${cssStr}</style>
            </head>
            <body>
                <div id="root">${content}</div>
                <script>
                    window.context = {
                        state: ${JSON.stringfiy(store.getState())}
                    }
                </script>
                <script src="/index.js"></script>
            </body>
        </html>
    `;
}

src/components/Home/index.js

import React, { Component } from 'react';
import { connect } from 'react-redux';
import { getHomeList } from './store/actions';
import styles from './style.css';

class Home extends Component {
    componentWillMount() { // 处理样式
        if (this.props.staticContext) { // 服务端运行存在,客户端运行不存在。所以客户端不要执行。将样式存储在context中。
            this.props.staticContext.css.push(styles._getCss());
        }
    }

    getList() {
        const { list } = this.props;
        return this.props.list.map(item => <div key={item.id}>{item.title}</div>)
    }
    render() {
        return <div className={styles.test}>
            <div>Home</div>
            {this.getList()}
            <button onClick={() => { alert('click1'); }>按钮</button>
        </div>
    }

    componentDidMount() {
        if (!this.props.list.length) {
            this.props.getHomeList();
        }
    }
}

Home.loadData = (store) => {
    // 执行action,扩充store。
    return store.dispatch(getHomeList());
}

const mapStatetoProps = state => ({
    list: state.home.newsList
});

const mapDispatchToProps = dispatch => ({
    getHomeList() {
        dispatch(getHomeList());
    }
})

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

src/server/index.js在render方法里面对样式进行处理。

import express from 'express';
import proxy from 'express-http-proxy';
import { matchRoute } from 'react-router-config';
import { render } from './utils';
import { getStore } from '../store'; // 使用store
import routes from '../Routes';

const app = express();
app.use(express.static('public'));

app.use('/api', proxy('xx.xx.xx.xx', {
    proxyReqPathResolver: (req) => { // 转发到哪个路径
        return req.url;
    }
}))

app.get('*', function(req, res) {
    const store = getStore();
    const matchedRoutes = matchRoute(routes, req,path);
    const promises = [];
    matchedRoutes.forEach(item => {
        if (item.route.loadData) {
            const promise = new Promise((resolve, reject) => {
                item.route.loadData(store).then(resolve).catch(resolve);
            })
            promises.push(promise);
        }
    });
    Promise.all(promises).then(() => {
        const context = { css: [] };
        const html = render(store, routes, req, context)
        res.send(html); 
    })
})
var server = app.listen(3000);

其实上面代码还有一个问题。Home组件上我们挂载了一个loadData的方法,但是Home文件我们导出的并不是Home组件,而是connect包装过后的组件,所以导出的是另一个组件。不过幸好connect会分析原组件有哪些属性,并且再挂载到当前输出的内容上,所以后面我们使用Home组件的时候仍旧可以调用loadData方法。不过这样并不太好,最好还是直接声明一下,避免代码使用混乱。

将loadData挂载到ExportHome上。

src/components/Home/index.js

...

// Home.loadData = (store) => {
//     // 执行action,扩充store。
//     return store.dispatch(getHomeList());
// }

const mapStatetoProps = state => ({
    list: state.home.newsList
});

const mapDispatchToProps = dispatch => ({
    getHomeList() {
        dispatch(getHomeList());
    }
})

const ExportHome = connect(mapStatetoProps, mapDispatchToProps)(Home);

ExportHome.loadData = (store) => {
    // 执行action,扩充store。
    return store.dispatch(getHomeList());
}

export default ExportHome

使用高阶组件精简代码

上面我们的样式编写太麻烦了,我们首先需要使用componentWillMount生命周期,让后将它的样式注入到context之中。所以每一个组件都需要这样一段代码。这样的设计时并不合理的。我们可以整理一下。使用高阶组件。

src/withStyle.js创建这个高阶组件函数。这个函数返回一个组件。其实这个函数是生成高阶组件的函数,而返回的组件叫做高阶组件,他的工作是渲染前push样式。

我们这个函数要接收样式文件styles,因为组件并不知道styles在哪。还要接收原本要渲染的组件DecoratedComponent,在高阶组件中渲染出来,并且将参数传递进去。

import React, { Component } from 'react';

export default (DecoratedComponent, styles) => {
    return class NewComponent extends Component {
        componentWillMount() {
            if (this.props.staticContext) {
                this.props.staticContext.css.push(styles._getCss());
            }
        }
        render() {
            return <DecoratedComponent {...this.props}/>
        }
    }
}

这样高阶组件就写完了,接着我们改造一下Home组件。

src/components/Home/index.js,这里可以删掉自身的componentWillMount了,引入withStyle函数,然后再底部导出的时候使用withStyle包裹住Home组件,再传入styles样式就可以了。withStyle(Home, styles);

import React, { Component } from 'react';
import { connect } from 'react-redux';
import { getHomeList } from './store/actions';
import styles from './style.css';
import withStyle from '../../withStyle';

class Home extends Component {
    getList() {
        const { list } = this.props;
        return this.props.list.map(item => <div key={item.id}>{item.title}</div>)
    }
    render() {
        return <div className={styles.test}>
            <div>Home</div>
            {this.getList()}
            <button onClick={() => { alert('click1'); }>按钮</button>
        </div>
    }

    componentDidMount() {
        if (!this.props.list.length) {
            this.props.getHomeList();
        }
    }
}

const mapStatetoProps = state => ({
    list: state.home.newsList
});

const mapDispatchToProps = dispatch => ({
    getHomeList() {
        dispatch(getHomeList());
    }
})

const ExportHome = connect(mapStatetoProps, mapDispatchToProps)(withStyle(Home, styles));

ExportHome.loadData = (store) => {
    // 执行action,扩充store。
    return store.dispatch(getHomeList());
}

export default ExportHome

SEO优化

SEO优化也叫搜索引擎优化。

title和description对搜索引擎优化基本没什么帮助,他们只是网站的描述。百度的搜索会根据网站所有文本的内容进行匹配,给网站进行分类。所以很多时候我们搜索出来的网站和我们需要的内容一致,但是搜索出来的网站titile中并不包含搜索的关键词。

一个网站是由文字,多媒体,链接三部分组成。

在当今的互联网,内容需要原创,原创作品会得到更多的流量,SEO会分析内容的原创性。所以文字我们可以增加原创属性。

链接到的网站内容和当前网站的内容要相关,相关性越强SEO权重越高。

多媒体也需要原创。

React-Helmet

React-Helmet可以定制页面的title和meta

import React, { Component, Fragment } from 'react';
import { Helmet } from 'react-helmet';

class Home extends Component {
    render() {
        return <Fragment>
            <Helmet>
                <title>这是Helmet定义的title</title>
                <meta name="description" content="这是Helmet定义的description" />
            </Helmet>
            <div>Home</div>
            {this.getList()}
            <button onClick={() => { alert('click1'); }>按钮</button>
        </Fragment>
    }
}

上面的代码只是客户端的渲染,服务器短的渲染有一点不一样,不过也很简单,我们修改一下utils.js

src/server/utils.js

...

import { Helmet } from 'react-helmet';

export const render = (store, routes, req, context) => {
    ...

    const helmet = Helmet.renderStatic();
    return `
        <html>
            <head>
                ${helmet.title.toString()}
                ${helmet.meta.toString()}
                <style>${cssStr}</style>
            </head>
            <body>
                <div id="root">${content}</div>
                <script>
                    window.context = {
                        state: ${JSON.stringfiy(store.getState())}
                    }
                </script>
                <script src="/index.js"></script>
            </body>
        </html>
    `;
}

预渲染

url:localhost:8000/render?url=http://localhost:3000/

server.js

const prerender = require('prerender');
const server = prerender({
    port: 8000
});

server.start();

运行

node ./server.js

这样访问到的url内容中就存在了页面元素。我们可以在网站外层架设一层nginx,如果访问是个蜘蛛就将请求转发给预渲染服务器,如果是用户就将请求转发给真实的服务器。