1. 程式人生 > >對React Hooks的一些思考

對React Hooks的一些思考

React Hooks正式公佈也有一長段時間了,在選擇第一時間接受“總之這就是未來了,你不陪跑也得陪跑”的現實之後,逐漸嘗試著在腦內對一些既有的程式碼基於Hooks去進行重構,也閱讀了不少社群裡的討論。

在社群中,大部分佈道者都提到了諸如“過於冗繁的元件巢狀”、“與內部原理的更親密接觸”、“比原先更細粒度的邏輯組織與複用”等優勢。而在此之外,基於我自己的一些經驗,也在更學院派的維度上有一些見解,權當補充。

總體感受

首先,受Suspence和Hooks的影響,函式元件不再是純粹的資料到檢視的對映這已然成為既定的事實,React團隊決定走上這一條路,那必然已有權衡,也必然不會回頭。對於使用者而言,更為緊要的是在當下思考一下“函式元件將被賦予怎麼樣的全新定義”這一問題。

在我看來,暫先撇開Suspence這個對函式元件影響比較明確的東西之外,在Hooks的加持之下,元件將在原有的“輸入到輸出的對映邏輯”這一嚴格定義之外,逐漸地追加上“邏輯所依賴的前置條件”這一內容。

const WordCount = () => {
    const [text, onChange] = useState('');

    return (
        <div>
            <TextBox value={text} onChange={onChange} />
            <span>{text.length} characters</span>
        </div>
    );
};

如果除去useState的一行,將textonChange作為元件的props輸入,這一元件就是完美的“輸入到輸出的對映邏輯”,即將2個屬性對映為一組React元素。

而在基於Hooks的實現之下,useState這一行本質上是在聲明後半部分(return)“依賴於一個狀態”這一事實。

除此之外,諸如useContextuseEffect等,其呼叫也都是在一個函式元件中宣告整個渲染過程的前置條件,需要一個Context,或者需要一些副作用。

基於這一考慮,現在的我應該會期望未來函式元件在使用Hooks時,統一將Hooks的呼叫放在函式的開頭部分,隨後緊跟一個純函式元件的渲染邏輯,即一個元件的邏輯拆分為:

const FunctionComponent = props => {
    // 對所有Hooks的呼叫,宣告前置條件

    // 對props及hooks提供的內容的運算處理,資料加工

    // 將資料轉變為JSX並返回
};

我想盡可能地遵守這一順序是能夠指導高度可維護的元件的設計和實現的。當出現需要在第2步之後再呼叫Hooks的場景時,應當立即思考元件的拆分和粒度是否合理,這往往是元件應該進一步拆分成更多細粒度元件的訊號。

狀態管理

React Hooks最為經典的內建函式即useState,它會返回一個Tuple,結構為[value, setValue]

在實際操作中,我們現有使用的自研框架react-kiss(後續再專門寫個文章來介紹)的思路與其幾乎如出一轍:

import {withTransientRegion} from 'react-kiss';

const initialState = {
    text: ''
};

const workflow = {
    onChange(text) {
        return {text};
    }
};

const WordCount = ({text, onChange}) => (
    <div>
        <TextBox value={text} onChange={onChange} />
        <span>{text.length} characters</span>
    </div>
);

export default withTransientRegion(initialState, workflow)(WordCount);

除去我們無法讓狀態本身直接是一個字串,以及API的使用略為繁雜外,其模式並沒有什麼區別。甚至基於withTransientRegion並且沒有使用到react-kiss的非同步能力的元件,都能非常簡單地使用withStatewithReducer進行重構,不費額外的成本。

我認為在useState的衝擊之下,絕大部分“正常”使用React的State的開發者應當反思以下幾個問題:

  1. 為何沒有將“狀態”與“變更狀態的邏輯”兩兩配對,用更好的程式碼結構來組織它們。
  2. 為何沒有將狀態進行更細粒度的拆分,沒有聯動關係的狀態放到不同的元件中單獨管理,而是習慣性地使用一個大的狀態,以及多處setState進行部分狀態的更新。
  3. 為何沒有將狀態的管理與檢視的渲染進行隔離,把一個帶有複雜的render實現的類元件拆分為一個“單純管理狀態的類元件”和一個“實現渲染邏輯的純函式元件”,並讓前者的render方法直接返回後者。

