JS里面的一些重点

JS里面的一些重点

image-20250219154522454

1、原型和原型链

JavaScript 中所有的对象都有一个内置属性,称为它的 prototype(原型)。它本身是一个对象,故原型对象也会有它自己的原型,逐渐构成了原型链。原型链终止于拥有 null 作为其原型的对象上。

末端通常是 Object.prototype,其 [[Prototype]]null

函数的prototype属性

当我们定义函数时,JS会自动为它创建一个prototype属性,这是一个对象,里面存放了通过该构造函数创建实例时共享的属性和方法。

对象的内部原型链接

每个对象都有一个内部属性,通常用[[Prototype]]表示,指向原型对象,也就是构造该对象的模版对象。可以通过__proto__访问这个链接,也可以使用Object.getPropertyOf(obj)来获取。

对象创建时,自动设置原型对象

当使用构造函数创建对象时(使用new关键字),新对象的[[Prototype]]会自动设置为构造函数的prototype对象。

原型链

原型链就是对象及其内部属性[[Prototype]]属性形成的一条链。

当我们访问一个对象的属性时,JS引擎首先在对象自身查找。如果没有找到,沿着原型链依次查找其原型、原型的原型…直到找到该属性或者达到链的末端(通常是 Object.prototype,其 [[Prototype]]null

修改原型

  • 在原型对象上添加或修改属性:所有实例都会通过原型链获得最新的值。

  • 直接替换原型:会丢失自动生成的constructor属性。需要手动修复。

    1
    2
    3
    4
    5
    6
    Person.prototype = {
    constructor: Person, // 修复 constructor 属性
    sayHi: function() {
    console.log("Hi, I'm " + this.name);
    }
    };

获取对象上非原型链上的属性obj.hasOwnProperty(key)

2、闭包 Closure

闭包的定义:是指一个函数和其创建时的词法环境的组合。闭包允许函数记住并访问它定义时所在的作用域,即使这个函数在其作用域外执行。

例如,下面的内部函数能够访问外部函数的局部变量,即使外部函数执行结束,局部变量a仍然存在于闭包中。内部函数和其引用的变量a 共同构成了一个闭包。

1
2
3
4
5
6
7
8
9
10
function outer() {
var a = 1; // outer 的局部变量
function inner() {
console.log(a); // inner 能访问 a
}
return inner;
}

var closureFn = outer(); // outer 执行后返回 inner
closureFn(); // 输出 1,inner 依然可以访问 a

闭包的形成和原理

  • 词法作用域:函数定义时就确定了作用域,不是运行时决定的。闭包依赖了这个特性,内部函数保存了其创建时的作用域环境。
  • 函数和环境的绑定:当函数被创建,JS引擎会为其生成一个隐式属性,指向其定义时的作用域链。这样即使外部函数已经执行完毕,内部函数仍然可以通过这条作用域链访问到外部函数的局部变量。
  • 内存和声明周期:闭包持有对外部变量的引用,所以外部变量不会被垃圾回收器立即回收。

闭包的常见用途

  • 私有变量,数据封装

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    function createCounter() {
    let count = 0; // 私有变量
    return {
    increment: function() {
    count++;
    console.log(count);
    },
    decrement: function() {
    count--;
    console.log(count);
    }
    };
    }

    let counter = createCounter();
    counter.increment(); // 输出 1
    counter.decrement(); // 输出 0
    // 无法直接访问 count
  • 函数工厂,创建定制化的函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function makeAdder(x) {
    return function(y) {
    return x + y;
    };
    }

    let add5 = makeAdder(5);
    console.log(add5(3)); // 输出 8

  • 实现模块化,封装变量和函数,暴露有限的接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    var Module = (function() {
    var privateVar = "I am private";
    function privateMethod() {
    console.log(privateVar);
    }
    return {
    publicMethod: function() {
    privateMethod();
    }
    };
    })();

    Module.publicMethod(); // 输出 "I am private"

注意事项

  • 内存泄露:闭包会让外部变量在闭包一直存在,消耗内存,要注意及时解除不再需要的引用,
  • 性能问题:大量的闭包引用大量的数据,可能导致额外的内存占用
  • 调试难度:涉及作用域链,可能会增加调试复杂度

循环中使用闭包解决 var 定义函数的问题

1
2
3
4
5
for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i)
}, i * 1000)
}

