MrErHu / blog

Star 就是最大的鼓励 👏👏👏

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

React 高阶组件(HOC)入门指南

MrErHu opened this issue · comments

之前的文章React Mixins入门指南介绍了React Mixin的使用。在实际使用中React Mixin的作用还是非常强大的,能够使得我们在多个组件**用相同的方法。但是工程中大量使用Mixin也会带来非常多的问题。Dan Abramov在文章Mixins Considered Harmful
介绍了Mixin带来的一些问题,总结下来主要是以下几点:

  • 破坏组件封装性: Mixin可能会引入不可见的属性。例如在渲染组件中使用Mixin方法,给组件带来了不可见的属性(props)和状态(state)。并且Mixin可能会相互依赖,相互耦合,不利于代码维护。
  • 不同的Mixin中的方法可能会相互冲突

为了处理上述的问题,React官方推荐使用高阶组件(High Order Component)

高阶组件(HOC)

刚开始学习高阶组件时,这个概念就透漏着高级的气味,看上去就像是一种先进的编程技术的一个深奥术语,毕竟名字里就有"高阶"这种字眼,实质上并不是如此。高阶组件的概念应该是来源于JavaScript的高阶函数:

高阶函数就是接受函数作为输入或者输出的函数

这么看来柯里化也是高阶函数了。React官方定义高阶组件的概念是:

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

(本人也翻译了React官方文档的Advanced Guides部分,官方的高阶组件中文文档戳这里)

这么看来,高阶组件仅仅只是是一个接受组件组作输入并返回组件的函数。看上去并没有什么,那么高阶组件能为我们带来什么呢?首先看一下高阶组件是如何实现的,通常情况下,实现高阶组件的方式有以下两种:

  1. 属性代理(Props Proxy)
  2. 反向继承(Inheritance Inversion)

属性代理

又是一个听起来很高大上的名词,实质上是通过包裹原来的组件来操作props,举个简单的例子:

import React, { Component } from 'React';
//高阶组件定义
const HOC = (WrappedComponent) =>
 class WrapperComponent extends Component {
    render() {
      return <WrappedComponent {...this.props} />;
    }
}
//普通的组件
class WrappedComponent extends Component{
    render(){
        //....
    }
}

//高阶组件使用
export default HOC(WrappedComponent)

上面的例子非常简单,但足以说明问题。我们可以看见函数HOC返回了新的组件(WrapperComponent),这个组件原封不动的返回作为参数的组件(也就是被包裹的组件:WrappedComponent),并将传给它的参数(props)全部传递给被包裹的组件(WrappedComponent)。这么看起来好像并没有什么作用,其实属性代理的作用还是非常强大的。

操作props

我们看到之前要传递给被包裹组件WrappedComponent的属性首先传递给了高阶组件返回的组件(WrapperComponent),这样我们就获得了props的控制权(这也就是为什么这种方法叫做属性代理)。我们可以按照需要对传入的props进行增加、删除、修改(当然修改带来的风险需要你自己来控制),举个例子:

const HOC = (WrappedComponent) =>
    class WrapperComponent extends Component {
        render() {
            const newProps = {
                name: 'HOC'
            }
            return <WrappedComponent
                {...this.props}
            />;
        }
    }

在上面的例子中,我们为被包裹组件(WrappedComponent)新增加了固定的name属性,因此WrappedComponent组件中就会多一个name的属性。

获得refs的引用

我们在属性代理中,可以轻松的拿到被包裹的组件的实例引用(ref),例如:

import React, { Component } from 'React';
 
const HOC = (WrappedComponent) =>
    class wrapperComponent extends Component {
        storeRef(ref) {
            this.ref = ref;
        }
        render() {
            return <WrappedComponent
                {...this.props}
                ref = {::this.storeRef}
            />;
        }
    }

上面的例子中,wrapperComponent渲染接受后,我们就可以拿到WrappedComponent组件的实例,进而实现调用实例方法的操作(当然这样会在一定程度上是反模式的,不是非常的推荐)。

抽象state

