概述
本篇文章使用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可以點擊這里獲取。