Hook 是一种用于封装用户界面中状态行为(stateful behavior)和副作用(side effects)的更简单的方式。它们 最初在 React 中引入[5],并被其他像 Vue[6]、Svelte[7] 的框架广泛采用,甚至被改造成为 通用函数式 JS[8] 方案。然而,它们的功能设计需要对 JavaScript 的闭包有很好的理解。
本文我们通过构建一个简易版本 React Hook 来重新介绍闭包。有两个目的:展示如何有效使用闭包以及展示如何使用仅 29 行代码构建一个微型的 JS Hooks。最后,我们来谈一下自定义 Hook 是如何自然产生的。
⚠️ 注意:为了理解 Hooks,你不需要跟着完成这些步骤。如果你想通过这个练习来提高 JS 基础知识,那么可能会有所帮助。别担心,这并不难!
什么是闭包?
Hook 的 卖点之一[9] 是能完全避免类和高阶组件的复杂性。然而,有些人认为使用 Hook,不过是把将一个我们需要解决的问题换成了另一个问题。现在,我们不必担心 绑定上下文[10],而是 要担心闭包[11]。正如 Mark Dalgleish 所讲的这样[12]:
推文:“有了 Hook,初学者就不必再去学习关于“this”的知识来避免犯错。” 闭包:[一脸渴望] 还有我呢
闭包是 JS 中的一个基本概念。尽管如此,它们因为对许多人(特别是新手开发者)来说很难理解而臭名昭著。《你不知道的 JS》[13] 的作者 Kyle Simpson 将闭包定义为:
当函数在其词法作用域之外执行时仍能够记住和访问其词法作用域,就会形成闭包。
它们显然与词法作用域的概念密切相关,MDN 将其定义[14] 为“当函数被嵌套时,解析器如何解析变量名称”。我们来看一个实际例子来更好的理解一下:
// 案例 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] // 暴露 getter、setter 函数供外部调用
}
var [foo, setFoo] = useState(0) // 使用数组解构
console.log(foo()) // 0 - 得到刚传入的 initialValue 的值
setFoo(1) // 设置 useState 作用域内的 `_val`
console.log(foo()) // 1 - 得到新传入的值
在这里,我们正在创建 React 的 useState
Hook 的原始版本。在我们的函数中,有两个内部函数 state
和 setState
。state
返回上面定义的本地变量 _val
,并且 setState
将接收到的参数(即 newVal
)赋值给本地变量。
我们在此处实现了一个 getter 函数作为 state
,这不是理想做法[15],稍后我们会解决这个问题。重要的是,在 foo
和 setFoo
中,我们能够访问和操作(也就是“闭合”)内部变量 _val
。它们保留对 useState
作用域的访问权限,并将这个引用称为闭包。在 React 和其他框架的上下文中,它类似状态,也确实如此。
如果你想深入了解闭包,请阅读 MDN[16]、YDKJS[17] 和 DailyJS[18] 上关于该主题的文章;但如果你已经理解了上面代码,可以忽略,继续往下看。
在函数组件中使用
让我们在一个熟悉的场景中(一个计数器组件)使用我们新创建的 useState
函数。
// 案例 1
function Counter() {
const [count, setCount] = useState(0) // 使用之前实现的 useState()
return {
click() {
setCount(count() + 1)
},
render() {
console.log('[render]', { count: count() })
}
}
}
在这里,我们选择将状态通过 console.log
输出而不是渲染到 DOM 中。我们还为 Counter
暴露了操作 API,以便在脚本中运行而无需附加事件处理程序。通过这种设计,我们能够模拟组件的渲染和对用户操作的响应。
虽然这样做可行,但调用 getter(即本例中的 count()
)来访问状态并不完全符合真实的 React.useState
Hook API。让我们来修复一下。
过期闭包(Stale Closure)
如果我们想要匹配真正的 React API,我们的状态必须是一个变量而不是一个函数。如果我们只是暴露 _val
而不将其包装在函数中,那么我们会遇到一个 BUG:
// 案例 0.1 - 案例 0 重写,注意有BUG!
export function useState(initialValue) {
var _val = initialValue // `_val` 是 useState 函数内的一个本地变量
// 移除 state() 函数
function setState(newVal) {
// 一样
_val = newVal // 设置 `_val` 的值
}
return [_val, setState] // 直接暴露 `_val`
}
var [foo, setFoo] = useState(0) // 使用数组解构
console.log(foo) // 0 - 得到刚传入的 initialValue 的值
setFoo(1) // 设置 useState 作用域内的 `_val`
console.log(foo) // 0 - 天哪,有 BUG!!
这是“过期闭包”问题的一种形式。当我们从 useState
的输出中解构 foo
时,它只是引用了初始 useState
调用时的 _val
…… 并且永远不会被修改!这不是我们想要的结果。我们是想组件始终反映当前状态,同时只作为变量而不是函数方式使用!这两个目标似乎相悖。
模块中的闭包
我们可以通过将闭包移动到另一个闭包中来解决 useState
难题!(嘿,伙计,我听说你喜欢闭包……)
// 案例 2
const MyReact = (function () {
let _val // 在模块作用域内保留状态
return {
render(Component) {
const Comp = Component()
Comp.render()
return Comp
},
useState(initialValue) {
// 每次调用时,增加 _val 值赋值判断
_val = _val || initialValue
function setState(newVal) {
_val = newVal
}
return [_val, setState]
}
}
})()
在这里,我们选择使用 模块模式(Module Pattern)[19] 来制作我们的简易 React。与 React 一样,它跟踪组件状态(在我们的示例中,仅跟踪一个具有 _val
状态的组件)。此设计允许 MyReact
“渲染”你的函数组件,从而使其能够每次都使用正确闭包分配内部 _val
值:
// [续] 案例 2
function Counter() {
const [count, setCount] = MyReact.useState(0) // 使用之前实现的 useState()
return {
click() {
setCount(count + 1)
},
render() {
console.log('[render]', { count: count })
}
}
}
let App
App = MyReact.render(Counter) // [render] { count: 0 }
App.click()
App = MyReact.render(Counter) // [render] { count: 1 }
现在这看起来更像是使用了 Hooks 的 React!
你可以 在 YDKJS 中阅读有关模块模式和闭包的更多信息[20]。
复刻 useEffect
到目前为止,我们已经介绍了 useState
,这是第一个基础的 React Hook。下一个最重要的 Hook 是 useEffect
。与 setState
不同,useEffect
以异步方式执行,这意味着更多机会遇到闭包问题。
我们可以扩展之前实现的简易 React 版本来获得 useEffect
支持:
// 案例 3
const MyReact = (function () {
let _val, _deps // 在模块作用域内保留状态、依赖项(useEffect 使用)
return {
render(Component) {
const Comp = Component()
Comp.render()
return Comp
},
useEffect(callback, depArray) {
const hasNoDeps = !depArray
const hasChangedDeps = _deps
? /* 非首次调用 */ depArray.some((dep, i) => !Object.is(dep, _deps[i]))
: /* 首次调用 */true
if (hasNoDeps || hasChangedDeps) {
callback()
_deps = depArray
}
},
useState(initialValue) {
// 每次调用时,增加 _val 值赋值判断
_val = _val || initialValue
function setState(newVal) {
_val = newVal
}
return [_val, setState]
}
}
})()
// 使用
function Counter() {
const [count, setCount] = MyReact.useState(0) // 使用之前实现的 useState()
MyReact.useEffect(() => {
console.log('[effect]', count)
}, [count])
return {
click() {
setCount(count + 1)
},
render() {
console.log('[render]', { count: count })
}
}
}
let App
App = MyReact.render(Counter)
// [effect] 0
// [render] { count: 0 }
App.click()
App = MyReact.render(Counter)
// [effect] 1
// [render] { count: 1 }
为了追踪依赖项(因为 useEffect
在依赖项更改时重新运行),我们引入另一个变量来跟踪 _deps
。
没什么魔法,就是数组
我们有一个相当不错的 useState
和 useEffect
功能克隆,但两者都是 单例[21],即组件内部 useState
、useEffect
最多只能使用一次,多了就会有 BUG。为了比较真实的实现效果和获得乐趣,我们需要继续修改代码让组件能支持接受任意数量的 useState
和 useEffect
调用。幸运的是,正如 Rudi Yardley 所写[22],React Hook 的实现没什么魔法,就是数组。所以,我们会声明一个用来持有 hooks
的数组,并将 _val
和 _deps
合并到 hooks
数组中,就不会有单例问题了:
// 案例 4
const MyReact = (function () {
let hooks = [], // Hook 数组
currentHookIndex = 0 // 记录当前要访问的 Hook 索引
return {
render(Component) {
const Comp = Component()
Comp.render()
currentHookIndex = 0 // 重置索引,为下一次渲染做准备
return Comp
},
useEffect(callback, depArray) {
const hasNoDeps = !depArray
const deps = hooks[currentHookIndex]
const hasChangedDeps = deps
? /* 非首次调用 */ depArray.some((dep, i) => !Object.is(dep, deps[i]))
: /* 首次调用 */true
if (hasNoDeps || hasChangedDeps) {
callback()
hooks[currentHookIndex] = depArray
}
currentHookIndex++
},
useState(initialValue) {
hooks[currentHookIndex] = hooks[currentHookIndex] || initialValue
const setStateHookIndex= currentHookIndex // setState 闭包中使用!
function setState(newVal) {
hooks[setStateHookIndex] = newVal
}
return [hooks[currentHookIndex++], setState]
}
}
})()
请注意这里使用了 setStateHookIndex
,它似乎没有做任何事情,但却有用,它是用来保证 setState
设置的始终是当时暴露出去的那个当前 currentHookIndex
值,而不是最新的(因为后面会更新 currentHookIndex
(currentHookIndex++
))!如果删了它,就有 BUG 了,后面 setState
时使用的都是无效 currentHookIndex
,根本不会读取到。(试一下!)
// [续] 案例 4
function Counter() {
const [count, setCount] = MyReact.useState(0)
const [text, setText] = MyReact.useState('foo') // 第二个状态 Hook
MyReact.useEffect(() => {
console.log('[effect]', count)
}, [count, text])
return {
click() {
setCount(count + 1)
},
render() {
console.log('[render]', { count, text })
},
noop() {
setCount(count)
},
type(txt) {
setText(txt)
}
}
}
let App
App = MyReact.render(Counter)
// [effect] 0
// [render] { count: 0, text: 'foo' }
App.click()
App = MyReact.render(Counter)
// [effect] 1
// [render] { count: 1, text: 'foo' }
App.type('bar')
App = MyReact.render(Counter)
// [effect] 1
// [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
// [render] { count: 2, text: 'bar' }
基本做法是声明一个保留 Hook 的数组和一个记录当前要访问的 Hook 索引的变量,每当调用一个 Hook 时索引值就会递增,并在组件渲染后重置,方便后续渲染。
当然,自定义 Hook[23] 也是支持的:
// 案例 4.1
function Component() {
const [text, setText] = useSplitURL('www.netlify.com')
return {
type(txt) {
setText(txt)
},
render() {
console.log('[render]', { text })
}
}
}
function useSplitURL(str) {
const [text, setText] = MyReact.useState(str)
const masked = text.split('.')
return [masked, setText]
}
let App
App = MyReact.render(Component)
// [render] { text: [ 'www', 'netlify', 'com' ] }
App.type('www.reactjs.org')
App = MyReact.render(Component)
// [render] { text: [ 'www', 'reactjs', 'org' ] }
这真正地说明了 Hook 是“没什么魔法的” ——自定义 Hook 只是从框架提供的基本组件中简单衍生出来的——不管是 React,还是我们一直在构建的简易版本。
请注意,从这里你可以轻松理解 Hook 规则的第一条:只能在顶层调用[24]。我们已经使用 currentHookIndex
变量明确地模拟了 React 对调用顺序的依赖关系。你可以通过我们的实现来 阅读整个规则说明[25],并完全理解其中发生的所有事情。
同时,请注意第二条规则“仅能在 React 函数中调用 Hook[26]”也不是我们实现的必要结果,但将代码分成有状态逻辑和无状态逻辑显然是一个好习惯。(作为一个很好的副作用,它还使编写工具更容易,确保你遵循第一条规则。你不能在循环和条件语句中包装命名为常规 JavaScript 函数(译注:作为约定,不带 use 前缀)的有状态函数而导致自己犯错。遵循第二条规则也有助你遵循第一条规则。)
总结
此时,我们可能已经尽力将这个练习推到了极限。你可以尝试将 useRef 实现为一行代码[27],或者让渲染函数实际上采用 JSX 并挂载到 DOM 上,或者在这个仅有 29 行的 React Hook 克隆中省略了数百万的其他重要细节。但是希望你通过使用闭包来获得一些经验,并获得一个有用的心理模型,以解密 React Hook 的工作原理。