JS基础系列之 —— 你不知道的闭包

·  阅读 347

一些书籍中对于闭包的定义

  • 闭包是指有权访问另一个函数作用域中的变量的函数。 ——《JavaScript高级程序设计》

  • 闭包允许函数访问并操作函数外部的变量 。——《JavaScript忍者秘籍》

  • 闭包使得函数可以继续访问定义时的词法作用域。 ——《你不知道的JavaScript》

  • 所有JavaScript函数都是闭包。 ——《JavaScript权威指南》

这些书籍基本上谈及的大多是闭包有什么能力,但没有谈及概念本身。今天我们主要谈论:

  • 「 闭包 」从哪嘎达来?

  • 「 闭包 」是啥?

  • 「 闭包 」有啥用?

闭包是 「 函数式编程 」这一流派产生出来的概念。今天的话我们也是从这个点切入去谈论一下闭包。

「 闭包 」从何出来?

呱呱落地 (一篇论文与闭包的诞生 1964)

对于闭包第一次出现是什么时候,比较权威的文献我们可从维基百科中搜索 「 闭包 」可以看到

彼得·兰丁(Peter Landin)在1964年将术语「闭包」定义为一种包含环境成分和控制成分的实体,用于在他的SECD机器上对表达式求值。

PETER LANDIN 彼得 · 兰丁 介绍

  • 计算机科学家

  • 《The Next 700 Programming Languages》

  • 提出了λ演算可被用作计算机程序语言的模型,推动了函数式编程这一流派的出现。

  • 《The Mechanical Evaluation of Expressions》(1964)中提出「闭包」 的定义。

我们可以通过百度学术搜索查看 Peter Landin 的 The mechanical evaluation of expressions

在这片论文中提到了「 闭包 」的定义,翻译过来大概是一下内容:

后面慢慢介绍。

闭包的定义是在 1964年,但它的发展也不是一天就出现的,也是要经过一个过程的,这个过程大概分三个阶段。

  • 「 萍水相逢 」- Lisp 1.5 与「 闭包 」

  • 「 情投意合 」- Scheme 与「 闭包 」

  • 「 如胶似漆 」- JavaScript 与「 闭包 」

下面我就通过一些故事大概讲一下这个过程吧!

1、萍水相逢

Lisp 1.5 束缚自由变量

在 1979年 《History of Lisp》回忆录中,在创建 Lisp 中的过程中,谈到一个小故事。

故事

大麦在刚开始创建 Lisp 的时候并没有把它当成一门编程语言,其实就是做成类似于记号一样的东西,但是大麦有一个非常厉害的学生。

史帝芬·罗素 (小罗) 把 Lisp 变成了一门可以在计算机上运行的编程语言。

大麦和小罗的对话

大麦:Lisp 解释器这东西不好搞啊!

小罗:给个机会!能成!

大麦:行吧!你折腾去吧!

问题:动态作用域计算时造成的一个错误。

大麦:有个小 bug 解决一下

小罗:我加个局部的静态作用域试试?

我们知道Lisp 是一个动态作用域的语言。一旦有修饰符的地方就会变成静态作用域。相当于在这个地方给这个变量限定了一个范围,这个其实呢就是闭包的一个雏形。

闭包我们经常听到的就是 (闭包解决的主要就是内部环境引用外部环境变量的问题 )闭包中的函数具有可以访问外部作用域中变量的能力。网上很多这样的说法。但是不幸的是,早期的Lisp 是禁用了这个能力。函数只能访问自己作用域中的变量。

因为这个动态作用域如果它想要访问其它作用域的话,就需要维护一个符号表,这个符号表呢就是特别复杂的一个东西。所以说,这个问题其实是根上的一个问题。和Lisp 动态作用域有关,也就导致了 Lisp 和 「 闭包 」擦肩而过,差一点就出现闭包了。

后来为了解决动态作用域的问题,催生了一门使用静态作用域的函数式编程语言。就是 Scheme。也就是接下来简单谈一谈的。

2、一见钟情

Scheme 与闭包

静态作用域

因为 「 闭包 」 和 Scheme 配合的特别好,闭包也是在 Scheme 中慢慢被人们接受,慢慢到后来的发扬光大。然后到后来的 JS 也采用了闭包。关于这块 ,我们不谈论太多,我们主要还是谈论 JS 中的闭包,大家有个印象就好。感兴趣的话,有一本书 叫 《SICP》,中文名叫 《计算机程序的构造和解释》,有兴趣可以看看。

