前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

浅析 React 高阶组件 HOC 的两种实现与常见陷阱

首页2018-07-23 00:10:24Front-End
JavaScriptReact高阶组件组件复用

项目里有三个页面都要做同一件事:进页面先查登录态,没登录跳登录页,登录了再拉数据。第一遍是复制粘贴,第二遍改需求的时候发现有一个页面漏改了。这种「逻辑一样、UI 完全不同」的复用需求,用继承解决不了,因为它们的渲染内容毫无共同点;抽成工具函数也不行,因为它要挂在组件的生命周期上。

高阶组件就是 React 给这类问题的答案。这篇把它的两种实现方式讲透,顺带把我在实际项目里踩过的几个坑摊开说,最后聊聊今天还该不该用它。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • 高阶组件到底是什么,它和高阶函数的关系
  • 属性代理的写法,以及为什么它是最常用的那一种
  • 反向继承能做到属性代理做不到的什么事
  • 静态方法丢失、ref 断链、props 命名冲突这三个必踩的坑
  • HOC、render props、Hooks 三者的取舍
  • 这套模式在今天的位置

# 一、高阶组件是什么

高阶组件其实就是一个函数,传入一个组件返回一个新的组件。它接受一个组件作为参数,返回一个新的组件。这个新的组件会使用你传给它的组件作为子组件。

高阶组件的作用其实不言而喻,就是为了组件之间的代码复用。组件可能有着某些相同的逻辑,把这些逻辑抽离出来,放到高阶组件中进行复用。高阶组件内部的包装组件和被包装组件之间通过 props 传递数据。

名字里的「高阶」和函数式编程里的高阶函数是一回事。高阶函数接收函数返回函数,高阶组件接收组件返回组件。签名写出来就很直观:

// 高阶函数
const withLog = fn => (...args) => {
  console.log('调用参数', args)
  return fn(...args)
}

// 高阶组件
const withAuth = Component => props => {
  // 返回一个新组件
}
@前端进阶之旅: 代码已经复制到剪贴板

有一点要先说清楚,HOC 不是 React 提供的 API,你在 React 的文档目录里找不到一个叫 createHOC 的东西。它纯粹是一种约定俗成的写法,是从 React 组件即函数这个特性里自然长出来的模式。这也是为什么不同库里的 HOC 长相各不相同,redux 的 connect、react-router 的 withRouter、antd 的 Form.create,写法细节都有出入,但骨架都是「组件进,组件出」。

# 二、如何实现高阶组件

高阶组件其实就是处理 react 组件的函数。那么我们如何实现一个高阶组件?有两种方法。

# 2.1 属性代理

属性代理是最常见的实现方式,将被处理组件的 props 和新的 props 一起传递给新组件。

