重学 JavaScript 作用域和闭包(下)

简介: 1. 作用域对于多数编程语言,最基本的功能就是能够存储变量当中的值、并且允许我们对这个变量的值进行访问和修改。那么有了变量之后,应该把它放在哪里、程序如何找到它们?是否需要提前约定好一套存储变量、访问变量的规则?答案是肯定的,这套规则就是作用域

2. 闭包


(1)闭包基本概念

MDN中闭包的定义:

一个函数和对其周围状态的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。

通俗来讲,闭包其实就是一个可以访问其他函数内部变量的函数。即一个定义在函数内部的函数,或者说闭包是个内嵌函数。


通常情况下,函数内部变量是无法在外部访问的(即全局变量和局部变量的区别),因此使用闭包的作用,就具备实现了能在外部访问某个函数内部变量的功能,让这些内部变量的值始终可以保存在内存中:


function fun1() {
 var a = 1;
 return function(){
  console.log(a);
 };
}
var result = fun1();
result();  // 1
复制代码


这段代码在控制台中输出的结果是 1(即 a 的值)。可以发现,a 变量作为一个 fun1 函数的内部变量,正常情况下作为函数内的局部变量,是无法被外部访问到的。但是通过闭包,最后可以拿到 a 变量的值。


从直观上来看,闭包这个概念为 JavaScript 中访问函数内变量提供了途径和便利。这样做的好处很多,比如,可以利用闭包实现缓存等。


(2)闭包产生原因

上面说了作用域的概念,我们还需要知道作用域链的基本概念。当访问一个变量时,代码解释器会首先在当前的作用域查找,如果没找到,就去父级作用域去查找,直到找到该变量或者不存在父级作用域中,这样的链路就是作用域链。


需要注意,每一个子函数都会拷贝上级的作用域,形成一个作用域链:

var a = 1;
function fun1() {
  var a = 2
  function fun2() {
    var a = 3;
      console.log(a);//3
    }
  }
}
复制代码


可以看到,fun1 函数的作用域指向全局作用域(window)和它自己本身;fun2 函数的作用域指向全局作用域 (window)、fun1 和它本身;而作用域是从最底层向上找,直到找到全局作用域 window 为止,如果全局还没有的话就会报错。这就很形象地说明了什么是作用域链,即当前函数一般都会存在上层函数的作用域的引用,那么他们就形成了一条作用域链。


由此可见,闭包产生的本质就是:当前环境中存在指向父级作用域的引用

function fun1() {
  var a = 2
  function fun2() {
    console.log(a);  //2
  }
  return fun2;
}
var result = fun1();
result();
复制代码


可以看到,这里 result 会拿到父级作用域中的变量,输出 2。因为在当前环境中,含有对 fun2 函数的引用,fun2 函数恰恰引用了 window、fun1 和 fun2 的作用域。因此 fun2 函数是可以访问到 fun1 函数的作用域的变量。


那是不是只有返回函数才算是产生了闭包呢?其实也不是,回到闭包的本质,只需要让父级作用域的引用存在即可,因此可以这样修改上面的代码:

var fun3;
function fun1() {
  var a = 2
  fun3 = function() {
    console.log(a);
  }
}
fun1();
fun3();
复制代码


可以看到,其中实现的结果和前一段代码的效果其实是一样的,就是在给 fun3 函数赋值后,fun3 函数就拥有了 window、fun1 和 fun3 本身这几个作用域的访问权限;然后还是从下往上查找,直到找到 fun1 的作用域中存在 a 这个变量;因此输出的结果还是 2,最后产生了闭包,形式变了,本质没有改变。


(3)闭包应用场景

下面来看看闭包的表现形式及应用场景:

  • 在定时器、事件监听、Ajax 请求、Web Workers 或者任何异步中,只要使用了回调函数,实际上就是在使用闭包:
// 定时器
setTimeout(function handler(){
  console.log('1');
},1000);
// 事件监听
document.getElementById(app).addEventListener('click', () => {
  console.log('Event Listener');
});
复制代码


  • 作为函数参数传递的形式:
var a = 1;
function foo(){
  var a = 2;
  function baz(){
    console.log(a);
  }
  bar(baz);
}
function bar(fn){
  // 这是闭包
  fn();
}
foo();  // 输出2,而不是1
复制代码


  • IIFE(立即执行函数),创建了闭包,保存了全局作用域(window)和当前函数的作用域,因此可以输出全局的变量:
