概述
本篇文章使用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
目录下新建了page
和component
两个目录分别用于存放页面组件和通用组件。页面组件包括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.js
与App.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是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
属性,同时它还有三个可选属性error
、payload
和meta
。
- 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
。根据state
与action
,reducer会进行处理并返回一个新的state
(同时也是一个新的object
,而不去修改原state
)。可以通过简单的switch操作来实现:
// reducer/goods.js
const goods = (state, action) => {
switch (action.type) {
case 'GET_GOODS':
return {
...state,
data: action.payload
};
// 其他action处理……
}
}
对应createAction
,redux-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推荐使用BrowserRouter
,BrowserRouter
需要history
相关的API支持。
首先,需要在App.js
中添加BrowserRouter
组件,并将Route
组件放在BrowserRouter
内。其中Route
组件接收两个属性:path
和component
,分别是匹配的路径与加载渲染的组件
// 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
组件,分别在不同的路径下加载不同的页面组件(welcome
与goods
)
// 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:3000
、http://localhost:3000/welcome
和http://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;
最终页面效果如下:
现在在这个demo里,我们点击左侧的导航,右侧内容发生变化,浏览器不会刷新。基于React+Redux+React-router,我们实现了一个最基础版的SPA(单页应用)。
额外的部分,异步请求
如果你还记得在redux数据流部分,是怎么给goods页面传入数据的:dispatch(actions.getGoods(GOODS))
,我们直接给getGoods
这个action
构造器传入GOODS
列表,作为加载的数据。但是,在实际的应用场景中,往往,我们会在action中发送ajax请求,从后端获取数据;在等待数据获取的过程中,可能还会有一个loading效果;最后收到了response响应,再渲染响应页面。
基于以上的场景,重新整理一下我们的action内的思路:
- component渲染完成后,触发一个action,
dispatch(actions.getGoods())
。这个action并不会带列表的参数,而是向后端请求结果。 - 在
getGoods()
这个方法里,主要会做这三件数:首先,触发一个requestGoods
的action,用于表示现在正在请求数据;其次,会调用一个叫fetchData()
的方法,这个就是向后端请求数据的方法;最后,在拿到数据后,再触发一个receiveGoods
的action,用于标识请求完成并带上渲染的数据。 - 其他部分与之前类似。
这里就有一个问题,基于上面的讨论,我们需要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效果,然后等“后端”数据返回后渲染出列表。
最后的最后,如果你还没有走开
再介绍一个redux调试神器——redux-devTools,可以在chrome插件中可以找到
在开发者工具中使用,可以很方便的进行redux的调试
当然,需要在代码中进行简单的配置。对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可以点击这里获取。