React 进阶 -- 高阶组件 HOC

高阶组件(HOC)是 React 中用于复用组件逻辑的一种高级技巧,是一种基于 React 的组合特性而形成的设计模式。

高阶组件是参数为组件,返回值为新组件的函数。组件是将 props 转换为 UI,而高阶组件是将组件转换为另一个组件。

使用

const EnhancedComponent = higherOrderComponent(WrappedComponent);

以下代码及组件基于官方文档例子补充并扩展:

DataSource数据源

let comments = [{
    id: 0,
    msg: "Hello"
  },
  {
    id: 1,
    msg: "Hello1"
  },
  {
    id: 2,
    msg: "Hello2"
  },
];

let blogPosts = [{
    id: 0,
    msg: "文章1"
  },
  {
    id: 1,
    msg: "文章2"
  },
  {
    id: 2,
    msg: "文章3"
  },
];

let listeners = []; // 存放所有监听事件

const DataSource = {
  getComments() { // 获取评论
    return comments;
  },
  getBlogPosts() { // 获取所有文章
    return blogPosts;
  },
  getBlogPost(id) { // 根据 id 获取文章
    return blogPosts.find(function (blog) {
      return blog.id === id;
    })
  },
  addBlogPost(blog) { // 发布文章
    blogPosts.push(blog);
    DataSource.broadCast(); // 数据源更新,调用 broadCast 函数执行
  },
  updateBlogPost(blog) { // 更新文章
    let hasUpdate = false;
    for (let i = 0; i < blogPosts.length; i++) {
      const curBlog = blogPosts[i];
      if (blog.id === curBlog.id) {
        blogPosts[i] = Object.assign({}, curBlog, blog);
        hasUpdate = true;
      }
    }

    if (hasUpdate) {
      DataSource.broadCast(); // 数据源更新,调用 broadCast 函数执行
    }
  },
  addComment(comment) { // 添加评论
    comments.push(comment);
    DataSource.broadCast(); // 数据源更新,调用 broadCast 函数执行
  },
  addChangeListener(hander) {
    listeners.push(hander); // 添加监听,向 listeners 数组中添加需要执行的函数
  },
  removeChangeListener(hander) { // 移除监听
    let listenersNew = [];
    for (let i = 0; i < listeners.length; i++) {
      if (listeners[i] !== hander) {
        listenersNew.push(listeners[i]);
      }
    }
    listeners = listenersNew; // 移除后覆盖
  },
  broadCast() {
    listeners.map(listener => listener()); // 遍历 listeners 并执行其中的每个监听函数
  }
}

export default DataSource;

CommentList 组件,它订阅外部数据源,用以渲染评论列表:

class Comment extends Component {
  render() {
    return <div>{this.props.comment.msg}</div>;
  }
}

class CommentList extends Component {
  constructor(props) {
    super(props);
    this.state = {
      comments: DataSource.getComments(),
    };
  }

  componentDidMount() {
    // 订阅更改
    DataSource.addChangeListener(this.handleChange);
  }

  componentWillUnmount() {
    // 清除订阅
    DataSource.removeChangeListener(this.handleChange);
  }

  handleChange = () => {
    // 当数据源更新时,更新组件状态
    this.setState({
      comments: DataSource.getComments(),
    });
  };

  handleAddNewComment = () => {
    const id = Date.now();
    DataSource.addComment({ id: id, msg: "新评论" + id });
  };

  render() {
    return (
      <div>
        {this.state.comments.map((comment) => (
          <Comment comment={comment} key={comment.id} />
        ))}
        <button onClick={this.handleAddNewComment}>添加新评论</button> {/*点击按钮添加新评论*/}
      </div>
    );
  }
}
添加新评论

BlogList 组件用于订阅单个博客帖子:

class BlogPost extends Component {
  constructor(props) {
    super(props);
    this.state = {
      blogPost: DataSource.getBlogPost(this.props.id),
    };
  }

  componentDidMount() {
    // 订阅更改
    DataSource.addChangeListener(this.handleChange);
  }

  componentWillUnmount() {
    // 清除订阅
    DataSource.removeChangeListener(this.handleChange);
  }

  handleChange = () => {
    // 当数据源更新时,更新组件状态
    this.setState({
      blogPost: DataSource.getBlogPost(this.props.id),
    });
  };

  handleChangeBlog = () => {
    const id = Date.now();
    const newBlog = Object.assign({}, this.state.blogPost, {
      msg: "修改后的文章" + id,
    });
    DataSource.updateBlogPost(newBlog);
  };

  render() {
    return (
      <div>
        <div>{this.state.blogPost.msg}</div>
        <button onClick={this.handleChangeBlog}>修改文章</button>
      </div>
    );
  }
}

对比 CommentListBlogPost 组件我们可以发现:虽然它们在 DataSource 上调用不同的方法,且渲染不同的结果,但它们的大部分实现都是一样的:

  • 在挂载时,向 DataSource 添加一个更改侦听器。
  • 在侦听器内部,当数据源发生变化时,调用 setState
  • 在卸载时,删除侦听器。

所以,当我们的应用很大时,订阅和调用将会发生很多次,高阶组件正是解决了这个问题。它允许我们在一个地方定义这个逻辑,并在许多组件之间共享它。

