专栏首页前端之旅经典面试题解析

经典面试题解析

本篇博客专门用于收集各类经典面试题,并给出相关的解题思路和原理。

1.考点:块级作用域和闭包

先看一道很经典的面试题

var a=[];
for(var i=0;i<10;i++){
    a[i] = function(){
        console.log(i);
    }
}                     
console.log(a[6]);

如果你认为输出的是6,那么恭喜你答错了。正确答案是10。首先分析一下这段代码的具体执行过程。

var a=[];
var i=0;   
/* 用var声明的变量要么在函数作用域中,要么在全局作用域中,很明显这里是在全局作用域中,
因此认为i是全局变量,直接放在全局变量中。*/
a[0]=function(){
console.log(i);
/* 关键!!这里之所以i为i而不是0;是因为我们只是定义了该函数,并未调用它,所以没有进入
该函数执行环境,i当然不会沿着作用域链向上搜索找到自由变量i的值。*/
}  // 由于不具备块级作用域,所以该函数暴露在全局作用域中。


var i=1;   //第二次循环,这时var i=1;覆盖了前面的var i=0;即现在全局变量i为1;
a[1]=function(){
console.log(i);  //解释同a[0]函数。
}

var i=2;   // 第三次循环,这时var i=2;覆盖了前面的var i=1;即现在全局变量i为2;
a[2]=function(){
console.log(i);
}


......第四次循环 此时i=3  这个以及下面的i不断的覆盖前面的i,因为都在全局作用域中
......第五次循环 此时i=4
......第六次循环 此时i=5
......第七次循环 此时i=6
......第八次循环 此时i=7
......第九次循环 此时i=8   


var i=9;
a[9]=function(){
    console.log(i);
}


var i=10;// 这时i为10,因为不满足循环条件,所以停止循环。

紧接着在全局环境中继续向下执行。

a[6]();
/* 这时调用a[6]函数,所以随即进入a[6]函数的执行上下文环境中,即
function(){console.log(i)}中,此时执行函数中的代码console.log(i),
因为在当前的函数执行上下文中不存在变量i,所以i为自由变量,此时会
沿着作用域链向上寻找,进而进入了全局作用域中寻找变量i,而全局作用域
中的i在循环跑完后已经变成了10,所以a[6]的值就是10了。*/

那么,如果我们想要输出6,应该怎么修改代码呢?两种方法。 1.使用let形成块级作用域,配合闭包使用

var a=[];

{ //进入第一次循环
    let i=0; 
    /*注意:因为使用let使得for循环为块级作用域,此次let i=0
    在这个块级作用域中,而不是在全局作用域中。*/
    a[0]=function(){
      console.log(i);
}; 
/* 注意:由于是用let声明的i,所以使整个块成为块级作用域,又由于a[0]这个函数
引用到了上一级作用域中的自由变量,所以a[0]就成了一个闭包。*/
}
/*声明:这里用{}表达并不符合语法,只是希望通过它来说明let存在时,这个for循环块
是块级作用域,而不是全局作用域。*/    


讲道理,上面这是一个块级作用域,就像函数作用域一样,执行完毕,其中的变量会被销毁,
但是因为这个块级作用域中存在一个闭包,且该闭包维持着对自由变量i的引用,所以在闭包
被调用之前也就是后续为了测试而console.log出a[..]之前,此次循环的自由变量i即0不会
被销毁.

{ //进入第二次循环
     let i=1; 
     /*注意:进入第二次循环即进入第二个代码块,此时处于激活状态的是let i=1。
     它位于与let i=0不同的块级作用域中,所以两者不会相互影响。*/
     a[1]=function(){
         console.log(i);
     }; //同样,这个a[i]也是一个闭包
}

......进入第三次循环,此时其中let i=2;
......进入第四次循环,此时其中let i=3;
......进入第五次循环,此时其中let i=4;
......进入第六次循环,此时其中let i=5;
......进入第七次循环,此时其中let i=6;
......进入第八次循环,此时其中let i=7;
......进入第九次循环,此时其中let i=8;

{//进入第十次循环
    let i=9;
    a[i]=function(){
        console.log(i);
    };//同样,这个a[i]也是一个闭包
}

{
    let i=10;
    /*不符合条件,不再向下执行,导致此次的块级作用域中不存在闭包,导致let i=10
    未像前面的i一样等待被闭包引用,故此次的i没有必要继续存在,随即被销毁。*/
}

a[6]();
/*调用a[6]()函数,这时执行环境随即进入下面这个代码块中的执行环境:
funcion(){console.log(i)};*/
即进入:
{ 
     let i=6; 
     a[6]=function(){
          console.log(i);
     }; //同样,这个a[i]也是一个闭包
}

