Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

react进阶之高阶组件 #25

Open
sunyongjian opened this issue Jun 27, 2017 · 22 comments
Open

react进阶之高阶组件 #25

sunyongjian opened this issue Jun 27, 2017 · 22 comments
Labels

Comments

@sunyongjian
Copy link
Owner

前言

本文属于react进阶用法,如果你还不了解react,建议从文档开始看起。

我们都知道高阶函数是什么, 高阶组件其实是差不多的用法,只不过传入的参数变成了react组件,并返回一个新的组件.

A higher-order component is a function that takes a component and returns a new component.

形如:

const EnhancedComponent = higherOrderComponent(WrappedComponent); 

高阶组件是react应用中很重要的一部分,最大的特点就是重用组件逻辑。它并不是由React API定义出来的功能,而是由React的组合特性衍生出来的一种设计模式。
如果你用过redux,那你就一定接触过高阶组件,因为react-redux中的connect
就是一个高阶组件。

另外本次demo代码都放在 https://github.com/sunyongjian/hoc-demo

引入

先来一个最简单的高阶组件

import React, { Component } from 'react';
import simpleHoc from './simple-hoc';

class Usual extends Component {
  render() {
    console.log(this.props, 'props');
    return (
      <div>
        Usual
      </div>
    )
  }
}
export default simpleHoc(Usual);
import React, { Component } from 'react';

const simpleHoc = WrappedComponent => {
  console.log('simpleHoc');
  return class extends Component {
    render() {
      return <WrappedComponent {...this.props}/>
    }
  }
}
export default simpleHoc;

组件Usual通过simpleHoc的包装,打了一个log... 那么形如simpleHoc就是一个高阶组件了,通过接收一个组件class Usual,并返回一个组件class。 其实我们可以看到,在这个函数里,我们可以做很多操作。 而且return的组件同样有自己的生命周期,function,另外,我们看到也可以把props传给WrappedComponent(被包装的组件)。 高阶组件的定义我都是用箭头函数去写的,如有不适请参照arrow function

装饰器模式

高阶组件可以看做是装饰器模式(Decorator Pattern)在React的实现。即允许向一个现有的对象添加新的功能,同时又不改变其结构,属于包装模式(Wrapper Pattern)的一种

ES7中添加了一个decorator的属性,使用@符表示,可以更精简的书写。那上面的例子就可以改成:

import React, { Component } from 'react';
import simpleHoc from './simple-hoc';

@simpleHoc
export default class Usual extends Component {
  render() {
    return (
      <div>
        Usual
      </div>
    )
  }
}

是同样的效果。
当然兼容性是存在问题的,通常都是通过babel去编译的。 babel提供了plugin,高阶组件用的是类装饰器,所以用transform-decorators-legacy babel

两种形式

属性代理