1、var修改为let

2、闭包+立即执行函数

1
2
3
4
5
6
7
for (var i = 1; i <= 5; i++) {
(function(j) {
setTimeout(function timer() {
console.log(j);
}, j * 1000);
})(i);
}

3、作用域和作用域链

概念

作用域指的是代码中定义变量和函数的区域,以及这些变量和函数能够被访问的范围。

JS使用的是词法作用域,代码编写时就确定,不是运行时动态决定的。

主要类型

全局作用域:在代码的最外层定义的变量和函数,在整个程序中都可以访问

  • 最外层函数和最外层函数外面定义的变量拥有全局作用域

  • 所有未定义直接赋值的变量自动声明为全局作用域

    1
    2
    3
    function foo() {
    bar = 10; // bar 未用 var/let/const 声明,将自动成为全局变量
    }
  • 所有window对象的属性拥有全局作用域

  • 全局作用域有很大的弊端,过多的全局作用域变量会污染全局命名空间,容易引起命名冲突。

函数作用域:每个函数创建一个独立的作用域,函数内部定义的变量只在函数内可见

块级作用域:使用letconst定义的变量具有块级作用域,在大括号内有效

  • let和const声明的变量不会有变量提升,也不可以重复声明
  • 在循环中比较适合绑定块级作用域,这样就可以把声明的计数器变量限制在循环内部。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 全局作用域
var globalVar = "global";

function outer() {
// outer 的作用域
var outerVar = "outer";

function inner() {
// inner 的作用域
var innerVar = "inner";
console.log(globalVar); // 可访问全局变量
console.log(outerVar); // 可访问外层函数变量
console.log(innerVar); // 可访问自身变量
}

inner();
// console.log(innerVar); // 会报错,innerVar 不在 outer 的作用域内
}

outer();
// console.log(outerVar); // 会报错,outerVar 不在全局作用域内

作用域链

作用域链是指在变量查找时形成的一系列作用域的链条。

当JS引擎在某个作用域内查找一个变量,没有找到,就会沿着作用域链向外层查找,直到查找到全局作用域为止。如果最终在全局作用域中也找不到,就返回undefined。(严格模式会报错)

形成过程

每当函数被调用,会创建一个执行上下文,其中包含该函数的变量对象。每个执行上下文还会保存一个指向外层作用域的引用(父作用域),多个执行上下文按嵌套关系构成一个链条,这就是作用域链。

特点

  • 静态性(词法作用域)
  • 嵌套查找

作用

保证对执行环境有权访问的所有变量和函数的有序访问,通过作用域链,可以访问到外层环境的变量和函数。

底层原理

在 JavaScript 中,每个执行环境(比如函数执行时的环境或全局执行环境)都会有一个与之关联的变量对象(Variable Object),它存储了该环境内所有的变量和函数。作用域链实际上就是一个由这些变量对象组成的链表,用于在查找变量时依次搜索各个执行环境。

1、当前执行上下文的变量对象在最前面

2、向上查找外层执行环境

3、全局执行上下文的变量对象始终位于最后

4、执行上下文

概念

每一段代码在执行时,都必须在某个执行上下文中运行。无论是全局代码、函数代码,还是 eval 执行的代码,都有对应的执行上下文。执行上下文保存了代码运行时需要的一切信息。

  • 变量环境/词法环境(Variable/ Lexical Environment):存储变量、函数声明、参数等数据。
  • 作用域链(Scope Chain):用于解析变量、函数名的查找顺序,保证内部代码可以访问外部环境的变量。
  • this 绑定:定义当前上下文中 this 的值。

