一個 react+redux 工程例項

LucasHC發表於2017-03-23

在前幾天的一篇文章中總結部分提到了學習過程中基礎的重要性。當然,並不是不支援大家學習新的框架,這篇文章就分享一下react+redux工程例項。

一直在學習研究react.js,前前後後做了幾次分享。並在我參與的公司產品私信專案也使用了這套技術棧 。學習過程期間,感覺react+redux初級DEMO不多,社群上多是用爛了的todolist教程,未免乏味。

這篇文章主要實現一個簡單的例子,難度不大,但是貫穿了react+redux基本思想。
他將會是一個連續教程,這只是第一篇,不涉及redux中介軟體,redux處理非同步等內容,也不涉及react效能優化,不可變資料immutable.js的內容。但這些不涉及到的內容,都會隨著這個demo的複雜度一步一步提升,在後續章節有分析和使用。

整個專案的程式碼,你可以在GitHub中找到。

簡介

百度經驗個人中心(WAP端)是經驗流量較多的頁面群,其中的個人定製頁面是重要的頁面之一,請用手機點選這裡檢視效果,頁面的截圖如下:

一個 react+redux 工程例項
頁面截圖

功能一目瞭然。主要分為兩大塊:
1)可以在『選擇分類』區塊中選擇自己喜歡的經驗分類條目來訂閱。在該區塊中,我們可以點選『換一換』按鈕來切換分類條目。
2)已選結果會再『已選分類』區塊裡面展示。在『已選分類』區塊裡,我們可以點選相關經驗分類條目來取消訂閱。

在現線上上版本中,我們採用了傳統的操作DOM方式(zepto類庫)來實現這一系列互動。使用react,又是一種全新的思想。孰優孰劣,可以在結尾處大家自己總結。
好了,廢話不多說,我們馬上進入正題。

架構

為了和我們線上程式碼保持一致,我採用了fisp來做工程化組織。現在社群上大多都是採用webpack,其實這些工具用哪個都一樣,解決的問題也都類似,這裡不再展開。即使你不懂FIS,也不妨礙繼續閱讀。

app/ app資料夾下

action.es定義了頁面互動中dispatch的所有actions;
app.jsx是頁面入口指令碼;
component.jsx定義了頁面的元件;
reducer.jsx接收action,該檔案定義了所有用到的reducer。

lib + lib-nomod 資料夾下

這兩個資料夾是我們要用的框架原始碼,比如react.js+redux.js等等;
該專案用到的是react15.3.1版本未壓縮版,這個版本比較穩定。
採用未壓縮版的原因是想使用react addons 的perf,因為在後續章節中,會有效能優化部分的分析;
我們知道,react和redux其實獨立存在,我們使用流行的react-redux.js庫來實現兩者的連線。

其他相關檔案

其他還有 fis-conf.js檔案:這是用來做fis配置的,比如打包規則,釋出規則,編譯配置等;
同時,我們配置了babel來編譯es6和jsx等,還配置了autoprefixer;
server.conf是fis的附屬檔案,用來做資料mock;
build.sh和BCLOUD是上線指令碼相關,這裡我們並不上線,只是學習react的用法。

頁面資料

我們部門後端是PHP,採用Smarty模板。這個頁面會在請求時同步給出一些資料,比如使用者資訊等,輸出在模版裡。我們的同步資料如截圖(由於機密性原因,資料進行了精簡、重新命名和重新設計):

一個 react+redux 工程例項
頁面同步資料

我們關心selectList和likedList:
1)likedList給出當前使用者已經選則的訂閱分類條目;
2)selectList給出所有可選的分類條目,一共從1-127,127個可選條目,資料格式如上。

具體實現

說了這麼多,終於可以進入具體程式碼層面了。如果上邊的內容你似懂非懂,也沒有關係。因為涉及了一些專案組織上的內容。下邊的內容,就是具體的程式碼分析。

資料設計

react+redux開發前端的思想是頁面由資料驅動。
上邊已經分析到我們的頁面主要由兩種資料:
1)一個是selectList,我們姑且叫做選擇池資料;
2)另一個是likedList,我們叫做已選資料。
這兩處資料初始由reducer拿到,設定為容器元件的初始狀態,並由容器元件傳遞給相應展示元件。

元件設計

元件設計如下截圖:

一個 react+redux 工程例項
元件設計

按照react-redux思想,元件分為:
1)容器元件,負責接收資料;
2)展示(木偶)元件負責向上接收資料,根據資料展現元件UI。

其實很明顯,我們主要就是兩個展示元件,叫做:
1)SelectedBlock,負責展示使用者已選已訂閱內容;
2)SelectListBlock,展示頁面選擇池可供選擇的內容。
他們一起被套在叫做DemoApp的父元件裡面。