var a = 2;
(function IIFE(){
  console.log(a);  // 输出2
})();
复制代码


IIFE 是一种自执行匿名函数,这个匿名函数拥有独立的作用域。这不仅可以避免了外界访问此 IIFE 中的变量,而且又不会污染全局作用域。

  • 结果缓存(备忘模式)

备忘模式就是应用闭包的特点的一个典型应用。比如下面函数:

function add(a) {
    return a + 1;
}
复制代码


当多次执行 add() 时,每次得到的结果都是重新计算得到的,如果是开销很大的计算操作的话就比较消耗性能了,这里可以对已经计算过的输入做一个缓存。所以这里可以利用闭包的特点来实现一个简单的缓存,在函数内部用一个对象存储输入的参数,如果下次再输入相同的参数,那就比较一下对象的属性,如果有缓存,就直接把值从这个对象里面取出来。实现代码如下:

function memorize(fn) {
    var cache = {}
    return function() {
        var args = Array.prototype.slice.call(arguments)
        var key = JSON.stringify(args)
        return cache[key] || (cache[key] = fn.apply(fn, args))
    }
}
function add(a) {
    return a + 1
}
var adder = memorize(add)
adder(1)            // 输出: 2    当前: cache: { '[1]': 2 }
adder(1)            // 输出: 2    当前: cache: { '[1]': 2 }
adder(2)            // 输出: 3    当前: cache: { '[1]': 2, '[2]': 3 }
复制代码


使用 ES6 的方式实现:

function memorize(fn) {
    const cache = {}
    return function(...args) {
        const key = JSON.stringify(args)
        return cache[key] || (cache[key] = fn.apply(fn, args))
    }
}
function add(a) {
    return a + 1
}
const adder = memorize(add)
adder(1)            // 输出: 2    当前: cache: { '[1]': 2 }
adder(1)            // 输出: 2    当前: cache: { '[1]': 2 }
adder(2)            // 输出: 3    当前: cache: { '[1]': 2, '[2]': 3 }
复制代码


备忘函数中用 JSON.stringify 把传给 adder 函数的参数序列化成字符串,把它当做 cache 的索引,将 add 函数运行的结果当做索引的值传递给 cache,这样 adder 运行的时候如果传递的参数之前传递过,那么就返回缓存好的计算结果,不用再计算了,如果传递的参数没计算过,则计算并缓存 fn.apply(fn, args),再返回计算的结果。


(4)循环输出问题

最后来看一个常见的和闭包相关的循环输出问题,代码如下:

for(var i = 1; i <= 5; i ++){
  setTimeout(function() {
    console.log(i)
  }, 0)
}
复制代码


这段代码输出的结果是 5 个 6,那为什么都是 6 呢?如何才能输出 1、2、3、4、5 呢?


可以结合以下两点来思考第一个问题:

  • setTimeout 为宏任务,由于 JS 中单线程 eventLoop 机制,在主线程同步任务执行完后才去执行宏任务,因此循环结束后 setTimeout 中的回调才依次执行。
  • 因为 setTimeout 函数也是一种闭包,往上找它的父级作用域链就是 window,变量 i 为 window 上的全局变量,开始执行 setTimeout 之前变量 i 已经就是 6 了,因此最后输出的连续就都是 6。

那如何按顺序依次输出 1、2、3、4、5 呢?


1)利用 IIFE可以利用 IIFE(立即执行函数),当每次 for 循环时,把此时的变量 i 传递到定时器中,然后执行,改造之后的代码如下。

for(var i = 1;i <= 5;i++){
  (function(j){
    setTimeout(function timer(){
      console.log(j)
    }, 0)
  })(i)
}
复制代码


可以看到,通过这样改造使用 IIFE(立即执行函数),可以实现序号的依次输出。利用立即执行函数的入参来缓存每一个循环中的 i 值。


2)使用 ES6 中的 letES6 中新增的 let 定义变量的方式,使得 ES6 之后 JS 发生革命性的变化,让 JS 有了块级作用域,代码的作用域以块级为单位进行执行。

for(let i = 1; i <= 5; i++){
  setTimeout(function() {
    console.log(i);
  },0)
}
复制代码


可以看到,通过 let 定义变量的方式,重新定义 i 变量,则可以用最少的改动成本,解决该问题。


