[譯]深入淺出:React Hooks是如何工作的?

翻譯自netlify博客里的一篇文章

Hooks 是在用戶界面中封裝有狀態的行為和副作用(side effects)的一種基礎性的更加簡單的方法。他們被React首次引入 ,已經被其他前端框架如VueSvelte,甚至是通用JS函數式編程框架等廣泛采納。但是,它們函數式的設計需要開發者對JS中的閉包有一個好的理解。

這篇文章,我們通過寫一個小型的克隆版React Hooks來再次介紹閉包。主要有兩個目的——演示閉包的有效用例和向你們展示如何只用29行具備可讀性的JS代碼來寫一套Hooks。最后,我們會介紹自定義Hooks是如何自然地出現的。

?? 注意:你并不需要跟著寫這些代碼。練習寫這些代碼可能對你的JS基礎有一定幫助。別擔心,沒有那么難!

什么是閉包?

使用Hooks的很多賣點之一就是可以避免類組件和高階組件的復雜性。然而,有些人用上Hooks,可能感覺從一個坑掉進了另一個坑。雖然不用再擔心綁定上下文的問題,但是我們現在又要擔心閉包。正如Mark Dalgleish那句令人印象深刻的總結

一張關于React Hooks和閉包的星球大戰的惡搞圖片

閉包是JS中的基礎概念。盡管如此,對新手來說它的難于理解可是臭名昭著了。You Don’t Know JS 的作者Kyle Simpson對閉包有一個著名的定義:

閉包是指當一個函數在它的詞法作用域以外執行的時候,依然可以記憶和使用它的詞法作用域。

它們明顯跟詞法作用域的概念是緊密相關的。MDN是這樣定義的:“當函數嵌套在一起時,語法分析器如何找到變量名定義的地方”。讓我們通過一個實際的例子來更好地說明:

// 樣例 0
function useState(initialValue) {
  var _val = initialValue // _val是useState函數里定義的局部變量
  function state() {
    // state是一個內部函數,是閉包
    return _val // state() 使用了它的父函數里聲明的變量_val
  }
  function setState(newVal) {
    // 同樣
    _val = newVal // 設置_val的值,但是沒有暴露_val
  }
  return [state, setState] // 暴露出函數以便外部使用
}
var [foo, setFoo] = useState(0) // 數組解構
console.log(foo()) // 打印0 - 我們給的初始值
setFoo(1) // 設置useState作用域里的_val
console.log(foo()) // 打印1 - 新值,即使調用的是相同的方法

這里我們寫了一個簡單的模仿React的useState hook。在我們的函數里,有兩個內部函數,statesetStatestate返回在上面定義的一個局部變量_valsetState將傳入的參數值設置給這個局部變量(i.e.newVal)。

我們這里實現的state是一個getter函數,這個并不理想,我們過會兒來修改它。重點在于通過foosetFoo,我們可以使用和修改 (a.k.a. “close over”)內部的變量_val。它們保留了對useState作用域的引用,這就叫閉包。在React和其他前端框架中,這看上去像state,實際上就是state。

如果你想深入探索閉包,我推薦你讀讀MDN, YDKJSDailyJS中有關這個話題的內容,但是如果你理解了上面的代碼樣例,其實就足夠了。

在函數組件中的用法

讓我們用看上去更熟悉一些的方式應用一下我們新打造的useState。我們來寫一個Counter組件!

// 樣例 1
function Counter() {
  const [count, setCount] = useState(0) // 跟上面一樣的useState
  return {
    click: () => setCount(count() + 1),
    render: () => console.log('render:', { count: count() })
  }
}
const C = Counter()
C.render() // render: { count: 0 }
C.click()
C.render() // render: { count: 1 }

這里我們選擇只是console.log出來我們的state而不是渲染到DOM。我們還為我們的Counter組件暴露了一組API,這樣就可以在腳本里調用,而不需要綁定一個事件處理函數。采用這樣的設計,我們可以模擬組件的渲染和對用戶行為的反應。

雖然程序可以工作,但是真正的React.useState不是調用getter函數去拿到state的。讓我們修改一下。

過時的閉包

如果我們想貼近真實的React API,我們不得不把state從函數改成變量。如果我們只是簡單地暴露_val而不是包住變量_val的函數的話,我們會遇到一個bug:

// 樣例 0, 再審視 - 這是有bug的!
function useState(initialValue) {
  var _val = initialValue
  // 沒有 state() 函數了
  function setState(newVal) {
    _val = newVal
  }
  return [_val, setState] // 直接暴露_val
}
var [foo, setFoo] = useState(0)
console.log(foo) // 打印 0 不需要調用函數
setFoo(1) // 設置useState作用域里的_val
console.log(foo) // 打印 0 - 喔!!

