【React/Redux/Router/Immutable】React最佳實踐的正確食用姿勢

在我的Blog上觀看:

【React/Redux/Router/Immutable】React最佳實踐的正確食用姿勢 - dtysky|一個行者的軌跡

現代前端框架基本都是對傳統系統應用框架的搬運,React雖定位為一個View層的框架,實際上卻包含了MVVM中的每一環,每一個組件都可以看做是擁有所有環節的結合體。其激進的設計不但體現在JSX這個融合了HTML+JS+CSS的語法糖,也體現在了對MVVM的雜糅,然而和直覺不同,這並沒有使得三者混亂分離,或者說M V VM這三者的聚合併不會帶來什麼問題,反而有一些益處。真正的問題在於組件嵌套帶來的組件通信和VDOM使用不當帶來的性能問題,而Redux和Immutable就是來解決這個問題的。此外,React-router的出現使得前端路由成為可能,這幾者結合起來大幅加強了一個SPA的開發效率和可維護性。

接下來將以我的博客為例,論述一下這個最佳實踐。

React

React的出現首先打破的是HTML、JS、CSS分離的格局,許多遺老遺少大呼又回到了JSP時代,宣稱這是在逆歷史而行在開倒車,而新晉分子則都不以為然,認為這才是趨勢。當然歷史已經告訴我們二元論是站不住腳的,所以優劣必然並存,一切大都是博弈之下的選擇。

Preview

傳統前端開發主要是堆砌HTML結構,加之調優的樣式並賦予小部分JS代碼用於交互,邏輯大頭基本都在後端。然而隨著WEB業務的日趨複雜,前端邏輯的複雜度已不可同日而語,SPA的出現、以及後續一堆基於WEBKIT瀏覽器的加殼應用使得傳統方式難以抗住需求,新的設計方法必須被提出。然而傳統系統應用領域實際上早就對這些東西研究的十分深入,所以直接搬來即可。React搬來的是經典的MVC架構幾次演化後迭代而成的MVVM,即「模型-視圖-視圖模型」模型,這種模型的一個經典實現就是MS的WPF,React的許多理念與其也十分相似,比如單向數據流,方法綁定等等,雖沒有WPF這種經典完善,但也做的很好。其基本理念是:

  1. 組件化,每一個組件都擁有自己獨立的namespace。
  2. 內部維護一個狀態模型,稱為state。
  3. 組件可以嵌套,並通過props變數完成和父級的通信,可以向此變數掛載方法來使得父級響應子級的請求。
  4. 內部狀態可以影響視圖層的渲染結果,視圖層可以綁定事件控制狀態,從而影響狀態。
  5. 完備的生命周期,可以預測並控制整個組件的狀態流向。

如此,便實現了一個健壯的、組件化的視圖框架。通過這種設計,加之JSX的混寫,我們可以對一個組件的所有狀態進行非常細粒度的控制,也可以將整個組件封裝好粗粒度調用,伸縮性非常好。不僅如此,內聯樣式的優先度可以使得整個頁面渲染更快,通過一些lib也可以實現偽類樣式,唯一的缺點大概就是和設計人員的分工和IDE的智能補全了(笑)。

要注意的是,對於數據的流向,React是強制單向數據流的,也就是說,數據只能由狀態單向得傳遞到視圖層,而不能直接由視圖層傳回來,視圖層對狀態的影響只能通過綁定事件來實現。這可以預防雙向綁定帶來的狀態預測困難問題,當然,雙向綁定也有其好處,但個人認為其好處是遠小於負面影響的。

對React基本原理和應用具體如何編寫,此文章不做多餘探討,閱讀者應當有一些基本的功底。下面主要討論標題所論述的「最佳實踐」。

有一定React使用經驗的的程序員,應當都或多或少遇到過工程龐大後的性能、以及當組件樹嵌套過深時組件間的通信問題。前者主要由VDOM處理不當導致的反覆render造成,而後者,則是React自身的設計特性造成的。

DOM相關優化

