之前寫過一篇文章,分享了我利用閑暇時(shí)間,使用React+Redux技術(shù)棧重構(gòu)的百度某產(chǎn)品個(gè)人中心頁面。您可以參考這里,或者參考Github代碼倉庫地址。
這個(gè)工程實(shí)例中,我采用了廠內(nèi)的工程構(gòu)建工具-FIS3,并貫穿了react+redux基本思想。
今天這篇文章給大家分享一個(gè)更加復(fù)雜,但是非常有趣的一個(gè)項(xiàng)目-
News Early單頁應(yīng)用。
最近我發(fā)現(xiàn),React Redux生態(tài)圈項(xiàng)目活躍。但是作品質(zhì)量“良莠不齊”,很多非常熱門的項(xiàng)目不僅沒有起到“布道”作用,而且在一定程度上“誤導(dǎo)”了讀者。在這篇文章里面我會(huì)有詳細(xì)說明。當(dāng)然,我自己也是資歷淺顯,水平有限。希望大神能夠給與斧正。
我把這個(gè)項(xiàng)目所有代碼托管在了我個(gè)人Github之中,感興趣的讀者可以跟我探討。
同時(shí)通過這個(gè)項(xiàng)目實(shí)例和這篇文章,一步一步說明了這個(gè)項(xiàng)目開發(fā)細(xì)節(jié),并且包括了優(yōu)化手段等內(nèi)容。希望使大家對于React技術(shù)棧,包括:React UI框架 + Redux數(shù)據(jù)流框架+React Router路由管理+Webpack構(gòu)建工具等,有一個(gè)更加清晰深刻的理解。
項(xiàng)目背景
在國外上學(xué)和工作期間,能暢通無阻的訪問諸如:BBC,CNN,ESPN,Le Figaro等新聞媒體是一大便利,也是我個(gè)人閑暇時(shí)期一個(gè)喜好之一。
甚至外出旅游時(shí),在酒店收看這些媒體衛(wèi)視(尤其CNN)竟然也是放松休閑的一大方式。。。
當(dāng)然,國內(nèi)環(huán)境對于這些境外媒體顯然不是太友好。
基于此,我設(shè)計(jì)開發(fā)了News Early項(xiàng)目。
這個(gè)項(xiàng)目是一個(gè)包括:BBC,CNN,The NewYork Times等70多個(gè)國際知名媒體的即時(shí)頭條新聞聚合APP。
News Early is a simple and easy-to-use Web APP that gathers the headlines currently published on a range of news sources and blogs (70 and counting so far).
整個(gè)項(xiàng)目我使用了包括但不限于以下技術(shù)棧和構(gòu)建工具:
1)React UI框架from Facebook;
2)JSX模版;
3)Redux數(shù)據(jù)流設(shè)計(jì);
4)Webpack構(gòu)建工具;
5)Less預(yù)處理器;
......
項(xiàng)目設(shè)計(jì)
整個(gè)Web APP的部分使用體驗(yàn),我用以下GIF圖示來呈現(xiàn):
(請耐心等待GIF圖加載)

-
頁面頂部導(dǎo)航條
包括:側(cè)欄菜單開啟按鈕和右側(cè)的刷新頁面按鈕。 -
頁面內(nèi)容頭部輪播圖
支持自動(dòng)播放和手勢滑動(dòng)操控。 -
頁面主體部分
主體部分是所對應(yīng)的新聞?lì)l道的headlines頭條新聞,一般有10-20個(gè)items左右。每一個(gè)item包含一張新聞圖片,新聞導(dǎo)讀(Abstract)以及新聞發(fā)布時(shí)間(publish time)。 -
左側(cè)折疊菜單欄
功能用于新聞?lì)l道的篩選。
以Gif圖截取為止,一共接入了:BBC News,BBC Sport,CNN,ESPN,F(xiàn)inancial Times,USA Today,MTV News7家國際媒體。
因?yàn)槲也皇歉阋曈X設(shè)計(jì)的,也不是做頁面交互設(shè)計(jì)的(好吧,我只是一枚碼農(nóng))。所以為了節(jié)省時(shí)間,整體APP的樣式上,包括界面顏色等,我參考了賣座網(wǎng)的實(shí)現(xiàn)。
項(xiàng)目架構(gòu)和落地
下面,我為大家介紹一下整個(gè)項(xiàng)目的設(shè)計(jì)構(gòu)成和開發(fā)細(xì)節(jié)。
數(shù)據(jù)流狀態(tài)演示
熟悉Redux數(shù)據(jù)流框架的同學(xué),應(yīng)該對于store,dispatch,action,reducer,以及中間件等概念比較熟悉。這里不再進(jìn)行講解。
這套架構(gòu)中,最重要的就是數(shù)據(jù)流的設(shè)計(jì)。
首先,我們先整體看一下在“切換頻道”這個(gè)交互發(fā)生時(shí),整個(gè)項(xiàng)目的數(shù)據(jù)流向和數(shù)據(jù)結(jié)構(gòu)的演示:

目錄結(jié)構(gòu)
如圖所示:

整個(gè)項(xiàng)目業(yè)務(wù)代碼部分,我拆分成9個(gè)UI組件,1個(gè)全局Store,一個(gè)actions定義文件。
- app是開發(fā)目錄
- actions目錄集中了全局所有的actions
- components目錄集中了全局用到的所有UI組件
- reducers目錄集中了Redux架構(gòu)中的所有reducers
- store目錄定義全局唯一的store
- style目錄集中了全局所有組件的樣式文件
- main.js為全局的入口函數(shù)
- build是打包后結(jié)果目錄
- index.html是輸出頁面文件
- bundle.js開發(fā)目錄下腳本文件打包后的產(chǎn)出
- img文件定義了APP開啟時(shí)的loading圖片
- node_modules相信大家不會(huì)陌生,這是依賴文件
...
其他配置文件不再一一介紹。
10個(gè)組件包括:
- appIndex: 組件容器
- billboardCarousel: 頁面輪播圖組件
- currentChanel: 頁面headlines新聞?lì)^條組件
- homeView: 主體頁面
- imagePlaceholder: 占位圖組件
- loading: 加載提示組件
- navBar :頂部導(dǎo)航組件
- sideBar: 側(cè)邊欄組件
- routerWrap: 路由相關(guān)組件
骨架構(gòu)建
我認(rèn)為,redux之所以學(xué)習(xí)曲線陡,很大程度上就在于數(shù)據(jù)流的貫通上。
“組件觸發(fā)(dispatch)各種action,單向數(shù)據(jù)流流向reducer,reducer是一個(gè)純函數(shù)(函數(shù)式編程思想),接收處理action,返回新的數(shù)據(jù),組件進(jìn)而更新”
這一套理論并不難理解。
但是落實(shí)在工程上,尤其要結(jié)合react,那就不好做了。即使有人做出來,業(yè)務(wù)就算可以跑得通,但是相比核心思想,卻是背道而馳。社區(qū)上我看過很多項(xiàng)目,在寫法上不分青紅皂白,只要能運(yùn)行,胡亂設(shè)計(jì)一通,誤導(dǎo)初學(xué)者。
比如在整個(gè)項(xiàng)目中,存在多個(gè)stores這種常見的問題。
那么,為什么不建議存在多個(gè)store呢?
答案可以在官方FAQ中找到。內(nèi)容較多,如果英文閱讀吃力,我大體翻譯一下:
熟悉Flux原始模型的讀者可能了解,F(xiàn)lux存在多個(gè)stores,每個(gè)store都維護(hù)了不同層次的數(shù)據(jù)。這樣設(shè)計(jì)的問題在于,一個(gè)store需要等待另外一個(gè)store的操作處理。我們Redux實(shí)現(xiàn)了切分?jǐn)?shù)據(jù)層次,避免了這種情況的發(fā)生。
僅維持單個(gè)store不僅可以使用Redux DevTools,還能簡化數(shù)據(jù)的持久化及深加工、精簡訂閱的邏輯處理。
單一store這種方式,我們不用考慮store模塊的導(dǎo)入、 Redux應(yīng)用的封裝,后期支持服務(wù)器渲染也將變得更為簡便。
如果上邊這段話過于抽象,難以理解的話,那就直接看我的代碼實(shí)現(xiàn)吧。
定義全局唯一的store:
const store = createStore(
combineReducers({
sideBarChange,
contents,
routing: routerReducer
}),
composeEnhancers(applyMiddleware(thunkMiddleware)),
);
其中,我使用了redux-thunk作為中間件,用于處理異步action。這樣,把異步過程放在action級別解決,對component沒有影響。
另外composeEnhancers是用于使用redux devtool的設(shè)置。
容器組件構(gòu)建:
const mapStateToProps = (state) => {
return {
showLeftNav: state.sideBarChange.showLeftNav,
loading: state.contents.loading,
contents: state.contents.contents,
currentChanel: state.contents.currentChanel
}
}
var App = connect(mapStateToProps)(AppIndex);
render(
<Provider store={store}>
<Router history={history}>
<Route path="/" component={App}>
<Route path="home" component={HomeView}/>
</Route>
</Router>
</Provider>,
document.getElementById('app')
);
其中,我使用了react-redux進(jìn)行連接。AppIndex是整個(gè)項(xiàng)目唯一的容器組件。進(jìn)行action的dispatch,以及向下傳遞props給UI組件(木偶組件)。
如果你還不理解容器組件和UI組件的區(qū)別,可以去官方文檔學(xué)習(xí)。這兩個(gè)概念極其重要,它直接決定你是否能設(shè)計(jì)出有效且合理的組件架構(gòu)。
另外,你會(huì)發(fā)現(xiàn)我使用了react-router進(jìn)行路由管理。其實(shí)整個(gè)項(xiàng)目沒有必要使用單頁路由。這個(gè)路由管理的引入,說實(shí)話,比較雞肋。但并不會(huì)對項(xiàng)目產(chǎn)生任何影響。我引入他的原因主要有兩點(diǎn)。
- 第一是,后續(xù)進(jìn)行二次開發(fā),考慮到更多的產(chǎn)品迭代的話,使用路由管理是必須的,我們要為長遠(yuǎn)準(zhǔn)備。
- 另一個(gè)原因就是,我從來沒用用過,好吧,想嘗鮮下。
actions設(shè)計(jì)
actions當(dāng)然是必不可少的,我這里選取最重要的“fetchContents”這個(gè)action creator來討論一下。
初次進(jìn)入頁面時(shí),以及左側(cè)邊欄點(diǎn)擊選擇新聞?lì)l道時(shí),都要去拉取數(shù)據(jù)。比如,APP第一次渲染,默認(rèn)加載“BBC News”新聞?lì)l道,頁面主體組件在掛載完成后:
componentDidMount() {
//獲取內(nèi)容
this.props.fetchContents('bbc-news');
}
向上調(diào)用fetchContents方法,并逐級上傳到容器組件。由容器組件進(jìn)行dispatch:
fetchContents={(source)=>{this.props.dispatch(action.fetchContents(source))}}
source表示拉取的新聞?lì)l道。此處當(dāng)然是'bbc-news'。
在actions.js文件中,進(jìn)行異步action的處理并拉取數(shù)據(jù)。這里,我使用了最新的fetch API來代替古老的XHR,并利用fetch的promise的理念,封裝了一層_get方法,用于AJAX異步請求:
const sendByGet = ({url}, dispatch) => {
let finalUrl = url + '&apiKey=1a445a0861e'
return fetch(finalUrl)
.then(res => {
if (res.status >= 200 && res.status < 300) {
return res.json();
}
return Promise.reject(new Error(res.status));
})
}
對應(yīng)的action操作:
export const fetchContents = (source) => {
const url = '...';
return (dispatch) => {
dispatch({type: FETCH_CONTENTS_START});
if (sessionStorage.getItem(source)) {
console.log('get from sessionStorage');
let articles = JSON.parse(sessionStorage.getItem(source));
dispatch({type: FETCH_CONTENTS_SUCCESS, contents: Object.assign(articles, {currentChanel: source.toUpperCase()})})
}
else {
sendByGet({url}, dispatch)
.then((json) => {
if (json.status === 'ok') {
sessionStorage.setItem(source, JSON.stringify(json.articles));
return dispatch({type: FETCH_CONTENTS_SUCCESS, contents: Object.assign(json.articles, {currentChanel: source.toUpperCase()})})
}
return Promise.reject(new Error('FETCH_CONTENTS_SUCCESS failure'));
})
.catch((error) => {
return Promise.reject(error)
})
}
}
}
請求優(yōu)化
我們知道,這些異步請求的訪問速度是很慢的。因此,我采用了幾種方法來進(jìn)行優(yōu)化。
- 第一個(gè)方法就是加載時(shí)的loading美化。
我使用了來自網(wǎng)絡(luò)的圖片占位。
當(dāng)我把控制臺(tái)中網(wǎng)絡(luò)環(huán)境人為的模擬為3G時(shí),頁面效果如下:
(請耐心等待GIF圖加載)

