不要再拿組合式?API 與 React?Hooks 來對比了!

在網上能看到很多人在談論 Vue 的組合式 API(Composition API)與 React Hooks 時,將兩者一起拿來比較,并認為隨著這兩者的推出,React 和 Vue 變得越來越像,甚至還引發過一些不大不小的撕逼。

誠然,Vue 的組合式 API 在設計之初受到過 React Hooks 的啟發,且這兩者都可以把邏輯抽象成函數的組合,來實現邏輯的內聚與復用,在視覺上有相似之處。但或許這就是它們所剩無幾的相同點了。

本質上組合式 API 與 React Hooks 的心智模型還是大不相同的,如果將它們混為一談,不但容易引發對兩者的誤解,更可能(基于這份誤解)造成寫出來的代碼中含有不易察覺的 bug。

組合式 API 的動機與實現

組合式 API 的效果用下面這張圖片就可以清楚地表示出來:

在傳統的選項式 API(Options API)中,組件為我們提供了一組 Object 形式的配置對象。我們可以將響應式變量定義在 data 字段,監聽邏輯定義在 watch 字段,各個生命周期想做的事情定義在諸如 mounted 等生命周期字段中。

但真實世界中的一個組件往往由復雜的邏輯交織而成,在組合式 API 中,我們只能把同一邏輯關注點的代碼分散到各個字段中。例如我們需要一個監聽器,但開始監聽和取消監聽就得分別放在 mountedbeforeDestroy 中。隨著組件體積的膨脹,這些代碼邏輯所在的位置愈發割裂,使得開發者在閱讀理解代碼時也愈發困難。

為了解決這個問題,組合式 API 就應運而生了。在組合式 API 中,我們不再要求業務代碼把自己的邏輯分門別類地放置在配置對象中,而是利用依賴倒置的思想,將配置對象的各個字段變為鉤子函數(例如 mounted: someFunconMounted(someFunc) ),提供給業務代碼,使業務代碼能夠將自己鉤入 組件的生命周期等邏輯中。這樣,相同邏輯關注點的代碼就可以被放在一起,從而提高了代碼的內聚性。

一段典型的組合式 API 代碼如下所示(來自官網的例子):

setup(props) {
  const repositories = ref([])
  const getUserRepositories = async () => {
    repositories.value = ... // fetch repositories
  }

  onMounted(getUserRepositories)

  // 在 user prop 的響應式引用上設置一個偵聽器
  watch(user, getUserRepositories)

  return {
    repositories,
    getUserRepositories
  }
}

與它等價的選項式 API 則是:

export default {
  data() {
    return {
      repositories: [],
    }
  },
  watch: {
    user: 'getUserRepositories'
  },
  methods: {
    getUserRepositories() { 
      this.repositories = ... // fetch repositories
    },
  },
  mounted() {
    this.getUserRepositories()
  }
}

雖然這個簡單的例子里兩種寫法看上去差不多,但如果這個組件的功能再復雜些,選項式 API 寫出來的代碼很快就會變成一團亂麻。而組合式 API 中,我們的這段代碼邏輯可以天然地放到一個函數當中,隱藏掉內部實現的細節,使組件整體的代碼保持清爽。

不僅如此,以函數來表示邏輯,還可以實現邏輯的細粒度復用。這也是組合式 API 帶來的另一個好處。在選項式 API 中,雖然可以通過 mixin 的方式實現一定的復用,但存在著變量名沖突、難以處理邏輯之間的依賴關系等種種問題。而通過函數的方式進行邏輯復用,使這些問題迎刃而解,并且沒有任何 magic,可以說是十分自然優雅。

Android Lifecycle API

與其說 Vue 的組合式 API 像 React Hooks,我覺得不如說它更像 Android 的 Lifecycle API 。我挺驚訝居然沒有人把這兩者作對比。說好的大前端呢

