寫在前面
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,通過 [偏函數應用](/articles/基礎語法-haskell 筆記 1/#articleHeader11) 來傳入其它參數,例如:
// React Redux 的 `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. 視圖內容完全一樣,但組件樹結構差別很大:
[caption id="attachment_1950" align="alignnone" width="625"]
react hoc to render props[/caption]
可以通過 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](https://medium.com/ @dan_abramov/making-sense-of-react-hooks-fdbde8803889),在線 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) -
useStateAPI 設計上不太完美
(摘自 Drawbacks)
參考資料
-
[Mixins Are Dead. Long Live Composition](https://medium.com/ @dan_abramov/mixins-are-dead-long-live-higher-order-components-94a0d2f9e750)
-
[Making Sense of React Hooks](https://medium.com/ @dan_abramov/making-sense-of-react-hooks-fdbde8803889)
暫無評論,快來發表你的看法吧