前言

在通关 JS 的道路上你一定也遇到过两个难缠的大 BOSS:this的指向和闭包,而且不能逃课。

在使用 React 时,如果你习惯编写 class 组件,就绕不开this;如果你使用函数组件,就一定会用到 React Hooks,而 React Hooks 又大量使用闭包。(闭包真的无处不在呢!)

voldmort laugh

本文将用代码示例,仿写一个简化版的 useState Hook,浅析闭包在 React 中的应用。当然本文的代码示例不代表 React 源码的真实情况。

前置条件

想要彻底理解本文内容需要你:

  • 对 JS 闭包有大概了解。不太了解也没关系,我会在本文回顾。
  • 浏览过 React Hooks 文档,最好是使用过 React Hooks。

Let's get our hands dirty!

“闭包”是什么?

请允许我引用来自《你不知道的 JavaScript》对闭包的定义:

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

第一次读到这句话的时候,我深刻地体会到了什么叫“每一个字我都认识,但是我完全不知道它说了什么”的绝望。现在回看,其实《你不知道的 JavaScript》的章节设计得很好,想要真的理解闭包这个概念,推荐熟读前面词法作用域部分,了解了作用域,如何查找变量,闭包问题也就引刃而解了。

对于我个人来说,开悟的那一刻是当我把闭包的英文closure转换成动词词组close over来理解的那一刻。close就是打包了当前作用域,而over超越了自己的作用域,在作用域之外也可以执行。

lightbulb

顺便提一句,很多概念直接通过英文来理解会容易很多,另一个例子是作用域的英文:scope。技术书能读英文原版就读原版,比方说《JavaScript 忍者秘籍》,很好的一本书,但是中文翻译了个啥???

废话就说这么多,请看一个经典的代码示例:

function addOne() {
  let num = 1;
  return function inner() {
    num = num + 1;
    return num;
  };
}

const outer = addOne();

console.log(outer()); // 2
console.log(outer()); // 3
console.log(outer()); // 4
console.log(outer()); // 5
num = 999; // 报错

让我们一起看看在上面的代码片段发生了什么?

  • 当我们在调用outer函数的时候,实际调用的是addOne的内部的inner函数。
  • inner函数引用了其作用域之外的num的值。
  • inner函数在自己的作用域(即addOne函数)之外(即outer函数)中执行。
  • 我们通过在addOne函数中返回inner函数(close),并在外部调用(over)实现了一个经典的闭包(closure)。

而通过打印到控制台的结果,发现实现了两个效果:

  1. num为私有变量,尝试在全局修改变量的值会报错。私有变量也是闭包的常用场景之一。
  2. 反复调用outer函数可以实现数字的等差累加(+1),也就是说outer是一个有状态的函数(stateful function)。

闭包与有状态函数(stateful function)

有状态函数就是利用闭包,使得函数拥有内部状态,这样每一次调用函数会基于上一次的结果产生不一样的结果。(可以对比纯函数的概念理解)

看到这里你是不是想到了什么?React 不就是利用 React Hooks 对组件进行状态管理嘛?!

仿写 useState

编写 useState 函数

那让我们进入正题,从仿写useState开始。

根据useState的结构,可以得出如果编写一个useState函数的话,需要返回一个数组,包含一个值和一个相关的 setter 函数:

function myUseState(initValue) {
  let _value = initValue;
  const state = _value;
  const setState = (newValue) => {
    _value = newValue;
  };
  return [state, setState];
}

让我们看看上面的代码片段发生了什么:

  • useState函数返回了一个数组,这个数组包含两个变量:state和一个 setter 函数setState
  • useState函数通过内部变量_value记住了我们传入的初始值,setState引用了这个值,并做修改。

在真实的 React 中,我们是通过React.useState来调用这个 hook,那我们需要调整一下上面这个函数的写法,将函数封装在 React 中。

我们先写个简单的练手,把前文的outer函数改写一下:

const outer = (function addOne() {
  let num = 1;
  return function inner() {
    num = num + 1;
    return num;
  };
})();

console.log(outer()); // 2
console.log(outer()); // 3
console.log(outer()); // 4
console.log(outer()); // 5

利用 IIFE 将代码封装后和之前的输出结果一致,同理就可以把myUseState函数改写为:

const MyReact = (function () {
  function myUseState(initValue) {
    let _value = initValue;
    const state = _value;
    const setState = (newValue) => {
      _value = newValue;
    };
    return [state, setState];
  }
  return { myUseState };
})();

让我们测试一下使用:

const [count, setCount] = MyReact.myUseState(1);

console.log(count); //1
setCount(count + 1);
console.log(count); //1

为什么setCount失效了?让我们来分析一下调用setCount(count + 1)的时候发生了什么:

  • setCount是在外部调用了setState函数(over)
  • setState可以访问和修改myUseState函数内部_value的值(close)
  • setState是一个闭包,并且当我们在调用setCount(count + 1)时,确实改变了_value的值
  • count拿到的值是返回数组的第一项,并不持续追踪_value值的变化

让我们再回到闭包的概念:

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

很容易以为误闭包记住的是值,但实际上闭包 close(记住)的是作用域。那我们只需要把state也修改为_value的闭包,让 state 也记住它的作用域就可以了:

const MyReact = (function () {
  function myUseState(initValue) {
    let _value = initValue;
    const state = () => _value; //state 为 _value 的闭包
    const setState = (newValue) => {
      _value = newValue;
    };
    return [state, setState]; //外部可以调用 state
  }
  return { myUseState };
})();

对应打印到控制台的代码也要修改:

const [count, setCount] = MyReact.myUseState(1);

console.log(count()); //1
setCount(count() + 1);
console.log(count()); //2

编写函数组件

让我们继续:

在真实的 React Hook 的使用场景中,我们是在一个函数组件中调用 Hook,让我们边调整边完善:

function MyComponent() {
  const [count, setCount] = MyReact.myUseState(1);

  return {
    //此处应该为 JSX
  };
}

假设返回的 JSX 是一个可以展示count的值的标签,以及一个按钮,每次点击之后count的值加 1。

为了简化案例,我们把 JSX 改写为打印到控制台,这样 return 返回的对象就包含两个方法:

  • 一个render方法,在控制台输出count的值;
  • 一个click方法,模拟按钮,每次调用改变count的值。
function MyComponent() {
  const [count, setCount] = MyReact.myUseState(1);

  return {
    render: () => console.log(count),
    click: () => setCount(count + 1),
  };
}

这样我们就构建好了一个函数组件。

编写渲染方法

通常我们使用ReactDOM.render(要渲染的内容,在哪里渲染)将函数组件渲染到页面,那么我们的仿写还差一个渲染方法:

const MyReact = (function () {
  function myUseState(initValue) {
    ...
    return [state, setState];
  }

  function myRender(Component){
    const _c = Component();
    _c.render();//在控制台打印状态
    return _c;
  }

  return { myUseState, myRender };
})();

由于在编写函数组件的时候,我们返回的render方法是() => console.log(count),直接在控制台打印状态,所以我们对myUseState要稍作修改:

const MyReact = (function () {
  let _val; //放在最外层
  function myUseState(initVal) {
    const state = _val || initVal;//不需要使用函数
    const setState = (newVal) => {
      _val = newVal;
    };
    return [state, setState];
  }
  ...
  return { myUseState, myRender };
})();

现在完整的代码为:

const MyReact = (function () {
  let _value; //在 MyReact 最外层声明变量
  function myUseState(initValue) {
    const state = _val || initVal; //不需要使用函数
    const setState = (newValue) => {
      _value = newValue;
    };
    return [state, setState];
  }
  function myRender(Component) {
    const _c = Component();
    _c.render(); //在控制台打印状态
    return _c;
  }
  return { myUseState, myRender };
})();

function MyComponent() {
  const [count, setCount] = MyReact.myUseState(1);

  return {
    render: () => console.log(count),
    click: () => setCount(count + 1),
  };
}

我们来查看一下调用效果:

var App = MyReact.myRender(MyComponent); //1
App.click();
var App = MyReact.myRender(MyComponent); //2

简要分析一下在这个代码片段里发生了什么:

  • MyReact.myRender方法对我们编写的组件MyReactComponent进行调用,直接将count的值打印到控制台,同时也暴露了click方法;
  • 使用App.click调用暴露的方法,在非setCount的词法作用域调用setCount方法(over),修改了之前的_val值(close);
  • 当我们再次利用myRender方法获取count值时,count拿到的是修改后_val的值,就从1变成了2

做得不错!

但往往我们在一个函数组件中使用不止一个useState

function MyComponent() {
  const [count, setCount] = MyReact.myUseState(1);
  const [text, setText] = MyReact.myUseState('freeCodeCamp');

  return {
    render: () => console.log({ count, text }), //同时展现两个变量
    click: () => setCount(count + 1),
    type: (newText) => setText(newText), //假设有一个输入框
  };
}

打印控制台就会出现诡异的效果:

var App = MyReact.myRender(MyComponent); //{"count": 1, "text": 1}
App.click();
var App = MyReact.myRender(MyComponent); //{"count": 2, "text": 2}

这是因为我们只有_val这一个值来存储变量,这个时候我们就需要引入数组:

const MyReact = (function () {
  let hooks = [];
  let idx = 0;
  function myUseState(initVal) {
    const state = hooks[idx] || initVal;
    const _idx = idx; //记住这个值
    const setState = (newVal) => {
      hooks[_idx] = newVal; //调用 setState 修改的是创建 state 同索引所指向的值
    };
    idx++; //这样才能在 hook 数组中记录下一个值
    return [state, setState];
  }

  function myRender(Component) {
    idx = 0; //每一次渲染的时候,索引要回到 0
    const C = Component();
    C.render();
    return C;
  }
  return { myUseState, myRender };
})();

重新调用:

var App = MyReact.myRender(Component); //{"count": 1, "text": 'freeCodeCamp'}
App.click();
var App = MyReact.myRender(Component); //{"count": 2, "text": 'freeCodeCamp'}
App.setText('awesome!');
var App = MyReact.myRender(Component); //{"count": 2, "text": 'awesome!'}

这里要稍微解释一下为什么在myRender函数中,一定要将idx的值重新设置为0

因为每一次调用MyReact.myRender都是独立的,如果不将索引设置为0的话,多次调用useState会将在上次索引上继续索引,这样上次索引的记录就不会被重写,每次渲染Component时就会返回同样的状态值和 setter 函数。

总结

探索闭包和 React Hooks 之间的关系是一段有趣的旅程。

你也可以尝试仿写其他 hook 方法。

希望这篇文章可以启发你,在以后的打怪之路上优雅地理解和利用闭包。
it's only the beginning

参考资料

Swyx - Deep dive: How do React hooks really work?
Rudi Yardley - React hooks: not magic, just arrays