React闭包陷阱

React HooksReact 16.8引入的一个新特性,其出现让React的函数组件也能够拥有状态和生命周期方法,其优势在于可以让我们在不编写类组件的情况下,更细粒度地复用状态逻辑和副作用代码,但是同时也带来了额外的心智负担,闭包陷阱就是其中之一。

闭包

React闭包陷阱的名字就可以看出来,我们的问题与闭包引起的,那么闭包就是我们必须要探讨的问题了。函数和对其词法环境lexical environment的引用捆绑在一起构成闭包,也就是说,闭包可以让你从内部函数访问外部函数作用域。在JavaScript,函数在每次创建时生成闭包。在本质上,闭包是将函数内部和函数外部连接起来的桥梁。通常来说,一段程序代码中所用到的名字并不总是有效或可用的,而限定这个名字的可用性的代码范围就是这个名字的作用域scope,当一个方法或成员被声明,他就拥有当前的执行上下文context环境,在有具体值的context中,表达式是可见也都能够被引用,如果一个变量或者其他表达式不在当前的作用域,则将无法使用。作用域也可以根据代码层次分层,以便子作用域可以访问父作用域,通常是指沿着链式的作用域链查找,而不能从父作用域引用子作用域中的变量和引用。

为了定义一个闭包,首先需要一个函数来套一个匿名函数。闭包是需要使用局部变量的,定义使用全局变量就失去了使用闭包的意义,最外层定义的函数可实现局部作用域从而定义局部变量,函数外部无法直接访问内部定义的变量。从下边这个例子中我们可以看到定义在函数内部的name变量并没有被销毁,我们仍然可以在外部使用函数访问这个局部变量,使用闭包,可以把局部变量驻留在内存中,从而避免使用全局变量,因为全局变量污染会导致应用程序不可预测性,每个模块都可调用必将引来灾难。

const Student = () => {
const name = "Ming";
const sayMyName = function(){ // `sayMyName`作为内部函数,有权访问父级函数作用域`Student`中的变量
console.log(name);
}
console.dir(sayMyName); // ... `[[Scopes]]: Scopes[2] 0: Closure (student) {name: "Ming"} 1: Global` ...
return sayMyName; // `return`是为了让外部能访问闭包,挂载到`window`对象实际效果是一样的
}
const stu = Student();
stu(); // `Ming`

实际开发中使用闭包的场景有非常多,例如我们常常使用的回调函数。回调函数就是一个典型的闭包,回调函数可以访问父级函数作用域中的变量,而不需要将变量作为参数传递到回调函数中,这样就可以减少参数的传递,提高代码的可读性。在下边这个例子中,我们可以看到local这个变量是局部的变量,setTimeout进行调用的词法作用域是全局的作用域,理论上是无法访问local这个局部变量的,但是我们采用了闭包的方式创建了一个能够访问内部局部变量的函数,所以这个变量的值能够被正常打印。如果我们类似于第二个setTimeout直接将参数传递也是可以的,但是如果我们在这里封装了很多逻辑,那么这个参数传递就变得比较复杂了,根据实际情况用闭包可能会更合适一些。

const cb = () => {
const local = 1;
return () => {
console.log(local);
};
} setTimeout(cb(), 1000); // 1
setTimeout(console.log, 2000, 2); // 2

我们可以再看一个例子,我们在写Node时可能会遇到一个场景,在调用其他第三方服务接口的时候会会被限制频率,比如对于该接口1s最多请求3次,此时我们通常有两种解决方案,一种方案是在请求的时候就限制发起请求的频率,直接在发起的时候就控制好,被限频的请求需要排队,另一种方案是不限制发起请求的频率,而是采用一种基于重试的机制,当请求的结果是被限频的时候,我们就延迟一段时间再次发起请求,可以用指数退避算法等方式来控制重试时间,实际上以太网在拥堵的时候就采用了这种方法,每次发生碰撞后,设备会根据指数退避算法来计算等待时间,等待时间会逐渐增加,从而降低了设备再次发生碰撞的概率。