雖說 Android 原生并沒有 Vue 核心的響應式數據概念(當然,已經有官方支持的 LiveData 與 DataBinding 這些了更不用說 Jetpack Compose,但這里不展開,不然就沒完沒了了),但 Android Lifecycle API 與 Vue 組合式 API 所要解決的問題 —— 即邏輯關注點分散、混亂的問題 —— 與解決手段,卻是有著異曲同工之妙。

在傳統的 Android 應用開發中,不少邏輯都依賴于生命周期實現。Android 中擁有生命周期的單元是 Activity 和 Fragment ,其他的業務邏輯若是需要感知生命周期,就需要把它們的代碼放到 Activity 和 Fragment 的各個生命周期中,這就導致了代碼膨脹與邏輯關注點分離的問題。

例如我們需要一個感知當前地理位置的功能,一般以如下形式實現:

class MyActivity extends AppCompatActivity {
    private MyLocationListener myLocationListener;

    @Override
    public void onCreate(...) {
        // 可以類比為 Vue 的 created
        myLocationListener = new MyLocationListener(this, (location) -> { ... });
    }

    @Override
    public void onStart() {
        // 可以類比為 Vue 的 mounted
        super.onStart();
        myLocationListener.start();
    }

    @Override
    public void onStop() {
        // 可以類比為 Vue 的 beforeDestroy
        super.onStop();
        myLocationListener.stop();
    }
}

很明顯,這樣的做法與 Vue 的選項式 API 存在同樣的邏輯割裂、組件膨脹等問題。Android 官方也逐漸意識到了這個問題。因此,在它們提供的新的架構組件中,推出了 Lifecycle API。它提供的解決方案我們也不難想到。我們把 Lifecycle 作為一個單獨的概念抽象出來,使之不局限于 Activity 和 Fragment 中。而需要感知生命周期的業務邏輯,可以以回調函數的形式把這些邏輯勾入 相應的生命周期中。

這樣一來,和這個業務單元的所有相關代碼都可以被放在一起,提高了內聚性,且保持了 Activity/Fragment 文件本身邏輯的清晰。另一方面,我們在使用這業務邏輯時也無需關心它究竟在 Activity 還是 Fragment 中,只要存在生命周期就好。這也提升了邏輯的可復用性。

使用 Lifecycle API 實現的例子如下所示:

public class MyObserver implements LifecycleObserver {
    // 可以類比為 Vue 的 onMounted()
    @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
    public void connectListener() {
        ...
    }

    // 可以類比為 Vue 的 onBeforeUnmount()
    @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
    public void disconnectListener() {
        ...
    }
}

// 類比為在 setup 函數中調用你自己的邏輯
myLifecycleOwner.getLifecycle().addObserver(new MyObserver());

對比 Android 與 Vue,可以看到它們所面臨的問題,以及選擇的解決方式,都是極為相似的。

那么 React Hooks 呢?

在 React Hooks 出現之前,我們編寫有狀態組件,只能使用類組件(Class Component)的形式,并在類組件的生命周期方法中編寫相關的邏輯。明顯,這和 Vue 與 Android 中我們面臨的問題是一樣的。我們可以輕易地將上文展現的解決方式遷移到 React 中,為它設計一套新的 API:

class MyComponent extends React.Component {
  // 相當于 Vue 的 setup()
  contructor(props) {
    super(props)
    // 把業務邏輯注冊到生命周期中,以實現邏輯的內聚和可復用
    didMount(() => { ... })
    didUpdate(() => { ... })
  }
  
  render() {
    return ...
  }
}

很容易理解,不是么?可是 React 團隊卻不滿意于此,而是交出了一份不一樣的答卷。他們完全拋棄了類組件的寫法,連帶著拋棄了生命周期的概念。

我們學習一項新技術時,會想怎么把舊的思維方式遷移過去。這不就有人會問:class 的生命周期方法是怎么對應到新的 hooks 上面的呢?于是搜索了一下發現 useEffect 可以通過各種寫法的組合來模擬原來的 componentDidMountcomponentDidUpdatecomponentWillUnmount,然后一邊抱怨怎么起了個這么一個和原來的生命周期完全不像的名字,一邊死記硬背各種組合的用法。

