写在前面
React 里,组件是代码复用的主要单元,基于组合的组件复用机制相当优雅。而对于更细粒度的逻辑(状态逻辑、行为逻辑等),复用起来却不那么容易:
Components are the primary unit of code reuse in React, but it’s not always obvious how to share the state or behavior that one component encapsulates to other components that need that same state.
(摘自Use HOCs For Cross-Cutting Concerns)
很难把状态逻辑拆出来作为一个可复用的函数或组件:
However, we often can’t break complex components down any further because the logic is stateful and can’t be extracted to a function or another component.
因为一直以来,都缺少一种简单直接的组件行为扩展方式:
React doesn’t offer a way to “attach” reusable behavior to a component (for example, connecting it to a store).
(摘自It’s hard to reuse stateful logic between components)
等等,HOC 不是扩展方式吗,甚至 Mixin 也行啊?
严格来讲,Mixin、Render Props、HOC 等方案都只能算是在既有(组件机制的)游戏规则下探索出来的上层模式:
To be clear, mixins is an escape hatch to work around reusability limitations in the system. It’s not idiomatic React.
(摘自Proposal for porting React’s Mixin APIs to a generic primitive)
HOCs are not part of the React API, per se. They are a pattern that emerges from React’s compositional nature.
一直没有从根源上很好地解决组件间逻辑复用的问题……直到 Hooks 登上舞台
P.S.Mixin 看似属于下层解决方案(React 提供了内部支持),实际上只是内置了一个mixin()
工具函数,唯一特殊之处是冲突处理策略:
A class can use multiple mixins, but no two mixins can define the same method. Two mixins can, however, implement the same lifecycle method. In this case, each implementation will be invoked one after another.
一.探索
为了进一步复用组件级以下的细粒度逻辑(比如处理横切关注点),探索出了种种方案:
大致过程是这样:
理论基础 | 方案 | 缺陷 |
---|---|---|
Mixin | 组件复杂度陡升,难以理解 | |
声明式优于命令式,组合优于继承 | Higher-Order Components, Render Props | 多重抽象导致Wrapper Hell |
借鉴函数式思想 | Hooks | 写法限制、学习成本等 |
二.Mixin
Mixins allow code to be shared between multiple React components. They are pretty similar to mixins in Python or traits in PHP.
Mixin 方案的出现源自一种 OOP 直觉,虽然 React 本身有些函数式味道,但为了迎合用户习惯,早期只提供了React.createClass() API 来定义组件:
React is influenced by functional programming but it came into the field that was dominated by object-oriented libraries. It was hard for engineers both inside and outside of Facebook to give up on the patterns they were used to.
自然而然地,(类)继承就成了一种直觉性的尝试。而在 JavaScript 基于原型的扩展模式下,类似于继承的Mixin方案就成了首选:
// 定义Mixin
var Mixin1 = {
getMessage: function() {
return 'hello world';
}
};
var Mixin2 = {
componentDidMount: function() {
console.log('Mixin2.componentDidMount()');
}
};
// 用Mixin来增强现有组件
var MyComponent = React.createClass({
mixins: [Mixin1, Mixin2],
render: function() {
return <div>{this.getMessage()}</div>;
}
});
(摘自上古文档react/docs/docs/mixins.md)
Mixin 主要用来解决生命周期逻辑和状态逻辑的复用问题:
It tries to be smart and “merges” lifecycle hooks. If both the component and the several mixins it uses define the componentDidMount lifecycle hook, React will intelligently merge them so that each method will be called. Similarly, several mixins can contribute to the getInitialState result.
允许从外部扩展组件生命周期,在Flux等模式中尤为重要:
It’s absolutely necessary that any component extension mechanism has the access to the component’s lifecycle.
缺陷
但存在诸多缺陷:
组件与 Mixin 之间存在隐式依赖(Mixin 经常依赖组件的特定方法,但在定义组件时并不知道这种依赖关系)
多个 Mixin 之间可能产生冲突(比如定义了相同的
state
字段)Mixin 倾向于增加更多状态,这降低了应用的可预测性(The more state in your application, the harder it is to reason about it.),导致复杂度剧增
隐式依赖导致依赖关系不透明,维护成本和理解成本迅速攀升:
难以快速理解组件行为,需要全盘了解所有依赖 Mixin 的扩展行为,及其之间的相互影响
组价自身的方法和
state
字段不敢轻易删改,因为难以确定有没有 Mixin 依赖它Mixin 也难以维护,因为 Mixin 逻辑最后会被打平合并到一起,很难搞清楚一个 Mixin 的输入输出
毫无疑问,这些问题是致命的
所以,React v0.13.0 放弃了 Mixin(继承),转而走向HOC(组合):
Idiomatic React reusable code should primarily be implemented in terms of composition and not inheritance.
示例
(不考虑 Mixin 方案存在的问题)单从功能上看,Mixin 同样能够完成类似于 HOC 的扩展,例如:
var SetIntervalMixin = {
componentWillMount: function() {
this.intervals = [];
},
setInterval: function() {
this.intervals.push(setInterval.apply(null, arguments));
},
componentWillUnmount: function() {
this.intervals.forEach(clearInterval);
}
};
// 等价于React v15.5.0以下的React.createClass
var createReactClass = require('create-react-class');
var TickTock = createReactClass({
mixins: [SetIntervalMixin], // Use the mixin
getInitialState: function() {
return {seconds: 0};
},
componentDidMount: function() {
this.setInterval(this.tick, 1000); // Call a method on the mixin
},
tick: function() {
this.setState({seconds: this.state.seconds + 1});
},
render: function() {
return (
<p>
React has been running for {this.state.seconds} seconds.
</p>
);
}
});
ReactDOM.render(
<TickTock />,
document.getElementById('example')
);
(摘自Mixins)
P.S.[React v15.5.0]正式废弃React.createClass()
API,移至create-react-class,内置 Mixin 也一同成为历史,具体见React v15.5.0
三.Higher-Order Components
Mixin 之后,HOC 担起重任,成为组件间逻辑复用的推荐方案:
A higher-order component (HOC) is an advanced technique in React for reusing component logic.
但HOC 并不是新秀,早在React.createClass()
时代就已经存在了,因为 HOC 建立在组件组合机制之上,是完完全全的上层模式,不依赖特殊支持
形式上类似于高阶函数,通过包一层组件来扩展行为:
Concretely, A higher-order component is a function that takes a component and returns a new component.
例如:
// 定义高阶组件
var Enhance = ComposedComponent => class extends Component {
constructor() {
this.state = { data: null };
}
componentDidMount() {
this.setState({ data: 'Hello' });
}
render() {
return <ComposedComponent {...this.props} data={this.state.data} />;
}
};
class MyComponent {
render() {
if (!this.data) return <div>Waiting...</div>;
return <div>{this.data}</div>;
}
}
// 用高阶组件来增强普通组件,进而实现逻辑复用
export default Enhance(MyComponent);
理论上,只要接受组件类型参数并返回一个组件的函数都是高阶组件((Component, ...args) => Component
),但为了方便组合,推荐Component => Component
形式的 HOC,通过偏函数应用来传入其它参数,例如:
// React Redux's `connect`
const ConnectedComment = connect(commentSelector, commentActions)(CommentList);
对比 Mixin
HOC 模式下,外层组件通过 Props 影响内层组件的状态,而不是直接改变其 State:
Instead of managing the component’s internal state, it wraps the component and passes some additional props to it.
并且,对于可复用的状态逻辑,这份状态只维护在带状态的高阶组件中(相当于扩展 State 也有了组件作用域),不存在冲突和互相干扰的问题:
// This function takes a component...
function withSubscription(WrappedComponent, selectData) {
// ...and returns another component...
return class extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.state = {
data: selectData(DataSource, props)
};
}
componentDidMount() {
// ... that takes care of the subscription...
DataSource.addChangeListener(this.handleChange);
}
componentWillUnmount() {
DataSource.removeChangeListener(this.handleChange);
}
handleChange() {
this.setState({
data: selectData(DataSource, this.props)
});
}
render() {
// ... and renders the wrapped component with the fresh data!
// Notice that we pass through any additional props
return <WrappedComponent data={this.state.data} {...this.props} />;
}
};
}
最重要的,不同于 Mixin 的打平+合并
,HOC 具有天然的层级结构(组件树结构),这种分解大大降低了复杂度:
This way wrapper’s lifecycle hooks work without any special merging behavior, by the virtue of simple component nesting!
缺陷
HOC 虽然没有那么多致命问题,但也存在一些小缺陷:
扩展性限制:HOC 并不能完全替代 Mixin
Ref 传递问题:Ref 被隔断
Wrapper Hell:HOC 泛滥,出现 Wrapper Hell
扩展能力限制
一些场景下,Mixin 可以而 HOC 做不到,比如PureRenderMixin:
PureRenderMixin implements shouldComponentUpdate, in which it compares the current props and state with the next ones and returns false if the equalities pass.
因为 HOC 无法从外部访问子组件的 State,同时通过shouldComponentUpdate
滤掉不必要的更新。因此,React 在支持 ES6 Class 之后提供了React.PureComponent来解决这个问题
Ref 传递问题
Ref 的传递问题在层层包装下相当恼人,函数 Ref 能够缓解一部分(让 HOC 得以获知节点创建与销毁),以致于后来有了React.forwardRef
API:
function logProps(Component) {
class LogProps extends React.Component {
componentDidUpdate(prevProps) {
console.log('old props:', prevProps);
console.log('new props:', this.props);
}
render() {
const {forwardedRef, ...rest} = this.props;
// Assign the custom prop "forwardedRef" as a ref
return <Component ref={forwardedRef} {...rest} />;
}
}
// Note the second param "ref" provided by React.forwardRef.
// We can pass it along to LogProps as a regular prop, e.g. "forwardedRef"
// And it can then be attached to the Component.
return React.forwardRef((props, ref) => {
return <LogProps {...props} forwardedRef={ref} />;
});
}
(摘自Forwarding refs in higher-order components)
Wrapper Hell
没有包一层解决不了的问题,如果有,那就包两层……
Wrapper Hell 问题紧随而至:
You will likely find a “wrapper hell” of components surrounded by layers of providers, consumers, higher-order components, render props, and other abstractions.
多层抽象同样增加了复杂度和理解成本,这是最关键的缺陷,而 HOC 模式下没有很好的解决办法
四.Render Props
与 HOC 一样,Render Props 也是一直以来都存在的元老级模式:
The term “render prop” refers to a technique for sharing code between React components using a prop whose value is a function.
例如抽离复用光标位置相关渲染逻辑,并通过 Render Props 模式将可复用组件与目标组件组合起来:
class Mouse extends React.Component {
constructor(props) {
super(props);
this.handleMouseMove = this.handleMouseMove.bind(this);
this.state = { x: 0, y: 0 };
}
handleMouseMove(event) {
this.setState({
x: event.clientX,
y: event.clientY
});
}
render() {
return (
<div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
{/*
Instead of providing a static representation of what <Mouse> renders,
use the `render` prop to dynamically determine what to render.
*/}
{this.props.render(this.state)}
</div>
);
}
}
即组件的一部分渲染逻辑由外部通过 Props 提供,其余不变的部分可以复用
类比 HOC
技术上,二者都基于组件组合机制,Render Props 拥有与 HOC 一样的扩展能力
称之为 Render Props,并不是说只能用来复用渲染逻辑:
In fact, any prop that is a function that a component uses to know what to render is technically a “render prop”.
(摘自Using Props Other Than render)
而是表示在这种模式下,组件是通过render()
组合起来的,类似于 HOC 模式下通过 Wrapper 的render()
建立组合关系
形式上,二者非常相像,同样都会产生一层“Wrapper”(EComponent
和RP
):
// HOC定义
const HOC = Component => WrappedComponent;
// HOC使用
const Component;
const EComponent = HOC(Component);
<EComponent />
// Render Props定义
const RP = ComponentWithSpecialProps;
// Render Props使用
const Component;
<RP specialRender={() => <Component />} />
更有意思的是,Render Props 与 HOC 甚至能够相互转换:
function RP2HOC(RP) {
return Component => {
return class extends React.Component {
static displayName = "RP2HOC";
render() {
return (
<RP
specialRender={renderOptions => (
<Component {...this.props} renderOptions={renderOptions} />
)}
/>
);
}
};
};
}
// 用法
const HOC = RP2HOC(RP);
const EComponent = HOC(Component);
function HOC2RP(HOC) {
const RP = class extends React.Component {
static displayName = "HOC2RP";
render() {
return this.props.specialRender();
}
};
return HOC(RP);
}
// 用法
const RP = HOC2RP(HOC);
<RP specialRender={() => <Component />} />
在线 Demo:https://codesandbox.io/embed/hocandrenderprops-0v72k
P.S.视图内容完全一样,但组件树结构差别很大:
可以通过 React DevTools 查看https://0v72k.codesandbox.io/
五.Hooks
HOC、Render Props、组件组合、Ref 传递……代码复用为什么这样复杂?
根本原因在于细粒度代码复用不应该与组件复用捆绑在一起:
Components are more powerful, but they have to render some UI. This makes them inconvenient for sharing non-visual logic. This is how we end up with complex patterns like render props and higher-order components.
HOC、Render Props 等基于组件组合的方案,相当于先把要复用的逻辑包装成组件,再利用组件复用机制实现逻辑复用。自然就受限于组件复用,因而出现扩展能力受限、Ref 隔断、Wrapper Hell……等问题
那么,有没有一种简单直接的代码复用方式?
函数。将可复用逻辑抽离成函数应该是最直接、成本最低的代码复用方式:
Functions seem to be a perfect mechanism for code reuse. Moving logic between functions takes the least amount of effort.
但对于状态逻辑,仍然需要通过一些抽象模式(如Observable)才能实现复用:
However, functions can’t have local React state inside them. You can’t extract behavior like “watch window size and update the state” or “animate a value over time” from a class component without restructuring your code or introducing an abstraction like Observables.
这正是 Hooks 的思路:将函数作为最小的代码复用单元,同时内置一些模式以简化状态逻辑的复用
例如:
function MyResponsiveComponent() {
const width = useWindowWidth(); // Our custom Hook
return (
<p>Window width is {width}</p>
);
}
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
});
return width;
}
(摘自Making Sense of React Hooks,在线 Demo 见https://codesandbox.io/embed/reac-conf-2018-dan-abramov-hooks-example-mess-around-o5zcu)
声明式状态逻辑(const width = useWindowWidth()
),语义非常自然
对比其它方案
比起上面提到的其它方案,Hooks 让组件内逻辑复用不再与组件复用捆绑在一起,是真正在从下层去尝试解决(组件间)细粒度逻辑的复用问题
此外,这种声明式逻辑复用方案将组件间的显式数据流与组合思想进一步延伸到了组件内,契合 React 理念:
Hooks apply the React philosophy (explicit data flow and composition) inside a component, rather than just between the components.
缺陷
Hooks 也并非完美,只是就目前而言,其缺点如下:
额外的学习成本(Functional Component 与 Class Component 之间的困惑)
写法上有限制(不能出现在条件、循环中),并且写法限制增加了重构成本
破坏了
PureComponent
、React.memo
浅比较的性能优化效果(为了取最新的props
和state
,每次render()
都要重新创建事件处函数)在闭包场景可能会引用到旧的
state
、props
值内部实现上不直观(依赖一份可变的全局状态,不再那么“纯”)
React.memo
并不能完全替代shouldComponentUpdate
(因为拿不到 state change,只针对 props change)useState
API 设计上不太完美
(摘自Drawbacks)