对于订阅了 DataSource 的组件,比如 CommentListBlogPost,我们可以编写一个创建组件函数。该函数将接受一个子组件作为它的其中一个参数,该子组件将订阅数据作为 prop。让我们调用函数 withSubscription

const CommentListWithSubscription = withSubscription(
  CommentList,
  (DataSource) => DataSource.getComments()
);

const BlogPostWithSubscription = withSubscription(
  BlogPost,
  (DataSource, props) => DataSource.getBlogPost(props.id)
);
// withSubscription 第一个参数是被包装组件。第二个参数通过 DataSource 和当前的 props 返回我们需要的数据。

需要接收返回数据的通过 props 传递:

/*
    //BlogPost里的 
    state = {
        blogPost: DataSource.getBlogPost(props.id)
    }
    //使用高阶组件
    withSubscription(BlogPost, (DataSource,props) => DataSource.getBlogPost(props.id))
*/

function withSubscription(WrappedComponent, selectData) {
  return class extends React.Component {
    constructor(props) {
      super(props);
      this.state = {
        data: selectData(DataSource, props),
      };
    }

    componentDidMount() {
      // ...负责订阅相关的操作...
      DataSource.addChangeListener(this.handleChange);
    }

    componentWillUnmount() {
      DataSource.removeChangeListener(this.handleChange);
    }

    handleChange = () => {
      this.setState({
        data: selectData(DataSource, this.props),
      });
    };

    render() {
      return <WrappedComponent data={this.state.data} {...this.props} />;
    }
  };
}

完成了抽离后,我们就可以在多个不同的地方复用它:

// 修改后的 CommentList 组件
class CommentList extends Component {
  handleAddNewComment = () => {
    const id = Date.now();
    DataSource.addComment({ id: id, msg: "新评论" + id });
  };

  render() {
    return (
      <div>
        {this.props.data.map((comment) => (
          <Comment comment={comment} key={comment.id} />
        ))}
        <button onClick={this.handleAddNewComment}>添加新评论</button>
      </div>
    );
  }
}

export default withSubscription(CommentList, (DataSource) =>
  DataSource.getComments()
);

// 修改后的 BlogPost 组件
class BlogPost extends Component {
  constructor(props) {
    super(props);
    this.state = {
      data: this.props.data,
    };
  }

  handleChangeBlog = () => {
    const id = Date.now();
    const newBlog = Object.assign({}, this.props.data, {
      msg: "修改后的文章" + id,
    });
    DataSource.updateBlogPost(newBlog);
  };

  render() {
    return (
      <div>
        <div>{this.props.data.msg}</div>
        <button onClick={this.handleChangeBlog}>修改文章</button>
      </div>
    );
  }
}

export default withSubscription(BlogPost, (DataSource, props) =>
  DataSource.getBlogPost(props.id)
);

// App.js
function App() {
  return (
    <div className='App'>
      <CommentList />
      <BlogPost id={0} />
    </div>
  );
}
最终效果

HOC 可以只接收被包裹的组件也可接收多个参数,例如 Redux 中的 connect 函数:

connect(mapStateToProps, mapDispatchToProps)(Component);

不难看出 connect 是一个返回高阶组件的高阶组件,它采用了类似下面的写法:

function connect(mapStateToProps, mapDispatchToProps){
    return function(Component){
        return class extends React.Component {}
    }
}

注意

  1. 不要在 render 方法中使用 HOC

    React 的 diff 算法(称为协调)使用组件标识来确定它是应该更新现有子树还是将其丢弃并挂载新子树。 如果从 render 返回的组件与前一个渲染中的组件相同(===),则 React 通过将子树与新子树进行区分来递归更新子树。 如果它们不相等,则完全卸载前一个子树。

    render() {
      // 每次调用 render 函数都会创建一个新的 EnhancedComponent,既损失性能又会导致该组件及其所有子组件的状态丢失
      // EnhancedComponent1 !== EnhancedComponent2
      const EnhancedComponent = enhance(MyComponent);
      // 这将导致子树每次渲染都会进行卸载,和重新挂载的操作!
      return <EnhancedComponent />;
    }
    
  2. 复制静态方法

    当 React 组件上有静态方法,使用 HOC 包装后,新组件无法拥有原始组件的任何静态方法:

    // 定义静态函数
    WrappedComponent.staticMethod = function() {/*...*/}
    // 现在使用 HOC
    const EnhancedComponent = enhance(WrappedComponent);
    
    // 增强组件没有 staticMethod
    typeof EnhancedComponent.staticMethod === 'undefined' // true
    

    可以使用下面的方法解决:

    // 1.返回之前把这些方法拷贝到容器组件上
    function enhance(WrappedComponent) {
      class Enhance extends React.Component {/*...*/}
      // 必须准确知道应该拷贝哪些方法
      Enhance.staticMethod = WrappedComponent.staticMethod;
      return Enhance;
    }
    
    // 2. 使用 hoist-non-react-statics 自动拷贝所有非 React 静态方法
    import hoistNonReactStatic from 'hoist-non-react-statics';
    function enhance(WrappedComponent) {
      class Enhance extends React.Component {/*...*/}
      hoistNonReactStatic(Enhance, WrappedComponent);
      return Enhance;
    }
    
  3. Refs 无法被传递

    如果将 ref 添加到 HOC 的返回组件中,则 ref 引用指向容器组件,而不是被包装组件。可以通过 React.forwardRef API 解决该问题。