Hooks 是在用户界面中封装有状态的行为和副作用(side effects)的一种基础性的更加简单的方法。他们被React首次引入 ,已经被其他前端框架如Vue,Svelte,甚至是通用JS函数式编程框架等广泛采纳。但是,它们函数式的设计需要开发者对JS中的闭包有一个好的理解。
这篇文章,我们通过写一个小型的克隆版React Hooks来再次介绍闭包。主要有两个目的——演示闭包的有效用例和向你们展示如何只用29行具备可读性的JS代码来写一套Hooks。最后,我们会介绍自定义Hooks是如何自然地出现的。
⚠️ 注意:你并不需要跟着写这些代码。练习写这些代码可能对你的JS基础有一定帮助。别担心,没有那么难!
什么是闭包?
使用Hooks的很多卖点之一就是可以避免类组件和高阶组件的复杂性。然而,有些人用上Hooks,可能感觉从一个坑掉进了另一个坑。虽然不用再担心绑定上下文的问题,但是我们现在又要担心闭包。正如Mark Dalgleish那句令人印象深刻的总结:
闭包是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。在我们的函数里,有两个内部函数,state
和setState
。state
返回在上面定义的一个局部变量_val
,setState
将传入的参数值设置给这个局部变量(i.e.newVal
)。
我们这里实现的state
是一个getter函数,这个并不理想,我们过会儿来修改它。重点在于通过foo
和setFoo
,我们可以使用和修改 (a.k.a. “close over”)内部的变量_val
。它们保留了对useState
作用域的引用,这就叫闭包。在React和其他前端框架中,这看上去像state,实际上就是state。
如果你想深入探索闭包,我推荐你读读MDN, YDKJS和DailyJS中有关这个话题的内容,但是如果你理解了上面的代码样例,其实就足够了。
在函数组件中的用法
让我们用看上去更熟悉一些的方式应用一下我们新打造的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了!
复制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
。
没有魔法,只是数组
我们有了一个非常不错的克隆版的useState
和useEffect
,但都是实现得不太好的单例 (分别只能有一个存在,否则会有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 Abramov和Divya Sasidharan审阅了这篇文章的草稿,用他们的宝贵意见完善了它。剩下的所有错误都算我的..