引入里我们写的最简单的形式,就是属性代理(Props Proxy)的形式。通过hoc包装wrappedComponent,也就是例子中的Usual,本来传给Usual的props,都在hoc中接受到了,也就是props proxy。 由此我们可以做一些操作

  • 操作props
    最直观的就是接受到props,我们可以做任何读取,编辑,删除的很多自定义操作。包括hoc中定义的自定义事件,都可以通过props再传下去。

    import React, { Component } from 'react';
    
    const propsProxyHoc = WrappedComponent => class extends Component {
    
      handleClick() {
        console.log('click');
      }
    
      render() {
        return (<WrappedComponent
          {...this.props}
          handleClick={this.handleClick}
        />);
      }
    };
    export default propsProxyHoc;

    然后我们的Usual组件render的时候, console.log(this.props) 会得到handleClick.

  • refs获取组件实例
    当我们包装Usual的时候,想获取到它的实例怎么办,可以通过引用(ref),在Usual组件挂载的时候,会执行ref的回调函数,在hoc中取到组件的实例。通过打印,可以看到它的props, state,都是可以取到的。

    import React, { Component } from 'react';
    
    const refHoc = WrappedComponent => class extends Component {
    
      componentDidMount() {
        console.log(this.instanceComponent, 'instanceComponent');
      }
    
      render() {
        return (<WrappedComponent
          {...this.props}
          ref={instanceComponent => this.instanceComponent = instanceComponent}
        />);
      }
    };
    
    export default refHoc;
  • 抽离state

    这里不是通过ref获取state, 而是通过 { props, 回调函数 } 传递给wrappedComponent组件,通过回调函数获取state。这里用的比较多的就是react处理表单的时候。通常react在处理表单的时候,一般使用的是受控组件(文档),即把input都做成受控的,改变value的时候,用onChange事件同步到state中。当然这种操作通过Container组件也可以做到,具体的区别放到后面去比较。看一下代码就知道怎么回事了:

    // 普通组件Login
    import React, { Component } from 'react';
    import formCreate from './form-create';
      
    @formCreate
    export default class Login extends Component {
      render() {
        return (
          <div>
            <div>
              <label id="username">
                账户
              </label>
              <input name="username" {...this.props.getField('username')}/>
            </div>
            <div>
              <label id="password">
                密码
              </label>
              <input name="password" {...this.props.getField('password')}/>
            </div>
            <div onClick={this.props.handleSubmit}>提交</div>
            <div>other content</div>
          </div>
        )
      }
    }
    //HOC
    import React, { Component } from 'react';
    
    const formCreate = WrappedComponent => class extends Component {
    
      constructor() {
        super();
        this.state = {
          fields: {},
        }
      }
    
      onChange = key => e => {
        this.setState({
          fields: {
            ...this.state.fields,
            [key]: e.target.value,
          }
        })
      }
    
      handleSubmit = () => {
        console.log(this.state.fields);
      }
    
      getField = fieldName => {
        return {
          onChange: this.onChange(fieldName),
        }
      }
    
      render() {
        const props = {
          ...this.props,
          handleSubmit: this.handleSubmit,
          getField: this.getField,
        }
    
        return (<WrappedComponent
          {...props}
        />);
      }
    };
    export default formCreate;

    这里我们把state,onChange等方法都放到HOC里,其实是遵从的react组件的一种规范,子组件简单,傻瓜,负责展示,逻辑与操作放到Container。比如说我们在HOC获取到用户名密码之后,再去做其他操作,就方便多了,而state,处理函数放到Form组件里,只会让Form更加笨重,承担了本不属于它的工作,这样我们可能其他地方也需要用到这个组件,但是处理方式稍微不同,就很麻烦了。

反向继承

反向继承(Inheritance Inversion),简称II,本来我是叫继承反转的...因为有个模式叫控制反转嘛...
跟属性代理的方式不同的是,II采用通过 去继承WrappedComponent,本来是一种嵌套的关系,结果II返回的组件却继承了WrappedComponent,这看起来是一种反转的关系。
通过继承WrappedComponent,除了一些静态方法,包括生命周期,state,各种function,我们都可以得到。上栗子:

 // usual
import React, { Component } from 'react';
import iiHoc from './ii-hoc';

@iiHoc
export default class Usual extends Component {
  
  constructor() {
    super();
    this.state = {
      usual: 'usual',
    }
  }

  componentDidMount() {
    console.log('didMount')
  }

  render() {
    return (
      <div>
        Usual
      </div>
    )
  }
}
 
//IIHOC
import React from 'react';

const iiHoc = WrappedComponent => class extends WrappedComponent {
    render() {
      console.log(this.state, 'state');
      return super.render();
    }
}

export default iiHoc;

iiHoc return的组件通过继承,拥有了Usual的生命周期及属性,所以didMount会打印,state也通过constructor执行,得到state.usual。
其实,你还可以通过II:

渲染劫持

这里HOC里定义的组件继承了WrappedComponent的render(渲染),我们可以以此进行hijack(劫持),也就是控制它的render函数。栗子:

//hijack-hoc
import React from 'react';

const hijackRenderHoc = config => WrappedComponent => class extends WrappedComponent {
  render() {
    const { style = {} } = config;
    const elementsTree = super.render();
    console.log(elementsTree, 'elementsTree');
    if (config.type === 'add-style') {
      return <div style={{...style}}>
        {elementsTree}
      </div>;
    }
    return elementsTree;
  }
};

export default hijackRenderHoc;
//usual
@hijackRenderHoc({type: 'add-style', style: { color: 'red'}})
class Usual extends Component {
  ...
}

我这里通过二阶函数,把config参数预制进HOC, 算是一种柯理化的思想。
栗子很简单,这个hoc就是添加样式的功能。但是它暴露出来的信息却不少。首先我们可以通过config参数进行逻辑判断,有条件的渲染,当然这个参数的作用很多,react-redux中的connect不就是传入了props-key 嘛。再就是我们还可以拿到WrappedComponent的元素树,可以进行修改操作。最后就是我们通过div包裹,设置了style。但其实具体如何操作还是根据业务逻辑去处理的...