在这里我们需要关注第二种方案中如何进行重试,我们在发起请求的时候通常会携带比较多的信息,比如urltokenbody等数据进行查询,如果我们需要进行重试,那么肯定需要找个地方把这些数据存储下来以备下次发起请求,那么在何处存储这些变量呢,当然我们可以在global/window中构造一个全局的对象来存储,但是之前也提到过了全局变量污染会导致应用程序不可预测性,所以在这里我们更希望用闭包来进行存储。在下边这个例子中我们就使用了闭包来存储了请求时的一些信息,并且在重试时保证了这些信息是最初定义时的信息,这样就不需要污染全局变量,而且需要对于业务调用来说,我们可以再包装一侧requestWithLimit,当内部的请求正常完整之后才会Resolve Promise,将这部分重试机制封装到内部会更加易用。

const requestFactory = (url, token) => {
return function request(){ // 假设这个函数会发起请求并且返回结果
return { url, token };
}
} const req1 = requestFactory("url1", "token1");
console.log(req1()); // 发起请求 `{url: 'url1', token: 'token1'}`
console.log(req1()); // 重试请求 `{url: 'url1', token: 'token1'}`
const req2 = requestFactory("url2", "token2");
console.log(req2()); // 发起请求 `{url: 'url2', token: 'token2'}`
console.log(req2()); // 重试请求 `{url: 'url2', token: 'token2'}`

Js是静态作用域,但是this对象却是个例外,this的指向问题就类似于动态作用域,其并不关心函数和作用域是如何声明以及在何处声明的,只关心是从何处调用的,this的指向在函数定义的时候是确定不了的,只有函数执行的时候才能确定this到底指向谁,当然实际上this的最终指向的是那个调用的对象。this的设计主要是为了能够在函数体内部获得当前的运行环境context,因为在Js的内存设计中Function是独立的一个堆地址空间,不和Object直接相关,所以才需要绑定一个运行环境。

前边提到了词法作用域是在定义时就确定了,所以词法作用域也可以称为静态作用域。那么我们可以看下下边的例子,这个例子是不是很像我们的React Hooks来定义的组件。运行这个例子之后,我们可以看到虽然对于这个函数执行起来看起来都是是完全一样的,但是最后打印的时候得到的值是得到了之前作用域中的值。我们现在需要关注的是fn这个函数,我们我们说的定义时确定词法作用域这句话具体指的是这个函数被声明并定义的时候确定词法作用域,或者说是在生成函数地址的时候确定词法作用域。其实但从这个例子看起来好像没什么问题,本来就是应该这个样子的,那么为什么要举这个例子呢,其实在这里想表达的意思是,如果我们在写代码的时候不小心保持了之前的fn函数地址,那么虽然我们希望得到的index5,但是实际拿到的index却是1,这其实就是所谓的闭包陷阱了,我们在下边探讨React的时候也可以通过这个例子理解React的视图模型。

const collect = [];

const View = (props) => {
const index = props.index; const fn = () => {
console.log(index);
} collect.push(fn); return index;
} for(let i=0; i<5; ++i){
View({index: i + 1});
} collect.forEach(fn => fn()); // 1 2 3 4 5

闭包陷阱

说到这陷阱,不由得想起来一句话,出门出门就上当,当当当当不一样,平时开发的时候可以说是一不小心就上当掉入了陷阱。那么我们这个陷阱是完全由闭包引起的吗,那肯定不是,这只是Js的语言特性而已,那么这个陷阱是完全由React引起的吗,当然也不是,所以接下来我们就要来看看为什么需要闭包和React结合会引发这个陷阱。

首先我们要考虑下React渲染视图的机制,我们可以想一下,React是没有模版的,类似于Vuetemplate这部分,那么也就是说React是很难去拿到我们希望渲染的视图,就更不用谈去做分析了。那么在Hooks中应该如何拿到视图再去更新DOM结构呢,很明显我们实际上只需要将这个Hooks执行一遍即可,无论你定义了多少分支多少条件,我只要执行一遍最后取得返回值不就可以拿到视图了嘛。同时也是因为React渲染视图非常的灵活,从而不得不这样搞,Vue不那么灵活但是因为模版的存在可以做更多的优化,这实际上还是个取舍问题。不过这不是我们讨论的重点,既然我们了解到了React的渲染机制,而且在上边我们举了一个函数多次运行的示例,那么在这里我们举一个组件多次执行的示例,

