ECMAScript基础知识点

676 阅读17分钟

前端面试题系列文章:

【1】「2023」HTML基础知识点

【2】「2023」ECMAScript基础知识点

【3】「2023」CSS基础知识点

【4】「2023」计算机网络基础知识

【5】「2023」计算机网络-HTTP知识点

【6】「2023」浏览器相关知识点

【7】「2023」React相关知识点

【8】「2023」TypeScript相关知识点

【9】「2023」Webpack相关知识点

【10】「2023」代码输出结果相关知识点

【11】「2023」手动实现代码相关知识点

【12】「2023」性能优化相关知识点

【13】「2023」H5相关知识点

X-Mind原图地址:

FE-eight-part-essay

数据类型

基本数据类型

JavaScript有八种基本的数据类类型,分别是:Undefined、Null、Boolean、Number、String、Object、Symbol、BigInt

Symbol:用于创建唯一不可变的值。比如创建对象的私有属性或者方法、避免属性名冲突、定义常量

BigInt:用于表示任意精度整数的数据类型。比如:处理大整数、避免精度的丢失。

数据类型的检测

根据经验,使用Object.prototype.toString.call(obj).slice(8, -1);判断类型最准确的方法,其他方法多多少少会存在一些局限性,比如:

  • typeof: 方法对数组、对象、null都会被判断为object;
  • instanceof: 只能判断引用数据类型,而不能判断基本数据类型;
  • constructor: 虽然constructor可以正确判断数据的类型(以及访问构造函数),但是对象实例可以修改自己的constructor属性。
console.log(f.constructor === Array); // true

为什么要用原型上的toString方法才能判断类型呢?因为Array、Function对toString方法都进行了重写。要得到对象的具体类型时,只能调用Object原型上的toString方法。

Null 和 Undefined 区别

Null 和 Undefined 是两种不同的基本数据类型。Undefined 代表的含义是未定义,null 代表的是空对象。使用 typeof 去判断 null 时会返回 ”object“,这是一个历史遗留问题。

在解构赋值的时候,如果被解构的值为null,那么初始值不生效。

const obj =  { name: null, age: ''};
const { name = 'zaoren', age = 18 } = obj;
console.log(name); // null

typeof NaN 的结果是什么

NaN 指 ”不是一个数字“(not a number), 用来指出数字类型中出错的情况,即“执行数学运算没有成功,这是失败后返回的结果”。

typeof NaN; // "number"

原型与原型链

prototype属性

每个函数都有一个 prototype 属性,举个例子:

function Person() {

}
// prototype是函数才会有的属性
Person.prototype.name = 'Kevin';
var person1 = new Person();
var person2 = new Person();
console.log(person1.name) // Kevin
console.log(person2.name) // Kevin

那么Person函数的 prototype 属性到底指向的是什么呢? 该构造函数的原型

那什么是原型呢?可以这样理解:每一个JavaScript对象(除了Null)在创建的时候都会与之关联另外一个对象,这个对象就是我们所说的原型。

__proto__属性

这是每个JavaScript对象(除了Null)都具有的一个属性,叫__proto__,这个属性会指向该对象的原型

为了证明这一点,我们可以在浏览器的控制台输入:

function Person() {

}
var person = new Person();
console.log(person.__proto__ === Person.prototype); // true

于是我们得到以下的关系图:

img

既然实例对象和构造函数都可以指向原型,那么原型能不能指向实例对象或者构造函数呢?

constructor属性

原型不可以指向实例,但可以指向构造函数。每个原型都有一个constructor属性指向关联的构造函数。验证一下:

function Person {
  
}

console.log(Person === Person.prototype.constuctor); // true

于是我们得到以下的关系图:

原型链的概念

常问问题:谈谈js的原型链是怎么回事?

每一个JavaScript对象在创建的时候就会关联另外一个对象。当我们访问JavaScript对象的某个属性,而该属性不存在的时候,就会去原型上查找,原型上不存在,就会去原型的原型上查找。相互关联的原型组成的链式结构就是原型链。

常问问题:instanceof的原理?

原型链。instanceof右边变量的原型链上是否能找到等于左边变量的 proto

继承

类式继承(原型链继承)

通过将子类的原型指向父类的实例实现继承

// 父类
function SuperClass() {
  // 共有方法
  this.superValue = true;
}

// 父类添加原型方法
SuperClass.prototype.getSuperValue = function() {
  return this.superValue;
}

// 子类
function SubClass() {
  this.subValue = false;
}

// 类式继承
SubClass.prototype = new SuperClass();

SubClass.prototype.getSubValue = function() {
  return this.subValue;
}

var sub1 = new SubClass();

类式继承存在以下缺点:

  1. 如果父类构造函数中的共有属性有引用类型,当我们修改某个实例的这个引用类型的时候,另一个实例也会受影响

  2. 子类在创建的时候,无法给父类的构造函数传参(相当于每次从父类继承过来的属性都是同一个)

构造函数继承

为了解决类式继承的两个缺点,我们可以尝试着使用构造函数继承(使用call、apply来调用父类的构造函数)。

function SuperClass(id) {
  // 引用类型共有属性
  this.books = [1, 2, 3];
  // 值类型共有属性
  this.id = id;
}

SuperClass.prototype.showBooks = function() {
  console.log(this.books);
};

function SubClass(id) {
  SuperClass.call(this, id); // 构造函数继承,对没错是你最熟悉的样子
}

var instance1 = new SubClass(10);
var instance2 = new SubClass(11);
instance1.books.push(4);
console.log(instance1.books, instance1.id, instance2.book, instance2.id); // [1, 2, 3] 10 [1, 2, 3, 4] 11

instance1.showBooks(); // TypeError

但构造函数任然存在缺点:

由于这种继承不涉及到原型prototype,所以父类的原型属性我们就继承不了。

组合继承

组合继承就是类式继承 + 构造函数继承。使用了组合继承,既解决了类式继承的引用类型问题和传参问题(类式继承的缺点),又解决了构造函数继承不能使用父类的原型属性问题(构造函数继承的缺点)

function SuperClass(name) {
  this.name = name;
  this.books = [1, 2, 3];
}

SuperClass.prototype.getName = function() {
  console.log(this.name);
}

function SubClass(name, time) {
  SuperClass.call(this, name);  // 构造函数继承方法
  // 从父类继承后,新增一个属性
  this.time = time;
}

SubClass.prototype = new SuperClass(); // 类式继承方法

SubClass.prototype.getTime = function() {
  console.log(this.time);
}

var instance1 = new SubClass('name1', 2020);
var instance2 = new SubClass('name2', 2020);

instance1.books.push(4);
console.log(instance1.books); // [1, 2, 3]
console.log(instance2.books); // [1, 2, 3, 4] 改变引用类型属性不会对其他实例造成影响
instance1.getName(); // 可以调用父类的原型属性
instance1.getTime(); // 子类的原型属性

组合继承的缺点:

由于使用了构造函数继承和类式继承,父类的构造函数在构造函数继承中被调用了一次,在类式继承中又被调用了一次。会存在父类构造函数被调用多次的情况。

原型式继承

原型式继承,其实是基于类式继承的一个封装。类式继承需要我们先声明一个子类,然后将子类的prototype指向父元素的实例,而原型式继承则是在内部声明一个过渡对象,直接返回实例

function inheritObject(o) {
  // 声明一个过渡函数对象
  function F() {}
  // 过渡对象的原型继承父对象 (感觉和类式继承有点像)
  F.prototype = o;
  return new F();
}

原型式继承的缺点:

  1. 如果父类构造函数中的共有属性有引用类型,当我们修改某个实例的这个引用类型的时候,另一个实例也会受影响

  2. 子类在创建的时候,无法给父类的构造函数传参(相当于每次从父类继承过来的属性都是同一个)

  3. 不支持对子类额外添加属性

寄生式继承

在原型式继承里,如果子类想要赋予额外的属性,是一件很麻烦的事情,寄生式继承就提供了一种更好的方案(其实就是又包了一层)。

function inheritObject(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

var book = {
  name: 'js book',
  aLikeBook: ['css book', 'html book']
}

function createBook(obj) {
  var o = new inheritObject(obj);
  o.getName = function() {
    console.log(name);
  };
  return o;
}

寄生式继承的缺点(和类式继承一样):

  1. 如果父类构造函数中的共有属性有引用类型,当我们修改某个实例的这个引用类型的时候,另一个实例也会受影响
  2. 子类在创建的时候,无法给父类的构造函数传参(相当于每次从父类继承过来的属性都是同一个)

寄生组合式继承

顾名思义,就是构造函数继承 + 寄生式继承

组合寄生式集成可以分成两部分:一部分式继承父类的属性和方法,另一部分继承父类的原型属性。

// 原型式继承
function inheritObject(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

// 对子类原型的处理,为什么呢?我们不希望子类的原型是父类的实例,听着别扭?你可以打印一下类式继承的sub1.__proto__.constructor 发现是父类的构造函数,接下来需要进行改造
function inheritPrototype(subClass, superClass) {
  // 复制一份父类的原型副本保存在变量中
  var p = inheritObject(superClass.prototype);
  // 修正因为重写子类原型导致子类的constructor属性被修改
  p.constructor = subClass;
  // 设置子类的原型
  subClass.prototype = p; // p可以看作是父类的一个实例,而F只是一个过渡对象
}

目前看来这是一种比较完美的继承方法。

静态作用域与动态作用域

作用域

作用域是指程序源代码中定义变量的区域。作用域规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。

JavaScript采用词法作用域,也就是静态作用域

静态作用域

来看以下代码。因为 JavaScript 采用的是词法作用域,函数的作用域在函数定义的时候就决定了。而与词法作用域相对的是动态作用域,函数的作用域是在函数调用的时候才决定的。

var value = 1;

function foo() {
    console.log(value);
}

function bar() {
    var value = 2;
    foo();
}

bar(); // 1

是不是很惊讶?我们来分析一下,执行foo()函数,foo函数会从内部查找是否有局部变量,如果没有,就会根据书写的位置,查找上面一层的代码,也就是value=1,所以会打印1。

动态作用域

函数的作用域在函数调用的时候才决定的

常问问题:谈谈你对作用域的理解?

1.作用域规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。

2.作用域有静态作用域和动态作用域两种。

3.JavaScript中采用的是词法作用域(也就是静态作用域),函数的作用域在函数定义的时候就决定了。

JavaScript的执行上下文(JavaScript代码是顺序执行的吗?)

执行上下文

当我们去运行代码的时候,会进行一个"准备工作"(我理解为预编译),会一段一段地分析代码。举个例子,当我们执行到一个函数的时候,就会进行准备工作。更专业点说就叫做“执行上下文”。

JavaScript代码的执行顺序

如果问到 JavaScript 代码执行顺序的话,想必写过 JavaScript 的开发者都会有个直观的印象:顺序执行。真的是这样吗?请看以下代码:

function foo() {

    console.log('foo1');

}

foo();  // foo2

function foo() {

    console.log('foo2');

}

foo(); // foo2

结论:JavaScript引擎并非一行一行地分析和顺序执行,而是一段一段地分析执行。当执行一段可执行代码的时候,会进行一个“准备工作”(执行上下文),比如变量提升和函数提升。这个“一段一段”是怎么划分的呢?JS引擎遇到一段怎样的代码才会去做准备工作呢?

可执行代码

JavaSciprt在碰到可执行代码时会去做准备工作,可执行代码分为以下三种:

  • 全局代码
  • 函数代码
  • eval代码

当我们去运行可执行代码的时候,会进行一个"准备工作"(我理解为预编译),会一段一段地分析代码。举个例子,当我们执行到一个函数的时候,就会进行准备工作。更专业点说就叫做“执行上下文”。

执行上下文栈

每个函数都有一个执行上下文,那么多的执行上下文是如何管理的呢?JavaScript引擎利用执行上下文栈(Execution context stack, ESC)来管理执行上下文。

执行上下文中的三个重要属性

  • 变量对象(Variable object, VO):变量对象是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明
  • 作用域链(Scope chain):当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级执行上下文的对象变量中查找,一直找到全局上下文的对象变量。这样由多个执行上下文的变量对象构成的链表结构就称为作用域链
  • This:在全局执行上下文中,this的值指向全局对象。在函数执行上下文中,this的值取决于该函数是如何被调用的。(现在可以理解this永远指向最后调用他的那个对象这句话了吗?)

JavaScript中的变量提升

JS引擎在正式执行之前先进行一次预编译,在这个过程中,首先将变量声明及函数提升至当前作用域的顶部(当前执行上下文的顶部)。

常问问题:谈谈JavaScript的变量提升。

1.这是JavaScript的一个特性。在代码执行之前,会先进行分析。所有的变量声明和函数声明都会被提升到当前作用域的顶部(但是赋值不会提升,变量默认赋值为undefined)。

2.let和const声明的变量也会提升到当前作用域的顶部,但是不会被赋予’undefined‘,所以如果在let、const声明之前使用变量,会报错。(称为暂时性死区)

闭包

闭包的概念

闭包是指那些能访问自由变量的函数;自由变量是指在函数中使用的,但是既不是函数参数也不是函数的局部变量的变量;闭包的组成: 函数 + 函数能够访问的自由变量。

闭包的两个常用用途

  • 在函数外部能够访问函数内部的变量:通过使用闭包,可以通过在外部调用闭包函数,从而在外部访问到函数内部的变量,可以通过此方法创建私有变量。
  • 使已经运行结束的函数上下文中的变量对象继续留在内存中,因为闭包函数保留了这个变量对象的引用,所以这个变量对象不会被回收。

常问问题:谈谈你对闭包的理解?

1.闭包是能访问自由变量的函数。

2.闭包的原理需要讲到词法作用域。每个函数都有一个词法作用域,它定义了函数如何查找变量的规则。当函数内部引用了在其外部作用域的变量时,JavaScript引擎就会创建一个闭包,将外部作用域的变量保存下来。

3.闭包通常用来创建私有变量,避免全局作用域的变量污染,还可以实现模块化,将相关的函数和数据封装在一个闭包内(webpack)。

事件循环

什么是事件循环

首先,js是一门单线程的语言,当我们在执行一些耗时的操作时,可能需要用户等待较长时间。所以js执行过程中,先将同步代码执行完。将异步代码的回调放入到一个消息任务队列中。当同步代码执行完之后,清空队列中的所有微任务,再从消息队列中取出一个宏任务执行,再执行所有的同步代码并清空微任务,反复执行该过程。

宏任务

宏任务很简单,就是指消息队列中等待被主线程执行的事件。每个宏任务在执行时,JS引擎都会重新创建栈,然后随着宏任务中函数调用,调用栈(主线程的任务)也随之变化,最终,当该宏任务执行结束时,整个栈又会被清空,接着主线程继续执行下一个宏任务。

常见的宏任务有:setTimeout 、setInterval、I/O操作

微任务

微任务稍微复杂一点,它的执行时机在主函数执行结束之后,下一个宏任务执行之前。

常见的微任务有: new Promise().then()、MutationObserver

为什么引入微任务?只有宏任务行不行?

不行,JavaScript中之所以要引入微任务,一方面是由于宏任务的执行时机是不可控的,不利于控制代码的执行顺序,引入微任务。另一方面,使用微任务可以改变我们异步的编程模型,使得我们可以以同步的形式来编写异步代码。

Node中的时间循环和浏览器中的事件循环有什么区别?

常问问题:Node中的时间循环和浏览器中的事件循环有什么区别?

浏览器中的事件循环:因为JS本身是单线程的,所以遇到一个异步事件后并不能会等待执行结果,而是将这个事件挂起,继续执行执行栈中的其他任务。当异步任务返回结果后,会将执行结果存在一个任务队列中。任务列队会分为宏任务队列和微任务队列,当执行栈中的任务执行完了之后,会去查看微任务队列中是是否要执行的任务,有的话则执行完微任务队列中的任务。如果没有,则会从宏任务队列中取出第一个事件到执行栈中执行。如此反复。

Nodejs事件循环:Node中的事件循环是分阶段的:timers、I/O callbacks、idle prepare、poll、check、close callbacks。

而且在Nodejs10以前,执行完一个阶段的所有任务,执行nextTick、再执行所有的微任务。Nodejs10以后,和浏览趋于统一(会完成一个宏任务之后就去清空微任务队列)。

Nodejs中的nextTick回调在微任务之前执行。

this/call/apply/bind

常问问题:谈一谈JavaScript中的this?

this可以理解为执行上下文中的一个属性(this、作用域链、变量对象)。在实际开发的过程中,有四种调用模式。 1.如果该函数作为一个对象的方法来调用时,this 指向这个对象。 2.当函数不是一个对象的属性时,直接作为函数来调用时,this 指向全局对象 3.如果一个函数用 new 调用时,函数执行前会新创建一个对象,this指向新创建的实例。 4.如果是call、apply调用模式,则指向当前的object

call和 apply的区别

它们的作用一模一样,都是用来改变this的指向。只是传参的形式不同。

  • apply 接受两个参数,第一个参数指定了函数体内 this 对象的指向,第二个参数为一个类数组。
  • call 接收的参数不固定,单第一个参数也指定了函数体内 this 对象的指向,从第二个参数开始往后,每个参数被依次传入函数。

以下例子为demo,实现call、apply、bind函数

const arr = [1,2,3];
Object.prototype.toString.call(arr).slice(8, -1); // Array

generate原理?

  • 通过对生成器函数进行包装,以添加必要的方法,例如 next 和 return。这个包装过程还涉及到对生成器对象的一些修改,以确保 next 等方法的调用能够正确地进入一个由 switch case 语句组成的状态机模型。
  • 此外,Regenerator 还利用闭包技巧,用来保持生成器函数的上下文信息,以便在需要时能够正确地恢复执行。

为什么0.1+0.2不等于0.3?

常问问题:为什么0.1+0.2不等于0.3?

因为在ECMAScript中是按照64位存储的,而浮点数在存储的时候会先转成二进制,但是像0.1再转成二进制的时候是一个无限循环的数,,所以当0.1存下来的时候精度就已经发生了丢失,当我们再用浮点数进行计算的时候,使用的是丢失精度之后的数。