要值得注意的是,《SICP》中也提到了闭包,但是那个闭包和我们今天提到的闭包。因为数学学科中有闭包的概念,计算机科学中也有闭包的概念。虽然同名,但不是一个东西。《SICP》中的闭包指得是数学学科中的闭包。我们这谈的 Scheme 中的闭包,他是计算机科学中的闭包。如果看的话注意一下哈。

book.douban.com/subject/114…

3、如胶似漆

JavaScript 与闭包

为什么说 JS 中的概念是挺重要的概念呢?

首先,JS借鉴了 Scheme 中的很多东西。比如说:函数是一等公民,把函数当作一个值来传递,可以作为另一个函数的参数或者返回值。

另外呢,JS 从 Scheme 借鉴了静态作用域。所以 闭包就变得非常理所当然了。

但是呢,JS 中的闭包和之前的编程语言中的闭包还是有一些不同。换句话说,JS 在一定程度上也扩展了闭包。这个呢我们等会讲闭包是什么?看 MDN 上对闭包的定义会看到这个事。

小结

  • 呱呱落地 :彼得·兰丁在1964年发表的一篇论文中第一次定义了闭包

  • 萍水相逢 :Lisp 1.5 ,闭包的雏形,由于两人不合适 (Lisp 动态作用域),没走到一起

  • 一见钟情 :Scheme 与闭包,天作之合(Scheme 静态作用域)

  • 如胶似漆 :JavaScript 与闭包,属于扩展了闭包的能力,涉及实现,一会详细讲解

「 闭包 」是什么?

我们从两个角度去讲

  1. λ演算: 彻底理解闭包。

  2. JavaScript 中闭包的实现: 彻底理解 MDN 中对于 「 闭包 」的定义。

λ演算中的闭包

变量 抽象 应用 自由变量 开放表达式

首先,我们为什么要从 λ演算的角度去讲闭包,λ演算是什么?其实就是我们整个函数式编程的根基。Vue3, React 都在往函数式组件的趋势上靠拢。今天呢,我们了解一下 λ演算也还是挺好的。

我们看下面这段话,其实就是1964年彼得·兰丁在论文中提到的「 闭包 」的定义。

闭包,包含了一个 λ表达式 和它所被计算所需的相关环境。

我们就不得不谈一下 λ验算。

我们在深入之前,先了解一下阿隆佐·邱奇。他创造了 λ表达式。

λ 演算,你是知道的,但你以为你不知道。

λ 演算

最简单 λ演算语法

  1. 变量

  2. 抽象

  3. 应用

变量

  • 语言描述:可绑定值得东西

  • 数学形式:x (一个符号)

  • JavaScript:x (一个标识符)

  • λ演算中:x(一个符号)

既然变量是可以绑定值的,那么λ演算中变量就可分为两类变量

  • 约束变量:绑定了值得变量(已赋值的变量)

  • 自由变量:未绑定值得变量(未赋值的变量)

抽象 (定义函数)

  • 语言描述:输入一个值,输出这个值加 1 后的值

  • 数学形式:F(x)= x + 1

  • JavaScript:x => x + 1

  • λ演算:λx.x+1

抽象 (定义函数)

  • 语言描述:输入一个值,输出这个值加 1 后的值

  • 数学形式:F(x)= x + 1

  • JavaScript:x => x + 1

  • λ演算:λx.x+1

应用 (调用函数)

  • 语言描述:值传给函数并执行

  • 数学形式:F(8)

  • JavaScript:(x => x + 1)(8)

  • λ演算:(λx.x+1)8

那么如果传入的是两个值呢?如何用λ演算表示?

JS写法:x => y => x + y
λ演算写法:(λx.(λy.x+y)y)x

λ演算写法:(λx.(λy.x+y)2)1  // 3
复制代码

(λx.x+y)1 其中 y 是一个自由变量,我们不知道它的值,这种包含自由变量的表达式就是一个开放表达式。 对于这个表达式,只知道 x 的值,不知道 y 的值。那我们如何找到 y 从而计算这个表达式的值呢?

假设程序是没有任何错误的,y 是实际存在的,只不过不在当前表达式中。

两种方式

这种方式在运行时获取,之前提到的 Lisp 语言,因为是动态作用域,运行时变量才获取到值,要实现的话必须要用符号表来实现,但是它的维护成本太高,那么有没有其他的方式呢?

那么我们可以推理出:

闭包,包含了一个 λ表达式 和它所被计算所需的相关环境。

闭包 = 开放 λ表达式 + 使得开放表达式闭合的一个环境。