element-tree

我的应用场景

  • 通常我会通过高阶组件去优化之前老项目写的不好的地方,比如两个页面UI几乎一样,功能几乎相同,仅仅几个操作不太一样,却写了两个耦合很多的页面级组件。当我去维护它的时候,由于它的耦合性过多,经常会添加一个功能(这两个组件都要添加),我要去改完第一个的时候,还要改第二个。而且有时候由于我的记性不好,会忘掉第二个... 就会出现bug再返工。更重要的是由于个人比较懒,不想去重构这部分的代码,因为东西太多了,花费太多时间。所以加新功能的时候,我会写一个高阶组件,往HOC里添加方法,把那两个组件包装一下,也就是属性代理。这样新代码就不会再出现耦合,旧的逻辑并不会改变,说不定哪天心情好就会抽离一部分功能到HOC里,直到理想的状态。

  • 另一种情况就是之前写过一个组件A,做完上线,之后产品加了一个新需求,很奇怪要做的组件B跟A几乎一模一样,但稍微有区别。那我可能就通过II的方式去继承之前的组件A,比如它在didMount去fetch请求,需要的数据是一样的。不同的地方我就会放到HOC里,存储新的state这样,再通过劫持渲染,把不同的地方,添加的地方进行处理。但其实这算Hack的一种方式,能快速解决问题,也反映了组件设计规划之初有所不足(原因比较多)。

  • Container解决不了的时候甚至不太优雅的时候。其实大部分时候包一层Container组件也能做到差不多的效果,比如操作props,渲染劫持。但其实还是有很大区别的。比如我们现在有两个功能的container,添加样式和添加处理函数的,对Usual进行包装。栗子:

    //usual
    class Usual extends Component {
    
      render() {
        console.log(this.props, 'props');
        return <div>
          Usual
        </div>
      }
    };
    export default Usual;
    //console - Object {handleClick: function}  "props"
    import React, { Component } from 'react';
    import Usual from './usual';
    
    class StyleContainer extends Component {
    
      render() {
        return (<div style={{ color: '#76d0a3' }}>
          <div>container</div>
          <Usual {...this.props} />
        </div>);
      }
    }
    
    export default StyleContainer;
    import React, { Component } from 'react';
    import StyleContainer from './container-add-style';
    
    class FuncContainer extends Component {
      handleClick() {
        console.log('click');
      }
    
      render() {
        const props = {
          ...this.props,
          handleClick: this.handleClick,
        };
        return (<StyleContainer {...props} />);
      }
    }
    
    export default FuncContainer;

    外层Container必须要引入内层Container,进行包装,还有props的传递,同样要注意包装的顺序。当然你可以把所有的处理都放到一个Container里。那用HOC怎么处理呢,相信大家有清晰的答案了。

    const addFunc = WrappedComponent => class extends Component {
      handleClick() {
        console.log('click');
      }
      
      render() {
        const props = {
          ...this.props,
          handleClick: this.handleClick,
        };
        return <WrappedComponent {...props} />;
      }
    };
    
    const addStyle = WrappedComponent => class extends Component {
    
      render() {
        return (<div style={{ color: '#76d0a3' }}>
          <WrappedComponent {...this.props} />
        </div>);
      }
    };
    
    const WrappenComponent = addStyle(addFunc(Usual));
    
    class WrappedUsual extends Component {
    
      render() {
        console.log(this.props, 'props');
        return (<div>
          <WrappedComponent />
        </div>);
      }
    }

    显然HOC是更优雅一些的,每个HOC都定义自己独有的处理逻辑,需要的时候只需要去包装你的组件。相较于Container的方式,HOC耦合性更低,灵活性更高,可以自由组合,更适合应付复杂的业务。当然当你的需求很简单的时候,还是用Container去自由组合,应用场景需要你清楚。

注意点(约束)