三种类型

  • 全局执行上下文(Global Execution Context)

    当 JavaScript 脚本首次加载时,首先会创建一个全局执行上下文。全局执行上下文对应全局对象。在全局上下文中定义的变量和函数,都会成为全局对象的属性或方法。

  • 函数执行上下文(Function Execution Context)

    每次调用函数时,都会为该函数创建一个新的执行上下文。函数上下文拥有自己的变量环境、作用域链和 this 值(通常由函数调用的方式决定)。当函数调用结束后,对应的执行上下文会被销毁(前提是没有被闭包引用,从而延长其生命周期)。

  • Eval 执行上下文(Eval Execution Context)

    当使用 eval 函数执行字符串形式的代码时,会创建一个特殊的执行上下文(实际使用中较少)。

执行上下文的创建过程

1、创建阶段(Creation Phase)

变量对象(Variable Object):在这个阶段,解析器会扫描整个上下文,处理所有的变量和函数声明,将它们存储到变量对象中。这个过程也包括“提升”(Hoisting)的行为,即变量和函数声明会提前注册,但变量的赋值不会提前。

建立作用域链:确定当前执行上下文的作用域链,通常是由当前上下文的变量对象和所有外层上下文的变量对象构成的列表。

确定 this 的绑定:根据调用方式确定当前上下文中 this 的值。

2、执行阶段(Execution Phase)

代码开始按顺序执行,此时变量对象已经准备好,所有标识符的查找将沿着作用域链进行。

执行上下文的调用栈(Call Stack)

全局执行上下文 总是最先进入调用栈,并且在整个程序生命周期内保持在栈底。每当调用函数时,都会创建一个新的函数执行上下文并推入调用栈;函数执行结束后,其上下文会从调用栈中弹出。

5、this/call/apply/bind

this:函数执行时所关联的上下文对象。它的具体值取决于函数被调用的方式,而不是函数定义的位置。

构造函数调用:this 指向新创建的实例对象

1
2
3
4
5
function Person(name) {
this.name = name;
}
const person = new Person('Bob');
console.log(person.name); // 输出 "Bob"

间接调用(call/apply/bind):显式指定 this 的值

对象方法调用:this 指向调用该方法的对象