// https://codesandbox.io/s/react-closure-trap-jl9jos?file=/src/multi-count.tsx
import React, { useState } from "react"; const collect: (() => number)[] = []; export const MultiCount: React.FC = () => {
const [count, setCount] = useState(0); const click = () => {
setCount(count + 1);
}; collect.push(() => count); const logCollect = () => {
collect.forEach((fn) => console.log(fn()));
}; return (
<div>
<div>{count}</div>
<button onClick={click}>count++</button>
<button onClick={logCollect}>log {">>"} collect</button>
</div>
);
};

我们首先点击三次count++这个按钮,此时我们的视图上的内容是3,但是此时我们点击log >> count这个按钮的时候,发现在控制台打印的内容是0 1 2 3,这其实就是跟前边的例子一样,因为闭包+函数的多次执行造成的问题,因为实际上Hooks实际上无非就是个函数,React通过内置的use为函数赋予了特殊的意义,使得其能够访问Fiber从而做到数据与节点相互绑定,那么既然是一个函数,并且在setState的时候还会重新执行,那么在重新执行的时候,点击按钮之前的add函数地址与点击按钮之后的add函数地址是不同的,因为这个函数实际上是被重新定义了一遍,只不过名字相同而已,从而其生成的静态作用域是不同的,那么这样便可能会造成所谓的闭包陷阱。

其实关于闭包陷阱的问题,大部分都是由于依赖更新不及时导致的,例如useEffectuseCallback的依赖定义的不合适,导致函数内部保持了对上一次组件刷新时定义的作用域,从而导致了问题。例如下边这个例子,我们的useEffect绑定的事件依赖是count,但是我们在点击count++的时候,实际上useEffect要执行的函数并没有更新,所以其内部的函数依然保持了上一次的作用域,从而导致了问题。

// https://codesandbox.io/s/react-closure-trap-jl9jos?file=/src/bind-event.tsx
import { useEffect, useRef, useState } from "react"; export const BindEventCount: React.FC = () => {
const ref1 = useRef<HTMLButtonElement>(null);
const [count, setCount] = useState(0); const add = () => {
setCount(count + 1);
}; useEffect(() => {
const el = ref1.current;
const handler = () => console.log(count);
el?.addEventListener("click", handler);
return () => {
el?.removeEventListener("click", handler);
};
}, []); return (
<div>
{count}
<div>
<button onClick={add}>count++</button>
<button ref={ref1}>log count 1</button>
</div>
</div>
);
};

当我们多次点击count++按钮之后,再去点击log count 1按钮,发现控制台输出的内容还是0,这就是因为我们的useEffect保持了旧的函数作用域,而那个函数作用的count0,那么打印的值当然就是0,同样的useCallback也会出现类似的问题,解决这个问题的一个简单的办法就是在依赖数组中加入count变量,当count发生变化的时候,就会重新执行useEffect,从而更新函数作用域。那么问题来了,这样就能解决所有问题吗,显然是不能的,副作用依赖可能会造成非常长的函数依赖,可能会导致整个项目变得越来越难以维护,关于事件绑定的探讨可以研究下前边 Hooks与事件绑定 这篇文章。

那么有没有什么好办法解决这个问题,那么我们就需要老朋友useRef了,useRef是解决闭包问题的万金油,其能存储一个不变的引用值。设想一下我们只是因为读取了旧的作用域中的内容而导致了问题,如果我们能够得到一个对象使得其无论更新了几次作用域,我们都能够保持对同一个对象的引用,那么更新之后直接取得这个值不就可以解决这个问题了嘛。在React中我们就可以借助useRef来做到这点,通过保持对象的引用来解决上述的问题。

// https://codesandbox.io/s/react-closure-trap-jl9jos?file=/src/use-ref.tsx
import { useEffect, useRef, useState } from "react"; export const RefCount: React.FC = () => {
const ref1 = useRef<HTMLButtonElement>(null);
const [count, setCount] = useState(0);
const refCount = useRef<number>(count); const add = () => {
setCount(count + 1);
}; refCount.current = count;
useEffect(() => {
const el = ref1.current;
const handler = () => console.log(refCount.current);
el?.addEventListener("click", handler);
return () => {
el?.removeEventListener("click", handler);
};
}, []); return (
<div>
{count}
<div>
<button onClick={add}>count++</button>
<button ref={ref1}>log count 1</button>
</div>
</div>
);
};

同样的,当我们多次点击count++按钮之后,再去点击log count 1按钮,发现控制台输出的内容就是最新的count值了而不是跟上边的例子一样一直保持0,这就是通过在Hooks中保持了同一个对象的引用而实现的。通过useRef我们就可以封装自定义Hooks来完成相关的实现,例如有必要的话可以实现一个useRefState,将stateref一并返回,按需取用。再比如下边这个ahooks实现的useMemoizedFn,第一个ref保证永远是同一个引用,也就是说返回的函数永远指向同一个函数地址,第二个ref用来保存当前传入的函数,这样发生re-render的时候每次创建新的函数我们都将其更新,也就是说我们即将调用的永远都是最新的那个函数。由此通过两个ref我们就可以保证两点,第一点是无论发生多少次re-render,我们返回的都是同一个函数地址,第二点是无论发生了多少次re-render,我们即将调用的函数都是最新的。

type noop = (this: any, ...args: any[]) => any;

type PickFunction<T extends noop> = (
this: ThisParameterType<T>,
...args: Parameters<T>
) => ReturnType<T>; function useMemoizedFn<T extends noop>(fn: T) {
const fnRef = useRef<T>(fn); // why not write `fnRef.current = fn`?
// https://github.com/alibaba/hooks/issues/728
fnRef.current = useMemo(() => fn, [fn]); const memoizedFn = useRef<PickFunction<T>>();
if (!memoizedFn.current) {
memoizedFn.current = function (this, ...args) {
return fnRef.current.apply(this, args);
};
} return memoizedFn.current as T;
}

每日一题

https://github.com/WindrunnerMax/EveryDay

参考

https://juejin.cn/post/6844904193044512782
https://juejin.cn/post/7119839372593070094
http://www.ferecord.com/react-hooks-closure-traps-problem.html