VDOM是React的核心概念之一,「虛擬DOM樹」,相對於真實的DOM樹,它由用戶創建後只存在於預處理階段,只有在滿足一定條件的狀況下才會被渲染為真實的DOM。比起操作真實的DOM,虛擬DOM多了一步differ的過程,所以其實是要比直接操作慢的,但考慮到其帶來的工程性的提升,這點性能損失根本不足為道——本因如此,但現實的很多使用中卻並非總是如此。除了differ之外,不適當的重繪都會使得性能的顯著下降,這大都是因為使用不當造成的。在組件的生命周期中,有一個階段是shouldComponentUpdate,這個方法的參數是nextProps和nextState,返回的是一個bool值,React會根據這個返回值來允許或阻止組件重繪。這是一個影響性能的核心方法,其基本使用如下:

shoudComponentUpdate(nextProps, nextState) { return nextPropppps !== this.props;}

當然,這一句只是偽代碼,在真實情況下這麼寫是沒用的(JS的機制)。通過設置合適的條件,我們可以防止絕大部分的無效重繪,這也是React應有的使用方法。這要求我們在設計之初就對組件的功能有著清晰的了解,但好在如果設計的尚可,組件應該是有一個合適的粒度的,這一部分也不會太複雜。

除了自己判斷之外,如果結合下面所言的Redux,還可以用其提供的輔助函數connect來決定子級組件需要件聽哪些父級狀態,這個下面說。

在此之外,我們還需要根據React的建議對組件設計好key,這是其進行VDOM differ的核心憑據,尤其在以數組的形式描寫一個序列的子級組件之時,這個key是必須提供的:

<Parent> [ <Child0 key=0/>, <Child1 key=1/>, ...... ]</Parent>

組件間狀態傳遞

組件間的狀態傳遞是原生React的一個老大難問題,第一節已經介紹過,原生的方法只能使得狀態在相鄰的父級和子級之間傳遞,如果嵌套超過三層,便很容易出現下面的情況:

表現為代碼即:

// child.jshandleTextChange(value) { handleTextChangeParent(value);}// parent.jshandleTextChangeParent(value) { handleTextChangeGrandparent(value);}

如果組件的層級再增長,這無疑是災難性的,但很遺憾在現代開發中這種狀況很常見。解決這種問題的方法之一是自己定義全局單例和事件管理器來共享變數,就像這篇文章寫得這樣,我們可以在組件初始化的時候註冊若干事件,並在合適的地方dispatch來實現組件間的跨級方法調用,而狀態,則可以用全局單例來共享:

// child1.jsimport globalState from "./globalState";import eventManager from "./globalState";export default class Child1 extends React.Component{ constructor(props){ super(props); this.state = {playing: false}; eventManager.register("ChangeMode", ::this.refresh); } refresh() { this.setState(globalState); }}// child2.jsexport default class Child2 extends React.Component refresh() { eventManager.dispatch("ChangeMode"); }}// globalState.jsexport default {playing: false}

這樣實際上也有一些問題,我們可以發現,如果將globalState的變換交給一個單獨的模塊,將其稱為controller,便又成了經典的MVC模式。這種模式最後C這一層一般會非常臃腫龐大,所以小應用也就罷了,大應用.......

但好在我們還有Redux,並且不難發現,Redux和以上這種策略其實有一些思想上的相似。

Redux

原理

在Redux之前,FB官方出過一個配套的庫叫做Flux,它從一定程度上解決了上述的問題,但無奈過於複雜,使得很多場景下難以使用。所幸不久後Redux應運而出,它脫胎於Flux,並借鑒了一些elm語言的思想,整體走函數式風格——單向數據流,無狀態編程。Redux的基本思想是這樣的:

  1. 維護一個頂層數據倉庫,稱為store。
  2. 全局只能存在一個store,所有的狀態都存在於其中。
  3. 有一些被稱為reducer的方法,例如reducer/theme.js,它是唯一可以准許被修改store中state的方法,每個reducer對應一個state,他接受上一次的state、一個action的type和可選的修改量,根據type來進行操作並返回下一級的state。
  4. 一些被稱為action的東西,例如action/index.js,至於說東西,因為這個東西比較抽象,只能說它定義了一些可預測的行為,他可以是type,也可以是一些帶有副作用的方法,一般和dispatch方法配合使用。
  5. dispatch方法是Redux的核心方法,它是觸發action的唯一途徑。

