1. 程式人生 > >react精華之函式化hooks

react精華之函式化hooks

這一節我們來介紹 Hooks,React v16.7.0-alpha 中第一次引入了 Hooks 的概念,因為這是一個 alpha 版本,不算正式釋出,所以,將來正式釋出時 API 可能會有變化。

Hooks 的目的,簡而言之就是讓開發者不需要再用 class 來實現元件

還記得之前我們介紹的經典 Counter 元件嗎?不考慮用 Redux 或者 Mobx 來管理狀態的話,Counter 元件就需要把計數資料放在 state 裡,要用 state,就意味著需要定義一個 class。

很多時候,一個簡單元件也需要實現一個 class,的確是一件很煩的事,有了 Hooks 之後,事情就簡單多了,我們用幾個已經公開的 Hooks API 來看看如何避免寫 class。

useState

Hooks 會提供一個叫 useState 的方法,它開啟了一扇新的定義 state 的門,對應 Counter 的程式碼可以這麼寫:

import { useState } from 'react';

const Counter = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
       <div>{count}</div>
       <button onClick={() => setCount(count + 1)}>+</button>
       <button onClick={() => setCount(count - 1)}>-</button>
    </div>
  );
};

注意看,Counter 擁有自己的“狀態”,但它只是一個函式,不是 class。

useState 只接受一個引數,也就是 state 的初始值,它返回一個只有兩個元素的陣列,第一個元素就是 state 的值,第二個元素是更新 state 的函式。

我們利用解構賦值(destructuring assignment)把兩個元素分別賦值給 count 和 setCount,相當於這樣的程式碼:

  // 下面程式碼等同於: const [count, setCount] = useState(0);
  const result = useState(0);
  const count = result[0];
  const setCount = result[1];

利用 count 可以讀取到這個 state,利用 setCount 可以更新這個 state,而且我們完全可以控制這兩個變數的命名,只要高興,你完全可以這麼寫:

  const [theCount, updateCount] = useState(0);

因為 useState 在 Counter 這個函式體中,每次 Counter 被渲染的時候,這個 useState 呼叫都會被執行,useState 自己肯定不是一個純函式,因為它要區分第一次呼叫(元件被 mount 時)和後續呼叫(重複渲染時),只有第一次才用得上引數的初始值,而後續的呼叫就返回“記住”的 state 值。

讀者看到這裡,心裡可能會有這樣的疑問:如果元件中多次使用 useState 怎麼辦?React 如何“記住”哪個狀態對應哪個變數?

React 是完全根據 useState 的呼叫順序來“記住”狀態歸屬的,假設元件程式碼如下:

const Counter = () => {
  const [count, setCount] = useState(0);
  const [foo, updateFoo] = useState('foo');
  
  ...
}

每一次 Counter 被渲染,都是第一次 useState 呼叫獲得 count 和 setCount,第二次 useState 呼叫獲得 foo 和 updateFoo(這裡我故意讓命名不用 set 字首,可見函式名可以隨意)。React 是渲染過程中的“上帝”,每一次渲染 Counter 都要由 React 發起,所以它有機會準備好一個記憶體記錄,當開始執行的時候,每一次 useState 呼叫對應記憶體記錄上一個位置,而且是按照順序來記錄的。React 不知道你把 useState 等 Hooks API 返回的結果賦值給什麼變數,但是它也不需要知道,它只需要按照 useState 呼叫順序記錄就好了。

正因為這個原因,Hooks,千萬不要在 if 語句或者 for 迴圈語句中使用!

像下面的程式碼,肯定會出亂子的:

const Counter = () => {
    const [count, setCount] = useState(0);
    if (count % 2 === 0) {
        const [foo, updateFoo] = useState('foo');
    }
    const [bar, updateBar] = useState('bar');
  ...
}

因為條件判斷,讓每次渲染中 useState 的呼叫次序不一致了,於是 React 就錯亂了。

useEffect

除了 useState,React 還提供 useEffect,用於支援元件中增加副作用的支援。

在 React 元件生命週期中如果要做有副作用的操作,程式碼放在哪裡?

當然是放在 componentDidMount 或者 componentDidUpdate 裡,但是這意味著元件必須是一個 class。

在 Counter 元件,如果我們想要在使用者點選“+”或者“-”按鈕之後把計數值體現在網頁標題上,這就是一個修改 DOM 的副作用操作,所以必須把 Counter 寫成 class,而且新增下面的程式碼:

componentDidMount() {
  document.title = `Count: ${this.state.count}`;
}

componentDidMount() {
  document.title = `Count: ${this.state.count}`;
}

而有了 useEffect,我們就不用寫一個 class 了,對應程式碼如下:

import { useState, useEffect } from 'react';

const Counter = () => {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    document.title = `Count: ${this.state.count}`;
  });

  return (
    <div>
       <div>{count}</div>
       <button onClick={() => setCount(count + 1)}>+</button>
       <button onClick={() => setCount(count - 1)}>-</button>
    </div>
  );
};

useEffect 的引數是一個函式,元件每次渲染之後,都會呼叫這個函式引數,這樣就達到了 componentDidMount 和 componentDidUpdate 一樣的效果。