這是一種過時閉包的表現形式。當我們從useState的返回值解構出foo時,它的值等于最初調用useState時的_val,并且再也不會變了!這不是我們想要的;我們通常需要我們的組件state作為變量而不是作為函數就可以反映當前的狀態!這兩個目標似乎完全相反。

模塊中的閉包

我們可以通過把我們的閉包移動到另一個閉包里面來解決我們的useState難題。(Yo dawg 我聽說你喜歡閉包…)

// 樣例 2
const MyReact = (function() {
  let _val // 在模塊作用域中聲明狀態
  return {
    render(Component) {
      const Comp = Component()
      Comp.render()
      return Comp
    },
    useState(initialValue) {
      _val = _val || initialValue // 每次運行都重新賦值
      function setState(newVal) {
        _val = newVal
      }
      return [_val, setState]
    }
  }
})()

這里我們選擇使用模塊模式來重構我們的克隆版React。同React一樣,它要追蹤組件狀態(在我們的例子里,它用保存狀態的_val只追蹤一個組件)。這種設計模式使MyReact可以“render”你的函數組件,通過正確的閉包它每次運行都可以給內部的_val賦值:

// 樣例 2 繼續
function Counter() {
  const [count, setCount] = MyReact.useState(0)
  return {
    click: () => setCount(count + 1),
    render: () => console.log('render:', { count })
  }
}
let App
App = MyReact.render(Counter) // render: { count: 0 }
App.click()
App = MyReact.render(Counter) // render: { count: 1 }

現在這看上去很像有Hooks的React了!

你可以在YDKJS里讀到更多關于模塊模式和閉包的內容

復制useEffect

目前為止,我們已經介紹了最基礎的React HookuseState。另一個非常重要的hook是useEffect。與setState不同,useEffect是異步執行的,這意味著更可能會遇到閉包問題。

我們可以這樣擴展已經寫好的MyReact:

// 樣例 3
const MyReact = (function() {
  let _val, _deps // 在作用域里聲明狀態和依賴變量
  return {
    render(Component) {
      const Comp = Component()
      Comp.render()
      return Comp
    },
    useEffect(callback, depArray) {
      const hasNoDeps = !depArray
      const hasChangedDeps = _deps ? !depArray.every((el, i) => el === _deps[i]) : true
      if (hasNoDeps || hasChangedDeps) {
        callback()
        _deps = depArray
      }
    },
    useState(initialValue) {
      _val = _val || initialValue
      function setState(newVal) {
        _val = newVal
      }
      return [_val, setState]
    }
  }
})()

// 用法
function Counter() {
  const [count, setCount] = MyReact.useState(0)
  MyReact.useEffect(() => {
    console.log('effect', count)
  }, [count])
  return {
    click: () => setCount(count + 1),
    noop: () => setCount(count),
    render: () => console.log('render', { count })
  }
}
let App
App = MyReact.render(Counter)
// effect 0
// render {count: 0}
App.click()
App = MyReact.render(Counter)
// effect 1
// render {count: 1}
App.noop()
App = MyReact.render(Counter)
// // no effect run
// render {count: 1}
App.click()
App = MyReact.render(Counter)
// effect 2
// render {count: 2}

為了追蹤依賴項的變化(因為當依賴項變化,useEffect會再次執行),我們引入了另一個變量_deps

沒有魔法,只是數組

我們有了一個非常不錯的克隆版的useStateuseEffect,但都是實現得不太好的單例 (分別只能有一個存在,否則會有bug)。為了做點有意思的東西(也為了演示最后一個過時閉包的例子),我們需要使它們可以有任意數量的狀態和副作用。幸運的是,正如Rudi Yardley寫到的,React Hooks不是魔法,僅僅是數組。所以我們定義了一個hooks數組。我們也利用這個機會把_val_deps放進了hooks數組里:

// 樣例 4
const MyReact = (function() {
  let hooks = [],
    currentHook = 0 // hooks數組,和一個數組下標!
  return {
    render(Component) {
      const Comp = Component() // 執行效果
      Comp.render()
      currentHook = 0 // 為下一次render重置hooks數組下標
      return Comp
    },
    useEffect(callback, depArray) {
      const hasNoDeps = !depArray
      const deps = hooks[currentHook] // 類型: 數組 | undefined
      const hasChangedDeps = deps ? !depArray.every((el, i) => el === deps[i]) : true
      if (hasNoDeps || hasChangedDeps) {
        callback()
        hooks[currentHook] = depArray
      }
      currentHook++ // 這個hook運行結束
    },
    useState(initialValue) {
      hooks[currentHook] = hooks[currentHook] || initialValue // 類型: 任意
      const setStateHookIndex = currentHook // 用于setState的閉包!
      const setState = newState => (hooks[setStateHookIndex] = newState)
      return [hooks[currentHook++], setState]
    }
  }
})()

