×

基于react技术栈的单页应用(SPA)搭建_快速入门实践

96
AlienZHOU
2017.08.14 21:20* 字数 4278

概述

本篇文章使用create-react-app作为脚手架,结合react技术栈(react + redux + react-router),构建一个简单的单页面应用demo。文章会一步步地讲解如何构建这么一个单页应用。文章的最后也会给出相应的demo地址

本文主要是对SPA搭建的实践过程讲解,在对react、redux、react-router有了初步了解后,来运用这些技术构建一个简单的单页应用。这个应用包括了侧边导航栏与主体内容区域。下面简单罗列了将会用到的一些框架与工具。

  • create-react-app:脚手架
  • react:负责页面组件构建
  • react-router:负责单页应用路由部分的控制
  • redux:负责管理整个应用的数据流
  • react-redux:将react与redux这两部分相结合
  • redux-thunk:redux的一个中间件。可以使action creator返回一个function(而不仅仅是object),并且使得dispatch方法可以接收一个function作为参数,通过这种改造使得action支持异步(或延迟)操作
  • redux-actions:针对redux的一个FSA工具箱,可以相应简化与标准化action与reducer部分

好了,话不多说,一起来构建你的单页应用吧。

使用create-react-app脚手架

create-react-app是Facebook官方出品的脚手架。有了它,你只需要一行指令即可跳过webpack繁琐的配置、npm繁多的引入等过程,迅速构建react项目。

首先安装create-react-app

npm i -g create-react-app

安装完成后,就可以使用create-react-app指令快速创建一个基于webpack的react应用程序

cd $your_dir
create-react-app react-redux-demo

这时你可以进入react-redux-demo这个目录,运行npm start既可启动该应用。

打开访问localhost:3000看到下方对应的页面,就说明项目基础框架创建完毕了。

启动页面

创建React组件

修改目录结构

下面在我们的react-redux-demo项目,查看一下相应的目录结构

|--public
    |--index.html
    |-- ……
|--src
    |--App.js
    |--index.js
    |-- ……
|--node_modules

其中public中存放的内容不会被webpack编译,所以可以放一些静态页面或图片;src中存放的内容才会被webpack打包编译,我们主要工作的目录就是在src下。

了解react的同学肯定知道,在react中我们通过构建各种react component来实现一个新的世界。在我们的项目里,会基于此,将组件分为通用组件部分与页面组件部分。通用组件也就是我们普遍意义上的组件,一些大型项目会维护一个自己的组件库,其中的组件会被整个项目共享;页面组件实际上就是我们项目中所呈现出来的各个页面。因此,我们的目录会变成这样

|--public
      |--index.html
      |-- ……
|--src
    |--page
         |--welcome.js
         |--goods.js
    |--component
         |--nav
             |--index.js
             |--index.css
    |--App.js
    |--index.js
    |-- ……
|--node_modules

src目录下新建了pagecomponent两个目录分别用于存放页面组件和通用组件。页面组件包括welcome.js和商品列表页good.js,通用组件包括了一个导航栏nav

两种组件形式

编写页面或组件,类似于静态页的开发。推荐的组件写法有两种:

1)纯函数形式:该类组件为无状态组件。由于使用函数来定义,因此不能访问this对象,同时也没有生命周期方法,只能访问props。这类组件主要是一些纯展示类的小组件,通过将这些小组件进行组合构成更为复杂的组件。例如:

const Title = props => (
    <h1>
        {props.title} - {props.subtitle}
    </h1>
)

2)es6形式的组件:该类组件一般为复杂的或有状态组件。使用es6的class语法进行创建。需要注意的是,在页面/组件中使用this注意其指向,必要时需要绑定。绑定方法可以使用bind函数或箭头函数。创建方式如下:

class Title extends Component {
    constructor(props) {
        super(props);
        this.state = {
            shown: true
        };
    }
    
    render() {
        let style = {
            display: this.state.shown ? 'block' : none
        };
        return (
            <h1 style={style}>
                {props.title} - {props.subtitle}
            </h1>
        );
    }
}

下面是这两种组件之间的对比:

Presentational Components Container Components
Purpose How things look (markup, styles) How things work (data fetching, state updates)
Aware of Redux No Yes
To read data Read data from props Subscribe to Redux state
To change data Invoke callbacks from props Dispatch Redux actions
Are written By hand Usually generated by React Redux

鉴于上面的分析,我们可以将导航栏nav编写为无状态组件,而page中的部分使用有状态的组件。

导航栏组件nav

// component/nav/index.css
.nav {
    margin: 30px;
    padding: 0;
}
.nav li {
    border-left: 5px solid sandybrown;
    margin: 15px 0;
    padding: 6px 0;
    color: #333;
    list-style: none;
    background: #bbb;
}

// component/nav/index.js
import React from 'react';
import './index.css';

const Nav = props => (
    <ul className="nav">
        {
            props.list.map((ele, idx) => (
                <li key={idx}>{ele.text}</li>
            ))
        }
    </ul>
);

export default Nav;

修改后的App.jsApp.css

// App.css
.App {
    text-align: center;
}
.App::after {
    clear: both;
}
.nav_bar {
    float: left;
    width: 300px;
}
.conent {
    margin-left: 300px;
    padding: 30px;
}

// App.js
import React, { Component } from 'react';
import Nav from './component/nav';
import Welcome from './page/welcome';
import Goods from './page/goods';
import './App.css';

const LIST = [{
    text: 'welcome',
    url: '/welcome'
}, {
    text: 'goods',
    url: '/goods'
}];

const GOODS = [{
    name: 'iPhone 7',
    price: '6,888',
    amount: 37
}, {
    name: 'iPad',
    price: '3,488',
    amount: 82
}, {
    name: 'MacBook Pro',
    price: '11,888',
    amount: 15
}];

class App extends Component {
    render() {
        return (
            <div className="App">
                <div className="nav_bar">
                    <Nav list={LIST} />
                </div>
                <div className="conent">
                    <Welcome />
                    <Goods list={GOODS} />
                </div>
            </div>
        );
    }
}

export default App;

welcome页面

// page/welcome.js
import React from 'react';

const Welcome = props => (
    <h1>Welcome!</h1>
);

export default Welcome;

goods页面

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

class Goods extends Component {
    render() {
        return (
            <ul className="goods">
                {
                    this.props.list.map((ele, idx) => (
                        <li key={idx} style={{marginBottom: 20, listStyle: 'none'}}>
                            <span>{ele.name}</span> | 
                            <span>¥ {ele.price}</span> | 
                            <span>剩余 {ele.amount} 件</span>
                        </li>
                    ))
                }
            </ul>
        );
    }
}

export default Goods;

现在我们的页面是这样的

使用redux来管理数据流

redux数据流示意图

redux是flux架构的一种实现。图中展示了,在react+redux框架下,一个点击事件是如何进行交互的。

然而redux并不是完全依附于react的框架,实际上redux是可以和任何UI层框架相结合的。因此,为了更好得结合redux与react,对redux-flow中的store有一个更好的全局性管理,我们还需要使用react-redux

npm i --save redux
npm i --save react-redux

同时,为了更好地创建action和reducer,我们还会在项目中引入redux-actions:一个针对redux的一个FSA工具箱,可以相应简化与标准化action与reducer部分。当然,这是可选的

npm i --save redux-actions

下面我们会以goods页面为例,实现以下场景:goods页面组件渲染完成后,发送请求,获取商品列表。其中获取数据的方法会使用mock数据。

为了实现这些功能,我们需要进一步调整目录结构

|--public
      |--index.html
      |-- ……
|--src
    |--page
         |--welcome.js
         |--goods.js
    |--component
         |--nav
             |--index.js
             |--index.css
    |--action
         |--goods.js
    |--reducer
         |--goods.js
         |--index.js
    |--App.js
    |--index.js
    |-- ……
|--node_modules

首先,创建action

首先,我们要创建对应的action。

