Airing

Airing

哲学系学生 / 小学教师 / 程序员,个人网站: ursb.me
github
email
zhihu
medium
tg_channel
twitter_id

React Hooks 源碼解析(2): 組件邏輯復用與擴展

React 源碼版本: v16.9.0
源碼註釋筆記:airingursb/react

如何重用和擴展 React 組件的狀態邏輯?具體而言,有以下五種方案:

  1. Mixins
  2. Class Inheritance
  3. Higher-Order Component
  4. Render Props
  5. React Hooks

下面,我們一一介紹五種方案的實現。

1. Mixins#

Mixins

Mixins 混合,其將一個對象的屬性拷貝到另一個對象上面去,其實就是對象的融合,它的出現主要就是為了解決代碼重用問題。

擴展:說到對象融合,Object.assign 也是常用的方法,它跟 Mixins 有一個重大的區別在於 Mixins 會把原型鏈上的屬性一並複製過去(因為for...in),而 Object.assign 則不會。

由於現在 React 已經不再支持 Mixin 了,所以本文不再贅述其如何使用。至於以前在 React 中如何使用 Mixin,請參考這篇文章:React Mixin 的使用 | segmentfault

Mixins 雖然能解決代碼重用的問題,但是其會產生許多問題,甚至弊大於利,由此 React 現在已經不支持 Mixins 了。具體而言,有以下幾個缺點:

  1. 代碼過於耦合:Mixins 引入了隱藏的依賴關係,代碼之間可能會相互依賴,相互耦合,不利於代碼維護。
  2. 名稱相同的 Mixin 不可以同時使用:比如 FluxListenerMixin 定義 handleChange()WindowSizeMixin 定義handleChange(),則不能同時使用它們,甚至我們也無法在自己的組件上定義具有此名稱的方法。
  3. 雪球效應的複雜度: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,即高階組件。雖然名字很高級,但其實和高階函數一樣並沒有什麼神奇的地方。

回顧一下高階函數的定義:

  1. 函數可以作為參數被傳遞
  2. 函數可以作為返回值輸出

其實高階組件也就是一個函數,且該函數接受一個組件作為參數,並返回一個新的組件。需要注意的是高階組件是一個函數,而不是一個組件。可見 HOC 其實就是一個裝飾器,因此也可以使用 ES 7 中的裝飾器語法,而本文為了代碼的直觀性就不使用裝飾器語法了。

擴展閱讀:裝飾器提案 proposal-decorators | GitHub

image

高階組件也有兩種實現:

  1. 繼承式的 HOC:即反向繼承 Inheritance Inversion
  2. 代理式的 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 更加簡單,接下來舉個例子來看看,該案例具體的項目代碼可以點下面按鈕進入調試:

Edit HOC

image

這裡有兩個組件 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);

Redux connect

另外,還有 antd 的 Form 也是用 HOC 實現的。

const WrappedNormalLoginForm = Form.create()(NormalLoginForm);

雖然 HOC 在組件邏輯重用上提供了很多便利,也有許多項目會使用這種模式,但 HOC 還是存在一些缺點的:

  1. Wrapper Hell,組件層級嵌套過多(Debug 過 Redux 的必然深有體會),這讓調試變得非常困難。
  2. 為了在 Debug 中顯示組件名,需要顯示聲明組件的 displayName
  3. 對 Typescript 類型化不夠友好
  4. 無法完美地使用 ref(注:React 16.3 中提供了 React.forwardRef 可以轉發 ref,解決了這個問題)
  5. 靜態屬性需要手動拷貝:當我們應用 HOC 去增強另一個組件時,我們實際使用的組件已經不是原組件了,所以我們拿不到原組件的任何靜態屬性,我們可以在 HOC 的結尾手動拷貝它們。
  6. 透傳了不相關的 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

image

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 可謂是組件邏輯重用擴展的完美方案。具體而言,有以下優點:

  1. 避免命名衝突:Hook 和 Mixin 在用法上有一定的相似之處,但是 Mixin 引入的邏輯和狀態是可以相互覆蓋的,而多個 Hook 之間互不影響。
  2. 避免 Wrapper Hell:原理類似於回調地獄之於 async + await。
  3. Hooks 擁有 Functional Component 的所有優點(請閱讀該系列第一篇文章),同時若使用 useState、useEffect、useRef 等 Hook 可以在 Functional Component 中使用 State、生命週期和 ref,規避了 Functional Component 固有的缺點。

至於 Hooks 的具體實現,我們下一篇文章中再談。

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。