請注意這里setStateHookIndex的用法,看上去好像什么都沒做,但其實它是用來避免setState成為currentHook的閉包!如果你把它拿掉,setState將因為被它閉包的currentHook的值已經過時而不能正常工作。(試一下!)

// 樣例 4 繼續 - 用法
function Counter() {
  const [count, setCount] = MyReact.useState(0)
  const [text, setText] = MyReact.useState('foo') // 第二個hook!
  MyReact.useEffect(() => {
    console.log('effect', count, text)
  }, [count, text])
  return {
    click: () => setCount(count + 1),
    type: txt => setText(txt),
    noop: () => setCount(count),
    render: () => console.log('render', { count, text })
  }
}
let App
App = MyReact.render(Counter)
// effect 0 foo
// render {count: 0, text: 'foo'}
App.click()
App = MyReact.render(Counter)
// effect 1 foo
// render {count: 1, text: 'foo'}
App.type('bar')
App = MyReact.render(Counter)
// effect 1 bar
// render {count: 1, text: 'bar'}
App.noop()
App = MyReact.render(Counter)
// // no effect run
// render {count: 1, text: 'bar'}
App.click()
App = MyReact.render(Counter)
// effect 2 bar
// render {count: 2, text: 'bar'}

所以從基本的直覺出發,我們應該聲明一個hooks數組和一個元素索引。每當一個hook被調用的時候,元素索引會遞增,每當組件被渲染的時候,元素索引被重置。

你還免費獲得了自定義hooks

// 樣例 4, 再次審視
function Component() {
  const [text, setText] = useSplitURL('www.netlify.com')
  return {
    type: txt => setText(txt),
    render: () => console.log({ text })
  }
}
function useSplitURL(str) {
  const [text, setText] = MyReact.useState(str)
  const masked = text.split('.')
  return [masked, setText]
}
let App
App = MyReact.render(Component)
// { text: [ 'www', 'netlify', 'com' ] }
App.type('www.reactjs.org')
App = MyReact.render(Component)
// { text: [ 'www', 'reactjs', 'org' ] }}

這真的就是“不是魔法”的hooks的基本原理——自定義Hooks僅僅是從框架提供的基本特性中發展而來的——不論是React還是我們剛剛寫的克隆版。

推導Hooks的規則

注意從這里你可以粗淺地理解Hooks的第一條規則只能在頂層調用Hooks。我們已經用currentHook變量清楚地模擬了React對Hooks調用順序的依賴。你可以帶著我們的代碼實現讀一遍Hooks規則的完整解釋 ,完整地理解正在發生的一切。

還要注意第二條規則,“只能從React函數中調用Hooks”,雖然在我們的代碼實現中不是必要的,但遵守這條規則可以讓你從代碼里清楚地區分出有狀態的那部分邏輯,這確實是好的實踐。(作為一個不錯副作用,它也使編寫工具來確保你遵守了第一條原則更加容易。你就不會一不小心在循環和條件判斷中使用有狀態的而且像普通的JavaScript函數那樣命名的函數,搬起石頭砸自己的腳。遵守規則2能幫助你遵守規則1。)

結論

到這里我們可能已經最大程度地擴展了這個練習。你可以試著用一行代碼實現useRef,或者使render函數用JSX語法把元素實際渲染到DOM上,或者完善我們在這28行React Hooks克隆版代碼里忽略的其他重要的細節。希望你已經收獲了在上下文中使用閉包的一些經驗,和解密React Hooks是如何工作的一個有效的思維方式。

我想感謝Dan AbramovDivya Sasidharan審閱了這篇文章的草稿,用他們的寶貴意見完善了它。剩下的所有錯誤都算我的..

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,048評論 6 542
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,414評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,169評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,722評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,465評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,823評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,813評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,000評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,554評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,295評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,513評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,035評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,722評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,125評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,430評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,237評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,482評論 2 379

推薦閱讀更多精彩內容

  • React是現在最流行的前端框架之一,它的輕量化,組件化,單向數據流等特性把前端引入了一個新的高度,現在它又引入的...
    老鼠AI大米_Java全棧閱讀 5,788評論 0 26
  • 在學會使用React Hooks之前,可以先看一下相關原理學習React Hooks 前言 在 React 的世界...
    DC_er閱讀 9,120評論 1 16
  • 作為一個合格的開發者,不要只滿足于編寫了可以運行的代碼。而要了解代碼背后的工作原理;不要只滿足于自己的程序...
    六個周閱讀 8,475評論 1 33
  • 你還在為該使用無狀態組件(Function)還是有狀態組件(Class)而煩惱嗎?——擁有了hooks,你再也不需...
    水落斜陽閱讀 82,350評論 11 100
  • 你還在為該使用無狀態組件(Function)還是有狀態組件(Class)而煩惱嗎?——擁有了hooks,你再也不需...
    米亞流年閱讀 946評論 0 5