近期频繁问到的基础面试做个总结 —— js篇

51 阅读21分钟

1.ES6有哪些新特性?

ES6(ECMAScript 2015)是JavaScript的一个重要更新,引入了许多新特性和语法改进,以下是其中一些主要的新特性:

  1. let 和 const 声明

    • let 允许声明块级作用域的变量,替代了 var 的函数作用域。
    • const 声明一个只读的常量,一旦声明,常量的值就不能被修改。
  2. 箭头函数

    • 箭头函数提供了更简洁的函数书写方式,并且绑定了词法作用域的 this 值。
  3. 模板字面量

    • 使用反引号 ` \ 可以创建多行字符串和插入表达式。
  4. 默认参数

    • 函数可以指定参数的默认值,当调用时未提供参数或传递 undefined 时会使用默认值。
  5. 解构赋值

    • 可以轻松地从数组或对象中提取数据并赋值给变量。
  6. 扩展运算符(Spread Operator)

    • ... 可以将一个数组展开为多个参数或将多个参数组合成一个数组。
  7. 类和模块

    • 引入了类(class)和模块(module)的概念,使得 JavaScript 更接近传统面向对象语言的写法。
  8. Promise

    • 异步编程的一种解决方案,使得回调地狱(Callback Hell)得以避免,更加清晰和易于管理。
  9. 生成器(Generators)

    • 可以控制函数的执行过程,通过 yield 暂停和恢复代码执行。
  10. 新的数据结构

    • 包括 MapSetWeakMapWeakSet 等,提供了更好的数据存储和操作方式。
  11. 模块化

    • 提供了原生的模块化支持,可以让 JavaScript 文件更好地组织和复用。
  12. 其他语法改进

    • 包括对象字面量的增强语法、新的数值方法、新的字符串方法等。

2.什么是原型?什么是原型链?

原型(Prototype)和原型链(Prototype Chain)是 JavaScript 中重要的概念,它们与对象和继承密切相关。

原型(Prototype)

在 JavaScript 中,每个对象都有一个原型对象(prototype),它是一个对象或者 null。对象可以从其原型继承属性和方法。原型对象也是对象,因此它也有自己的原型,这形成了一个链,称为原型链。

每个对象(除了根对象 Object.prototype)都有一个原型,可以通过 __proto__ 属性来访问。在 ES6 中,可以使用 Object.getPrototypeOf() 方法来获取对象的原型。

原型链(Prototype Chain)

原型链是由对象的原型对象形成的链式结构。当访问一个对象的属性或方法时,如果对象本身没有这个属性或方法,JavaScript 引擎就会沿着原型链向上查找,直到找到相应的属性或方法或者到达原型链的顶端(即 Object.prototype)。

例如,假设有对象 obj,当你访问 obj.someProperty 时,JavaScript 首先查找 obj 自身是否有 someProperty,如果没有,它会查找 obj.__proto__,也就是 obj 的原型对象,看看那里是否有 someProperty,如此继续直到找到或者到达原型链的末尾。

原型链的形成使得 JavaScript 实现了对象的继承机制。通过原型链,可以实现对象之间的属性和方法的共享,从而节省内存并使得对象的属性和方法的继承更加灵活和动态。

总结来说,原型是每个对象都有的一个属性,它指向该对象的原型对象;而原型链则是由对象的原型对象形成的查找链,用于属性和方法的继承和查找。

3.js的继承方式有哪些?

在 JavaScript 中,实现对象之间继承的方式有多种,主要可以归纳为以下几种:

  1. 原型链继承(Prototype Chain Inheritance)

    • 使用原型链来实现继承,子类的原型指向父类的实例。这种方式简单,但有一个重要的问题是所有子类实例共享同一个父类实例,可能会导致实例间的属性共享问题。
    function Parent() {
        this.name = 'Parent';
    }
    
    function Child() {
        this.name = 'Child';
    }
    
    Child.prototype = new Parent();
    
  2. 构造函数继承(Constructor Inheritance)

    • 在子类构造函数内调用父类构造函数,使用 callapply 方法来实现属性的继承。这种方式避免了属性共享的问题,但无法继承父类原型链上的方法。
    function Parent() {
        this.name = 'Parent';
    }
    
    function Child() {
        Parent.call(this);
        this.type = 'Child';
    }
    
  3. 组合继承(Combination Inheritance)

    • 结合了原型链继承和构造函数继承的方式,通过调用父类构造函数来实现属性的继承,并通过设置子类的原型为一个父类的实例来继承父类原型链上的方法。
    function Parent() {
        this.name = 'Parent';
    }
    
    function Child() {
        Parent.call(this);
        this.type = 'Child';
    }
    
    Child.prototype = new Parent();
    Child.prototype.constructor = Child;
    
  4. 原型式继承(Prototype Pattern)

    • 利用一个空的构造函数来实现对象的浅复制,通过传入一个原型对象来创建一个继承自该对象的新对象。
    function createObject(obj) {
        function F() {}
        F.prototype = obj;
        return new F();
    }
    
  5. 寄生式继承(Parasitic Inheritance)

    • 和原型式继承类似,但是在这种模式下,创建的函数不是用来作为构造函数使用的,只是在内部以某种方式增强对象。
    function createAnother(original) {
        var clone = Object.create(original);
        clone.sayHi = function() {
            console.log('hi');
        };
        return clone;
    }
    
  6. 寄生组合式继承(Parasitic Combination Inheritance)

    • 结合了组合继承和寄生式继承的优点,避免了组合继承的缺点。通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。
    function inheritPrototype(Child, Parent) {
        var prototype = Object.create(Parent.prototype);
        prototype.constructor = Child;
        Child.prototype = prototype;
    }
    

这些继承方式各有优缺点,选择适合场景的继承方式可以有效地提高代码的复用性和可维护性。

4.ES6中是如何实现继承的?

在 ES6 中,JavaScript 实现继承的主要方式是通过类和 extends 关键字。ES6 引入了类(class)的概念,它提供了一种更接近传统面向对象语言(如 Java 和 C++)的写法,其中包含了构造函数和方法的定义,以及基于原型的继承机制。

ES6 中的继承实现

  1. 类定义和构造函数

    • 使用 class 关键字定义类,通过 constructor 方法定义构造函数。构造函数用于初始化对象实例的状态。
    class Parent {
        constructor(name) {
            this.name = name;
        }
    }
    
  2. 继承语法

    • 使用 extends 关键字实现类的继承。子类通过 extends 关键字继承父类的属性和方法,并可以在 constructor 方法中通过 super() 调用父类的构造函数进行初始化。
    class Child extends Parent {
        constructor(name, age) {
            super(name); // 调用父类的构造函数进行初始化
            this.age = age;
        }
    }
    
  3. 方法继承

    • 子类继承父类的方法和属性。在子类中可以直接调用父类的方法,也可以重写父类的方法。
    class Parent {
        constructor(name) {
            this.name = name;
        }
        sayName() {
            console.log(`My name is ${this.name}`);
        }
    }
    
    class Child extends Parent {
        constructor(name, age) {
            super(name);
            this.age = age;
        }
        sayAge() {
            console.log(`I am ${this.age} years old`);
        }
    }
    
  4. super 关键字

    • super 关键字用于调用父类的构造函数和方法。在子类的构造函数中必须首先调用 super(),否则会导致错误。
  5. 静态方法

    • ES6 还引入了静态方法(static methods)的概念,可以通过类名直接调用,静态方法不会被子类继承。
    class Parent {
        static hello() {
            console.log('Hello from Parent');
        }
    }
    
    class Child extends Parent {
        static hi() {
            console.log('Hi from Child');
            super.hello(); // 调用父类的静态方法
        }
    }
    

通过 ES6 的类和继承机制,JavaScript 实现了更加清晰和易于理解的面向对象编程模式,同时保留了原型链的灵活性和动态性。这种方式避免了传统的原型链继承模式中的一些不便,使得代码结构更加模块化和可维护。

5.谈一谈你对事件轮询的理解

事件轮询(Event Loop)是 JavaScript 异步编程中非常重要的概念,特别是在浏览器和 Node.js 等环境中。理解事件轮询需要从 JavaScript 单线程的特性说起。

JavaScript 单线程

JavaScript 是一门单线程语言,意味着它在同一时间只能执行一个任务。这个任务可以是同步的,也可以是异步的。同步任务会按照代码的顺序依次执行,而异步任务则会被放入任务队列(Task Queue)中等待执行。

任务队列

任务队列分为两种主要类型:

  • 宏任务(Macrotask):包括整体代码块、setTimeout、setInterval 等。
  • 微任务(Microtask):Promise、process.nextTick 等。

事件轮询的过程

事件轮询是 JavaScript 引擎(例如浏览器或 Node.js)在执行代码时,处理异步任务的一种机制。它主要涉及以下几个部分:

  1. 执行栈(Execution Stack):JavaScript 引擎执行 JavaScript 代码的地方,按照执行顺序依次执行。

  2. 任务队列(Task Queue):存放着待执行的任务,分为宏任务队列和微任务队列。

  3. 事件轮询过程

    • 当执行栈为空时(即所有同步任务执行完成),JavaScript 引擎会去检查微任务队列。
    • 如果微任务队列中有任务,会依次执行完所有微任务。
    • 当微任务队列为空时,会从宏任务队列中取出一个任务执行。
    • 执行该宏任务时,可能会产生新的微任务,这些微任务会被添加到微任务队列的末尾。
    • 微任务执行完毕后,再次从宏任务队列中取出一个任务执行,如此循环。

示例理解

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');

在这个例子中:

  • 执行顺序是先同步代码 console.log('script start')console.log('script end')
  • 然后微任务队列中会有两个任务,分别是两个 Promise 的 then 方法;
  • 最后,宏任务队列中有一个 setTimeout,会等待执行。

总结

事件轮询机制保证了 JavaScript 单线程环境下异步任务的正确执行顺序,使得 JavaScript 具备了处理高效异步编程的能力。理解事件轮询有助于开发者更好地利用 JavaScript 的异步特性,编写出高效和稳定的代码。

6.什么是闭包?闭包的使用场景是什么?

闭包(Closure)是指函数和其相关的引用环境的组合。具体来说,闭包是指那些能够访问自由变量的函数。自由变量是指在函数内部,但不是在函数参数或局部变量中声明的变量。闭包可以让函数访问外部作用域中的变量,即使外部函数已经执行结束。

闭包的基本概念

在 JavaScript 中,闭包形成的原因是因为函数可以在其定义的作用域以外执行,并且仍然保持对定义时的词法作用域的访问能力。这意味着闭包可以:

  • 访问函数外部声明的变量
  • 保留对这些变量的持续引用,即使外部函数已经返回

使用闭包的场景

闭包在 JavaScript 中具有广泛的应用,主要用途包括但不限于以下几个方面:

  1. 封装私有变量和方法

    function counter() {
        let count = 0;
        return {
            increment: function() {
                count++;
            },
            getCount: function() {
                return count;
            }
        };
    }
    let counter1 = counter();
    counter1.increment();
    console.log(counter1.getCount()); // 输出 1
    

    在这个例子中,count 是一个私有变量,只能通过返回的对象中的方法来访问和修改。这种方式可以有效地隐藏变量并防止外部直接访问。

  2. 延续局部变量的生命周期

    function makeAdder(x) {
        return function(y) {
            return x + y;
        };
    }
    let add5 = makeAdder(5);
    console.log(add5(2)); // 输出 7
    

    makeAdder 函数中,返回的匿名函数形成了闭包,它可以访问 makeAdder 的参数 x,并将其保留在内存中,使得 add5 可以在稍后调用时记住 x 的值。

  3. 实现模块化

    let module = (function() {
        let privateVar = 'This is private';
        return {
            publicVar: 'This is public',
            getPrivateVar: function() {
                return privateVar;
            }
        };
    })();
    console.log(module.publicVar); // 输出 'This is public'
    console.log(module.getPrivateVar()); // 输出 'This is private'
    

    闭包允许将代码组织成模块,其中私有变量和函数被封装在模块内部,只有暴露出去的方法才可以在外部使用。

  4. 函数柯里化

    function multiply(a) {
        return function(b) {
            return a * b;
        };
    }
    let multiplyBy2 = multiply(2);
    console.log(multiplyBy2(3)); // 输出 6
    

    通过闭包,可以轻松实现函数的柯里化,即将一个接受多个参数的函数转换为一系列接受一个参数的函数。

总结

闭包是 JavaScript 强大的特性之一,它使得函数可以捕获并操作其定义时的外部作用域。合理利用闭包可以实现更优雅和灵活的代码结构,同时也需要注意闭包可能带来的内存泄漏问题,特别是长期持有大量资源的情况下。

7.如何从代码层面上解决闭包带来的内存泄露?浏览器的垃圾回收机制是什么样的?

解决闭包(Closure)可能带来的内存泄露问题需要从代码编写和资源管理两个方面着手。闭包在 JavaScript 中可以捕获外部作用域中的变量,使得函数可以访问并持有这些变量,即使外部函数执行完毕也能保持对其的引用,这可能导致内存泄漏问题,尤其是在闭包中捕获了大量资源或形成了循环引用时。

解决方案:

  1. 精简闭包捕获的变量

    • 确保闭包只捕获必要的变量,而不是整个对象或不必要的变量。尽量减少闭包捕获的资源量,避免捕获过多的外部变量。
  2. 手动释放不再需要的引用

    • 当闭包不再需要时,手动解除对外部变量的引用,尤其是全局变量或长期持有的资源。例如,将闭包内部函数设置为 null,或者手动解除事件监听器等。
    let expensiveObject = /* some resource */;
    
    function foo() {
        let localVar = /* some local variable */;
        return function bar() {
            // 使用 expensiveObject 和 localVar
        };
    }
    
    let closure = foo();
    
    // 当不再需要闭包时,手动解除引用
    closure = null;
    
  3. 避免循环引用

    • 注意避免在闭包中形成循环引用,特别是在处理事件监听器或使用 thisarguments 时。循环引用会导致 JavaScript 引擎无法回收相关的内存空间。
  4. 使用事件委托

    • 在处理事件时,尽量使用事件委托而不是为每个元素绑定闭包事件处理函数。这样可以减少闭包的数量和复杂性,降低内存泄漏的风险。

浏览器内部的处理:

浏览器的垃圾回收机制是一种自动管理内存的过程,旨在检测和清理不再被程序使用的内存空间,从而避免内存泄漏并优化内存使用效率。不同的浏览器和 JavaScript 引擎可能会有不同的实现细节,但一般而言,垃圾回收机制包括以下几个关键组成部分:

  1. 标记清除(Mark and Sweep)

    • 这是最常见的垃圾回收算法。它的基本思想是在运行时跟踪内存中所有对象的引用情况。当某个对象不再被引用(即没有任何对象引用到该对象时),这个对象就被标记为可回收的垃圾。垃圾回收器会定期执行“标记”步骤来识别不再使用的对象,然后执行“清除”步骤来释放这些对象占用的内存空间。
  2. 引用计数(Reference Counting)

    • 这是另一种垃圾回收的策略,不过在现代浏览器中较少使用,因为它不能处理循环引用的情况。引用计数跟踪每个对象被引用的次数。当引用计数为零时,表示该对象不再被使用,可以被回收。然而,循环引用会导致对象的引用计数永远不为零,从而造成内存泄漏。
  3. 清除阶段(Sweeping)

    • 在标记阶段后,垃圾回收器会执行清除阶段,即释放被标记为垃圾的对象占用的内存空间。这一过程是在主线程之外进行的,以避免影响程序的性能。
  4. 内存压缩和优化

    • 有些垃圾回收器会对内存空间进行整理和优化,以减少内存碎片化的影响,进而提高内存的使用效率。
  5. 增量回收

    • 为了减少垃圾回收造成的长时间停顿(即暂停所有 JavaScript 执行),一些现代浏览器使用增量垃圾回收技术。这种技术允许垃圾回收器在多个小步骤中进行,而不是一次性地暂停全部程序执行。

垃圾回收的具体实现因浏览器和引擎而异。例如,V8 引擎(Chrome 和 Node.js 使用的引擎)采用了高效的标记清除算法,并结合了增量标记和延迟清理策略来提升性能。Firefox 使用的 SpiderMonkey 引擎也有其独特的垃圾回收优化。

总体而言,浏览器的垃圾回收机制通过自动化管理内存的释放和重用,帮助开发者避免手动处理内存泄漏和管理复杂的内存分配问题,从而提高了 JavaScript 程序的性能和可靠性。

8.谈谈防抖和节流,简单手写一个

防抖(Debounce)和节流(Throttle)

防抖(Debounce)和节流(Throttle)用于控制高频事件触发次数的两种优化技术。它们的目的都是减少不必要的函数调用,避免性能问题,但它们在具体行为上有明显区别。

  • 防抖(Debounce):防抖会在事件停止触发后等待一段时间才执行函数。如果在这段时间内事件再次触发,等待时间会重新计算。防抖适用于一些需要在用户停止输入、调整等操作后才执行的场景,例如搜索框输入提示、调整窗口大小等。

  • 节流(Throttle):节流会在一定时间间隔内只允许函数执行一次,即使在这段时间内事件多次触发。节流适用于需要限制频繁触发的场景,例如滚动事件、鼠标移动等。

简单手写防抖和节流函数

防抖函数
function debounce(func, wait) {
    let timeout;
    return function(...args) {
        clearTimeout(timeout);
        timeout = setTimeout(() => {
            func.apply(this, args);
        }, wait);
    };
}

// 使用示例
const handleResize = debounce(() => {
    console.log('Window resized');
}, 500);

window.addEventListener('resize', handleResize);

解释:在防抖函数中,每次事件触发时都会清除之前的定时器,并重新设置一个新的定时器,只有当指定的等待时间内没有再次触发事件,才会执行函数。

节流函数
function throttle(func, wait) {
    let lastTime = 0;
    return function(...args) {
        const now = Date.now();
        if (now - lastTime >= wait) {
            func.apply(this, args);
            lastTime = now;
        }
    };
}

// 使用示例
const handleScroll = throttle(() => {
    console.log('Window scrolled');
}, 1000);

window.addEventListener('scroll', handleScroll);

解释:在节流函数中,每次事件触发时,函数会检查距离上次执行是否已过了指定的时间间隔。如果是,则执行函数并更新上次执行的时间,否则忽略本次触发。

区别
  • 防抖:适用于需要在事件停止后才执行的情况。通过重置计时器确保在指定时间内的事件只执行一次。
  • 节流:适用于需要限制频繁事件的执行频率的情况。它确保在指定的时间间隔内只执行一次函数,而不管事件是否频繁触发。

通过这两种技术,开发者可以有效控制事件触发频率,从而提升应用性能和用户体验。

9.实现一个简单的promise,并且说一下promise.race和promise.allSettled区别

实现一个简单的 Promise

要实现一个简单的 Promise,我们需要理解它的基本结构和方法。以下是一个简化版的 Promise 实现:

class SimplePromise {
    constructor(executor) {
        this.status = 'pending';
        this.value = undefined;
        this.reason = undefined;
        this.onFulfilledCallbacks = [];
        this.onRejectedCallbacks = [];

        const resolve = (value) => {
            if (this.status === 'pending') {
                this.status = 'fulfilled';
                this.value = value;
                this.onFulfilledCallbacks.forEach(callback => callback(value));
            }
        };

        const reject = (reason) => {
            if (this.status === 'pending') {
                this.status = 'rejected';
                this.reason = reason;
                this.onRejectedCallbacks.forEach(callback => callback(reason));
            }
        };

        try {
            executor(resolve, reject);
        } catch (error) {
            reject(error);
        }
    }

    then(onFulfilled, onRejected) {
        return new SimplePromise((resolve, reject) => {
            if (this.status === 'fulfilled') {
                try {
                    const result = onFulfilled(this.value);
                    resolve(result);
                } catch (error) {
                    reject(error);
                }
            } else if (this.status === 'rejected') {
                try {
                    const result = onRejected(this.reason);
                    reject(result);
                } catch (error) {
                    reject(error);
                }
            } else if (this.status === 'pending') {
                this.onFulfilledCallbacks.push(() => {
                    try {
                        const result = onFulfilled(this.value);
                        resolve(result);
                    } catch (error) {
                        reject(error);
                    }
                });

                this.onRejectedCallbacks.push(() => {
                    try {
                        const result = onRejected(this.reason);
                        reject(result);
                    } catch (error) {
                        reject(error);
                    }
                });
            }
        });
    }

    catch(onRejected) {
        return this.then(null, onRejected);
    }
}

// 示例
let promise = new SimplePromise((resolve, reject) => {
    setTimeout(() => {
        resolve("Success!");
    }, 1000);
});

promise.then(result => {
    console.log(result); // "Success!"
}).catch(error => {
    console.log(error);
});

Promise.racePromise.allSettled 的区别

Promise.race
  • 定义Promise.race 接受一个由多个 Promise 对象组成的数组(或可迭代对象),并返回一个新的 Promise。这个新的 Promise 会在数组中的第一个 Promise 完成(无论是 resolve 还是 reject)时,采用这个第一个完成的 Promise 的状态和值。

  • 用法示例

    let p1 = new Promise((resolve) => setTimeout(resolve, 500, 'P1 Resolved'));
    let p2 = new Promise((resolve, reject) => setTimeout(reject, 100, 'P2 Rejected'));
    
    Promise.race([p1, p2])
        .then(result => {
            console.log(result); // "P2 Rejected"
        })
        .catch(error => {
            console.error(error); // "P2 Rejected"
        });
    

    在这个例子中,Promise.race 返回的 Promise 会采用 p2 的结果,因为 p2 最先完成。

Promise.allSettled
  • 定义Promise.allSettled 也接受一个由多个 Promise 对象组成的数组(或可迭代对象),并返回一个新的 Promise。这个新的 Promise 在所有提供的 Promise 都已完成(不论是 resolve 还是 reject)时才会完成,并且结果是一个数组,数组的每个元素是对应的 Promise 的状态和值。

  • 用法示例

    let p1 = new Promise((resolve) => setTimeout(resolve, 500, 'P1 Resolved'));
    let p2 = new Promise((resolve, reject) => setTimeout(reject, 100, 'P2 Rejected'));
    
    Promise.allSettled([p1, p2])
        .then(results => {
            console.log(results);
            // [
            //   { status: 'fulfilled', value: 'P1 Resolved' },
            //   { status: 'rejected', reason: 'P2 Rejected' }
            // ]
        });
    

    在这个例子中,Promise.allSettled 返回的 Promise 会在 p1p2 都完成后,返回它们的状态和结果。

区别总结
  • Promise.race 返回第一个完成的 Promise 的结果(不论成功还是失败)。
  • Promise.allSettled 返回所有 Promise 完成后的结果,无论它们是成功还是失败。每个结果对象都会包含 statusvalue/reason 字段。

10.深浅拷贝区别是什么?常用的深浅拷贝有什么区别?深拷贝的方法中有什么区别?

深拷贝与浅拷贝的区别

浅拷贝(Shallow Copy)

浅拷贝是对对象的第一层属性进行拷贝,拷贝的只是属性的引用。对于基本数据类型(如字符串、数字、布尔值),浅拷贝会复制值本身;而对于引用类型(如数组、对象),浅拷贝仅复制引用,即新对象的属性与原对象的属性指向相同的内存地址。如果原对象中的引用类型的数据发生改变,浅拷贝后的对象也会受到影响。

浅拷贝的实现方法

  • Object.assign()
  • 展开运算符(...

示例

let obj1 = { a: 1, b: { c: 2 } };
let obj2 = Object.assign({}, obj1);
obj2.b.c = 3;

console.log(obj1.b.c); // 输出 3,因为 obj2.b 引用的是同一个对象
let obj3 = { a: 1, b: { c: 2 } };
let obj4 = { ...obj3 };
obj4.b.c = 3;

console.log(obj3.b.c); // 输出 3,因为 obj4.b 引用的是同一个对象
深拷贝(Deep Copy)

深拷贝是对对象进行递归拷贝,即不仅复制对象的所有属性,还会递归地拷贝每一个属性所指向的对象。这样,新对象与原对象完全独立,互不影响。深拷贝适用于复杂嵌套对象的拷贝。

深拷贝的实现方法

  • 手动递归拷贝
  • JSON.parse(JSON.stringify())
  • 使用第三方库如 lodash_.cloneDeep 方法

示例

  1. 手动递归拷贝

    function deepClone(obj) {
        if (obj === null || typeof obj !== 'object') {
            return obj;
        }
    
        if (Array.isArray(obj)) {
            return obj.map(item => deepClone(item));
        }
    
        let newObj = {};
        for (let key in obj) {
            if (obj.hasOwnProperty(key)) {
                newObj[key] = deepClone(obj[key]);
            }
        }
        return newObj;
    }
    
    let obj1 = { a: 1, b: { c: 2 } };
    let obj2 = deepClone(obj1);
    obj2.b.c = 3;
    
    console.log(obj1.b.c); // 输出 2,因为 obj1 和 obj2 是独立的对象
    
  2. JSON.parse(JSON.stringify())

    let obj1 = { a: 1, b: { c: 2 } };
    let obj2 = JSON.parse(JSON.stringify(obj1));
    obj2.b.c = 3;
    
    console.log(obj1.b.c); // 输出 2,因为 obj1 和 obj2 是独立的对象
    

    注意:这种方法虽然简单,但有一些局限性,例如无法拷贝 undefinedFunctionSymbol 等特殊数据类型,同时对于循环引用的对象会抛出错误。

  3. lodash_.cloneDeep 方法

    const _ = require('lodash');
    
    let obj1 = { a: 1, b: { c: 2 } };
    let obj2 = _.cloneDeep(obj1);
    obj2.b.c = 3;
    
    console.log(obj1.b.c); // 输出 2,因为 obj1 和 obj2 是独立的对象
    

深拷贝方法的区别

  1. JSON.parse(JSON.stringify())

    • 优点:实现简单,一行代码即可完成大部分场景的深拷贝。
    • 缺点:无法处理 undefinedFunctionSymbol、循环引用等情况,不能拷贝 Date 对象等特殊类型。
  2. 手动递归拷贝

    • 优点:可以完全控制拷贝的过程,处理各种特殊情况,例如循环引用、特殊类型等。
    • 缺点:实现较复杂,需要处理大量细节,且容易出错。
  3. _.cloneDeep

    • 优点:功能强大,能够处理各种复杂情况,且实现简洁。
    • 缺点:需要依赖 lodash 库,可能在性能上稍逊于手写的递归拷贝。

总结

  • 浅拷贝:适合简单对象的拷贝,当对象内部没有嵌套引用类型时使用。
  • 深拷贝:适合复杂嵌套对象的拷贝,尤其是需要完全独立的副本时使用。
  • 选择方法:对于简单场景,可以使用 JSON.parse(JSON.stringify())Object.assign 等方法;对于复杂场景,推荐使用 _.cloneDeep 或手写递归深拷贝函数。