其实官网有很多,简单介绍一下。

  • 最重要的原则就是,注意高阶组件不会修改子组件,也不拷贝子组件的行为。高阶组件只是通过组合的方式将子组件包装在容器组件中,是一个无副作用的纯函数

  • 要给hoc添加class名,便于debugger。我上面的好多栗子组件都没写class 名,请不要学我,因为我实在想不出叫什么名了... 当我们在chrome里应用React-Developer-Tools的时候,组件结构可以一目了然,所以DisplayName最好还是加上。
    constructor

  • 静态方法要复制
    无论PP还是II的方式,WrappedComponent的静态方法都不会复制,如果要用需要我们单独复制。

  • refs不会传递。 意思就是HOC里指定的ref,并不会传递到子组件,如果你要使用最好写回调函数通过props传下去。

  • 不要在render方法内部使用高阶组件。简单来说react的差分算法会去比较 NowElement === OldElement, 来决定要不要替换这个elementTree。也就是如果你每次返回的结果都不是一个引用,react以为发生了变化,去更替这个组件会导致之前组件的状态丢失。

     // HOC不要放到render函数里面
     
     class WrappedUsual extends Component {
    
      render() {
        const WrappenComponent = addStyle(addFunc(Usual));
    
        console.log(this.props, 'props');
        return (<div>
          <WrappedComponent />
        </div>);
      }
    }
  • 使用compose组合HOC。函数式编程的套路... 例如应用redux中的middleware以增强功能。redux-middleware解析

    const addFuncHOC = ...
    const addStyleHOC = ...//省略
    
    const compose = (...funcs) => component => {
      if (funcs.lenght === 0) {
        return component;
      }
      const last = funcs[funcs.length - 1];
      return funcs.reduceRight((res, cur) => cur(res), last(component));
    };
    
    const WrappedComponent = compose(addFuncHOC, addStyleHOC)(Usual);

    关于注意点,官网有所介绍,不再赘述。链接

总结

高阶组件最大的好处就是解耦和灵活性,在react的开发中还是很有用的。
当然这不可能是高阶组件的全部用法。掌握了它的一些技巧,还有一些限制,你可以结合你的应用场景,发散思维,尝试一些不同的用法。

@KevinHu-1024
Copy link

great!

@coder-layne
Copy link

很赞

@nsuedu
Copy link

nsuedu commented Sep 9, 2017

这么好的文章哪里去找

@nsuedu
Copy link

nsuedu commented Sep 9, 2017

不过 注意点(约束)里面怎么突然没有栗子了呢

@sunyongjian
Copy link
Owner Author

@nsuedu 3q... 好评。
注意点没写例子… 可能当时偷懒了吧。

@yinguangyao
Copy link

最近也看了不少关于高阶组件的文章,在开发中,是不是一开始就全部用无状态组件+高阶函数的形式最好呢?把状态之类的剥离出来都写到高阶函数里面,这样无状态组件也可以很容易就被复用了

@sunyongjian
Copy link
Owner Author

@yinguangyao 我个人觉得,高阶组件跟你写不写无状态组件是木有关系的,不是说无状态组件的复用性就一定好。首先介绍我理解的无状态组件的使用场景。并不是任何时候,无状态组件都是最佳选择。比如有一些状态维护到内部,要比 container 组件维护要好很多,举个例子,CheckBoxGroup 这种,选中的状态,打钩不打钩,我觉得 CheckBox 自己有个 state 去控制就好了。如果 CheckBox 是无状态的,父组件可能就需要一个数组去维护每一个的选中状态,我觉得是更麻烦一些的。还是看使用场景吧,无状态组件大多数情况下还是比较好的。
然后就是高阶组件,能用 container 就不需要引入高阶组件啊,只是我们意识到有时候高阶组件的复用性,效果更好的时候,才会用。比如做个 loading?某些权限控制? 这些可以被抽进来的逻辑,尽管按类别封进高阶组件,在不同的页面去使用。
所以,并不一定全部都是无状态组件+高阶函数就是最好的。高阶组件只是更优雅的去处理 container 组件不好处理的一些情况。
复用性是牵扯到很多方面的,理解业务,你想好怎么拆分组件,拓展性,组件的写法等等。不知道能不能回答你的问题

@yinguangyao
Copy link

@sunyongjian 因为公司的业务问题吧,也没有太多机会去实践这个,不过我理解你的意思了,在有些需要复用的场景,比如添加一个样式、页面加载的loading这种,关于你说的checkbox这个例子,假如有场景,最多只让选中两个,但是后来改需求了,最多选中三个,或者在其他页面也有checkbox,但是让最多选四个,这个时候应该是适合高阶函数的,只是有时候一开始意识不到这个组件以后会不会被复用

@sunyongjian
Copy link
Owner Author

嗯,对。这些东西肯定之前是不可能想到的,所以你组件要拆的够细呀。你现在用 React 吗 @yinguangyao

@yinguangyao
Copy link