useState正是將以上三個理論合而為一,用一個非常簡潔的API表現出來的經典設計:

  1. valuesetValue配對,後者一定影響前者,前者僅被後者影響,作為一個整體它們完全不受外界的影響。
  2. 在幾乎所有的示例中,都推薦value是一個非常細粒度的值,甚至可以是一個字串之類的原子值(在原本的React中使用非namespace型的物件作為state並不被提倡)。鼓勵在一個函式元件中多次使用useState來得到不同維度的狀態。
  3. 在呼叫useState之外,函式元件依然會是一個實現渲染邏輯的純元件,對狀態的管理已經被Hooks內部所實現。

在我們的團隊中,不定期地會相互強調一個原則:有狀態的元件沒有渲染,有渲染的元件沒有狀態。在現在回過頭來看,這個原則會為我們後續向Hooks的遷移提供非常大的便利。

生命週期

useEffect也是當下聊得最多的Hooks之一。官方在推出這一API時,也告訴了大家一個事實,React團隊將傾向於把componentDidMountcomponentDidUpdate不作區分。

事實上,React團隊早就有了這一傾向,並通過先前版本的API向開發者傳達了這一訊號,那就是用getDerivedStateFromProps替代componentWillReceiveProps

componentWillReceiveProps的時代,元件的state其實根據生命週期階段的不同,是有2個不同的計算方法的:

  1. 在mount之前,推薦在建構函式中通過props引數來計算state,並直接賦值為this.state
  2. 在mount之後,使用componentWillReceiveProps來計算並使用setState進行更新。

getDerivedStateFromProps作為一個靜態方法,根本沒有區別這兩者,它是唯一的“通過props計算state”的入口。甚至為了將其統一,React團隊ref="https://github.com/reactjs/rfcs/pull/6#discussion_r162865372">放棄了將prevProps作為這一方法的引數,在社群造成了不少的討論。而我在與eslint-plugin-react討論規則時有提出一個觀點:

將元件的狀態分為2部分,一部分為自己生成自己管理的自治狀態(owned),另一部分為由props計算得來的衍生狀態(derived)。在初始化狀態時,僅初始化自治狀態,將原生狀態賦為 null,並在 getDerivedStateFromProps中再進行初始化(即便在建構函式中可以完成衍生狀態的計算)。

我至今依然相信上述說法是React團隊通過getDerivedStateFromProps這一API想傳遞給開發者的思想,即不要再區分元件是否已經mount,使用統一的API來統一地進行狀態管理。

而在這一思想的引導下,在我們的產品中,對於最為常見“在生命週期中請求資料”這一場景,我們是這麼處理的:

import {connectOnLifeCycle} from '@baidu/etui-hocs';
import {connect} from 'react-redux';
import {property} from 'lodash';
import {compose} from 'recompose';
import {fetchMessageForUser} from '../../actions';

const Welcome = ({message, username}) => (
    <div>
        Hellow {uesername}: {message}
    </div>
);

const connects = [
    {
        grab: property('message'), // 沒資料的時候準備載入
        selector: property('username'), // 引數變化的時候進行載入
        fetch(username, {onRequestMessage}) {
            return onRequestMessage(username);
        }
    }
];

const mapStateToProps = state => ({username: state.currentUser.username});

const mapDispatchToProps = {
    onRequestMessage: fetchMessageForUser
};

const enhance = compose(
    connect(mapStateToProps, mapDispatchToProps),
    connectOnLifeCycle(connects)
);

export default enhance(Welcome);

可以看到,我們是將與生命週期相關的邏輯交給了connectOnLifeCycle這一HOC,從而保持元件是一個純粹的渲染過程,通過純函式元件的形式實現。而對於connectOnLifeCycle我們並沒有定義mount與update的區別,而是用3個屬性來宣告一個外部的依賴:

  1. 資料在哪裡(grab):當資料有的時候,就沒必要發起請求,這是一個很直接的邏輯。
  2. 資料與什麼相關(selector):可以認為這是“獲取資料的引數”,僅當引數發生變化時,請求才會被重新發起(當然資料不存在依然是前提)。
  3. 怎麼發起請求(fetch):真正的請求邏輯。

對於一個偏向資訊管理的系統,往往我們只要只要關注好這3個內容,就能快速地完成功能模組的開發,而mount還是update完全是不重要的。

工具支援

