本篇博客专门用于收集各类经典面试题,并给出相关的解题思路和原理。
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}
首先,根据《JavaScript 权威指南》的说法:**JavaScript 总是严格按照从左至右的顺序来计算表达式。**比如,w = x + y * z
,首先计算子表达式 w
,再依次(从左到右)计算 x
,y
,z
这三个子表达式,接着将 y * z
视为一个表达式进行求值,再将结果与 x
相加作为一个表达式进行求值,最后变成一个常规的赋值表达式进行赋值。
那么,对于示例代码来说也是这么一个计算顺序:
代码 | 注释 | 补充 |
---|---|---|
a | 计算单值表达式 a ,得到 a 的引用 | 这里的 a 是初始 a |
a.x | 将 x 这个标识符作为. 运算符的右操作数,计算表达式 a.x ,得到结果值(Result),它是一个 a.x 的“引用” | 这个“引用”当作一个数据结构,通常有 base、name、strict 三个成员。无论x 属性是否存在(这里暂时不存在),a.x 都会被表达为 {"base": a, "name": "x", ...} 。而这里的 a 仍然指向旧对象。 |
a | 计算单值表达式 a ,得到 a 的引用 | 这里的 a 是初始 a |
a = {n:2} | 赋值操作使得左操作数 a 作为一个引用被覆盖,同时操作完成后返回右操作数 {n:2} | 这里的这个 a 的的确确被覆盖了,这意味着往后通过 a 访问到的只能是新对象。但是,有一个 a 是不会变的,那就是被 a.x 的 Result 保存下来的引用 a ,它作为一个当时既存的、不会再改变的结果,仍然指向旧对象。 |
a.x = {n:2} | 指向旧对象的 a 新建了 x 属性,这个属性关联对象 {n:2} | 注意,这里对 a.x 进行了写操作(赋值),直到这次赋值发生的那一刻,才有了为旧对象动态创建 x 属性这个过程。 |
所以,旧对象(丧失了引用的最初对象)和新对象(往后通过 a
可以访问到的那个对象)分别变成:
// 旧对象
a:{
n:1,
x:{n:2}
}
// 新对象
a:{
n:2
}
现在,执行 console.log(a.x)
,这里 a.x
被作为 rhs 读取,引擎会开始检索是否真的有 a["x"]
这个东西,因为此时通过 a
能访问到的只能是新对象,它自然是没有 x
属性的,所以此时 —— 直到这次读取发生的那一刻,才有了为新对象动态创建 x
属性这个过程。
Note:也就是说,在引擎从左到右计算表达式的过程中,尽管可能遇见类似 a.x
这样本不存在的属性,但无论如何,都会存在 {"base": a, "name": "x", ...}
这样的数据结构,而在后续真正对 x
进行 读写 的时候,这个 x
才会得到创建。
自此,我们的疑惑也就解开了。这个代码块所做的事情,实际上是向旧有对象添加一个指向新对象的属性,并且如果我们想要在后续仍然持有对旧对象的访问,可以在赋值覆盖之前新建一个指向旧对象的变量。
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 可以作为一个着手方向。