React 源碼版本: v16.9.0
源碼註釋筆記:airingursb/react
如何重用和擴展 React 組件的狀態邏輯?具體而言,有以下五種方案:
- Mixins
- Class Inheritance
- Higher-Order Component
- Render Props
- React Hooks
下面,我們一一介紹五種方案的實現。
1. Mixins#
Mixins 混合,其將一個對象的屬性拷貝到另一個對象上面去,其實就是對象的融合,它的出現主要就是為了解決代碼重用問題。
擴展:說到對象融合,
Object.assign
也是常用的方法,它跟 Mixins 有一個重大的區別在於 Mixins 會把原型鏈上的屬性一並複製過去(因為for...in
),而Object.assign
則不會。
由於現在 React 已經不再支持 Mixin 了,所以本文不再贅述其如何使用。至於以前在 React 中如何使用 Mixin,請參考這篇文章:React Mixin 的使用 | segmentfault
Mixins 雖然能解決代碼重用的問題,但是其會產生許多問題,甚至弊大於利,由此 React 現在已經不支持 Mixins 了。具體而言,有以下幾個缺點:
- 代碼過於耦合:Mixins 引入了隱藏的依賴關係,代碼之間可能會相互依賴,相互耦合,不利於代碼維護。
- 名稱相同的 Mixin 不可以同時使用:比如
FluxListenerMixin
定義handleChange()
和WindowSizeMixin
定義handleChange()
,則不能同時使用它們,甚至我們也無法在自己的組件上定義具有此名稱的方法。 - 雪球效應的複雜度:Mixins 數量比較多的時候,組件是可以感知到的,甚至組件代碼中還要為其做相關處理增加 Hack 邏輯,這樣會給代碼造成滾雪球式的複雜性。
2. Class Inheritance#
說到類組件的代碼邏輯重用,熟悉 OOP 的同學肯定第一時間想到了類的繼承,A 組件只要繼承 B 組件就可以重用父類中的方法。但同樣的,我也相信使用 React 的同學不會用繼承的方法去重用組件的邏輯。
這裡主要的考慮是代碼質量問題,如果兩個組件本身業務比較複雜,做成繼承的方式就很不好,閱讀子組件代碼的時候,對於那麼不明就裡、沒有在該組件中聲明的方法還需要跑到去父組件裡去定位,而 React 希望一個組件只專注於一件事。
另外,如果重寫子組件的生命週期,那父組件的生命週期會被覆蓋,這也是我們在開發中不願意看到的。
Facebook 對在 React 中使用繼承這件事 “深惡痛絕”,官網在 Composition vs Inheritance 一文中寫到:“在 Facebook,我們在成百上千個組件中使用 React,我們並沒有發現需要使用繼承來構建組件層次的情況。”
的確,函數式編程和組件式編程思想某種意義上是一致的,它們都是 “組合的藝術”,一個大的函數可以有多個職責單一的函數組合而成。同樣的,組件也是如此。我們做 React 開發時,總是會不停規劃組件,將大組件拆分成子組件,對組件做更細粒度的控制,從而保證組件的純淨性,使得組件的職責更單一、更獨立。組合帶來的好處就是可重用性、可測試性和可預測性。
因此,優先考慮組合,才去考慮繼承,並且 Facebook 在官網的文章中推薦使用 HOC 去實現組件的邏輯重用(詳見《Higher-Order Components》),那下面我們就來看看 HOC 到底是什麼。
3. HOC(Higher-Order Component)#
HOC,Higher-Order Component,即高階組件。雖然名字很高級,但其實和高階函數一樣並沒有什麼神奇的地方。
回顧一下高階函數的定義:
- 函數可以作為參數被傳遞
- 函數可以作為返回值輸出
其實高階組件也就是一個函數,且該函數接受一個組件作為參數,並返回一個新的組件。需要注意的是高階組件是一個函數,而不是一個組件。可見 HOC 其實就是一個裝飾器,因此也可以使用 ES 7 中的裝飾器語法,而本文為了代碼的直觀性就不使用裝飾器語法了。
擴展閱讀:裝飾器提案 proposal-decorators | GitHub
高階組件也有兩種實現:
- 繼承式的 HOC:即反向繼承 Inheritance Inversion
- 代理式的 HOC:即屬性代理 Props Proxy
由於繼承官方不推崇,繼承式的 HOC 可能會原始組件的邏輯而並非簡單的重用和擴展,因此繼承式的 HOC 依然有許多弊端,我們這裡就列一段代碼展示一下,但就不展開講了。
// 繼承式 HOC
import React, { Component } from 'react'
export default const HOC = (WrappedComponent) => class NewComponent extends WrappedComponent {
componentWillMount() {
console.log('這裡會修改原始組件的生命週期')
}
render() {
const element = super.render()
const newProps = { ...this.props, style: { color: 'red' }}
return React.cloneElement(element, newProps, element.props.children)
}
}
可以看到繼承式的 HOC 也確實可以重用和擴展原始組件的邏輯。而代理式的 HOC 更加簡單,接下來舉個例子來看看,該案例具體的項目代碼可以點下面按鈕進入調試:
這裡有兩個組件 Profile 和 Home,兩個組件都被 Container 包裹,且每個 Container 的樣式一樣並且都有一個 title。這裡我們希望 Profile 和 Home 都可以重用 Container 的樣式和結構,現在我們用 HOC 實現一下:
// app.js
import React from "react";
import ReactDOM from "react-dom";
import Profile from "./components/Profile";
import Home from "./components/Home";
import "./styles.css";
function App() {
return (
<div className="App">
<Profile name={"Airing"} />
<Home />
</div>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
// Container.js
import React, { Component } from "react";
import "../styles.css";
export default title => WrappedComponent =>
class Container extends Component {
render() {
return (
<div className="container">
<header className="header">
{title}
</header>
<div>
<WrappedComponent url={"https://me.ursb.me"} {...this.props} />
</div>
</div>
);
}
};
// Profile.js
import React, { Component } from "react";
import WrappedComponent from "./WrappedComponent";
class Profile extends Component {
render() {
return (
<>
<p>作者: {this.props.name}</p>
<p>博客: {this.props.url}</p>
<p>組件 A</p>
</>
);
}
}
export default WrappedComponent("Profile")(Profile);
// Home.js
import React, { Component } from "react";
import WrappedComponent from "./WrappedComponent";
class Home extends Component {
render() {
return (
<>
<p>組件 B</p>
</>
);
}
}
export default WrappedComponent("Home")(Home);
可以發現這裡的 HOC 其實本質上是原始組件的一個代理,在新組件的 render 函數中,將被包裹組件渲染出來,除了 HOC 自己要做的工作,其餘功能全都轉手給了被包裹的組件。
而 Redux 的 connect
函數其實也是 HOC 的一個應用。
ConnectedComment = connect(mapStateToProps, mapDispatchToProps)(Component);
等同於
// connect是一個返回函數的函數(就是個高階函數)
const enhance = connect(mapStateToProps, mapDispatchToProps);
// 返回的函數就是一個高階組件,該高階組件返回一個與Redux store
// 關聯起來的新組件
const ConnectedComment = enhance(Component);
另外,還有 antd 的 Form 也是用 HOC 實現的。
const WrappedNormalLoginForm = Form.create()(NormalLoginForm);
雖然 HOC 在組件邏輯重用上提供了很多便利,也有許多項目會使用這種模式,但 HOC 還是存在一些缺點的:
- Wrapper Hell,組件層級嵌套過多(Debug 過 Redux 的必然深有體會),這讓調試變得非常困難。
- 為了在 Debug 中顯示組件名,需要顯示聲明組件的
displayName
- 對 Typescript 類型化不夠友好
- 無法完美地使用 ref(注:React 16.3 中提供了 React.forwardRef 可以轉發 ref,解決了這個問題)
- 靜態屬性需要手動拷貝:當我們應用 HOC 去增強另一個組件時,我們實際使用的組件已經不是原組件了,所以我們拿不到原組件的任何靜態屬性,我們可以在 HOC 的結尾手動拷貝它們。
- 透傳了不相關的 props:HOC 可以劫持 props,在不遵守約定的情況下可以覆蓋掉透傳的 props。另外,這也導致中間組件也接受了不相關的 props,代碼可讀性變差。
/**
* 使用高階組件,我們可以代理所有的props,但往往特定的HOC只會用到其中的一個或幾個props。
* 我們需要把其他不相關的props透傳給原組件
*/
function visible(WrappedComponent) {
return class extends Component {
render() {
const { visible, ...props } = this.props;
if (visible === false) return null;
return <WrappedComponent {...props} />;
}
}
}
下圖對比了 Mixin 和 HOC 的差異:(圖源:【React 深入】從 Mixin 到 HOC 再到 Hook)
4. Render Props#
Render Props 其實很常見,比如 React Context API:
class App extends React.Component {
render() {
return (
<ThemeProvider>
<ThemeContext.Consumer>
{val => <div>{val}</div>}
</ThemeContext.Consumer>
</ThemeProvider>
)
}
}
React 的 props 並沒有限定類型,它可以是一個函數,於是就有了 render props,這種模式也很常見。它的實現思路很簡單,把原來該放組件的地方,換成了回調,這樣當前組件裡就可以拿到子組件的狀態並使用。
但是,這會產生和 HOC 一樣的 Wrapper Hell 問題。
5. React Hooks#
而以上的問題,使用 Hooks 均可以得到解決,Hooks 可謂是組件邏輯重用擴展的完美方案。具體而言,有以下優點:
- 避免命名衝突:Hook 和 Mixin 在用法上有一定的相似之處,但是 Mixin 引入的邏輯和狀態是可以相互覆蓋的,而多個 Hook 之間互不影響。
- 避免 Wrapper Hell:原理類似於回調地獄之於 async + await。
- Hooks 擁有 Functional Component 的所有優點(請閱讀該系列第一篇文章),同時若使用 useState、useEffect、useRef 等 Hook 可以在 Functional Component 中使用 State、生命週期和 ref,規避了 Functional Component 固有的缺點。
至於 Hooks 的具體實現,我們下一篇文章中再談。