从一道看似简单的面试题重新理解JS执行机制与定时器

作者: bbsmax 发布时间: 2019-09-10 浏览: 2085 次 编辑

 壹 ❀ 引

最近在看前端进阶的系列专栏,碰巧看到了几篇关于JS事件执行机制的面试文章,因为我在之前一篇 JS执行机制详解,定时器时间间隔的真正含义 博文中也有记录JS执行机制,所以正好用于作为测试自己的理解情况,那么本文顺着题目来重新理一理思路,说说我对于题目的理解,扩充知识点。

本文站在你对于JS执行机制与定时器已经有所了解的前提下展开,若非如此,建议先了解相关概念会更好,那么本文开始。

 贰 ❀ 一道变化的面试题

 题目一:

说说以上代码输出什么?

没错,这只是一个非常简单的for循环,依次输出0 - 4;我想大家对于for循环一定都非常熟悉,这里通过步骤拆解简单展示下for循环的执行步骤:

变量 i 从头到尾就只声明了一次,然后开始第一次条件判断,满足条件执行代码体,之后 i 自增,继续条件判断,如果条件不满足则跳出循环。

好了,题目升级,我们将for循环内部改成一个定时器,现在会输出什么呢?

 题目二:

我想稍微有看过类似笔试题的同学,应该都知道,大约在等待一秒钟后,同时输出五个5。原因是定时器是异步任务,for循环的每次循环虽然都会创建一个定时器,但并没有同步执行,而是等到for循环执行完毕后,统一执行了五个定时器,而此时变量 i 早已自增为5。

我们用步骤拆分模拟执行,如下:

由于定时器是异步任务,我们可以理解为最后执行,所以真正的样子应该是这样:

此时 i 已经自增为5。那么有同学又要问了,为什么是等待一秒后同时输出五个5,而不是每隔一秒输出一个5呢?这就得明白定时器时间真正表示的含义,我们看下面这个例子:

请问上述代码是每隔三秒输出一个1呢,还是等待三秒后同时输出两个1呢?

答案是后者,如果你觉得答案是前者,那是因为你误会了定时器的时间含义,3000ms并不是定时器执行前的等待时间,而是将定时器中的回调函数加入任务队列前的等待时间。

我们这个世界只有一条时间线,也没发现平时世界,程序也是如此,3秒倒计时后,两个定时器的回调函数接近同时被加入到了任务队列,因为回调执行耗时可以忽略不计,所以就像同时输出了。

我们通过一个例子来验证这一点,如下:

请问谁先执行?时间间隔又是怎么样?

答案是大约等待一秒后先输出2,再等待大约2秒输出1;原因是1秒过后,第二个定时器回调先加入任务队列,再过2秒将第一个定时器回调加入任务队列,然后开始执行。而任务队列具有FIFO(先进先出)的特性,我们忽略回调执行耗时,也就是1S=>1=>2S=>2这个结果了。

若你对于JS执行机制或者定时器执行这块有疑虑,可以阅读博主关于JS执行机制的博客,一定会对你有所帮助:JS执行机制详解,定时器时间间隔的真正含义

好了,第二题拓展说了稍微有点多,我们将题目二再变形,如下,请说说题目三输出结果是什么,时间间隔是多少?

 题目三:

结合前面对于for循环的拆分,以及对定时器时间含义重新了解,答案是几乎无等待的先输出一个5,之后每隔一秒再输出四个5。

有同学肯定又要问了,前面你不是说定时器运行时变量 i 已经是5了吗,照理说不是应该等待五秒之后同时输出五个5吗?如果你是这么认为的,那是因为你没理解定时器的执行规则。

准确来说,定时器异步执行的只是定时器中的回调函数,定时器的时间可不会异步,这里只是将固定的时间换成了一个简单乘法计算而已,所以时间计算在运行到定时器时就已经同步计算完毕了,我们改写代码应该是这样:

OK,我们对于定时器的理解又更进了一步,那么问题来了,请以题目三为原型做出修改,让for循环先输出0之后每隔一秒依次输出1,2,3,4。做法其实有多种,先自己想想再看答案:

 1.我们可以利用闭包:

上述代码中我们利用一个自执行函数包裹了定时器,而定时器中的回调函数引用了外层自调函数的形参 i ,所以此时定时器的回调函数是一个闭包。

有趣的事情来了,当创建每个自执行函数时变量 i 都会作为参数立刻传入到自执行函数体内,此时 i 已经和函数作用域绑定到了一起,而定时器回调函数引用了外层函数的 i ,这样就达到我们想要的目的了,我们改写代码:

没错,定时器是异步,它应该最后执行,但JS采用的是静态作用域,函数在定义时,它的作用域就已经被确定了,不管它在何处被调用,它能访问的外层作用域就是被创建时所在的作用域,我们再次改写:

