从一道面试题-计时器函数谈闭包

引子:一道面试题,让实现一个每隔1秒倒计时的函数
试错答案:
(1)

for(var i = 5; i > 0; i--){
  setTimeout(function(){
    console.log(i)
  },i*1000)
}
// 5s内依次打出5个0

当循环完成时才会轮到setTimeout异步执行其回调函数function,此时i已经变成0,故5个console.log(i)里的i全使用的是0。如果目的是为了打印54321,则需要换一种方式,比如用函数参数来保存i即54321(利用闭包)。

(2)

for (var i = 5; i > 0; i--) {
  (function(i) {
    setTimeout(function() {
      console.log(i)
    }, 1000)
  })(i)
} // 1s后一起打出54321

// 或者下边这种方式同理,只是写法稍不同

for(var i = 5 ; i  > 0; i--){
  setTimeout((function(i){
    return function(){
      console.log(i)
    }
  })(i), 1000)
} // 1s后一起打出54321

这次实现了for循环的每一轮i值都分别传进了5个异步setTimeout的回调里,但没有隔1秒分别打印而是一股脑打出,故还需改动setTimeout的第2个参数。

(3)总结上两次错误

for (var i = 5; i > 0; i--) {
  (function(i) {
    setTimeout(function() {
      console.log(i)
    }, (5-i+1) * 1000)
  })(i)
}
// 5s内每隔1秒依次打出54321

正确答案:

function 倒计时(num) {
  for (var i = num; i > 0; i--) {
    (function(i) {
      setTimeout(function() {
        console.log(i)
      }, (num-i+1) * 1000)
    })(i)
  }
}
倒计时(5) // 5秒内每隔1秒依次打印54321

function count(num){
  for (var i=num; i>0; i--) {
    (function(i){
      setTimeout(() => console.log(i), 1000*(num-i+1))
    })(i)
  }
}
count(5) // 5秒内每隔1秒依次打印54321

// 不用for循环的思路
function 计时器(num) {
  var time = num
  function a() {
    if (time === 0) return
    setTimeout(function() {
      a()
    },1000)
    console.log(time)
    time--
  }
  a()
}
计时器(5) // 5秒内每隔1秒依次打印54321

上题除了es5的自执行函数解法外,还有其他思路:

法2: es6的let块极作用域

for循环头部的let声明会有一个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。for循环头部的let不仅将i绑定到for循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。

function count(num){
  for (let i=num; i>0; i--) {
    setTimeout(() => console.log(i), 1000*(num-i+1))
  }
}
count(5)  // 5秒内每隔1秒依次打印54321

法3: 地址传递

function count(num){
  var out = (i) => {
    setTimeout (() => console.log(i), 1000*(num-i+1))
  }
  for (var i=num; i>0; i--) {
    out(i)
  }
}
count(5)  // 5秒内每隔1秒依次打印54321

法4: Promise.all()方法

function count(num) {
  var arr = []
  var output = (i) => new Promise(resolve => {
    setTimeout(()=>{
      console.log(i)
      resolve()
    }, 1000*(num-i+1))
  })
  for (var i=num; i>0; i--) {
    arr.push(output(i))
  }
  Promise.all(arr)
}
count(5) // 5秒内每隔1秒依次打印54321

法5: async await方法

function sleep(){
  return new Promise((resolve)=>{
    setTimeout(resolve,1000);
  })
}
async function count(num){
  for(let i=num;i>0;i--){
    await sleep();
    console.log(i)
  }
}
count(5) // 5秒内每隔1秒依次打印54321

法6: setInterval

function count(num) {
  var i = num
  var fn = setInterval(function () {
    console.log(i--)
    if (i <= 0) {
      clearInterval(fn)
    }
  }, (num-i+1) * 1000)
}
count(5) // 5秒内每隔1秒依次打印54321

JavaScript的变量作用域:函数内部可以直接读取全局变量,在函数外部无法读取函数内的局部变量。"链式作用域"结构(chain scope)即,子对象会一级一级地向上寻找所有父对象的变量,父对象的所有变量,对子对象都是可见的,反之则不成立。

MDN上说:闭包是函数和声明该函数的词法环境的组合。阮一峰老师的理解是:闭包就是能够读取其他函数内部变量的函数。由于在Javascript语言中,只有函数内部的子函数才能读取局部变量,因此可以把闭包简单理解成"定义在一个函数内部的函数"。

