8道经典JavaScript面试题解析你真的掌握JavaScript了吗

23次阅读

共计 4749 个字符,预计需要花费 12 分钟才能阅读完成。

JavaScript 是前端开发中非常重要的一门语言,浏览器是他主要运行的地方。JavaScript 是一个非常有意思的语言,但是他有很多一些概念,大家经常都会忽略。比如说,原型,闭包,原型链,事件循环等等这些概念,很多 JS 开发人员都研究不多。

所以今天,就来和大家看看下面几个问题,大家可以先思考一下,尝试作答。

八道面试题

问题 1:下面这段代码,浏览器控制台上会打印什么?

问题 2: 如果我们使用 let 或 const 代替 var,输出是否相同

问题 3:“newArray”中有哪些元素?

问题 4:如果我们在浏览器控制台中运行 ’foo’ 函数,是否会导致堆栈溢出错误?


问题 5: 如果在控制台中运行以下函数,页面(选项卡) 是否会有响应

问题 6: 我们能否以某种方式为下面的语句使用展开运算而不导致类型错误

问题 7:运行以下代码片段时,控制台上会打印什么?

问题 8:xGetter() 会打印什么值?

答案

前面的问题我们都举例出来了,接下来我们会从头到尾,一个个来分析我们这些问题的答案,给大家一些学习的思路

问题 1:
使用 var 关键字声明的变量在 JavaScript 中会被提升,并在内存中开辟空间,由于没有赋值,无法定义数值类型,所以分配默认值 undefined。var 声明的变量,真正的数值初始化,是发生在你确定赋值的位置。同时,我们要知道,var 声明的变量是函数作用域的,也就是我们需要区分局部变量和全局变量,而 let 和 const 是块作用域的。所以我们这道题的运行过程是这样的:

var a = 10; // 全局作用域,全局变量。a=10
function foo() {
// var a 
// 的声明将被提升到到函数的顶部。// 比如:var a

console.log(a); // 打印 undefined

// 实际初始化值 20 只发生在这里
   var a = 20; // local scope
}

图解在下面,好理解一点

所以问题 1 的答案是:undefined

问题 2:
let 和 const 声明可以让变量在其作用域上受限于它所在的块、语句或表达式中。和 var 不同的地方在于,这两个声明的变量,不会被提升。并且我们会有一个称为暂时死区(TDZ)。如果访问 TDZ 中的变量的话,就会报 ReferenceError,因为他们的的作用域是在他们声明的位置的,不会有提升。所以必须在执行到声明的位置才能访问。

var a = 10; // 全局使用域
function foo() { // TDZ 开始

// 创建了未初始化的 'a'
    console.log(a); // ReferenceError

// TDZ 结束,'a' 仅在此处初始化,值为 20
    let a = 20;
}

图解:

问题 2 答案:ReferenceError:a undefined。

问题 3:

这个问题,是循环结构会给大家带来一种块级作用域的误区,在 for 的循环的头部使用 var 声明的变量,就是单个声明的变量绑定(单个存储空间)。在循环过程中,这个 var 声明的 i 变量是会随循环变化的。但是在循环中执行的数组 push 方法,最后实际上是 push 了 i 最终循环结束的 3 这个值。所以最后 push 进去的全都是 3。

// 误解作用域: 认为存在块级作用域
var array = [];
for (var i = 0; i < 3; i++) {
    // 三个箭头函数体中的每个 'i' 都指向相同的绑定,// 这就是为什么它们在循环结束时返回相同的值 '3'。array.push(() => i);
}
var newArray = array.map(el => el());
console.log(newArray); // [3, 3, 3]

图解:

如果想记录每一次循环的值下来,可以使用 let 声明一个具有块级作用域的变量,这样为每个循环迭代创建一个新的绑定。

// 使用 ES6 块级作用域
var array = [];
for (let i = 0; i < 3; i++) {
    // 这一次,每个 'i' 指的是一个新的的绑定,并保留当前的值。// 因此,每个箭头函数返回一个不同的值。array.push(() => i);
}
var newArray = array.map(el => el());
console.log(newArray); // [0, 1, 2]

还有解决这个问题的另外一种解决方案就是使用闭包就好了。

let array = [];
for (var i = 0; i < 3; i++) {array[i] = (function(x) {return function() {return x;};
    })(i);
}
const newArray = array.map(el => el());
console.log(newArray); // [0, 1, 2]  

问题 3 答案:3,3,3

问题 4
JavaScript 的并发模式基于我们常说的”事件循环“。
浏览器是提供运行时环境来给我们执行 JS 代码的。浏览器的主要组成包括有调用堆栈,事件循环,任务队列和 WEB API。像什么常用的定时器 setTimeout,setInterval 这些全局函数就不是 JavaScript 的一部分,而是 WEB API 给我们提供的。

JS 调用栈是后进先出 (LIFO) 的。引擎每次从堆栈中取出一个函数,然后从上到下依次运行代码。每当它遇到一些异步代码,如 setTimeout,它就把它交给 Web API(箭头 1)。因此,每当事件被触发时,callback 都会被发送到任务队列(箭头 2)。
事件循环 (Event loop) 不断地监视任务队列 (Task Queue),并按它们排队的顺序一次处理一个回调。每当调用堆栈(call stack) 为空时,Event loop 获取回调并将其放入堆栈 (stack)(箭头 3) 中进行处理。请记住,如果调用堆栈不是空的,则事件循环不会将任何回调推入堆栈。

