写点什么

一步步实现 React-Hooks 核心原理

  • 2022-12-12
    浙江
  • 本文字数:4885 字

    阅读完需:约 16 分钟

React Hooks 已经推出一段时间,大家应该比较熟悉,或者多多少少在项目中用过。写这篇文章简单分析一下 Hooks 的原理,并带大家实现一个简易版的 Hooks。


这篇写的比较细,相关的知识点都会解释,给大家刷新一下记忆。

Hooks

Hooks 是 React 16.8 推出的新功能。以这种更简单的方式进行逻辑复用。之前函数组件被认为是无状态的。但是通过 Hooks,函数组件也可以有状态,以及类组件的生命周期方法。


useState 用法示例:


import React, { useState } from 'react';
function Example() { // count是组件的状态 const [count, setCount] = useState(0);
return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> );}
复制代码

闭包

开始之前,我们来简单回顾一下闭包的概念,因为 Hooks 的实现是高度依赖闭包的。


闭包(Closure),Kyle Simpson 在《你不知道的 Javascript》中总结闭包是:


Closure is when a function is able to remember and access its lexical scope even when that function is executing outside its lexical scope.


闭包就是,函数可以访问到它所在的词法作用域,即使是在定义以外的位置调用。


闭包的一个重要应用就是,实现内部变量/私有数据。


var counter = 0;
// 给计数器加1function add() { counter += 1;}
// 调用 add() 3次add(); // 1add(); // 2counter = 1000;add(); // 1003
复制代码


这里因为 counter 不是内部变量,所以谁都能修改它的值。我们不想让人随意修改 counter 怎么办?这时候就可以用闭包:


function getAdd() {  var counter = 0;  return function add() {counter += 1;}}var add = getAdd();add(); // 1add(); // 2add(); // 3counter = 1000 // error! 当前位置无法访问counter
复制代码


我们还可以把函数的定义挪到调用的位置,用一个立即执行函数表达式 IIFE(Immediately Invoked Function Expression):


var add = (function getAdd() {  var counter = 0;  return function add() {counter += 1;}})();add(); // 1add(); // 2add(); // 3
复制代码


这种通过 IIFE 创建闭包的方式也叫做模块模式(Module Pattern),它创建了一个封闭的作用域,只有通过返回的对象/方法来操纵作用域中的值。这个模式由来已久了,之前很多 Javascript 的库,比如 jQuery,就是用它来导出自己的实例的。

开始动手实现

理清闭包的概念后可以着手写了。从简单的入手,先来实现 setState。


function useState(initialValue) {  var _val = initialValue; // _val是useState的变量  function state() {    // state是一个内部函数,是闭包    return _val;  }  function setState(newVal) {    _val = newVal;  }  return [state, setState];}var [foo, setFoo] = useState(0);console.log(foo()); // 0setFoo(1);console.log(foo()) // 1
复制代码


根据 useState 的定义来实现。比较简单不需要多解释。

将 useState 应用到组件中

现在我们将这个简易版的 useState 应用到一个 Counter 组件中:


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 了,因为我们只关心组件的状态,所以每次 render 的时候打印 count 的值。


这里点击 click 之后,counter 的值加一,useState 的基本功能实现了。但现在 state 是一个函数而不是一个变量,这和 React 的 API 不一致,接下来我们就来改正这一点。

过期闭包

function useState(initialValue) {  var _val = initialValue  // 去掉了state()函数  function setState(newVal) {    _val = newVal  }  return [_val, setState] //直接返回_val}var [foo, setFoo] = useState(0)console.log(foo) // 0setFoo(1) // 更新_valconsole.log(foo) // 0 - BUG!
复制代码


如果我们直接把 state 从函数改成变量,问题就出现了,state 不更新了。无论点击几次,Counter 的值始终不变。这个是过期闭包问题(Stale Closure Problem)。因为在 useState 返回的时候,state 就指向了初始值,所以后面即使 counter 的值改变了,打印出来的仍然就旧值。我们想要的是,返回一个变量的同时,还能让这个变量和真实状态同步。那如何来实现呢?参考 前端进阶面试题详细解答

模块模式

解决办法就是将闭包放在另一个闭包中。


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]    }  }})()
复制代码


我们运用之前提到的模块模式,创建一个 MyReact 模块(第一层闭包),返回的对象中包含 useState 方法(第二层闭包)。useState 返回值中的 state,指向的是 useState 闭包中的_val,而每次调用 useState,_val 都会重新绑定到上层的_val 上,保证返回的 state 的值是最新的。解决了过期闭包的问题。