3)定时器第三个参数setTimeout 作为经常使用的定时器,它是存在第三个参数的。我们经常使用前两个,一个是回调函数,另外一个是定时时间,setTimeout 从第三个入参位置开始往后,是可以传入无数个参数的。这些参数会作为回调函数的附加参数存在。那么结合第三个参数,调整完之后的代码如下:


for(var i=1;i<=5;i++){
  setTimeout(function(j) {
    console.log(j)
  }, 0, i)
}
复制代码


可以看到,第三个参数的传递,可以改变 setTimeout 的执行逻辑,从而实现想要的结果,这也是一种解决循环输出问题的途径。


相关文章
|
1月前
|
JavaScript 前端开发
js的作用域作用域链
【10月更文挑战第29天】理解JavaScript的作用域和作用域链对于正确理解变量的访问和生命周期、避免变量命名冲突以及编写高质量的JavaScript代码都具有重要意义。在实际开发中,需要合理地利用作用域和作用域链来组织代码结构,提高代码的可读性和可维护性。
|
2月前
|
自然语言处理 JavaScript 前端开发
深入理解JavaScript中的闭包:原理与实战
【10月更文挑战第12天】深入理解JavaScript中的闭包:原理与实战
|
1月前
|
JavaScript 前端开发
js 闭包的优点和缺点
【10月更文挑战第27天】JavaScript闭包是一把双刃剑,在合理使用的情况下,它可以带来很多好处,如实现数据封装、记忆功能和模块化等;但如果不注意其缺点,如内存泄漏、变量共享和性能开销等问题,可能会导致代码出现难以调试的错误和性能问题。因此,在使用闭包时,需要谨慎权衡其优缺点,根据具体的应用场景合理地运用闭包。
111 58
|
1月前
|
自然语言处理 JavaScript 前端开发
[JS]作用域的“生产者”——词法作用域
本文介绍了JavaScript中的作用域模型与作用域,包括词法作用域和动态作用域的区别,以及全局作用域、函数作用域和块级作用域的特点。通过具体示例详细解析了变量提升、块级作用域中的暂时性死区等问题,并探讨了如何在循环中使用`var`和`let`的不同效果。最后,介绍了两种可以“欺骗”词法作用域的方法:`eval(str)`和`with(obj)`。文章结合了多位博主的总结,帮助读者更快速、便捷地掌握这些知识点。
33 2
[JS]作用域的“生产者”——词法作用域
|
1月前
|
前端开发 JavaScript 数据处理
CSS 变量的作用域和 JavaScript 变量的作用域有什么不同?
【10月更文挑战第28天】CSS变量和JavaScript变量虽然都有各自的作用域概念,但由于它们所属的语言和应用场景不同,其作用域的定义、范围、覆盖规则以及与其他语言特性的交互方式等方面都存在明显的差异。理解这些差异有助于更好地在Web开发中分别运用它们来实现预期的页面效果和功能逻辑。
|
1月前
|
JavaScript 前端开发
如何在 JavaScript 中实现块级作用域?
【10月更文挑战第29天】通过使用 `let`、`const` 关键字、立即执行函数表达式以及模块模式等方法,可以在JavaScript中有效地实现块级作用域,更好地控制变量的生命周期和访问权限,提高代码的可维护性和可读性。
|
1月前
|
JavaScript 前端开发
javascript的作用域
【10月更文挑战第19天javascript的作用域
|
1月前
|
缓存 JavaScript 前端开发
js 闭包
【10月更文挑战第27天】JavaScript闭包是一种强大的特性,它可以用于实现数据隐藏、记忆和缓存等功能,但在使用时也需要注意内存泄漏和变量共享等问题,以确保代码的质量和性能。
36 7
|
1月前
|
自然语言处理 JavaScript 前端开发
JavaScript闭包:解锁编程潜能,释放你的创造力
【10月更文挑战第25天】本文深入探讨了JavaScript中的闭包,包括其基本概念、创建方法和实践应用。闭包允许函数访问其定义时的作用域链,常用于数据封装、函数柯里化和模块化编程。文章还提供了闭包的最佳实践,帮助读者更好地理解和使用这一强大特性。
19 2
|
20天前
|
存储 缓存 自然语言处理
掌握JavaScript闭包,提升代码质量与性能
掌握JavaScript闭包,提升代码质量与性能