闭包 = 函数 + 使得函数中每一个自由变量都获得绑定值的一个环境。

闭包 = 函数 + 环境。

函数是包含自由变量的函数。

环境是使所有自由变量都获得绑定值的环境。

JavaScript 中的闭包

词法环境和 [[Environment]]

MDN 中闭包的概念:

  1. 一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。

  2. 也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。

  3. 在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

    function outFun(){ var a = 1; function inFun(){ a++; console.log(a); } return inFun; }

    var funA = outFun(); var funB = outFun(); funA(); // 2 funB(); // 2 funB(); // 3 funB(); // 4

「 闭包 」有啥用?

1、防抖

在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新计时。

场景:

  1. 登录、发短信等按钮避免用户点击太快,以致于发送了多次请求,需要防抖

  2. 调整浏览器窗口大小时,resize 次数过于频繁,造成计算过多,此时需要一次到位,就用到了防抖

  3. 文本编辑器实时保存,当无任何更改操作一秒后进行保存

  4. input 框实时搜索并发送请求展示下拉列表

    /**

    • 防抖
    • @param {Function} func 目标函数
    • @param {Number} wait 等待时间
    • @param {Boolean} immediate 是否立即执行一次 */ export const debounce = (func, wait, immediate = true) => { let timer; return function(...args) { const context = this; const _args = args; if (timer) { clearTimeout(timer); } if (immediate) { if (!timer) func.apply(context, args); timer = setTimeout(() => { timer = null; }, wait); } else { timer = setTimeout(() => { func.apply(context, _args); }, wait); } }; };

深入浅出防抖函数 debounce

2、节流

每隔一段时间,只执行一次函数(单位时间内事件只能触发一次)。

场景:

  1. scroll 事件,每隔一秒计算一次位置信息等

  2. 浏览器播放事件,每隔一秒计算一次进度信息等

  3. 做商品预览图的放大镜效果时,不必每次鼠标移动都计算位置

    /**

    • 事件节流
    • @param fn 事件函数
    • @param delay 节流时间 */ export const throttle = (fn, delay = 100) => { let timer = null; return (...args) => { const that = this; const arguments = args; if (timer) { return; } timer = setTimeout(() => { fn.apply(that, arguments); timer = null; }, delay); }; };

深入浅出节流函数 throttle

3、封装私有变量

function People(num) { // 构造器
  var lives = num;
  this.getLives = function() {
    return lives;
  };
  this.addLives = function() {
    lives++;
  };
  this.subLives = function() {
    lives--;
  };
}
var xiaoming = new People(5); // new方法会固化this为xiaoming哦
xiaoming.addLives();
console.log(xiaoming.lives);      // undefined
console.log(xiaoming.getLives()); // 6
var xiaohong = new People(20);
console.log(xiaohong.getLives()); // 20
复制代码

闭包常见的用法,封装私有变量。用户无法直接获取和修改变量的值,必须通过调用方法;并且这个用法可以创建只读的私有变量。

4、延长外部函数局部变量的生命周期

koa2框架的中间件实现原理

function compose (middleware) {
  // 提前判断中间件类型,防止后续错误
  if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
  for (const fn of middleware) {
    // 中间件必须为函数类型
    if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
  }
  return function (context, next) {
    // 采用闭包将索引缓存,来实现调用计数
    let index = -1
    return dispatch(0)
    function dispatch (i) {
      // 防止next()方法重复调用
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        // 包装next()返回值为Promise对象
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        // 异常处理
        return Promise.reject(err)
      }
    }
  }
}
复制代码

5、即时函数与闭包的结合使用

A. 类库包装

// 下方的代码展示了,为什么jquery库中,它可以放心的用jquery而不担心这个变量被替换
(function(){
  var jQuery = window.jQuery = function() {
    // Initialize
  };
  // ...
})()
复制代码

B. 模块化

例 NodeJS 模块化原理:

NodeJS 会给每个文件包上这样一层函数,引入模块使用 require,导出使用 exports,而那些文件中定义的变量也将留在这个闭包中,不会污染到其他地方。

(funciton(exports, require, module, __filename, __dirname) {
    /* 自己写的代码  */
})();
复制代码

C. 简洁代码

// 例如有如下data
data = {
  a: {
    b: {
      c: {
        get: function(){},
        set: function(){},
        add: function(){}
      }
    }
  }
}
// 第一种调用这三个方法的代码如下, 繁琐
data.a.b.c.get();
data.a.b.c.set();
data.a.b.c.add();
// 第二种方法如下, 引入多余变量
var short = data.a.b.c;
short.get();
short.set();
short.add();
// 第三种使用即时函数 优雅
(function(short){
  short.get();
  short.set();
  short.add();
})(data.a.b.c)
复制代码

