【面试必备】Javascript基础知识(2)

34 阅读11分钟

写在前面的话:最近复习过程中积累的笔记,本文内容主要涉及函数和异步编程。内容来源五花八门,如有不妥或错误欢迎指出。

函数的常见属性

  1. name

  2. length 表示函数期望的参数个数。常用于函数参数校验。length 的值是第一个有默认值的参数之前的参数个数,且不包含剩余参数。

    function fun1(b = "a", a) {}; // 0
    function fun2(a, b = "a") {}; // 1
    function fun3(a, ...args) {} // 1
    function fun4(a = 1, b, c) {}; // 0,一般参数顺序应该是:非默认参数->默认参数->剩余参数
    
  3. prototype 是函数特有的属性,用于构造实例对象

    function Person() {}
    console.log(Person.prototype); // { constructor: Person }
    
  4. arguments 在函数内部可访问,是一个类数组对象,被 ES6 的 ...args 替代,表示剩余参数

原型链

每个函数都有 prototype 属性,指向自身的原型对象(原型对象通过 constructor 属性指回构造函数本身)。每个对象都有 __proto__ 属性,指向自身构造函数的原型对象。

当我们访问对象的属性或方法时,首先会在对象自身上查找,如果找不到,那么还会在对象的原型上查找,以及原型的原型,依此类推,直到找到或者到达原型链的末尾 null

实例.__proto__ === 构造函数.prototype
构造函数.prototype.constructor === 构造函数
Object.prototype.__proto__ === null

当函数通过 new 关键字调用的时候,就称之为构造函数。一个函数能否 new 关键字调用被取决于是否具有 [[construct]] 方法。箭头函数不能作为构造函数。在 ES5 中的构造函数上定义的原型方法可以作为构造函数(具有[[construct]] 方法),一般来说一个构造函数中的原型方法是不应该作为构造函数的,因此在 ES6 中的改进是,class 中定义的原型方法不能作为构造函数。

ES5 的构造函数继承

在 ES5 中,继承是用构造函数实现的,例如以下是一个构造函数及其实例对象。

function Person(name, age){ // 使用 for in 遍历时,会遍历实例方法和原型方法
  this.name = name;
  this.age = age;
}
​
Person.prototype.sayName = function(){ // 原型方法
  console.1og(`我的名字是${this.name}`);
};
​
Person.staticFunction = function(){ // 静态方法
  console.log("我是静态方法");
}
​

需要了解以下几种继承方式:

  1. 原型链继承:通过让子类的原型对象指向父类的实例来实现原型链继承。这样,子类的实例就可以通过原型链访问到父类的属性和方法。缺点在于包含引用类型值的原型属性会被所有实例共享。 换而言之,如果一个实例改变了该属性,那么其他实例的该属性也会被改变。

    Child.prototype = new Parent();
    Child.prototype.constructor = Child;
    
  2. 构造函数继承:通过在子类的构造函数中调用父类的构造函数,并使用 callapply 方法来改变父类构造函数中 this 的指向,从而将父类的属性复制到子类实例中。优点在于不会出现原型链继承中的原型属性被共享的问题。缺点在于不能继承父类 prototype 上的属性。

    function Child() {
      Parent.call(this);
    }
    
  3. 组合继承(原型链继承 + 构造函数继承)优点在于不会出现原型链继承中的原型属性被共享的问题,也不会出现构造函数继承中的不能继承父类 prototype 上的属性的问题。缺点在于调用了两次 Parent(),在 Child 的实例和 prototype 上都添加了父类的属性和方法,造成冗余。

    function Child() {
      // 1. 构造函数继承,继承实例属性
      Parent.call(this);
    }
    Child.prototype = new Parent(); // 2. 原型链继承,继承原型方法
    Child.prototype.constructor = Child; // 修复constructor指向
    
  4. 寄生组合继承。优点:不会出现原型链继承中的原型属性被共享的问题,也不会出现构造函数继承中的不能继承父类 prototype 上的属性的问题,而且只调用一次Parent(),因此不会在Child的prototype上添加父类的属性和方法。缺点在于 Child.prototype 的原始属性和方法会丢失。

    function Child() {
      // 1. 构造函数继承,继承实例属性
      Parent.call(this);
    }
    Child.prototype = Object.create(Parent.prototype); // 不调用Parent构造函数
    Child.prototype.constructor = Child; // 修复constructor指向
    

ES6 的 class 继承

class 是 ES6 引入的语法糖,用来定义类和创建对象。

class Person{ // for in、Object.keys()等仅仅遍历实例属性和方法,不会遍历原型方法
  constructor(name, age) { // constructor() 是类的初始化方法,在使用 new 创建对象时被自动调用
    this.name = name;
    this.age = age;
    this.say = () => {
      console.log(`我的名字是${this.name}`) // 实例方法,每个实例都有独立的函数副本,自动绑定 this
    }
  }
​
  sayName(){ // 原型方法,所有实例共享同一函数(节省内存),动态绑定 this
    console.log(`我的名字是${this.name}`);
  }
​
  
  static staticFunction(){ // 静态方法,通过类名调用,如Person.staticFunction()
    console.log("我是静态方法")
  }
}

实际开发中建议使用 class 语法实现继承。super 是 ES6 新特性提出的特殊关键字。super 只能在子类中使用,用于访问父类属性或调用父类方法。子类在继承父类时,构造器中必须先使用 super 关键字再使用 this

class Child extends Parent {
    constructor(name, age) {
      super(name); // 必须在使用 this 前调用
      this.age = age;
    }
}

this 的指向

this 在运行时被绑定。优先级:new 绑定 > 显式绑定 > 隐式绑定。

  1. 全局调用:在严格模式 use strict 下,this 为 undefined。在非严格模式下,this 默认指向 window(浏览器环境)或 global(Node.js)。

  2. new 关键字调用构造函数创建对象时,this 指向新创建的对象。

    通过 new 关键字调用构造函数创建对象时,会经过以下过程:

    1. 创建一个空对象。
    2. 空对象的 __proto__ 属性指向构造函数的 prototype 属性。
    3. 执行构造函数,如果构造函数中有 this,那么 this 指向这个空对象。
    4. 返回刚刚创建的对象,除非构造函数 return 了其他对象。(构造函数 return 了基本类型是无效的,依然会返回刚刚创建的对象)
    function Student(name) {
      console.log(this); // Student {}
      this.name = name;  // Student { name: 'Mary'}
    }
    var stu = new Student('Mary');
    
  3. 箭头函数: this 指向的是它定义时所处的外层作用域的 this。(在用 Babel 将箭头函数转换为低版本语法时,是在外层作用域定义了 _this = this,并在内部使用 _this

  4. 隐式绑定:对象调用自身具有的方法时,this 会隐式绑定到对象上。(谁直接调用/最近,this 就指向谁)

  5. 显式绑定:如果对象要调用自身没有的方法,可以使用 callapplybind 显式地将方法的 this 的指向对象,其中 callapply 是立即调用函数,call 参数逐个传递,apply 参数以数组形式传递,bind() 则会返回一个绑定了 this 的新函数。(箭头函数的 this 不能通过这种方法改变)

    function greet(greeting) {
        console.log(`${greeting}, my name is ${this.name}`);
    }
    ​
    const person = { name: "Bob" };
    ​
    // 使用 call
    greet.call(person, "Hello"); // 输出 "Hello, my name is Bob"// 使用 apply
    greet.apply(person, ["Hi"]); // 输出 "Hi, my name is Bob"// 使用 bind
    const greetBob = greet.bind(person, "Hey");
    greetBob(); // 输出 "Hey, my name is Bob"
    

垃圾回收机制(GC)

  • 引用计数(Reference Counting)算法,是早先的一种垃圾回收算法,目前很少使用。它把对象是否是垃圾定义为有没有其他对象引用到它,如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。缺点:无法处理循环引用的问题,会造成内存泄漏。

  • 标记清除(Mark-Sweep)算法:分为标记阶段和清除阶段。

    标记阶段的具体做法:遍历所有的可达对象,将可达对象标记为“存活”。所谓可达对象,就是从根对象(通常是全局对象或栈中的变量)出发,能直接或间接访问到的对象,比如说一个对象作为函数的参数,在函数体内部可以访问到,或者一个对象作为另一个对象的属性,可以通过引用链找到它。

    在清除阶段,垃圾回收器会再次遍历所有的对象,对于没有被标记为“存活”的对象,进行回收。

    优点:解决了循环引用的问题。

    标记清除算法清除垃圾后,分配内存有三种算法:(假如需要大小为size的内存)First-fit,找到大于等于 size 的块立即返回。Best-fit,遍历整个空闲列表,返回大于等于 size 的最小分块。Worst-fit,遍历整个空闲列表,找到最大的分块,然后切成两部分,一部分 size 大小,并将该部分返回。都会面临以下缺点:内存碎片化、分配速度慢。

  • 标记压缩(Mark-Compact)算法类似标记清除算法,但在清除后会整理内存碎片,可以应对内存碎片化的缺点,但性能较低。

  • 分代回收算法:根据对象的存活时间,将内存分为新生代和老年代。

    新生代主要存储生命周期短的对象,使用复制算法快速回收,使用并行算法(仍然是一种全停顿算法)增加垃圾回收的效率。

    老年代主要存储生命周期长的对象,使用标记-清除或标记-压缩算法,使用增量标记(即三色标记算法)减少全停顿的时间,对于已经标记好的对象引用关系被修改的情况,采取写屏障的策略(强三色不变性)。V8采用懒性清除(Lazy Sweeping)来清理释放内存。

  • 内存泄露: JS 中常见的内存泄露主要有 4 种,分别是全局变量、闭包、DOM 元素的引用、定时器。

闭包与内存泄漏

  • 闭包(Closure)即函数 + 词法环境。闭包的两大作用是保存和保护。前者指的是当外部函数执行完毕并被销毁后,由于外部函数作用域中的变量依然被内部函数引用,所以即使外部函数已经不存在,这个变量也不会被 GC 清除,常用于计数器、防抖节流、柯里化中需要保存状态的情况。后者指的是闭包将变量保存在函数作用域中,可以防止外部直接访问或修改,在 ESM 之前,IIFE(立即调用函数表达式)就是利用闭包来实现模块化(依赖管理 + 变量私有)。
  • 但是如果闭包使用不当,可能会导致内存泄漏。内存泄漏指的是一些资源已经不再被使用,但是它们占据的内存空间没有被释放的情况。例如,当闭包中的内部函数使用 addEventListener 绑定事件时,如果监听的 DOM 元素被移除,而未调用 removeEventListener 解除监听,闭包会持续引用外部变量,导致内存泄漏。解决方案是在元素移除前手动调用 removeEventListener 解除监听。通过 Chrome Dev 的 Performance 和 Memory 可以排查内存泄漏问题。

WeakMap

WeakMap 是一种键值对的集合,其中键是弱引用,只能是对象,值可以是任意类型。当键对象被回收时,与之关联的值也会被自动清除。

WeakMapMap 的区别:

  1. WeakMap 中的键必须是对象,而 Map 中的键可以是任意类型。
  2. WeakMap 中,键是弱引用,不会阻止与之关联的对象被垃圾回收,而在 Map 中,键是强引用,会阻止与之关联的对象被垃圾回收。
  3. WeakMapkey 无法用 forEachkeys() 遍历,更加安全。

WeakMap 的使用场景:

  1. 引用 DOM 元素。在浏览器中,DOM 元素可能会被移除(比如用户切换页面、删除组件),如果 DOM 在 WeakMap 中被引用可以被自动回收,但如果在 Map 中被引用,就无法被垃圾回收,导致内存泄漏。
  2. 实现缓存。obj 被置为 null 后,WeakMap 里的缓存会被自动回收,不会造成内存泄漏。Map 则会继续存储 obj 的数据,即使 obj 不再被使用。

作用域链

当我们访问一个变量时,JS 引擎首先会在当前作用域寻找这个变量。如果当前作用域没有这个变量,就会去上一层作用域寻找。如果上一层作用域找不到,就去上上层寻找。直到全局作用域都找不到时,返回 undefined

事件循环

事件循环(Event Loop)是 JavaScript 中处理异步操作的机制,使单线程的 JavaScript 能够完成大量的并发操作。

EventLoop 提供执行栈(Call Stack)、宏任务队列、微任务队列来调度和执行异步任务。宏任务由浏览器 / Node.js 宿主环境调度,包括定时器回调、I/O 操作、DOM 事件回调等。微任务由 JS 引擎自身调度,主要包括 promise.thenqueueMicrotaskMutationObserver 等。

EventLoop 的执行顺序:

  1. 执行栈中没有任务时,事件循环会检查微任务队列。
  2. 如果微任务队列中有任务,则会依次执行微任务队列中的所有任务。
  3. 执行完微任务队列中的任务后,事件循环会开始从宏任务队列中取任务来执行。
  4. 执行完一个宏任务后,继续检查微任务队列。
  5. 事件循环会反复进行这些步骤,直到任务队列为空。

Promise

JS 的异步处理方案:回调函数(Callback Function)是将一个函数作为参数传递给另一个函数并延迟调用的技术,通常用于处理异步操作。在 Promise 出现之前,回调函数常常被用于异步编程,如果回调函数嵌套过多,可能出现回调地狱的问题。Promise 是 ES6 新增的异步编程解决方案,通过链式调用解决了回调地狱的问题。

Promise 对象代表一个异步操作,有三种状态pending(进行中)、fulfilled(已成功)和 rejected(已失败)。只有异步操作的结果可以决定状态。一旦 Promise 对象的状态改变,就不会再变,并且任何时候都可以得到这个结果。这与事件(Event)完全不同,事件的特点是,如果你错过了它,再去监听,是得不到结果的。

创建 Promise:Promise 构造函数接受一个函数作为参数,该函数的两个参数分别是 resolvereject

实例方法(链式调用的核心):then, catch, finally.then() 方法处理 resolve,返回一个 fulfilled 状态的 Promise。如果传入两个回调函数,那么第二个用于处理 rejected.catch() 方法处理 rejected,是 .then(null, onRejected) 的语法糖,.finally() 无论成功失败都会执行,用于清理工作。

错误处理:推荐写法是在链末尾使用单个 .catch() 处理所有错误。执行器函数和 .then 回调中的同步错误会被自动捕获,转为 rejected Promise,错误会沿着链一直向后传递,直到遇到 .catch() 处理。不过这些异步错误无法被 try-catch 捕获,如果想要被 try-catch 捕获的话,需要用 throw 来抛出错误。

静态方法Promise.all(), Promise.race(), Promise.allSettled(), Promise.any(), Promise.resolve(), Promise.reject()

Async / Await

async / await 分别用于声明一个异步函数和等待一个异步函数执行完成,当使用 await 时必须声明 async。使用 async / await 的好处在于:1. 减少嵌套;2. Promise 的 reject 是异步的,而 try-catch 只能捕获同步错误。因此 async / await 使得处理同步 + 异步错误更加方便,它产生的错误可以被 try-catch 捕获,可以被定位。

生成器和迭代器

生成器是一种特殊的函数,用 function* 定义。调用生成器时会返回一个迭代器,使用迭代器的 next() 方法,会执行生成器的一个 yield 然后暂停。async / await 的底层实现就是生成器与迭代器。数组等可迭代对象的解构的原理也是生成器和迭代器(不过对象的解构赋值是基于属性名匹配,而非迭代器)。

迭代器是一种对象,它提供了一种按序访问集合元素的方法。它具有两个核心方法:next()return()next() 方法返回一个包含 valuedone 属性的对象,value 表示当前迭代的值,done 表示迭代是否完成。return() 方法用于提前终止迭代。

function* generatorExample(){
  yield 1;
  yield 2;
  yield 3;
}
const iterator = generatorExample();
console.log(iterator.next());// { value: 1, done: false }
console.log(iterator.next());// { value: 2, done: false }
console.log(iterator.next());// { value: 3, done: false }
console.log(iterator.next());// { value: undefined, done: true }

常用设计模式

  1. 单例模式:确保一个类只有一个实例,并提供一个全局访问点。应用:全局状态管理、数据库连接池、日志记录器。
  2. 工厂模式:将对象的创建逻辑封装在一个工厂方法中,客户端无需关心对象的创建细节。
  3. 观察者模式:定义了一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖它的对象都会收到通知并自动更新。如数据绑定、发布-订阅系统。
  4. 代理模式:为另一个对象提供代理对象,二者实现相同的接口,代理对象可以在调用真实对象之前或之后执行额外的逻辑。Vue 3 使用 Proxy 对象来实现响应式系统。