全局环境:this 通常指向全局对象(在浏览器中是 window;在 Node.js 中是 global

箭头函数:不创建自己的 this,它的 this 值继承自外部作用域,即定义时的上下文。

callapplybind 的介绍

1、call:立即调用函数,并显式指定 this 的值,同时依次传入参数。

1
greet.call(person, 'Hello', '!'); 

2、apply:与 call 类似,立即调用函数,并显式指定 this,但参数以数组(或类数组对象)的形式传入。

1
greet.apply(person, ['Hi', '!!!']); 

3、bind:返回一个新的函数,该函数永久绑定了指定的 this 值,可以在稍后调用。

bind 不会立即调用函数,而是返回一个新函数,可以在后续任意时刻调用。

1
2
3
4
5
6
7
8
function greet(greeting, punctuation) {
console.log(greeting + ', ' + this.name + punctuation);
}

const person = { name: 'Frank' };

const greetFrank = greet.bind(person, 'Hey');
greetFrank('?'); // 输出 "Hey, Frank?"

实现它们

call:

1、在目标对象添加临时属性,指向要调用的函数

2、目标对象调用函数,这样函数的this就指向了目标对象

3、调用结束,删除临时属性

1
2
3
4
5
6
7
8
9
10
11
12
13
Function.prototype.myCall = function(context, ...args) {
// 如果 context 为 null 或 undefined,则默认指向全局对象(浏览器下为 window)
context = context || globalThis;
// 为避免属性名冲突,生成一个独一无二的属性名
const fnSymbol = Symbol();
// 将函数(this)赋值给目标对象的一个临时属性
context[fnSymbol] = this;
// 通过目标对象调用该函数,并传入参数
const result = context[fnSymbol](...args);
// 删除临时属性
delete context[fnSymbol];
return result;
};

apply:(与call类似,只不过区别是接收的参数是数组或者类数组类型)

1
2
3
4
5
6
7
8
9
Function.prototype.myApply = function(context, argsArray) {
context = context || globalThis;
const fnSymbol = Symbol();
context[fnSymbol] = this;
// 使用扩展运算符或 apply 调用函数
const result = argsArray ? context[fnSymbol](...argsArray) : context[fnSymbol]();
delete context[fnSymbol];
return result;
};

bind:

返回一个新函数并通过闭包捕获原函数、绑定上下文和预设参数;

在调用时判断调用方式(普通调用或构造调用)来正确确定 this

维护原型链,保证构造函数场景下新实例能正确继承原函数的原型属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Function.prototype.myBind = function(context, ...args) {
const self = this; // 保存原函数引用

// 返回一个新函数(绑定函数)
function boundFunc(...bindArgs) {
// 如果通过 new 调用,this instanceof boundFunc 为 true,此时使用新创建的实例作为 this,
// 否则,使用绑定时传入的 context 作为 this
return self.apply(
this instanceof boundFunc ? this : context,
args.concat(bindArgs)
);
}

// 设置原型链,保证通过 new 调用时,新对象可以继承原函数的原型
boundFunc.prototype = Object.create(self.prototype);

return boundFunc;
};

6、异步编程

异步编程:允许程序在等待某些耗时操作(如网络请求、文件读取或定时任务)完成的同时,继续执行其他任务,不用一直阻塞等待。当耗时任务完成后,通过回调函数、事件、Promise、或 async/await 等机制处理结果。异步编程通过事件循环机制,可以使耗时任务在后台执行,不阻塞主线程。

同步编程:执行过程是按照顺序的,每个任务都必须等待前一个任务完成后才能开始,若某个任务耗时较长,就会导致程序暂停,降低响应速度。

事件循环 Event Loop

任务队列:当异步任务(如定时器、网络请求)完成后,其对应的回调函数会被放入任务队列。

执行栈:JavaScript 有一个单一的执行栈,当栈内没有同步任务时,事件循环会从任务队列中取出一个任务执行。

异步执行:这样就实现了即使某些任务耗时较长,程序也能继续执行其他任务,等待耗时任务完成后再处理结果。

常见的异步编程方式

1、回调函数Callback

回调函数是一个函数,它被传递给另一个函数作为参数,并在某个特定的时刻被调用。

最传统的异步方式,在任务完成后调用指定的函数来处理结果。

1
2
3
4
5
6
7
8
function fetchData(callback) {
setTimeout(() => {
callback('数据加载完成');
}, 2000);
}
fetchData((result) => {
console.log(result);
});

2、Promise

提供了一种更优雅的方式来处理异步操作,使得代码更易读、易维护。

它通过链式调用来避免回调地狱。

1
2
3
4
5
6
7
8
9
10
11
const fetchDataPromise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve('数据加载完成');
}, 2000);
});

fetchDataPromise.then(result => {
console.log(result);
}).catch(error => {
console.error(error);
});

3、async/await

基于 Promise 的语法糖,使得异步代码看起来像同步代码,更直观。

1
2
3
4
5
6
7
8
9
async function fetchData() {
const result = await new Promise((resolve, reject) => {
setTimeout(() => {
resolve('数据加载完成');
}, 2000);
});
console.log(result);
}
fetchData();
  • 定时器函数 setTimeout
1
2
3
const timerId = setTimeout(() => {
console.log('2秒后执行的回调');
}, 2000);

返回值:定时器ID

取消定时器:clearTimeout(timerId)

工作原理:在浏览器中,setTimeout 属于浏览器提供的 Web API,定时器功能是由浏览器的其他部分(通常运行在独立线程中)处理的。调用setTimeout时,JS引擎会把回调函数和延迟时间传递给浏览器,浏览器会注册一个定时器,记录回调函数和延迟时间。在独立的线程中启动定时器,在后台等待指定的延迟时间。当延迟时间结束,浏览器将回调函数放入任务队列(宏任务队列),等待事件循环空闲后执行。

特点:不保证精确性,延迟只是一个最小等待时间,实际回调的执行时间可能因为当前任务的执行或者其他定时器的竞争而延迟。setTimeout允许耗时任务(例如 DOM 操作、网络请求等)异步调度,避免阻塞主线程。

  • Promise

Promise 是一种对异步操作结果进行包装的机制,可以更优雅地处理回调函数,避免“回调地狱”(callback hell),从而使异步代码更容易理解和维护。