action是一个object类型,对于action的结构有Flux有相关的标准化建议FSA
一个action必须要包含type属性,同时它还有三个可选属性errorpayloadmeta

  • type属性相当于是action的标识,通过它可以区分不同的action,其类型只能是字符串常量或Symbol
  • payload属性是可选的,可以使任何类型。payload可以用来装载数据;在error为true的时候,payload一般是用来装载错误信息。
  • error属性是可选的,一般当出现错误时其值为true;如果是其他值,不被理解为出现错误。
  • meta属性可以使任何类型,它一般会包括一些不适合在payload中放置的数据。

我们可以创建一个获取goods信息的action:

// action/goods.js
const getGoods = goods => {
    return {
        type: 'GET_GOODS',
        payload: goods
    };
}

这样,我们就可以得到GET_GOODS这个action。

在项目中,使用redux-actions对actions进行创建与管理:

createAction(type, payloadCreator = Identity, ?metaCreator)

createAction相当于对action创建器的一个包装,会返回一个FSA,使用这个返回的FSA可以创建具体的action。

payloadCreator是一个function,处理并返回需要的payload;如果空缺,会使用默认方法。如果传入一个Error对象则会自动将action的error属性设为true

example = createAction('EXAMLE', data => data);
// 和下面的使用效果一样
example = createAction('EXAMLE');

因此上面的方式可以改写为:

// action/goods.js
import {createAction} from 'redux-actions';
export const getGoods = createAction('GET_GOODS'); 

* 此外,还可以使用createActions同时创建多个action creators。

其次,创建state的处理方法——reducer

针对不同的action,会有不同的reducer对应进行state处理,它们通过type的值相互对应。
reducer是一个处理state的方法(function),该方法接收两个参数,当前状态state和对应的action。根据stateaction,reducer会进行处理并返回一个新的state(同时也是一个新的object,而不去修改原state)。可以通过简单的switch操作来实现:

// reducer/goods.js
const goods = (state, action) => {
    switch (action.type) {
        case 'GET_GOODS':
            return {
                ...state,
                data: action.payload
            };
        // 其他action处理……
    }
}

对应createActionredux-actions也有相应的reducer方式:

handleAction(type, reducer | reducerMap = Identity, defaultState)

type可以是字符串,也可以是createAction返回的action创建器:

handleAction('GET_GOODS', {
    next(state, action) {...},
    throw(state, action) {...}
}, defaultState);

//或者可以是
handleAction(getGoods, {
    next(state, action) {...},
    throw(state, action) {...}
}, defaultState);

此外,有时候一些操作的一系列action可以在语义和业务逻辑上是有一定联系的,我们希望将他们放在一起便于维护。可以通过handleActions方法将多个相关的reducer写在一起,以便于后期维护:

handleActions(reducerMap, defaultState)

因此,我们使用redux-actions来改写我们之前写的reducer

// reducer/goods.js
import {handleActions} from 'redux-actions';

export const goods = handleActions({
    GET_GOODS: (state, action) => ({
        ...state,
        data: action.payload
    })
}, {
    data: []
});

然后,对reducer进行合并

因为在redux中会统一管理一个store,因此,需要将不用的reducer所处理的state进行合并。

redux为我们提供了combineReducers方法。当业务逻辑过多时,我们可以将多个reducer进行组合,生成一个统一的reducer。虽然现在我们只有一个reducer,但是为了拓展性和示范性,在这里还是创建了一个reducer/index.js文件来进行reducer的合并,生成一个rootReducer

// reducer/index.js
import {combineReducers} from 'redux';
import {goods} from './goods';

export const rootReducer = combineReducers({
    goods
});

之后,将页面组件与数据流相结合

上面的部分已经将redux中的action与reducer创建完毕了,然而,现在的数据流和我们的组件仍然是处于分离状态的,我们需要让全局的state,即store,的变化能够驱动页面组件的变化,才能完成redux-flow中的最后一环。这就需要将store中的各部分state映射到组件的props上。

解决这个问题就要用到我们之前提到的react-redux工具了。

首先,我们需要基于rootReducer创建一个全局的store。在src目录下新建一个store.js文件,调用redux的createStore方法:

// store.js
import {createStore} from 'redux';
import {rootReducer} from './reducer';
export const store = createStore(rootReducer);

