1. 程式人生 > >React 高階元件

React 高階元件

本文將探討如何構建更易於複用,更為靈活的React高階元件。

在實際的應用開發中,多個React元件之間可能需要共用一段完成某些特定功能的程式碼,那麼如何在不同的元件間複用這段程式碼就成了一個值得思考的問題(或者說,如何建立具有類似功能的,不同的React元件)。

在JavaScript的世界中,函式是第一等的公民,既能夠作為引數傳遞,也能作為函式的返回值,所以通過高階函式的形式,我們能夠在一個函式中包裝另一個傳入的函式並返回一個新的函式,從而在原函式基礎上實現更加豐富的功能。

而React元件不是純函式就是ES6類(本質上也是函式)的形式,所以我們可以借鑑高階函式的思想,來構建我們的高階元件。

一. 高階元件的概念及應用

高階元件(Higher Order Component, HOC)實際上也是React元件,只不過它接收其他的React元件作為引數,並返回一個新的React元件(聽起來是不是十分類似於高階函式呢),從而增強傳入元件的功能。

高階元件的實現方式可以分為兩類:(1)代理方式的高階元件; (2)繼承方式的高階元件;

1.代理方式的高階元件:

所謂的代理,就是指當前的高階元件只是傳入元件的代理,返回的新元件必然會用到傳入的元件。基本上就是以下這種形式:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

import React from "react";

/* 如果包裹元件不需要除render之外的生命週期函式或者是維護自己的狀態,也可以寫成無狀態元件的形式 */

function ProxyHigherOrderComponent(WrappedComponent){

return class WrappingComponent extends React.Component{

render(){

return (

<WrappedComponent />

)

}

}

}

export default ProxyHigherOrderComponent;

//使用:

const NewComponent = ProxyHigherOrderComponent(OldComponent)

<NewComponent />

當然,我們這裡的代理元件只是簡單的在JSX中返回傳入的元件,並沒有增加任何的功能。不過代理方式的高階元件大體上就是這種形式。

代理方式的高階元件可應用在以下場景中: (1)操縱props; (2)訪問ref; (3)抽取狀態; (4)包裝元件;

(1)操縱props

採用代理方式構造的高階元件,被包裹元件接收外部資料(props)的任務就必須交由返回的包裹元件來完成,那麼在這個過程中,我們就可以對props進行一定的操作,比如增減,刪除或者是修改傳遞給被包裹元件的props列表。

例如,以下高階元件的功能是可以刪除傳入的特定的prop:

1

2

3

4

5

6

function removeSpecialProp(WrappedComponent){

return function newComponent(props){

const { specialProp, ...otherProps } = props;

return <WrappedComponent { ...otherProps } />

}

}

當然,你也可以增加特定的prop,一切都根據實際需求決定:

1

2

3

4

5

function removeSpecialProp(WrappedComponent, newProps, newProp){

return function newComponent(props){

return <WrappedComponent { ...otherProps, newProp } { ...newProps } />

}

}

不過這種方式也有一種缺點,那就是必須要求被包裹的元件(WrappedComponent)能夠接收特定名稱的prop屬性,否則即使你傳遞一個新值進去,那也是無效的。(之後會介紹以函式為子元件的方法來避免這一缺陷)

(2)訪問ref

需要明確一點,在React元件中儘量不要使用ref獲得元件元素(類例項)或者是DOM元素的引用然後操作它們,因為它會破壞元件的封裝性。當然在極少數情況下可能還是會用到它。

本節將介紹如何通過代理高階元件的形式,使得傳入的元件都能通過ref獲取並操作DOM元素。

首先,簡要介紹常規元件中是如何使用ref,然後再進一步構造我們的refsHOC高階元件。

ref提供了一種方式,用於訪問在render方法中建立的DOM節點或React元素。使用ref有三種方式(其中String型別的Ref屬於舊版API,已經被廢棄):

1.React.createRef()

1

2

3

4

5

6

7

8

9

class MyComponent extends React.Component {

constructor(props) {

super(props);

this.myRef = React.createRef();

}

render() {

return <div ref={this.myRef} />;

}

}

然後通過this.myRef.current屬性就能夠獲取到應用ref屬性的元素的引用。current屬性的型別,根據使用ref屬性的節點型別,也會不一樣:如果應用ref屬性的元素是普通的HTML元素,那麼current屬性獲取到的就是該底層DOM元素(比如上例中的div元素);如果是自定義類元件,ref物件將接收該元件已掛載的例項作為它的current(由於無狀態元件是沒有對應例項的,所以不能在函式式元件上使用ref屬性)。

(題外話:如果在React元件上使用ref,React會在元件載入時將DOM元素傳入current屬性,在解除安裝時則會改回null。ref的更新會發生在componentDidMount或componentDidUpdate生命週期鉤子之前。)

2.回撥Ref

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