三种状态

pending等待中 fulfilled成功 rejected失败

一旦 Promise 的状态从 pending 转变为 fulfilled 或 rejected,就不会再改变。

链式调用

Promise 提供了 .then().catch().finally() 方法,可以在操作完成后进行处理,并将多个异步操作串联起来,实现链式调用。

工作原理

创建Promise实例,构造函数接收一个执行器函数

函数立即执行,并传入两个函数参数resolve, reject,分别用于将Promise的状态从pending转换为fulfilled或者rejected。

通过.then方法注册成功时要执行的回调函数,.catch方法注册失败时的回调函数。

怎么实现的链式调用呢?

.then() 方法会返回一个新的 Promise,这使得可以将多个异步操作串联起来,形成一个链式结构,按顺序处理每个操作的结果或错误。

1
2
3
4
5
6
7
8
9
const promise = new Promise((resolve, reject) => {
// 模拟异步操作
setTimeout(() => {
// 如果操作成功,调用 resolve 并传入结果
resolve("操作成功");
// 如果操作失败,可以调用 reject
// reject("操作失败");
}, 1000);
});
  • async/await关键字

是Promise的语法糖,使得异步代码的写法看起来更像同步代码,提升代码的可读性和可维护性。

在函数前加上async关键字,就定义了一个异步函数,无论函数内部返回什么,async函数总会返回一个Promise。await关键字只能在async函数内部使用,它会暂停async函数的执行,等待一个Promise,如果成功,返回解析值。如果失败,抛出异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
async function fetchData() {
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
return data;
} catch (error) {
throw new Error('数据获取失败');
}
}

fetchData()
.then(data => console.log(data))
.catch(error => console.error(error));

工作原理:

暂停与恢复
当遇到 await somePromise 时,async 函数会暂停执行,将后续代码注册为一个微任务(microtask),并等待 somePromise 完成。完成后,事件循环会从微任务队列中取出这个任务,恢复 async 函数的执行,并将 Promise 的解析值赋给 await 表达式。

非阻塞特性
虽然在 async 函数内部看起来像是同步代码,但实际上 async/await 并不会阻塞整个主线程,它只是在当前 async 函数内部暂停执行,其他代码仍然可以继续运行。

7、面向对象

1、对象创建的方式有哪些

  • 对象字面量 :使用花括号定义对象

    1
    2
    3
    4
    const obj = {
    name: 'Alice',
    age: 25
    };
  • 构造函数:定义一个函数作为构造函数,使用new关键字创建实例

    1
    2
    3
    4
    5
    function Person(name, age) {
    this.name = name;
    this.age = age;
    }
    const person1 = new Person('Bob', 30);
  • es6 类class:使用class语法定义构造函数和原型方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Person {
    constructor(name, age) {
    this.name = name;
    this.age = age;
    }

    greet() {
    console.log(`Hello, my name is ${this.name}`);
    }
    }
    const person2 = new Person('Charlie', 28);
  • Object.create:基于现有对象创建一个新对象,并指定新对象的原型

    1
    2
    3
    4
    5
    6
    7
    8
    const proto = {
    greet() {
    console.log(`Hi, I am ${this.name}`);
    }
    };

    const obj2 = Object.create(proto);
    obj2.name = 'Dave';
  • new Object:通过Object构造函数

    1
    2
    3
    const obj3 = new Object();
    obj3.name = 'Frank';
    obj3.age = 35;
  • 工厂函数:普通的函数,用来封装对象的创建逻辑,返回新对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    function createPerson(name, age) {
    return {
    name,
    age,
    greet() {
    console.log(`Hello, my name is ${name}`);
    }
    };
    }

    const person3 = createPerson('Eve', 22);

