项目里有三个页面都要做同一件事:进页面先查登录态,没登录跳登录页,登录了再拉数据。第一遍是复制粘贴,第二遍改需求的时候发现有一个页面漏改了。这种「逻辑一样、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: