JS面试题(一)

lxf2023-03-09 14:00:01

JavaScript

js是如何操作cookie的

设置cookie的值:

document.cookie = "userId=nick123"

设置cookie的过期时间:

document.cookie = "userId=nick123; expires=Wed, 15 Jan 2020 12:00:00 UTC"

设置cookie的路径:

document.cookie = "userId=nick123; expires=Wed, 15 Jan 2020 12:00:00 UTC; path=/user"

设置cookie的域:

document.cookie = "userId=nick123; expires=Wed, 15 Jan 2020 12:00:00 UTC; path=/user; domain=mysite.com"

读取页面所有cookie:将单个页面的所有 cookie 作为字符串获取,每个 cookie 用分号分隔

const cookies = document.cookie

读取具有特定名称的Cookie:需要自定义方法,先获取全部的cookie,然后将想要的cookie过滤出来

function getCookieValue(name) {
  const nameString = name + "="
  
  const value = document.cookie.split(";").filter(item => {
    return item.includes(nameString)
  })
  
  if (value.length) {
    return value[0].substring(nameString.length, value[0].length)
  } else {
    return ""
  }
}

更新cookie:通过创建的方式用新值覆盖 cookie 来更改它的值。

删除cookie:给 cookie 设置一个空值,并将其过期日期设置为过去的任意时间来删除 cookie。

解释 JavaScript 的单线程模型,以及为什么这样设计

单线程模型:个人理解就是js的事件循环

JS设计成单线程主要是跟js的用途有关,作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM,这就决定了js只能是单线程,否则会出现很多复杂的问题。假设js是多线程,比如你在一个线程中修改这个dom,但是你又在其他线程中删除这个dom,这时候浏览器的js引擎在解析的时候就茫然了!所以为了避免复杂性,js一诞生就被设计成了单线程。

setTimeout 的延时为何做不到精确

因为setTimeout的回调函数会在完成计时后被添加到宏任务队列中等待执行,但是如果此时主线程尚有任务执行,就需要继续等待,所以setTimeout执行的条件是:计时完成并且主线程空闲

请修改以下代码, 使最后能顺序打印出 1, 2, 3, 4, 5

要求: 每个数字之间, 间隔时间为 1秒(提示, 好好审题哟)

for (var i = 0; i < 5; i++) {
  setTimeout(function () {
    console.log(i);
  }, 1000);
}

首先上边代码肯定是直接打印5个5,你可能会想到将var改变成let:

for (let i = 0; i < 5; i++) {
  setTimeout(function () {
    console.log(i);
  }, 1000);
}

这次打印的结果不是五个5 是1秒之后一次性打印0,1,2,3,4,不满足题意。

以下方法都可以实现

for (var i=0; i<5; i++) {
      (function (i) {
        setTimeout(() => console.log(i), 1000*i)
      })(i)
}
function sleep(time,val){
    return new Promise((res)=>{
        setTimeout(()=>res(val),time)
    })
}

async function test(){
    for (let i = 0; i < 5; ) {
        i++
        console.log(await sleep(1000,i))
    }
}
test()

请说明以下程序打印出什么结果, 并简要说明推导依据

const result = ['1', '3', '10'].map(parseInt);
// 这⾥会打印出什么呢? 
console.log( result );

map的参数是:

function(current, index, arr) { // 当前元素值,当前元素索引值,数组本身
}

parseInt的参数是:

parseInt(str, radix) 
// 将一个radix进制的数str转化为10进制数
// str解析的字符串,radix是⼏进制(若省略或为0,则以10进⾏解析,若⼩于2或者⼤于36,则返回NaN)

所以结果是[1, NaN, 2]

进制转换

参考

转换为十进制

parseInt(str, radix)

  • 第一个参数是需要解析的字符串;其他进制不加前缀。
  • 第二个参数是一个进制基数,表示转换时按什么进制来理解这个字符串,默认值10,表示转十进制。
  • 第二个参数如果非数字,则自动转数字,如无法转称数字则忽略该参数;是数字时,必须是 2-36 的整数,如果省略该参数或其值为 0,则数字将以 10 为基础来解析;超出该范围,返回 NaN。

Number()

可以把字符串转为数字,支持其他进制的字符串,默认转成十进制数字。
字符串中如果存在无效的进制字符时,返回 NaN
记住,需要使用进制前缀0b0o0x

Number('0b11100') // 28
Number('0o33') // 27
Number('0x33') //51

Number('0x88kk') // NaN

+(一元运算符)

Number() 一样,可以把字符串转为数字,支持其他进制的字符串,默认转成十进制数字。
字符串中如果存在无效的进制字符时,返回 NaN
需要使用进制前缀

+'0b11100' // 28
+'0o33' // 27
+'0x33' //51

+'0x88kk' // NaN

十进制转化为其他进制

Number.prototype.toString(radix)

它支持传入一个进制基数,用于将数字转换成对应进制的字符串,它支持转换小数。 未指定默认值为 10,基数参数的范围 2-36,超过范围,报错:RangeError。

(11.25).toString(2)

axios、fetch、ajax三者的区别

三者都用于网络请求,但是是不同的维度:

  • ajax是技术统称
  • fetch是具体的api
  • axios是第三方库,通过npm安装,是通过api(fetch或者XMLHttpRequest)实现的

fetch

  • 浏览器的原生api,用于网络请求
  • 和XMLHttpRequest一个级别的,是他的升级版本
  • 更简洁,支持Promise
function ajax1(url){
    return fetch(url).then(res=>{})
}

XMLHttpRequest实现ajax

function ajaxFunc(url,cb){
    const xhr = new XMLHttpRequest();
    xhr.open('GET',url,false);
    xhr.onreadystatechange = function(){
        if(xhr.readystate===4){
            if(xhr.status===200){
                cb(xhr.responseText)
            }
        }
    }
    xhr.send(null)
}

fetch 是否可以共享 Cookie?

跨域中要携带cookie时需要解决两个问题:跨域和携带cookie

解决跨域问题

fetch可以设置不同的模式使得请求有效. 模式可在fetch方法的第二个参数对象中定义.

fetch(url, {mode: 'cors'});

可以定义的模式如下:

  • same-origin:表示同源可以进行访问,反之浏览器拒绝
  • cors: 表示同源和带有cors响应头的跨域可以请求成功,其他拒绝
  • cors-with-forced-preflight: 表示在发出请求前, 将执行preflight检查.
  • no-cors: 表示跨域请求不带cors响应头场景,此时的相应类型为opaque,但是在opaque的返回类型中,我们几乎不能查看到任何有价值的信息,比如不能查看response, status, url。

解决跨域携带cookie的问题

跨域请求中需要带有cookie时, 可在fetch方法的第二个参数对象中添加credentials属性, 并将值设置为”include”.

fetch(url,{
  credentials: 'include'
});

除此之外, credentials 还可以取以下值:

  • omit: 缺省值, 默认为该值.
  • same-origin: 同源, 表示同域请求才发送cookie.

但是同时需要目标服务器可以接受接受跨域发送cookies请求,否则会被浏览器的同源策略阻挡。

服务器端需要支持Access-Control-Allow-Credentials策略,服务器同时设置Access-Control-Allow-Credentials响应头为"true", 即可允许跨域请求携带 Cookie。

fetch为什么可以使用then,两个 then 分别对应着什么?

因为fetch()就是 promise 的实例,而then方法被定义到promise.prototype上。所以fetch可以使用then方法。

fetch 的第一个 then 执行的第一个 resolve 回调函数,函数参数为 Response 对象。直接输出 Response 对象不是我们需要的数据,使用response.json()或者response.text()等方法获取到我们需要的数据(当然还有其他数据转换的方法)。