@sunyongjian 我们公司的技术栈目前是上古老业务还是Lizard(基于backbone的一个Hybrid框架)的,新业务基本都是react了,只不过还是受制于Lizard框架,react只能在单独的页面中用。。。
刚刚老大发消息说,因为最近facebook那个协议风波,上面的大领导正在讨论要不要换框架。。。

@sunyongjian
Copy link
Owner Author

@yinguangyao 哦哦。react 的多页面呗。
协议早就有了... 只不过才被挖出来吧。不过这是法律方面的东西问题了... 咱们也不懂

@yinguangyao
Copy link

@sunyongjian 对啊,而且这边最近刚刚转react native,现在要是换框架,感觉要雪崩了。。。目前还没有可以代替react native的框架。。。

@kingdombuilding
Copy link

这篇文章太棒了。

@stephenzhao
Copy link

return class extends Component 这句 怎解释?返回的是一个组件 还是类?

@sunyongjian
Copy link
Owner Author

@stephenzhao Class 啊,组件的类,使用的时候实例化

@caianfa
Copy link

caianfa commented Dec 6, 2018

超赞 写的很好 感谢

@wangxiansheng123
Copy link

@sunyongjian 对啊,而且这边最近刚刚转react native,现在要是换框架,感觉要雪崩了。。。目前还没有可以代替react native的框架。。。
用flutter啊

@xuyonglin222
Copy link

老哥,🐂🍺

@sundylink
Copy link

const hoc1 = (data) => (wrapperComponent) => {

return class extends Component {

    render() {
        console.log('hoc1');
        return <wrapperComponent {...this.props} {...data}/>
    }
}

};

const hoc2 = (data) => (wrapperComponent) => {

return class extends Component {

    render() {
        console.log('hoc2');
        return <wrapperComponent {...this.props} {...data}/>
    }
}

};

class Test extends Component {

render() {
    console.log('init');
    return <div>test</div>
}

}

let enhance = compose(hoc1({
title: '11'
}), hoc2({
age: 10
}));

export default enhance(Test);

测试结果先输出:

hoc1
hoc2
init

compose执行顺序是从右到左执行,那么结果应该是 hoc2 hoc1,init, 这个如何解释?

@sunyongjian

@sunyongjian
Copy link
Owner Author

@sundylink
首先,文中的 compose 用的是 reduceRight,什么意思呢,就是

// compose(f,g)(params) => f(g(params))

所以对于 compose 的参数 f, g 这些函数来说,执行顺序的确是从右到左,即 ←。
针对你给的栗子:
传给 compose 的是 f, g

const  f = (WrapperComponent) => {
  console.log('f1');
  return class extends Component {
      render() {
          console.log('hoc1');
          return <WrapperComponent {...this.props} />
      }
  }
};
const g = (WrapperComponent) => {
  console.log('g1');
  return class extends Component {
      render() {
          console.log('hoc2');
          return <WrapperComponent {...this.props}/>
      }
  }
};

我加了 f1, g1 两个 console,这里的执行顺序的确是先 f1,后 g1,这跟你的理解是一样的。
但是此时 compose 的返回值,并没有执行到组件内部,所以 hoc1, hoc2 是还未执行的。
返回的组件是未实例化的,也就是组件函数未执行。
此时得到的是一个调用链,即 f 内部的,实例化 g 的返回值,g 内部实例化你传入的 Test,所以此时执行当然是 f 内部 render 先执行,然后是 g 内部的。

抛开组件,举个栗子,再理解一下:

function a(fn) {
  console.log('a');
  return () => {
    console.log('aa');
    return fn();// 相当于 jsx 的组件实例化
  };
}

function b(fn) {
  console.log('b');
  return () => {
    console.log('bb');
    return fn();
  };
}
// 这里相当于 compose, 空函数就是要修饰的组件。
var ab = a(b(() => {}));
ab(1);

@sundylink
Copy link

@sunyongjian

我加了 f1, g1 两个 console,这里的执行顺序的确是先 f1,后 g1,这跟你的理解是一样的。

compose(f, g), 那输出应该是: g1 ,f1 才对吧,

@alanhe421
Copy link

对于渲染的很多组件,我们经常有这样的需求就是用户是XXX角色,才显示某个组件,或者用户是AAA或者BBB,才显示某个组件,这个判断逻辑基于的数据需要从redux中拿,我在想这些判断是不是可以提炼出去,做成组件。这个组件只是简单的控制需不需要显示子组件。从而将这段判断逻辑抽离成容器组件。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests