你不知道的 Hooks 和 JS 函数——过时的变量

7,914 阅读6分钟

前言

随着 React hooks 的普及以及 Vue3 等各类函数式组件风靡前端圈子后,大家的开发逐步开始向更轻量级的模式转型,在这个过程中,函数式编程又再一次被拉回了前端工程师的视野,比如我们会使用 compose 让我们写的 HOC 更像 Decorator:

// with compose 
compose(
  withRouter, 
  LoadingHOC,
  React.memo
)(FuncComponent)

// without compose
withRouter(LoadingHOC(React.memo(FuncComponent)))

也或者我们会写更多的纯函数让我们的逻辑能够得到更抽象地复用,我们会搬出很多可能四五年前我们就在研究的各种骚操作来赋予今天我们所写的逻辑一些新的变幻。但这些我们曾熟悉的,在我们习惯了面向对象后,如今却又感到些许陌生了。当然,今天我们的主题自然不是函数式编程。

想不到吧

搬了这么多年的前端砖,你肯定知道变量声明提升、JS 函数作用域链吧,那今天要说的就是那些你曾经以为很熟悉的 JS 函数和它的作用域链,却在如今给你抛出很多让你困惑的异常呈现的那些事。我们先看一个最典型案例,我在编写一个再普通不过、平平无奇的 React hooks 函数式组件时遇到了这样的一个问题,先给大家看一段 demo 代码:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setTimeout(() => {
      console.log(`You clicked ${count} times`);
    }, 3000);
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

组件在 useEffect 中会执行一段异步回调,异步任务在业务开发中是再平常不过的场景了,但现在发生了一件和我们代码习惯相悖的一件事情,比如我连续点击五次按钮,你觉得控制台的输出结果会是什么呢?这里我也就不卖关子啦,写习惯面向对象的我们,可能下意识的想到最后输出的肯定是 5 个 5,因为异步任务是被压入异步队列里了,但是组件的渲染又是同步进行的,自然最后取到的结果就应该和页面渲染呈现是一致的。但是最后的输出却和我们的一贯的想法并不太一致,结果告诉我们,React 似乎保存了异步函数声明时的状态,又或者说,有一份快照被存储了下来。

function-component

我找了很多文章,其中也包括 Dan 的博客 《useEffect 完整指南》等等(大家可以在在文末的参考文章里看见),大多数文章都抛出了一些实用的解决方案,同时也给出了更多奇怪的场景让我更加迷惑(我本来是想解决问题的),但最让我纠结的是,他们却没有解释产生这样的问题的原因,或者说,这一切只是源于框架的设计,或者抛出一个很冗统而又让人司空见惯的概念——闭包。作为追求极致的 ByteDancer,强迫症不容许我就这样放任不管了,因此我决定,重新拿出那本封印多年的小红书,再回去深扒一下 JS 的函数,到底是个什么样的实现机制。

所以回到一开始的问题,那又是为什么会产生这样的现象呢?故事还得从一个叫 VO 的兄弟说起。

你在学变量声明时,可曾听过的的 VO 和 AO?

作为前端工程师,大家对 JS 声明提升肯定再熟悉不过了,比如下面这样一段代码:

function test(x) {
  var b = 20; // local variable of the function context
}

test(30)
  
alert(a) // undefined
alert(b) // "b" is not defined
alert(c) // "c" is not defined

var a = 10 // variable of the global context
c = 40

"b" is not defined 应该很好理解,那这里就会抛出一个问题了,既然声明会提前,为什么 c 会是 is not defined,大家肯定会说“因为 c 没有 var 所以没有声明”也有道理,这里我也就不拐弯抹角了,直接切入正题。

因为变量会与执行上下文相关,所以解释引擎需要知道其数据存储在何处以及如何获取它们,人们为了更好的描述这一机制,变给他起了个名字叫 Variable object,即变量(管理)对象。 ——《ECMA-262-3 in detail. Chapter 2. Variable object.》

所以,可以把一个 JS 脚本能够完整跑起来的过程分为两个阶段,分别是声明阶段已经执行阶段。声明阶段负责收集变量构建 VO 以及 VO 之间的引用指向,执行阶段负责给 VO 带入执行上下文生成 AO,给 VO 上声明的变量进行赋值或者其他变更流程。

因为我们都知道,JS 函数执行都会生成一个独立的作用域,因此,每次只要有函数声明,都会生成一份新的 VO 用来存储作用域内的变量。所以刚刚我们写的那段 JS 代码,在解释引擎进入声明阶段后,得到的 VO 便是如下面的代码所示:

// Variable object of the global context
VO(globalContext) = {
  a: undefined,
  test: <reference to FunctionDeclaration 'test'>
};
  
// Variable object of the "test" function context
VO(test functionContext) = {
  x: undefined,
  b: undefined
};

自然,解释引擎在声明阶段解析到 c = 40,会认为这是一个赋值语句,因此不会被挂在 VO 上,当执行到 alert(c) 时,解析引擎在 VO(globalContext) 上找不到变量 c 的定义,自然就抛出了 "c" is not defined 的 Error,当执行到 c = 40 这段代码的时候,VO 被语句执行复写了,这时候整体的 VO 就是变成下面这样了:

// Variable object of the global context
VO(globalContext) = {
  a: 10,
  c: 40,
  test: <reference to FunctionDeclaration 'test'>
};
  
// Variable object of the "test" function context
VO(test functionContext) = {
  x: 30,
  b: 20
};

这样你就可以理解了吧,为什么 JS 的声明会被提前,为什么函数的声明会覆盖变量的声明,为什么有些时候会抛出 XXX is not defined,其实本质就是他们被挂载到 VO 上的既定顺序不同,或者说他们在执行时是否已经被挂在 VO 上的区别而已。

Lexical environments (词法环境)与 Call-Stack (调用堆栈)

刚刚讲完了 VO 和 AO,ES5 规范为了让其中的概念更加清晰而又通俗易懂(其实我自己从听说 VO 到完全理解也差不多花了快小半年时间了),又引入了词法环境(Lexical environments)这样一个概念,别看这个词特别高大上,其实他要解决的就是我们平时理解的,子级作用域能够拿到父级作用域的变量这一个问题。

为了用我们最熟悉的 JS 语法结构来说明这一机制,我们来举一个比较简单的例子:

var x = 10;
 
function foo() {
  var y = 20;
}

比如这样一段代码,我们都知道,function foo 会因为是函数所以会形成独立的作用域,并且,他也能够获取到他的父级上下文里的变量,因此被转译成词法环境后得到的结果就如下面这样:

// environment of the global context
globalEnvironment = {
  environmentRecord: {
    // built-ins:
    Object: function,
    Array: function,
    // etc ...
    // our bindings:
    x: 10
  },
  outer: null // no parent environment
};
 
// environment of the "foo" function
fooEnvironment = {
  environmentRecord: {
    y: 20
  },
  outer: globalEnvironment
};

这里我们可以看到,在每个 Environment 里都有一个 outer 字段来标记当前作用域的父级上下文,因此,在当前上下文内找不到的变量,解释引擎就会顺着 outer 字段不断递归向上找寻,直到找到这一变量为止。因此,我们可以知道,一个函数在声明时,它能够获取到的变量,就已经被确定了。

现在,我们已经知道了函数要从哪里获取自己的变量,并且在此之前你应该了解过,当函数执行时,它会被压入 JS 的调用堆栈中,而这样的堆栈结构,正好可以用来存储函数执行时具体变量的值。

Call-Stack 存在的意义:每当一个函数被执行时,它的执行记录(包括它的形参和局部变量)都会被压入到调用堆栈中。因此,如果函数调用另一个函数(或递归地调用自身),则会将另一个堆栈推入当前函数的堆栈中。函数上下文执行结束后,解释引擎便会将执行记录从堆栈中删除(出栈) ——《ECMA-262-5 in detail. Chapter 3.1. Lexical environments: Common Theory.》

这里举一个代码示例来为大家讲解这样 Call-Stack 函数出栈入栈的过程:

var bar = (function foo() {
  var x = 10;
  var y = 20;
  return function bar() {
    return x + y;
  };
})();
 
bar(); // 30

这是我们平时可能经常都会书写的一类闭包,它真正在 Call-Stack 中的存在过程如下图所示:

  1. 首先,进入 IIFE,函数 foo 声明,根据之前我们所知道的 Environment 生成方式,它知道内部有两个局部变量 x 和 y,还有一个 function bar,函数有独立的作用域需要单独生成一个 Environment,bar 函数里使用了 x 和 y 两个变量,可以通过 Environment 的 outer 属性在 foo 的 Environment 中找到
  2. 接着,函数 foo 被执行并被压入 Call-Stack,通过在执行过程中给 Environment 赋值生成了一份 EnvironmentRecord 写入内存,形成一个静态引用,这个引用包含了当前函数的 Environment 在内存中的信息,作为一份“快照”被保留了下来,函数执行后的 return 的 bar,指向了当前这份引用
  3. bar 被执行,找到了 foo 的 EnvironmentRecord 并读取到了所需的变量 x 和 y 的值,完成 return

这就是我们平时书写的函数在 Call-Stack 中的存在过程。而且大家在图中也不难发现,这里的全局 bar 函数对内存中的 EnvironmentRecord 的引用一直存在,如果没有断开这段引用,解释引擎没法知道何时会再调用 bar 函数,何时还会需要用到 EnvironmentRecord 里面的变量,因此 EnvironmentRecord 在内存中就永远不会被 GC 回收,内存也不会被释放,这就是内存泄漏,这对于在 node 或者服务端运行环境中,是致命的。当然解决方法也很简单,只需要一行代码:

bar = null

我们及时告知 GC 来回收引用就不会导致上面的问题了。

这里留下一个给大家思考的问题,再举另一个我们在业务编码中比较常见的例子,你可以亲手尝试一下通过自己的理解验证上面咱们的结论:

function createIncrement(i) {
  let value = 0;
  function increment() {
    value += i;
    console.log(value);
    const message = `Current value is ${value}`;
    return function logValue() {
      console.log(message);
    };
  }
  return increment;
}

const inc = createIncrement(1);
const log = inc(); // 打印 1
inc();             // 打印 2
inc();             // 打印 3
log();             // 打印 "Current value is 1"

你可以按照刚刚我们的思路,梳理出这个函数执行流程在 Call-Stack 中的存在过程吗?如果你做到了,我相信你也能很轻松的理解为什么会是这样的输出了。

再回头解释 Hooks 中那些过时的变量

我们回到一开始我们就抛出的那个函数组件:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setTimeout(() => {
      console.log(`You clicked ${count} times`);
    }, 3000);
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

我们按照刚刚的结论重新梳理一下五次点击后的 Environment 生成和 Call-Stack 出入栈过程。

  1. 首先首次点击,触发 setCountstate 更新,组件重新渲染,函数式组件则被重新执行
  2. Counter 函数执行:函数被压入 Call-StackEnvironmentRecord 被确定形成引用,countsetCount 传入的值,setTimeout 回调被声明,发现其中引用了 Counter 的 count 局部变量,指向 Counter 的 EnvironmentRecord 引用,setTimeout 回调被压入异步队列,等待触发
  3. 再次点击,state 被更新,Counter 重新执行,生成了新的 EnvironmentRecord,也生成了新的 setTimeout 回调,此时的回调指向的是当前的 EnvironmentRecord,并被压入异步队列等待触发
  4. 第一个 setTimeout 的回调被触发,找到对应的 EnvironmentRecord 引用,拿到 EnvironmentRecord 内的 count 值,执行 console.log

这样一来,是不是就能完整解释我们一开始遇到的奇怪现象了吧。不仅如此,《useEffect 完整指南》这篇文章里提到很多现象,都能完整解释了。

随着我们编写的函数式组件的数量快速增加,我们可能经意间或者不经意间都会写下了大量的闭包,因此在这过程中,当我们了解了 JS 函数的“快照”特性后,我们自然就会更加小心这些可能会“过时”的变量们,或者说,当我们发现执行的返回结果不符合预期时,我们也能合理地解释为什么会产生这样的效果,如何去规避他们。

结语

通过这样完整的梳理,其实不单单解决了我们一开始产生的问题——为什么这些函数会存在“过时”的状态或者变量,我们还明白了 JS 变量提升的实现机制,为什么可以通过作用域链找到上层的变量,为什么会发生内存泄漏,遇到这些问题的解决办法又是什么。

所以这一切并不是 React hooks 或者是一些新兴的函数式编程框架引入的新特性,其实这一切一直都是 JS 这门语言产生以来就有的语法特性而已,只是在之前,我们因为闭包不稳定、存在性能问题等等,没有去大量使用它们,并且,在前几年的 JS 发展历程中,大家也一直在追逐着面向对象编程,本身对象的引用指针机制,之所以他们不会“过时”,正是因为他们永远只是引用,他们只是代表一个指向,这就更符合传统编程的思想和我们编码一贯的思维定势,这类函数式组件的出现,我理解,只是把我们过去不熟悉的那部分内容,重新搬回我们的视野了。但是我们之所以需要去适应这样一个转变的过程,其实只是一个返璞归真的过程,让我们重新去看见 JS 这门语言本身的特性。

这篇文章可能没法给你的技术能力带来一些什么质的提升和飞跃,但是我想,在函数式组件日益蓬勃的今天,将会有越来越多的开发者参与进来,在编写这些函数式组件时,如果遇到了无法参透的瓶颈,或许这篇文章能给你一个解惑的方向,因为这篇文章,也只是记录了我从在业务开发中遇到问题并不断尝试解惑的过程而已。

所以没有什么神乎其技的框架设计,这也不是你学不动的新型设计模式,这些没准都是 JS 这门语言在设计之初就留下的 feature 而已!一切都源于那个美好的开始——函数产生独立作用域。

所以,看完这篇文章以后,你觉得自己还认识 JS 函数嘛?

参考文章

文中部分内容及案例摘选自下面几篇文章,建议大家在读完本篇后共同食用:

useEffect 完整指南

使用 JS 及 React Hook 时需要注意过时闭包的坑(文中有解决方法)

你还要我怎样的JS系列(3) -- VO

ECMA-262-3 in detail. Chapter 2. Variable object.

ECMA-262-5 in detail. Chapter 3.2. Lexical environments: ECMAScript implementation.

ECMA-262-5 in detail. Chapter 3.1. Lexical environments: Common Theory.

硬广

我们团队招人啦!!!欢迎加入字节跳动商业变现前端团队,我们在做的技术建设有:前端工程化体系升级、团队 Node 基建搭建、前端一键式 CI 发布工具、组件服务化支持、前端国际化通用解决方案、重依赖业务系统微前端改造、可视化页面搭建系统、商业智能 BI 系统、前端自动化测试等等等等,拥有近百号人的北上杭大前端团队,一定会有你感兴趣的领域,如果你想要加入我们,欢迎点击我的内推通道:

✨✨✨✨✨

内推传送门(黄金招聘季,点击获取字节跳动内推机会!)

校招专属入口(字节跳动校招内推码: HTZYCHN,投递链接: 加入字节跳动-招聘)

✨✨✨✨✨

如果你想了解我们部门的日常生(dòu)活(bī)以及工作环(fú)境(lì),也可以点击这里了解噢~