面试题:改造代码,使之隔秒输出0 - 9

通过一道面试题,文末总结一下js【变量快照】和【缓存变量值】的手法。原题考察的是对js作用域和对setTimeout api的理解,在本文主要给出5种解法。


题目的提出: “改造下面的代码,使之隔秒输出0 - 9,写出你能想到的所有解法:”

// 题目代码:
for (var i = 0; i < 10; i++) {
  setTimeout(() => {
    console.log(i);
    // debugger;
  }, i * 1000)
}

考察的是对作用域和对setTimeout api的理解。

首先不急着答题,若想更好地理解,我们思考一下代码未改动前打印结果和原理。

// 首先解释原代码的打印结果:【没有形成闭包】

打印全都是10。没有形成闭包。

因为计数变量 i 它还是属于全局作用域:

for (var i = 0; i < 10; i++) {
  // 故意不写代码,让打印测试结果更加清楚
}
console.log(window.i) // 10
console.log(i) // 10,明显没有闭包,i是全局变量啊,因为是全局变量所以驻留在内存中
setTimeout(() => {
  console.log(i) // 10
}, 2000)

证明:setTimeout引用计数变量i,也没有产生闭包(计数变量还是没有达到闭包的要有“外层函数”的条件):

for(var i = 0; i < 10; i++) {
  setTimeout(function() {
      console.log(i);
      debugger; // 在这里断点,查看是否有闭包
  }, i * 1000);
}

回到本文的正题,这个面试题的解法有哪些呢?

方法一:let / const块级作用域 于for循环

原理:利用 let 变量的特性 — 在每一次 for 循环的过程中,let 声明的变量会在当前的块级作用域里面(for 循环的 body 体,也即两个花括号之间的内容区域)创建一个词法环境(Lexical Environment),该环境里面包括了当前 for 循环过程中的 i,(也就是类似函数作用域的效果)具体链接

另外,记得曾在知乎上看到,方应杭也是这么说的。PS:chrome控制台断点debug能看到block作用域。

for (let i = 0; i < 10; i++) {
  setTimeout(() => {
    console.log(i);
  }, i * 1000)
}
// 等价于:
for (var i = 0; i < 10; i++) {
  let _i = i;// 所以即便在本次循环中篡改_i也不会影响到i
  setTimeout(() => {
    console.log(_i);
    // console.log(++_i);
  }, i * 1000)
}

写法2:let tmp局部变量,需要是块级作用域。一定要let,而var tmp是不行的。

for (var i = 0; i < 10; i++) {
  let tmp = i;
  setTimeout(() => {
    console.log(tmp);
  }, i * 1000)
}

方法二:“外层函数”传入自由变量+setTimeout构建闭包

原理: 这里主要是setTimeout帮助构建闭包。 函数局部作用域在这里也是构建闭包的条件之一。另外,不会因为单纯有IIFE就会有闭包。

// 这个还真的是闭包,不信在chrome debug断点一下:应该是setTimeout回调函数引用了自执行函数的自由变量,导致该自由变量没被回收。
// 不信的话,可以去掉setTimeout,闭包就没了。
for (var i = 0; i < 10; i++) {
  (i => {
    setTimeout(() => {
      console.log(i);
    }, i * 1000)
  })(i)
}

另外一种方式:(后面会补充说到,如果setTimeout的第一个参数是js语句,该语句会被同步执行)

for(var i = 0; i < 10; i++) {
  setTimeout(
    (function(i) {
      return function() {
          console.log(i)
      }
    })(i)
  , i * 1000)
}
// 这样也是同理的:!!!!!!!!!!!
for (var i = 0; i < 10; i++) {
  const cbGenerator = (i) => {
    return function() {
      console.log(i)
    }
  }
  setTimeout(cbGenerator(i), i * 1000);
}

证明:不会因为单纯有IIFE就会有闭包。

IIFE 其实并不属于闭包的范畴: 参考链接如下:

记住,记住,这样是连打印结果都不符合题目要求的:

// 没用:连打印结果都不符合题目要求
for (var i = 0; i < 10; i++) {
  setTimeout(function () {
    (i => {
      console.log(i);
    })(i)
  }, i * 1000)
}

另外,比如这个,因为没有setTimeout,不符题目要求。另外,也因为没有setTimeout,即便有函数按值传递的变量快照的效果,也没有形成闭包:

// 没有形成闭包:
// 这也对照证明了setTimeout是有帮助构建闭包的作用的!!!!!!!!!
for(var i= 0; i < 5; i++){
  console.log('add timeout: ', i);
  (function(index){
      console.log('imitate timeouting: ', index);
      debugger;
  })(i)
}
// 而如果基于此,给log再套多一层自执行,就又有闭包了,你懂的。

方法三:try catch特殊的作用域机制: 即利用其它方式构建出局部作用域

对于js,一般来说只有全局作用域和函数作用域。而这个catch大括号包裹的部分,作用域链的表现类似于函数作用域。非常特别。

for (var i = 0; i < 10; i++) {
  try {
    throw new Error(i);
  } catch ({
    message: i // 为了重命名解构出i变量
  }) {
    setTimeout(() => {
      console.log(i);
    }, i * 1000)
  }
}

方法4:bind返回新函数:利用的是柯里化的缓存效果,没有闭包产生

for (var i = 0; i < 10; i++) {
  setTimeout(console.log.bind(Object.create(null), i), i * 1000)
  // also or: 
  // setTimeout(console.log.bind(console, i), i * 1000)
}