原諒我使用了這么粉嫩少女的加載圖。。。
-
第二個(gè)方法其實(shí)是一個(gè)trick,我的全局圖片在初始狀態(tài)時(shí)opacity設(shè)置為0,在onload事件觸發(fā)時(shí)設(shè)置一個(gè)fadeIn的效果:
<img ref="image" src={imgSrc} onLoad= {this.handleImageLoaded.bind(this)}/> handleImageLoaded() { this.refs['image'].style.opacity = 1; }
這樣的一個(gè)小技巧最初來自Facebook對用戶體驗(yàn)的研究。如果您對此有興趣,可以在我的另外一篇文章中找到相關(guān)內(nèi)容。
- Web Storage來進(jìn)行優(yōu)化
因?yàn)楦鞔笮侣劽襟w的headlines發(fā)布更新是不定時(shí)的,這個(gè)時(shí)間間隔可能較長。而我考慮到用戶使用這個(gè)Web APP一般都是在碎片時(shí)間中。因此我采用了sessionStorage進(jìn)行緩存內(nèi)容。不要問我為什么不使用localStorage...,如果你存在疑問,建議對于Web Storage的特性再去回爐重修一下。
具體實(shí)現(xiàn)方式就是在發(fā)送請求時(shí)判斷sessionStorage是否已經(jīng)存在此新聞媒體(比如bbc)的數(shù)據(jù)。如果存在就使用緩存。否則就去進(jìn)行AJAX請求,請求成功的回調(diào)函數(shù)里進(jìn)行緩存的種植。
代碼部分如下:
if (sessionStorage.getItem(source)) {
console.log('get from sessionStorage');
let articles = JSON.parse(sessionStorage.getItem(source));
dispatch({type: FETCH_CONTENTS_SUCCESS, contents: Object.assign(articles, {currentChanel: source.toUpperCase()})})
}
else {
sendByGet({url}, dispatch)
.then((json) => {
if (json.status === 'ok') {
sessionStorage.setItem(source, JSON.stringify(json.articles));
return dispatch({type: FETCH_CONTENTS_SUCCESS, contents: Object.assign(json.articles, {currentChanel: source.toUpperCase()})})
}
return Promise.reject(new Error('FETCH_CONTENTS_SUCCESS failure'));
})
.catch((error) => {
return Promise.reject(error)
})
}
當(dāng)然,有種植緩存,就要有清除緩存。這個(gè)按鈕我設(shè)置在里navBar組件的最右側(cè):
const CLEAR_SESSIONSTORAGE = 'CLEAR_SESSIONSTORAGE';
export const refresh = () => {
sessionStorage.clear();
return dispatch => dispatch({type: CLEAR_SESSIONSTORAGE});
}
其他細(xì)節(jié)
為了使用先進(jìn)的構(gòu)建工具的需求,我使用了node最新版本。但是因?yàn)楣ぷ鳂I(yè)務(wù)的需要,又要同時(shí)保留低版本node環(huán)境。為此,我使用了:n這個(gè)利器進(jìn)行node版本管理。
同時(shí),我使用了webPack一系列強(qiáng)大開發(fā)功能和構(gòu)建功能。包括但不限于:
- 熱更新
- Less編譯插件
- 服務(wù)器構(gòu)建,使用了8088端口
- jsx,es6編譯
- 打包發(fā)布
- 彩色日志
...等等,但是我可不是webpack專家。在狼廠,當(dāng)然使用更多的是FIS構(gòu)建工具。關(guān)于FIS和webpack的比較,我的網(wǎng)紅同事@顏大神有過探索。
總結(jié)
這篇文章涉及到了較為前沿的前端開發(fā)技術(shù)棧。包括了React框架,Redux數(shù)據(jù)流框架以及函數(shù)式編程、異步action中間件,fetch異步請求,webpack配置等等。也無形中涉及到了一些成熟產(chǎn)品的設(shè)計(jì)理念思路。當(dāng)然這個(gè)項(xiàng)目還遠(yuǎn)沒有成熟。在代碼倉庫中,我會(huì)不間斷進(jìn)行更新。
希望本文對大家在各個(gè)維度都有所啟發(fā)。也懇請業(yè)界大牛不吝賜教,進(jìn)行斧正。
最后想跟大家談一下對于框架和前端學(xué)習(xí)的一些感受。我記得我剛開始工作,在初次接觸前端時(shí),是使用ionic,即Angular框架和phoneGap開發(fā)hybrid移動(dòng)APP。當(dāng)時(shí)我是完全懵b的,只是感覺比利時(shí)同事用的超high,6到飛起。每次他用濃重的比利時(shí)口音法語給我講解時(shí),我聽的云里霧里,不知所以。
現(xiàn)在想想當(dāng)時(shí)那么菜的原因還是在于自己的JS基礎(chǔ)不夠牢固。當(dāng)你面對迅速更新?lián)Q代的前端技術(shù)踟躕茫然時(shí),唯一的捷徑就是從基礎(chǔ)抓起,從JS原型原型鏈,this,執(zhí)行環(huán)境上下文等等看起。
覺得前端知識有欠缺的讀者們,歡迎follow我。最近我會(huì)帶大家“重讀”JS經(jīng)典書籍,以code demo的形式提煉知識點(diǎn),并會(huì)同步到博客和個(gè)人Github上。
Happying code!