以response.json()为例,它返回一个 Promise,Promise 的解析 resolve 结果是将文本体解析为 JSON,数据在[[[PromiseValue]]里面,但是直接取是取不出来的,Promise的设计文档中说了,[[PromiseValue]]是个内部变量,外部无法得到,只能在then中获取,可以调用then方法去处理数据。所以就会用到第二次then了。

顺带一提:axios 会自动转换 JSON 数据,所以 axios 只需要一次then能取出数据。

现在就重点理解下[[[PromiseValue]]这个怎么获取到的?

/* 用于描述思维的代码 */
executor(resolve, reject) {
    // ... some code
    resolve(value);
    ...
}
...
resolve(value) {
    PromiseStatus = 'fulfilled';
    PromiseValue = value;
    ...
    // 接着调用回调链中的回调函数
    // ... some code
}

resolve(value) ,参数 value 会被赋值给 PromiseValue。这句话是说 then 的回调函数参数使用的都是 PromiseValue,所以直接输出就会获取到 PromiseValue 的值。

什么时候不能使用箭头函数

  • 对象中的方法:this并不指向这个对象
  • 原型方法
  • 构造函数
  • 动态上下文中的回调函数
  • vue生命周期和methods中的函数

首先回想下箭头函数的特点:

  • 没有arguments
  • 无法通过call、apply、bind绑定this的指向
  • this取决于父作用域的this
  • 没有prototype

以下情况下不能使用箭头函数:

  1. 对象中的方法:this并不指向这个对象

    const obj = {
        name: 'jerry',
        getName: () => {
            return this.name
        }
    }
    
    console.log(obj.getName())
    
    // class中可以:这也决定了react组件中的方法可以使用箭头函数定义
    
    class Foo {
        constructor(name) {
            this.name = name
        }
        getName = () => {
            return this.name
        }
    }
    
    const foo = new Foo('jerry')
    console.log(foo.getName())
    
  2. 原型方法:

        function Myfunc(name){
            this.name = name;
        }
        Myfunc.prototype.sayName = ()=>{
            console.log(this === window) // true
            return this.name
        }
        const me = new Myfunc('hanson')
        me.sayName() // ''
    
  3. 构造函数:联想一下new一个构造函数的过程中都干了什么,就知道为什么不能用作构造函数了

  4. 动态上下文中的回调函数:addEventListener中回调函数中有this指向执行时的父作用域,不指向元素

    const button = document.getELementById('myButton');
    button.addEventListener('click',()=>{
        console.log(this === window);
        this.innerHTML = 'click button';
    })
    

    在全局上下文中,this指向window。当点击事件发生的时候,浏览器尝试使用按钮上下文调用处理函数,但是箭头函数不会更改其预定义的上下文,this.innerHTM相当于window.innerHTML,没有任何意义。

  5. vue生命周期和methods中的函数:本质是js对象,但是react可以,因为react是class。

数据类型

js数据类型分为原始数据类型和引用数据类型:

  • 原始数据类型包括:String,Number,Boolean,null,undefined,symbol,BigInt
  • 引用数据类型为Object,具体包括:Object,Array,Date,Function,RegExp

数据类型的存储形式

原始数据保存在栈内存中,可以按值查找。

引用数据保存在堆内存中,每个对象有一个引用地址,相应的内存地址保存到栈内存中,按照引用地址查找。

这将会导致复制前后的两个基本数据类型的变量互不影响,而复制前后的引用数据类型永远保持一致,因为复制前后的两个变量指向同一个引用地址。

typeof null为什么返回object

因为在 JS 的最初版本中,使用的是 32 位系统,为了性能考虑使用低位存储了变量的类型信息,000 开头代表是对象,然而 null 表示为全零,所以将它错误的判断为 object。

数据类型的判断

  • typeof判断除了null以外的全部基本数据类型
  • instanceof或者constructor.toString判断引用数据类型
  • 判断数组可以使用Array.isArray()

typeof

一元运算符(只能带一个参数),除了null和object不能准确判断,别的(string,number,undefined,Boolean,symbol)都能准确判断。 typeof(NaN)===number

instanceof()

a instanceof b:检测a的原型链上是否有b.prototype,返回true或者false。

instanceof 运算符用来测试一个对象在其原型链中是否存在一个构造函数的 prototype 属性.

var a = [];
console.log(a instanceof Array) // true

constructor

返回变量的构造函数:

{}.constructor.toString().indexOf('Object') > -1

arr.indexOf(a):返回a在arr中的位置,arr中有两个a的时候,返回第一个a的位置。不存在时,返回-1。

数据类型转换

ECMAScript内部定义了一些‘抽象操作’,定义了各种值类型的转换规则,包括ToNumber、ToString、ToBoolean和ToPrimitive。

  • 一种基本类型转换为另一种基本类型:ToNumber、ToString、ToBoolean
  • 对象类型转换为基本类型:ToPrimitive

转换为字符串

当一次运算的期望值是字符串的时候就会发生ToString转换,例如alert(value)显示value时最终会转换为字符串显示。当然我们可以调用String(value)函数显示将value转换为字符串。

ToSting的转换比较直观:

转换前转换后
null'null'
undefined'undefined'
true和false'true'和'false'
Symbol()和Symbol(‘foo’)“Symbol()”和 “Symbol(foo)”
数字转化为对应的字符串

转化为数字

数字转换会发生在数学函数和表达式中。 转换规则:

转换前转换后
null0
undefinedNaN
true和false1和0
SymbolNaN
字符串首先去除两边空格。剩下的是空字符串:结果为0;是可以转换为有效数字的值:转换为对应数字;否则是NaN

转换为布尔

发生在逻辑运算中。

除了‘’,null,undefined,NaN,0转换为false之外,其他的都转换为true。

‘0’和仅包含空格的字符串(“ “)会转换成true。

引用数据类型转换为原始数据类型

当对象处于期望值是基本类型的场景下,就会发生对象转换为基本类型值。会用到ToPrimitive算法。

对象数据类型会转换为数字类型或者字符串类型,转换为布尔类型都是true。

ToPrimitive这个算法允许我们自定义对象转换规则,但是要依赖环境,用所谓的hint表示,它有三个可能取值。

string

当操作期望值是字符串的时候。这样的操作包括输出对象或者将对象作为属性使用。

var obj = {},
    anotherObj = {};

// 输出操作
alert(obj); //[object Object]

// 将对象作为属性名使用
anotherObj[obj] = 123; //相当于anotherObj['[object Object]'] = 123;

number

当操作期望值是数字,比如数学运算:

// 显式转换
let num = Number(obj);

// 数学运算(除了二元加号)
let n = +obj; // 一元加号
let delta = date1 - date2;

// 大于/小于比较
let greater = user1 > user2;

default

适用于运算结果不确定的操作中。比如二元加号既可以将两字符串连接也可以将两数字相加。还有当一个对象使用==与字符串、数字、布尔值或者Symbol值比较时。

// 二元加号
let total = car1 + car2;

// obj == string/number/boolean/symbol
if (user == 1) {}

在实践中,所有内置对象(除了Date)对于 default和number的处理遵循同一套转换规则:

  • 如果obj[Symbol.toPrimitive](hint)方法存在,就调用。
  • 否则,如果hint是string:不论是否存在,尝试调用obj.toString()和obj.valueOf()。
  • 否则,如果hint是number或者default:不论是否存在,尝试调用obj.valueOf()和obj.toString()。

js有一个预先定义的Symbol值Symbol.toPrimitive,定义对象发生转换时调用的方法名:

obj[Symbol.toPrimitive] = function (hint) {
    // 返回一个基本类型值,否则报错。
    // hint 的值取 "string"、"number" 和 `default` 之一
};

下面定义一个user实现这个方法:

let user = {
    name: 'John',
    money: 1000,

    [Symbol.toPrimitive](hint) {
        alert(`hint: ${hint}`);
        return hint == 'string' ? `{name: "${this.name}"}` : this.money;
    }
};

alert(user); // hint: string -> {name: "John"}
alert(+user); // hint: number -> 1000
alert(user + 500); // hint: default -> 1500

要注意的是:如果先调用方法返回了一个基本类型,就不在调用后边的方法。例如,对于hint等于string的情况,如果先调用了toString方法反回了一个基本类型值,那么不再调用valueOf。

也可以定义valueOf和toString方法:

let user = {
    name: 'John',
    money: 1000,

  // 针对 hint 等于 "string" 的情况
  toString() {
    return `{name: "${this.name}"}`;
  },

  // 针对 hint 等于 "number" 或 "default" 的情况
  valueOf() {
    return this.money;
  }
};

alert(user); //(调用toString){name: "John"}  
alert(+user); // (调用valueOf)1000
alert(user + 500); // (调用valueOf)1500

若是不提供valueOf方法,那么toString就成为了对象转换的唯一途径:

let user = {
    name: 'John',

  // 针对 hint 等于 "string" 的情况
  toString() {
    return this.name;
  },


};

alert(user); //(调用toString)John 
alert(+user); // (调用toString)NaN
alert(user + 500); // (调用toString)John500

toString 和 valueOf 方法应该返回一个基本类型值,但由于历史原因,如果返回了一个对象,也不会报错,而是会忽略这个方法(就像这个方法不存在一样)。

与此相反,Symbol.toPrimitive 必须 返回一个基本类型值,否则会报错。

四则运算:隐式转换

加法

  • 有一方是字符串,则加法起到“连接”作用。
  • Boolean + Number,Boolean + Boolean 转换为数字相加
  • Object + Number object调用valueof如果不是String, Boolean或者Number类型就继续调用toString()。

其余运算转换成Number进行运算。

== 和 ===、以及Object.is的区别

  • === 和Object.is是严格判断,object.is解决了===存在的一些问题(这些问题就是两者判断结果的不同之处,后边解析会讲到)
  • == 是非严格的判断,当类型不一样时会发生数据类型的转换

=== 严格等于

  • 首先判断类型是否相同,类型不同则不相同。
  • 有一个值是NaN则不相等,NaN === NaN 为false,+0 === -0 为true。
  • 剩下的按位取均相同则相等。

object.is加强版严格等于

和严格等于基本一致,只存在两方面的差别(===中判断存在的问题):

Object.is(+0,-0)   //false
Object.is(NaN,NaN)   //true

==非严格等于

  • 先判断类型是否相同,相同的话返回x==y的结果。
  • 类型不同,先转换为相同类型再比较。 string转换为number,Boolean转换为number,object先转换为原始类型再比较。
  • 均为string长度和内容完全相同则相等。
  • null==undefined 结果为true。

this指针,以及this指向问题

  • 在每一个函数中,都有一个内置的变量:this
  • 大多数情况下,this存储的是当前函数的调用者
  • this指向在定义时是不确定的,在不同的情况下,this指向是不一定相同的

默认绑定

直接在函数中调用,默认指向全局window对象,严格模式下为undefined:

function print_fun(){
    console.log(this);
}
print_fun()

隐式绑定

对象中调用,指向该对象

var obj = {
    name:'jerry',
    age:23,
    print:function(){
        console.log(this);
    }
}
obj.print() //{ name: 'jerry', age: 23, print: [Function: print] }

隐式绑定存在着this丢失的问题(作为变量传递或者变量赋值产生丢失):

// 作为变量传递
var name = '全局';
let obj = {
    name: '对象',
    fn: function () {
        console.log(this.name);
    }
};

function fn1(param) {
    param();
};
fn1(obj.fn);//全局
//obj.fn作为变量传递给fn1(),this并没有跟函数绑定在一起

//变量赋值
var name = '全局';
let obj = {
    name: '对象',
    fn: function () {
        console.log(this.name);
    }
};
let fn1 = obj.fn;
fn1(); //全局

并非全部的this丢失都会指向全局对象,比如:

var name = '全局';
let obj = {
    name: 'obj',
    fn: function () {
        console.log(this.name);
    }
};
let obj1 = {
    name: 'obj1'
}
obj1.fn = obj.fn;
obj1.fn(); //obj1
//虽然丢失了 obj 的隐式绑定,但是在赋值的过程中,又建立了新的隐式绑定,这里this就指向了对象 obj1。

显示绑定

通过call,apply,bind改变this的指向。

new绑定

通过new的方式,永远指向新创建的对象:

function person(){
    this.name = 'jerry';
    this.age = 23;
    console.log(this);//person { name: 'jerry', age: 23 }
}
var obj = new person();  //指向obj

箭头函数中的 this

箭头函数中没有this,this的作用域取决于外层作用域中的this

function fn() {
    return () => {
        console.log(this.name);
    };
}
let obj1 = {
    name: 'obj1'
};
let obj2 = {
    name: 'obj2'
};
let bar = fn.call(obj1); // fn this指向obj1
bar.call(obj2); //obj1

箭头函数的this取决于外层作用域的this,fn函数执行时this指向了obj1,所以箭头函数的this也指向obj1。除此之外,箭头函数this还有一个特性,那就是一旦箭头函数的this绑定成功,也无法被再次修改,有点硬绑定的意思

当然,箭头函数的this也不是真的无法修改,我们知道箭头函数的this就像作用域继承一样从上层作用域找,因此我们可以修改外层函数this指向达到间接修改箭头函数this的目的。

事件绑定中的this

指向事件源

btn.onclick = function(){
    //this 指向的就是btn元素节点。
--------------------------------------------------------------------------    
    fn();//this指向 window(注意)
}

改变this的指向

call、apply、bind都可以改变this的指向:

  1. 三者相同的点
  • 第一个参数都是要指向的对象。
  • 都采用后续传参。
  1. 三者不同点
  • 传参方式不同:call按顺序单个传入即可,apply放在数组中传入,bind均可。
  • call和apply会直接执行函数,bind等到调用时才会执行。
  • call和apply是单次绑定,bind绑定之后不能再改变。

普通函数的this指向有哪些情况

(1)总是代表着它的直接调用者,如obj.fn,fn里的最外层this就是指向obj

(2)默认情况下,没有直接调用者,this指向window

(3)严格模式下(设置了'use strict'),this为undefined

(4)当使用call,apply,bind(ES5新增)绑定的,this指向绑定对象

原型

  • 原型对象存放共有属性。
  • 构造函数的prototype属性指向原型对象。
  • 原型对象的constructor属性指回构造函数。
  • 每个新生成的对象都有一个内置属性_proto_指向原型对象。

原型对象

在JS中,我们可以通过构造函数创建一个对象:

function person(name){
    this.school='HBschool',  
    this.name = name
};

var obj1 = new person('tom');
var obj2 = new person('jerry');

obj1和obj2是两个对象,改变其中一个school属性并不能使另一个改变:

obj1.school = 'anotherSchool';
console.log(obj2.school)    //HBschool

这说明obj1和obj2两个的相同属性school没有被共享,为了实现属性共享JS设计了原型对象来存储对象共享的属性。

prototype

JS设计原型对象来存放对象的共享属性,那么怎么使新创建的对象有共享属性呢?

JS的构造函数中有一个prototype属性,指向原型对象。即将所有的共享属性放在构造函数的prototype属性指向的原型对象中,私有属性放在构造函数中。

prototype是构造函数的属性,指向原型对象。

_ proto_(原型)

每创建一个新的对象,都会有一个_proto_属性,这个属性是自动生成的,这个_proto_属性指向的就是原型对象。

新创建的对象的内置属性_ proto_ 指向其构造函数的原型对象,构造函数的原型对象也是一个对象,也有_proto_属性,指向其构造函数的原型对象,由此向上连接起来构成原型链。

原型对象中除了_proto_属性外还有constructor属性,constructor属性指回构造函数。

原型链

新创建的对象实例_proto_属性指向其构造函数的原型对象,原型对象还有原型对象,以此类推构成原型链。所有的原型对象都是Object的实例,所以最后指向Object构造函数的原型对象,Object构造函数的_proto_指向null。

当在读取对象属性时,会先在对象内部查找,查找不到时会沿着原型链向上查找,查找不到则会报错。

prototype和__ proto __ 的区别

  • prototype是构造函数所独有,指向原型对象。
  • __ proto__ 是对象实例所独有的,指向prototype。
  • prototype是内部属性,不能访问,所以通过_ proto _访问。

加深理解

function person(name){
    this.school='HBschool',  
    this.name = name
};

person.prototype.all={
    one:'lll',
    two:'222'
}

var obj1 = new person('tom');
console.log('obj1--->',obj1)

var obj2 = new person('jerry');
console.log('obj2--->',obj2)

JS面试题(一)

改变all中的共享属性,obj1和obj2中都会改变。

new的过程发生了什么

  • 创建一个空的新对象。
  • 连接到原型。
  • 将this指向现对象(构造函数作用域给新对象)。
  • 为新对象添加属性。
  • 返回新对象。

具体使用方法:

function person (name){
    this.name = name;
};

var obj1 = new person();

手动实现new

function _new(fn) {
  // 1、创建一个对象
  let res = {};
  
  // 2、连接到原型
  if (fn.prototype !== null) {
    res.__proto__ = fn.prototype;
  }

  //3、绑定this
  let ret = fn.apply(res, Array.prototype.slice.call(arguments, 1));

  // 4、返回对象(构造函数返回的是基本类型或没有返回值---返回res;返回的是引用类型---返回构造函数返回的值)
  if ((typeof ret === "object" || typeof ret === "function") && ret != null) {
    return ret;
  }

  return res;
}

// 测试
function Person(name, age) {
  this.name = name;
  this.age = age;
}

let obj = _new(Person, "jerry", 22);
console.log(obj); //Person { name: 'jerry', age: 22 }

function Another(name, age) {
  return {
    name: name,
    age: age,
    class: 3,
  };
}

let obj2 = _new(Another, "tom", 23);
console.log(obj2) //{ name: 'tom', age: 23, class: 3 }

对象的创建方法及之间差别

创建对象的方法:

  • new构造函数
  • 字面量
  • Object.create()
  • 对象实例化:new Object()

差别:

  • 除了Object.create创建的对象其余的方式创建的对象,原型指向object.prototype,继承内置的Object;
  • Object.create(arg,null)创建的对象取决于arg: 若是arg为null,则为空对象,没原型,不继承任何对象; 若是arg为对象,原型指向arg,继承arg。

闭包

闭包是指外部函数return一个内部函数,内部的函数可以访问外部函数的变量,外部函数不能访问内部函数的变量。

或者可以理解为:一个函数,创建他时的作用域在执行时的作用域以外,并且包

function outer() {
     var  a = '变量1'
     var  inner = function () {
            console.info(a)
     }
    return inner    // inner 就是一个闭包函数,因为他能够访问到outer函数的作用域
}

闭包的作用

闭包可以用在许多地方。它的最大用处有两个:

  • 一个是前面提到的可以读取函数内部的变量,
  • 另一个就是让这些变量的值始终保持在内存中,不会在函数调用后被自动清除。

开发中常用到的场景:

  • 变量封装:保护私有变量
  • 防抖、节流
  • 防止垃圾回收

闭包优缺点

1.优点

  • 可以读取函数内部的变量
  • 避免了全局污染

2.缺点

  • 变量不会被收回,消耗的内存
  • 使用不当会造成内存泄漏等问题

作用域

作用域就是变量与函数的可访问范围,控制着变量与函数的可见性和生命周期。

作用域分为以下几种:

  • 全局作用域:任何地方都能访问到
    • 在函数外边声明的变量拥有全局作用域。
    • 未声明直接赋值的变量拥有全局作用域。
    • window 对象的属性拥有全局作用域。
  • 局部作用域(函数作用域):代码片段中可以访问到。
    • 函数内声明变量,在外部无法访问。
  • ES6中块级作用域:在代码块中声明,涉及到let和const。

没有块级作用域时存在的问题:

// 1. 变量提升导致内层变量可能会覆盖外层变量
var i = 5;
function func(){
    console.log(i);  //undefined
    if(true){
        var i = 6;
    }
}
func()

// 2. 用来计数的循环变量泄露为全局变量
 for(var i = 0; i < 10 ; i++){
    //  console.log(i)
 }
 console.log(i); //10

作用域链

理解方式一:函数层层嵌套,子函数可以访问父函数作用域内的变量,从子函数向父函数的作用域串联起来形成作用域链。

理解方式二:通过执行栈理解,每当函数执行的时候,会为函数创建一个执行上下文,执行栈用于存储执行上下文。当代码执行的时候会创建一个全局执行上下文压入到执行栈底,当一个函数被调用的时候,会创建一个函数执行上下文压入栈顶,函数执行完毕的时候会将上下文弹出执行栈,作用域链可以理解为从栈顶向栈底的作用域连接起来。

垃圾回收机制

内存泄漏

不再用到的内存没有及时释放,就叫做内存泄漏。

我们不会再引用某个对象时,垃圾回收器认为这个对象还在被引用,一次不会释放它,从而造成了内存泄漏。

为什么系统需要垃圾回收

由于字符串、数组、对象没有固定大小,所以当他们的大小已知时,才能对他们进行动态的存储分配。JavaScript程序每次创建字符串、数组或者对象时,解释器都必须分配内存来存储那个实体,只要像这样动态地分配了内存,最终都要释放这些内存以便他们能够被再用,否则,JavaScript的解释器将会消耗完系统中所有可用的内存,造成系统崩溃。

JS不像C/C++,他有自己的一套垃圾回收机制(Garbage Collection)。JavaScript的解释器可以检测到何时程序不再使用一个对象了,当他确定了一个对象是无用的时候,他就知道不再需要这个对象,可以把它所占用的内存释放掉了。

标记清除法

js中最常用的垃圾收集方式是标记清除。当变量进入环境(例如,在函数中声明一个变量)时,就将这个变量标记为“进入环境”,而当变量离开环境时,则将其标记为“离开环境”。

从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到他们。

可以使用任何方式来标记变量。比如,可以通过翻转某个特殊的位来记录一个变量何时进入环境,或者使用一个“进入环境的”变量列表及一个“离开环境的”变量列表来跟踪哪个变量发生变化。

垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记(当然,可以使用任何标记方式)。然后,它会去掉环境中的变量以及被环境中的变量引用的变量标记。当变量离开环境(不再需要引用变量)的时候,再重新给这些变量添加标记,而在此之后重新被加上标记的变量将被视为准备删除的变量,原因是环境中的变量已经无法访问到这些变量了。最后,垃圾收集器完成内存清除工作,销毁那些带标记的值并回收他们所占用的内存空间。

原理是看变量是否在执行环境中被引用,不被引用则删除。

引用计数法(不常见)

引用计数的含义是跟踪记录每个值被引用的次数。当声明了一个变量并将一个引用类型值赋给该变量时,则这个值的引用次数就是1。如果同一个值又被赋给另一个变量,则该值的引用次数加1。相反,如果包含对这个值引用的变量又取的了另一个值,则这个值的引用次数减1.当这个值的引用次数变成0时,则说明没有办法再访问这个值了,因而就可以将其占用的内存空间收回来。这样,当垃圾收集器下次再运行时,它就会释放那些引用次数为零的值所占用的内存。

原理是计算每个值引用的次数。

var a="hello world";//hello world引用次数为1
var b="world";//world引用次数也为1
var a=b;//hello world引用次数为0,之后将删除释放,而world次数为2

这种方法存在内存泄漏的问题:

function problem() {
    var objA = new Object();
    var objB = new Object();
    objA.someOtherObject = objB;
    objB.anotherObject = objA;
}

在这个例子里面,objA和objB通过各自的属性相互引用,这样的话,两个对象的引用次数都为2,在标记清除法中,由于函数执行之后,这两个对象都离开了作用域,因此这种相互引用不是个问题。但在采用引用技数策略的实现中,当函数执行完毕后,objectA和objectB还将继续存在,因为他们的引用次数永远不会时0。加入这个函数被多次调用,就会导致大量内存得不到回收。

管理内存

因为自动垃圾回收机制的存在,内存的分配与回收完全实现了自动管理

内存空间的使用过程(JS的内存生命周期):

  • 分配所需要的内存空间
  • 使用分配到的内存空间
  • 不需要时将其释放归还

js中存在的问题就是分配给web浏览器的内存通常比分配给桌面应用的少,因此为了使页面性能更好,要保证变量占用的内存尽量少,最好的办法就是将不用的变量引用释放掉,也叫作解除引用。

在局部作用域中,当函数执行完,局部变量也就没有存在的必要,因此垃圾收集器很容易做出判断并回收。但是在全局中,变量什么时候需要自动释放内存空间则很难判断,因此我们在开发时,应尽量避免使用全局变量,如果使用了,在不使用它时,通过赋值null的方式释放引用,以确保能够及时回收内存空间。

Chrome V8引擎中的垃圾回收机制

答案总结

V8采用了一种分代回收的策略,将内存分为两个生代:新生代和老生代。

新生代使用Scavenge算法进行回收。在Scavenge算法的实现中,主要采用了Cheney算法

Cheney算法算法是一种采用复制的思想实现的垃圾回收算法。它将内存一分为二,每一部分空间称为semispace。

在这两个 semispace中,一个处于使用状态,另一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间。

当我们分配对象时,先是在From空间中进行分配。当开始进行垃圾回收算法时,会检查From空间中的存活对象,这些存活对象将会被复制到To空间中 (复制完成后会进行紧缩),而非活跃对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换。

新生代的垃圾回收中总有一半空间是空闲的。

老生代使用标记清除和标记压缩相结合进行垃圾回收。

标记清除分为标记和清除两个阶段。在标记阶段需要遍历堆中的所有对象,并标记那些活着的对象,然后进入清除阶段。在清除阶段中,只清除没有被标记的对象。由于标记清除只清除死亡对象,而死亡对象在老生代中占用的比例很小,所以效率较高

标记清除有一个问题就是进行一次标记清除后,内存空间往往是不连续的,会出现很多的内存碎片。如果后续需要分配一个需要内存空间较多的对象时,如果所有的内存碎片都不够用,将会使得V8无法完成这次分配,提前触发垃圾回收。

标记压缩正是为了解决标记清除所带来的内存碎片的问题。标记整理在标记清除的基础进行修改,将其清除阶段变为紧缩阶段。在整理的过程中,将活着的对象向内存区的一段移动,移动完成后直接清理掉边界外的内存。紧缩过程涉及对象的移动,所以效率并不是太好,但是能保证不会生成内存碎片。

Chrome V8的老生代使用标记清除和标记整理结合的方式,主要采用标记清除算法,如果空间不足以分配从新生代晋升过来的对象时,才使用标记整理。

JavaScript的垃圾回收器

JavaScript使用垃圾回收机制来自动管理内存。垃圾回收是一把双刃剑,其好处是可以大幅简化程序的内存管理代码,降低程序员的负担,减少因长时间运转而带来的内存泄露问题。但使用了垃圾回收即意味着程序员将无法掌控内存。ECMAScript没有暴露任何垃圾回收器的接口。我们无法强迫其进行垃圾回收,更无法干预内存管理。

内存管理问题

在浏览器中,Chrome V8引擎实例的生命周期不会很长(谁没事一个页面开着几天几个月不关),而且运行在用户的机器上。如果不幸发生内存泄露等问题,仅仅会影响到一个终端用户。且无论这个V8实例占用了多少内存,最终在关闭页面时内存都会被释放,几乎没有太多管理的必要(当然并不代表一些大型Web应用不需要管理内存)。但如果使用Node作为服务器,就需要关注内存问题了,一旦内存发生泄漏,久而久之整个服务将会瘫痪(服务器不会频繁的重启)。

Chrome的内存限制

Chrome限制了所能使用的内存极限(64位为1.4GB,32位为1.0GB),这也就意味着将无法直接操作一些大内存对象。

Chrome之所以限制了内存的大小,表面上的原因是V8最初是作为浏览器的JavaScript引擎而设计,不太可能遇到大量内存的场景,而深层次的原因则是由于V8的垃圾回收机制的限制。由于V8需要保证JavaScript应用逻辑与垃圾回收器所看到的不一样,V8在执行垃圾回收时会阻塞 JavaScript 应用逻辑,直到垃圾回收结束再重新执行JavaScript应用逻辑,这种行为被称为“全停顿”(stop-the-world)。 若V8的堆内存为1.5GB,V8做一次小的垃圾回收需要50ms以上,做一次非增量式的垃圾回收甚至要1秒以上。这样浏览器将在1s内失去对用户的响应,造成假死现象。如果有动画效果的话,动画的展现也将显著受到影响。

Chrome V8的堆构成

V8的堆其实并不只是由老生代和新生代两部分构成,可以将堆分为几个不同的区域:

  • 新生代内存区:大多数的对象被分配在这里,这个区域很小但是垃圾回收特别频繁
  • 老生代指针区:属于老生代,这里包含了大多数可能存在指向其他对象的指针的对象,大多数从新生代晋升的对象会被移动到这里
  • 老生代数据区:属于老生代,这里只保存原始数据对象,这些对象没有指向其他对象的指针。
  • 大对象区:这里存放体积超越其他区对象大小的对象,每个对象有自己的内存,垃圾回收器不会移动大对象。
  • 代码区:代码对象,也就是包含JIT之后指令的对象,会被分配在这里。唯一拥有执行权限的内存区
  • Cell区、属性Cell区、Map区:存放Cell、属性Cell和Map,每个区域都是存放相同大小的元素,结构简单

每个区域都是由一组内存页构成,内存页是V8申请内存的最小单位,除了大对象区的内存页较大以外,其他区的内存页都是1MB大小,而且按照1MB对齐。内存页除了存储的对象,还有一个包含元数据和标识信息的页头,以及一个用于标记哪些对象是活跃对象的位图区。另外每个内存页还有一个单独分配在另外内存区的槽缓冲区,里面放着一组对象,这些对象可能指向其他存储在该页的对象。垃圾回收器只会针对新生代内存区、老生代指针区以及老生代数据区进行垃圾回收。

Chrome V8的垃圾回收机制

自动垃圾回收算法的演变过程中出现了很多算法,但是由于不同对象的生存周期不同,没有一种算法适用于所有情况。

V8采用了一种分代回收的策略,将内存分为两个生代:新生代和老生代。

新生代的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。

分别对新生代和老生代使用不同的垃圾回收算法来提升垃圾回收的效率。对象起初都会被分配到新生代,当新生代的对象满足某些条件时,会被移动到老生代。

V8的分代内存

默认情况下,64位环境下的V8引擎的新生代内存大小32MB、老生代内存大小为1400MB,而32位则减半,分别为16MB和700MB。V8内存的最大保留空间分别为1464MB(64位)和732MB(32位)。具体的计算公式是4*reserved_semispace_space_ + max_old_generation_size_,新生代由两块reserved_semispace_space_组成,每块16MB(64位)或8MB(32位)

新生代垃圾回收算法

大多数的对象被分配到这里,这个区域很小但是垃圾回收特别频繁。在新生代分配内存非常的方便,我们需要保存一个指向内存区的指针,根据新对象的大小不断的进行递增。当该指针到达了新生代内存区的末尾,就会有一次清理(仅仅是清理新生代)

新生代使用Scavenge算法进行回收。在Scavenge算法的实现中,主要采用了Cheney算法。

Cheney算法算法是一种采用复制的方式实现的垃圾回收算法。它将内存一分为二,每一部分空间称为semispace。

在这两个 semispace中,一个处于使用状态,另一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间。

当我们分配对象时,先是在From空间中进行分配。当开始进行垃圾回收算法时,会检查From空间中的存活对象,这些存活对象将会被复制到To空间中 (复制完成后会进行紧缩),而非活跃对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换。

也就是说,在垃圾回收的过程中,就是通过将存活对象在两个semispace之间进行复制。可以很容易看出来,使用Cheney算法时,总有一半的内存是空的。但是由于新生代很小,所以浪费的内存空间并不大。而且由于新生代中的对象绝大部分都是非活跃对象,需要复制的活跃对象比例很小,所以其时间效率十分理想。复制的过程采用的是BFS(广度优先遍历)的思想,从根对象出发,广度优先遍历所有能到达的对象。

具体的对比过程

对象的晋升

当一个对象经过多次新生代的清理依旧幸存,这说明它的生存周期较长,也就会被移动到老生代,这称为对象的晋升,具体移动的标准有两种:

  • 对象从From空间复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一个新生代的清理,如果是,则复制到老生代中,否则复制到To空间中
  • 对象从From空间复制到To空间时,如果To空间已经被使用了超过25%,那么这个对象直接被复制到老生代

如果新生代中的一个对象只有一个指向它的指针,而这个指针在老生代中,我们如何判断这个新生代的对象是否存活?为了解决这个问题,需要建立一个列表用来记录所有老生代对象指向新生代对象的情况。每当有老生代对象指向新生代对象的时候,我们就记录下来。

老生代的垃圾回收算法

老生代所保存的对象大多数是生存周期很长的甚至是常驻内存的对象,而且老生代占用的内存较多。

老生代占用内存较多(64位为1.4GB,32位为700MB),如果使用Scavenge算法,浪费一半空间不说,复制如此大块的内存消耗时间将会相当长。所以Scavenge算法显然不适合。V8在老生代中的垃圾回收策略采用Mark-Sweep和Mark-Compact相结合

Mark-Sweep(标记清除)

标记清除分为标记和清除两个阶段。在标记阶段需要遍历堆中的所有对象,并标记那些活着的对象,然后进入清除阶段。在清除阶段中,只清除没有被标记的对象。由于标记清除只清除死亡对象,而死亡对象在老生代中占用的比例很小,所以效率较高

标记清除有一个问题就是进行一次标记清除后,内存空间往往是不连续的,会出现很多的内存碎片。如果后续需要分配一个需要内存空间较多的对象时,如果所有的内存碎片都不够用,将会使得V8无法完成这次分配,提前触发垃圾回收。

在老生代中,以下情况会先启动标记清除算法:

  • 某一个空间没有分块的时候
  • 空间中被对象超过一定限制
  • 空间不能保证新生代中的对象移动到老生代
Mark-Compact(标记整理/标记压缩)

标记整理正是为了解决标记清除所带来的内存碎片的问题。标记整理在标记清除的基础进行修改,将其清除阶段变为紧缩阶段。在整理的过程中,将活着的对象向内存区的一段移动,移动完成后直接清理掉边界外的内存。紧缩过程涉及对象的移动,所以效率并不是太好,但是能保证不会生成内存碎片。

Chrome V8的老生代使用标记清除和标记整理结合的方式,主要采用标记清除算法,如果空间不足以分配从新生代晋升过来的对象时,才使用标记整理。

js 内存泄漏

内存泄漏是指一块被分配的内存,不再使用,却也没有回收。

js的垃圾回收机制(GC:garbage collec)有两种方法:标记清除法和引用计数法。其中引用计数法就存在内存泄漏的问题。

  • 意外的全局变量

    • 未声明直接赋值的变量
    • 函数中通过this为变量赋值:函数中的this在非严格模式下指向wiodow。可以通过加上 'use strict' 启用严格模式来避免这类问题, 严格模式会组织你创建意外的全局变量。
  • 闭包引起内存泄漏:闭包会保持对原来作用域的引用,导致原来的作用域不被回收,但是这是闭包的特性,是我们使用闭包时可以预料到的,应该不可以理解成内存泄漏

  • 没有清理的DOM元素引用

     var elements = {
        button: document.getElementById('button'),
        image: document.getElementById('image'),
        text: document.getElementById('text')
    };
    
    function doStuff() {
        image.src = 'http://some.url/image';
        button.click();
        console.log(text.innerHTML);
    }
    
    function removeButton() {
        document.body.removeChild(document.getElementById('button'));
    
    // 虽然我们用removeChild移除了button, 但是还在elements对象里保存着#button的引用
    // 换言之, DOM元素还在内存里面.
    }
    
  • 被遗忘的定时器或者回调

类的创建和继承

类(class)是对象的模板,定义了同一组对象(也称实例)共有的属性和方法。JS中没有类的概念,但是可以模拟出来。

function + this

function User(){
    this.name="123";
    this.getName=function(){
        return this.name;
    }	
}
//使用类
var user=new User();
console.log(user.getName());
// 123
console.log(user);
// User{ name: '123' ,getName:[Function]}
console.log(User())
// undefined

this + prototype

function User(name){
	this.name=name;
	this.age=1;
}
User.prototype={
		getName: function(){
			return this.name;
		}
} 
var  user = new User('hello');
console.log(user.getName());//访问方法
// hello

console.log(user.name);//访问属性
// hello

console.log(user.age);//访问属性
// 1

console.log(user);
// { name: 'hello', age: 1 }

Object.create()

User={
    name:'hello',
    getName:function(){
        return this.name;
    }
}

//使用
var user=Object.create(User);
console.log(user.getName());

经典继承(由构造函数继承)

function parent1(){
    this.name='parent1'
}
parent1.prototype.say=function(){
    console.log('parent1-say!!!')
}

function child1(){
    parent1.call(this);  //将parent1的this指向child1的this对象,向child1的this里边添加name为parent1,实现继承
    this.type='child1'
}

console.log(new child1()); // child1 { name: 'parent1', type: 'child1' }
  • 原型链上的东西没有被继承
  • 子类调用的时候可以向父类传参数

原型继承

function parent2(){
    this.name='parent2';
    this.paly=[1,2,3]
}

function child2(){
    this.type = 'child2'
}

child2.prototype=new parent2();

console.log(new child2());  // child2 { type: 'child2' }
console.log(new child2().name); // parent2

var str1=new child2();
var str2 = new child2()

str1.paly.push(4);

console.log('str1.paly-->',str1.paly); // str1.paly--> [ 1, 2, 3, 4 ]
console.log('str2.play-->',str2.paly); // str2.play--> [ 1, 2, 3, 4 ]

改变一个对象的属性,另一个对象的属性也会改变,prototype指向原型对象,内部的属性是共享属性。

混合继承

function Father(name){
	this.name = name;
	this.colors = ["red","blue","green"];
}

Father.prototype.sayName = function(){  //原型对象上的共享方法
	console.log(this.name);
};

function Son(name,age){
     
	Father.call(this,name);     //继承实例属性,第一次调用 Father()    
	this.age = age; // 子类自己的属性
}

// 子类和父类共享的方法(实现了父类属性和方法的复用)                              
Son.prototype = Father.prototype;   //继承父类方法,第二次调用 Father()

// 子类实例对象共享的方法
Son.prototype.sayAge = function(){
	console.log(this.age);
}

console.log(Son.prototype) //Father { sayName: [Function], sayAge: [Function] }

构造函数继承私有属性,原型继承原型链上的方法。缺点是调用两次构造函数。

深浅拷贝

拷贝,简单点说就是复制。

拷贝是针对内部属性而言的。对于最外层的对象,永远是创建的新的对象,然后将源对象内部的属性拷贝进来。

引用数据类型数据保存在堆内存中,对应引用地址保存在栈内存中。 在比较引用数据类型时比较的是引用地址。

浅拷贝

创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。

  • 如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址 ,所以如果其中一个对象改变了这个地址指向的内容,就会影响到另一个对象。
  • 对拷贝后的数据内容修改会影响原来数据。

自己实现拷贝

function clone(o) {
    const obj = {};
    for (let i in o) {
    
    <!--hasOwnProperty忽略继承属性length toString等-->
        if(o.hasOwnProperty(i)){
            obj[i] = o[i]
        }        
    }
    return obj
};
var source_obj = {
    a:1,
    b:2,
    c:{d:4}
}
var clone_obj = clone(source_obj);
clone_obj.c.d = 555;
console.log(source_obj); //{ a: 1, b: 2, c: { d: 555 } }
console.log(clone_obj);  //{ a: 1, b: 2, c: { d: 555 } }

哪些方法可以实现浅拷贝

  • Object.assign(target,source)
var target = {};
var source1 = { a:{c:3}};
var source2 = { b: 2 };

// 接收的第一个对象是拷贝目标,剩下的对象是拷贝的源对象(可以是多个)
Object.assign(target, source1, source2);
console.log(target);  //{ a: { c: 3 }, b: 2 }

target.a.c = 'change';
console.log(target); //{ a: { c: 'change' }, b: 'change' }

console.log(source1); //{ a: { c: 'change' } }
  • 展开运算符
  • Array.prototype.slice(start,end)
  • Array.prototype.concat():concat方法进行数组的合并返回一个新数组,如果我们传入的是一个空数组(即用原数组与空数组合并),那么显而易见返回的数组就是拷贝得到的数组

深拷贝

  • 将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象。
  • 对拷贝后的数据内容修改不会影响原来数据。
  • 进行无限层级的拷贝。

JSON.parse()

先将要拷贝的对象或数组传入JSON.stringify()中,此时得到了一个JSON字符串,我们把一个引用类型的数据变成了基本的字符串类型,假设此时的这个JSON字符串就叫str吧,那么再将str传入JSON.parse()中,此时str被变成了一个普通的js对象(或数组),所以会得到一个新的对象(或数组)。此数组是由字符串str新产生的,与原对象(或)数组自然没有关系,因此能实现深度克隆。

存在不足:

  • 会忽略symbol 和undefined
  • 不能序列化函数
  • 不能解决循环引用的对象
  • 不能解决对象原型链上的属性和方法

比如:

let a = {
    age: undefined,
    sex: Symbol('male'),
    jobs: function() {},
    name: 'yck'
}
let b = JSON.parse(JSON.stringify(a))
console.log(b) // {name: "yck"}

自己实现深拷贝

function deepCopy(obje) {
    if(typeof obje === 'object'){
        let afterClone = Array.isArray(obje) ? [] : {};
        for(let item in obje){
            if(obje.hasOwnProperty(item)){
                afterClone[item] = deepCopy(obje[item]);
            }
        }
        return afterClone;
    }else{
        return obje;
    }
}

var theObj = {
    name:'jerry',
    age:15,
    a:undefined,
    b:{
        c:456
    }
};

// 测试
var newObj = deepCopy(theObj);

console.log(newObj);  // { name: 'jerry', age: 15, a: undefined, b: { c: 456 } }
console.log(theObj);  // { name: 'jerry', age: 15, a: undefined, b: { c: 456 } }

newObj.b.c = 'change';  

console.log(newObj);  // { name: 'jerry', age: 15, a: undefined, b: { c: 'change' } }
console.log(theObj);  // { name: 'jerry', age: 15, a: undefined, b: { c: 456 } }

var theArr = [1,2,{d:4}];
var newArr = deepCopy(theArr);

console.log('ADS',newArr); // [ 1, 2, { d: 4 } ]

newArr[2].d =555;
console.log(theArr);  // [ 1, 2, { d: 4 } ]
console.log(newArr);  // [ 1, 2, { d: 555 } ]

// 死循环测试
var a = {};
a.a=a;

var new_a = deepCopy(a);
console.log(new_a);  //RangeError: Maximum call stack size exceeded

如何解决深拷贝循环引用问题

但是上述方法循环引用会报错,for example:

var A = {
    name:'jerry',
    age:15,
    a:undefined,
    b:{
        c:456
    }
};
A.A=A

利用Map的key可以是引用数据类型,将要拷贝的对象当做key存起来,value是深拷贝后的对象:

function deepCopy(obje,map=new Map()) {
    if(typeof obje === 'object'){
        let afterClone = Array.isArray(obje) ? [] : {};
        
        if(map.get(obje)){
		      return map.get(obje);
		  }
		  map.set(obje,afterClone);
		  
		  
        for(let item in obje){
            if(obje.hasOwnProperty(item)){
                afterClone[item] = deepCopy(obje[item],map);
            }
        }
        // return afterClone;
        return map.get(obje);
    }else{
        return obje;
    }
}

如上代码解决了循环引用的问题。

在node环境下会打印如下内容:

afterClone <ref *1> {
  name: 'jerry',
  age: 15,
  a: undefined,
  b: { c: 456 },
  A: [Circular *1]
}

node通过cirular来标识是循环引用,而浏览器会正常输出处理完的循环引用对象。

解决循环引用的原理是:在每次对复杂数据类型进行深拷贝前保存其值,如果下次又出现了该值,就不再进行拷贝,直接截止。

JS执行机制

执行上下文

执行上下文是一个抽象的概念,我理解为执行代码时的环境中。每当JS代码运行的时候,他都是在执行上下文中运行。

执行上下文分为三种:

  • 全局执行上下文:默认或者说基础的上下文,不在函数中的代码执行时在全局上下文中。一个程序只有一个全局上下文。
  • 函数执行上下文:每当调用一个函数时,会为这个函数创建一个新的上下文。函数执行上下文可以有无穷多个。
  • Eval函数执行上下文:行在 eval 函数内部的代码也会有它属于自己的执行上下文,JavaScript 开发者并不经常使用 eval。

执行栈

执行栈用来存储代码运行时创建的执行上下文。

代码开始执行时会创建全局执行上下文并压入栈底,每当引擎调用一个函数时,会为该函数创建函数执行上下文并且压入栈顶部。

引擎执行那些执行上下文位于栈顶的函数。当函数执行完毕时执行上下文从栈中弹出,控制流达到当前栈下一个执行上下文(顶部)。

执行上下文生命周期

执行上下文创建包含三个阶段:创建阶段、执行阶段、回收阶段。

创建阶段

创建阶段在函数被调用,但是还未执行任何代码之前。 主要完成三件事:创建变量对象,创建作用域链,决定this指向。

创建变量对象

变量对象中依次存储以下三个内容:

  • 函数的所有形参:创建arguments对象,用来存放形参。
  • 所有函数声明:在VO对象中以函数名建立一个属性,属性值为函数的地址。如果函数名的属性已经存在了,那么该属性将会被新的引用所覆盖
  • 所有变量声明:在变量对象中以变量名建立一个属性,属性值为undefined。如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性。

ES6支持新的变量声明方式let/const,规则与var完全不同,它们是在上下文的执行阶段开始执行的,避免了变量提升带来的一系列问题。

在了解创建变量对象的过程之前,先通过一个例子了解一下变量提升和函数提升:

变量提升和函数提升的例子:

console.log(a);  //undefined
console.log(first()); //first
console.log(second);  //undefined
  
var a = 'Hello World!';

function first() {
    return 'first'
}

var second = function () {
  return 'second'
}

console.log(second); //[Function: second]

上述代码相当于:

var a; // 变量a提升
var second; // 变量b提升,但是函数没提升
function first() {  // 整个函数被提升
    return 'first'
}
console.log(a);  //undefined
console.log(first()); //first
console.log(second);  //undefined
  
a = 'Hello World!';
second = function () {
  return 'second'
}

console.log(second); //[Function: second]

从上边也可以看出两种声明函数之间存在的差别,一个提升的是变量,另一个提升的是整个函数。

注意:当遇到函数和变量同名且都会被提升的情况,函数声明优先级比较高,因此变量声明会被函数声明所覆盖,但是可以重新赋值。

alert(a); //输出:function a(){ alert('我是函数') }
function a() {
    alert("我是函数");
} //
var a = "我是变量";
alert(a); //输出:'我是变量'

因为当 JS 解释器在遇到非匿名的立即执行函数时,会创建一个辅助的特定对象,然后将函数名称作为这个对象的属性,因此函数内部才可以访问到 foo,但是这个值又是只读的,所以对它的赋值并不生效,所以打印的结果还是这个函数,并且外部的值也没有发生更改。

var foo = 1;
(function foo() {
    foo = 10
    console.log(foo)
}())
// [Function: foo]

接下来这个例子看一看创建上下文过程到底是怎样的:

var a = 10;
function b () {
    console.log('全局的b函数')
};
function bar(a, b) {
    console.log('1', a, b) 
    var a = 1
    function b() {
        console.log('bar下的b函数')
    }
    console.log('2', a, b) 
}
bar(2, 3)
console.log('3', a, b)

/*
1 2 function b() {
        console.log('bar下的b函数')
    }
2 1 function b() {
        console.log('bar下的b函数')
    }
3 10 function b () {
    console.log('全局的b函数')
}
*/

创建过程:

// 创建阶段:
// 第一步,遇到了全局代码,进入全局上下文,此时的执行上下文栈是这样
ECStack = [
    globalContext: {
        VO: {
            // vo依次存储,优先处理全局下的b函数声明,值为该函数所在内存地址的引用
            b: <reference to function>,
            // 按顺序再处理bar函数声明,因为是在全局上下文中,并不会分析bar函数的参数
            bar: <refernce to function>,
            // 再处理变量,并赋值为undefined
            a: undefined
        }
    }
];
// 第二步,发现bar函数被调用,就又创建了一个函数上下文,此时的执行上下文栈是这样
ECStack = [
    globalContext: {
        VO: {
            b: <reference to function b() {}>, 
            bar: <refernce to function bar() {}>,
            a: undefined
        }
    },
    <bar>functionContext: {
        VO: {
            //vo依次存储,优先分析函数的形参
            arguments: {
                0: 2,(a)
                1: 3,(b)
                length: 2,
                callee: bar
            },
            a: 2,
            // b: 3,
            // 再分析bar函数中的函数声明b,并且赋值为b函数所在内存地址的引用, 它发现VO中已经有b:3了,就会覆盖掉它。因此上面一行中的b:3实际上不存在了。
            b: <refernce to function b() {}>
            // 接着分析bar函数中的变量声明a,并且赋值为undefined, 但是发现VO中已经有a:2了,因此下面一行中的a:undefined也是会不存在的。
            // a: undefined
        }
    }
]

执行阶段

  • 变量赋值
  • 函数引用
  • 执行其他代码

上述例子执行阶段为:

// 执行阶段:
// 第三步:首先,执行了bar(2, 3)函数,紧接着,在bar函数里执行了console.log('1', a, b)。全局上下文中依然还是VO,但是函数上下文中VO就变成了AO。并且代码执行到这,就已经修改了全局上下文中的变量a.
ECStack = [
    globalContext: {
        VO: {
            b: <reference to function b() {}>, 
            bar: <refernce to function bar() {}>,
            a: 10,
        }
    },
    <bar>functionContext: {
        AO: {
            arguments: {
                0: 2,
                1: 3,
                length: 2,
                callee: bar
            },
            a: 2,
            b: <refernce to function b() {}>
        }
    }
]

// 因此会输出结果: '1', 2, function b() {console.log('bar下的b函数')};


// 第四步:执行console.log('2', a, b)的时候, 发现里面的变量a被重新赋值为1了。
ECStack = [
    globalContext: {
        VO: {
            b: <reference to function b() {}>, 
            bar: <refernce to function bar() {}>,
            a: 10,
        }
    },
    <bar>functionContext: {
        AO: {
            arguments: {
                0: 2,
                1: 3,
                length: 2,
                callee: bar
            },
            a: 1,
            b: <refernce to function b() {}>
        }
    }
]
// 因此会输出结果: '2', 1, function b() {console.log('bar下的b函数')};

// 第五步,执行到console.log('3', a, b)的时候,ECStack发现bar函数已经执行完了,就把bar从ECStack给弹出去了。此时的执行上下文栈是这样的。

ECStack = [
    globalContext: {
        VO: {
            b: <reference to function b() {}>, 
            bar: <refernce to function bar() {}>,
            a: 10,
        }
    }
]

// 因此会输出结果: '3', 10, function b() {console.log('全局的b函数')}

上边我们会发现执行函数代码的时候,VO会变成AO。

VO是变量对象,AO是活动对象。

只有全局上下文中的变量对象允许通过VO属性名称来间接访问,函数上下文中,不能直接访问VO对象。

函数执行上下文中,VO不能直接访问,此时由活动对象AO继续扮演VO的角色。 未进入执行阶段前,变量对象中的属性都不能访问!但是进入到执行阶段之后,变量对象转变成了活动对象,里面的属性都能被访问了,然后开始进行执行阶段的操作。

因此,对于函数上下文来讲,活动对象与变量对象其实都是同一个对象,只是处于执行上下文的不同生命周期。不过只有处于执行上下文栈栈顶的函数执行上下文中的变量对象,才会变成活动对象。全局上下文的变量对象有一个特殊的地方,即它的变量对象就是window对象,全局上下文的变量对象不能变成活动对象。

回收阶段

执行完毕出栈,等待被销毁。

JS执行机制

JS是单线程的语言,单线程就是就是指一次只能完成一件任务。如果有多个任务,就必须排队,前面一个任务完成,再执行后面一个任务。比如:早上上班的时候你正要打卡,这时候你来电话了,你先接电话后打卡,这就是单线程。边接电话边打卡,这就是多线程,但最终两者的结果是一样的。

单线程存在的缺点是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。常见的浏览器无响应(假死),为解决这个问题JS将任务执行分为两种模式:同步和异步。

同步:同步是指当程序1调用程序2时,程序2返回结果后才继续向下执行程序1的步骤。 异步是指当程序1调用程序2时,不等程序2返回结果就继续向下执行程序1。

  • 同步任务和异步任务会进行到不同的执行“场所”,同步任务进行到主线程,异步进入Event Table并注册函数。
  • 当指定的事情完成时,Event Table会将这个函数移入Event Queue
  • 主线程内的任务执行完毕为空时会去Event Queue读取对应的函数,进入主线程执行
  • 上述过程不断重复,也就是常说的事件循环

以下代码为例:

setTimeout(function(){
    console.log('执行延时函数');
},3000);

console.log('代码执行完毕')

/* 执行结果:
代码执行完毕
执行延时函数
*/

上述代码中,setTimeout是异步任务,进入Event Table,满足延时3秒后,把要执行的任务加入到Event Queue中。又因为是单线程任务要一个个执行,如果前面的任务需要的时间太长,那么只能等着(即使超过)。

所以我们可以总结出来,定时器执行的条件是

  • 延迟时间足够
  • 主线程空闲

那么接下来观察一段代码:

setTimeout(()=>{
console.log("定时器开始执行");
})
 
new Promise(function(resolve){
    console.log("准备执行for循环了");
    for(var i=0;i<100;i++){
        i==22&&resolve();
    }
}).then(()=>console.log("执行then函数"));
 
console.log("代码执行完毕");

按照我们之前来分析:

  1. setTimeout 是异步任务,被放到event table
  2. new Promise 是同步任务,被放到主进程里,直接执行打印 '准备执行for循环了'
  3. then里的函数是异步任务,被放到event table
  4. console.log('代码执行结束')是同步代码,被放到主进程里,直接执行

所以最后的执行结果是:【准备执行for循环-->代码执行完毕-->定时器开始执行-->执行then函数 】

但是正确的结果是:【准备执行for循环–>代码执行完毕–>执行then函数–>定时器开始执行】

原来上述划分并不准确。正确划分如下:

宏任务、微任务

  • 宏任务:Script (整体代码),setTimeout, setInterval, setImmediate, I/O, UI rendering.
  • 微任务:process.nextTick, Promise(原生),Object.observe,MutationObserver

不同类型的任务会进入对应的Event Queue,比如setTimeout和setInterval会进入相同的Event Queue;Promise,process.nextTick会进入相同的Event Queue。

首先会从整体的Script代码进行执行,遇到宏任务加入到宏任务队列,遇到微任务加入到微任务队列,宏任务执行完毕后会看微任务队列中有没有待执行的任务,有的话取出全部的微任务进行执行,之后看有没有待执行的宏任务,有的话取出一个宏任务执行,执行完毕后再取出全部的微任务....一直循环下去

事件循环的顺序决定代码执行的顺序。

JS面试题(一)

console.log('1');

setTimeout(function () {
    console.log('2');
    process.nextTick(function () {
        console.log('3');
    })
    new Promise(function (resolve) {
        console.log('4');
        resolve();
    }).then(function () {
        console.log('5')
    })
})

process.nextTick(function () {
    console.log('6');
})

new Promise(function (resolve) {
    console.log('7');
    resolve();
}).then(function () {
    console.log('8')
})

setTimeout(function () {
    console.log('9');
    process.nextTick(function () {
        console.log('10');
    })
    new Promise(function (resolve) {
        console.log('11');
        resolve();
    }).then(function () {
        console.log('12')
    })
})

// 1 7 6 8 2 4 3 5 9 11 10 12
setTimeout(() => {
    console.log("定时器开始执行");
})

new Promise(function (resolve) {
    console.log("准备执行for循环了");
    resolve()
}).then(() => console.log("执行then函数"));

console.log("代码执行完毕");

/*
准备执行for循环了
代码执行完毕
执行then函数
定时器开始执行
*/
setTimeout(() => {
    console.log("定时器开始执行");
})
new Promise(function (resolve) {
    console.log("准备执行for循环了");
    resolve()
}).then(() => console.log("执行then函数"));

process.nextTick(function () {
    console.log('4');
})
process.nextTick(function () {
    console.log('5');
})
process.nextTick(function () {
    console.log('6');
})
console.log("代码执行完毕");

/*
准备执行for循环了
代码执行完毕
4
5
6
执行then函数
定时器开始执行
*/

上边代码可以看出,4、5、6是比then后进入微任务的,却是先执行的。

对比以下代码:

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

new Promise(function (resolve) {
    console.log('promise');
    resolve()  // 返回resolve
}).then(function () {
    console.log('then');
})

console.log('console');

<!--promise-->
<!--console-->
<!--then-->
<!--setTimeout-->

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

new Promise(function (resolve) {
    console.log('promise');
    // resolve()
}).then(function () {
    console.log('then');
})

console.log('console');

<!--promise-->
<!--console-->
<!--setTimeout-->

由此可见,只有返回resolve之后,then才会被添加到微任务队列中。

process.nextTick 优先级高于 Promise。 setTimeout的优先级高于setIImmediate。