属性代理的情况下,我们可以将被包裹组件(WrappedComponent)中的状态提到包裹组件中,一个常见的例子就是实现不受控组件受控的组件的转变(关于不受控组件和受控组件戳这里)

class WrappedComponent extends Component {
    render() {
        return <input name="name" {...this.props.name} />;
    }
}

const HOC = (WrappedComponent) =>
    class extends Component {
        constructor(props) {
            super(props);
            this.state = {
                name: '',
            };

            this.onNameChange = this.onNameChange.bind(this);
        }

        onNameChange(event) {
            this.setState({
                name: event.target.value,
            })
        }

        render() {
            const newProps = {
                    value: this.state.name,
                    onChange: this.onNameChange
            }
            return <WrappedComponent {...this.props} {...newProps} />;
        }
    }

上面的例子中通过高阶组件,我们将不受控组件(WrappedComponent)成功的转变为受控组件.

用其他元素包裹组件

我们可以通过类似:

    render(){
        <div>
            <WrappedComponent {...this.props} />
        </div>
    }

这种方式将被包裹组件包裹起来,来实现布局或者是样式的目的。

在属性代理这种方式实现的高阶组件,以上述为例,组件的渲染顺序是: 先WrappedComponent再WrapperComponent(执行ComponentDidMount的时间)。而卸载的顺序是先WrapperComponent再WrappedComponent(执行ComponentWillUnmount的时间)。

反向继承

反向继承是指返回的组件去继承之前的组件(这里都用WrappedComponent代指)

const HOC = (WrappedComponent) =>
  class extends WrappedComponent {
    render() {
      return super.render();
    }
  }

我们可以看见返回的组件确实都继承自WrappedComponent,那么所有的调用将是反向调用的(例如:super.render()),这也就是为什么叫做反向继承。

渲染劫持

渲染劫持是指我们可以有意识地控制WrappedComponent的渲染过程,从而控制渲染控制的结果。例如我们可以根据部分参数去决定是否渲染组件:

const HOC = (WrappedComponent) =>
  class extends WrappedComponent {
    render() {
      if (this.props.isRender) {
        return super.render();
      } else {
        return null;
      }
    }
  }

甚至我们可以修改修改render的结果:

//例子来源于《深入React技术栈》

const HOC = (WrappedComponent) =>
    class extends WrappedComponent {
        render() {
            const elementsTree = super.render();
            let newProps = {};
            if (elementsTree && elementsTree.type === 'input') {
                newProps = {value: 'may the force be with you'};
            }
            const props = Object.assign({}, elementsTree.props, newProps);
            const newElementsTree = React.cloneElement(elementsTree, props, elementsTree.props.children);
            return newElementsTree;
    }
}
class WrappedComponent extends Component{
    render(){
        return(
            <input value={'Hello World'} />
        )
    }
}
export default HOC(WrappedComponent)
//实际显示的效果是input的值为"may the force be with you"

上面的例子中我们将WrappedComponent中的input元素value值修改为:may the force be with you。我们可以看到前后elementTree的区别:
elementsTree:

element tree
newElementsTree:

newElementsTree

在反向继承中,我们可以做非常多的操作,修改state、props甚至是翻转Element Tree。反向继承有一个重要的点: 反向继承不能保证完整的子组件树被解析,开始我对这个概念也不理解,后来在看了React Components, Elements, and Instances这篇文章之后对这个概念有了自己的一点体会。
React Components, Elements, and Instances这篇文章主要明确了一下几个点:

  • 元素(element)是一个是用DOM节点或者组件来描述屏幕显示的纯对象,元素可以在属性(props.children)中包含其他的元素,一旦创建就不会改变。我们通过JSXReact.createClass创建的都是元素。
  • 组件(component)可以接受属性(props)作为输入,然后返回一个元素树(element tree)作为输出。有多种实现方式:Class或者函数(Function)。

所以, 反向继承不能保证完整的子组件树被解析的意思的解析的元素树中包含了组件(函数类型或者Class类型),就不能再操作组件的子组件了,这就是所谓的不能完全解析。举个例子:

import React, { Component } from 'react';

const MyFuncComponent = (props)=>{
    return (
        <div>Hello World</div>
    );
}

class MyClassComponent extends Component{

    render(){
        return (
            <div>Hello World</div>
        )
    }

}

class WrappedComponent extends Component{
    render(){
        return(
            <div>
                <div>
                    <span>Hello World</span>
                </div>
                <MyFuncComponent />
                <MyClassComponent />
            </div>

        )
    }
}

const HOC = (WrappedComponent) =>
    class extends WrappedComponent {
        render() {
            const elementsTree = super.render();
            return elementsTree;
        }
    }

export default HOC(WrappedComponent);

element tree1
element tree2

我们可以查看解析的元素树(element tree),div下的span是可以被完全被解析的,但是MyFuncComponentMyClassComponent都是组件类型的,其子组件就不能被完全解析了。

操作props和state

在上面的图中我们可以看到,解析的元素树(element tree)中含有propsstate(例子的组件中没有state),以及refkey等值。因此,如果需要的话,我们不仅可以读取propsstate,甚至可以修改增加、修改和删除。

在某些情况下,我们可能需要为高阶属性传入一些参数,那我们就可以通过柯里化的形式传入参数,例如:

import React, { Component } from 'React';

const HOCFactoryFactory = (...params) => {
    // 可以做一些改变 params 的事
    return (WrappedComponent) => {
        return class HOC extends Component {
            render() {
                return <WrappedComponent {...this.props} />;
            }
        }
    }
}

可以通过下面方式使用:

HOCFactoryFactory(params)(WrappedComponent)

这种方式是不是非常类似于React-Redux库中的connect函数,因为connect也是类似的一种高阶函数。反向继承不同于属性代理的调用顺序,组件的渲染顺序是: 先WrappedComponent再WrapperComponent(执行ComponentDidMount的时间)。而卸载的顺序也是先WrappedComponent再WrapperComponent(执行ComponentWillUnmount的时间)。

HOC和Mixin的比较

借用《深入React技术栈》一书中的图:
HOCandMixin

高阶组件属于函数式编程(functional programming)**,对于被包裹的组件时不会感知到高阶组件的存在,而高阶组件返回的组件会在原来的组件之上具有功能增强的效果。而Mixin这种混入的模式,会给组件不断增加新的方法和属性,组件本身不仅可以感知,甚至需要做相关的处理(例如命名冲突、状态维护),一旦混入的模块变多时,整个组件就变的难以维护,也就是为什么如此多的React库都采用高阶组件的方式进行开发。

反向继承不能保证完整的子组件树被解析

感觉这个概念是个无用的概念,按照上文的描述:

class WrappedComponent extends Component{
    render(){
        return(
            <div>
                <div>
                    <span>Hello World</span>
                </div>
                <MyFuncComponent />
                <MyClassComponent />
            </div>

        )
    }
}

const HOC = (WrappedComponent) =>
    class extends WrappedComponent {
        render() {
            const elementsTree = super.render();
            return elementsTree;
        }
    }

elementsTree 在控制台中 props.children 中的 MyFuncComponentMyClassComponent 是无法获取到对应的 props.children

但是脱离 高阶组件 的概念, React 本身子组件相关的渲染概念

class WrappedComponent extends Component{
    render() {
        const a = (
            <div>
                <div>
                    <span>Hello World</span>
                </div>
                <MyFuncComponent />
                <MyClassComponent />
            </div>
        )
      return a
    }
}

这里变量 a props.children 中的 MyFuncComponentMyClassComponent 是无法获取到对应的子组件树的

所以不知道是作者对于这句特性:反向继承不能保证完整的子组件树被解析 的理解错误了 还是 我的理解错误了 可以讨论下


搜了很多 这篇文章应该是很少对 反向继承不能保证完整的子组件树被解析 这句话有解释的文章 👍

commented

一直有关注 HOC,最近因为用 II 的方式去改变一个组件库的 UI 显示,才稍微理解 HOC 的 II 方式