有了以上劃分,我們有了:
1)SelectedBlock元件需要關心已選資料likedList;
2)SelectListBlock則選擇池資料和已選資料都需要關心。
你可能會問『SelectListBlock關心選擇池資料不就夠了嗎?』
但是,產品經理要求在選擇池裡,當渲染使用者已選條目時,需要樣式置灰,並且在點選已選分類條目時不在觸發action。所以選擇池SelectListBlock元件也要依賴已選資料,進而做出相應的變化。

這兩項資料由react-redux派分給容器元件,並由容器元件按需分給展示元件;

有了以上基礎,我們看最外層的DemoApp元件全部程式碼:

class DemoApp extends React.Component {
    constructor(props) {
        super(props);
    }
    render() {
        const { dispatch } = this.props;
        return (
            <div>
                <SelectedBlock 
                    likedList={this.props.likedList} 
                    onDeleteLikeItem={(item)=>{dispatch(action.deleteLikeItem(item))}}>
                </SelectedBlock>

                <SelectListBlock 
                    selectList={this.props.selectList} 
                    likedList={this.props.likedList} 
                    onAddLikeItem={(index, item) =>{dispatch(action.addLikeItem(index, item))}}>
                </SelectListBlock>
            </div>
        )
    }
}複製程式碼

我們看他的render()部分,很明顯,他平行巢狀了:
1)SelectedBlock元件,並把likedList資料作為屬性向其傳遞;
2)同時,包含了SelectListBlock,並把selectList,likedList資料作為屬性向其傳遞。

那麼SelectedBlock設計如下:

class SelectedBlock extends React.Component {
    constructor(props) {
        super(props);
    }
    deleteItem(event, index) {
        this.props.onDeleteLikeItem(index);
    }
    render() {
        let likedList = this.props.likedList;
        let likedListArray = [];
        let likedListKey = Object.keys(likedList);
        likedListKey.forEach(function(index){
            likedListArray.push(likedList[index]);
        })
        return (
            <div>
                <h2>已選分類(<em id="f-num">{likedListArray.length}</em>)</h2>
                <div className="selected-list" style={{overflow: 'auto'}}>
                    <ul className="feed-list">
                        {
                            likedListArray.length > 0 ?
                            likedListArray.map((item, index) => {
                                return (
                                    <li style={{position: 'relative'}}>
                                        <span>{item}</span>
                                        <a style={deleteIconStyle} 
                                            onClick={event=>{this.deleteItem(event, likedListKey[index])}}>
                                        </a>
                                    </li>
                                )
                            })
                            :
                            <li className="empty-list">還沒有任何訂閱<br />請從下方選擇訂閱</li>
                        }
                    </ul>
                </div>
            </div>
        )
    }
}複製程式碼

我們把likedList轉換成likedListArray陣列,在render()裡面,直接使用map迴圈輸出;
當使用者刪除某一條目時,觸發deleteItem(event, index)方法,該方法向上傳遞,並在DemoApp父元件中,觸發相應action。這個刪除過程並不是一個單純元件內行為,因為這個action會使得已選資料發生變化,進而影響SelectListBlock元件。所以一系列邏輯需要在reducer中處理,處理完後重置已選資料,進而頁面更新。

SelectListBlock元件也很好理解:

class SelectListBlock extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            flag: 0
        }
    }
    onChangeGroup(event) {
        event.stopPropagation();
        let flagNow = this.state.flag;
        if (flagNow == 117) {
            this.setState({
                flag: 0
            });
        }
        else {
            this.setState({
                flag: flagNow + 9
            });
        }
    }
    onSelectItem(index, item) {
        var likedList = this.props.likedList;
        var likedListKey = Object.keys(likedList);
        if ( likedListKey.indexOf(index.toString()) >= 0 ) {
            return;
        }
        this.props.onAddLikeItem(index, item);
    }
    render() {
        let selectListArray = [];
        for (var i in this.props.selectList) {
            selectListArray.push(this.props.selectList[i])
        }
        let likedList = this.props.likedList;
        let likedListKey = Object.keys(likedList);
        return (
            <div>
                <h2 className="clr">
                    <span onClick={event=>{this.onChangeGroup(event)}}>換一換</span>
                選擇分類</h2>
                <ul className="feed-list clr">
                    {
                        selectListArray.slice(this.state.flag, this.state.flag+9).map((item, index)=>{
                            return (
                                <li onClick={event=>{this.onSelectItem((index + this.state.flag), item)}} 
                                    key={index + this.state.flag}>
                                    {(likedListKey.indexOf((index + this.state.flag).toString()) >= 0 ?
                                        <span className='disable'>{item}</span>
                                        :
                                        <span>{item}</span>
                                    )}
                                </li>
                            )
                        })
                    }
                </ul>
            </div>
        )
    }
}複製程式碼

