為什麼 React 推崇 HOC 和組合的方式,而不是繼承的方式來擴展組件?
React 組件本身是 class 的寫法,為什麼不直接用繼承的方式給組件添加行為,而推薦使用 HOC 和組合的方式?總感覺是 OOP 和 FP 混著用,有點彆扭。
OOP和FP並不矛盾,所以混著用沒毛病,很多基於FP思想的庫也需要OOP來搭建。
為什麼React推崇HOC和組合的方式,我的理解是React希望組件是按照最小可用的思想來進行封裝的,理想的說,就是一個組件只做一件的事情,且把它做好,DRY。在OOP原則,這叫單一職責原則。如果要對組件增強,首先應該先思路這個增強的組件需要用到哪些功能,這些功能由哪些組件提供,然後把這些組件組合起來
D中A相關的功能交由D內部的A來負責,D中B相關的功能交由D內部的B來負責,D僅僅負責維護A,B,C的關係,另外也可以額外提供增加項,實現組件的增強。
繼承有什麼不好,注意,React只是推薦,但沒限制。其實用繼承來擴展組件也沒問題,而且也存在這樣的場景。比如:有一個按鈕組件,僅僅是對&
但是,用繼承的方式擴展前,要先思考,新組件是否與被繼承的組件是不是同一類型的,同一類職責的。如果是,可以繼承,如果不是,那麼就用組合。怎麼定義同一類呢,回到上面的Button的例子,所謂同一類,就是說,我直接用IconButton直接替換掉Button,不去改動其他代碼,頁面依然可以正常渲染,功能可以正常使用,就可以認為是同一類的,在OOP中,這叫做里氏替換原則。
繼承會帶來什麼問題,以我的實踐經驗,過渡使用繼承,雖然給編碼帶來便利,但容易導致代碼失控,組件膨脹,降低組件的復用性。比如:有一個列表組件,叫它ListView吧,可以上下滾動顯示一個item集,突然有一天需求變了,PM說,我要這個ListView能像iOS那樣有個回彈效果。好,用繼承對這個ListView進行擴展,加入了回彈效果,任務closed。第二天PM找上門來了,希望所有上下滾動的地方都可以支持回彈效果,這時候就懵逼啦,怎麼辦?把ListView中回彈效果的代碼copy一遍?這就和DRY原則相悖了不是,而且有可能受到其他地方代碼的影響,處理回彈效果略有不同,要是有一天PM希望對這個回彈效果做升級,那就有得改啦。應對這種場景,最好的辦法是啥?用組合,封裝一個帶回彈效果的Scroller,ListView看成是Scroller和item容器組件的組合,其他地方需要要用到滾動的,直接套一個Scroller,以後不管回彈效果怎麼變,我只要維護這個Scroller就好了。當然,最理想的,把回彈效果也做成一個組件SpringBackEffect,從Scroller分離出來,這樣,需要用回彈效果的地方就加上SpringBackEffect組件就好了,這就是為什麼組合優先於繼承的原因。
頁面簡單的時候,組合也好,繼承也罷,可維護就好,能夠快速的響應需求迭代就好,用什麼方式實現到無所謂。但如果是一個大項目,頁面用到很多組件,或者是團隊多人共同維護的話,就要考慮協作中可能存在的矛盾,然後通過一定約束來閉坑。組合的方式是可以保證組件具有充分的復用性,靈活度,遵守DRY原則的其中一種實踐。
以上是我個人觀點,如有不足和錯誤的地方,歡迎斧正。
有一種HOC的實現方式就是返回一個傳入組件類的子類,當然這樣也不提倡,因為這把兩個類糾結在一起了,即使是OO的原則,也是favor composition over inheritance。
先看一下在 React 中我們已經怎樣地使用了組件繼承(後面用 Extending 替代)和 HOC :
- 常見的 Extending 有
class Comp extends React.Component/PureComponent
- 常見的 HOC 有 Redux 的
connect()(Comp)
、ReactRouter 的withRouter(Comp)
,收發本地 localstoreage、非同步遠端數據的 Proxier、第三方的各種Table/Form/Matrix.create(Comp)
,他們的輸出都是一個 ComponentClass,後者要求要有個既成的 ComponentClass 作為輸入。
可以發現 Extending 主要用於定義介面,由 React 負責工作流的執行,它遵循一份具有 React lifecycle 的「契約」,Extending 則是用於取得這個「契約」的模板,開發者往必要或需要的生命周期內注入代碼實現,實現的多餘的方法僅供業務自身使用;同時 React.Component 還提供了一些供你使用而無須實現的方法或者屬性,比如 this.setState
、 this.forceUpdate
、this.context
、this.refs
。但 HOC 卻不止起到「契約」的作用,它包含的是許多第三方總結出的最佳實踐,比如 connect
幫我們訂閱 Redux store,讀取 context,混合 dispatch 與 state,Form.create
傳遞給我們表單變化的勾子。如果只使用 Extending 的方式會怎樣?...似乎...會?挺不好用的。
HOC 們目前接手了大多數的 dirty things,把可復用、抽象的邏輯用一些設計模式的形態歸納出來了,有的混入的自己的 state,有的使用了 context,有的實現了一些生命周期方法。通常 HOC 會產生一個新的組件作為 state, context, refs, 生命周期的容器作為小型的「框架」,隔離業務組件 。
用 Extending 確實也可以做,但它帶來了我們極不情願的低層邏輯侵入,你的業務要和抽象完的邏輯耦合。比如:
# 在 constructor 初始化 state 時:
constructor (props) {
super(props) this.state = { ...this.state, data: 222 } }# 在優化組件性能時:
shouldComponentUpdate (nextProps, nextState) { return super.shouldComponentUpdate(nextProps, nextState) ...}
可以看出,不僅僅是書寫的麻煩,我們在 extends 時還必須知道 BaseComponent 的實現了哪些方法(如果不使用 TypeScript),在初始化 state、聲明其它方法時要小心避免污染父類的方法(父類的 state 中也許也有一個 property 叫 "data" ),有時還不得操作父類暴露給開發者的 context,帶來了相當的風險。
而 HOC 可能帶來的負收益只是 Component 的層級變多(使用反轉繼承的 HOC 還不會增加層級),在 React devtools 中可以感受感受下。但卻有明顯的收益:
- 沒有侵入性,充分復用 React 組件的基於 props 與 state 更新的機制,HOC 內含的 state,context 以 props 的形式注入輸入組件,開發者只要關心自己的 state 與 擴展過的 props,注入的 props 用 Redux Store Subscription 也好,EventEmmiter 也好,有雙向綁定的 Observerable 也好,都不用關心;
- HOC 注入的 props 可以使用命名空間保存傳遞給 HOC,這裡要 diss 一下 ReactRouter 要佔掉 location, params, route, router, routes, routeParams 這麼多保字,完全可以用一個 RR 之類的對象包裹一下;
- HOC 可以輕鬆創建帶有插槽 (slot) 的組件,用
this.props.children
)填充,換成 Extending 方式只能分解super.render()
再改變填充; - HOCs 可以組合(Compose)起來,順序由你的需求決定;
- HOC 本身是裝飾器,它比 Extending 只會更強,HOC 內部可以運用眾多的設計模式,用怎麼做都可以,也就是說你仍然可以在 HOC 內部去做 Extending(比如 Radium 庫),抽象/抺平/統一介面、優化性能(shouldComponentUpdate)、拼接組件、代理屬性、創建單例(portal、modal, toast,notifer);
- 可以只寫 SFC (Stateless Function Component) ,性能優化、可歸納的邏輯交給 HOC;
- 業務里不用面對討厭的 ref 了,你可以用 HOC 反轉控制把子組件的方法注入父組件,就像 react-component/form 乾的那樣;
- Functional Style...
PS:
關於第四點: 如果用 Extending,當你有 withRouter(connect(...)(Comp))
或者 conncent(...)(withRouter(Comp))
的需求時,JS 像許多編程語言一樣不允許你用多重繼承(考慮到菱形繼承),多層繼承又書寫則形如地獄。基於框架運行時的一層繼承是好的(這是大多數前後端框架的實踐),多層則不被建議,絕大多數時候,組合優於繼承: Composition over Inheritance - YouTube 。
主要是組件的state是私有的,不然早用了
比如說我就很喜歡用純函數組件……(泣
推薦閱讀:
TAG:前端開發 | JavaScript | React |