好了,现在有了前面这些知识,我们可以看一下这道题的讲解过程:
实现步骤:

  1. 调用 foo()会将 foo 函数放入调用堆栈(call stack)。
  2. 在处理内部代码时,JS 引擎遇到 setTimeout。
  3. 然后将 foo 回调函数传递给 WebAPIs(箭头 1)并从函数返回,调用堆栈再次为空
  4. 计时器被设置为 0,因此 foo 将被发送到任务队列(箭头 2)。
  5. 由于调用堆栈是空的,事件循环将选择 foo 回调并将其推入调用堆栈进行处理。
  6. 进程再次重复,堆栈不会溢出。

问题 4 答案:堆栈不会溢出。

问题 5:
在很多时候,很多做前端开发的同学都是认为循环事件图中就只会有一个任务列表。但事实上不是这样的,我们是可以有多个任务列表的。由浏览器选择其中一个队列并在该队列进行处理回调。
从底层来看,JavaScript 中是可以有宏认为和微任务的,比如说 setTimeout 回调是宏任务,而 Promise 回调是微任务。

他们有什么区别呢?
主要的区别在于他们的执行方式。宏任务在单个循环周期中一次一个低堆入堆栈,但是微任务队列总是在执行后返回到事件之前清空。所以,如果你以处理条目的速度向这个队列添加条目,那么你就永远在处理微任务。只有当微任务队列为空时,事件循环才会重新渲染页面。

然后我们再回到我们前面讲的问题 5 中:

function foo() {return Promise.resolve().then(foo);
};    

我们这段代码,每次我们去调用【foo】的时候,都会在微任务队列上加另一个【foo】的回调,因此事件循环没办法继续去处理其他的事件了(比如说滚动,点击事件等等),直到该队列完全清空位置。因此,不会执行渲染,会被阻止。

问题 5 答案:不会响应。

问题 6:
在我们做面试题的时候,展开语法和 for-of 语句去遍历 iterable 对象定义要遍历的数据。其中我们要使用迭代器的时候,Array 和 Map 都是有默认迭代操作的内置迭代器的。
但是,对象是不可迭代的,也就是我们这道题里的,这是一个对象的集合。但是我们可以使用 iterable 和 iterator 协议来把它变成可以迭代的。
在我们研究对象的时候,如果一个对象他实现了 @@iterator 方法,那么它就是可以迭代的。这意味着这个对象(在他的原型链上的一个对象)必须是又 @@iterator 键的属性的,然后我们就可以利用这个键,通过常量 Symbol.iterator 获得。
下面是这道题的举例写法:

var obj = {x: 1, y: 2, z: 3};
obj[Symbol.iterator] = function() {
    // iterator 是一个具有 next 方法的对象,// 它的返回至少有一个对象
    // 两个属性:value&done。// 返回一个 iterator 对象
    return {next: function() {if (this._countDown === 3) {
               const lastValue = this._countDown;
               return {value: this._countDown, done: true};
              }
            this._countDown = this._countDown + 1;
            return {value: this._countDown, done: false};
        },
        _countDown: 0
    };
};
[...obj]; // 打印 [1, 2, 3]

问题 6 答案:如上是一种方案,可以避免 TypeError 异常。

问题 7:
在看这个问题的时候,我们要先理解 for-in 循环遍历本身的可枚举属性和对象从原来的原型继承来的属性。可枚举属性是可以在 for-in 循环期间可以访问的属性。
当我们知道这个知识点前提了之后,我们在看这道题,你就知道这道题打印的其实就是只能打印这些特定的属性。

var obj = {a: 1, b: 2}; //a,b 都是可枚举属性

// 将 {c:3} 设置为 'obj' 的原型,// 并且我们知道 for-in 循环也迭代 obj 继承的属性
// 从它的原型,'c' 也可以被访问。Object.setPrototypeOf(obj, { c: 3});

// 我们在 'obj' 中定义了另外一个属性 'd',// 但是将 'enumerable' 可枚举设置为 false。这意味着 'd' 将被忽略。Object.defineProperty(obj, "d", { value: 4, enumerable: false});
// 所以最后使用 for-in 遍历这个对象集合,那就是只能遍历出可枚举属性
for (let prop in obj) {console.log(prop);
}

// 也就是只能打印
// a
// b
// c

图解

问题 7 答案:a、b、c

问题 8:
首先我们可以看到 var x 是一个全局遍历,在不是严格模式下,这个 X 就直接是 window 对象的属性了。在这段代码里,我们最重要是要理解 this 的对象指向问题,this 始终是指向调用方法的对象的。所以,在 foo,xGetter()的情况下,this 指向的是 foo 对象,返回的就是在 foo 中的属性 x,值就是 90。但是在 xGetter()的情况下,他是直接调用的 foo 的 getx()方法,但是其中 this 的指向是在 xGetter 的作用域,就是指向的 window 对象中,这时指向的就是全局变量 x 了,值也就是 10。

var x = 10; // 全局变量
var foo = {
    x: 90,//foo 对象的内部属性
    getX: function() {return this.x;}
};
foo.getX(); // 此时是指向的 foo 对象,// 所以打印的是 X 属性 值就是 90
let xGetter = foo.getX;//xGetter 是在全局作用域,// 这里的 this 就是指向 window 对象
xGetter(); // 打印 10


问题 8 答案:10

最后

ok,我们的 8 道问题都解决了,如果你前面写的答案全部都正确,那么你非常棒!去面试前端工作起码 12k 起步了。就算做不出来或者做错了也没有关系,我们都是不断通过犯错来学习的,一步步的理解错误,理解背后的原因,才能进步。

更多技术好文,前端开发学习教程,欢迎关注公众号【前端研究所】看更多前端技术文章!

正文完
 0