store、action、reducer和dispatch,加上一個getState,便構成了Redux。我們用store作為頂層實例,action定義行為,dispatch觸發行為,reducer改變狀態,getState獲取狀態,如下:

這便是Redux的基本原理,十分簡單。在默認狀態下,所有使得數據的變動方法都是pure的,沒有任何副作用,一切都是同步可預測的(所謂同步可預測,就是可以精確掌控在什麼時刻的狀態時什麼樣的),我們可以在初始化時賦予它任何狀態,以此作為整個應用的開端,這也是Redux下的服務端渲染的基礎。

和React綁定

雖然Redux是一個通用性的庫,但目前還是和React結合使用最多,在安裝了react-redux這個綁定之後,我們便可以利用它提供的Provider組件和connect方法將store和React的根級組件進行連接:

const store = createStore( reducers, initState, applyMiddleware(...middleware));const Root = <Provider store={store}> <APP /> </Provider>;// APP@connect( state => ({...state}))

這裡面,reducers的定義可見reducers.js,它利用combineReducers這個語法糖合併許多二級的reducer到根級reducer,並自動根據其生成二級的state樹,注意裡面的initState,這是一個可選的初始狀態,在服務端渲染中至關重要,而applyMiddleware這個方法則是用於應用中間件的,中間件在下一節會有詳解。Provider組件將APP這個應用根級組件包起來,作為新的根級組件。connect這個方法通過一個方法(這裡可以理解為filter)篩選要傳給下一級的state,由於是根級組件,所有狀態都要傳,所以將其解包即可。

這裡不難發現,如果每一次狀態改變都要從根組件向下傳遞,那麼默認狀況下,所有子級組件都會響應改變事件,可能會引起大量不必要的重繪或邏輯操作,這除了用上一章所言的shouldComponentUpdate來屏蔽之外,還可以注意connect這個方法提供的篩選,它實際上會幫我們做一次類似於內部的shouldComponentUpdate,合理利用它甚至可以幫我們完全解決子級組件重繪的問題。

如此一來,我們便可以將一個應用的文件目錄組織為這樣:src,components是React組件,actions裡面定義行為,reducers里定義改變狀態的方法。

執行action

在理想狀況下,數據的改變都是pure的,無副作用,整個過程中沒有IO操作也沒有非同步調用,這樣數據的流向非常清晰,不會混亂。我們只需要不斷使用以下語句來改變狀態即可:

dispatch({type: actionTypes.change.theme.current, theme: "home"});

接下來的事情交給Redux自己去解決就好,它會根據這個action的type去適配reducer並修改相應的state,並從根組件經過一路篩選將狀態傳遞到需要的組件中,有需求的子級組件監聽到了變化便可以執行相應的操作。

這種理想的狀況確實很美好,但是現實世界是複雜的,這種情況對於絕大多數SPA是不可能存在的——ajax是基本需求。所以我們便需要一些具有副作用的action來幫我們執行操作,這裡就需要提到Redux的中間件機制了,他的中間件充分利用了高階函數的特性,有興趣的可以在官網自行查看原理,這裡我們需要了解的僅僅是——中間件本質上就是類似於流水線的東西,一個action請求被發出後,Redux會將其輪流送入被註冊的中間件,經過這些中間件之後再最終進入reducer之內改變狀態。利用這個特點,我們便可以使用中間件來攔截action請求,實現副作用。

Redux官方提供了這樣的一個中間件——redux-thunk,其原理十分簡單,代碼只有寥寥幾行,不再贅述。通過它,我們便可以定義一種特殊的action,並在其中執行有副作用的操作:

