不優雅的 React Hooks

時至今日,React Hooks 已在 React 生態中大放異彩,席捲了幾乎所有的 React 應用。而其又與 Function Component 以及 Fiber 架構幾近天作之合,在當下,我們好像毫無拒絕它的道理。

誠然,Hooks 解決了 React Mixins 這個老大難的問題,但從它各種奇怪的使用體驗上來說,我認爲現階段的 Hooks 並不是一個好的抽象。

紅臉太常見,也來唱個黑臉,本文將站在一個「挑刺兒」的視角,聊聊我眼中的 React Hooks ~

「奇怪的」規矩

React 官方制定了一些 Hooks 書寫規範用來規避 Bug,但這也恰恰暴露了它存在的問題。

命名

Hooks 並非普通函數,我們一般用use開頭命名,以便與其他函數區分。

但相應地,這也破壞了函數命名的語義。固定的use前綴使 Hooks 很難命名,你既爲useGetState這樣的命名感到困惑,也無法理解useTitle到底是怎麼個use法兒。

相比較而言,以_開頭的私有成員變量和$尾綴的流,則沒有類似的困擾。當然,這只是使用習慣上的差異,並不是什麼大問題。

調用時序

在使用useState的時候,你有沒有過這樣的疑惑:useState雖然每次render() 都會調用,但卻可以爲我保持住 State,如果我寫了很多個,那它怎麼知道我想要的是什麼 State 呢?

const [name, setName] = useState('xiaoming')
console.log('some sentences')
const [age, setAge] = useState(18)

兩次useState只有參數上的區別,而且也沒有語義上的區分(我們僅僅是給返回值賦予了語義),站在 useState的視角,React 怎麼知道我什麼時候想要name而什麼時候又想要age的呢?

以上面的示例代碼來看,爲什麼第 1 行的useState會返回字符串name,而第 3 行會返回數字age呢? 畢竟看起來,我們只是「平平無奇」地調用了兩次useState而已。

答案是「時序」。useState的調用時序決定了結果,也就是,第一次的useState「保存」了 name 的狀態,而第二次「保存」了age的狀態。

// Class Component 中通過字面量聲明與更新 State,無一致性問題
this.setState({
  name: 'xiaoming',  // State 字面量 `name`,`age`
  age: 18,
})

React 簡單粗暴地用「時序」決定了這一切(背後的數據結構是鏈表),這也導致 Hooks 對調用時序的嚴格要求。也就是要避免所有的分支結構,不能讓 Hooks 「時有時無」。

// ❌ 典型錯誤
if (some) {
  const [name, setName] = useState('xiaoming')
}

這種要求完全依賴開發者的經驗抑或是 Lint,而站在一般第三方 Lib 的角度看,這種要求調用時序的 API 設計是極爲罕見的,非常反直覺。

最理想的 API 封裝應當是給開發者認知負擔最小的。好比封裝一個純函數add(),不論開發者是在什麼環境調用、在多麼深的層級調用、用什麼樣的調用時序,只要傳入的參數符合要求,它就可以正常運作,簡單而純粹。

function add(a: number, b: number) {
  return a + b
}

function outer() {
  const m = 123;
  setTimeout(() ={
    request('xx').then((n) ={
      const result = add(m, n)         // 符合直覺的調用:無環境要求
    })
  }, 1e3)
}

可以說「React 確實沒辦法讓 Hooks 不要求環境」,但也不能否認這種方式的怪異。

類似的情況在redux-saga裏也有,開發者很容易寫出下面這種「符合直覺」的代碼,而且怎麼也「看」不出有問題。

import { call } from 'redux-saga/effects'

function* fetch() {
  setTimeout(function() {
    const user = yield call(fetchUser)
    console.log('hi', user)                  // 不會執行到這兒
  }, 1e3)
}

yield call()在 Generator 裏調用,看起來真的很「合理」。但實際上,function*需要 Generator 執行環境,而call也需要redux-saga的執行環境。雙重要求之下,實例代碼自然無法正常運行。

useRef 的「排除萬難」

從本義上來說,useRef其實是 Class Component 時代React.createRef()的等價替代。

官方文檔 [1] 中最開始的示例代碼可以佐證這一點(如下所示,有刪減):

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  return (
    <input ref={inputEl} type="text" />
  );
}

但因爲其實現特殊,也常作他用。

