半熟前端

軟體工程師 / 台灣人 / 在前端的路上一邊探索其他領域的可能性

前端

從 class component 到 hooks

這篇文章並不會一一介紹各個 react-hooks 的 API,而是試著從設計的角度出發,來探討設計背後的原因。主要會分成幾個段落:

  • Function Component 與 Class Component 的差異
  • 如何在元件中複用類似的邏輯
  • 淺談 high-order component、render props、mixin

Function Component 與 Class Component

React Function Component 與 React class component 的差別在哪裡?從功能上來看可以歸納幾點:

  1. Function component 沒有 state (如果不使用 hook 的話)
  2. Function component 沒有 this (這點很重要)
  3. Class component 可以宣告 instance method

1. Function component 沒有 state (如果不使用 hook 的話)

以往寫 Function Component 時,只能靠傳入 props 的方式來操作元件,這沒有什麼不好的,這點毋庸置疑。

這樣寫也很容易測試,但 function component 並沒辦法完全取代 class component,原因在於 class component 可以使用各種生命週期方法,來對元件做更細膩的控制,也給予了更多彈性在內部元件的實作上,state 的機制則是開放了更細膩的操作給元件內部實現更複雜的邏輯。

2. Function component 沒有 this

Function Component 因為是個 function,所以沒有 this,或者說 this 指向的物件並不是 component 本身。這提供了幾個好處,一是語法上的簡潔性,不用再寫像是 this.state.xxx 的冗長語法,也不用煩惱在傳入事件處理器時要根據使用場景來 bind(this)

雖然官方文件寫得很清楚,不要直接操作 state,一律用 setState 的方法來更新狀態,但是在實際開發上,仍然會有一些不看文件、天馬行空的工程師使用不正確的使用方法操作 state。像是直接寫出下面這樣子的程式碼:

this.state.verified = true;

這樣子其實沒辦法讓 React 觸發更新機制,更容易造成其他工程師的誤解,如果遍地都是這樣的程式碼,會讓重構或是改寫的困難度加倍。Function Component 可以直接從語法上避免掉這個問題。雖然要寫出難以維護的代碼不怕找不到方式就是了...。

特別注意的是,雖然 class component 如果沒有使用到生命週期時可以直接轉為 function component,但兩者還是有差別的,最大的差別在於 this 的控制。

由於 props 與 state 都是 immutable 的,在使用 class component 的時候,React 內部的實現會幫你操控 this 指向 component,也就是說 this 是可變的。

在多數情況下這沒有什麼問題,不過一旦牽扯到 setTimeout 或是 setInterval 這些非即時執行的程式碼時就要特別注意了:

function Profile({ userId }) {
  setTimeout(() => {
    fetchUserProfile(userId).then(alert);
  }, 2000);
  
  return ...
}

class Profile extends React.Component {
  componentDidUpdate() {
  	setTimeout(() => {
      getUserProfile(this.props.userId);
    }, 2000);
  }
  
  getProfile() {
    fetchUserProfile(this.props.userId);
  }
  
  render() {
    return ...
  }
}

如果在這兩秒間 userId 改變了會發生什麼事?

你發現了嗎?class component 因為 this 會變化的關係,所以在這 2 秒鐘期間如果改變了 userId,class component 會使用呼叫此方法時的 props,而 function component 則是使用在按鈕點擊時的 props。

在使用 this.props 的時候,context 已經改變了,導致沒有辦法得到正確的結果。你可以宣告變數先把 this.props 存起來,但一樣避免不了根本的問題,如果在 setTimeout 裡的實作又有其他呼叫 props 的方法怎麼辦呢?

相對地,使用 function component 因為沒有 this 的關係,所以我們可以安心地使用 setTimeout 或是 setInterval

不過既然是 class,你就可以實作 instance method 暴露一些方法給外部做操作。

這不一定是件好事(大多時候甚至是件壞事),之後如果想要修改這個方法或是重新命名,就要考量到是否有在其他地方、原件當中直接呼叫這個方法,會讓元件的修改或重構變得更加麻煩。

除非在初期規劃架構時就可以保證這個 method 的通用性,不然很容易導致後面重構時花上更大的工夫。

另外,在 class component 中很難去共用邏輯類似的程式碼。

假設我們想要根據使用者的登入狀態來判斷顯示內容好了。如果希望每個需要根據 isLoggedIn 狀態做顯示的元件都可以直接存取這個狀態,一般會怎麼設計?

第一個方法或許可以考慮將 isLoggedIn 的邏輯跟實作放入 context 當中,每次想要存取 context 就另外使用 Consumer 包住元件。

這會讓整個實作變得冗長,也會多一些跟元件本身實作無關的程式(像是加入 Consumer)

大概會像這樣:

const {Provider, Consumer} = createContext(null);

export default class UserContext extends React.Component {
  state = {
    isLoggedIn: false,
  };

  componentDidMount() {
    fetchUser()
    	.then(res => this.setState({
  			isLoggedIn: true    
      }))
  }

  render() {
    <Provider value={this.state}>
			{this.props.children}
    </Provider> 
  }
}

export Consumer;

App.js

const App = () => <UserContext>
  <Profile />
</UserContext>

Profile.js