然后,我们需要让所有的组件都能访问到store。最简单的方式就是使用react-redux
提供的Provider对整个应用进行包装。这样就可以使所有的子页面、子组件能访问到store。因此需要改写index.js

// index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import {Provider} from 'react-redux';
import {store} from './store';

ReactDOM.render(
    <Provider store={store}>
        <App />
    </Provider>,
document.getElementById('root'));

最后,才是进行组件与状态的连接。将store中需要映射的部分connect到我们的组件上。使用其connect方法可以做到这一点:

connect(mapStateToProps)(component);

redux中存在一个全局的store,其中存储了整个应用的状态,对其进行统一管理。connect可以将这个状态中的数据连接到页面组件上。其中,mapStateToProps是store中状态到该组件属性的一个映射方式,component是需要连接的页面组件。通过connect方法,一旦store发生变化,组件也就会相应更新。

我们需要修改原先page/goods.js

import React, { Component } from 'react';
import {connect} from 'react-redux';

class Goods extends Component {
    render() {
        return (
            <ul className="goods">
                {
                    this.props.list.map((ele, idx) => (
                        <li key={idx} style={{marginBottom: 20, listStyle: 'none'}}>
                            <span>{ele.name}</span> | 
                            <span>¥ {ele.price}</span> | 
                            <span>剩余 {ele.amount} 件</span>
                        </li>
                    ))
                }
            </ul>
        );
    }
}

const mapStateToProps = (state, ownProps) => ({
    goods: state.goods.data
});
// -export default Goods;
export default connect(mapStateToProps)(Goods);

此外,也可以为组件中相应的方法映射对应的action的触发:

const mapDispatchToProps = dispatch => ({
    onShownClick: () => dispatch($yourAction)
});

最后,在组件渲染完成后触发整个flow

如果产生了一个需要状态更新的交互,可以通过在组件中相应部分触发action来实现状态更新-->组件更新。触发方式:

dispatch($your_action)

connect后的组件,其props里会有一个dispatch的属性,就是个dispatch方法:

let dispatch = this.props.dispatch;

因此,最终的page/goods.js组件如下:

import React, { Component } from 'react';
import {connect} from 'react-redux';
import * as actions from '../action/goods';

const GOODS = [{
    name: 'iPhone 7',
    price: '6,888',
    amount: 37
}, {
    name: 'iPad',
    price: '3,488',
    amount: 82
}, {
    name: 'MacBook Pro',
    price: '11,888',
    amount: 15
}]; 

class Goods extends Component {
    componentDidMount() {
        let dispatch = this.props.dispatch;
        dispatch(actions.getGoods(GOODS));
    }
    render() {
        return (
            <ul className="goods">
                {
                    this.props.goods.map((ele, idx) => (
                        <li key={idx} style={{marginBottom: 20, listStyle: 'none'}}>
                            <span>{ele.name}</span> | 
                            <span>¥ {ele.price}</span> | 
                            <span>剩余 {ele.amount} 件</span>
                        </li>
                    ))
                }
            </ul>
        );
    }
}

const mapStateToProps = (state, ownProps) => ({
    goods: state.goods.data
});

export default connect(mapStateToProps)(Goods);

注意到,组件中数据不再是由App.js中写入的了,而是经过了完整的redux-flow的过程获取并渲染的。注意同时修改App.js

import React, { Component } from 'react';
import Nav from './component/nav';
import Welcome from './page/welcome';
import Goods from './page/goods';
import './App.css';

const LIST = [{
    text: 'welcome',
    url: '/'
}, {
    text: 'goods',
    url: '/goods'
}];

class App extends Component {
    render() {
        return (
            <div className="App">
                <div className="nav_bar">
                    <Nav list={LIST} />
                </div>
                <div className="conent">
                    <Welcome />
                    <Goods />
                </div>
            </div>
        );
    }
}

export default App;

现在访问页面,虽然效果和之前一致,但是其内部构造和原理已经大不相同了。

最后一部分:添加路由系统

单页应用中的重要部分,就是路由系统。由于不同普通的页面跳转刷新,因此单页应用会有一套自己的路由系统需要维护。