React闭包陷阱的更多相关文章

  1. C#_闭包陷阱

    如果匿名方法(Lambda表达式)引用了某个局部变量,编译器就会自动将该引用提升到该闭包对象中. 即将for循环中的变量i修改成了引用闭包对象的公共变量i.这样一来,即使代码执行后离开了原局部变量i的 ...

  2. js闭包陷阱问题

    JavaScript是一种非常强大的函数式编程语言,可以动态创建函数对象. 由于JavaScript还支持闭包(Closure),因此,函数可以引用其作用域外的变量,非常强大. 来看看在JavaScr ...

  3. 从 React 原理来看 ahooks 是怎么解决 React 的闭包问题的?

    本文是深入浅出 ahooks 源码系列文章的第三篇,该系列已整理成文档-地址.觉得还不错,给个 star 支持一下哈,Thanks. 本文来探索一下 ahooks 是怎么解决 React 的闭包问题的 ...

  4. C#中闭包的陷阱

    我们在使用lambda的时候会遇到闭包,在闭包中有一个陷阱是在for循环中产生的,先上代码: class Program { static void Main(string[] args) { Act ...

  5. 理解C#中的闭包

    闭包的概念 内层的函数可以引用包含在它外层的函数的变量,即使外层函数的执行已经终止.但该变量提供的值并非变量创建时的值,而是在父函数范围内的最终值. 闭包的优点 使用闭包,我们可以轻松的访问外层函数定 ...

  6. JavaScript——以简单的方式理解闭包

    闭包,在一开始接触JavaScript的时候就听说过.首先明确一点,它理解起来确实不复杂,而且它也非常好用.那我们去理解闭包之前,要有什么基础呢?我个人认为最重要的便是作用域(lexical scop ...

  7. javascript 闭包的理解

    1 需要明白概念: 执行环境 变量对象,活动对象 作用域,作用域链 闭包 垃圾处理机制 闭包陷阱

  8. 理解Python闭包概念

    闭包并不只是一个python中的概念,在函数式编程语言中应用较为广泛.理解python中的闭包一方面是能够正确的使用闭包,另一方面可以好好体会和思考闭包的设计思想. 1.概念介绍 首先看一下维基上对闭 ...

  9. JavaScript函数——闭包

    闭包 概念 只有函数内部的子函数才能读取局部变量,所以闭包可以理解成"定义在一个函数内部的函数".在本质上,闭包是将函数内部和函数外部连接起来的桥梁 例子 function out ...

  10. python通俗讲解闭包

    通俗理解闭包 先来看看什么是闭包吧 闭包是引用了自由变量的函数.这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外.所以,有另一种说法认为闭包是由函数和与其相关的引用环境组合 ...

随机推荐

  1. Java开发者的Python进修指南:JSON利器之官方json库、demjson和orjson的实用指南

    JSON JSON作为目前最流行的传输格式,在Python中也有相应的实现方式.由于JSON格式的文本可以跨平台并且简单易用,因此被广泛传播.因此,我们今天的主要讨论内容是如何熟练地应用Python的 ...

  2. [转帖]django使用html渲染页面样式+数据库管理员的创建

    一.django页面渲染 1.在templates中创建html格式的文件-index.html,在该文件中添加body,设置样式,比如: <h1 style = "backgroun ...

  3. 【转帖】Java Full GC (Ergonomics) 的排查

    文章目录 1. Full GC (Ergonomics) 1.1 Java 进程一直进行 Full GC 1.2 Full GC 的原因 1.3 检查堆占用 2. 代码检查 3. 解决方式 1. Fu ...

  4. 软件缺陷(bug)

    生活中我们肯定听过身边的朋友说过:'这tm就是个bug','你就是bug一样的存在' 等话语.当你听到这句话的时候或许有些懵逼或许认为这货说的什么玩意.其实当你想成为一名测试工程师的时候你就要天天和b ...

  5. 一文搞懂Redis

    作者: 京东物流 刘丽侠 姚再毅 康睿 刘斌 李振 一.Redis的特性 1.1 Redis为什么快? 基于内存操作,操作不需要跟磁盘交互,单次执行很快 命令执行是单线程,因为是基于内存操作,单次执行 ...

  6. 一文详解 Netty 组件

    作者:京东物流 张弓言 一.背景 Netty 是一款优秀的高性能网络框架,内部通过 NIO 的方式来处理网络请求,在高负载下也能可靠和高效地处理 I/O 操作 作为较底层的网络通信框架,其被广泛应用在 ...

  7. Mac 版的 Quicker CirMenu

    之前在Windows上用过一款圆盘菜单工具Quicker, 感觉非常方便, 换成Macos后,一直没有找到类似应用. 最近终于发现,一款好用的快捷键收集,触发工具CirMenu. 其核心功能是可以根据 ...

  8. 浅浅的源码剖析grpc-go(一)

    最近在学习 rpc 相关的知识,如果让我去从头设计一个 rpc,我从使用者的角度出发,究竟需要去做一下什么工作? 第一,RPC 本质上就是一个远程调用,那肯定就需要通过网络来传输数据.虽然传输协议可以 ...

  9. js循环之map在工作中的使用

    map函数会返回一个全新的数组哈(重要 在实际中很有用) map循环空那个数组时,不会报错的. 使用map的优势 可以返回一个全新的数组 可以用于过滤 ps==>:map里面不要有判断,否者会返 ...

  10. ETL之apache/hop-web 2.5安装和简单入门

    一.使用Docker 安装部署 1.拉取镜像 推荐使用下面的web版本 docker pull apache/hop:latest docker pull apache/hop-web:latest ...