2、对象继承的方式有哪些

  • 原型链继承:利用对象的原型属性,将一个对象作为另一个对象的原型。

    • 优点:实现简单,能够继承父对象的属性和方法。
    • 缺点:所有子对象共享父对象的引用类型属性,容易出现引用共享的问题。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    function Parent() {
    this.name = 'Parent';
    }
    Parent.prototype.sayHello = function() {
    console.log('Hello from ' + this.name);
    };

    function Child() {}
    // 让 Child 的原型指向 Parent 的一个实例
    Child.prototype = new Parent();

    const child = new Child();
    child.sayHello(); // 输出 "Hello from Parent"
  • 2、借用构造函数继承(经典继承):在子构造函数内部调用父构造函数,通过 callapply 将父构造函数的执行上下文绑定到子实例上,从而让子实例拥有父构造函数中的属性。

    优点:每个子对象都有自己的属性副本,不会共享引用类型的属性。

    缺点:只能继承父构造函数中的属性,不能继承父构造函数原型中的方法。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    function Parent(name) {
    this.name = name;
    this.colors = ['red', 'blue'];
    }

    function Child(name, age) {
    Parent.call(this, name); // 借用构造函数
    this.age = age;
    }

    const child1 = new Child('Alice', 10);
    child1.colors.push('green');
    console.log(child1.colors); // 输出 ["red", "blue", "green"]

    const child2 = new Child('Bob', 12);
    console.log(child2.colors); // 输出 ["red", "blue"]
  • 3、组合继承:结合了原型链继承和借用构造函数继承

    优点:能够同时继承属性和方法,比较全面。

    缺点:父构造函数被调用了两次(一次在 Child.prototype = new Parent(),一次在 Parent.call(this, name)),可能会带来不必要的性能损耗。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    function Parent(name) {
    this.name = name;
    this.colors = ['red', 'blue'];
    }
    Parent.prototype.sayHello = function() {
    console.log('Hello from ' + this.name);
    };

    function Child(name, age) {
    Parent.call(this, name); // 继承属性
    this.age = age;
    }
    Child.prototype = new Parent(); // 继承方法
    Child.prototype.constructor = Child; // 修复 constructor 指向

    const child = new Child('Alice', 10);
    child.sayHello(); // 输出 "Hello from Alice"
  • 4、寄生式继承:创建一个仅用于封装继承过程的函数,该函数内部先创建一个以某对象为原型的新对象,然后对这个新对象进行增强,最后返回它。

    优点:简单易用,可以对对象进行定制和扩展。

    缺点:缺少对引用类型属性的封装问题,容易造成共享问题。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    function createObj(original) {
    function F() {}
    F.prototype = original;
    return new F();
    }

    const parent = {
    name: 'Parent',
    sayHello: function() {
    console.log('Hello from ' + this.name);
    }
    };

    const child = createObj(parent);
    child.name = 'Child';
    child.sayHello(); // 输出 "Hello from Child"
  • 5、寄生组合继承:是对组合继承的优化,避免了在设置子对象原型时调用父构造函数,从而只调用一次父构造函数,提高效率。常用方法是利用 Object.create 来创建子对象的原型。

    优点:避免了组合继承中父构造函数调用两次的问题,是目前推荐使用的继承方式。

    缺点:实现上稍微复杂一点,但大部分情况都值得使用这种方式。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    function Parent(name) {
    this.name = name;
    this.colors = ['red', 'blue'];
    }
    Parent.prototype.sayHello = function() {
    console.log('Hello from ' + this.name);
    };

    function Child(name, age) {
    Parent.call(this, name);
    this.age = age;
    }
    // 使用 Object.create 代替 new Parent()
    Child.prototype = Object.create(Parent.prototype);
    Child.prototype.constructor = Child;

    const child = new Child('Alice', 10);
    child.sayHello(); // 输出 "Hello from Alice"

  • 6、ES6 Class 继承:ES6 引入了 class 关键字和 extends 关键字,使得对象继承写法更加简洁。底层原理依然基于原型链,但语法更加直观和类似于传统面向对象语言。

    优点:语法更清晰、更接近传统面向对象编程习惯,同时内置处理了原型继承的问题。

    缺点:本质上还是基于原型继承,某些底层细节与 ES5 的继承方式类似。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    class Parent {
    constructor(name) {
    this.name = name;
    this.colors = ['red', 'blue'];
    }
    sayHello() {
    console.log('Hello from ' + this.name);
    }
    }

    class Child extends Parent {
    constructor(name, age) {
    super(name); // 调用父类构造函数
    this.age = age;
    }
    }

    const child = new Child('Alice', 10);
    child.sayHello(); // 输出 "Hello from Alice"
  • 7、Object.create() 方式:使用 Object.create 创建一个新对象,并指定其原型为传入的对象。这种方式非常直接,可以精确控制新对象的原型链。

    优点:简单、直接,可以灵活地创建基于现有对象的新对象。

    缺点:只能继承对象本身的方法和属性,对于构造函数模式下的属性初始化需要另外处理。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    const parent = {
    name: 'Parent',
    sayHello: function() {
    console.log('Hello from ' + this.name);
    }
    };

    const child = Object.create(parent);
    child.name = 'Child';
    child.sayHello(); // 输出 "Hello from Child"