a[6]函数(闭包)这个执行环境中,它会首先寻找该执行环境中是否存在 i,没有找到,
就沿着作用域链继续向上到了函数所在的块级作用域,找到了自由变量i=6,于是输出了6,
即a[6]()的结果为6。闭包既已被调用,所以整个代码块中的变量i和函数a[6]()被销毁。

2.利用自执行函数 说来惭愧,本来如果明白这道题的原理,应该自然想到可以利用自执行函数达到相同的目的,但是最后还是在群里朋友的点拨下才明白的。 实际很简单,前面我们说过一句很关键的话:

这里之所以 i 为 i 而不是 0;是因为我们只是定义了该函数,并未调用它,所以没有进入该函数执行环境,i 当然不会沿着作用域链向上搜索找到自由变量 i 的值

那么反过来想一想,假如我们在定义了函数之后即刻对其进行了调用,是否此时将会在环境中寻找 i 的值并马上替换掉 console.log(i) 中的 i 呢?是的。要立刻调用函数,用自执行函数就可以,代码如下:

var a=[];
for(var i=0;i<10;i++){
    a[i] = (function(){
        console.log(i);
    })()
}                     

需要注意的是,这里每一次的循环实际上是对当前函数进行一次立即调用,所以在循环的同时对应的值就已经打印出来了,并且这些函数的返回值依次赋值给数组元素。在没有显式指定函数返回值时,默认返回 undefined,因此后续再访问数组元素时只能得到 undefined。

2.考点:连等、解析和引用类型

这是某大厂一道知名的面试题,表面简单但是坑很多。

var a = {n:1};
var b = a;
a.x = a ={n:2};
console.log(a.x);  // undefined
console.log(b.x);  // {n:2}

我们来分析一下这段代码到底是怎么执行的,就会明白为什么结果与我们预想的完全不同,甚至可以说很怪异。

var a = {n:1};
var b = a;

首先,这两句令a和b同时引用了{n:2}对象,接着的a.x = a = {n:2}是关键。尽管赋值是从右到左的没错,但是.的优先级比=要高,所以这里首先执行a.x,相当于为a(或者b)所指向的{n:1}对象新增了一个属性x,即此时对象将变为{n:1;x:undefined}。之后按正常情况,从右到左进行赋值,此时执行a ={n:2}的时候,a重定向,指向了新对象{n:2},而b依然指向的是旧对象,这点是不变的。接着的关键来了:执行a.x = {n:2}的时候,并不会重新解析一遍a,而是沿用最初解析a.x时候的a,也即旧对象,故此时旧对象的x的值为{n:2},旧对象为 {n:1;x:{n:2}},它被b引用着。 后面输出a.x的时候,又要解析a了,此时的a当然是重定向后的指向新对象的a,而这个新对象是没有x属性的,故得到undefined;而输出b.x的时候,将输出旧对象的x属性的值,即{n:2}

3.考点:异步、作用域、闭包

如果无法深入到内部,从原理层面上理解代码的运行机制,那么知识只是浮在表面、浅尝辄止。“同步优先,异步靠边,回调垫底”的口诀可以帮助我们迅速判断,但是我希望用自己刚学习的事件循环机制来解释这道题。 实际上这也是比较普遍的一道面试题:

for (var i = 0; i < 3; i++) {
   setTimeout(function() {
       console.log(i);
     }, 0);
     console.log(i);
 }
 代码最后输出什么?

如果不熟悉异步,很可能直截了当地回答是:0 0 1 1 2 2 。 正确答案应该是 0 1 2 3 3 3 根据事件循环的机制,跑循环和输出i的值都是主线程上的同步任务,既然是同步任务,当然是按照顺序执行,所以0 1 2是容易理解的。那么setTimeout怎么办呢?setTimeout是异步任务,并不在主线程上,而是在宏任务队列里,它必须等待主线程的执行栈清空,才有自己的“一席之地”,才能去执行,所以这里我们直接忽略setTimeout,将前三次循环的setTimeout都挂在任务队列里。之后,循环跑完了,主线程的同步任务结束。此时i变成了3。 轮到任务队列了——> 我们回过头调用setTimeout里的回调函数,进行i的输出。当然,由于i只有一个,即全局变量,所以此时输出的都是3,三次setTimeout即三次3。

如果我们要输出 0 1 2 0 1 2 呢? 其实这里就和第一个考点很像了。这里有三种方法,

1.将var改为let 改为 let 后会形成多个独立的块级作用域,这样,每个setTimeout里的回调函数的i都将对应每一次循环的i(因为是块级作用域)。接着,由于输出和循环依然是同步任务,所以输出 0 1 2;之后轮到任务队列,也是输出0 1 2

