[译]深入浅出:React Hooks是如何工作的?

翻译自netlify博客里的一篇文章

Hooks 是在用户界面中封装有状态的行为和副作用(side effects)的一种基础性的更加简单的方法。他们被React首次引入 ,已经被其他前端框架如VueSvelte,甚至是通用JS函数式编程框架等广泛采纳。但是,它们函数式的设计需要开发者对JS中的闭包有一个好的理解。

这篇文章,我们通过写一个小型的克隆版React Hooks来再次介绍闭包。主要有两个目的——演示闭包的有效用例和向你们展示如何只用29行具备可读性的JS代码来写一套Hooks。最后,我们会介绍自定义Hooks是如何自然地出现的。

⚠️ 注意:你并不需要跟着写这些代码。练习写这些代码可能对你的JS基础有一定帮助。别担心,没有那么难!

什么是闭包?

使用Hooks的很多卖点之一就是可以避免类组件和高阶组件的复杂性。然而,有些人用上Hooks,可能感觉从一个坑掉进了另一个坑。虽然不用再担心绑定上下文的问题,但是我们现在又要担心闭包。正如Mark Dalgleish那句令人印象深刻的总结

一张关于React Hooks和闭包的星球大战的恶搞图片

闭包是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。在我们的函数里,有两个内部函数,statesetStatestate返回在上面定义的一个局部变量_valsetState将传入的参数值设置给这个局部变量(i.e.newVal)。

我们这里实现的state是一个getter函数,这个并不理想,我们过会儿来修改它。重点在于通过foosetFoo,我们可以使用和修改 (a.k.a. “close over”)内部的变量_val。它们保留了对useState作用域的引用,这就叫闭包。在React和其他前端框架中,这看上去像state,实际上就是state。

如果你想深入探索闭包,我推荐你读读MDN, YDKJSDailyJS中有关这个话题的内容,但是如果你理解了上面的代码样例,其实就足够了。

在函数组件中的用法

让我们用看上去更熟悉一些的方式应用一下我们新打造的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了!

你可以在YDKJS里读到更多关于模块模式和闭包的内容

复制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

没有魔法,只是数组

我们有了一个非常不错的克隆版的useStateuseEffect,但都是实现得不太好的单例 (分别只能有一个存在,否则会有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 AbramovDivya Sasidharan审阅了这篇文章的草稿,用他们的宝贵意见完善了它。剩下的所有错误都算我的..

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,064评论 5 466
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,606评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,011评论 0 328
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,550评论 1 269
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,465评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,919评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,428评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,075评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,208评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,185评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,191评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,914评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,482评论 3 302
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,585评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,825评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,194评论 2 344
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,703评论 2 339

推荐阅读更多精彩内容

  • React是现在最流行的前端框架之一,它的轻量化,组件化,单向数据流等特性把前端引入了一个新的高度,现在它又引入的...
    老鼠AI大米_Java全栈阅读 5,772评论 0 26
  • 在学会使用React Hooks之前,可以先看一下相关原理学习React Hooks 前言 在 React 的世界...
    DC_er阅读 9,053评论 1 16
  • 作为一个合格的开发者,不要只满足于编写了可以运行的代码。而要了解代码背后的工作原理;不要只满足于自己的程序...
    六个周阅读 8,410评论 1 33
  • 你还在为该使用无状态组件(Function)还是有状态组件(Class)而烦恼吗?——拥有了hooks,你再也不需...
    水落斜阳阅读 82,301评论 11 100
  • 你还在为该使用无状态组件(Function)还是有状态组件(Class)而烦恼吗?——拥有了hooks,你再也不需...
    米亚流年阅读 939评论 0 5