雖然本質上,依然是 componentDidMount 和 componentDidUpdate 兩個生命週期被呼叫,但是現在我們關心的不是 mount 或者 update 過程,而是“after render”事件,useEffect 就是告訴元件在“渲染完”之後做點什麼事。

讀者可能會問,現在把 componentDidMount 和 componentDidUpdate 混在了一起,那假如某個場景下我只在 mount 時做事但 update 不做事,用 useEffect 不就不行了嗎?

其實,用一點小技巧就可以解決。useEffect 還支援第二個可選引數,只有同一 useEffect 的兩次呼叫第二個引數不同時,第一個函式引數才會被呼叫,所以,如果想模擬 componentDidMount,只需要這樣寫:

  useEffect(() => {
    // 這裡只有mount時才被呼叫,相當於componentDidMount
  }, [123]);

在上面的程式碼中,useEffect 的第二個引數是 [123],其實也可以是任何一個常數,因為它永遠不變,所以 useEffect 只在 mount 時呼叫第一個函式引數一次,達到了 componentDidMount 一樣的效果。

useContext

在前面介紹“提供者模式”章節我們介紹過 React 新的 Context API,這個 API 不是完美的,在多個 Context 巢狀的時候尤其麻煩。

比如,一段 JSX 如果既依賴於 ThemeContext 又依賴於 LanguageContext,那麼按照 React Context API 應該這麼寫:

<ThemeContext.Consumer>
    {
        theme => (
            <LanguageContext.Cosumer>
                language => {
                    //可以使用theme和lanugage了
                }
            </LanguageContext.Cosumer>
        )
    }
</ThemeContext.Consumer>

因為 Context API 要用 render props,所以用兩個 Context 就要用兩次 render props,也就用了兩個函式巢狀,這樣的縮格看起來也的確過分了一點點。

使用 Hooks 的 useContext,上面的程式碼可以縮略為下面這樣:

const theme = useContext(ThemeContext);
const language = useContext(LanguageContext);
// 這裡就可以用theme和language了

這個useContext把一個需要很費勁才能理解的 Context API 使用大大簡化,不需要理解render props,直接一個函式呼叫就搞定。

但是,useContext也並不是完美的,它會造成意想不到的重新渲染,我們看一個完整的使用useContext的元件。

const ThemedPage = () => {
    const theme = useContext(ThemeContext);
    
    return (
       <div>
            <Header color={theme.color} />
            <Content color={theme.color}/>
            <Footer color={theme.color}/>
       </div>
    );
};

因為這個元件ThemedPage使用了useContext,它很自然成為了Context的一個消費者,所以,只要Context的值發生了變化,ThemedPage就會被重新渲染,這很自然,因為不重新渲染也就沒辦法重新獲得theme值,但現在有一個大問題,對於ThemedPage來說,實際上只依賴於theme中的color屬性,如果只是theme中的size發生了變化但是color屬性沒有變化,ThemedPage依然會被重新渲染,當然,我們通過給HeaderContentFooter這些元件新增shouldComponentUpdate實現可以減少沒有必要的重新渲染,但是上一層的ThemedPage中的JSX重新渲染是躲不過去了。

說到底,useContext需要一種表達方式告訴React:“我沒有改變,重用上次內容好了。”

希望Hooks正式釋出的時候能夠彌補這一缺陷。

Hooks 帶來的程式碼模式改變

上面我們介紹了 useStateuseEffect 和 useContext 三個最基本的 Hooks,可以感受到,Hooks 將大大簡化使用 React 的程式碼。

首先我們可能不再需要 class了,雖然 React 官方表示 class 型別的元件將繼續支援,但是,業界已經普遍表示會遷移到 Hooks 寫法上,也就是放棄 class,只用函式形式來編寫元件。

對於 useContext,它並沒有為消除 class 做貢獻,卻為消除 render props 模式做了貢獻。很長一段時間,高階元件和 render props 是元件之間共享邏輯的兩個武器,但如同我前面章節介紹的那樣,這兩個武器都不是十全十美的,現在 Hooks 的出現,也預示著高階元件和 render props 可能要被逐步取代。

但讀者朋友,不要覺得之前學習高階元件和 render props 是浪費時間,相反,你只有明白 React 的使用歷史,才能更好地理解 Hooks 的意義。

可以預測,在 Hooks 興起之後,共享程式碼之間邏輯會用函式形式,而且這些函式會以 use- 字首為約定,重用這些邏輯的方式,就是在函式形式元件中呼叫這些 useXXX 函式。

例如,我們可以寫這樣一個共享 Hook useMountLog,用於在 mount 時記錄一個日誌,程式碼如下:

const useMountLog = (name) => {
    useEffect(() => {
        console.log(`${name} mounted`);    
    }, [123]);
}

任何一個函式形式元件都可以直接呼叫這個 useMountLog 獲得這個功能,如下:

const Counter = () => {
    useMountLog('Counter');
    
    ...
}

對了,所有的 Hooks API 都只能在函式型別元件中呼叫,class 型別的元件不能用,從這點看,很顯然,class 型別元件將會走向消亡。