2.利用自执行函数 让函数在定义之后就即刻执行,那么函数中的 i 就会指向当前循环的 i,这个 i 的值为多少在那时就已经确定了,而不再是随着跑循环而动态变化。这里又有两种自执行的方法:

for (var i = 0; i < 3; i++) {
     setTimeout((function(i) {
         return function() {
             console.log(i);
         };
      })(i), 0);  
     console.log(i);  
 }

或者

for (var i = 0; i < 3; i++){
    (function (i) {
        setTimeout(function () {
            console.log(i);
        }, 0)
    })(i);
    console.log(i);
   }

一个是将回调函数作为自执行函数,一个是将setTimeout函数作为自执行函数,效果是一样的。

3.利用bind()

for (var i = 0; i < 3; i++) {
   setTimeout(function(i) {
       console.log(i);
     }.bind(null,i), 0);
     console.log(i);
 }

bind() 的第一个参数是 thisArg,用来绑定 this,这里我们不管,直接传参 null,重点在于第二个参数,这个参数也就是回调函数的参数。这里要理解循环做了什么:每一次循环,实际上执行的是 setTimeout() 方法,执行完之后把每次的回调函数挂载在队列里,后续等主任务清空之后,再一一执行。这里添加了 bind() 方法后,每次循环除了挂载回调函数,其实还完成了硬绑定,这时候对应的 i 值已经存在于回调函数的词法作用域里了。所以,后面执行回调函数的时候,每个函数都能在词法作用域中找到自己对应的 i 值。

4.考点:作用域、NFE的函数名只读性

let b = 10;
(function b(){
    b=20;
    console.log(b);
})();
console.log(b);  
// 代码最后输出什么?

如果没有认识到NFE函数的函数名只读性,这道题就会做错。正确答案应该是:

f {
    b=20;
    console.log(b);
}
10

要理解这道题,先来看另一段代码

var c=function b(){
    console.log("234");
    console.log(b);
}
console.log(b)  // b is no defined

首先,这是一个具名函数表达式,即NFE。而NFE的函数名只能在函数内部访问,所以我们将该函数的引用赋给变量c之后,就只能通过c()调用该函数,而不能通过b()调用,更不能访问b。并且还要注意,函数名在函数内部类似于一个const常量,只能访问而不能对它进行修改。

理解这一点之后再来看最开始的代码,这是一段IIFE—–立即执行函数表达式(因为括号是操作符,所以认为括号里的是表达式而不是声明),它同样也是具名函数表达式,自然也有上面的性质。函数自调用,遇到b=20语句时开始在函数作用域中查找b是在哪里声明的,结果发现就是函数b,然后试图对函数名进行修改,因为这种修改相当于是修改一个常量,所以是无效的(非严格模式下静默失败,严格模式下抛出Type错误)。忽略了这段语句后,等于是只输出b,也就是输出函数本身。之后,我们在全局下输出b,根据上面的说法,我们无法在NFE函数外部访问NFE的函数名,所以这里的b代表的不是函数,而是用let声明的那个变量b。

let b = 10;
(function b(){
    var b=20;
    console.log(b);
})();
// 20

当然,如果在函数内部用var或者let重新声明一个同名变量b并赋值,则是允许的,此时的b变量与函数b没有任何关系,仅仅是同名而已。 PS:NFE 函数名为什么是只读的?规范有说吗?还真有,看下面:

The production FunctionExpression : function Identifier ( FormalParameterListopt ) { FunctionBody } is evaluated as follows: 1.Let funcEnv be the result of calling NewDeclarativeEnvironment passing the running execution context’s Lexical Environment as the argument 2.Let envRec be funcEnv’s environment record. 3.Call the CreateImmutableBinding concrete method of envRec passing the String value of Identifier as the argument. 4.Let closure be the result of creating a new Function object as specified in 13.2 with parameters specified by FormalParameterListopt and body specified by FunctionBody. Pass in funcEnv as the Scope. Pass in true as the Strict flag if the FunctionExpression is contained in strict code or if its FunctionBody is strict code. 5.Call the InitializeImmutableBinding concrete method of envRec passing the String value of Identifier and closure as the arguments. 6.Return closure.

NOTE The Identifier in a FunctionExpression can be referenced from inside the FunctionExpression’s FunctionBody to allow the function to call itself recursively. However, unlike in a FunctionDeclaration, the Identifier in a FunctionExpression cannot be referenced from and does not affect the scope enclosing the FunctionExpression.

重点就在第三和第五的 ImmutableBinding,注意这是一个不可变的绑定。 关于这道题的详细解释,移步: https://segmentfault.com/q/1010000002810093

5. this 绑定