无论通过何种手段,只要将内部函数传递到所在的词法作用域以外,它都会持有对原始作用域的引用,无论在何处执行这个函数都会使用闭包。

闭包最大的特点,就是它可以“记住”诞生的环境。

for (var i = 5; i > 0; i--) {
  (function(i) {
    setTimeout(function() {
      console.log(i)
    }, (5-i+1) * 1000)
  })(i)
}
// 5s内每隔1秒依次打出54321

上题中for循环执行的5次操作中(i=5,i=4,i=3,i=2,i=1)每一次都将当次i值传入当轮的立即执行函数(function(i){...})(i)中,分别是(function(i){...})(5)、(function(i){...})(4)、(function(i){...})(3)、(function(i){...})(2)、(function(i){...})(1)。

i=5 那一轮循环执行时其内的(function(i){...})(i)记住了它的诞生环境,故它的i为5;同理i=4那一轮循环执行时其内的(function(i){...})(i)记住了它的诞生环境,其i为4;...直到最后一轮i=1 时的(function(i){...})(i)记住了i为1。

下面再以另一个例子写法1来解释闭包:

function test(num) {
  return function () {
    return num++;
  };
}
 
var inc = test(5);
 
inc() // 5
inc() // 6
inc() // 7

test函数实际上结果是返回一个function(){...},var inc = test(5)后inc实际上等于function(){return 5++} , 执行 inc()一次相当于调用function(){...}一次,此时return 5,再执行inc()一次相当于又调用了一次function(){...}故return 6,再执行一次inc()就return7。

注:调用function(){...}一次即——(function(){...})()

(function() {
  console.log(2)
})() 
// 打印2(这是个匿名函数的立即执行写法)


闭包内部函数记住了第一次传的参数5,并且每次调用都是在这个5的基础上+1,所以连续三次后num的值变成了7。将上述函数改写成下面写法2可更直观理解:

function test(num) {
  var temp = num
  return function () {
    return temp ++;
  };
}
 
var inc = test(5);
 
inc() // 5
inc() // 6
inc() // 7

这种写法2与写法1实际上是一样效果的。 var inc = test(5)后inc实际上是返回的function(){...},再执行inc()实际上是调用了function(){...}。这个匿名函数function(){...}内的temp在自己的作用域内没有定义,则会沿着作用域链向它的父函数(包裹函数)中找,找到了var temp = num(传进来的5),此时这个内部的闭包函数就会将找到的保存在自己的作用域中,故闭包中存在了temp为5的这个量,随后执行temp++使得temp=6;
再下一句inc()时,实际上是执行了test(5)(),即(function(){...})(),test函数的 num参数的确又变为了5,但当它执行到内部的闭包函数时,发现闭包函数的作用域内已经有temp值了(为6),就不会再沿作用域链向其父对象查找了,所以就导致第二次传参的时候用户传任意值都没有用,因为它内部闭包已经“记住”了第一次传的值。

(function() {
  console.log(2)
})(6)
// 打印2
// inc()后又一句inc(50)相当于(function(){...})(50),调用的该匿名函数并未定义入参故你传进6还是50还是别的数都没用,都是()调用而已


function test(num) {
  return function () {
    return num++;
  };
}
 
var inc = test(5);
 
inc(5) // 5
inc(50) // 6
inc(500) // 7

当我们理解了闭包的概念后,就会出现一种需求,我们并不需要多次调用闭包函数,甚至并不需要闭包的返回值,仅仅是需要它执行一次,但函数声明后加()会报错,如function a(){/* code */}() //SyntaxError: Unexpected token (

当JavaScript在读取代码时,看到function关键字后,就已经认为这是一个函数声明了,而在函数声明之后,是不可以直接加()来执行的。我们要做的就是让js知道这不是一个函数声明即可。可以用圆括号将整个函数包起来再(),更可以使用简单的判断符号来处理。这样的做法,就称为立即执行函数

(function outerFn(){ console.log(1) })() // 打印1
!function outerFn(){ console.log(2) }() // 打印2
~function outerFn(){ console.log(3) }() // 打印3
true && function outerFn(){ console.log(4) }() // 打印4

一般情况下,只对匿名函数使用这种立即执行函数。它的核心是闭包,实现的目的有以下几个:不必为函数命名,避免污染全局变量;内部形成单独的块级作用域,封装一些私有变量;内部变量执行完即销毁,不会占用更多的内存。


现在我们再回到开头面试题解答不用for循环的那一种思路来复盘一下:

// 不用for循环的思路
function 计时器(num) {
  var time = num
  function a() {
    if (time === 0) return
    setTimeout(function() {
      a()
    },1000)
    console.log(time)
    time--
  }
  a()
}
计时器(5) // 5秒内每隔1秒依次打印54321

运行“计时器(5)”时,其内部的同步代码a()运行,a函数中自己没有定义time变量就沿着作用域链向上找,找到其父环境中定义的 var time = 5 便继续运行,遇到setTimeout异步先放进下一个宏任务,接着运行当前宏任务 console.log(5),再执行5--此时time值变为4,当前闭包函数将找到的time保存在自己的作用域里。注意:此时语句 “计时器(5)”已经执行完毕,按理来说其内局部变量应该在调用完毕后从内存中清理掉,然鹅a函数还存在内存中,a的存在依赖于“计时器”函数,因此“计时器”中的time 也始终存在于内存中,并不会在调用结束后被垃圾回收机制回收。(这就是闭包造成的)
该宏任务完成后,1秒后运行之前塞进TaskQueue里等待的setTimeout的回调函数即a(),此时再执行一遍a函数,运行到有关time值的语句时因为自己的作用域内已经存了time=4了便不再向上一级查找了,打印4...依此类推打出321 。

//  写成return写法也可以达到一样效果,就是调用方式对应改一下
function 计时器(num) {
  var time = num
  function a() {
    if (time === 0) return
    setTimeout(function() {
      a()
    },1000)
    console.log(time)
    time--
  }
  return a
}
计时器(5)() // 5秒内每隔1秒依次打印54321

闭包的用途:最大用处有两个,一个是让这些变量的值始终保持在内存中(暂存数据),一个是可以读取函数内部的变量(封装数据)

用途2如下例:

function f1(){
  var n=999;
}
alert(n); // Uncaught ReferenceError: n is not defined

// 在函数外部无法读取函数内的局部变量

// 出于种种原因,我们有时候需要得到函数内的局部变量。正常情况下,这是办不到的,只有在函数的内部,再定义一个函数。
function f1(){
  var n=999;
 function f2(){
   alert(n); // 999
 }
}

函数f2就被包括在函数f1内部,这时f1内部的所有局部变量,对f2都是可见的。但是反过来就不行,f2内部的局部变量,对f1就是不可见的。这就是Javascript语言特有的"链式作用域"结构(chain scope),子对象会一级一级地向上寻找所有父对象的变量。所以,父对象的所有变量,对子对象都是可见的,反之则不成立。

既然f2可以读取f1中的局部变量,那么只要把f2作为返回值,我们不就可以在f1外部读取它的内部变量了吗!

function f1(){
  var n=999;
 function f2(){
  alert(n); 
 }
 return f2;
}
var result=f1();
result(); // 999


// 或者写成下方这种也一样弹出999
function f1(){
  var n=999;
 function f2(){
  alert(n); 
 }
 f2()
}
f1()

f2函数,就是闭包。

用途1(暂存数据)的例子呢,如下:

function header() {
  var temperature = 0
  function fn () {
    temperature++
    console.log(temperature)
  }
  return fn
}

var tempUp = header()
tempUp() // 打印1
tempUp() // 打印2

var temp2 = header()
temp2() // 打印1

在JavaScript中,若一函数执行完成后其中的局部变量再不使用的话就会被浏览器自动回收。但如果它还继续被使用,就会导致它无法被回收。上例中, return fn 导致fn内的内存无法被释放,局部变量被存起来可让后续的接口来操作改变它,达到了封装的效果。这就体现了闭包的用途:把变量暂存起来供后续使用。

根据垃圾回收机制,正常函数调用完后内部的变量就会销毁,但闭包却能使本该销毁的变量一直保留(因为这个变量要在别处被闭包使用)。所以闭包并不会造成内存泄漏(指用不到的变量依旧占据内存空间的现象),造成内存泄露副作用的是在使用IE浏览器的情况下才有的Bug。


最后,再次引用yck大佬的归纳来总结闭包:

函数A内部有一个函数B,函数B可以访问到函数A中的变量,那么函数B就是闭包。

function A() {
  var a = 1
  window.B = function () {
    console.log(a)
  }
}
A()
B() // 1

所以上闭包并不一定就非得是“函数嵌套了函数再返回一个函数”。在JavaScript中闭包存在的意义就是让我们可以间接访问函数内部的变量。


本文引用参考以下文章:

编辑于 2019-11-06 20:15