虽然定时器的回调看着是最后执行,但它在执行时由于自己没有 i ,所以只能从父级作用域找,而它的父级就是创建它的自执行函数。

 2.我们可以利用按值传递特性

我们知道JS中基本类型的数值作为函数参数时都是按值传递的,什么意思呢,通过一个例子来解释:

上述代码中,我声明了一个基本类型的变量 x 与一个引用类型的数组 y,作为参数传入到了函数中,分别对x y进行了修改,函数执行完毕之后,x y会发生变化吗?

直觉告诉我们,x不会变化,而 y 被修改了,这有点类似与深浅拷贝,当基本类型的数据作为函数参数时总是按值传递,就像额外拷贝了一份进去,而引用类型的数据传递的其实是一个引用地址,任何操作都会修改原有的数据。

懂了这个就好办了,我们直接声明一个函数,在for循环中将变量 i 作为调用函数的参数就好了,像这样:

是不是有点把闭包自执行函数移到外面的感觉,原理类似,按值传递居然这么好用。

 3.我们可以利用ES6的let

当for循环中使用let去声明变量 i 时,利用块级作用域的特性,让每次循环的 i 成为独立的一份,直接上代码:

这里我不太好详细解释为何let可以到达目的,如果你对for循环中使用var 和 let声明变量 i 究竟有何不同,以及为何每次循环 i 都是独立的一份有兴趣,欢迎阅读博主 for循环中let与var的区别,块级作用域如何产生与迭代中变量i如何记忆上一步的猜想 这篇文章,顺着我的思路,一定给你整的明明白白。

其实当我们使用let 声明变量 i 时,此时for循环用递归来模拟应该是这样,如果你看不懂以下改写,还是建议阅读我上面推荐的文章。

 4.我们可以使用定时器第三参数

不知道有多少人知道定时器其实还有第三参数,如果我们想给定时器回调函数传递参数,就可以借助第三参数,直接上代码:

在创建定时器时,i 同时还作为回调函数的形参传入了回调,根据按值传递的特性,不管定时器何时执行,i 早与创建时的 i 已经绑定在了一起。是不是很棒,原来定时器第三参数还可以这么使用,又学到了一点。

那么到这里,我们居然掌握了四种做法,利用自执行函数创建闭包,利用按值传值的特性,利用ES6的let,以及利用定时器的第三参数。

好了,既然说到了代码改写,那么问题再次升级:

 题目四

定时器的第一参数由一个普通的回调函数变成了一个自执行函数,其它没什么改变,说说会怎么执行?

答案是无等待的同时输出0,1,2,3,4。说到这可能有同学就有疑问了,输出0-4也就算了,为何输出之间还没间隔了。难道不是把五个自执行函数压入任务队列,然后先输出0后每隔一秒一次输出吗。

我们都知道定时器有两种写法,以setTimeout为例:

我们常用的是写法一,写法二之所以能正常运行,其实是类似于eval让字符串运行了,所以并不推荐第二种写法。而题目三的代码类似与这样:

这段代码的意思是,运行到定时器时,直接将第一参数的函数给执行了,定时器的时间直接不会起作用了。我们可以通过下面的例子来证明这一点:

上述代码几乎无等待的输出1,尽管这是一个周期性定时器,但之后都不会再执行了,因为第一参数不是一个合格的回调函数。

怎么样,对定时器是不是又加深了一点印象,好了,面试题就到此为止了,说了很多,拓展了很多,我们来做个总结。

 叁 ❀ 总结

通过本文的阅读,我们知道了以下知识点

1.定时器是一个异步任务,准确来说,最终异步执行的是定时器的回调函数,倒计时以及定时器本身并非异步。

2.我们理解了定时器时间的真正含义,它并非表示过多久之后执行,而是过多久之后将回调函数加入到任务队列。

3.我们知道了任务队列具有先进先出(FIFO)的特性,不管两个定时器定义先后如何,先加入队列的始终先执行(哪个时间设置的小先执行)。

4.我们了解了定时器其实还有第三参数,它可以为回调函数传递参数。

5.我们知道了定时器回调函数常用的两种写法,以及当回调函数带括号时会造成什么问题。

6.我们知道了函数参数如果是简单类型数据时具有按值传递的特性,以及JS具备静态作用域的概念。

7.我们知道了四种让上方面试题依次输出0-4的改写方法。

最后我还知道,如果你在以后的面试中偶遇了类似的题目,你大概能秀的面试官头皮发麻,那么到这里本文结束。

 肆 ❀ 参考

Excuse me?这个前端面试在搞事!

80% 应聘者都不及格的 JS 面试题