那么為什么 React 團隊放著前面簡單的方式不做,而是采用這種看似如此繞的方式呢?其實 React Hooks 為我們帶來的是一種新的思維方式,只是我們還拿舊的思路去理解它,所以覺得它比較繞。

為了說明這個問題,我們還得從類組件說起。眾所周知,React 采用了函數式編程的理念,通過不可變性來感知狀態變更,從而觸發 UI 更新。無狀態的函數組件是純函數,自然可以無限次數地調用。可對于有狀態的類組件,每次渲染僅僅是調用它的 render 函數,這個組件類的實例在多次渲染之間仍然是同一個,因此在它身上定義的實例變量事實上成為了可變數據。這就使得類組件的工作方式在 React 理念中顯得格格不入,甚至帶來 bug 的風險。

可變數據的典型便是 this.state。雖然理論上我們必須通過 setState 函數來修改 this.state,但這只是為了讓 React 能夠感知到變化并觸發重新渲染。你也完全可以直接對 this.state 賦值,然后手動觸發渲染,或是讓別處觸發的重新渲染使你之前的修改體現在 UI 上。

這樣的行為符合我們長久以來(習慣于面向對象與可變數據)的認知,但有時會給我們帶來麻煩,尤其是在有異步行為的場景中。React 核心開發者 Dan Abramov 在他的文章中舉了一個十分有代表性的例子。例如在社交網站中,我們可以點擊關注一個人的社交帳號,向服務器請求回包后彈窗告知用戶關注成功。

class ProfilePage extends React.Component {
  showMessage = () => {
    alert('Followed ' + this.props.user);
  };

  handleClick = () => {
    setTimeout(this.showMessage, 3000);
  };

  render() {
    return <button onClick={this.handleClick}>Follow</button>;
  }
}

這段代碼存在著 bug,而 bug 的根源就在 this.props 的可變性上。假如我們在 A 的主頁點擊了關注,但在 3 秒中內切換到 B 的主頁,由于 this.props.user 變成了 B,彈窗的內容就成了“Followed B”。但如果我們使用函數組件,就可以天然規避這個問題(注意這個例子并不涉及 Hooks):

function ProfilePage(props) {
  const showMessage = () => {
    alert('Followed ' + props.user);
  };

  const handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return (
    <button onClick={handleClick}>Follow</button>
  );
}

沒有 bug 是因為,作為函數式組件,ProfilePage 本質只是一個普通的函數。當 user 改變時,React 傳入新的 props 重新執行了這個函數。但在上次函數執行過程中聲明的 showMessage 函數,它綁定的 props 的值仍然是上次執行時的值,而不是像類組件那樣,改變了 this.props 的內容。以這個函數的視角來看,props 只是一個普通的對象值罷了,而不是某個實例變量的引用,因此在當次函數執行過程中,所有地方讀到的 props 永遠都是同一個值,即當次渲染傳入的值。

理解了函數組件如何規避可變狀態帶來的問題,就可以理解為何要用 Hooks 取代類組件。通過 Hooks,我們可以讓 state 也和 props 一樣,獲得在組件單次渲染中的不可變性。這也更加貫徹了 React 的函數式、不可變的理念。我們可以把上面的例子用 useState 來改寫,一樣是符合我們預期,沒有 bug 的。

function ProfilePage() {
  const [user, setUser] = useState('A')
  
  const showMessage = () => {
    alert('Followed ' + user);
  };

  const handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return (
    <button onClick={handleClick}>Follow</button>
  );
}

沒有 bug 的原因是,user 變量在單次渲染時實際上就是一個普通的字符串(可以想象把所有的 user 都替換成 'A' )。而倘若通過 setUser 改變了 user 值,也只是觸發下一次對函數的調用,對當次渲染毫無影響。

我們可以以 Vue 的視角審視下這個例子。如果你認為 Vue 的組合式 API 與 React Hooks 相當的話,我們可以用組合式 API 來實現這個例子:

<template>
  <select v-model="user">
    <option>A</option>
    <option>B</option>
  </select>
  <button @click="handleClick">Follow</button>
</template>
<script setup>
import { ref } from 'vue'

const user = ref('A')
const showMessage = () => alert(`Followed ${user.value}`)
const handleClick = () => setTimeout(showMessage, 3000)
</script>

不難看出這段代碼和 React 的類組件實現一樣,是存在 bug 的版本(當然,用選項式 API 也一樣)。但是對于 Vue 來說這樣的結果是符合預期的。因為不像 React 遵循的不可變性理念,Vue 采用的是響應式數據的理念,數據是可變的,這必然要求我們持有響應式數據的引用。

事實上想要使用 React Hooks 完美還原這段 Vue 代碼的能力,我們也要采取類似的做法,通過 useRef Hook 為 user 變量創建一個可以橫跨多次渲染的引用。而想要在 Vue 中規避這個 bug,并沒有完全對應 React 函數組件的做法,只能另想辦法規避了。

useEffect 與 watch

React 的 useEffect Hook 與 Vue 組合式 API 的 watch 方法是一組經常被拿出來比較的概念。它們做的事情類似,都是在各自的依賴發生改變時,執行對應的邏輯。但光看它們的名字卻是大不相同。倘若我們了解了 React Hooks 與 Vue 組合式 API 各自的理念和動機,就不難理解它們為什么不同。

Vue 的 watch API 很容易理解,它對應于原來選項式 API 中的 watch 選項。顧名思義,就是監聽數據的變更。由于 Vue 基于響應式數據的理念,能夠跟蹤所有響應式變量的變更,因此能做到這些并不費吹灰之力。

而對于 React 的 useEffect Hook,上文已經提到,我們不要再用生命周期的思想去類比它,而是從函數式的角度去理解。

在理想情況下,所有渲染函數都是純函數,這樣我們可以放心地無限次調用它們。但是現實顯然沒有這么美好,除了渲染之外往往還要做些別的事情。React 統一把這些事情認為是副作用(Side Effect),這也就是函數名 useEffect 的來歷。

說句題外話,從源碼的角度,React 核心只是維護內部的組件樹。至于把樹的最新狀態同步到 DOM 上這一工作(即 react-dom 的職責),也被認為是一種副作用。

既然是渲染的副作用,那它自然是在每次渲染之后都會調用一次。而它的依賴數組,也只是用來規避重復調用副作用時可能帶來的性能、死循環等問題。在 React 看來,依賴數組只是一個普通的數組,并不非得是真正的依賴。這也是為什么 React 不會為我們自動收集依賴。如果你愿意,你完全可以通過自己編排依賴數組來達到目的。只是大多數時候人腦應付不過來過于復雜的邏輯,因此官方推薦我們誠實地把依賴數組的內容與副作用函數的真正依賴保持一致。

所以 useEffectwatch 這兩者在設計意圖上就完全不同,只是最終殊途同歸而已。網上很多文章在對比組合式 API 與 React Hooks 時只會輕描淡寫地提到一句“前者只執行一次,后者會執行多次”,卻沒有意識到這一區別的背后隱藏著它們設計理念上的巨大差別。

結語

在對比了 Vue 組合式 API 與 React Hooks 之后,我們發現它們并不是像看上去那樣變得逐漸相似,恰恰相反,它們進一步把自己的特點推向了極致。Vue 通過組合式 API 進一步暴露了它的響應式數據能力,使之不再局限于 Vue 實例以內,更便于邏輯組合與復用。而 React 通過 Hooks 彌補了 class 組件中可變數據的隱含問題,進一步貫徹了函數式編程、不可變數據的設計理念。React 和 Vue 實際上變得越來越不同了

而對于我們使用者來說,在使用一項技術前需要讓自己充分理解這一技術的設計理念,順著它的思路來寫代碼。當然,這需要花點時間去學習和適應新的概念,尤其是對于 React Hooks。順著正確的心智模型,才能事半功倍,而不是想當然地把舊的經驗套用在新技術上,等出了問題再高呼真坑。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容