MyReact 还提供了另外一个方法 render,方法中调用组件的 render 方法来“渲染”组件,也是为了不渲染 DOM 的情况下进行测试。


function Counter() {  const [count, setCount] = MyReact.useState(0)  return {    click: () => setCount(count + 1),    render: () => console.log('render:', { count })  }}let AppApp = MyReact.render(Counter) // render: { count: 0 }App.click()App = MyReact.render(Counter) // render: { count: 1 }
复制代码


这里每次调用 MyReact.render(Counter),都会生成新的 Counter 实例,调用实例的 render 方法。render 方法中调用了 MyReact.useState()。MyReact.useState()在多次执行之间,外层闭包中的_val 值保持不变,所以 count 会绑定到当前的_val 上,这样就可以打印出正确的 count 值了。

实现 useEffect

实现了 useState 之后,接下来实现 useEffect。


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]    }  }})()
// usagefunction 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 AppApp = 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)// // 没有执行effect// render {count: 1}App.click()App = MyReact.render(Counter)// effect 2// render {count: 2}
复制代码


在 MyReact.useEffect 中,我们将依赖数组保存到_deps,每次调用,都和前一次的依赖数组进行比对。发生变化才触发回调。


注意这里在比较依赖时用的是 Object.is, React 在比较 state 变化时也是用它。注意 Object.is 在比较时不会做类型转换(和==不同)。另外 NaN === NaN 返回 false,但是 Object.is(NaN, NaN)会返回 true。


(简单起见,我们实现的 useEffect,回调函数是同步执行的,所以打印出来的 log 是 effect 先执行,然后才是 render。实际 React 中 useEffect 的回调函数应该是异步执行的)

支持多个 Hooks

到此为止我们已经简单实现了 useState 和 useEffect。但还有一个问题,就是 useState 和 useEffect 每个组件中只能用一次。


那么怎么才能支持使用多次 hooks 呢,我们可以将 hooks 保存到一个数组中。


const MyReact = (function() {  let hooks = [],    currentHook = 0 // 存储hooks的数组,和数组指针  return {    render(Component) {      const Comp = Component() // 执行effect      Comp.render()      currentHook = 0 // 每次render后,hooks的指针清零      return Comp    },    useEffect(callback, depArray) {      const hasNoDeps = !depArray      const deps = hooks[currentHook]      const hasChangedDeps = deps ? !depArray.some((el, i) => !Object.is(el, deps[i])) : true      if (hasNoDeps || hasChangedDeps) {        callback()        hooks[currentHook] = depArray      }      currentHook++ // 每调用一次指针加一    },    useState(initialValue) {      hooks[currentHook] = hooks[currentHook] || initialValue      const setStateHookIndex = currentHook // 注意⚠️这句不是没用。是避免过期闭包问题。      const setState = newState => (hooks[setStateHookIndex] = newState)      return [hooks[currentHook++], setState]    }  }})()
复制代码


注意这里用了一个新的变量 setStateHookIndex 来保存 currentHook 的值。这是为了避免 useState 闭包包住旧的 currentHook 的值。


将改动应用到组件中:


function Counter() {  const [count, setCount] = MyReact.useState(0)  const [text, setText] = MyReact.useState('foo') // 第二次用了useState  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 AppApp = 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 支持的基本思路,就是用一个数组存放 hooks。每次使用 hooks 时,将 hooks 指针加 1。每次 render 以后,将指针清零。

Custom Hooks

接下来,可以借助已经实现的 hooks 继续实现 custom hooks:


function Component() {  const [text, setText] = useSplitURL('www.google.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 AppApp = MyReact.render(Component)// { text: [ 'www', 'google', 'com' ] }App.type('www.reactjs.org')App = MyReact.render(Component)// { text: [ 'www', 'reactjs', 'org' ] }}
复制代码

重新理解 Hooks 规则

了解 Hooks 的实现可以帮助我们理解 Hooks 的使用规则。还记得使用 Hooks 的原则吗?hooks 只能用到组件最外层的代码中,不能包裹在 if 或者循环里,原因是在 React 内部,通过数组来存储 hooks。所以必须保证每次 render,hooks 的顺序不变,数量不变,才能做 deps 的比对。


用户头像

还未添加个人签名 2022-07-31 加入

还未添加个人简介

评论

发布
暂无评论
一步步实现React-Hooks核心原理_JavaScript_helloworld1024fd_InfoQ写作社区