class MyComponent extends React.Component {

constructor(props){

super(props);

this.input = null;

this.typeValue = this.typeValue.bind(this);

this.useRef(element){

this.input = element;

}

this.state = {

inputText: ""

}

}

typeValue(){

this.setState({

inputText: this.input.value

});

}

render() {

return (

<div>

<input

type={"text"}

ref = {this.useRef}

onChange={this.typeValue}

/>;

</div>;

);

}

}

上例中的this.input所指向的就是對應DOM元素的引用。

以上就是ref的基本使用方法,接下來就要建立能夠使傳入元件(被包裹元件)訪問自身的ref引用的高階元件,在這裡,將使用回撥ref的方式構建:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

const refsHOC = (WrappedComponent) => {

return class HOCComponent extends React.Component {

constructor(props){

super(props);

this.linkRef = this.linkRef.bind(this);

}

linkRef(element){

this.root = element;

}

render(){

const props = { ...this.props, ref: this.linkRef }

return <WrappedComponent  { ...props } />

}

}

}

該元件的原理同樣也是增加傳遞給WrappedComponent的props,只不過這個newProp是函式型別的ref屬性。最後,我們在返回的新元件中,通過this.root來獲取對WrappedComponent元件例項的引用。

(3)抽取狀態

說到抽取狀態的高階元件用法就不得不提到react-redux提供的connect()()方法,該方法接收mapStateToProps以及mapDispatchToProps這兩個函式,並返回一個高階元件,該高階元件接收一個傻瓜元件作為引數,最後返回一個對應的容器元件。大致就是這麼一個過程。

在傻瓜元件和容器元件之間的關係中,傻瓜元件並不會管理自己的狀態(所以一般是通過無狀態元件來實現),而所有的狀態管理都交給包裹它的容器元件來完成,這種模式就是一種“抽取狀態”。

接下來將具體實現一個簡易的connect()()高階元件,以體會抽取狀態這一過程:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

const doNothing = () => ({});

function connect(mapStateToProps=doNothing, mapDispatchToProps=doNothing){

/* 返回的這個函式才是高階元件 */

retrun (DumbComponent) => {

/* ContainerComponent才是將要返回的容器元件 */

class ContainerComponent extends React.Component {

constructor(props){

super(props);

this.handleChange = this.handleChange.bind(this);

}

componentDidMount(){

this.unsubscribe = this.context.store.subscribe(this.handleChange);

}

componentWillMount(){

this.unsubscribe();

}

handleChange(){

this.forceUpdate();

}

shouldComponentUpdate(){

//只有當接收的引數發生變化時才重新渲染

}

render(){

const store = this.context.store;

const propsPassToDumb = {

...this.props,

...mapStateToProps(store.getState(), this.props),

...mapDispatchToProps(store.dispatch, this.props)

};

return (

<DumbComponent {...propsPassToDumb} />

);

}

};

/*

*往往生成的容器元件中需要被包裹一層Provider元件,以直接向內部傳遞store物件,

*而無須通過props逐層傳遞,所以接收資料方需要定義從context物件中接收的資料的型別contextType

*/

ContainerComponent.contextType = {

store: React.propTypes.object

};

return ContainerComponent;

}

}

這裡的程式碼參考了Redux創造者Dan Abramov的connect.js explained。當然這段程式碼也是很好理解的,個人認為的更加完整的connect方法的實現可以點選這裡

(4)包裝元件

在使用代理方式構造的高階元件中完全可以在render函式中引入起來的元素,甚至可以組合多個其他React元件,這樣就能給被包裹元件新增更為豐富的行為。例如:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

const styleHOC = (WrappedComponent, style) => {

return class HOCComponent extends React.Component {

render(){

return (

<div style={style} >

<WrappedComponent {...this.props} />

</div>

);

}

}

}

const style = {

color: "red"

};

const NewComponent = styleHOC(WrappedComponent, style);

有了這個高階元件就可以給任何的一個元件補充style樣式。

2.繼承方式的高階元件

繼承方式的高階元件採用繼承關係關聯作為引數的元件和返回的元件,例如:

1

2

3

4

5

6

7

function inheritedHigherOrderComponent(WrappedComponent){

return class NewComponent extends WrappedComponent{

render(){

return super.render();

}

}

}

直接通過super獲取父類中render方法的引用然後再呼叫它。值得注意的是,在繼承方式下的高階元件中,返回的NewComponent和被包裹的WrappedComponent元件實際上是繼承關係,所以二者只有一個生命週期。

繼承方式的高階元件可以應用於以下場景: (1)操縱props; (2)操縱生命週期函式;

(1)操縱props

繼承方式的高階元件也可以操縱props,只不過由於採用的是繼承方式,操縱方式不太一樣:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

function AddNewStyleProp(WrappedComponent){

return class newComponent extends WrappedComponent{

constructor(props){

super(props);

}

render(){

const elements = super.render();

const newStyle ={

color: (elements && elements.type === "div") ? "red" : "green"

}

const newProps = {...this.props, style: newStyle};

return React.cloneElement(elements, newProps, elements.props.children);

}

}

}

