探究 React Hooks 的工作原理

36 阅读9分钟

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 和 setStatestate 返回上面定义的本地变量 _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],即组件内部 useStateuseEffect 最多只能使用一次,多了就会有 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 值,而不是最新的(因为后面会更新 currentHookIndexcurrentHookIndex++))!如果删了它,就有 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 的工作原理。