29行代码深入React Hooks原理

4,441 阅读8分钟
这是一篇译文,原文地址  

作为一种改变组件状态、处理组件副作用的方式,Hooks这个概念最早由React提出,而后被推广到其他框架,诸如Vue、Svelte,甚至出现了原生JS库。但是,要熟练使用Hooks需要对JS闭包有比较好的理解。

在这篇文章中,我们通过造一个迷你React Hooks轮子来讲解闭包的应用。这么做主要有2个目的:

  1. 展示闭包如何在Hooks内使用
  2. 展示一下如何用29行代码实现一个迷你React Hooks

文章最后我们会讲解下自定义Hooks是如何回事。

⚠️你不需要跟着敲一遍代码就能理解Hooks,虽然这么做能帮你巩固下JS基础。别担心,这一点都不难!

闭包是什么?

Hooks的主要卖点之一是可以避免复杂的Class组件和高阶组件。但是有些人觉得使用Hooks有点像从一个坑里进到另一个坑里。虽然不用担心Class组件的this指向的问题,但是又得担心闭包的引用,so sad~

尽管闭包是JS的基础概念,但仍有很多前端萌新对其似懂非懂。Kyle Simpson在他的著作《你不知道的JS》中这么定义闭包:

当代码已经执行到一个函数词法作用域之外,但是这个函数仍然可以记住并访问他的词法作用域,那么他就形成了闭包。

所以说闭包的概念和词法作用域是紧密联系的,这是MDN对其的定义。我们来看一个例子:

// Demo 0
function useState(initialValue) {
  var _val = initialValue // _val是useState创建的本地变量
  function state() {
    // state是一个闭包
    return _val // state() 使用了由外层函数定义的_val
  }
  function setState(newVal) {
    // 同样
    _val = newVal // 赋值_val
  }
  return [state, setState] // 将函数暴露给外部使用
}
var [foo, setFoo] = useState(0) // 数组解构
console.log(foo()) // 打印 0 - initialValue(初始值)
setFoo(1) // 在useState作用域内设置_val的值
console.log(foo()) // 打印 1 - 虽然调用同一个方法,但返回新的 initialValue

现在我们有了初版的React useState hook。在我们的函数内部有2个函数,statesetStatestate返回内部变量_val的值,setState将该内部变量设置为传参的值。

我们这里将state实现为一个getter函数,这不是很理想,但没有关系,我们待会儿会改进他。重点是我们通过保存对useState作用域的访问,能够读写内部变量_val,这个引用就叫做闭包。在React和其他的框架中,这看起来像state,其实,这就是state

如果你想更深入的了解闭包,我推荐阅读MDNDailyJS。如果只是为了理解这篇文章,那理解上面这个例子就够了。

在函数式组件中使用

让我们用我们刚才实现的useState来实现一个Counter组件。

// Demo 1
function Counter() {
  const [count, setCount] = useState(0) // 和上文实现的一样
  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 }

我们的render方法没有渲染DOM,而是简单的打印我们的state。同时我们暴露click方法来替代绑定点击事件。通过以上方法我们模拟了组件的渲染和点击。

虽然代码可以运行,但把state设计为一个getter并不符合React.useState的表现,我们来改造他!

失效的闭包

如果要和实际的React API保持一致,我们的state需要设计为一个变量而不是一个函数。但如果只是简单的返回_val,我们会遇到一个bug:

// Demo 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) // sets  将useState作用域内的_val赋值为1
console.log(foo) // 打印 0 - 杯具!!

这是一种闭包失效的问题。当我们解构了useState的返回值,他引用了useState调用时的函数内部的_val,这是个值引用,所以不会再改变了!这并不是我们想要的。总的来说,我们希望组件state能够实时反应最新的state,同时又不能是一个函数调用!这两个目标看起来是完全相悖的。

模块内部的闭包

要解决我们面对的useState难题,我们可以把我们的闭包...放到另一个闭包里嘛(认真脸)

// Demo 2
const MyReact = (function() {
  let _val // 在函数作用域内保存_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一样追踪组件的状态(在我们的例子里,他只会追踪一个组件,内部在_val中保存state)。这样的设计使得我们的迷你React可以“render”函数式组件,并且每次正确的为内部变量_val赋值。

// 继续 Demo 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 }

现在看起来更像React Hooks了!

实现useEffect

到目前为止,我们实现了第一个基础hook——useState。让我们来看看另一个同样很重要的Hook——useEffect。不同于useState,useEffect的执行是异步的,这意味着更有可能出现闭包的问题。

一起来扩展我们的React吧:

// Demo 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),为了最终效果,我们希望迷你React可以接收任意数量的state和effect。幸运的是,React Hooks不是魔法,他仅仅是数组。所以,我们将有一个hooks数组。同时,既然_val_deps是独立的,我们可以把他们存储在hooks数组里。

// Demo 4
const MyReact = (function() {
  let hooks = [],
    currentHook = 0 // hooks数组 和 当前hook的索引
  return {
    render(Component) {
      const Comp = Component() // 执行 effects
      Comp.render()
      currentHook = 0 // 为下一次render重置hook索引
      return Comp
    },
    useEffect(callback, depArray) {
      const hasNoDeps = !depArray
      const deps = hooks[currentHook] // type: array | 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 // type: any
      const setStateHookIndex = currentHook // 为了setState引用正确的闭包
      const setState = newState => (hooks[setStateHookIndex] = newState)
      return [hooks[currentHook++], setState]
    }
  }
})()

注意我们定义的setStateHookIndex变量,虽然看起来没有做任何事,但它可以阻止setState引用到currentHook。如果不这么做,setState会引用到错误的hook。(试试!)

// 继续 Demo 4 - 使用
function Counter() {
  const [count, setCount] = MyReact.useState(0)
  const [text, setText] = MyReact.useState('foo') // 第二个 state 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)
// // 没有effect执行
// render {count: 1, text: 'bar'}
App.click()
App = MyReact.render(Counter)
// effect 2 bar
// render {count: 2, text: 'bar'}

简单来说,整个逻辑是通过一个hooks数组和一个每次调用hook都会递增的索引,并在每次组件render后都重置索引。

你也可以构造自定义hooks

// 再提一次 Demo 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“不是魔法”的原因——不管是在React中还是我们的迷你React,自定义hook只是脱离框架的原生JS函数。

推导Hooks的规则

现在你可以理解Hook规则的第一条:只在最顶层使用 Hook。通过currentHook变量我们模拟了React对调用顺序的依赖。你可以通读Hook规则详细说明并回忆我们的代码实现,相信你会完全明白的。

同时也请注意下第二条规则,只在 React 函数中调用 Hook,在我们的迷你React虽然不是必要的,但遵守这条规则可以确保组件的状态逻辑在代码中清晰可见(同时作为一个好的副作用,遵守规则二可以帮你更容易写出遵守规则一的自定义Hook。因为这使你不容易在循环、条件语句中写出像一般JS函数一样命名的有状态函数组件,遵守规则二帮助你遵守了规则一)。

结论

我们已经做了足够多的练习了。现在你可以试试一行代码实现 useRef,或实现一个接受JSX并渲染到DOM上的render函数,或者其他我们的迷你React忽略的重要细节。希望通过本文你了解了如何在上下文中使用闭包,以及React Hooks的工作原理。