六、思考题

思考一:函数作为返回值被传递

// 1. 函数作为返回值被传递
function create() {
    let a = 100;
    return function() {
        console.log(a);
    };
}
let fn = create();
let a = 200;
fn(); // 100
复制代码

思考二:函数作为参数被传递

// 2. 函数作为参数被传递
function print(fn) {
  var a = 200;
  fn();
}
var a = 100;
function fn() {
  console.log(a);
}
print(fn); // 100
复制代码

思考三:

 function fun(n, o) {
     console.log(o);
     return {
         fun: function(m) {
             return fun(m, n);
         }
     };
 }
 
// 第一部分
 var a = fun(0);             // ?
 a.fun(1);                   // ?
 a.fun(2);                   // ?
 a.fun(3);                   // ?
 
// 第二部分
 var b = fun(0).fun(1).fun(2).fun(3);        // ?
 
// 第三部分
 var c = fun(0).fun(1);      // ?
 c.fun(2);                   // ?
 c.fun(3);                   // ?
复制代码

分析:

1、第一部分

var a = fun(0); a.fun(1); a.fun(2); a.fun(3);

可以看出,第一个fun(0)是在调用第一层fun函数。第二个fun(1)是在调用前一个fun的返回值的fun函数,所以:后面几个fun(1),fun(2),fun(3),函数都是在调用第二层fun函数。

所以:

第一次调用fun(0)时,o为undefined;

第二次调用fun(1)时m为1,此时fun闭包了外层函数的n,也就是第一次调用的n=0,即m=1,n=0,并在内部调用第一层fun函数fun(1, 0),所以o为0;

第三次调用fun(2)时m为2,但依然是调用a.fun,所以还是闭包了第一次调用时的n,所以内部调用第一层的fun(2, 0),所以o为0;

第四次同理;

即最终答案为 undefined, 0, 0, 0。

2、第二部分

var b = fun(0).fun(1).fun(2).fun(3); //undefined,?,?,?

先从fun(0)开始看,肯定是调用的第一层fun函数,而他的返回值是一个对象,所以第二个fun(1)调用的是第二层fun函数,后面几个也是调用的第二层fun函数。

所以:

第一次调用第一层fun(0)时,o为undefined;

第二次调用 .fun(1)时m为1,此时fun闭包了外层函数的n,也就是第一次调用的n=0,即m=1,n=0,并在内部调用第一层fun函数fun(1, 0),所以o为0;

第三次调用 .fun(2)时m为2,此时当前的fun函数不是第一次执行的返回对象,而是第二次执行的返回对象。而在第二次执行第一层fun函数时是(1, 0),所以n=1,o=0,返回时闭包了第二次的n,遂在第三次调用第三层fun函数时m=2,n=1,即调用第一层fun函数fun(2, 1),所以o为1;

第四次调用 .fun(3)时m为3,闭包了第三次调用的n,同理,最终调用第一层fun函数为fun(3,2),所以o为2;

即最终答案:undefined, 0, 1, 2。

3、第三部分

var c = fun(0).fun(1); c.fun(2); c.fun(3);//undefined,?,?,?

根据前面两个例子,可以得知:

fun(0)为执行第一层fun函数,.fun(1)执行的是fun(0)返回的第二层fun函数,这里语句结束,c存放的是fun(1)的返回值,而不是fun(0)的返回值,所以c中闭包的也是fun(1)第二次执行的n的值。c.fun(2)执行的是fun(1)返回的第二层fun函数,c.fun(3)执行的也是fun(1)返回的第二层fun函数。

所以:

第一次调用第一层fun(0)时,o为undefined;

第二次调用 .fun(1)时m为1,此时fun闭包了外层函数的n,也就是第一次调用的n=0,即m=1,n=0,并在内部调用第一层fun函数fun(1, 0),所以o为0;

第三次调用 .fun(2)时m为2,此时fun闭包的是第二次调用的n=1,即m=2,n=1,并在内部调用第一层fun函数fun(2, 1),所以o为1;

第四次.fun(3)时同理,但依然是调用的第二次的返回值,遂最终调用第一层fun函数fun(3, 1),所以o还为1

即最终答案:undefined, 0, 1, 1。

分类:
前端
收藏成功!
已添加到「」, 点击更改