我们当然可以手写一个路由系统,但是,为了快速有效地创建于管理我们的应用,我们可以选择一个好用的路由系统。本文选择了react-router 4。这里需要注意,在v4版本里,react-router将WEB部分的路由系统拆分至了react-router-dom,因此需要npmreact-router-dom

npm i --save react-router-dom

本例中我们使用react-router中的BrowserRouter组件包裹整个App应用,在其中使用Route组件用于匹配不同的路由时加载不同的页面组件。(也可以使用HashRouter,顾名思义,是使用hash来作为路径)react-router推荐使用BrowserRouterBrowserRouter需要history相关的API支持。

首先,需要在App.js中添加BrowserRouter组件,并将Route组件放在BrowserRouter内。其中Route组件接收两个属性:pathcomponent,分别是匹配的路径与加载渲染的组件

// index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import {Provider} from 'react-redux';
import {store} from './store';
import {BrowserRouter, Route} from 'react-router-dom';

ReactDOM.render(
    <Provider store={store}>
        <BrowserRouter>
            <Route path='/' component={App}/>
        </BrowserRouter>
    </Provider>,
document.getElementById('root'));

此时我们启动服务器的效果和之前一直。因为此时路由匹配到了path='/',因此加载了App组件。

还记得我们在最开始部分创建的Nav导航栏组件么?现在,我们就要实现导航功能:点击对应的导航栏链接,右侧显示不同的区域内容。这需要改造index.js中的content部分:我们为其添加两个Route组件,分别在不同的路径下加载不同的页面组件(welcomegoods

// App.js
import React, { Component } from 'react';
import Nav from './component/nav';
import Welcome from './page/welcome';
import Goods from './page/goods';
import './App.css';
import {Route} from 'react-router-dom';

const LIST = [{
    text: 'welcome',
    url: '/welcome'
}, {
    text: 'goods',
    url: '/goods'
}];

class App extends Component {
    render() {
        return (
            <div className="App">
                <div className="nav_bar">
                    <Nav list={LIST} />
                </div>
                <div className="conent">
                    <Route path='/welcome' component={Welcome} />
                    <Route path='/goods' component={Goods} />
                </div>
            </div>
        );
    }
}

export default App;

现在,可以尝试在地址栏输入http://localhost:3000http://localhost:3000/welcomehttp://localhost:3000/goods来查看效果。

当然,实际项目里不可能是通过手动修改地址栏来“跳转”页面。所以需要用到Link这个组件。通过其中的to这个属性来指明“跳转”的地址。这个Link组件我们会添加到Nav组件中

// component/nav/index.js
import React from 'react';
import './index.css';
import {Link} from 'react-router-dom';

const Nav = props => (
    <ul className="nav">
        {
            props.list.map((ele, idx) => (
                <Link to={ele.url} key={idx}>
                    <li>{ele.text}</li>
                </Link>
            ))
        }
    </ul>
);

export default Nav;

最终页面效果如下:

最终效果图welcome页面
最终效果图goods页面

现在在这个demo里,我们点击左侧的导航,右侧内容发生变化,浏览器不会刷新。基于React+Redux+React-router,我们实现了一个最基础版的SPA(单页应用)。


点击这里可以下载这个demo。


额外的部分,异步请求

如果你还记得在redux数据流部分,是怎么给goods页面传入数据的:dispatch(actions.getGoods(GOODS)),我们直接给getGoods这个action构造器传入GOODS列表,作为加载的数据。但是,在实际的应用场景中,往往,我们会在action中发送ajax请求,从后端获取数据;在等待数据获取的过程中,可能还会有一个loading效果;最后收到了response响应,再渲染响应页面。

基于以上的场景,重新整理一下我们的action内的思路:

  1. component渲染完成后,触发一个action,dispatch(actions.getGoods())。这个action并不会带列表的参数,而是向后端请求结果。
  2. getGoods()这个方法里,主要会做这三件数:首先,触发一个requestGoods的action,用于表示现在正在请求数据;其次,会调用一个叫fetchData()的方法,这个就是向后端请求数据的方法;最后,在拿到数据后,再触发一个receiveGoods的action,用于标识请求完成并带上渲染的数据。
  3. 其他部分与之前类似。

这里就有一个问题,基于上面的讨论,我们需要actions.getGoods()这个方法返回一个function来实现我们在步骤2里所说的三个功能;然而,目前项目中的dispatch()方法只能接受一个object类型作为参数。所以,我们需要改造dispatch()方法。

改造的手段就是使用redux-thunk这个中间件。可以使action creator返回一个function(而不仅仅是object),并且使得dispatch方法可以接收一个function作为参数,通过这种改造使得action支持异步(或延迟)操作。

那么如何来改造呢?首先为redux加入redux-thunk这个中间件

npm i --save redux-thunk

然后修改store.js

// store.js
import {createStore, applyMiddleware, compose} from 'redux';
import {rootReducer} from './reducer';
import thunk from 'redux-thunk';

const middleware = [thunk];
export const store = createStore(rootReducer, compose(
    applyMiddleware(...middleware)
));

然后,基于之前的思路,整理action中的代码。在这里,我们使用setTimeout来模拟向后端请求数据:

// action/goods.js
import {createAction} from 'redux-actions';

const GOODS = [{
    name: 'iPhone 7',
    price: '6,888',
    amount: 37
}, {
    name: 'iPad',
    price: '3,488',
    amount: 82
}, {
    name: 'MacBook Pro',
    price: '11,888',
    amount: 15
}]; 

const requestGoods = createAction('REQUEST_GOODS');
const receiveGoods = createAction('RECEIVE_GOODS');

const fetchData = () => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(GOODS);
        }, 1500);
    });
};