這是我對React Hooks最為擔憂的一點。雖然社群裡對Hooks的支援普遍來自於“原有的React元件巢狀太深”這一觀點,但是於我而言,一定的元件巢狀實際是有很好的正面作用的。

以我們用於處理邏輯分支的react-whether庫為例,其在最終渲染的元件樹中是這樣的結構,可以通過React Devtools看到:

 

我們能從Devtools裡迅速地看到,元件進入了IfElseMode(相對應的還有一個SwitchMode),且條件沒有命中(matches={false}),最終渲染出了<a>更新</a>這樣的結構。

在我看來,在元件樹上留下適當的資訊,藉助於Devtools進行追蹤,是React開發中很好的一種除錯模式。正如我們經常會用console.log來定位資訊一樣,其本質是將一些過程中短生命週期的資訊(隨著函式呼叫棧而快速消失)固化下來,無論是通過控制檯還是通過元件樹給予開發者除錯時的支援。

對於更復雜的場景,只要邏輯的分層合理,使用HOC或render prop形式進行巢狀,在元件樹中可以讓開發者快速地通過檢查每一層的props快速將問題定位縮小至很小的一個範圍內,隨後基於純函式的高可測性來進行問題的復現和修復,同時能夠固化為單元測試的用例。

而Hooks的誕生,無疑在釋放一種訊號,讓開發者通過一個函式元件,使用不同的hook宣告其前置依賴,將這些資訊都壓縮在一起,並且(至少當前)在Devtools中將不會保留任何痕跡。這樣的結果是,也許在開發的過程中是非常痛快而流暢的,但凡遇到線上相對不易復現的BUG,其跟蹤和除錯過程將表現出成倍的痛苦。

API設計

我一直認為,React的API設計是非常強力的,甚至在我見過的各種團隊之中,僅有微軟能在API設計上說穩穩地強過React。React的API設計完全是語言級的模式,而不是簡簡單單的框架的玩法。這重點體現在幾個方面:

  1. 對API的廢棄有一整套成熟的流程。並不會在一個大版本中就立即拋棄N多的API,而是通過標記UNSAFE_、使用StrictMode等形式,給開發者一個平滑的過渡。這種跨越大版本的API廢棄模式,我只在語言級框架如.NET、Java中見過,尚未在任何一個檢視層的框架中見識。
  2. 不該用而又不得不存在的API,會用盡辦法噁心呼叫者。最為經典的dangerouslySetInnerHTML這一屬性,除了加一個dangerously這麼顯眼的字首外,還非得讓它的值是一個物件,物件裡還要有一個__html這樣逼死強迫症的屬性名。凡是對程式碼美感有一些些追求的開發者,都會想方設法讓這東西消失在自己的眼前。
  3. 所有的API都具備非常強的最佳實踐引導性。比如getDerivedStateFromProps這一方法,硬是給做成了靜態的,讓開發者用不到this,也自然很難胡亂地在裡面做出有副作用的事情來。現比如setState使用callback而不是Promise,並且官方義正言辭地拒絕了轉為Promise實現。現在看到useState的出現,再回過頭去看setState一直控制著讓開發者儘量不使用callback是多麼明智。
  4. API的釋出具有很好的承接性。先是componentDidCatch這一API,提供了Error Boundary的概念並讓廣大開發者接受,隨後才是Suspence這個非常具備破壞力的炸彈,實際則是通過Error Bounday和Promise的型別判斷來完成。這給了開發者接受和準備的時間,不至於因為一下子連帶的太多概念而產生更強烈的抵觸情緒。
  5. 完全突破原有理論的API可以在沒有大版本的情況下發布。從Suspence到Hooks,這兩個API的釋出確實給了我很大的震憾,而這震憾有很大一部分來自於它們居然只在一個小版本上就釋出出來了,可見React原本簡潔的API有著多大的包容性。

正是因為有這麼強大的API設計能力,React在引入Fiber這種幾乎破壞性的底層變化之際,幾乎沒有在社群掀起反對的波浪。事實上有很多對React使用不當的場合是沒有辦法無縫地遷移到非同步Fiber之上的,而15版本的React本身是可以搞出很多使用不當的場合的。依靠著自身API強大的最佳實踐引導能力,Fiber的推進到開發者的適配幾乎沒有出現過大的失敗案例,這實屬不易。

原文https://zhuanlan.zhihu.com/p/48264713