以上程式碼中需要注意的點就是不要忘了render()的返回值是一個JSX結構,但是實際上在內部會呼叫React.createElement()把該JSX結構轉換為JSON物件形式表示的虛擬DOM。所以通過elements.type能夠獲取到該節點物件的型別。

而React.cloneElement()方法則是用於克隆一個元素(返回一個新的React元素),傳入的引數分別是要克隆的節點的物件,要傳入新節點的props資料,以及它的子元素節點。(分別對應著DOM節點的三要素,元素型別,元素屬性,元素子節點,詳情可以點選這裡

實際上,繼承方式的高階元件並不太適合去操作props,一不小心就可能會改變this.props的值。所以,此種情形最好還是使用代理形式的高階元件。

(2)操縱生命週期函式

假如應用中有一些React元件都需要定義相同邏輯的shouldComponentUpdate()生命週期方法,我們當然可以在每個元件中都新增相同的shouldComponentUpdate()方法,但是顯然程式碼出現了重複。

如果我們採用繼承形式的高階元件就可以讓傳入的元件都複用共同的一段生命週期函式程式碼:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

import React from "react";

function InheritedShouldComponentUpdateHOC(WrappedComponent){

return class WrappingComponent extends WrappedComponent{

constructor(props){

super(props);

}

shouldComponentUpdate(nextProps, nextState){

return this.props !== nextProps || this.state !== nextState ;

}

}

}

export default InheritedShouldComponentUpdateHOC;

//使用:

const NewComponent_1 = InheritedShouldComponentUpdateHOC(WrappedComponent_1);

const NewComponent_2 = InheritedShouldComponentUpdateHOC(WrappedComponent_2);

<NewComponent_1 />

<NewComponent_2 />

這樣,NewComponent_1和NewComponent_2元件都具有了相同的shouldComponentUpdate()方法,儘管WrappedComponent_1和WrappedComponent_2元件的內部渲染邏輯可能並不相同。

這在我們使用其他人編寫的元件時也非常的有用,如果我們想在不修改元件邏輯的基礎上新增新的功能,就可以採用這種形式。

操縱生命週期函式是繼承形式高階元件的特用場景,代理方式是無法修改傳入元件的生命週期函式邏輯的。

3.高階元件命名

在使用高階元件時,必然會導致丟失被包裹的元件元素的名稱,這可能會在除錯的過程中造成不便,所以我們可以通過設定元件的displayName屬性來確保高階元件的名稱使我們所希望的。

例如,在使用connect方法時,希望得到的容器元件名稱是特定形式的名稱(如Connect_XXX),可以對connect()返回的高階元件採用如下操作:

1

2

3

4

5

6

7

function getDisplayName(WrappedComponent){

return WrappedComponent.displayName ||

WrappedComponent.name ||

"Component";

}

HOCComponent.displayName =`Connect_${getDisplayName(WrappedComponent)}`;

4.曾經的Mixin

React曾經支援一個Mixin的功能,也可以提供元件間程式碼複用的功能(已經被廢棄,因為它只能在React.createClass方式建立的元件中使用),具體是這樣使用的,瞭解一下即可:

1

2

3

4

5

6

7

8

9

10

11

12

13

const shouldUpdateMixin = {

shouldComponentUpdate: function(){

return this.props !== nextProps || this.state !== nextState ;

}

}

const SampleComponent = React.createClass({

mixins: [shouldUpdateMixin]

render: function(){

//......

}

});

這樣,SampleComponent元件中就具有了混入的shouldUpdateMixin物件中的方法shouldComponentUpdate。

Mixins正是由於其太過於靈活才被廢棄,因為它十分難以管理,並鼓勵往React元件中加入狀態。

二. 以函式為子元件

採用代理形式的高階元件固化了傳遞給原元件的props(即原元件必須顯示宣告它接收某個型別的prop屬性,你才能傳遞給它),我們可以採用以函式為子元件的方式來克服這一侷限。

實際上,以函式為元件的方式就是通過在代理元件和被包裹元件之間加了一層函式,通過該函式,我們可以傳遞任意的引數給它,被包裹元件則從函式中獲取它想要的引數,這樣傳遞的引數就不會再有名稱的限制了。例如:

下面的WrappingComponent依舊可以視為是一個代理元件,只不過它不需要傳入被包裹元件作為引數(高階元件的形式),但是它要求其子元件必須存在,且必須是一個函式,同時函式中必須返回一個JSX型別的結構。然後WrappingComponent依舊可以通過this.props.children()的形式代理自己的子函式包裹的元件,並傳遞引數。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

class WrappingComponent extends React.Component {

render(){

const thePropsThatIWantToPass = XXX;

return this.props.children(thePropsThatIWantToPass);

}

}

//子元素可以是類似於下面的函式:

(props) => {

return (

<div>

{props.X}

<OtherReactComponent otherName={props.XX}/>

</div>

); 

}

這種方式的好處,就是傳遞的引數名稱沒有限制,非常靈活。而他們之間的函式則成為了連線父元件和底層元件的橋樑。