目录
实验介绍
回忆下高考的时候,一张卷子基础题会占整套试卷的 80%甚至更多。而在前端面试中也是一样,作为前端开发人员 JavaScript 是我们最重要的开发语言,打牢 JavaScript 的基础知识就可以轻松应对大多数关于 JavaScript 的问题。在本节实验中我们以考代练,看看在面试中关于 JavaScript 都有哪些常考点吧!
知识点
-
JavaScript 变量、作用域、闭包、this、原型及原型链
-
JavaScript 事件循环
-
ES6+ 新特性
JavaScript 数据类型
面试中经常被问到 JavaScript 数据类型都有哪些?有些时候面试者因为紧张连这最基础的题目都回答不出来或者回答不够全面,下面我们来具体了解下 JavaScript 数据类型吧!
首先,需要答出 JavaScript 数据类型分为基本类型(简单类型)和引用类型(复杂类型)两种,分别是:
基本类型
-
string (字符串)
-
boolean (布尔)
-
number (数字)
-
undefined (undefined)
-
null (null)
-
symbol (代表创建后独一无二且不可变的数据类型)
引用类型
-
Object (对象)
-
Array (数组)
-
Function (函数)
这个时候面试官可能又会接着问:那你知道这两种数据类型有什么区别吗?
-
存放位置不同
基本类型的变量会保存在栈内存中,如果在一个函数中声明一个基本类型的变量,这个变量在函数执行结束后会自动销毁。 而引用类型的变量名会保存在栈内存中,但是变量值会存储在堆内存中,引用类型的变量不会自动销毁,当没有引用变量引用它时,系统的垃圾回收机制会回收它,具体存储方式如下图所示:
关于栈和堆,是一种简单的数据结构,不是本节实验的重点,这里只需要记住结论即可。
-
赋值不同
基本类型的赋值相当于深拷贝,赋值后相当于又开辟了一个内存空间,如下所示:
let a = 100;
let b = a;
b = 101;
console.log(a, b); // 100 101
可以看出当 a 的值改变时并不影响 b 的值。
而引用类型的赋值是浅拷贝,当我们对对象进行操作时,其实操作的只是对象的引用,如下所示:
let obj = {
name: "蓝桥",
};
let obj2 = obj;
obj2.name = "蓝桥云课";
console.log(obj.name, obj2.name); // 蓝桥云课 蓝桥云课
可以看出,我们把 obj 的值赋值给 obj2,当 obj2 的值改变时,obj 的值也被改变。
这个时候面试官可能又接着问:那如何实现一个对象的深拷贝呢?
最简单的方式就是使用 JSON.stringify
和 JSON.parse
这组 API,如下所示:
JSON.stringify() 方法用于将 JavaScript 值转换为 JSON 字符串;JSON.parse() 方法将 JSON 字符串转化为 JavaScript 对象
let obj = {
name: "蓝桥",
};
let obj2 = JSON.parse(JSON.stringify(obj));
obj2.name = "蓝桥云课";
console.log(obj.name, obj2.name); // 蓝桥 蓝桥云课
但是这种方式有些问题,当 obj
里面有函数或者 undefined
就会失效,如下所示:
let obj = {
fn: function () {
console.log("我是蓝桥");
},
name: undefined,
};
let obj2 = JSON.parse(JSON.stringify(obj));
console.log(obj2); //{}
所以万全之策还是要用递归,如下所示:
function deep(obj) {
let oo = {};
for (const key in obj) {
if (typeof obj[key] === "object") {
oo[key] = deep(obj[key]);
} else {
oo[key] = obj[key];
}
}
return oo;
}
上述代码的原理就是递归遍历对象的每个属性,分别赋值到一个新对象去。
现在再回头来看看我们的第一个问题,一个简简单单的数据类型就能牵扯到这么多知识点。有经验的面试官会从一道题目上层层递进的提问,来区分出不同面试者的水平。
原型及原型链
面试中经常问道,你知道什么是原型吗?或者你对原型链是如何理解的?说实话,这道面试题挺难的,要想彻底搞清楚原型及原型链,需要我们下很大功夫去理解,所以以下知识点建议大家反复阅读,逐步消化。
我们所有的知识点都来自于以下理论:
每个函数都有 prototype 属性,每一个对象都有 proto 属性(这个属性称之为原型),在我们执行 new 的时候,对象的 proto 指向这个构造函数的 prototype。
首先,每个函数都有个 prototype 属性,这个属性指向函数的原型对象,同时 prototype 里面有个 constructor 属性回指到该函数。如下所示:
function Demo() {}
Demo.prototype.constructor === Demo; // true
Demo.prototype 结构如下所示:
现在我们使用 new 操作符来创建一个实例对象,如下所示:
当使用 new 操作符后,Demo 就变成了构造函数
function Demo() {}
const d = new Demo(); // d 就是创建出来的实例对象
d 既然是对象,自然有 proto(原型) ,同时指向构造函数 Demo 的 prototype,如下所示:
function Demo() {}
const d = new Demo();
d.__proto__ === Demo.prototype; // true
当我们访问一个对象的属性时,程序会先去这个对象里面查找,如果没有找到会去这个对象的原型上查找,如下所示:
function Demo() {
this.name = "蓝桥";
}
Demo.prototype.say = function () {
console.log("我是", this.name);
};
const d = new Demo();
// 虽然 Demo 上没有 say 方法,但是因为Demo的prototype上有此方法,所以下面的调用可以正常打印。
d.say(); // 我是蓝桥
我们可以用一张图来描述这个查找过程。
这里我们只是体现出来一层查找,实际上 \_\_proto\_\_
是逐层向上查找的,这个查找过程也就是我们所说的原型链。
如果面试中遇到面试官问到原型相关的问题,只要我们记住上张图中的查找流程然后描述给面试官即可。有的同学可能会问,学这些理论有什么用呢?当然有用,下面我们就来学习实际的使用场景。
继承
JavaScript 是如何实现继承的?这也是一道常考的面试题,在 ES6 之前,我们都是通过原型的方式来实现,这也是原型的重要使用场景之一,通常有如下几种实现方式:
-
原型赋值
function Per(name) {
this.name = name || "蓝桥";
}
Per.prototype.say = function () {
console.log(this.name);
};
function Sun() {}
Sun.prototype = new Per(); //直接new 了一个父类的实例,然后赋给子类的原型
Sun.prototype.love = function () {
console.log(this.name);
};
const sun = new Sun();
console.log(sun.name); // 蓝桥
这种方法是直接 new 了一个父类的实例,然后赋给子类的原型。这样也就相当于直接将父类原型中的方法属性以及挂在 this 上的各种方法属性全赋给了子类的原型,简单粗暴!
因为简单粗暴,所以弊端很多,主要的问题就是:
-
创建子类实例时,无法向父类构造函数传参
-
来自原型对象的所有属性被所有实例共享
-
构造函数
既然直接 new 一个父类赋值给子类不合适,那我们在子类的 prototype 通过改变 this 指向调用父类也可以达到继承的目的,如下所示:
function Per(name) {
this.name = name || "蓝桥";
}
Per.prototype.say = function () {
console.log(this.name);
};
function Sun() {
Per.apply(this, arguments); // 通过apply 调用Pre 并改变Pre中的this指向
}
const sun = new Sun();
console.log(sun.name); // 蓝桥
但是这种方式并不能改变 prototype 中的 this 指向,所以不能继承父类的 prototype,如下所示:
sun.say(); // sun.say is not a function
显然这种方式也不合适。
-
组合继承
即然前两种方式都有自己的优缺点,那可以组合起来实现继承吗?答案肯定是可以的。代码如下所示:
function Per(name) {
this.name = name || "蓝桥";
}
Per.prototype.say = function () {
console.log(this.name);
};
function Sun() {
Per.apply(this, arguments);
}
Sun.prototype = new Per();
Sun.prototype.constructor = Sun; // 使得Sun.prototype.constructor指向自己
const sun = new Sun();
console.log(sun.name); // 蓝桥
sun.say(); // 蓝桥
这样做看似完美,但是却把父类的属性继承了两份,如下所示:
如果父类本身比较大的话,这样实现继承对内存的消耗比价大,显然也不太合适。
那有没有一种完美的方式实现继承呢?答案是有的。
-
寄生组合继承
function Per(name) {
this.name = name || "蓝桥";
}
Per.prototype.say = function () {
console.log(this.name);
};
function Sun() {
Per.apply(this, arguments);
}
Sun.prototype = Object.create(Per.prototype);
Sun.prototype.constructor = Sun; // 使得Sun.prototype.constructor指向自己
const sun = new Sun();
console.log(sun.name); // 蓝桥
sun.say(); // 蓝桥
Object.create()
方法创建一个新对象,使用现有的对象来提供新创建的对象的proto。这样就不会出现像组合继承那种情况了。
虽然说我们实现了继承,但是在 ES6 之前,实现一个继承还是相对复杂的,在 ES6 之后,我们有了 extends
关键字,很优雅的实现继承,如下所示:
class Per {
constructor(name) {
this.name = name || "蓝桥";
}
say() {
console.log(this.name);
}
}
class Sun extends Per {
constructor(name) {
super(name); // 可以给父类传递参数
this.age = 16;
}
}
const sun = new Sun("lyh");
console.log(sun); //{name: "lyh" , age: 16}
说了这么多实现方式,下面我们来总结下这道面试题。
面试官问:说说 JavaScript 是如何实现继承的?
答:实现继承有以下几种方式,
-
原型继承,创建一个父类直接赋值给子类的 prototype。
缺点:父类的属性将会被所示的子类共享;不能给父类传参。
-
构造函数继承,在子类中通过改变 this 指向来调用父类。
缺点:不能继承父类的 prototype。
-
组合继承,结合原型继承和构造函数继承的优点。
缺点:子类继承了两份父类,重复继承。
-
寄生组合继承,通过 Object.create 方式来优化组合继承。
缺点:实现起来比较复杂。
-
ES6 extends 实现继承。
缺点:可能在低版本浏览器存在兼容性问题。
作用域与闭包
几乎所有编程语言最基本的功能之一,就是能够储存变量当中的值,并且能在之后对这个值进行访问或修改。事实上,正是这种储存和访问变量的值的能力将状态带给了程序。若没有了状态这个概念,程序虽然也能够执行一些简单的任务,但它会受到高度限制,做不到非常有趣。 但是将变量引入程序会引起几个很有意思的问题,也正是我们将要讨论的:这些变量住在哪里?换句话说,它们储存在哪里?最重要的是,程序需要时如何找到它们?
这些问题说明需要一套设计良好的规则来存储变量,并且之后可以方便地找到这些变量, 这套规则被称为作用域。
上面是《你不知道的 JavaScript》书籍中对作用域的描述,那什么是作用域呢?简单概括说就是一种存储变量读取变量的规则。那这是一套什么规则呢?我们具体来学习。
在 ES6 之前,JavaScript 只有函数级作用域,没有块级作用域,如下所示:
function demo() {
var a = "蓝桥";
console.log(a); // 蓝桥
}
console.log(a); //a is not defined
我们在函数内部声明了一个 a 变量,在函数内部可以访问到在函数外部是访问不到的,这就是函数级作用域的作用。但是如果我们不在函数内部声明呢?
if (true) {
var a = "蓝桥";
}
console.log(a); // 蓝桥
我们在一个 if 语句中声明一个变量 a,在外部还是可以访问到,这是因为 JavaScript 没有块级作用域,导致变量提升,刚好 if 中为 true,所以显示蓝桥,如果 if 中为 false,则输出 undefined。关于这一点有一道非常经典的面试题,如下所示:
请问以下代码打印的结果是?
for (var i = 0; i < 10; i++) {
setTimeout(function () {
console.log(i);
}, 1000);
}
有的同学可能会回答,打印出来的是 0-9,正确答案是打印了 10 次 10。这就是因为 JavaScript 没有块级作用域所致,我们简单梳理下这段代码的执行过程:
-
代码执行 for 循环,i 依次从 0 加到 9,循环十次。
-
代码等待定时器 1 秒钟时间到,执行定时的里面的内容。
-
执行打印 i 语句,因为定时器函数中没有声明 i 变量,所以代码只能去定时器函数外的作用域去查找。
-
在外部找到了 i 此时 i 已经变成了 10,所以打印 10 次 10。
在上面执行过程中,有一条重要的变量查找规则就是,如果在当前作用域中没有发现此变量的声明,程序就会去他父作用域去查找,直到找到为止,在浏览器中最外层的作用域是 window
,如果最后在 window
上都没有找到的话,就会返回 xxx is not defined
查找结束。
那我们如何让上面的程序按照我们的意愿打印出 0-9 呢?这就要使用闭包的知识了。
那什么是闭包呢?在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。
概念学习起来比较抽象,我们还是举例说明:
我们知道,因为作用域的问题,函数内部的变量只能在函数内部使用,外部是访问不了的。
function demo() {
var a = "蓝桥";
}
console.log(a); // //a is not defined
而闭包要干的事就是可以让外部访问到这个变量,如下所示:
function demo() {
var a = "蓝桥";
return function () {
return a;
};
}
const d = demo();
console.log(d()); // 蓝桥
我们通过在 demo
函数中返回一个函数,在返回的函数中再返回这个变量,然后当我们在外部去调用这个返回出来的函数时就可以得到这个变量的值。也就是说 d 函数 保存了对 a 的引用,这就形成了闭包。
下面我们用闭包的知识来改写上面的 for 循环,让它可以打印出来 0-9
for (var i = 0; i < 10; i++) {
(function (i) {
setTimeout(function () {
console.log(i);
}, 1000);
})(i);
}
猛一看挺复杂,其实比较简单,就是在 setTimeout 外面套了一层自执行函数,把每次循环的 i 的结果给保存在当前作用域下,当执行定时器的时候,就可以去当前的作用域去找 i 的值了。
我们来总结一下。在面试中,面试官一般不会直接问你什么是作用域,什么是闭包,基本上都是给你一个应用场景或者是看一段代码的执行结果来考察这两个重要的知识点。所以同学们一定要把这两个知识点弄明白。
ES6+ 新特性
面试官通常会问这么一个问题:你都用过 ES6 的那些新特性?下面我们就来总结下这个问题。
-
let 和 const
这应该是我们使用最多的一个特性了。还记得上面说到的 JavaScript 只有函数级作用域没有块级作用域吗?那是在 ES6 之前,有了 ES6 的 let 和 const 我们声明出来的变量也有了块级作用域,如下所示:
{
var a = "蓝桥";
const b = "蓝桥";
let c = "蓝桥";
}
console.log(a); // 蓝桥
console.log(b); // b is not defined
console.log(c); // c is not defined
还记得上面的那个 for 循环吗?如果我们使用 ES6 进行改造就会简单很多,如下所示:
for (const i = 0; i < 10; i++) {
console.log(i); // 0-9
}
是不是比闭包的实现简单很多。
在使用上,let 和 const 虽然都可以用来声明变量,声明的变量都是块级作用域。但是用 const 声明的简单类型的变量不可以修改,如下所示:
const name = "蓝桥";
name = "蓝桥云课"; // 报错
let name = "蓝桥";
name = "蓝桥云课"; // 正常运行
const people = {
love: "吃饭",
};
people.love = "学习"; // 复杂类型的变量可以修改 正常运行
-
解构及扩展运算符
解构用法比较简单,如下所示:
const people = {
name: "张三",
love: "吃饭",
};
// 把name 和love 解构出来
const { name, love } = people;
console.log(name, love); // 张三 吃饭
在实际项目中,解构通常用于接口返回数据的处理,如下所示:
const data = await getInfo(); // 接口数据
const { name = "xxx", sex = 0 } = data; // 把自己想要的数据解构出来,并设置默认值
扩展运算符(...)可以理解成把对象里面的全部内容解构出来,使用如下所示:
const obj1 = {
name: "张三",
};
const obj2 = {
age: 18,
};
const user = { ...obj1, ...obj2 }; // { name:'张三',age:18 }
-
函数默认参数
在 ES6 中,函数是可以设置默认参数的,如下所示:
function demo(name = "张三") {
console.log("我是" + name);
}
值得注意的是,如果函数设置了默认值,那么函数的 length 属性将会失效,如下所示:
通过 length 可以获取函数的参数个数
function demo(name = "张三") {
console.log("我是" + name);
}
console.log(demo.length); // 输出0 实际是有一个参数
-
Symbol
Symbol 是 ES6 引入了一种新的原始数据类型,表示独一无二的值。Symbol 值通过 Symbol
函数生成,如下所示:
const fnName = Symbol();
typeof fnName; // "symbol"
call 函数可以用来改变函数的 this 指向,下面我们自己实现一个原生的call
方法myCall
来具体说明。如下所示:
const obj = {
name: "张三",
};
const userInfo = {
name: "李四",
say: function (age) {
console.log("我叫" + this.name, "年龄" + age);
},
};
userInfo.say.call(obj, 18); // 输出我叫张三 年龄18
原本 say 函数里面的 this 指向的是 userInfo 本身,经过 call 调用后改变了 this 指向,从而指向了 obj。
下面我们自己实现一个 myCall 方法,如下所示:
Function.prototype.myCall = function (obj, ...arg) {
// ...arg 表示后面的所以参数
const fnName = Symbol();
obj[fnName] = this;
const res = obj[fnName](...arg);
delete obj.fnName;
return res;
};
对于新手而言,手动实现一个 myCall 函数还是有一定难度的,实现这个函数的原理就是把要改变 this 指向的函数作为对象的一个属性,这样的话在实现这个函数的时候,this 自然就会指向这个对象了。
如果看不懂上面的函数那也没关系,因为这不是我们的重点。我们需要关心的是 Symbol
的使用,在这里我们使用 Symbol
创建出了一个唯一的函数名称,这样就确保不会跟 obj 原有的名称冲突。
-
Set 和 Map 数据结构
ES6 提供了新的数据结构 Set
。它类似于数组,但是成员的值都是唯一的,没有重复的值,具体使用如下所示:
const arr = new Set([1, 2, 3]);
arr.add(4); // 向arr中添加元素
arr.delete(1); // 删除数据为1的元素
arr.size; // 返回arr长度
arr.has(2); // 判断arr中是否有2这个元素
arr.clear(); // 清除所有元素
下面我们使用 Set
来实现数组去重:
const arr = [1, 2, 3, 4, 1, 2, 3];
const arr2 = [...new Set(arr)];
console.log(arr2); // [1,2,3,4]
是不是非常的简单。
Map
的使用很像对象 Object
,也是通过健值的方式储存数据,不同的是 Object
中的键只能是字符串,而 Map
中的健可以是任意数据类型,如下所示:
const m = new Map();
const k = {
name: "张三",
};
m.set(k, 18);
m.get(k); // 18
Map
的 api 和 Set
的基本一致,如下所示:
const m = new Map();
m.set("name", "张三"); // 设置元素
m.get("name"); // 张三
m.has("name"); // 判断有没有这个元素
m.size; // 获取map的长度
-
Promise
Promise
是异步编程的一种解决方案,在没有 Promise
之前,我们只能通过回调的方式实现异步编程,如下所示:
function fn(name, fn) {
const nameVal = "我是" + name;
// 用定时器模拟异步执行
setTimeout(function () {
fn(nameVal);
}, 1000);
}
fn("张三", function (val) {
console.log(val); // 我是张三
});
但是如果回调函数比较多的话,就会陷入回调地狱,大大降低了代码的可读性。而 Promise
就是来解决这个问题的,具体用法如下所示:
const promise = new Promise(function(resolve,reject){
if(/*异步程序成功*/){
resolve(res)
}else{
reject(error)
}
})
promise.then(function(res){
},function(error){
})
Promise 有几个比较重要的方法,如下所示:
-
Promise.prototype.then()
Promise
实例添加状态改变时的回调函数 -
Promise.prototype.catch()
发生错误时的回调函数 -
Promise.all()
Promise.all 可以将多个Promise
实例包装成一个新的Promise
实例。同时,成功和失败的返回值是不同的,成功的时候返回的是一个结果数组,而失败的时候则返回最先被 reject 失败状态的值 -
Promise.race()
可以将多个Promise
实例包装成一个新的Promise
实例,哪个结果获得的快,就返回那个结果,不管结果本身是成功状态还是失败状态
具体的使用和面试中常考的点我们会在浏览器事件循环再次讲到。以上列举的只是一些工作中我们经常用到的 API,关于 ES6 新增的其他 API 我们可以在这里进行学习。
虽然 ES6 新增了很多知识点,但在面试中最爱考的还是 JavaScript 对异步的处理方案,也就是 Promise
。 所以 Promise
的相关知识点需要我们重点去理解掌握。
浏览器事件循环机制
对于中高级前端开发人员来讲,这一节的内容至关重要。如果把 JavaScript 看作是一个人的话,那事件循环机制就相当于一个人的经络。
首先需要明确,浏览器之所以有事件循环机制,最本质的原因就是因为 JavaScript 是单线程的问题。那 Javascript 为什么是单线程呢?
作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准呢?这不就乱套了嘛。虽然 JavaScript 是单线程的,但是在实际开发中我们确实需要处理一些异步的问题,那就要求 JavaScript 的运行环境来提供一套方案让我们更好的处理一些异步问题,在前端层面,浏览器是 JavaScript 唯一的运行环境,这就有了浏览器的事件循环机制。
先来学习一下什么是宏任务和微任务
-
宏任务
JavaScript 是单线程,但浏览器是多线程的,JavaScript 执行在浏览器中,在 V8 里跑着的一直是一个一个的宏任务,就相当于排队打饭一样,一个人相当于一个宏任务。具体执行流程图如下:
let name = "张三";
setTimeout(function () {
console.log(name); // 李四
}, 0);
name = "李四";
console.log(name); // 李四
浏览器在执行上面代码时会先执行主线程代码(宏任务 1)然后再执行setTimeout
里面的代码。虽然 setTimeout
的定时时间为 0,但是浏览器在处理的时候会把它当做下一个宏任务进行处理,定时器也是宏任务的典型代表。
-
微任务
当浏览器执行完一个宏任务后,就会看看有没有微任务执行,如果有微任务执行就会先把当前的微任务执行完,再去执行下一个宏任务。微任务的代表有 ajax、回调函数、和 Promise。 执行流程如下:
主线程从 "任务队列" 中读取事件,这个过程是循环不断的,所以整个过程的这种运行机制又称为 Event Loop(事件循环)。为了方便理解,我们来看下面一个案例:
console.log(1);
setTimeout(() => {
console.log(2);
});
new Promise((res, req) => {
console.log(3);
res();
}).then(() => {
console.log(4);
});
console.log(5);
// 以上代码的执行结果是?
下面我们来分析下上面代码执行过程,如下所示:
代码从上到下执行 ⬇ 打印 1 ⬇ 遇到 setTimeout 是下一个宏任务,目前先不处理 ⬇ 遇到 Promise 打印出 3 then 回调函数是微任务,先不处理 ⬇ 打印 5 且第一个宏任务执行完毕 ⬇ 开始执行微任务队列 ⬇ 打印 4 微任务队列执行完毕 ⬇ 开始执行下一个宏任务 ⬇ 打印 2 ⬇ 程序结束
所以以上代码打印结果为 1 3 5 4 2
,这里需要注意的是 只有 Promise then 或者 catch 里面的方法是微任务,Promise 里面的回调是当作主程序的宏任务进行处理的。
实验总结
本实验我们学习了 JavaScript 的常考问题,这类问题偏向于理论居多,大家在学习中需要反复阅读并消化。并且对于这些理论知识要学会举一反三,最终助力于实际项目开发。