寫在前面(說點廢話)
作為一個react開發者,提起高階組價,我想并不陌生,即使是沒聽過,我相信,你毫無察覺的用過,比如redux中的connect
函數,比如antd 3.x中的createForm
方法,甚至ES6中的filter
、map
、reduce
也是高階函數。等等....?主題不是說“高階組件”嗎?怎么說了一堆函數,組件不應該是渲染UI的嗎?當然了,小編之所以這么叫,就是為了告訴大家高階組件也叫高階函數,是高階函數在react中的叫法,那么今天,小編就帶你聊一聊react的高階函數,以及它有什么樣的實際意義。
什么是高階組件
正如前面提到的,ES6的filter
、map
也可以被稱為是高階函數,那么它們的特點我想大家也知道,那就是“對一個數組做遍歷后返回一個新的數組”,其實在react HOC中,也是這樣的用法,在官網也給出了比較明確的定義
高階組件是參數為組件,返回值為新組件的函數。
組件是將props裝換為UI,而高階組件是將組件轉換為另一個組件。
高階組件(HOC)是 React 中用于復用組件邏輯的一種高級技巧。HOC 自身不是 React API 的一部分,它是一種基于 React 的組合特性而形成的設計模式。
現在我們對HOC的概念和它做的主要事情都搞清楚了,那么接下來,我們就來根據一個假設的需求來寫一個高階組件(同HOC)吧。
要寫一個高階組件,我們先得知道高階的使用場景,畢竟有些功能是可以用普通的封裝和復用來解決的,使用HOC反而讓其變得復雜起來。
- 代碼復用,邏輯抽象。(也就是說,我們不再需要把一些公共邏輯暴露出來來復用,完全可以抽象化,就像不可以不用理會
connect
是怎么把redux和react聯系起來也可以正常使用reudx一樣) - Props更改,我們可以通過屬性代理的方式,對原來的組件Props做出相應的更改。
- State抽象和更改(也就是說,我們可以通過HOC包裹一個組件后,讓其有其他的狀態,或者對其狀態做一些處理。)
通過demo來演示HOC的使用場景
上面我們說了一些概念理論性的東西,如果沒有接觸過HOC或者HOC新手來說,可能還是一頭霧水,那么現在就用一些簡單的demo來演示一些HOC的使用場景。
假設需求:現在我們有一個數字展示組件,接受一個展示的數字,他的父組件是一個“加法器”組件,每次點擊按鈕,會將狀態+1,然后傳給展示組件展示。如下圖那樣
代碼如下:
父組件:
import React, { useState } from 'react'
import CountView from './components/hoc1'
import { Button } from 'antd'
const Hoc = () => {
const [count, setCount] = useState(1)
const btnClick = () => {
setCount(count + 1)
}
return (
<>
<h1>HocDemo測試</h1>
<CountView
count = {count}
/>
<Button onClick={btnClick} type="primary" style={{ marginTop: '15px'}}>點我+1</Button>
</>
)
}
export default Hoc
子組件(數字展示組件)
import React from 'react'
const CountView = (props: any) => {
const { count } = props
return (
<div
style={{
width: '50px',
height: '50px',
background: '#090',
color: '#fff',
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}
>
{count}
</div>
)
}
export default CountView
可以看出,我們的代碼非常簡單(實際上,我們面對的可能是一個非常復雜的子組件和交互邏輯),現在我們需要將需求變更:要對當前的展示數字乘2。當前了,如果要完成這個需求,我們有更多方法,假設CountView
是一個第三方組件,我們不方便改它的源碼渲染邏輯,對于父組件的props邏輯更改,可能是一個首選的方案,但是我們這樣的需求有好幾個地方,并且可能后面還會改(聽起來很變態的產品需求),那么去修改父組件的邏輯就會變得工作量很大,也很麻煩,接下來,讓我們用高階組件的方式來實現代碼的復用吧。
定義高階組件
通過上面的概念講解,我們了解到,高階組件是參數為組件,返回值為新組件的函數。,那么我們這樣去定義:
import React from 'react'
const double = (WrappedComponent: any) => {
return (props: any) => {
const { count } = props
return (
<WrappedComponent count={count * 2} />
)
}
}
export default double
const CountView = (props: any) => {
const { count } = props
return (
<div
style={{
width: '50px',
height: '50px',
background: '#090',
color: '#fff',
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}
>
{count}
</div>
)
}
export default double(CountView)
實現了功能:
通過上面的代碼和效果,我們應該知道
HOC 不會修改傳入的組件,也不會使用繼承來復制其行為。相反,HOC 通過將組件包裝在容器組件中來組成新組件。HOC 是純函數,沒有副作用。
實際上,上面的功能中,高階組件接收被包裹組件CountView
的所有props,我們只是將props做了更改,然后傳給了CountView
組件,這樣,如果我們只需要在需要做此需求更改的地方,套上這個高階組件就好了,如果有邏輯修改,我們也只需要修改高階組件就好了。
細想一下,是不是和我們上面提到的高階組件的作用一樣呢?
- 代碼復用,邏輯抽象。(也就是說,我們不再需要把一些公共邏輯暴露出來來復用,完全可以抽象化,就像不可以不用理會
connect
是怎么把redux和react聯系起來也可以正常使用reudx一樣)- Props更改,我們可以通過屬性代理的方式,對原來的組件Props做出相應的更改。
實際上,我們上面的高階組件的形式叫做屬性代理,是高階組件的一個主流使用方式。這里有一個小彎,為什么高階組件返回組件的props會和被包裹組件的props一樣呢?畢竟是兩個組件,如果你光看高階組件的邏輯,這里很難理解,感覺很繞,如果你把眼光放在父組件,也就是使用“被包裹后的組件”的地方,你會發現:我們在使用被高階組件包裹后的組件和使用不被高階組件包裹的組件,他的使用方式完全一樣,所以也就導致了,我們傳到高階組件里面的props和被包裹組件應有的props完全一樣,這就是高階組件屬性代理的原理,同時,這也體現了他的便利,我們在封裝完高階組件之后,只需要在使用他的地方做一個調用即可,對于原邏輯并不造成任何影響。
上面在說高階組件優點的時候,還有一個“State抽象和更改”,這里同樣做一下demo演示,我們需要點擊數字展示區域的時候,改變他的背景顏色,我們對代碼做一下修改:
CountView組件
import React from 'react'
import double from './double'
const CountView = (props: any) => {
const { count, bg, divClick } = props
return (
<div
style={{
width: '50px',
height: '50px',
background: bg,
color: '#fff',
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}
onClick={divClick}
>
{count}
</div>
)
}
export default double(CountView)
Hoc
import React, { useState } from 'react'
const double = (WrappedComponent: any) => {
return (props: any) => {
const [ bg, setBg] = useState('#090')
const { count } = props
const divClick = () => {
setBg('#900')
}
return (
<WrappedComponent divClick={divClick} bg={bg} count={count * 2} />
)
}
}
export default double
上面的代碼中,我們做了這么幾件事,首先我們要做得演示是抽象state,這里無論是類組件還是函數組件使用hooks的狀態,都是可以改變的,上面的代碼中,我們做了這樣的事情。
- 將CountView組件的背景和點擊事件通過props抽象出來,如果不抽象的話,我們應該將顏色狀態和點擊事件都定義在本組件中,但是我們可以使用高階組件來抽象,那么就是將狀態抽離到高階組件返回的組件中,然后通過高階組件屬性代理的方式,將props傳回來,這樣,我們就在不修改調用方式的情況下,實現了狀態的抽象(讓狀態不存在于本組件中)。
- 在高階組件返回的組件中,我們將狀態和事件定義在其中,再通過props傳給被包裹組件,這樣,我們將被包裹組件的
state
抽象化了。
小結
- 高階組件的實現方法,其實包括以下兩種
- 屬性代理
- 反向繼承
我們上面的demo和講解都是講解的屬性代理,關于反向繼承,后面有時間有機會再講(有點難),感興趣的同學可以自行學學,實際上,因為現在類組價用的并不多,反向繼承也就用的不多了。一般的,我們通過屬性代理的方式就可以解決大部分問題。
- 不要改變原始組件,使用組合
我們在做高階組件的封裝的時候,不要試圖通過prototype
或者其他方式修改組件!比如下面的demo(摘自官網)
function logProps(InputComponent) {
InputComponent.prototype.componentDidUpdate = function(prevProps) {
console.log('Current props: ', this.props);
console.log('Previous props: ', prevProps);
};
// 返回原始的 input 組件,暗示它已經被修改。
return InputComponent;
}
// 每次調用 logProps 時,增強組件都會有 log 輸出。
const EnhancedComponent = logProps(InputComponent);
我們在HOC中,修改了被包裹組件的生命周期方法,讓他打印一些東西,這樣就會帶來下面的問題
- 被包裹組件再也無法像 HOC 增強之前那樣使用了,因為他已經被修改。
- 如果你再用另一個同樣會修改 componentDidUpdate 的 HOC 增強它,那么前面的 HOC 就會失效!同時,這個 HOC 也無法應用于沒有生命周期的函數組件。
所以,修改傳入組件的 HOC 是一種糟糕的抽象方式。調用者必須知道他們是如何實現的,以避免與其他 HOC 發生沖突。這顯然讓原本可以提效的東西變得更糟糕了!
相反的,我們應該使用組合的方式,那么我們使用組合的方式,修改上面的組件
function logProps(WrappedComponent) {
return class extends React.Component {
componentDidUpdate(prevProps) {
console.log('Current props: ', this.props);
console.log('Previous props: ', prevProps);
}
render() {
// 將 input 組件包裝在容器中,而不對其進行修改。Good!
return <WrappedComponent {...this.props} />;
}
}
}
這樣,我們實現了相同的功能,但是不用修改原始組件,也避免了和其他HOC沖突的情況,可以放心的使用這種抽象的邏輯抽離,并且也不用再關心,包裹的組件是函數組件還是類組件,高階組件是純函數、沒有副作用。
關于高階組件,官網還有一些約定,本文就把這些零碎的知識點整合一下把~并做適當的解釋。
約定
將不相關的 props 傳遞給被包裹的組件
render() {
// 過濾掉非此 HOC 額外的 props,且不要進行透傳
const { extraProp, ...passThroughProps } = this.props;
// 將 props 注入到被包裝的組件中。
// 通常為 state 的值或者實例方法。
const injectedProp = someStateOrInstanceMethod;
// 將 props 傳遞給被包裝組件
return (
<WrappedComponent
injectedProp={injectedProp}
{...passThroughProps}
/>
);
}
像上面的代碼一樣,我們把HOC中和自身無關的props透傳,也就是說,我們我們在使用這個被高階組件包裹過的組件后,可能會收到一些無關的屬性,我們不應該將這些無關的屬性傳給被包裹組件,這樣可以保證HOC的復用性和靈活性,否則,一些無關的屬性可能影響他包裹的組件。
最大化可組合性
- 并不是所有的 HOC 都一樣。有時候它僅接受一個參數,也就是被包裹的組件:
const NavbarWithRouter = withRouter(Navbar);
- HOC 通常可以接收多個參數。
const CommentWithRelay = Relay.createContainer(Comment, config);
包裝顯示名稱以便輕松調試
HOC 創建的容器組件會與任何其他組件一樣,會顯示在 React Developer Tools 中。為了方便調試,請選擇一個顯示名稱,以表明它是 HOC 的產物。
最常見的方式是用 HOC 包住被包裝組件的顯示名稱。比如高階組件名為 withSubscription
,并且被包裝組件的顯示名稱為 CommentList
,顯示名稱應該為 WithSubscription(CommentList)
注意事項
高階組件在使用的時候,有一些需要注意的地方,這里大家一定要關注一下
-
要在 render 方法中使用 HOC
如果大家了解過react的更新機制,就應該知道,react在更新的時候,是要做屬性或者節點的比較的
render() {
// 每次調用 render 函數都會創建一個新的 EnhancedComponent
// EnhancedComponent1 !== EnhancedComponent2
const EnhancedComponent = enhance(MyComponent);
// 這將導致子樹每次渲染都會進行卸載,和重新掛載的操作!
return <EnhancedComponent />;
}
因為在render中使用HOC會導致每次render都創建一個新的HOC,這樣會導致性能的問題,同時組件的重載會導致組件或者起子組件的狀態丟失這樣肯定會帶來一些負面的影響。
- 務必復制靜態方法
當你將 HOC 應用于組件時,原始組件將使用容器組件進行包裝。這意味著新組件沒有原始組件的任何靜態方法。也就是,我們在使用高階組件包裹一個組件后偶,因為是新生成的組件,所以里面的靜態方法會丟失,所以我們要把原始組件的靜態方法復制到高階函數返回的組件中。
// 定義靜態函數
WrappedComponent.staticMethod = function() {/*...*/}
// 現在使用 HOC
const EnhancedComponent = enhance(WrappedComponent);
// 增強組件沒有 staticMethod
typeof EnhancedComponent.staticMethod === 'undefined' // true
可以這樣修改
function enhance(WrappedComponent) {
class Enhance extends React.Component {/*...*/}
// 必須準確知道應該拷貝哪些方法 :(
Enhance.staticMethod = WrappedComponent.staticMethod;
return Enhance;
}
玩意靜態方法很多,有一些沒必要復制怎么辦?那么你可以在定義原始組件的時候,額外的導出他的靜態方法,這樣,我們在使用的時候,按需引入就好了
// 使用這種方式代替...
MyComponent.someFunction = someFunction;
export default MyComponent;
// ...單獨導出該方法...
export { someFunction };
// ...并在要使用的組件中,import 它們
import MyComponent, { someFunction } from './MyComponent.js';
Refs 不會被傳遞
因為我們的高階組件使用的是屬性代理(即使是反向繼承),ref都不會被當成props傳遞和代理,這時候,你可能需要使用React.forwardRef
來做ref
轉發了,比如下面這樣
import React, { forwardRef } from 'react'
import double from './double'
const CountView = (props: any, ref: any) => {
const { count, bg, divClick} = props
return (
<div
style={{
width: '50px',
height: '50px',
background: bg,
color: '#fff',
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}
onClick={divClick}
ref={ref}
>
{count}
</div>
)
}
export default double(forwardRef(CountView))
HOC
import React, { useState, useRef } from 'react'
const double = (WrappedComponent: any) => {
return (props: any, ref: any) => {
const [ bg, setBg] = useState('#090')
const WrappedComponentRef = useRef()
const { count } = props
const divClick = () => {
setBg('#900')
console.log(WrappedComponentRef.current)
}
return (
<WrappedComponent ref={WrappedComponentRef} divClick={divClick} bg={bg} count={count * 2} />
)
}
}
export default double
這樣我們就能通過ref轉發的形式正常的訪問被包裹組件中的Ref了。
因為Ref不是props,所以我們無法通過屬性代理的方式傳遞。一定要通過ref轉發,如果你對Ref轉發不屬性,那么可以看我之前寫的一篇關于ref的文章
寫在后面
本文介紹了高階組件(HOC,也叫高階函數)的用法,如果你弄名了,那你會發現,他可以優雅的處理好多邏輯的抽離,比普通的封裝更抽象,還更加靈活,文中我是用一個加法器做得demo,現實開發中,加法器這種簡單的demo肯定會很少,一般是比較復雜的邏輯,我們可以借助這種思想來幫我們完成更加復雜的封裝和抽離。那么肯定有小伙伴會問,高階組件到底有什么實際作用呢?有哪些業務場景需要用到呢?(下面內容可以略過)
我在寫文章的時候,也在思考這個問題,畢竟花時間去研究一個沒有實際意義的東西也是沒啥意思,我想到了兩種現實的實用場景
- 我們可以做公關的鑒權邏輯,比如通過屬性代理的方式,控制不同角色權限下一個組件的props渲染和事件執行方式,這樣就可以做到業務和權限的解耦。
- 統一格式化數據。在寫文章的時候,中間和群友交流問題,正好他提出了一個問題,我覺得正是高階組件的使用場景,問題是這樣的。
在使用antd組件庫時候,treeData的類型是這樣的
array<{key, title, children, [disabled, selectable]}>
也就是說,當我們的數據源不符合{title,value}
的形式,將會出現問題
對于上述問題,我當時正在寫文章,于是就想到了通過高階函數屬性代理去做,具體方式,如果你讀懂了上面的文章,應該不能想象,當然了,上面的問題,我們也可以使用一個方法包裹一些,返回這個tree
組件,就相當于做了一個簡單的組件封裝,就像下面這樣
如果你已經很了解高階組件了,那么就很容易知道高階組件的優勢了,首先同樣的問題除了
tree
組件還有treeSelect
組件,甚至還有其他的組件,他們都面臨著這樣的問題,如果使用上面的封裝,那么重復工作將做不少,如果使用高階組件,就不用管組件的類型是什么,我們需要返回什么樣的組件處理什么樣的props了,我們只需要使用高階組件對全部屬性做代理,單獨處理數據源就好了。這樣一看是不是高階組件更靈活呢?
好了,說了這么多,就到這里吧。有問題歡迎評論探討哦~
demo git 源碼https://github.com/sorryljt/demo-hoc-double