Hooks 是在用戶界面中封裝有狀態(tài)的行為和副作用(side effects)的一種基礎性的更加簡單的方法。他們被React首次引入 ,已經(jīng)被其他前端框架如Vue克蚂,Svelte俱诸,甚至是通用JS函數(shù)式編程框架等廣泛采納。但是维雇,它們函數(shù)式的設計需要開發(fā)者對JS中的閉包有一個好的理解淤刃。
這篇文章,我們通過寫一個小型的克隆版React Hooks來再次介紹閉包谆沃。主要有兩個目的——演示閉包的有效用例和向你們展示如何只用29行具備可讀性的JS代碼來寫一套Hooks钝凶。最后,我們會介紹自定義Hooks是如何自然地出現(xiàn)的。
?? 注意:你并不需要跟著寫這些代碼耕陷。練習寫這些代碼可能對你的JS基礎有一定幫助掂名。別擔心,沒有那么難哟沫!
什么是閉包饺蔑?
使用Hooks的很多賣點之一就是可以避免類組件和高階組件的復雜性。然而嗜诀,有些人用上Hooks猾警,可能感覺從一個坑掉進了另一個坑。雖然不用再擔心綁定上下文的問題隆敢,但是我們現(xiàn)在又要擔心閉包发皿。正如Mark Dalgleish那句令人印象深刻的總結(jié):
閉包是JS中的基礎概念。盡管如此拂蝎,對新手來說它的難于理解可是臭名昭著了穴墅。You Don’t Know JS 的作者Kyle Simpson對閉包有一個著名的定義:
閉包是指當一個函數(shù)在它的詞法作用域以外執(zhí)行的時候,依然可以記憶和使用它的詞法作用域温自。
它們明顯跟詞法作用域的概念是緊密相關(guān)的玄货。MDN是這樣定義的:“當函數(shù)嵌套在一起時,語法分析器如何找到變量名定義的地方”悼泌。讓我們通過一個實際的例子來更好地說明:
// 樣例 0
function useState(initialValue) {
var _val = initialValue // _val是useState函數(shù)里定義的局部變量
function state() {
// state是一個內(nèi)部函數(shù)松捉,是閉包
return _val // state() 使用了它的父函數(shù)里聲明的變量_val
}
function setState(newVal) {
// 同樣
_val = newVal // 設置_val的值,但是沒有暴露_val
}
return [state, setState] // 暴露出函數(shù)以便外部使用
}
var [foo, setFoo] = useState(0) // 數(shù)組解構(gòu)
console.log(foo()) // 打印0 - 我們給的初始值
setFoo(1) // 設置useState作用域里的_val
console.log(foo()) // 打印1 - 新值馆里,即使調(diào)用的是相同的方法
這里我們寫了一個簡單的模仿React的useState
hook隘世。在我們的函數(shù)里,有兩個內(nèi)部函數(shù)鸠踪,state
和setState
以舒。state
返回在上面定義的一個局部變量_val
,setState
將傳入的參數(shù)值設置給這個局部變量(i.e.newVal
)慢哈。
我們這里實現(xiàn)的state
是一個getter函數(shù)蔓钟,這個并不理想,我們過會兒來修改它卵贱。重點在于通過foo
和setFoo
滥沫,我們可以使用和修改 (a.k.a. “close over”)內(nèi)部的變量_val
。它們保留了對useState
作用域的引用键俱,這就叫閉包兰绣。在React和其他前端框架中,這看上去像state编振,實際上就是state缀辩。
如果你想深入探索閉包,我推薦你讀讀MDN, YDKJS和DailyJS中有關(guān)這個話題的內(nèi)容,但是如果你理解了上面的代碼樣例臀玄,其實就足夠了瓢阴。
在函數(shù)組件中的用法
讓我們用看上去更熟悉一些的方式應用一下我們新打造的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,這樣就可以在腳本里調(diào)用累贤,而不需要綁定一個事件處理函數(shù)叠穆。采用這樣的設計,我們可以模擬組件的渲染和對用戶行為的反應臼膏。
雖然程序可以工作硼被,但是真正的React.useState
不是調(diào)用getter函數(shù)去拿到state的。讓我們修改一下渗磅。
過時的閉包
如果我們想貼近真實的React API祷嘶,我們不得不把state從函數(shù)改成變量。如果我們只是簡單地暴露_val
而不是包住變量_val
的函數(shù)的話夺溢,我們會遇到一個bug:
// 樣例 0, 再審視 - 這是有bug的!
function useState(initialValue) {
var _val = initialValue
// 沒有 state() 函數(shù)了
function setState(newVal) {
_val = newVal
}
return [_val, setState] // 直接暴露_val
}
var [foo, setFoo] = useState(0)
console.log(foo) // 打印 0 不需要調(diào)用函數(shù)
setFoo(1) // 設置useState作用域里的_val
console.log(foo) // 打印 0 - 喔!!
這是一種過時閉包的表現(xiàn)形式。當我們從useState
的返回值解構(gòu)出foo
時烛谊,它的值等于最初調(diào)用useState
時的_val
风响,并且再也不會變了!這不是我們想要的丹禀;我們通常需要我們的組件state作為變量而不是作為函數(shù)就可以反映當前的狀態(tài)状勤!這兩個目標似乎完全相反。
模塊中的閉包
我們可以通過把我們的閉包移動到另一個閉包里面來解決我們的useState
難題双泪。(Yo dawg 我聽說你喜歡閉包…)
// 樣例 2
const MyReact = (function() {
let _val // 在模塊作用域中聲明狀態(tài)
return {
render(Component) {
const Comp = Component()
Comp.render()
return Comp
},
useState(initialValue) {
_val = _val || initialValue // 每次運行都重新賦值
function setState(newVal) {
_val = newVal
}
return [_val, setState]
}
}
})()
這里我們選擇使用模塊模式來重構(gòu)我們的克隆版React持搜。同React一樣,它要追蹤組件狀態(tài)(在我們的例子里焙矛,它用保存狀態(tài)的_val
只追蹤一個組件)葫盼。這種設計模式使MyReact
可以“render”你的函數(shù)組件,通過正確的閉包它每次運行都可以給內(nèi)部的_val
賦值:
// 樣例 2 繼續(xù)
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 }
現(xiàn)在這看上去很像有Hooks的React了村斟!
你可以在YDKJS里讀到更多關(guān)于模塊模式和閉包的內(nèi)容贫导。
復制useEffect
目前為止,我們已經(jīng)介紹了最基礎的React HookuseState
蟆盹。另一個非常重要的hook是useEffect
孩灯。與setState
不同,useEffect
是異步執(zhí)行的逾滥,這意味著更可能會遇到閉包問題峰档。
我們可以這樣擴展已經(jīng)寫好的MyReact:
// 樣例 3
const MyReact = (function() {
let _val, _deps // 在作用域里聲明狀態(tài)和依賴變量
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
會再次執(zhí)行),我們引入了另一個變量_deps
讥巡。
沒有魔法掀亩,只是數(shù)組
我們有了一個非常不錯的克隆版的useState
和useEffect
,但都是實現(xiàn)得不太好的單例 (分別只能有一個存在尚卫,否則會有bug)归榕。為了做點有意思的東西(也為了演示最后一個過時閉包的例子),我們需要使它們可以有任意數(shù)量的狀態(tài)和副作用吱涉。幸運的是刹泄,正如Rudi Yardley寫到的,React Hooks不是魔法怎爵,僅僅是數(shù)組特石。所以我們定義了一個hooks
數(shù)組。我們也利用這個機會把_val
和_deps
放進了hooks
數(shù)組里:
// 樣例 4
const MyReact = (function() {
let hooks = [],
currentHook = 0 // hooks數(shù)組鳖链,和一個數(shù)組下標姆蘸!
return {
render(Component) {
const Comp = Component() // 執(zhí)行效果
Comp.render()
currentHook = 0 // 為下一次render重置hooks數(shù)組下標
return Comp
},
useEffect(callback, depArray) {
const hasNoDeps = !depArray
const deps = hooks[currentHook] // 類型: 數(shù)組 | undefined
const hasChangedDeps = deps ? !depArray.every((el, i) => el === deps[i]) : true
if (hasNoDeps || hasChangedDeps) {
callback()
hooks[currentHook] = depArray
}
currentHook++ // 這個hook運行結(jié)束
},
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
的值已經(jīng)過時而不能正常工作推捐。(試一下!)
// 樣例 4 繼續(xù) - 用法
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'}
所以從基本的直覺出發(fā)侧啼,我們應該聲明一個hooks
數(shù)組和一個元素索引牛柒。每當一個hook被調(diào)用的時候,元素索引會遞增痊乾,每當組件被渲染的時候皮壁,元素索引被重置。
你還免費獲得了自定義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僅僅是從框架提供的基本特性中發(fā)展而來的——不論是React還是我們剛剛寫的克隆版哪审。
推導Hooks的規(guī)則
注意從這里你可以粗淺地理解Hooks的第一條規(guī)則:只能在頂層調(diào)用Hooks蛾魄。我們已經(jīng)用currentHook
變量清楚地模擬了React對Hooks調(diào)用順序的依賴。你可以帶著我們的代碼實現(xiàn)讀一遍Hooks規(guī)則的完整解釋 湿滓,完整地理解正在發(fā)生的一切畏腕。
還要注意第二條規(guī)則,“只能從React函數(shù)中調(diào)用Hooks”茉稠,雖然在我們的代碼實現(xiàn)中不是必要的描馅,但遵守這條規(guī)則可以讓你從代碼里清楚地區(qū)分出有狀態(tài)的那部分邏輯,這確實是好的實踐而线。(作為一個不錯副作用铭污,它也使編寫工具來確保你遵守了第一條原則更加容易恋日。你就不會一不小心在循環(huán)和條件判斷中使用有狀態(tài)的而且像普通的JavaScript函數(shù)那樣命名的函數(shù),搬起石頭砸自己的腳嘹狞。遵守規(guī)則2能幫助你遵守規(guī)則1岂膳。)
結(jié)論
到這里我們可能已經(jīng)最大程度地擴展了這個練習。你可以試著用一行代碼實現(xiàn)useRef磅网,或者使render函數(shù)用JSX語法把元素實際渲染到DOM上谈截,或者完善我們在這28行React Hooks克隆版代碼里忽略的其他重要的細節(jié)。希望你已經(jīng)收獲了在上下文中使用閉包的一些經(jīng)驗涧偷,和解密React Hooks是如何工作的一個有效的思維方式簸喂。
我想感謝Dan Abramov和Divya Sasidharan審閱了這篇文章的草稿,用他們的寶貴意見完善了它燎潮。剩下的所有錯誤都算我的..