我們首先把後端打過來的127條可供選擇的資料轉換為selectListArray陣列。這127個分類內容都對應一個index(1-127)。
在render()時候,因為頁面一次只展示9項待選項,所以我們把selectListArray用slice方法按順序切割出來9項輸出。
點選『換一換』按鈕時,觸發onChangeGroup()方法,這個方法是個元件內方法,他負責將slice引數+9,當到127(一共127項分類)時,還原回0。

我們知道,點選『換一換』觸發的onChangeGroup方法改變flag時,因為flag為該元件內部state,他的變化,將會引起該元件重新render(),所以資料池就會毫無壓力的切換了。

同時,我們給資料池裡每一項分類都繫結onSelectItem方法,該方法會向上傳遞給父元件,由父元件發出相應action。因為這個動作將會改變已選資料,從而影響平行的SelectedBlock元件。因此需要在reducer中處理。

action設計

有了以上元件的設計,很明顯我們需要定義兩個action:
1)第一個是新增某一條目到已選分類

export const ADD_LIKE_ITEM = 'ADD_LIKE_ITEM';複製程式碼

對應action creator:

export function addLikeItem (index, item) {
    return {
        type: ADD_LIKE_ITEM,
        obj: {
            index: index,
            item: item
        }
    }
}複製程式碼

返回action物件,包括type命名為ADD_LIKE_ITEM和負載資料:條目名item及其index。

2)另一個是在已選分類刪除某一條目:

export const DELETE_LIKE_ITEM = 'DELETE_LIKE_ITEM';複製程式碼

對應action creator:

export function deleteLikeItem (index) {
    return {
        type: DELETE_LIKE_ITEM,
        index
    }
}複製程式碼

返回action物件,包括type和負載資料。
到此為止,action指令碼只需要定義action,不需要進一步處理,對所有action的處理都會由reducer接受。

reducer設計

再次強調reducer是一個純函式,他接受兩個引數,一個是state,一個是action;並對相應的action,返回一個新的state,從而促使頁面裡訂閱相關state的元件再次render();
我們把同步模板資料initialLikeBlockState設為初始state:

var initialLikeBlockState = F.context('likedList');
function likeBlockReducer (state = initialLikeBlockState, action) {
    switch (action.type) {
        case actionType.ADD_LIKE_ITEM: {
            var addIndex = action.obj.index;
            var newLikedList = Object.assign({}, state, {
                [addIndex]: action.obj.item
            })
            return newLikedList;
        }
        case actionType.DELETE_LIKE_ITEM: {
            var newLikedList =  {};
            for (var key in state) {  
                var val = state[key];  
                newLikedList[key] = val;  
            }  
            var index = action.index;
            delete newLikedList[index];
            return newLikedList;
        }
        default: {
            return state;
        }
    }
}複製程式碼

當匹配ADD_LIKE_ITEM action時,我們把當前的state和action帶來的資料(item,index)進行merge,從而return 一個新的已選資料狀態,即新增了新分類item的state;
當匹配DELETE_LIKE_ITEM action時,我們把action負載帶來要刪除item的index刪除掉。返回刪除該條目之後的新state。

總結

截至目前,我們介紹了基本設計和開發思路。教程裡面已經基本包含了全部程式碼。

對比線上已有程式碼

1)和線上的zepto實現對比完全是兩種思路,經過比較,用react設計的程式碼程式碼量上有明顯的優勢。
2)開發思路上,是個蘿蔔青菜各有所愛的問題。但是對於寫慣了$()的我來說,這種全新的開發方式還是帶來了很大的驚喜。
3)線上實現這一套邏輯,可能對於一個簡單的UI互動,我們都需要選取很多dom元素,進行處理。整體上看,比較複雜且凌亂,不是很容易進行維護。

接下來...

當然,這只是第一步。後邊還有更多的路要走。比如:
1)我們在選擇或刪除一個條目時,如何給後端發非同步請求並沒有涉及。因此,redux非同步流程並沒有展現。後續章節會進一步講解。
2)我們的資料都是後端模板通過同步的方式傳遞過來的,資料量也不大,結構也不復雜,因此這一章為了簡單並未使用immutable.js。當然,後續章節會進一步講解。
3)這裡我並沒有介紹使用redux dev tool,這真的是一個很漂亮的利器。
尤其在資料複雜時候,對於除錯能幫上很大作用。後面我會單獨介紹一下關於這個工具的使用。
4)最後,這麼簡單的互動還並不會涉及頁面效能的問題。在後續章節,我會構造出極端CASE進行一些邊緣測試,並使用一些方法結合chrome dev tool進行效能優化,請進一步關注。

相關文章