8、垃圾回收与内存泄露

内存管理主要由垃圾回收机制(Garbage Collection, GC)自动完成,但如果不注意代码的设计和资源的管理,仍然可能导致内存泄漏。

垃圾回收:自动检测程序中不再被使用的内存,并将其回收,以便后续分配给其他对象。

标记清除(Mark-and-Sweep)
这是最常见的垃圾回收算法。其基本过程如下:

  1. 标记阶段:从根对象(如全局对象、当前执行上下文等)开始,遍历所有可达的对象,并做上标记。
  2. 清除阶段:所有没有被标记的对象被认为是不可达的,即没有引用指向它们,这些对象的内存将被释放。

引用计数(Reference Counting)
另一种算法是对每个对象维护一个引用计数,当计数为 0 时,表明没有任何引用指向该对象,可以被回收。不过引用计数容易产生循环引用的问题,因此现代 JavaScript 引擎一般采用标记清除或其改进版本(例如分代回收)。

分代回收(Generational Collection)
现代引擎(如 V8)会把对象分为“新生代”和“老生代”:

  • 新生代:存储生命周期较短的对象,垃圾回收频率较高。
  • 老生代:存储生命周期较长的对象,垃圾回收频率较低。
    这种方法可以提高性能,因为大部分对象很快就不再使用,只在新生代中进行频繁的垃圾回收。

内存泄漏:程序中不再需要的内存未能被垃圾回收机制释放,导致内存占用逐渐增加。长时间运行的程序如果发生内存泄漏,会引发性能下降、响应变慢甚至崩溃。

常见的内存泄漏原因:

  • 全局变量的滥用
    无意中将变量定义为全局变量(例如漏写 varletconst),使得这些变量一直存在于全局作用域中,从而无法被回收。
  • 闭包
    闭包虽然是实现数据封装和模块化的有力工具,但如果使用不当,闭包会意外地持有对不再需要的对象的引用,从而导致内存无法释放。
  • 定时器和事件监听器
    未能及时清除的定时器(如 setInterval)或未注销的事件监听器会一直持有对 DOM 节点或其他对象的引用,导致这些对象无法回收。
  • 脱离 DOM 的引用
    当 DOM 元素被移除后,如果代码中仍然持有对这些元素的引用,那么这些元素及其关联的数据不会被回收。
  • 缓存和数据结构
    长期存储在缓存或数据结构中的数据,如果没有合理的失效机制,也会导致内存泄漏。

如何防止内存泄漏?

  • 合理使用变量作用域
    避免在全局作用域中定义过多变量,尽可能使用局部变量,并及时清理不需要的引用。

  • 注意闭包的使用
    在使用闭包时,确保没有无意中引用大量数据或 DOM 元素,可以通过解除引用或采用更合适的设计来避免泄漏。

  • 及时清除定时器和事件监听器

    在不需要时,使用 clearIntervalclearTimeout 取消定时器,以及在组件卸载或 DOM 元素销毁时移除事件监听器。

  • 合理设计缓存机制
    对缓存数据设置失效机制,定期清理不再使用的数据。

  • 使用工具监控内存
    利用浏览器开发者工具或专门的内存检测工具(如 Chrome 的 Memory 面板、Node.js 的内存快照工具)来监控内存使用情况,及时发现和解决内存泄漏问题。