React Hooks 源碼中,useRef僅在 Mount 時期初始化對象,而 Update 時期返回 Mount 時期的結果(memoizedState)。這意味着一次完整的生命週期中,useRef 保留的引用始終不會改變。

而這一特點卻讓它成爲了 Hooks 閉包救星。

「遇事不決,useRef !」(useRef存在許多濫用的情況,本文不多贅述)

每一個 Function 的執行都有與之相應的 Scope,對於面向對象來說,this引用即是連接了所有 Scope 的 Context(當然前提是在同一個 Class 下)。

class Runner {
  runCount = 0

  run() {
    console.log('run')
    this.runCount += 1
  }

  xrun() {
    this.run()
    this.run()
    this.run()
  }

  output() {
    this.xrun()
    // 即便是「間接調用」`run`,這裏「仍然」能獲取 `run` 的執行信息
    console.log(this.runCount) // 3
  }
}

在 React Hooks 中,每一次的 Render 由彼時的 State 決定,Render 完成 Context 即刷新。優雅的 UI 渲染,乾淨而利落。

useRef多少違背了設計者的初衷, useRef可以橫跨多次 Render 生成的 Scope,它能保留下已執行的渲染邏輯,卻也能使已渲染的 Context 得不到釋放,威力無窮卻也作惡多端。

而如果說 this引用是面向對象中最主要的副作用,那麼 useRef亦同。從這一點來說,擁有 useRef寫法的 Function Component 註定難以達成「函數式」。

小心使用

有缺陷的生命週期

構造時

Class Component 和 Function Component 之間還有一個很大的「Bug」,Class Component 僅實例化一次後續僅執行 render() ,而 Function Component 卻是在不斷執行自身。

這導致 Function Component 相較 Class Component 實際缺失了對應的constructor構造時。當然如果你有辦法只讓 Function 裏的某段邏輯只執行一遍,倒是也可以模擬出constructor

// 比如使用 useRef 來構造
function useConstructor(callback) {
  const init = useRef(true)
  if (init.current) {
    callback()
    init.current = false
  }
}

生命週期而言, constructor 不能類同 useEffect ,如果實際節點渲染時長較長,二者會有很大時差。

也就是說,Class Component 和 Function Component 的生命週期 API 並不能完全一一對應,這是一個很引發錯誤的地方。

設計混亂的 useEffect

在瞭解useEffect的基本用法後,加上對其字面意思的理解(監聽副作用),你會誤以爲它等同於 Watcher。

useEffect(() ={
  // watch 到 `a` 的變化
  doSomething4A()
}[a])

但很快你就會發現不對勁,如果變量a未能觸發 re-render,監聽並不會生效。也就是說,實際還是應該用於監聽 State 的變化,即useStateEffect。但參數deps卻並未限制僅輸入 State。如果不是爲了某些特殊動作,很難不讓人認爲是設計缺陷。

const [a] = useState(0)
const [b] = useState(0)

useEffect(() ={
    // 假定此處爲 `a` 的監聽
}[a])

useEffect(() ={
    // 假定此處爲 `b` 的監聽
  // 實際即便 `b` 未變化也並未監聽 `a`,但此處仍然因爲會因爲 `a` 變化而執行
}[b, Date.now()])        // 因爲 Date.now() 每次都是新的值

useStateEffect的理解也並不到位,因爲useEffect實際還負責了 Mount 的監聽,你需要用「空依賴」來區分 Mount 和 Update。

useEffect(onMount, [])

單一 API 支持的能力越多,也意味着其設計越混亂。複雜的功能不僅考驗開發者的記憶,也難於理解,更容易因錯誤理解而引發故障。

useCallback

性能問題?

在 Class Component 中我們常常把函數綁在this上,保持其的唯一引用,以減少子組件不必要的重渲染。

class App {
  constructor() {
    // 方法一
    this.onClick = this.onClick.bind(this)
  }
  onClick() {
    console.log('I am `onClick`')
  }

  // 方法二
  onChange = () ={}

  render() {
    return (
      <Sub onClick={this.onClick} onChange={this.onChange} />
    )
  }
}

在 Function Component 中對應的方案即 useCallback

// ✅ 有效優化
function App() {
  const onClick = useCallback(() ={
    console.log('I am `onClick`')
  }[])

  return (<Sub onClick={onClick} />)
}