或者:(配合函数按值传递)

for (var i = 1; i <= 5; i++) {
  setTimeout(function timer(i) {
    console.log(i);
  }.bind(null, i), i * 1000);
}

来个demo模拟简化后的代码,反映:的确是柯里化缓存的功劳:

function hey(a,b,c) {
  console.log(this)
  console.log(a,b,c)
  debugger;
}

const foo = hey.bind({}, 1)
foo() // 1 undefined undefined

方法五:利用 setTimeout 函数的第三个参数【IE9之前不兼容该api】

复习一下setTimeout api的语法:setTimeout(function, milliseconds, param1, param2, ...)

第三个参数,会作为回调函数的第一个参数传入。第四第五个参数位同理,以此类推。

神奇的是,它的规律是:缓存这些参数,等到回调触发的时候使用。

代码1:

for (var i = 0; i < 10; i++) {
  setTimeout(i => {
    console.log(i);
  }, i * 1000, i)
}

代码2:

// 注意这段代码的setTimeout的第一个参数并不是完整js语句,不是同步执行的:
for (var i = 0; i < 10; i++) {
  setTimeout(console.log, i * 1000, i)
}

// 拓展知识一:语句作为setTimeout第一个参数位 #无法隔秒打印。不符合本题要求

如果想要更好地理解setTimeout api,那么你需要看看这个章节。

链接:提问的确是这个提问,但是没有人回答得好

原理:很多其它的方案只是把 console.log(i) 放到一个函数里面,因为 setTimeout 函数的第一个参数只接受函数以及字符串,如果是 js 语句的话,js 引擎应该会自动在该语句外面包裹一层函数

setTimeout毕竟不是js规范里面的api,其实是属于浏览器或者node环境的范畴。所以我也没找到对应的文档的说明。经过测试,大体上就是:如果第一个参数位是js语句,就会同步执行。

无法隔秒打印: 虽然能打印0 ~ 9,但是因为是第一个参数如果是语句就同步执行的。第二个参数的延迟时间就发挥不了作用了。因为都是同步执行完,就谈不上闭包了。

// 注意,这个就没法做到每隔一秒打印了,都是同步执行完的。
// 这样chrome控制台没有显示任何闭包:因为,i引用到的是计数变量,也就是这里的全局变量。
for (var i = 0; i < 10; i++) {
  setTimeout(console.log(i), i * 1000)
}
// or:
for (var i = 0; i < 10; i++) {
  setTimeout((() => {
    console.log(i);
  })(), i * 1000)
}

同理:区别只不过是i作为函数的局部变量,(即便用了setTimeout,)因为没有异步执行,没有产生闭包。

for (var i = 0; i < 10; i++) {
  setTimeout((i => { // 同步代码复制了一份局部变量
    console.log(i);
  })(i), i * 1000)
}
// 换种写法:call/apply 按值传参:
for (var i = 0; i < 10; i++) {
  setTimeout((i => {
    console.log(i);
  }).call(Object.create(null), i), i * 1000)
}
// 换种写法同理:
for (var i = 0; i < 10; i++) {
  setTimeout((i => {
    console.log(i);
  }).apply(Object.create(null), [i]), i * 1000)
}
// 换种写法同理:apply类数组
for (var i = 0; i < 10; i++) {
  setTimeout((i => {
    console.log(i);
  }).apply(Object.create(null), { length: 1, '0': i }), i * 1000)
}

总结并拓展:另一种实现(非引用数据类型的)变量快照的方式

我们可以总结一下上述所有【变量快照】或者【缓存变量值】的手法:(手法和每一个解题回答一一对应)

  • let / const+for循环:增加块级作用域 【变量快照】
  • 加中间函数,闭包维持之:增加并维持函数作用域 【变量快照】
  • try catch作用域:特殊的作用域机制 【变量快照】
  • 柯里化/bind使用 【缓存变量值】
  • setTimeout第3个参数 【缓存变量值】

说白了以上就是:要么增设并维持作用域,因此实现【变量快照】,要么就是【缓存变量值】。

而另外一种实现【变量快照】的方式是:借助能解析执行字符串的api,把变量值编进代码里面:(记得吗,eval作用域又是一种特殊的作用域

代码 1:

for (var i = 0; i < 10; i++) {
  setTimeout(eval('console.log(i)'), i * 1000)
}

代码 2,同样的效果:

for (var i = 0; i < 10; i++) {
  setTimeout(new Function('i', 'console.log(i)')(i), i * 1000)
}
// or:
for (var i = 0; i < 10; i++) {
  setTimeout(new Function('console.log(i)')(), i * 1000)
}
文章目录
  1. // 首先解释原代码的打印结果:【没有形成闭包】
  • 方法一:let / const块级作用域 于for循环
  • 方法二:“外层函数”传入自由变量+setTimeout构建闭包
    1. 证明:不会因为单纯有IIFE就会有闭包。
  • 方法三:try catch特殊的作用域机制: 即利用其它方式构建出局部作用域
  • 方法4:bind返回新函数:利用的是柯里化的缓存效果,没有闭包产生
    1. 来个demo模拟简化后的代码,反映:的确是柯里化缓存的功劳:
  • 方法五:利用 setTimeout 函数的第三个参数【IE9之前不兼容该api】
  • // 拓展知识一:语句作为setTimeout第一个参数位 #无法隔秒打印。不符合本题要求
  • 总结并拓展:另一种实现(非引用数据类型的)变量快照的方式