import { Consumer } from 'UserContext';
class Profile extends React.Component {
  render() {
    <Consumer>
    	{({ isLoggedIn }) => isLoggedIn ? showProfile() : null}  
    </Consumer>
  }
}

為了保持簡易性與複用性,在當時比較熱門的解決方案有 High Order component 以及 render props,以及更古老的 mixin。

Mixin

先說 Mixin,Mixin 的被棄用的原因很簡單,因為太容易影響元件的內部的實現以及不易修改。元件可能會依賴 mixin 裡頭定義的方法,造成之後修改相當困難,或是在一個 mixin 當中又依賴其他的 mixin。詳細可以參考這篇

當然這並不代表 mixin 就是一個不好的 pattern,只是在 React 當中或許不那麼適合。

High-order component

high-order component 是定義一個函數,可以用 React Component 當作參數來返回一個被包裝過的元件。

最經典的使用場景大概就是 redux 中的 connect 了:

const MyProfile = ({ profile }) => {
};

export default connect(state => ({
  profile: state.profile,
}))(MyProfile);

透過這種方式,可以在函數中自行定義參數,讓整個實現更加優雅,也不用修改元件內部的實作,唯一的 dependencies 在於 props 的接收,但透過 mapStateToProps 就可以很容易地將 props 修改為元件所需要的。

這個函數會回傳一個 React Component,通常實作是像這樣的:

const withWindowSize = (WrappedComponent) => class WindowComponent extends React.Component {
  state = {
    screenSize: window.innerWidth,
  }

  setScreenSize = () => this.setState({ screenSize: window.innerWidth });

  componentDidMount() {
    window.addEventListener('resize', this.setScreenSize);
  }

  componentWillUnmount() {
    window.removeEventListener('resize', this.setScreenSize);
  }

  render() {
    return <WrappedComponent {...this.props} windowSize={this.state.screenSize}  />
  }
}

不過,這種方式或多或少還是需要去了解背後的實作。像是:

像是這樣子的包裝,背後還是要知道,啊原來 withWindowSize 會自己幫我加入 windowSize這個屬性啊。

看似優雅,其實還是免不了要在函數裡頭做一些功夫,也需要額外在函數裡頭定義一個元件、還有修改 displayName 等等。

另外想要搭配其他函數使用時,也會變得相當冗長。雖然你可以用 compose的方式來簡化這個函式,但用起來難免還是有些不便。

withWindowSize(withScroll(withRouter(connect(...

這已經算是相當不錯的解決方案了,卻也造成了初學者在學習時不小的門檻。

Render Props

render props 是將參數包裝成函式的參數暴露給 children,這樣可以讓我們清楚知道傳進來的參數有哪些,也可以自由選擇要不要使用。

react 的 context Consumer 就是用 render props 的方式來取用的。

const ListContainer = ({ list }) => (
  <InfiniteList>
    {this.props.children(list)}
  </InfiniteList>
)

render props 也有一些缺點,你仍然要去元件裡頭查看傳入的參數有哪些,也要考慮如果對方不是傳入函數而是一般的元件的話是否有做對應的處理。

這幾個方案都不錯,但似乎就是有點美中不足。也因此 react-hooks 的概念與實作一推出,馬上造成 React 社群熱烈迴響。

Hooks 的概念在於去除 function component 原本的限制,你可以在 function component 裏頭使用 state、宣告 side-effect、ref、context 等等,以往只能在 class component 使用的東西統統可以用 function component 取代。

這讓開發者可以用更輕量簡潔的方式來組織程式碼,而且因為 hook 只是一個函數,也可以自己封裝邏輯。詳細可以參考 Dan 寫的 Making sense of react hooks

這邊舉一個最簡單的 useStateuseEffect 當作參考:

function useWindowSize() {
  const [windowSize, setWindowSize] = useState(window.innerWidth);
  
  useEffect(() => {
    const setSize = () => {
      setWindowSize(window.innerWidth);
    };

    window.addEventListener('resize', setSize);

    return () => window.removeListener('resize', setSize);
  }, []);
  
  return windowSize;
}

在元件當中,我們可以直接呼叫 useWindowSize 拿取當前的 window.innerWidth

const Layout = () => {
  const windowSize = useWindowSize();
  
  return ...
};

除了更容易知道 windowSize 的來源之外,實作起來也相當直覺,不像 high-order 或 render props 那樣要額外考慮其他情形。

Hooks 倒也不是全部都好,像是必須讓 hooks 調用順序一致來達到正確的 render 結果,為了確保結果又引入了新的 eslint-plugin-react-hooks

Hooks 現在仍然有些地方無法取代 class component,像是 componentDidCatch 或是 getSnapshotBeforeUpdate 等等的方法,使用 render props 時如果父層被 DOM 之類的結構包住,也沒辦法使用 hook 解決。

另外 function component 仍然沒辦法暴露方法給外部使用,只能用傳參數的方式控制,有時並不是那麼方便就是了。

結論

React 的社群夠廣大,才能夠讓核心團隊專注在開發這些功能上,也是因為有經歷過 react mixin,high order component 等各種嘗試性的解決方案,才能走到 Hooks 這個解決方案也說不定。

雖然解決方案看似層出不窮,但背後的原理都是類似的,為了解決某個問題而衍伸出的產物。