// ❌ 錯誤示範,`onClick` 在每次 Render 中都是全新的,<Sub> 會因此重渲染
function App() {
  // ... some states
  const onClick = () ={
    console.log('I am `onClick`')
  }

  return (<Sub onClick={onClick} />)
}

useCallback可以在多次重渲染中仍然保持函數的引用, 第2行的onClick也始終是同一個,從而避免了子組件<Sub>的重渲染。

useCallback源碼其實也很簡單:

Mount 時期僅保存 callback 及其依賴數組

Update 時期判斷如果依賴數組一致,則返回上次的 callback

順便再看看useMemo 的實現,其實它與 useCallback 的區別僅僅是多一步 Invoke:

無限套娃✓[2]

相比較未使用useCallback帶來的性能問題,真正麻煩的是useCallback帶來的引用依賴問題。

// 當你決定引入 `useCallback` 來解決重複渲染問題
function App() {
  // 請求 A 所需要的參數
  const [a1, setA1] = useState('')
  const [a2, setA2] = useState('')
  // 請求 B 所需要的參數
  const [b1, setB1] = useState('')
  const [b2, setB2] = useState('')

  // 請求 A,並處理返回結果
  const reqA = useCallback(() ={
    requestA(a1, a2)
  }[a1, a2])

  // 請求 A、B,並處理返回結果
  const reqB = useCallback(() ={
    reqA()                                           // `reqA`的引用始終是最開始的那個,
    requestB(b1, b2)                       // 當`a1`,`a2`變化後`reqB`中的`reqA`其實是過時的。
  }, [b1, b2])                   // 當然,把`reqA`加到`reqB`的依賴數組裏不就好了?
                                                             // 但你在調用`reqA`這個函數的時候,
                                                                 // 你怎麼知道「應該」要加到依賴數組裏呢?
  return (
    <>
      <Comp onClick={reqA}></Comp>
      <Comp onClick={reqB}></Comp>
    </>
  )
}

從上面示例可以看到,當useCallback之前存在依賴關係時,它們的引用維護也變得複雜。調用某個函數時要小心翼翼,你需要考慮它有沒有引用過時的問題,如有遺漏又沒有將其加入依賴數組,就會產生 Bug。

Use-Universal

Hooks 百花齊放的時期誕生了許多工具庫,僅ahooks 就有 62 個自定義 Hooks,真可謂「萬物皆可 use」~ 真的有必要封裝這麼多 Hooks 嗎?又或者說我們真的需要這麼多 Hooks 嗎?

合理封裝?

儘管在 React 文檔中,官方也建議封裝自定義 Hooks 提高邏輯的複用性。但我覺得這也要看情況,並不是所有的生命週期都有必要封裝成 Hooks。

// 1. 封裝前
function App() {
  useEffect(() ={           // `useEffect` 參數不能是 async function
    (async () ={
      await Promise.all([fetchA(), fetchB()])
      await postC()
    })()
  }[])
  return (<div>123</div>)
}
// --------------------------------------------------

// 2. 自定義 Hooks
function App() {
  useABC()
  return (<div>123</div>)
}

function useABC() {
  useEffect(() ={
    (async () ={
      await Promise.all([fetchA(), fetchB()])
      await postC()
    })()
  }[])
}
// --------------------------------------------------
// 3. 傳統封裝
function App() {
  useEffect(() ={
    requestABC()
  }[])
  return (<div>123</div>)
}

async function requestABC() {
  await Promise.all([fetchA(), fetchB()])
  await postC()
}

在上面的代碼中,對生命週期中的邏輯封裝爲 HookuseABC反而使其耦合了生命週期回調,降低了複用性。即便我們的封裝中不包含任何 Hooks,在調用時也僅僅是包一層useEffect而已,不算費事,而且讓這段邏輯也可以在 Hooks 以外的地方使用。

如果自定義 Hooks 中使用到的useEffectuseState總次數不超過 2 次,真的應該想一想這個 Hook 的必要性了,是否可以不封裝。

簡單來說,Hook 要麼「掛靠生命週期」要麼「處理 State」,否則就沒必要。

重複調用

Hook 調用很「反直覺」的就是它會隨重渲染而不停調用,這要求 Hook 開發者要對這種反覆調用有一定預期。