反向继承不能保证完整的子组件树被解析

先说明,这里的解析是指对象解析,不是 DOM 解析。

可能搞混了直接调用 super.render()ReactDOM.render() 的关系,前者其实只是直接调用了一个父类的成员方法,返回的 JSX,而后者是会递归调用每个组件的 render,直到最后的组件 type 为原生 dom,所以就算你使用 II ,return 回来的 JSX 包含 MyClassComponent,React 也能够正确地递归解析。

综上, super.render() 和直接变量赋值本质上都是打印出相应的 JSX
另外,为什么 MyClassComponent的 props 没有 children?因为用法是 <MyClassComponent /> 而不是 < MyClassComponent><span>xxxx<span></MyClassComponent> 啊。

image

image

@GeekaholicLin 谢谢指正

super.render() & reactDOM.render() 相互之间应该是不矛盾的

目前我对 React 渲染/更新的一点点浅显的了解(请指正、批评),render() 方法中 return 出来的都是 React Element ,这是对所要渲染内容的描述

如果我使用了反向继承,在子类(子组件)中使用了 const a = super.render() 其实本质上 a 也是 React Element

除非我删除相关的 React Element 结构 会导致子组件不被解析

我觉得很费解的是 解析 这个词的概念

希望能多分享一些修改组件库中相关的内容 可以让讨论更加有的放矢 其实我对这个 反向继承 的实际应用几乎没有看到过


感谢关于 props.children 的指正 谢谢

commented

@TomIsion 前边关于React Element的描述是对的,关于解析,文章说的不能保证完整的子组件树被解析 按照我的理解是,使用super.render()返回的值相当于浅复制<MyClassComponent /> 里的具体树结构是无法在当前的render方法拿到的(因为只有在 React 递归解析 MyClassComponent 时才会去看这个组件的具体结构),但是如你所说,不删除的话渲染还是会渲染的。

关于实际应用,我稍微讲一下自己的使用,仅做参考吧,有错误还望指出。
大致情况是,一个组件库提供的多选组件在渲染选中 item 的时候并没有给出自定义的形式,只能传入显示的字段名,而需求是需要根据特定的条件增加错误状态的标红并在鼠标移入时提示错误信息。

处理过程大致如下:

  1. 通过 React 的查找方法(比如判断 type 等)拿到所需节点
  2. 使用 自己构造的 Element 覆盖掉步骤 1 的节点

https://gist.github.com/GeekaholicLin/98e02e566a823c4b351772e1c6406f04
(额..不懂怎么插入代码预览,见谅)

II 的强大是由继承赋予的。this.childRenderMultiSelect 方法里的 this.xxxx 只要继承组件中没有相应的,会沿着继承链寻找,也就是说可以用到父类的某些方法或者属性。this.props比较特殊一点,是由继承组件的 外层传入的 props 以及 defaultProps (继承组件没有 defaultProps 会使用父类的 defaultProps)组成的,大致相当于 Object.assign({}, defaultProps, props)

实际上,上边代码中 this.childRenderMultiSelect 方法里边很大一部分是从原本的组件 copy 过来的,只进行一定的修改(从 isError 部分开始)。

PS: 话说回来,官方推荐组合的形式,所以能不用继承就不用吧。说实话,在 React 里边写继承感觉挺绕的...

@GeekaholicLin 感觉聊到最后其实还是没有办法很准备的给出

反向继承不能保证完整的子组件树被解析

的准确真正的原因,按照你的解释,需要去详细了解下 React 相关的渲染、更新机制


我倒是很喜欢 React 可以提供的继承机制 (比 Vue 只是写对象格式的配置项舒服多了,逃...


总之 感谢探讨 & 分享

commented

@TomIsion 反向继承不能保证完整的子组件树被解析:
1、就是不能更方便直接操作组件的子组件。
2、实际用处: 最常见就是 配合redux 的connect 用装饰器模式+反向继承 去写。这样每个页面就不要写啰里啰嗦的 connect() 了

写了点 demo
不同的写法而已,别太在乎