2023前端面试八股文-js(个人汇总自用,更新extend函数)

252 阅读24分钟
JavaScript的数据类型以及存储方式

其中有7种基本数据类型: ES5的5种:NullundefinedBooleanNumberString, ES6新增:Symbol 表示独一无二的值 symbol的应用场景: let xxx = Symbol('标识字符串')

  1. 作为私有属性,防止属性名命名冲突
  2. 替代常量, 消除魔法字符串

我们经常定义一组常量来代表一种业务逻辑下的几个不同类型,我们通常希望这几个常量之间是唯一的关系,为了保证这一点,我们需要为常量赋一个唯一的值(比如这里的'AUDIO'、'VIDEO'、 'IMAGE'),常量少的时候还算好,但是常量一多,你可能还得花点脑子好好为他们取个好点的名字。现在有了Symbol,我们大可不必这么麻烦了:

const TYPE_AUDIO = Symbol();
const TYPE_VIDEO = Symbol();
const TYPE_IMAGE = Symbol();
  1. 定义类的私有属性和方法
  2. 作为开源包的包名

ES10新增:BigInt 表示任意大的整数

一种引用数据类型: Object(本质上是由一组无序的键值对组成) 包含function,Array,Date等。JavaScript不支持创建任何自定义类型的数据,也就是说JavaScript中所有值的类型都是上面8中之一。

存储方式

  • 基本数据类型:直接存储在内存中,占据空间小,大小固定,属于被频繁使用的数据。
  • 引用数据类型:同时存储在内存与内存中,占据空间大,大小不固定。引用数据类型将指针存在中,将值存在中。当我们把对象值赋值给另外一个变量时,复制的是对象的指针,指向同一块内存地址。
Object.keys() 和Object.getOwnPropertyNames()的区别
  1. 使用Object.keys()获取对象的所有可枚举属性
  2. 使用Object.getOwnPropertyNames() 获取所有属性,无论是否是可枚举 object.keys()无法拿到symbol定义的属性名, 但是Object.getOwnPropertyNames()可以。
null 与 undefined的异同

相同点:

  • Undefined 和 Null 都是基本数据类型,这两个基本数据类型分别都只有一个值,就是 undefined 和 null

不同点:

  • undefined 代表的含义是未定义, null 代表的含义是空对象。
  • typeof null 返回'object',typeof undefined 返回'undefined'
null == undefined  // true
null === undefined // false

其实 null 不是对象,虽然 typeof null 会输出 object,但是这只是 JS 存在的一个悠久 Bug。在 JS 的最初版本中使用的是 32 位系统,为了性能考虑使用低位存储变量的类型信息,000 开头代表是对象,然而 null 表示为全零,所以将它错误的判断为 object 。虽然现在的内部类型判断代码已经改变了,但是对于这个 Bug 却是一直流传下来。

JavaScript中判断数据类型的几种方法
  • typeof一般用来判断基本数据类型,除了判断null会输出"object",其它都是正确的
  • typeof判断引用数据类型时,除了判断函数会输出"function",其它都是输出"object"

对于引用数据类型的判断,使用typeof并不准确,所以可以使用instanceof来判断引用数据类型

instanceof

Instanceof 可以准确的判断引用数据类型,它的原理是检测构造函数的prototype属性是否在某个实例对象的原型链上

object instanceof constructor
console.log(6 instanceof Number);                    // false
console.log(true instanceof Boolean);                // false 
console.log('nanjiu' instanceof String);                // false  
console.log([] instanceof Array);                    // true
console.log(function(){} instanceof Function);       // true
console.log({} instanceof Object);                   // true    
  • null和undefined是无效的对象,所以他们不会有constructor属性

  • 函数的construct是不稳定的,主要是因为开发者可以重写prototype,原有的construction引用会丢失,constructor会默认为Object

Object.prototype.toString.call() toString() 是 Object 的原型方法,调用该方法,默认返回当前对象的 [[Class]] 。这是一个内部属性,其格式为 [object Xxx] ,其中 Xxx 就是对象的类型。 对于 Object 对象,直接调用 toString() 就能返回 [object Object] 。而对于其他对象,则需要通过 call / apply 来调用才能返回正确的类型信息。

Object.prototype.toString.call('') ;   // [object String]
Object.prototype.toString.call(1) ;    // [object Number]
Object.prototype.toString.call(true) ; // [object Boolean]
Object.prototype.toString.call(Symbol()); //[object Symbol]
Object.prototype.toString.call(undefined) ; // [object Undefined]
Object.prototype.toString.call(null) ; // [object Null]
Object.prototype.toString.call(new Function()) ; // [object Function]
Object.prototype.toString.call(new Date()) ; // [object Date]
Object.prototype.toString.call([]) ; // [object Array]
Object.prototype.toString.call(new RegExp()) ; // [object RegExp]
Object.prototype.toString.call(new Error()) ; // [object Error]
Object.prototype.toString.call(document) ; // [object HTMLDocument]
Object.prototype.toString.call(window) ; //[object global] window 是全局对象 global 的引用
js数据类型转换

在JavaScript中类型转换有三种情况:

  • 转换为数字(调用Number(),parseInt(),parseFloat()方法)
  • 转换为字符串(调用.toString()或String()方法)
  • 转换为布尔值(调用Boolean()方法)

Number():可以把任意值转换成数字,如果要转换的字符串中有不是数字的值,则会返回NaN

Number('1')   // 1
Number(true)  // 1
Number('123s') // NaN
Number({})  //NaN

隐式转换:当+两边有一个是字符串,另一个是其它类型时,会先把其它类型转换为字符串再进行字符串拼接,返回字符串, 可以用+来转换, 也可以用*来转换

什么是作用域

Javascript中的作用域说的是变量的可访问性和可见性。也就是说整个程序中哪些部分可以访问这个变量,或者说这个变量都在哪些地方可见。 Javascript中有三种作用域: 全局作用域;任何不在函数中或是大括号中声明的变量,都是在全局作用域下,全局作用域下声明的变量可以在程序的任意位置访问。 函数作用域;函数作用域也叫局部作用域,如果一个变量是在函数内部声明的它就在一个函数作用域下面。这些变量只能在函数内部访问,不能在函数以外去访问。 块级作用域;ES6引入了letconst关键字,和var关键字不同,在大括号中使用letconst声明的变量存在于块级作用域中。在大括号之外不能访问这些变量。看例子:

{
  // 块级作用域中的变量
  let greeting = 'Hello World!';
  var lang = 'English';
  console.log(greeting); // Prints 'Hello World!'
}
// 变量 'English'
console.log(lang);
// 报错:Uncaught ReferenceError: greeting is not defined
console.log(greeting);

let,const,var的区别
  • 变量提升:let,const定义的变量不会出现变量提升,而var会
  • 块级作用域:let,const 是块作用域,即其在整个大括号 {} 之内可见,var:只有全局作用域和函数作用域概念,没有块级作用域的概念。
  • 重复声明:同一作用域下let,const声明的变量不允许重复声明,而var可以
  • 暂时性死区:let,const声明的变量不能在声明之前使用,而var可以
  • const 声明的是一个只读的常量,不允许修改

通俗来说,变量提升是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的行为。变量被提升后,会给变量设置默认值为 undefined

JavaScript作用域与作用域链

简单来说,作用域是指程序中定义变量的区域,它决定了当前执行代码对变量的访问权限

作用域链

当可执行代码内部访问变量时,会先查找当前作用域下有无该变量,有则立即返回,没有的话则会去父级作用域中查找...一直找到全局作用域。我们把这种作用域的嵌套机制称为作用域链

如何正确的判断this指向?

this的绑定规则有四种:默认绑定,隐式绑定,显式绑定,new绑定

  • 当函数独立调用时,如果在严格模式下,则绑定到 undefined,否则绑定到全局对象。
  • 函数是否在 new 中调用(new绑定),如果是,那么 this 绑定的是new中新创建的对象。
  • 函数是否通过 call,apply 调用,或者使用了 bind (即硬绑定),如果是,那么this绑定的就是指定的对象。
  • 函数是否在某个上下文对象中调用(隐式绑定),如果是的话,this 绑定的是那个上下文对象。一般是 obj.foo()
  • 如果把 null 或者 undefined 作为 this 的绑定对象传入 call、apply 或者 bind, 这些值在调用时会被忽略,实际应用的是默认绑定规则。
  • 箭头函数没有自己的 this, 它的this继承于上一层代码块的this。
for, for...of,for..in,forEach,map的区别?
  1. “for” 循环:它是最常使用的循环形式, 是通过生成数组的索引下标循环遍历数组的每一个数据元素
  2. for循环可以通过break关键词来终止循环的执行
  3. for可以使用return返回到外层函数,forEach不行
  4. for循环可以通过控制循环变量的数值控制对于循环的执行
  5. 如果for循环 变量是用var定义的, 那么可以在循环外可以调用循环变量,forEach循环在循环外不能调用循环变量

1.2 for...of(不能遍历对象)

在可迭代对象(具有 iterator 接口)(Array,Map,Set,String,arguments)上创建一个迭代循环,调用自定义迭代钩子,并为每个不同属性的值执行语句,不能遍历对象

1.3 for...in

for...in循环:遍历对象自身的和继承的可枚举的属性, 不能直接获取属性值。可以中断循环。

forEach

forEach: 只能遍历数组,不能中断,没有返回值(或认为返回值是undefined)。

  • forEach 遍历列表值,不能使用 break 语句或continue跳出循环,只能通过return或 throw(抛异常)的形式跳出循环。
  • for in 遍历对象键值(key),或者数组下标,不推荐循环一个数组
  • for of 遍历列表值,允许遍历 Arrays(数组), Strings(字符串), Maps(映射), Sets(集合)等可迭代的数据结构等.在 ES6 中引入的 for of 循环,以替代 for in 和 forEach() ,并支持新的迭代协议。
  • for in循环出的是key,for of循环出的是value;
  • for of是ES6新引入的特性。修复了ES5的for in的不足;
  • for of不能循环普通的对象,需要通过和Object.keys()搭配使用。
  • map: 只能遍历数组,不能中断,返回值是修改后的数组。

遍历方法对原始值的影响

  1. 所有遍历方法都不会影响原数组

  2. map和filter会返回一个新的数组

  3. forEach没有返回值

  4. some和every返回bool值且能提前中断循环

说说对原型的理解

在 JavaScript 中,每当定义一个对象(函数也是对象)时候,对象中都会包含一些预定义的属性。JS的每个函数在创建的时候,都会生成一个属性prototype,这个属性指向一个对象,这个对象就是此函数的原型对象。该原型对象中有个属性为constructor,指向该函数。这样原型对象它的函数之间就产生了联系。

原型对象里面存放一些公共的属性和方法

image.png

说说你对原型链的理解?

JS的复杂类型都是对象类型(Object),而JS不是一门完全面向对象编程的语言,所以如何涉及继承机制,就是一个问题。JS的设计者使用了构造函数来实现继承机制。

每个通过构造函数创建出来的实例对象,其本身有个属性__proto__,这个属性会指向该实例对象构造函数原型对象,这么说好像有点绕,我们看下图

image.png

__proto__ 并不是语言本身的特性,这是各大厂商具体实现时添加的私有属性,虽然目前很多现代浏览器的 JS 引擎中都提供了这个私有属性,但依旧不建议在生产中使用该属性,避免对环境产生依赖。生产环境中,我们可以使用 Object.getPrototypeOf 方法来获取实例对象的原型,然后再来为原型添加方法/属性。

当访问一个对象的某个属性时,会先在这个对象本身属性上查找,如果没有找到,则会通过它的__proto__隐式属性,找到它的构造函数原型对象,如果还没有找到就会再在其构造函数prototype__proto__中查找,这样一层一层向上查找就会形成一个链式结构,我们称为原型链

原型链的尽头是null

写一个extend函数, 实现父类到子类的继承。

const ext = (parent, children)=>{
   // 首先, 以父对象的原型对象创建新对象
    const instance = Object.create(parent.proptype);
    // 把新对象赋值给children的proptype
    children.proptype = instance;
    // 指定新的原型对象的contructor
    instance.constructor = children;
}

三种事件模型
  1. IE事件模型 IE事件只支持冒泡,所以事件流有两个阶段: 事件处理阶段:事件在达到目标元素时,触发监听事件。 事件冒泡阶段:事件从目标元素冒泡到 document,并且一次检查各个节点是否绑定了监听函数,如果有则执行。
// 绑定事件
el.attachEvent(eventType, handler)