某不知来源的面试题一道:

"use strict";
const a=[1,2,30];
const b=[4,5,60];
const c=[7,8,90];
a.forEach((function (){
  console.log(this);
}).bind(globalThis),b);
// 输出什么?

正确答案是:

window
window
window

这道题的难点在于,forEach()thisArg 指定了回调的 this,而回调本身也有一个 bind() 方法指定 this,那么应该以哪个为准呢?在这篇文章中曾经讨论过 this 绑定的问题,但是 forEach() 的 this 绑定好像并不符合文章里面的情况。不妨看一下 forEach()polyfill 代码:

A polyfill is a piece of code (usually JavaScript on the Web) used to provide modern functionality on older browsers that do not natively support it.

也就是说,forEach() 绑定 this 实际上也是通过 call() 实现的。 接下来再来看一下 bind()polyfill 代码:

bind() 实际上也是通过 apply() 实现的 —— 原理就是返回一个包装函数,这个函数在内部对初始函数完成了 this binding。之后不管怎么调用这个包装函数,this 都是使用 bind() 的thisArg。也就是说,即使是:

func.bind(obj1).bind(obj2);

func 中的 this 最后也是指向 obj1 而不是 obj2,原因在于 func.bind(obj1) 是一个返回的包装函数,内部的 this 是没有暴露出来的,看上去就像是一个没有 this 的函数,因此后面的 bind(obj2) 对其不生效。这也是为什么说 bind() 是 tight binding 的原因,一旦绑定就很难再改变。 理解这一点之后,再来看上面的题就简单了。题目的代码我们可以简化为:

const f0 = function () {
  console.log(this)
}
const f1 = f0.bind(globalThis)
a.forEach(f1, b)

f0 是初始函数,f1 是包装函数。那么在 forEach 进行迭代的时候,虽然指定了 this 是参数 b,但是由于此时的 f1 是一个内部完成了 this binding 的包装函数,因此其实已经没有 this 什么事了,自然 forEach 的 thisArg 也不生效。既然是 bind() 生效,那么结果自然是输出全局对象了。 Tip: 下次思考问题的时候,polyfill 可以作为一个着手方向。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 记两道关于事件循环的题

    这里的关键其实是搞清楚 await async2() 做了什么事情。我以为在 async1 内部,async2 被调用之后,就会继续往后执行,因此是先打印 as...

    Chor
  • Vue 生命周期与钩子函数

    Vue 的生命周期共有 8 个阶段,即创建前/后, 载入前/后,更新前/后,销毁前/销毁后,并对应地有很多钩子函数,让我们在控制整个Vue实例的过程时更容易形成...

    Chor
  • 深入浅出理解闭包

    本篇博客转载自@王福朋 王老师的系列文章。系列文章共计18篇,主要涉及js中的两个重难点—-原型和闭包。由于原型部分我在另外一篇博客有介绍,所以这里只集合了他关...

    Chor
  • 细数 JavaScript 实用黑科技(二)

    !! 操作符:!!variable 。 !! 可以将变量转换为布尔值。 !! 可以把任何类型的值转换为布尔值,并且只有当这个变量的值为 0 / null / "...

    前端博客 : alili.tech
  • 细数 JavaScript 实用黑科技(二)

    书接上文:细数 JavaScript 实用黑科技(一)( https://segmentfault.com/a/1190000016507835 )

    夜尽天明
  • JS学习系列 03 - 函数作用域和块作用域

    在 ES5 及之前版本,JavaScript 只拥有函数作用域,没有块作用域(with 和 try...catch 除外)。在 ES6 中,JS 引入了块作用域...

    liuxuan
  • 【前端工程师手册】JavaScript之作用域

    为啥是100而不是10呢??? 因为JavaScript是词法作用域 词法作用域简单地说就是:函数的作用域在声明的时候就决定好了。和词法作用域相对的是动态作用域...

    前端博客 : alili.tech
  • 《前端实战》之变量提升,函数声明提升及变量作用域详解

    之所以会写这篇文章,主要源于笔者在重构老项目的时候发现了一个bug,导致某个插件不生效了,在review加search code加断点调试之后,发现了原因:一个...

    徐小夕
  • 一篇文章带你了解JavaScript中的变量,作用域和内存问题

    引用类型的值是保存在内存中的对象,JavaScript不允许直接操作对象的内存空间,实际上操作对象的引用而不是实际对象。

    达达前端
  • 搞懂JavaScript引擎运行原理

    JS引擎 — 一个读取代码并运行的引擎,没有单一的“JS引擎”;,每个浏览器都有自己的引擎,如谷歌有V。

    前端小智@大迁世界

扫码关注云+社区

领取腾讯云代金券