export default function withHeader(WrappedComponent) {
  return class HOC extends Component {
    render() {
      return <div>
        <div className="demo-header">
          我是标题
        </div>
        <WrappedComponent {...this.props}/>
      </div>
    }
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

这段代码干的事是:包一层 div,塞一个标题进去,再把外面收到的所有 props 原封不动透传给被包装的组件。{...this.props} 这一行是属性代理的灵魂,少了它,被包装组件就一个 prop 都收不到。

在其他组件里,我们引用这个高阶组件,用来强化它。

@withHeader
export default class Demo extends Component {
  render() {
    return (
      <div>
        我是一个普通组件
      </div>
    );
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

使用 ES6 写法可以更加简洁。

export default(title) => (WrappedComponent) => class HOC extends Component {
  render() {
    return <div>
      <div className="demo-header">
        {title
          ? title
          : '我是标题'}
      </div>
      <WrappedComponent {...this.props}/>
    </div>
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

这个版本多包了一层函数,先收配置再收组件,用的时候是 withHeader('订单详情')(Demo)。这种柯里化的写法在 HOC 里非常普遍,connect(mapStateToProps)(Component) 就是同一个套路。好处是配置和组件分离,withHeader('订单详情') 本身可以被存下来复用。

从代码中看,就是使用 HOC 这个函数,向被处理的组件 WrappedComponent 上面添加一些属性,并返回一个包含原组件的新组件。

属性代理能做的事其实相当宽:注入 props、包裹额外的 DOM 结构、劫持 props 做转换、在自己的生命周期里做订阅和清理、甚至根据条件决定渲不渲染原组件。回到开头那个登录态的需求,用属性代理写出来大概是这样:

function withAuth(WrappedComponent) {
  return class extends Component {
    state = { checked: false }

    componentDidMount() {
      checkLogin().then(ok => {
        if (!ok) return redirectToLogin()
        this.setState({ checked: true })
      })
    }

    render() {
      if (!this.state.checked) return <Loading />
      return <WrappedComponent {...this.props} />
    }
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

三个页面各自 export default withAuth(PageA),逻辑就只有一份了。

顺便说一句上面那个装饰器写法。@withHeader 这种语法在 2018 年需要 Babel 的 legacy decorators 插件才能用,它当时还是一个提案。装饰器提案后来经历过重写,语义和早期的 legacy 版本并不完全一致。老项目里 @connect 这类写法很常见,新项目要不要用,建议先确认你的构建链支持哪一个版本的提案,具体以 Babel 和 TypeScript 的官方文档为准。稳妥的做法是直接写成函数调用 export default withHeader(Demo),没有任何兼容性负担。

# 2.2 反向继承

function HOC(WrappedComponent){
    return class HOC extends WrappedComponent {
        //继承了传入的组件
        test1(){
            return this.test2() + 5;
        }
 
        componentDidMount(){
            console.log('1');
            this.setState({number:2});
        }
 
        render(){
            //使用super调用传入组件的render方法
            return super.render();
        }
    }
}
 
@HOC
class OriginComponent extends Component {
    constructor(props){
        super(props);
        this.state = {number:1}
    }
 
    test2(){
        return 4;
    }
    componentDidMount(){
        console.log('2');
    }
 
    render(){
        return (
            <div>
                {this.state.number}{'and'}
                {this.test1()}
                这是原始组件
            </div>
        )
    }
}
 
//const newComponent = HOC(OriginComponent)
@前端进阶之旅: 代码已经复制到剪贴板

和属性代理最大的区别在第一行:extends WrappedComponent 而不是 extends Component。返回的组件继承了被包装组件,于是它能直接访问对方的 state、props 和实例方法,这是属性代理做不到的。

跑一下这个例子,控制台只会打印 1,不会打印 2。因为子类的 componentDidMount 覆盖了父类的同名方法,父类那个 console.log('2') 根本没被调用。想让两个都执行,得在子类里显式 super.componentDidMount()。这个细节很容易翻车,尤其当被包装组件是别人写的、你不知道它在生命周期里做了什么的时候。

render 里的 super.render() 是反向继承的招牌用法,业内叫渲染劫持。因为你拿到的是原组件 render 的返回值,也就是一棵 React 元素树,可以在返回之前对它做手脚,比如用 React.cloneElement 改属性、根据条件替换某个子节点、或者干脆返回 null 把整个组件藏起来。

不过我得说实话,反向继承在实际项目里用得非常少。原因有几个。它对被包装组件是有侵入性的,你必须知道对方是类组件(函数组件没有 render 方法可以 super,直接就用不了)。渲染劫持能改的也只是 render 返回的元素树,元素树里嵌套的子组件返回了什么,你依然看不到也改不了。再加上继承链一长,this 上的东西来自哪一层就没人说得清了。

React 官方文档里明确推荐组合优于继承,属性代理就是组合。绝大多数场景选属性代理就对了。

# 三、HOC 的三个坑

这几个坑我都实打实撞过,写出来省得你再撞一遍。

# 3.1 静态方法会丢

class Page extends Component { /* ... */ }
Page.fetchData = () => { /* SSR 预取数据 */ }

const Wrapped = withAuth(Page)
Wrapped.fetchData // undefined
@前端进阶之旅: 代码已经复制到剪贴板

属性代理返回的是一个全新的类,原组件挂在类上的静态属性一个都不会跟过来。做服务端渲染时这个坑特别致命,因为 SSR 框架常常靠 Component.fetchData 这类静态方法来预取数据,包一层 HOC 之后数据就没了,页面白屏,还不报错。

解决办法是手动拷贝,或者用 hoist-non-react-statics 这个包自动拷贝所有非 React 内置的静态属性:

import hoistNonReactStatic from 'hoist-non-react-statics'

function withAuth(WrappedComponent) {
  class HOC extends Component { /* ... */ }
  return hoistNonReactStatic(HOC, WrappedComponent)
}
@前端进阶之旅: 代码已经复制到剪贴板

redux 的 connect 内部就用了它,所以 connect 过的组件静态方法是保得住的。

# 3.2 ref 拿不到里面的实例

ref 不是普通的 prop,{...this.props} 展开的时候它不在里面。你在外面给 Wrapped 挂一个 ref,拿到的是 HOC 那一层的实例,而不是你真正想操作的那个组件。

标准解法是 React.forwardRef:

fe
  • 一、高阶组件是什么
  • 二、如何实现高阶组件
    • 2.1 属性代理
    • 2.2 反向继承
  • 三、HOC 的三个坑
    • 3.1 静态方法会丢
    • 3.2 ref 拿不到里面的实例
    • 3.3 props 命名冲突和调试困难
  • 四、今天还用 HOC 吗
  • 总结
  • 参考

← 手写一个迷你版 Redux,从 createStore 到中间件正则回顾总结 →