// 移除事件
el.detachEvent(eventType, handler)

  1. DOM0事件模型(## 原始事件模型) 通过元素属性来绑定事件 <button onclick="click()">点我</button> 先获取页面元素,然后以赋值的形式来绑定事件
	const btn = document.getElementById('btn')
	btn.onclick = function(){
	    //do something
	}
	// 解除事件
	btn.onclick = null

123456

DOM0缺点

一个dom节点只能绑定一个事件,再次绑定将会覆盖之前的事件。

dom2新增冒泡和捕获的概念,并且支持一个元素节点绑定多个事件 DOM2 级事件模型共有三个阶段:

  • 事件捕获阶段:事件从 document 向下传播到目标元素,依次检查所有节点是否绑定了监听事件,如果有则执行。
  • 事件处理阶段:事件在达到目标元素时,触发监听事件。
  • 事件冒泡阶段:事件从目标元素冒泡到 document,并且一次检查各个节点是否绑定了监听函数,如果有则执行。

这应该是大家用的最熟悉的事件绑定方法了。
addEventListener有三个参数 事件名称、事件回调、捕获/冒泡

	btn.addEventListener('click',function(){
	    console.log('btn')
	},true)
	box.addEventListener('click',function(){
	    console.log('box')
	},false)

123456

设置为true,则事件在捕获阶段执行,为false则在冒泡阶段执行。

如何阻止事件冒泡

w3c的方法是e.stopPropagation(),IE则是使用e.cancelBubble = true。例如:

JS延迟加载的方式

JavaScript会阻塞DOM的解析,因此也就会阻塞DOM的加载。所以有时候我们希望延迟JS的加载来提高页面的加载速度。

  • 把JS放在页面的最底部

  • script标签的defer属性:脚本会立即下载但延迟到整个页面加载完毕再执行。该属性对于内联脚本无作用 (即没有 「src」 属性的脚本)。

  • Async是在外部JS加载完成后,浏览器空闲时,Load事件触发前执行,标记为async的脚本并不保证按照指定他们的先后顺序执行,该属性对于内联脚本无作用 (即没有 「src」 属性的脚本)。

  • 动态创建script标签,监听dom加载完毕再引入js文件

说说什么是模块化开发

模块化的开发方式可以提高代码复用率,方便进行代码的管理。通常一个文件就是一个模块,有自己的作用域,只向外暴露特定的变量和函数。

几种模块化方案
  • 第一种是 CommonJS 方案,它通过 require 来引入模块,通过 module.exports 定义模块的输出接口。
  • 第二种是 ES6 提出的方案,使用 importexport 的形式来导入导出模块
  • 第三种是 AMD 方案,这种方案采用异步加载的方式来加载模块,模块的加载不影响后面语句的执行,所有依赖这个模块的语句都定义在一个回调函数里,等到加载完成后再执行回调函数。require.js 实现了 AMD 规范。
  • 第四种方案是 CMD 方案,这种方案和 AMD 方案都是为了解决异步模块加载的问题,sea.js 实现了 CMD 规范。它和require.js的区别在于模块定义时对依赖的处理不同和对依赖模块的执行时机的处理不同。

CMD是另一种js模块化方案,它与AMD很类似,不同点在于:AMD 推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。此规范其实是在sea.js推广过程中产生的。

CommonJS 与 ES6 Module 的差异

CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。

  • CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。
  • ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6 的import有点像 Unix 系统的“符号连接”,原始值变了,import加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。

CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。

  • 运行时加载: CommonJS 模块就是对象;即在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法,这种加载称为“运行时加载”。
  • 编译时加载: ES6 模块不是对象,而是通过 export 命令显式指定输出的代码,import时采用静态命令的形式。即在import时可以指定加载某个输出值,而不是加载整个模块,这种加载称为“编译时加载”。
如何在JavaScript中比较两个对象

JavaScript 提供了 3 种方法来对值进行比较:

  • 严格相等运算符 ===
  • 宽松相等运算符 ==
  • Object.is() 函数
const hero1 = {   name'Batman' }; 
const hero2 = {   name'Batman' };
hero1 === hero1; // => true 
hero1 === hero2; // => false 
hero1 == hero1; // => true 
hero1 == hero2; // => false 
Object.is(hero1, hero1); // => true 
Object.is(hero1, hero2); // => false
js 运行环境

把运行环境分为浏览器环境和非浏览器环境

js运行机制

JavaScript是一门单线程的非阻塞脚本语言, js中的任务分为同步任务和异步任务,

  • 同步任务: 同步任务不需要等待可立即看到执行结果,比如console
  • 异步任务: 异步任务需要等待一定的时候才能看到结果,比如setTimeout、网络请求 异步任务,又可以细分为宏任务和微任务。下面列举目前学过的宏任务和微任务。 宏任务: script标签, 事件, 网络请求(Ajax), setTimeout()(定时器), readFile()读取文件 微任务: Promise.then()
事件循环
  • 同步任务直接放入到主线程执行,异步任务(点击事件,定时器,ajax等)挂在后台执行,等待I/O事件完成或行为事件被触发。
  • 系统后台执行异步任务,如果某个异步任务事件(或者行为事件被触发),则将该任务添加到任务队列,并且每个任务会对应一个回调函数进行处理。
  • 这里异步任务分为宏任务与微任务,宏任务进入到宏任务队列,微任务进入到微任务队列。
  • 执行任务队列中的任务具体是在执行栈中完成的,当主线程中的任务全部执行完毕后,去读取微任务队列,如果有微任务就会全部执行,然后再去读取宏任务队列
  • 上述过程会不断的重复进行,也就是我们常说的事件循环(Event-Loop)

写出以下代码的执行结果

const async1 = async () => {
    console.log("async1 start ");
    await async2()
    console.log("async1 end ");

}

const async2 = async () => {
    console.log("async2")
}

console.log("script start ");

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

async1();

new Promise((resolve) => {
    console.log("promise 1");
    resolve()
}).then(() => {
    console.log("promise then")
});

console.log("script end ");

结果如下:

script start
async1 start
async2
promise 1
script end
async1 end
promise then
setTimeout

执行过程:

  1. 首先, 定义了async1 和async2两个函数, 函数在调用的时候才会执行, 所以先不管,
  2. async2 函数后面第一个console.log是同步代码, 所以第一个输出的是 script start
  3. setTimeout中的代码进入宏任务栈
  4. 调用async1, async1中的第一个console.log直接输出, 所以第二个输出内容是 async1 start
  5. async1 中使用await 调用了async2(); 那么async1中await后面的内容会进入微任务栈, async2中的console.log会直接输出, 所以 第三个输出async2, 此时微任务栈中待执行任务为输出"async1 end "
  6. async1函数完成后, 执行new Promise中的内容, new Promise本身是同步代码, 所以第四个输出promise 1
  7. Promise.then中的任务进微任务的栈, 此时微任务栈待输出 async1 end 和 promise then
  8. new Promise 同步代码执行完后, 执行 script end, 所以第五个输出 script end
  9. 清空微任务栈, 输出 async1 end, 和promise end
  10. 清空宏任务栈, 输出 setTimeout
说说你对闭包的理解,以及它的原理和应用场景?

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

闭包原理

函数执行分成两个阶段(预编译阶段和执行阶段)。

  • 在预编译阶段,如果发现内部函数使用了外部函数的变量,则会在内存中创建一个“闭包”对象并保存对应变量值,如果已存在“闭包”,则只需要增加对应属性值即可。
  • 执行完后,函数执行上下文会被销毁,函数对“闭包”对象的引用也会被销毁,但其内部函数还持用该“闭包”的引用,所以内部函数可以继续使用“外部函数”中的变量

利用了函数作用域链的特性,一个函数内部定义的函数会将包含外部函数的活动对象添加到它的作用域链中,函数执行完毕,其执行作用域链销毁,但因内部函数的作用域链仍然在引用这个活动对象,所以其活动对象不会被销毁,直到内部函数被烧毁后才被销毁。

  1. 可以从内部函数访问外部函数的作用域中的变量,且访问到的变量长期驻扎在内存中,可供之后使用
  2. 避免变量污染全局
  3. 把变量存到独立的作用域,作为私有成员存在

闭包的缺点

  1. 对内存消耗有负面影响。因内部函数保存了对外部变量的引用,导致无法被垃圾回收,增大内存使用量,所以使用不当会导致内存泄漏

  2. 对处理速度具有负面影响。闭包的层级决定了引用的外部变量在查找时经过的作用域链长度

  3. 可能获取到意外的值(captured value)

Object.is()与比较操作符=====的区别?
  • ==会先进行类型转换再比较
  • ===比较时不会进行类型转换,类型不同则直接返回false
  • Object.is()===基础上特别处理了NaN,-0,+0,保证-0与+0不相等,但NaN与NaN相等
call与apply、bind的区别?

实际上call与apply的功能是相同的,只是两者的传参方式不一样,而bind传参方式与call相同,但它不会立即执行,而是返回这个改变了this指向的函数。 apply可以传数组作为参数, call从第二个参数开始可以接收任意个参数

说说JavaScript数组常用方法
向数组添加元素的方法:
  1. push:向数组的末尾追加 返回值是添加数据后数组的新长度,改变原有数组
  2. unshift:向数组的开头添加 返回值是添加数据后数组的新长度,改变原有数组
  3. splice:向数组的指定index处插入 返回的是被删除掉的元素的集合,会改变原有数组
向数组删除元素的方法:
  1. pop():从尾部删除一个元素 返回被删除掉的元素,改变原有数组
  2. shift():从头部删除一个元素 返回被删除掉的元素,改变原有数组
  3. splice:在index处删除howmany个元素 返回的是被删除掉的元素的集合,会改变原有数组
数组排序的方法:
  1. reverse():反转,倒置 改变原有数组
  2. sort():按指定规则排序 改变原有数组
数组迭代方法

参数: 每一项上运行的函数, 运行该函数的作用域对象(可选)

every()

对数组中的每一运行给定的函数,如果该函数对每一项都返回true,则该函数返回true

如何深拷贝一个对象

参考文档 一个递归爆栈引起的对深拷贝的理解 - 掘金 (juejin.cn)

可以使用递归的方式来深拷贝一个对象, 获取对象的keys, 遍历, 判断一下key类型是不是基本类型, 如果是就直接赋值给新对象, 如果是object, 就递归拷贝子属性。

 const deepCopy = (obj) => {
    if (typeof obj !== Object) return;
    let new_obj = obj instanceof Array ? [] : {};
    for (let key in obj) {
        if (obj.hasOwnProperty(key)) {
            new_obj[key] = typeof obj[key] === "object" ? deepCopy(obj[key]) : obj[key];
        }
    }
    return new_obj;
};
  

递归深拷贝时, 如何解决时的循引用赖问题呢?

循环引用的情况,即对象的属性间接或直接的引用了自身,导致递归进入死循环,栈溢出。

解决循环引用问题,我们先定义一个map,来存储当前对象和拷贝对象的对应关系,当需要拷贝当前对象时,先去存储空间中找,有没有拷贝过这个对象,如果有的话直接返回,如果没有的话继续拷贝。

function clone(target, map = new Map()) {
    if (typeof target === 'object') {
        let cloneTarget = Array.isArray(target) ? [] : {};
        if (map.get(target)) {
            return map.get(target);
        }
        map.set(target, cloneTarget);
        for (const key in target) {
            cloneTarget[key] = clone(target[key], map);
        }
        return cloneTarget;
    } else {
        return target;
    }
};
 

如何解决深拷贝时, 栈溢出的问题

解决递归爆栈一般来说有两种方法,第一种方法是常见的尾部调用,第二种则是把递归改为循环。 尾部调用参考尾调用优化 - 阮一峰的网络日志 (ruanyifeng.com)

尾部调用能解决爆栈的核心原因是以下两段话

我们知道,函数调用会在内存形成一个"调用记录",又称"调用帧"(call frame),保存调用位置和内部变量等信息。如果在函数A的内部调用函数B,那么在A的调用记录上方,还会形成一个B的调用记录。等到B运行结束,将结果返回到A,B的调用记录才会消失。如果函数B内部还调用函数C,那就还有一个C的调用记录栈,以此类推。所有的调用记录,就形成一个"调用栈"(call stack)。

image.png

递归非常耗费内存,因为需要同时保存成千上百个调用记录,很容易发生"栈溢出"错误(stack overflow)。但对于尾递归来说,由于只存在一个调用记录,所以永远不会发生"栈溢出"错误。

尾部调用大法:

const deepCopy = (obj, index = 0, new_obj = obj instanceof Array ? [] : {}) => {
    if (typeof obj !== "object") return;
    // new_obj = result ? result : new_obj;
    let keys = Object.keys(obj);
    if (index === keys.length) {
        return new_obj;
    } else {
        if (obj.hasOwnProperty(keys[index])) {
            if (typeof obj[keys[index]] === "object") {
                new_obj[keys[index]] = new_obj;
                return deepCopy(obj[keys[index]], 0, new_obj);
            } else {
                new_obj[keys[index]] = obj[keys[index]];
                return deepCopy(obj, index + 1, new_obj);
            }
        }
    }
};

递归改循环

说到循环,我们就要创建一个栈,当栈为空时跳出循环,栈中的元素要保存父对象, key , 和data 。 被深克隆的那个对象, 他的parent 空对象, key 为undefined 。 递归开始后

const cloneForce = (x) => {
    let result = {};
    let stack = [
        {
            parent: result,
            key: undefined,
            data: x,
        },
    ];
    while (stack.length) {
        let node = stack.pop();
        let { parent, key, data } = node;
        let res = parent;
        if (typeof key !== "undefined") {
            res = parent[key] = {};
        }
        for (let k in data) {
            if (data.hasOwnProperty(k)) {
                if (typeof data[k] === "object") {
                    stack.push({
                        parent: res,
                        key: k,
                        data: data[k],
                    });
                } else {
                    res[k] = data[k];
                }
            }
        }
    }
    return result;
};