export const getGoods = () => async dispatch => {
    dispatch(requestGoods());
    let goods = await fetchData();
    dispatch(receiveGoods(goods));
};

相应地修改reducer中的代码

// reducer/goods.js
import {handleActions} from 'redux-actions';

export const goods = handleActions({
    REQUEST_GOODS: (state, action) => ({
        ...state,
        isFetching: true
    }),
    RECEIVE_GOODS: (state, action) => ({
        ...state,
        isFetching: false,
        data: action.payload
    })
}, {
    isFetching: false,
    data: []
});

可以看到,我们添加了一个isFetching的状态来表示数据是否加载完毕。

最后,还需要更新UI component层

// page/goods.js
import React, { Component } from 'react';
import {connect} from 'react-redux';
import * as actions from '../action/goods';

class Goods extends Component {
    componentDidMount() {
        let dispatch = this.props.dispatch;
        dispatch(actions.getGoods());
    }
    render() {
        return this.props.isFetching ? (<h1>Loading…</h1>) : (
            <ul className="goods">
                {
                    this.props.goods.map((ele, idx) => (
                        <li key={idx} style={{marginBottom: 20, listStyle: 'none'}}>
                            <span>{ele.name}</span> | 
                            <span>¥ {ele.price}</span> | 
                            <span>剩余 {ele.amount} 件</span>
                        </li>
                    ))
                }
            </ul>
        );
    }
}

const mapStateToProps = (state, ownProps) => ({
    isFetching: state.goods.isFetching,
    goods: state.goods.data
});

export default connect(mapStateToProps)(Goods);

最终,访问http://localhost:3000/goods页面会有一个大约1.5s的loading效果,然后等“后端”数据返回后渲染出列表。

loading效果
列表加载完毕

最后的最后,如果你还没有走开

再介绍一个redux调试神器——redux-devTools,可以在chrome插件中可以找到

redux-devTools extension

在开发者工具中使用,可以很方便的进行redux的调试

redux-devTools调试界面
redux-devTools调试界面

当然,需要在代码中进行简单的配置。对store.js进行一些小修改

import {createStore, applyMiddleware, compose} from 'redux';
import {rootReducer} from './reducer';
import thunk from 'redux-thunk';

const middleware = [thunk];
// export const store = createStore(rootReducer, compose(
//     applyMiddleware(...middleware)
// ));
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
export const store = createStore(rootReducer, composeEnhancers(
    applyMiddleware(...middleware)
));

以上。

现在,你可以愉快地进行SPA的开发啦!本文中的demo可以点击这里获取


Happy Coding!


web前端
Web note ad 1