写在前面的话:最近复习过程中积累的笔记,本文内容主要涉及函数和异步编程。内容来源五花八门,如有不妥或错误欢迎指出。
函数的常见属性
-
name
-
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,一般参数顺序应该是:非默认参数->默认参数->剩余参数 -
prototype 是函数特有的属性,用于构造实例对象
function Person() {} console.log(Person.prototype); // { constructor: Person } -
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("我是静态方法");
}
需要了解以下几种继承方式:
-
原型链继承:通过让子类的原型对象指向父类的实例来实现原型链继承。这样,子类的实例就可以通过原型链访问到父类的属性和方法。缺点在于包含引用类型值的原型属性会被所有实例共享。 换而言之,如果一个实例改变了该属性,那么其他实例的该属性也会被改变。
Child.prototype = new Parent(); Child.prototype.constructor = Child; -
构造函数继承:通过在子类的构造函数中调用父类的构造函数,并使用
call或apply方法来改变父类构造函数中this的指向,从而将父类的属性复制到子类实例中。优点在于不会出现原型链继承中的原型属性被共享的问题。缺点在于不能继承父类 prototype 上的属性。function Child() { Parent.call(this); } -
组合继承(原型链继承 + 构造函数继承)优点在于不会出现原型链继承中的原型属性被共享的问题,也不会出现构造函数继承中的不能继承父类 prototype 上的属性的问题。缺点在于调用了两次 Parent(),在 Child 的实例和 prototype 上都添加了父类的属性和方法,造成冗余。
function Child() { // 1. 构造函数继承,继承实例属性 Parent.call(this); } Child.prototype = new Parent(); // 2. 原型链继承,继承原型方法 Child.prototype.constructor = Child; // 修复constructor指向 -
寄生组合继承。优点:不会出现原型链继承中的原型属性被共享的问题,也不会出现构造函数继承中的不能继承父类 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 绑定 > 显式绑定 > 隐式绑定。
-
全局调用:在严格模式
use strict下,this 为undefined。在非严格模式下,this 默认指向window(浏览器环境)或global(Node.js)。 -
new关键字调用构造函数创建对象时,this指向新创建的对象。通过
new关键字调用构造函数创建对象时,会经过以下过程:- 创建一个空对象。
- 空对象的
__proto__属性指向构造函数的prototype属性。 - 执行构造函数,如果构造函数中有
this,那么this指向这个空对象。 - 返回刚刚创建的对象,除非构造函数 return 了其他对象。(构造函数 return 了基本类型是无效的,依然会返回刚刚创建的对象)
function Student(name) { console.log(this); // Student {} this.name = name; // Student { name: 'Mary'} } var stu = new Student('Mary'); -
箭头函数:
this指向的是它定义时所处的外层作用域的this。(在用 Babel 将箭头函数转换为低版本语法时,是在外层作用域定义了_this = this,并在内部使用_this) -
隐式绑定:对象调用自身具有的方法时,
this会隐式绑定到对象上。(谁直接调用/最近,this就指向谁) -
显式绑定:如果对象要调用自身没有的方法,可以使用
call、apply或bind显式地将方法的this的指向对象,其中call和apply是立即调用函数,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 是一种键值对的集合,其中键是弱引用,只能是对象,值可以是任意类型。当键对象被回收时,与之关联的值也会被自动清除。
WeakMap 与 Map 的区别:
WeakMap中的键必须是对象,而Map中的键可以是任意类型。- 在
WeakMap中,键是弱引用,不会阻止与之关联的对象被垃圾回收,而在Map中,键是强引用,会阻止与之关联的对象被垃圾回收。 WeakMap的key无法用forEach或keys()遍历,更加安全。
WeakMap 的使用场景:
- 引用 DOM 元素。在浏览器中,DOM 元素可能会被移除(比如用户切换页面、删除组件),如果 DOM 在
WeakMap中被引用可以被自动回收,但如果在Map中被引用,就无法被垃圾回收,导致内存泄漏。 - 实现缓存。
obj被置为null后,WeakMap里的缓存会被自动回收,不会造成内存泄漏。Map则会继续存储obj的数据,即使obj不再被使用。
作用域链
当我们访问一个变量时,JS 引擎首先会在当前作用域寻找这个变量。如果当前作用域没有这个变量,就会去上一层作用域寻找。如果上一层作用域找不到,就去上上层寻找。直到全局作用域都找不到时,返回 undefined。
事件循环
事件循环(Event Loop)是 JavaScript 中处理异步操作的机制,使单线程的 JavaScript 能够完成大量的并发操作。
EventLoop 提供执行栈(Call Stack)、宏任务队列、微任务队列来调度和执行异步任务。宏任务由浏览器 / Node.js 宿主环境调度,包括定时器回调、I/O 操作、DOM 事件回调等。微任务由 JS 引擎自身调度,主要包括 promise.then、queueMicrotask、MutationObserver 等。
EventLoop 的执行顺序:
- 执行栈中没有任务时,事件循环会检查微任务队列。
- 如果微任务队列中有任务,则会依次执行微任务队列中的所有任务。
- 执行完微任务队列中的任务后,事件循环会开始从宏任务队列中取任务来执行。
- 执行完一个宏任务后,继续检查微任务队列。
- 事件循环会反复进行这些步骤,直到任务队列为空。
Promise
JS 的异步处理方案:回调函数(Callback Function)是将一个函数作为参数传递给另一个函数并延迟调用的技术,通常用于处理异步操作。在 Promise 出现之前,回调函数常常被用于异步编程,如果回调函数嵌套过多,可能出现回调地狱的问题。Promise 是 ES6 新增的异步编程解决方案,通过链式调用解决了回调地狱的问题。
Promise 对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(已成功)和 rejected(已失败)。只有异步操作的结果可以决定状态。一旦 Promise 对象的状态改变,就不会再变,并且任何时候都可以得到这个结果。这与事件(Event)完全不同,事件的特点是,如果你错过了它,再去监听,是得不到结果的。
创建 Promise:Promise 构造函数接受一个函数作为参数,该函数的两个参数分别是 resolve 和 reject。
实例方法(链式调用的核心):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() 方法返回一个包含 value 和 done 属性的对象,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 }
常用设计模式
- 单例模式:确保一个类只有一个实例,并提供一个全局访问点。应用:全局状态管理、数据库连接池、日志记录器。
- 工厂模式:将对象的创建逻辑封装在一个工厂方法中,客户端无需关心对象的创建细节。
- 观察者模式:定义了一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖它的对象都会收到通知并自动更新。如数据绑定、发布-订阅系统。
- 代理模式:为另一个对象提供代理对象,二者实现相同的接口,代理对象可以在调用真实对象之前或之后执行额外的逻辑。Vue 3 使用 Proxy 对象来实现响应式系统。