React元件複用的方式
現前端的工程化越發重要,雖然使用Ctrl+C
與Ctrl+V
同樣能夠完成需求,但是一旦面臨修改那就是一項龐大的任務,於是減少程式碼的拷貝,增加封裝複用能力,實現可維護、可複用的程式碼就變得尤為重要,在React
中元件是程式碼複用的主要單元,基於組合的元件複用機制相當優雅,而對於更細粒度的邏輯(狀態邏輯、行為邏輯等),複用起來卻不那麼容易,很難把狀態邏輯拆出來作為一個可複用的函式或元件,實際上在Hooks
出現之前,都缺少一種簡單直接的元件行為擴充套件方式,對於Mixin
、HOC
、Render Props
都算是在既有(元件機制的)遊戲規則下探索出來的上層模式,一直沒有從根源上很好地解決元件間邏輯複用的問題,直到Hooks
登上舞臺,下面我們就來介紹一下Mixin
、HOC
、Render Props
、Hooks
四種元件間複用的方式。
Mixin
當然React
很久之前就不再建議使用Mixin
作為複用的解決方案,但是現在依舊能通過create-react-class
提供對Mixin
的支援,此外注意在以ES6
的class
方式宣告元件時是不支援Mixin
的。
Mixins
允許多個React
元件之間共享程式碼,它們非常類似於Python
中的mixins
或PHP
中的traits
,Mixin
方案的出現源自一種OOP
直覺,只在早期提供了React.createClass() API
(React v15.5.0
正式廢棄,移至create-react-class
)來定義元件,自然而然地,(類)繼承就成了一種直覺性的嘗試,而在JavaScript
基於原型的擴充套件模式下,類似於繼承的Mixin
方案就成了一個不錯的解決方案,Mixin
主要用來解決生命週期邏輯和狀態邏輯的複用問題,允許從外部擴充套件元件生命週期,在Flux
等模式中尤為重要,但是在不斷實踐中也出現了很多缺陷:
- 元件與
Mixin
之間存在隱式依賴(Mixin
經常依賴元件的特定方法,但在定義元件時並不知道這種依賴關係)。 - 多個
Mixin
之間可能產生衝突(比如定義了相同的state
欄位)。 Mixin
傾向於增加更多狀態,這降低了應用的可預測性,導致複雜度劇增。- 隱式依賴導致依賴關係不透明,維護成本和理解成本迅速攀升。
- 難以快速理解元件行為,需要全盤瞭解所有依賴
Mixin
的擴充套件行為,及其之間的相互影響 - 元件自身的方法和
state
欄位不敢輕易刪改,因為難以確定有沒有Mixin
依賴它。 Mixin
也難以維護,因為Mixin
邏輯最後會被打平合併到一起,很難搞清楚一個Mixin
的輸入輸出。
毫無疑問,這些問題是致命的,所以,Reactv0.13.0
放棄了Mixin
靜態橫切(類似於繼承的複用),轉而走向HOC
高階元件(類似於組合的複用)。
示例
上古版本示例,一個通用的場景是:
一個元件需要定期更新,用setInterval()
做很容易,但當不需要它的時候取消定時器來節省記憶體是非常重要的,React
提供生命週期方法來告知元件建立或銷燬的時間,下面的Mixin
,使用setInterval()
並保證在元件銷燬時清理定時器。
var SetIntervalMixin = {
componentWillMount: function() {
this.intervals = [];
},
setInterval: function() {
this.intervals.push(setInterval.apply(null, arguments));
},
componentWillUnmount: function() {
this.intervals.forEach(clearInterval);
}
};
var TickTock = React.createClass({
mixins: [SetIntervalMixin], // 引用 mixin
getInitialState: function() {
return {seconds: 0};
},
componentDidMount: function() {
this.setInterval(this.tick, 1000); // 呼叫 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")
);
HOC
Mixin
之後,HOC
高階元件擔起重任,成為元件間邏輯複用的推薦方案,高階元件從名字上就透漏出高階的氣息,實際上這個概念應該是源自於JavaScript
的高階函式,高階函式就是接受函式作為輸入或者輸出的函式,可以想到柯里化就是一種高階函式,同樣在React
文件上也給出了高階元件的定義,高階元件是接收元件並返回新元件的函式。具體的意思就是:
高階元件可以看作React
對裝飾模式的一種實現,高階元件就是一個函式,且該函式接受一個元件作為引數,並返回一個新的元件,他會返回一個增強的React
元件,高階元件可以讓我們的程式碼更具有複用性,邏輯性與抽象性,可以對render
方法進行劫持,也可以控制props
與state
等。
對比Mixin
與HOC
,Mixin
是一種混入的模式,在實際使用中Mixin
的作用還是非常強大的,能夠使得我們在多個元件中共用相同的方法,但同樣也會給元件不斷增加新的方法和屬性,元件本身不僅可以感知,甚至需要做相關的處理(例如命名衝突、狀態維護等),一旦混入的模組變多時,整個元件就變的難以維護,Mixin
可能會引入不可見的屬性,例如在渲染元件中使用Mixin
方法,給元件帶來了不可見的屬性props
和狀態state
,並且Mixin
可能會相互依賴,相互耦合,不利於程式碼維護,此外不同的Mixin
中的方法可能會相互衝突。之前React
官方建議使用Mixin
用於解決橫切關注點相關的問題,但由於使用Mixin
可能會產生更多麻煩,所以官方現在推薦使用HOC
。高階元件HOC
屬於函數語言程式設計functional programming
思想,對於被包裹的元件時不會感知到高階元件的存在,而高階元件返回的元件會在原來的元件之上具有功能增強的效果,基於此React
官方推薦使用高階元件。
HOC
雖然沒有那麼多致命問題,但也存在一些小缺陷:
- 擴充套件性限制
: HOC
並不能完全替代Mixin
,一些場景下,Mixin
可以而HOC
做不到,比如PureRenderMixin
,因為HOC
無法從外部訪問子元件的State
,同時通過shouldComponentUpdate
濾掉不必要的更新,因此,React
在支援ES6Class
之後提供了React.PureComponent
來解決這個問題。 Ref
傳遞問題: Ref
被隔斷,Ref
的傳遞問題在層層包裝下相當惱人,函式Ref
能夠緩解一部分(讓HOC
得以獲知節點建立與銷燬),以致於後來有了React.forwardRef API
。WrapperHell: HOC
氾濫,出現WrapperHell
(沒有包一層解決不了的問題,如果有,那就包兩層),多層抽象同樣增加了複雜度和理解成本,這是最關鍵的缺陷,而HOC
模式下沒有很好的解決辦法。
示例
具體而言,高階元件是引數為元件,返回值為新元件的函式,元件是將props
轉換為UI
,而高階元件是將元件轉換為另一個元件。HOC
在React
的第三方庫中很常見,例如Redux
的connect
和Relay
的createFragmentContainer
。
// 高階元件定義
const higherOrderComponent = (WrappedComponent) => {
return class EnhancedComponent extends React.Component {
// ...
render() {
return <WrappedComponent {...this.props} />;
}
};
}
// 普通元件定義
class WrappedComponent extends React.Component{
render(){
//....
}
}
// 返回被高階元件包裝過的增強元件
const EnhancedComponent = higherOrderComponent(WrappedComponent);
在這裡要注意,不要試圖以任何方式在HOC
中修改元件原型,而應該使用組合的方式,通過將元件包裝在容器元件中實現功能。通常情況下,實現高階元件的方式有以下兩種:
- 屬性代理
Props Proxy
。 - 反向繼承
Inheritance Inversion
。
屬性代理
例如我們可以為傳入的元件增加一個儲存中的id
屬性值,通過高階元件我們就可以為這個元件新增一個props
,當然我們也可以對在JSX
中的WrappedComponent
元件中props
進行操作,注意不是操作傳入的WrappedComponent
類,我們不應該直接修改傳入的元件,而可以在組合的過程中對其操作。
const HOC = (WrappedComponent, store) => {
return class EnhancedComponent extends React.Component {
render() {
const newProps = {
id: store.id
}
return <WrappedComponent
{...this.props}
{...newProps}
/>;
}
}
}
我們也可以利用高階元件將新元件的狀態裝入到被包裝元件中,例如我們可以使用高階元件將非受控元件轉化為受控元件。
class WrappedComponent extends React.Component {
render() {
return <input name="name" />;
}
}
const HOC = (WrappedComponent) => {
return class EnhancedComponent extends React.Component {
constructor(props) {
super(props);
this.state = { name: "" };
}
render() {
const newProps = {
value: this.state.name,
onChange: e => this.setState({name: e.target.value}),
}
return <WrappedComponent
{...this.props}
{...newProps}
/>;
}
}
}
或者我們的目的是將其使用其他元件包裹起來用以達成佈局或者是樣式的目的。
const HOC = (WrappedComponent) => {
return class EnhancedComponent extends React.Component {
render() {
return (
<div class="layout">
<WrappedComponent {...this.props} />
</div>
);
}
}
}
反向繼承
反向繼承是指返回的元件去繼承之前的元件,在反向繼承中我們可以做非常多的操作,修改state
、props
甚至是翻轉Element Tree
,反向繼承有一個重要的點,反向繼承不能保證完整的子元件樹被解析,也就是說解析的元素樹中包含了元件(函式型別或者Class
型別),就不能再操作元件的子元件了。
當我們使用反向繼承實現高階元件的時候可以通過渲染劫持來控制渲染,具體是指我們可以有意識地控制WrappedComponent
的渲染過程,從而控制渲染控制的結果,例如我們可以根據部分引數去決定是否渲染元件。
const HOC = (WrappedComponent) => {
return class EnhancedComponent extends WrappedComponent {
render() {
return this.props.isRender && super.render();
}
}
}
甚至我們可以通過重寫的方式劫持原元件的生命週期。
const HOC = (WrappedComponent) => {
return class EnhancedComponent extends WrappedComponent {
componentDidMount(){
// ...
}
render() {
return super.render();
}
}
}
由於實際上是繼承關係,我們可以去讀取元件的props
和state
,如果有必要的話,甚至可以修改增加、修改和刪除props
和state
,當然前提是修改帶來的風險需要你自己來控制。在一些情況下,我們可能需要為高階屬性傳入一些引數,那我們就可以通過柯里化的形式傳入引數,配合高階元件可以完成對元件的類似於閉包的操作。
const HOCFactoryFactory = (params) => {
// 此處操作params
return (WrappedComponent) => {
return class EnhancedComponent extends WrappedComponent {
render() {
return params.isRender && this.props.isRender && super.render();
}
}
}
}
注意
不要改變原始元件
不要試圖在HOC
中修改元件原型,或以其他方式改變它。
function logProps(InputComponent) {
InputComponent.prototype.componentDidUpdate = function(prevProps) {
console.log("Current props: ", this.props);
console.log("Previous props: ", prevProps);
};
// 返回原始的 input 元件,其已經被修改。
return InputComponent;
}
// 每次呼叫 logProps 時,增強元件都會有 log 輸出。
const EnhancedComponent = logProps(InputComponent);
這樣做會產生一些不良後果,其一是輸入元件再也無法像HOC
增強之前那樣使用了,更嚴重的是,如果你再用另一個同樣會修改componentDidUpdate
的HOC
增強它,那麼前面的HOC
就會失效,同時這個HOC
也無法應用於沒有生命週期的函式元件。
修改傳入元件的HOC
是一種糟糕的抽象方式,呼叫者必須知道他們是如何實現的,以避免與其他HOC
發生衝突。HOC
不應該修改傳入元件,而應該使用組合的方式,通過將元件包裝在容器元件中實現功能。
function logProps(WrappedComponent) {
return class extends React.Component {
componentDidUpdate(prevProps) {
console.log("Current props: ", this.props);
console.log("Previous props: ", prevProps);
}
render() {
// 將 input 元件包裝在容器中,而不對其進行修改,Nice!
return <WrappedComponent {...this.props} />;
}
}
}
過濾props
HOC
為元件新增特性,自身不應該大幅改變約定,HOC
返回的元件與原元件應保持類似的介面。HOC
應該透傳與自身無關的props
,大多數HOC
都應該包含一個類似於下面的render
方法。
render() {
// 過濾掉額外的 props,且不要進行透傳
const { extraProp, ...passThroughProps } = this.props;
// 將 props 注入到被包裝的元件中。
// 通常為 state 的值或者例項方法。
const injectedProp = someStateOrInstanceMethod;
// 將 props 傳遞給被包裝元件
return (
<WrappedComponent
injectedProp={injectedProp}
{...passThroughProps}
/>
);
}
最大化可組合性
並不是所有的HOC
都一樣,有時候它僅接受一個引數,也就是被包裹的元件。
const NavbarWithRouter = withRouter(Navbar);
HOC
通常可以接收多個引數,比如在Relay
中HOC
額外接收了一個配置物件用於指定元件的資料依賴。
const CommentWithRelay = Relay.createContainer(Comment, config);
最常見的HOC
簽名如下,connect
是一個返回高階元件的高階函式。
// React Redux 的 `connect` 函式
const ConnectedComment = connect(commentSelector, commentActions)(CommentList);
// connect 是一個函式,它的返回值為另外一個函式。
const enhance = connect(commentListSelector, commentListActions);
// 返回值為 HOC,它會返回已經連線 Redux store 的元件
const ConnectedComment = enhance(CommentList);
這種形式可能看起來令人困惑或不必要,但它有一個有用的屬性,像connect
函式返回的單引數HOC
具有簽名Component => Component
,輸出型別與輸入型別相同的函式很容易組合在一起。同樣的屬性也允許connect
和其他HOC
承擔裝飾器的角色。此外許多第三方庫都提供了compose
工具函式,包括lodash
、Redux
和Ramda
。
const EnhancedComponent = withRouter(connect(commentSelector)(WrappedComponent))
// 你可以編寫組合工具函式
// compose(f, g, h) 等同於 (...args) => f(g(h(...args)))
const enhance = compose(
// 這些都是單引數的 HOC
withRouter,
connect(commentSelector)
)
const EnhancedComponent = enhance(WrappedComponent)
不要在render方法中使用HOC
React
的diff
演算法使用元件標識來確定它是應該更新現有子樹還是將其丟棄並掛載新子樹,如果從render
返回的元件與前一個渲染中的元件相同===
,則React
通過將子樹與新子樹進行區分來遞迴更新子樹,如果它們不相等,則完全解除安裝前一個子樹。
通常在使用的時候不需要考慮這點,但對HOC
來說這一點很重要,因為這代表著你不應在元件的render
方法中對一個元件應用HOC
。
render() {
// 每次呼叫 render 函式都會建立一個新的 EnhancedComponent
// EnhancedComponent1 !== EnhancedComponent2
const EnhancedComponent = enhance(MyComponent);
// 這將導致子樹每次渲染都會進行解除安裝,和重新掛載的操作!
return <EnhancedComponent />;
}
這不僅僅是效能問題,重新掛載元件會導致該元件及其所有子元件的狀態丟失,如果在元件之外建立HOC
,這樣一來元件只會建立一次。因此每次render
時都會是同一個元件,一般來說,這跟你的預期表現是一致的。在極少數情況下,你需要動態呼叫HOC
,你可以在元件的生命週期方法或其建構函式中進行呼叫。
務必複製靜態方法
有時在React
元件上定義靜態方法很有用,例如Relay
容器暴露了一個靜態方法getFragment
以方便組合GraphQL
片段。但是當你將HOC
應用於元件時,原始元件將使用容器元件進行包裝,這意味著新元件沒有原始元件的任何靜態方法。
// 定義靜態函式
WrappedComponent.staticMethod = function() {/*...*/}
// 現在使用 HOC
const EnhancedComponent = enhance(WrappedComponent);
// 增強元件沒有 staticMethod
typeof EnhancedComponent.staticMethod === "undefined" // true
為了解決這個問題,你可以在返回之前把這些方法拷貝到容器元件上。
function enhance(WrappedComponent) {
class Enhance extends React.Component {/*...*/}
// 必須準確知道應該拷貝哪些方法 :(
Enhance.staticMethod = WrappedComponent.staticMethod;
return Enhance;
}
但要這樣做,你需要知道哪些方法應該被拷貝,你可以使用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;
}
除了匯出元件,另一個可行的方案是再額外匯出這個靜態方法。
// 使用這種方式代替...
MyComponent.someFunction = someFunction;
export default MyComponent;
// ...單獨匯出該方法...
export { someFunction };
// ...並在要使用的元件中,import 它們
import MyComponent, { someFunction } from "./MyComponent.js";
Refs不會被傳遞
雖然高階元件的約定是將所有props
傳遞給被包裝元件,但這對於refs
並不適用,那是因為ref
實際上並不是一個prop
,就像key
一樣,它是由React
專門處理的。如果將ref
新增到HOC
的返回元件中,則ref
引用指向容器元件,而不是被包裝元件,這個問題可以通過React.forwardRef
這個API
明確地將refs
轉發到內部的元件。。
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;
// 將自定義的 prop 屬性 “forwardedRef” 定義為 ref
return <Component ref={forwardedRef} {...rest} />;
}
}
// 注意 React.forwardRef 回撥的第二個引數 “ref”。
// 我們可以將其作為常規 prop 屬性傳遞給 LogProps,例如 “forwardedRef”
// 然後它就可以被掛載到被 LogProps 包裹的子元件上。
return React.forwardRef((props, ref) => {
return <LogProps {...props} forwardedRef={ref} />;
});
}
Render Props
與HOC
一樣,Render Props
也是一直以來都存在的元老級模式,render props
指在一種React
元件之間使用一個值為函式的props
共享程式碼的簡單技術,具有render props
的元件接收一個函式,該函式返回一個React
元素並呼叫它而不是實現一個自己的渲染邏輯,render props
是一個用於告知元件需要渲染什麼內容的函式props
,也是元件邏輯複用的一種實現方式,簡單來說就是在被複用的元件中,通過一個名為render
(屬性名也可以不是render
,只要值是一個函式即可)的prop
屬性,該屬性是一個函式,這個函式接受一個物件並返回一個子元件,會將這個函式引數中的物件作為props
傳入給新生成的元件,而在使用呼叫者元件這裡,只需要決定這個元件在哪裡渲染以及該以何種邏輯渲染並傳入相關物件即可。
對比HOC
與Render Props
,技術上,二者都基於元件組合機制,Render Props
擁有與HOC
一樣的擴充套件能力,稱之為Render Props
,並不是說只能用來複用渲染邏輯,而是表示在這種模式下,元件是通過render
()組合起來的,類似於HOC
模式下通過Wrapper
的render
()建立組合關係形式上,二者非常相像,同樣都會產生一層Wrapper
,而實際上Render Props
與HOC
甚至能夠相互轉換。
同樣,Render Props
也會存在一些問題:
- 資料流向更直觀了,子孫元件可以很明確地看到資料來源,但本質上
Render Props
是基於閉包實現的,大量地用於元件的複用將不可避免地引入了callback hell
問題。 - 丟失了元件的上下文,因此沒有
this.props
屬性,不能像HOC
那樣訪問this.props.children
。
示例
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>React</title>
</head>
<body>
<div id="root"></div>
</body>
<script src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
class MouseTracker extends React.Component {
constructor(props) {
super(props);
this.state = { x: 0, y: 0, }
}
handleMouseMove = (event) => {
this.setState({
x: event.clientX,
y: event.clientY
});
}
render() {
return (
<div onMouseMove={this.handleMouseMove}>
{this.props.render(this.state)} {/* Render Props */}
</div>
)
}
}
class MouseLocation extends React.Component {
render() {
return (
<>
<h1>請在此處移動滑鼠</h1>
<p>當前滑鼠的位置是: x:{this.props.mouse.x} y:{this.props.mouse.y}</p>
</>
)
}
}
ReactDOM.render(
<MouseTracker render={mouse => <MouseLocation mouse={mouse} />}></MouseTracker>,
document.getElementById("root")
);
</script>
</html>
Hooks
程式碼複用的解決方案層出不窮,但是整體來說程式碼複用還是很複雜的,這其中很大一部分原因在於細粒度程式碼複用不應該與元件複用捆綁在一起,HOC
、Render Props
等基於元件組合的方案,相當於先把要複用的邏輯包裝成元件,再利用元件複用機制實現邏輯複用,自然就受限於元件複用,因而出現擴充套件能力受限、Ref
隔斷、Wrapper Hell
等問題,那麼我們就需要有一種簡單直接的程式碼複用方式,函式,將可複用邏輯抽離成函式應該是最直接、成本最低的程式碼複用方式,但對於狀態邏輯,仍然需要通過一些抽象模式(如Observable
)才能實現複用,這正是Hooks
的思路,將函式作為最小的程式碼複用單元,同時內建一些模式以簡化狀態邏輯的複用。比起上面提到的其它方案,Hooks
讓元件內邏輯複用不再與元件複用捆綁在一起,是真正在從下層去嘗試解決(元件間)細粒度邏輯的複用問題此外,這種宣告式邏輯複用方案將元件間的顯式資料流與組合思想進一步延伸到了元件內。
檔案Hooks
也並非完美,只是就目前而言,其缺點如下:
- 額外的學習成本,主要在於
Functional Component
與Class Component
之間的比較上。 - 寫法上有限制(不能出現在條件、迴圈中),並且寫法限制增加了重構成本。
- 破壞了
PureComponent
、React.memo
淺比較的效能優化效果,為了取最新的props
和state
,每次render
()都要重新建立事件處函式。 - 在閉包場景可能會引用到舊的
state
、props
值。 - 內部實現上不直觀,依賴一份可變的全域性狀態,不再那麼
pure
。 React.memo
並不能完全替代shouldComponentUpdate
(因為拿不到state change
,只針對props change
)。useState API
設計上不太完美。
示例
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>React</title>
</head>
<body>
<div id="root"></div>
</body>
<script src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
const {useState, useEffect} = React;
function useMouseLocation(location){
return (
<>
<h1>請在此處移動滑鼠</h1>
<p>當前滑鼠的位置是: x:{location.x} y:{location.y}</p>
</>
);
}
function MouseTracker(props){
const [x, setX] = useState(0);
const [y, setY] = useState(0);
function handleMouseMove(event){
setX(event.clientX);
setY(event.clientY);
}
return (
<div onMouseMove={handleMouseMove}>
{useMouseLocation({x, y})}
</div>
)
}
ReactDOM.render(
<MouseTracker/>,
document.getElementById("root")
);
</script>
</html>
每日一題
https://github.com/WindrunnerMax/EveryDay
參考
https://zhuanlan.zhihu.com/p/38136388
https://juejin.cn/post/6844903910470057997
https://juejin.cn/post/6844903850038525959
https://my.oschina.net/u/4663041/blog/4588963
https://zh-hans.reactjs.org/docs/hooks-intro.html
https://zh-hans.reactjs.org/docs/hooks-effect.html
https://react-cn.github.io/react/docs/reusable-components.html
http://www.ayqy.net/blog/react%E7%BB%84%E4%BB%B6%E9%97%B4%E9%80%BB%E8%BE%91%E5%A4%8D%E7%94%A8/