function getListSource(url: string) { return dispatch => { dispatch({type: actionTypes.get.list.waiting}); return request.get(url) .timeout(1000) .then(res => { const list = res.body.content || []; dispatch({type: actionTypes.get.list.successful, url, list}); return Promise.resolve(res); }) .catch(err => { dispatch({type: actionTypes.get.list.failed, url}); return Promise.reject(err); }); };}// 執行dispatch(getListSource("/someting"));

如此,非同步操作也可以在Redux中實現了,我們在action中發起了一次ajax請求,並根據返回結果執行不同的pure-acition,來改變最終的狀態。這種操作在Blog項目中共有三個——action/source.js,讀者可以通過這些了解更多。

中間件的用處很多,除了這個之外,有一個很常用的中間件是redux-logger,它用於調試模式下的狀態路徑追蹤,將其應用後可以在瀏覽器控制台看到如下輸出:

這會使得調試變得方便清晰起來,也是Redux使得數據可預測的一個直觀體現。

reducer改變state

在接收到dispatch的請求之後,Redux會將此次的action分發到對應的reducer,它實現的分發方式本質上就是「輪詢」,正如前面所言,combinReducers只是一個語法糖,實際上在分發是還是會走一遍所有的reducer,reducer接收到參數後進入適當的分支,從而完成對狀態的修改。

這裡要特別注意,Redux的函數式特性決定了它的上一次狀態是不可變的,你不能去通過修改上一次狀態然後將其返回作為下一次的狀態,而是必須建立一個新的狀態來修改並返回它。這在一些情況下非常容易實現,但如果狀態層級比較深、本身比較複雜,JS自己的對象內部又是地址引用的,所以新建並維護狀態就可能變得格外複雜。雖然可以用lodash這樣的工具庫來簡化一些操作,但仍然沒有根本上解決問題,而這時候,FB官方的Immutable庫便出現了,它和Redux的相容性非常好。

Immutable.js

Immutable.js是FB出的一個不可變數據結構庫,它提供了一系列的數據結構和豐富的API,其數據結構有自己的內部實現,和JS原生對象的原理有所不同。作為使用者,我們需要關心的只是他提供的介面和特性。它有一個非常重要的特性就是不可變性,也就是說,Immutable對象自身是不可被修改的,一切對它的修改只會體現在修改它的方法的返回值上,它本身是不變的,這和Redux的理念不謀而合,加之其豐富得不亞於lodash的API,和Redux配套使用基本可以算是最佳組合。

在實際使用中,Immutable最常見的是下列API:

import Immutable from "immutable";// 將JS對象轉換為Immutable對象const im = Immutable.fromJS(obj)// 將Immutable對象轉換為JS對象const obj = im.toJS();// 將Immutable對象轉換為JSON對象const json = im.toJSON();// 判斷對象中是否有值const bool = im.has(name);// 判斷兩個對象是否相等const bool = im1.equals(im2);// 賦值/深度賦值,SetIn方法將依次查找到最後一層的key並執行賦值// 要注意這兩個方法並不會自動將obj轉換為immutable對象存儲!const newIm = im.set(key, value);const newIm = im.setIn([key1, key2, ......], value);// 取值/深度取值// 深度取值第二個參數是一個可選的默認值,這是一種optional的實現,如果找不到值,將會返回默認值const value = im.get(key);const value = im.getIn([key1, key2, ......], defaultValue);// 歸併,obj可以是Immutable對象也可以是JS對象const newIm = im.merge(obj);

在絕大多數應用中,這幾個API可以撐起半邊天,尤其是merge這個方法會被經常使用(我們最好保證可以用Immutable的地方都用它)。此外equals方法也可以在shouldComponentUpdate中配合使用,使得兩次props的對比變得非常簡單。

