React高階組件實踐
前言
React高階組件,即 Higher-Order Component,其官方解釋是:
A higher-order component is a function that takes a component and returns a new component.
一個傳入一個組件,返回另一個組件的函數,其概念與高階函數的將函數作為參數傳入類似。
用代碼來解釋就是:
const EnhancedComponent = higherOrderComponent(WrappedComponent);
以上通過 higherOrderComponent 函數返回的 EnhancedComponent 就是一個高階組件。所以簡單來說,高階只是一種設計模式(pattern),並非一種新的組件類型。
為何使用
關於高階組件解決的問題可以簡單概括成以下幾個方面:
- 代碼復用:這是高階組件最基本的功能。組件是React中最小單元,兩個相似度很高的組件通過將組件重複部分抽取出來,再通過高階組件擴展,增刪改props,可達到組件可復用的目的;
- 條件渲染:控制組件的渲染邏輯,常見case:鑒權;
- 生命周期捕獲/劫持:藉助父組件子組件生命周期規則捕獲子組件的生命周期,常見case:打點。
如何使用
遵循的原則
1、不要修改原始組件
常見做法是通過修改原組件的prototype來重寫其生命周期方法等(如給WrappedComponent.prototype.componentWillReceiveProps重新賦值)。請使用純函數返回新的組件,因為一旦修改原組件,就失去了組件復用的意義。
2、props保持一致
高階組件在為子組件添加特性的同時,要保持子組件的原有的props不受影響。傳入的組件和返回的組件在props上盡量保持一致。
3、保持可組合性
4、displayName
為了方便調試,最常見的高階組件命名方式是將子組件名字包裹起來。
5、不要在render方法內部使用高階組件
render中的高階組件會在每次render時重新mount,之前組件內部的state也會丟失。
使用方法對比
高階組件使用有幾種不同的方式,在介紹這幾種方式之前,我們可以幾個方面來分析他們之間的差異。一個React組件有以下幾個重要組成部分:
- props
- state
- ref
- 生命周期方法
- static方法
- React 元素樹
補充一下:為了訪問DOM elements(focus事件、動畫、使用第三方dom操作庫)時我們會用到ref屬性。它可以聲明在DOM Element和Class Component上,無法聲明在Functional Components上。一開始ref聲明為字元串的方式基本不推薦使用,在未來的react版本中可能不會再支持,目前官方推薦的用法是ref屬性接收一個回調函數。這個函數執行的時機為:
- 組件被掛載後,回調函數被立即執行,回調函數的參數為該組件的具體實例。
- 組件被卸載或者原有的ref屬性本身發生變化時,回調也會被立即執行,此時回調函數參數為null,以確保內存泄露。
所以不同方式的對比可以從以下幾個方面進行(原組件即傳入組件):
- 原組件所在位置:如能否被包裹或包裹其他組件;
- 能否讀取到或操作原組件的props
- 能否讀取、操作(編輯、刪除)原組件的state
- 能否通過ref訪問到原組件中的dom元素
- 是否影響原組件某些生命周期等方法
- 是否取到原組件static方法
- 能否劫持原組件生命周期方法
- 能否渲染劫持
使用方法介紹
下面我們來介紹下高階組件的使用方法,在介紹之前,我們假設有一個簡單的組件Student,有name和age兩個通過props傳入後初始化的state,一個年齡輸入框,一個點擊後focus輸入框的按鈕和一個sayHello的static方法。
class Student extends React.Component { static sayHello() { console.log(hello from Student); // eslint-disable-line } constructor(props) { super(props); console.log(Student constructor); // eslint-disable-line this.focus = this.focus.bind(this); } componentWillMount() { console.log(Student componentWillMount); // eslint-disable-line this.setState({ name: this.props.name, age: this.props.age, }); } componentDidMount() { console.log(Student componentDidMount); // eslint-disable-line } componentWillReceiveProps(nextProps) { console.log(Student componentWillReceiveProps); // eslint-disable-line console.log(nextProps); // eslint-disable-line } focus() { this.inputElement.focus(); } render() { return (<div stylex={outerStyle}> <p>姓名:{this.state.name}</p> <p> 年齡: <input stylex={inputStyle} value={this.state.age} ref={(input) => { this.inputElement = input; }} /> </p> <p> <input stylex={buttonStyle} type="button" value="focus input" onClick={this.focus} /> </p> </div>); }}
總的來說,高階組件中返回新組件的方式有以下3種:
1、直接返回一個stateless component,如:
function EnhanceWrapper(WrappedComponent) { const newProps = { source: app, }; return props => <WrappedComponent {...props} {...newProps} />;}
stateless component沒有自己的內部state及生命周期,所以這種方式常用於對組件的props進行簡單統一的邏輯處理。
- √ 原組件所在位置(能否被包裹或包裹其他組件)
- √ 能否取到或操作原組件的props
- 乄 能否取到或操作state
- 乄 能否通過ref訪問到原組件中的dom元素
- X 是否影響原組件生命周期等方法:props無法更改,所以也不會影響到componentWillReceiveProps方法。
- √ 是否取到原組件static方法
- X 能否劫持原組件生命周期:同5。
- 乄 能否渲染劫持
一些說明:
3:可以通過props 和回調函數對state進行操作。
4:因為stateless component並無實例,所以不要說ref,this都無法訪問。但是可以通過子組件的ref回調函數來訪問子組件的ref。
8:可以通過props來控制是否渲染及傳入數據,但對WrappedComponent內部render的控制並不是很強。
關於ref的訪問,以上面的子組件Student為例,父組件:
import Student from ../components/common/Student;function EnhanceWrapper(WrappedComponent) { let inputElement = null; function handleClick() { inputElement.focus(); } function wrappedComponentStaic() { WrappedComponent.sayHello(); } return props => (<div> <WrappedComponent inputRef={(el) => { inputElement = el; }} {...props} /> <input type="button" value="focus子組件input" onClick={handleClick} /> <input type="button" value="調用子組件static" onClick={wrappedComponentStaic} /> </div>);}const WrapperComponent = EnhanceWrapper(ShopList);
子組件中需要調用父組件傳入的ref回調函數:
<input ref={(input) => { this.inputElement = input; }}/>
<input ref={(input) => { this.inputElement = input; this.props.inputRef(input); }}/>
這樣父組件可以訪問到子組件中的input元素。
以下是ref調用和static方法調用的示例。
2、在新組件的render函數中返回一個新的class component,如:
function EnhanceWrapper(WrappedComponent) { return class WrappedComponent extends React.Component { render() { return <WrappedComponent {...this.props} />; } }}
- √ 原組件所在位置(能否被包裹或包裹其他組件)
- √ 能否取到或操作原組件的props
- 乄 能否取到或操作state
- 乄 能否通過ref訪問到原組件中的dom元素
- √ 是否影響原組件生命周期等方法(如:componentWillReceiveProps)
- √ 是否取到原組件static方法
- X 能否劫持原組件生命周期
- 乄 能否渲染劫持
一些說明:
3:可以通過props 和回調函數對state進行操作。
4:ref雖然無法直接通過this來直接訪問,但依舊可以利用上面所用的回調函數方式訪問。
7:高階組件和原組件的生命周期完全是React父子組件的生命周期關係。
8:和第一種類似,可以通過props來控制是否渲染及傳入數據,但對WrappedComponent內部render的控制並不是很強。
function EnhanceWrapper(WrappedComponent) { return class WrapperComponent extends React.Component { static wrappedComponentStaic() { WrappedComponent.sayHello(); } constructor(props) { super(props); console.log(WrapperComponent constructor); // eslint-disable-line this.handleClick = this.handleClick.bind(this); } componentWillMount() { console.log(WrapperComponent componentWillMount); // eslint-disable-line } componentDidMount() { console.log(WrapperComponent componentDidMount); // eslint-disable-line } handleClick() { this.inputElement.focus(); } render() { return (<div> <WrappedComponent inputRef={(el) => { this.inputElement = el; }} {...this.props} /> <input type="button" value="focus子組件input" onClick={this.handleClick} /> <input type="button" value="調用子組件static" onClick={this.constructor.wrappedComponentStaic} /> </div>); } };}
3、繼承(extends)原組件後返回一個新的class component,如:
function EnhanceWrapper(WrappedComponent) { return class WrappedComponent extends WrappedComponent { render() { return super.render(); } }}
此種方式最大特點是下允許 HOC 通過 this 訪問到 WrappedComponent,所以可以讀取和操作state/ref/生命周期方法。
- √ 原組件所在位置(能否被包裹或包裹其他組件)
- √ 能否取到或操作原組件的props
- √ 能否取到或操作state
- √ 能否通過ref訪問到原組件中的dom元素
- √ 是否影響原組件生命周期等方法
- √ 是否取到原組件static方法
- √ 能否劫持原組件生命周期
- √ 能否渲染劫持
function EnhanceWrapper(WrappedComponent) { return class WrapperComponent extends WrappedComponent { constructor(props) { super(props); console.log(WrapperComponent constructor); // eslint-disable-line this.handleClick = this.handleClick.bind(this); } componentDidMount(...argus) { console.log(WrapperComponent componentDidMount); // eslint-disable-line if (didMount) { didMount.apply(this, argus); } } handleClick() { this.inputElement.focus(); } render() { return (<div> {super.render()} <p>姓名:{this.state.name}</p> <input type="button" value="focus子組件input" onClick={this.handleClick} /> <input type="button" value="調用子組件static" onClick={WrapperComponent.sayHello} /> </div>); } };}
5:由於class繼承時會先生成父類的示例,所以 Student 的 constructor 會先於WrapperComponent 執行。其次,繼承會覆蓋父類的實例方法,所以在 WrapperComponent定義 componentDidMount 後Student的 componentDidMount 會被覆蓋不會執行。沒有被覆蓋的componentWillMount會被執行。
7:雖然生命周期重寫會被覆蓋,但可以通過其他方式來劫持生命周期。
function EnhanceWrapper(WrappedComponent) { const willMount = WrappedComponent.prototype.componentWillMount; const didMount = WrappedComponent.prototype.componentDidMount; return class WrapperComponent extends WrappedComponent { constructor(props) { super(props); console.log(WrapperComponent constructor); // eslint-disable-line this.handleClick = this.handleClick.bind(this); } componentWillMount(...argus) { console.log(WrapperComponent componentWillMount); // eslint-disable-line if (willMount) { willMount.apply(this, argus); } } componentDidMount(...argus) { console.log(WrapperComponent componentDidMount); // eslint-disable-line if (didMount) { didMount.apply(this, argus); } } handleClick() { this.inputElement.focus(); } render() { return (<div> {super.render()} <p>姓名:{this.state.name}</p> <input type="button" value="focus子組件input" onClick={this.handleClick} /> <input type="button" value="調用子組件static" onClick={WrapperComponent.sayHello} /> </div>); } };}
8:此種方法因為可以取到 WrappedComponent 實例的render結果,所以還可以通過React.cloneElement等方法修改由 render 方法輸出的 React 組件樹。
場景舉例
場景1:頁面復用
描述:項目中有兩個UI交互完全相同的頁面,但由於服務於不同的業務,數據來源及部分文案有所不同。目前數據獲取統一在lib層進行封裝,如 utils.getShopListA 和 utils.getShopListB。
思路:將獲取數據的函數作為參數傳入,返回高階組件。
components/ShopList.jsx
import React from react;class ShopList extends React.Component { componentWillMount() { } render() { // 使用this.props.data渲染 }}export default ShopList;
common/shopListWithFetching.jsx
import ShopList from ../components/ShopList.jsx;function shopListWithFetching(fetchData, defaultProps) { return class extends React.Component { constructor(props) { super(props); this.state = { data: [], }; } componentWillMount() { fetchData().then((list) => { this.setState({ data: list, }); }, (error) => { console.log(error); // eslint-disable-line }); } render() { return <ShopList data={this.state.data} {...defaultProps} {...this.props} />; } };}export default shopListWithFetching;
page/SholistA.jsx
import React from react;import ReactDOM from react-dom;import getShopListA from ../lib/utils;import shopListWithFetching from ../common/shopListWithFetching.jsx;const defaultProps = { emptyMsg: 暫無門店數據,};const SholistA = shopListWithFetching(getShopListA, defaultProps);ReactDOM.render(<SholistA />, document.getElementById(app));
page/SholistB.jsx
import React from react;import ReactDOM from react-dom;import getShopListB from ../lib/utils;import shopListWithFetching from ../components/ShopList.jsx;const defaultProps = { emptyMsg: 暫無合作的門店,};const SholistB = shopListWithFetching(getShopListB, defaultProps);ReactDOM.render(<SholistB />, document.getElementById(app));
場景2:頁面鑒權
描述:最近有一個新業務要上線,包含有一系列相關頁面。現在需要對其中幾個頁面增加白名單功能,如果不在白名單中的用戶訪問這些頁面只進行文案提示,不展示業務數據。一周後去掉白名單,對全部用戶開放。
以上場景中有幾個條件:
- 幾個頁面:鑒權代碼不能重複寫在頁面組件中;
- 只進行文案提示:鑒權過程在頁面部分生命周期(業務數據請求)之前;
- 一周後去掉白名單:鑒權應該完全與業務解耦,增加或去除鑒權應該最小化影響原有邏輯。
思路:將鑒權流程封裝,通過高階組件像一件衣服穿在在業務組件外面。
假設原有頁面(以page1和page2為例)代碼如下:
pages/Page1.jsx
import React from react;class Page1 extends React.Component { componentWillMount() { // 獲取業務數據 } render() { // 頁面渲染 }}export default Page1
pages/Page2.jsx
import React from react;class Page2 extends React.Component { componentWillMount() { // 獲取業務數據 } render() { // 頁面渲染 }}export default Page2
思路:通過高階組件將頁面頂層組件封裝,頁面載入時請求後端鑒權介面,在render方法中增加渲染邏輯,鑒權失敗展示文案,成功渲染原頁面組件,請求業務數據。
高階組件(components/AuthWrapper.jsx),鑒權方法名為whiteListAuth(lib/utils.js)。
import React from react;import { whiteListAuth } from ../lib/utils;/** * 白名單許可權校驗 * @param WrappedComponent * @returns {AuthWrappedComponent} * @constructor */function AuthWrapper(WrappedComponent) { class AuthWrappedComponent extends React.Component { constructor(props) { super(props); this.state = { permissionDenied: -1, }; } componentWillMount() { whiteListAuth().then(() => { // success this.setState({ permissionDenied: 0, }); }, (error) => { this.setState({ permissionDenied: 1, }); console.log(error); }); } render() { if (this.state.permissionDenied === -1) { return null; } if (this.state.permissionDenied) { return <div>功能即將上線,敬請期待~</div>; } return <WrappedComponent {...this.props} />; } } return AuthWrappedComponent;}export default AuthWrapper;
增加鑒權後的頁面
pages/Page1.jsx
import React from react;import AuthWrapper from ../components/AuthWrapper;class Page1 extends React.Component { componentWillMount() { // 獲取業務數據 } render() { // 頁面渲染 }}// export default Page1export default AuthWrapper(Page1);
pages/Page2.jsx
import React from react;import AuthWrapper from ../components/AuthWrapper;class Page2 extends React.Component { componentWillMount() { // 獲取業務數據 } render() { // 頁面渲染 }}// export default Page2export default AuthWrapper(Page2);
這樣鑒權與業務完全解耦,也避免鑒權失敗情況下多餘的數據請求,只需要增加/刪除一行代碼,改動一行代碼,即可增加/去除白名單的控制。
場景3:日誌及性能打點
描述:所有使用React的前端項目頁面需要增加PV,UV,性能打點。每個項目的不同頁面頂層組件生命周期中分別增加打點代碼無疑會產生大量重複代碼。
思路:通過extends方法返回高階組件,劫持原頁面組件的生命周期。具體可期待其他小夥伴後續的文章。
高階組件常見問題
Ref
如上面的第一、二種高階組件方法中所示,常規的通過this是無法獲取你想要的ref,但可以通過ref的回調函數獲取。
Static方法丟失
如上面的第一、二種高階組件方法中所示,高階組件對子組件包裝之後會返回一個容器組件,這意味著新組件不包含任何子組件中包含的靜態方法。為了解決這個問題,應該將靜態方法拷貝到容器組件之後,再將其返回。可以使用 hoist-non-react-statics 來自動的拷貝所有非React的靜態方法。當然另一個解決方案是將組件自身和靜態方法分別導出。
componentWillReceiveProps
如上面的第一、二種高階組件方法中所示,props層層傳遞必然會引起一些維護上的困難。
常用高階組件庫
React-Redux - connect
使用過React-Redux的同學都知道,組件中訪問全局state數據,我們需要調用connect函數,如官方示例中:
const VisibleTodoList = connect( mapStateToProps, mapDispatchToProps)(TodoList)
其中 TodoList 是一個React組件。以下是connect函數源代碼:
return function connect( mapStateToProps, mapDispatchToProps, mergeProps, { pure = true, areStatesEqual = strictEqual, areOwnPropsEqual = shallowEqual, areStatePropsEqual = shallowEqual, areMergedPropsEqual = shallowEqual, ...extraOptions } = {}) { return connectHOC(selectorFactory, {...})}
上面的connectHOC的默認值就是下面的 connectAdvanced
export default function connectAdvanced() { return function wrapWithConnect(WrappedComponent) { class Connect extends Component { render() { // 返回 return createElement(WrappedComponent, this.addExtraProps(selector.props)) } } } // Similar to Object.assign return hoistStatics(Connect, WrappedComponent)}
可以看出,connect函數傳入mapStateToProps等參數,執行結果是返回另一個函數。給這個函數傳入原始組件(WrappedComponent),會返回另一個新的組件(Connect),props也傳入了這個組件。
recompose
Recompose is a React utility belt for function components and higher-order components.
以 withHandlers 為例:
/* eslint-disable no-console */import { Component } from reactimport createEagerFactory from ./createEagerFactoryimport setDisplayName from ./setDisplayNameimport wrapDisplayName from ./wrapDisplayNameimport mapValues from ./utils/mapValuesconst withHandlers = handlers => BaseComponent => { const factory = createEagerFactory(BaseComponent) class WithHandlers extends Component { cachedHandlers = {} handlers = mapValues( typeof handlers === function ? handlers(this.props) : handlers, (createHandler, handlerName) => (...args) => { const cachedHandler = this.cachedHandlers[handlerName] if (cachedHandler) { return cachedHandler(...args) } const handler = createHandler(this.props) this.cachedHandlers[handlerName] = handler if ( process.env.NODE_ENV !== production && typeof handler !== function ) { console.error( // eslint-disable-line no-console withHandlers(): Expected a map of higher-order functions. + Refer to the docs for more info. ) } return handler(...args) } ) componentWillReceiveProps() { this.cachedHandlers = {} } render() { return factory({ ...this.props, ...this.handlers, }) } } return WithHandlers}export default withHandlers
Relay - RelayContainer
function createContainerComponent( Component: React.ComponentType<any>, spec: RelayContainerSpec,): RelayContainerClass { const ComponentClass = getReactComponent(Component); class RelayContainer extends React.Component<$FlowFixMeProps, { queryData: {[propName: string]: mixed}, rawVariables: Variables, relayProp: RelayProp, }, > { render(): React.Node { if (ComponentClass) { return ( <ComponentClass {...this.props} {...this.state.queryData} ref={component} // eslint-disable-line react/no-string-refs relay={this.state.relayProp} /> ); } else { // Stateless functional. const Fn = (Component: any); return React.createElement(Fn, { ...this.props, ...this.state.queryData, relay: this.state.relayProp, }); } } } return RelayContainer;}
Function as Child Components
在React社區中,還有另一種類似高階組件的方式叫做Function as Child Components。它的思路是將函數(執行結果是返回新的組件)作為子組件傳入,在父組件的render方法中執行此函數,可以傳入特定的參數作為子組件的props。
已上面的Student組件為例:
class StudentWithAge extends React.Component { componentWillMount() { this.setState({ name: 小紅, age: 25, }); } render() { return ( <div> {this.props.children(this.state.name, this.state.age)} </div> ); }}
使用的時候可以這樣:
<StudentWithAge> { (name, age) => { let studentName = name; if (age > 22) { studentName = `大學畢業的${studentName}`; } return <Student name={studentName} />; } }</StudentWithAge>
比起高階組件,這種方式有一些優勢:
1、代碼結構上少掉了一層(返回高階組件的)函數封裝。
2、調試時組件結構更加清晰;
3、從組件復用角度來看,父組件和子組件之間通過children連接,兩個組件其實又完全可以單獨使用,內部耦合較小。當然單獨使用意義並不大,而且高階組件也可以通過組合兩個組件來做到。
同時也有一些劣勢:
1、(返回子組件)函數佔用了父組件原本的props.children;
2、(返回子組件)函數只能進行調用,無法劫持劫持原組件生命周期方法或取到static方法;
3、(返回子組件)函數作為子組件包裹在父組件中的方式看起來靈活但不夠優雅;
4、由於子組件的渲染控制完全通過在父組件render方法中調用(返回子組件)函數,無法通過shouldComponentUpdate來做性能優化。
所以這兩種方式各有優劣,可根據具體場景選擇。
關於Mixins
在使用ES6語法寫組件之前,組件復用我們通常使用mixin方式,而使用ES6語法之後mixin不再支持,所以現在組內的項目中也不再使用。而mixin作為一種抽象和共用代碼的方案,許多庫(比如react-router)都依賴這一功能。
90% of the time you dont need mixins, in general prefer composition via high order components. For the 10% of the cases where mixins are best (e.g. PureRenderMixin and react-routers Lifecycle mixin), this library can be very useful.
在React官方文章Mixins Considered Harmful 中闡述了一些Mixins存在的問題:
- Mixins introduce implicit dependencies
- Mixins cause name clashes
- Mixins cause snowballing complexity
兩者生命周期上的差異
HOC的生命周期依賴於其實現,而mixin中除了render之外其他的生命周期方法都可以重複且會調用,但不可以設置相同的屬性或者包含相同名稱的普通方法。重複的生命周期調用方法的順序是:mixin方法首先會被調用(根據mixins中的順序從左到右的進行調用),然後再是組件的中方法被調用。
相關鏈接
Higher-Order Components - React
https://medium.com/@franleplant/react-higher-order-components-in-depth-cf9032ee6c3e
Functions as Child Components and Higher Order Components
https://medium.com/merrickchristensen/function-as-child-components-5f3920a9ace9
推薦閱讀:
※【譯】React如何抓取數據
※如何解決Typescript對React props的類型檢查
※2017 年底如何比較 Angular 4, React 16, Vue 2 的開發和運行速度?
※請問react中有什麼好用的ui庫嗎?
※React.js: web開發者的14個工具和資源