正如上文示例,對請求的封裝,很容易依賴useEffect,畢竟掛靠了生命週期就能確定請求不會反覆調。

function useFetchUser(userInfo) {
  const [user, setUser] = useState(null)
  useEffect(() ={
    fetch(userInfo).then(setUser)
  }[])

  return user
}

但,useEffect真的合適嗎?這個時機如果是DidMount,那執行的時機還是比較晚的,畢竟如果渲染結構複雜、層級過深,DidMount就會很遲。

比如,ul中渲染了 2000 個 li

function App() {
  const start = Date.now()

  useEffect(() ={
    console.log('elapsed:', Date.now() - start, 'ms')
  }[])

  return (
    <ul>
      {Array.from({ length: 2e3 }).map((_, i) =(<li key={i}>{i}</li>))}
    </ul>
  )
}

// output
// elapsed: 242 ms

那不掛靠生命週期,而使用狀態驅動呢?似乎是個好主意,如果狀態有變更,就重新獲取數據,好像很合理。

useEffect(() ={
  fetch(userInfo).then(setUser)
}[userInfo])                   // 請求參數變化時,重新獲取數據

但初次執行時機仍然不理想,還是在DidMount

let start = 0
let f = false

function App() {
  const [id, setId] = useState('123')
  const renderStart = Date.now()

  useEffect(() ={
    const now = Date.now()
    console.log('elapsed from start:', now - start, 'ms')
    console.log('elapsed from render:', now - renderStart, 'ms')
  }[id])                       // 此處監聽 `id` 的變化
  if (!f) {
    f = true
    start = Date.now()
    setTimeout(() ={
      setId('456')
    }, 10)
  }

  return null
}

// output
// elapsed from start: 57 ms
// elapsed from render: 57 ms
// elapsed from start: 67 ms
// elapsed from render: 1 ms

這也是上文爲什麼說useEffect設計混亂,你把它當做 State Watcher 的時候,其實它還暗含了「初次執行在DidMount」的邏輯。從字面意思Effect來看,這個邏輯纔是副作用吧。。。

狀態驅動的封裝除了調用時機以外,其實還有別的問題:

function App() {
  const user = useFetchUser({          // 乍一看似乎沒什麼問題
    name: 'zhang',
    age: 20,
  })

  return (<div>{user?.name}</div>)
}

實際上,組件重渲染會導致請求入參重新計算 -> 字面量聲明的對象每次都是全新的 -> useFetchUser因此不停請求 -> 請求變更了 Hook 內的 State user -> 外層組件<App>重渲染。

這是一個死循環!

當然,你可以用Immutable來解決同一參數重複請求的問題。

useEffect(() ={
  // xxxx
}[ Immutable.Map(userInfo) ])

但總的看來,封裝 Hooks 遠遠不止是變更了你代碼的組織形式而已。比如做數據請求,你可能因此而走上狀態驅動的道路,同時,你也要解決狀態驅動隨之帶來的新麻煩。

爲了 Mixin ?

其實,Mixin 的能力也並非 Hooks 一家獨佔,我們完全可以使用 Decorator 封裝一套 Mixin 機制。也就是說, Hooks 不能依仗 Mixin 能力去力排衆議。

const HelloMixin = {
  componentDidMount() {
    console.log('Hello,')
  }
}

function mixin(Mixin) {
  return function (constructor) {
    return class extends constructor {
      componentDidMount() {
        Mixin.componentDidMount()
        super.componentDidMount()
      }
    }
  }
}

@mixin(HelloMixin)
class Test extends React.PureComponent {
  componentDidMount() {
    console.log('I am Test')
  }

  render() {
    return null
  }
}

render(<Test />) // output: Hello, \n I am Test

不過 Hooks 的組裝能力更強一些,也容易嵌套使用。但需要警惕層數較深的 Hooks,很可能在某個你不知道的角落就潛伏着一個有隱患的useEffect

小結

原文鏈接:https://zhuanlan.zhihu.com/p/455317250?utm_source=ZHShareTargetIDMore&utm_medium=social&utm_oi=28389876432896

參考資料

[1]

官方文檔: https://link.zhihu.com/?target=https%3A//reactjs.org/docs/hooks-reference.html%23useref

[2]

✓: https://link.zhihu.com/?target=https%3A//www.alt-codes.net/check-mark-symbols.php

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/8u10MGdoxh_zvEw1iGz8-Q