React+Redux+Immutable這個組合由此完成了,合理使用它們,可以使我們的開發變得條理明晰,維護方便(當前,前提是配好那一堆噁心的環境,這裡不再贅述,詳情可以看這個Blog項目的配置文件(還沒有單元測試相關的東西,如果加上單元測試還需要趟更多的坑,比如這個項目))。

React-router

至此,一個簡單的SPA的構造條件便都滿足,但這又會帶來一個問題——SEO相關的問題。在這種SPA的模式下,頁面是沒有自己的獨立URL的,這樣不利於偽靜態頁面的生成,不便於搜索引擎索引,也不便於用戶訪問,前端路由就是來解決這個問題的。其核心在於利用js代碼截獲瀏覽器history中的url,通過捕獲跳轉或回退事件來完成應用內部的跳轉行為,並同步瀏覽器的地址欄。

有了前端路由,我們便可以實現對SPA的分頁,基於此,我們可以指定各自的url給各個頁面,這使得頁面的靜態化成為可能。React-router則是FB官方給React提供的前端路由庫,使用它,無論是搜索引擎還是用戶都可以直接通過靜態的url來訪問單個頁面。同時值得一提的是,React-router也可以在服務端使用,這樣便可以實現WEB和服務端渲染的同構實現。在Blog項目中,這一部分在這個文件中src/routes,其一個經典的實現如下:

const routes = ( <Route path="/" component={App}> <IndexRoute components={{content: Home}} /> <Route path="archives(/:index)" components={{content: Archives}} /> <Route path="category/:name(/:index)" components={{content: Category}} /> <Route path="article/:name" components={{content: Article}} /> ...... <Route path="*" components={{content: NotFound}} /> </Route>);

Route是React-router的核心組件,他的第一個屬性path是相對於根域名的路由地址,第二個component或者components是路由對應的組件。在這個路由中,根路徑對應的組件是APP,所以網站會將APP作為整個應用的入口,我們必須實現APP這個組件,並讓它能夠在不同路由下接受不同的components,來渲染出對應的DOM樹。

在此Blog工程中,這個APP組件在src/app,它的render方法中定義了許多基礎的視圖組件,並根據當前路由傳來的params和由對應路由中components參數傳來的content來確認主視圖中應當顯示哪些組件,這個主視圖實際上就是你當前在看的這個文章主體的位置。

比如在本頁中,url為dtysky.moe/article/Skil,相對於根域名的地址是/article/Skill-2016_10_09_a,它會走到article/:name這個路由中,而這個路由中有個必選參數name(形如(/:index)這種參數就是非必須選的),當進入這個路由後,APP組件首先會接收到參數this.props.param={name: Skill-2016_10_09_a}和this.props.content=Article,接下來我們只需要使用cloneElement方法將content組件加上需要傳入的參數放入DOM樹中即可:

render() { const {params, content} = this.props; return ( <div> ...... {cloneElement(content, {params, ......})} ...... </div> )}

如此,當用戶訪問當前的這個url後,Article這個組件將會被裝入DOM樹,接受params和其他的一些參數並渲染出響應的頁面。

完成了這一切後,我們將Routes作為根級組件掛載到React-redux的Provider下便可以完成和Redux的鏈接:

const Root = ( <Provider store={store}> <Router routes={routes} history={browserHistory} /> </Provider>);

使用React-router要特別注意組件自身生命周期的問題,清晰了解路由跳轉時組件的行為非常重要,請務必通讀官方的這篇說明Component Lifecycle。

More

至此,React最佳實踐的第一部分就差不多了,之所以說第一部分,是因為上面這些並沒有真正解決SEO、即首屏渲染的問題,這會涉及到React/Redux/React-router協作的服務端渲染,下一篇文章:【React/Redux】深入理解React服務端渲染將會詳細說明如何去操作。

知識可以學習,技術只能練習,這裡只能說個脈絡,更多請參考我的BlogReworkPro工程。


推薦閱讀:

如何在非 React 項目中使用 Redux
揭秘 React 狀態管理
Redux store 的動態注入

TAG:React | Redux | 前端框架 |