JS面试总结

86 阅读37分钟

浏览器内核和JS引擎

当我们在浏览器中输入地址的时候,都发生了什么?

当我们在浏览器中输入地址的时候,服务器会给我们返回index.html文件,浏览器内核在解析index.html文件的时候,遇到link标签会下载css文件,遇到script标签的时候会下载js文件,这时候我们需要的css和js代码都已经被下载下来了。浏览器内核会将html文件和css文件渲染成DOM树,然后布局,最后显示到界面上。如果遇到js代码,就需要js引擎来执行代码,其中最著名的就是V8引擎。

浏览器内核和JS引擎的关系

我们以WebKit为例, WebKit实际上由两部分组成:

  • WebCore: 负责HTML解析, 布局, 渲染等工作
  • JavaScriptCore: 解析 执行JS代码

一个比较强大的JS引擎就是V8引擎。

V8引擎原理

V8引擎会先将JS代码经过词法分析、语法分析解析(通过Parse模块)成AST语法树,然后再转成字节码(通过Ignition模块),字节码是跨平台的,最后运行的时候再将字节码转成汇编代码,然后再转成机器码。

JS代码 -> AST语法树 -> 字节码 -> 汇编 、机器码

V8引擎原理有哪些优化?

  1. 如果某个函数是经常调用的,V8引擎(通过TuboFan模块)会将字节码直接转成机器码,这样执行函数的时候效率会高很多。
  2. 因为JS没有类型检测,如果函数传入的参数不一样了,V8引擎检测到以后,会将机器码再转成字节码(Deoptimization)(比如:sum(1,2),本来是传数字,后来我们传字符串了sum('a'+'b'))。

为什么需要预解析呢?

  • 这是因为并不是所有的JavaScript代码,在一开始时就会被执行,如果对所有的JavaScript代码进行解析,必然会影响网页的运行效率;所以V8引擎就实现了Lazy Parsing(延迟解析)的方案,它的作用是将不必要的函数进行预解析,也就是只解析暂时需要的内容,而对函数的全量解析是在函数被调用时才会进行。
  • 比如我们在一个函数outer内部定义了另外一个函数inner,那么inner函数就会进行预解析。

JS代码执行的详细过程

var name = 'why'
var num = 10

JS代码的执行分为解析阶段和代码执行阶段。

解析阶段:也就是上面的Parse阶段

  1. 初始化全局对象: V8引擎在解析的时候,会在堆内存中创建一个全局对象:Global Object(GO), 该对象所有的作用域(scope)都可以访问,其中还有一个window属性指向自己;
  2. 将全局定义的变量加入到GlobalObject中(这时候的VO就是GO),只会提升声明,但是并不会赋值,默认赋值是undefined,这个过程也称之为变量的作用域提升(hoisting)
  3. 如果是全局函数,函数的名称也会被加入到GO中,而且V8默认会再开启一块空间用于存储函数,这时候GO中函数默认赋值的是函数储存空间的地址。函数储存空间存储的是 1. 函数体 2. 父级作用域。
  4. 解析完成后转成AST语法树 -> 字节码 -> 汇编、机器码

代码执行阶段

  1. 首先为了执行代码,v8引擎内部会有一个执行上下文栈(Execution Context Stack)(函数调用栈)
  2. 如果需要执行全局代码,需要创建全局执行上下文(Global Execution Context),全局执行上下文中有一个VO(Variable Object),对于全局执行上下文VO就是GO,然后将GEC放到ECS中。
  3. 然后开始执行代码
    • 如果是变量的赋值,就直接将将GO里面的变量赋值。
    • 如果是函数的调用,就先根据函数的地址查找函数,然后创建函数执行上下文(Functional Execution Context,简称FEC),并且压入到ECStack中。
      • 函数执行上下文中也有个VO,对应的是AO(Activation Object),AO中保存函数的参数和内部的变量,默认是undefined。
      • 然后才开始真正执行函数,将AO中保存函数的参数和内部的变量进行赋值。
      • 函数调用完毕,FEC会出栈,AO被销毁。

函数执行上下文包含三部分内容:

  1. 第一部分:在解析函数成为AST树结构时,会创建一个Activation Object(AO):AO中包含函数体、形参、arguments、函数里面定义的变量;
  2. 第二部分:作用域链:由VO(在函数中就是AO对象)和父级VO组成,查找时会一层层查找,这就是作用域链,如果找不到就是undefined;
  3. 第三部分:this绑定的值,this绑定的值是在运行时才决定的;

VO -> GO

VO -> AO

提示:ES6之后,VO改名成VE了,也就是变量环境的意思。

JS代码执行分析

  1. 首先解析的时候,会创建GO,这时候GO里面有
name:undefined
foo:0xa00
  1. 然后代码执行,GO中的name会被赋值为why
  2. 遇到foo函数调用时,会先解析函数,先创建函数执行上下文,函数执行上下文中有AO,然后将函数里面的参数、变量提升到AO对象里面,默认是undefined
num:undefined
m:undefined
n:undefined
bar:0xb00
  1. 然后才开始执行函数,执行到第5行,打印m为undefined,然后将AO对象里面的变量赋值
num:123
m:10
n:20
bar:0xb00
  1. 执行到13行又是函数调用,同样会创建函数执行上下文,函数执行上下文中也有AO
  2. 然后调用函数bar(),bar函数对应的AO中没有name,所以去上层作用域找,最后找到GO中的why

通过上面流程我们知道,变量定义之前打印变量是undefined,因为解析的时候就是undefined,还没赋值呢。提前调用函数是可以的,因为在解析的时候函数已经被放到GO中了。

JS代码执行分析题目

var n = 100

function foo() {
  n = 200
}

foo()

console.log(n) //200

1. 首先解析之后,GO里面的变量是n:undefined,foo:0xaoo
2. 然后执行代码,GO里面的n赋值为100
3. 执行函数,函数里面没有变量
4. 最后执行n = 200,函数对应的AO里面没有n,所以会去GO里面找,然后把n赋值为200,所以打印200
function foo() {
  console.log(n) //打印的是AO的n,是undefined
  var n = 200
  console.log(n) //打印AO里面的n,是200
}

var n = 100
foo()
var a = 100

function foo() {
  console.log(a) //打印undefined 解析的时候,AO里面是有个a为undefined
  return // 代码执行的时候遇到return才会停止,解析的时候不会停止
  var a = 200 
}

foo()
function foo() {
  m = 100 //这种语法一般是错误的,因为要写成 var m = 100
  // 但是js引擎遇到这种语法的时候,会把它放到全局对象里面,也就是GO,所以打印100
}

foo()
console.log(m) //100
function foo() {
  var a = b = 10
  // => 转成下面的两行代码
  // var a = 10
  // b = 10
}

foo()

console.log(a) //GO中没有a,所以打印undefined
console.log(b) //GO中有b,所以打印10

当GO中有同名的函数和变量的时候,此时优先赋值函数,如下:

console.log(a);
function a() {
  console.log('aaaaa');
}
var a = 1;
console.log(a);

1. 解析的时候GO中有a:undefined,a:0xa00
2. 执行的时候打印a,函数优先,所以打印函数
3. 最后将a赋值为1,所以打印1

打印:
/*
ƒ a() {
  console.log('aaaaa');
}
1
*/

JS内存管理

JS定义的变量如何分配内存?

  • JS对于基本数据类型内存的分配会在执行时,直接在栈空间进行分配;
  • JS对于复杂数据类型内存的分配会在堆内存中开辟一块空间,会有一个指针指向这个内存空间,指针是保存在栈中。
  • 堆,谐音队列,先排队的肯定先处理,所以先进先出。栈,谐音站,都站住了,肯定先进后出。

常见的GC算法

  1. 引用计数: 通过引用计数器,当引用计数器为0,就会被回收。缺点: 容易造成循环引用
  2. 标记清除: 这个算法是设置一个根对象(root object),垃圾回收器会定期从根对象开始,找所有从根开始有引用到的对象,对于哪些没有引用到的对象,就认为是不可用的对象;这个算法可以很好的解决循环引用的问题;也是JS引擎使用比较广泛的一种方式

数组有哪些原生方法

改变原数组:

  • push:数组后面增加一个元素
  • pop:数组后面删除一个元素
  • unshift:数组前面新增元素
  • shift:数组前面删除一个元素
  • splice:替换数组的元素,有三个参数array.splice(index,howmany,item1,.....,itemX),表示从下标多少,替换几个元素,替换为什么
  • sort:数组排序,默认按字母升序排序,当传入函数的时候可以按数字升序或降序排序
  • reverse:数组翻转

不改变原数组,返回新值:

  • concat:连接数组,将其他元素或者数组的元素拼接到数组的后面,然后返回新数组
  • slice:截取数组,array.slice(start, end),将包含start不包含end的元素截取作为一个新数组返回
  • join:传入分隔符,将数组中的元素通过指定分隔符拼接,然后返回字符串
  • filter:传入一个函数,过滤数组,函数返回值为false就过滤掉,返回新数组
  • map:传入一个函数,将数组中的元素操作一下返回,将所有的返回值作为一个新数组返回
  • find:传入一个函数,查找数组中的某个元素,如果函数返回true,最后会返回这个元素
  • findIndex:传入一个函数,查找数组中的某个元素,如果函数返回true,最后会返回这个元素的索引值
  • forEach:传入一个函数,遍历数组中的元素
  • every:传入一个函数,用来检测数组中的所有元素是否都满足某个条件,如果都满足,返回true,否则返回false
  • some:传入一个函数,如果数组中有一个元素满足条件就返回true,如果都不满足就返回false
  • reduce:传入两个参数,第一个参数是个函数,第二个参数是初始值可以不传,函数的第一个参数是上次执行的结果,array.reduce(function(total, currentValue, currentIndex, arr), initialValue),可以用来做累加

什么是闭包

一个函数,如果访问了外层作用域的变量,那么这个函数就是闭包。

:为什么说闭包有内存泄露的问题? 怎么解决?

比如如下内层函数访问了外层函数的变量,就形成了闭包.

function foo() {
  var name = "foo"
  var age = 18

  function bar() {
    console.log(name)
    console.log(age)
  }
  return bar
}

var fn = foo()
fn()

这时候执行完代码之后,内存结构如下:

引用关系是:

  1. GO对象引用了外层函数和外层函数,外层函数对应的AO对象引用了内层函数
  2. 外层函数的父作用域指向GO对象,内层函数的父作用域指向了外层函数对应的AO对象

根本原因就是GO对象引用了内层函数,内层函数的父级作用域引用了外层函数对应的AO对象,导致其无法被释放。

就算没有var name = "foo"这些代码,内层函数的父级作用域也是引用了外层函数对应的AO对象,只不过这时候AO对象是空对象。

解决办法:

fn = null
foo = null

补充:如果在内层函数中注释掉console.log(age),V8引擎为了性能考虑,对于AO对象中没被使用的属性,会被销毁的。

JS中this的指向

在全局作用域下的this

  • 对于浏览器,this绑定的就是window。
  • 对于Node,this绑定的是空对象{}。这是因为在Node中一个js文件就是一个模块,Node会加载这个模块,编译其中的代码,然后将编译之后的代码放到一个函数中,然后这个函数再通过call({})调用,所以最后绑定的this是空对象。

函数中this的四种绑定规则

函数在调用时,JS会默认给this绑定一个值,this是在运行时被绑定的,和函数定义的位置没关系。

  1. 默认绑定;独立的函数调用我们可以理解成函数没有被绑定到某个对象上进行调用, 这时候this就是window
  2. 隐式绑定;通过某个对象进行调用的,这时候函数中的this会被JS引擎隐式绑定到对象上
  3. 显示绑定;就是函数通过call、apply、bind进行调用
  4. new绑定;JS中的函数可以当做一个类的构造函数来使用,也就是使用new关键字, 这时候函数中的this就是创造出的那个对象

call、apply、bind的区别

首先,三种方法都是继承自Function.prototype的。

  1. call: 函数实例调用call方法会立即执行该函数
    • 第一个参数是指定函数内部中this的指向,参数是必须的,可以是null,undefined,但是不能为空。设置为null,undefined,this表明函数此时处于全局作用域,也就是this指向window。apply、bind也是一样。
    • 第二个参数是函数调用时需要传递的参数,必须一个一个添加
    • 经常用来做继承
  2. apply: 也会立即执行该函数, 第一个参数也是this,第二个参数必须使用数组传递
    • 经常和数组有关系, 比如找出数组中的最大值
      a var arr = [1, 66, 3, 99, 4];
      var max = Math.max.apply(Math, arr); 
      console.log(max);  // 99
      //这样就不用一个一个遍历了,直接使用数学方法就可以了 
      
  3. bind: 不调用函数,返回绑定this之后新函数的拷贝
    • 第一个参数是this, 第二个参数是第二个参数是函数调用时需要传递的参数, 必须一个一个添加
    • 不调用函数,但是还想改变this指向的场景

四种绑定规则的优先级

  1. new绑定 > 显示绑定 > 隐式绑定 > 默认绑定
  2. new绑定和call、apply是不允许同时使用的(因为它们都是调用一个函数),new绑定可以和bind一起使用。

关于剪头函数

  • 箭头函数不会绑定this、arguments属性,所以如果想找this、arguments会去上层作用域查找。
  • 箭头函数是没有显式原型的,所以不能作为构造函数,不能使用new来创建对象。

箭头函数的应用场景:使用箭头函数代替以前的var that = this代码,直接去上层作用域找this。

一些函数的this分析

  • 定时器的回调函数是通过fn.apply(window, ,)方式调用的,所以定时器中的回调函数中的this绑定是window
  • 元素的onclick回调函数是直接通过div.onclick()调用的,是隐式绑定,所以this是这个元素
  • 如果元素是通过addEventListener添加的回调,会被放到一个数组中,最后遍历数组,通过fn.call(div)调用,所以this绑定这个元素
  • 数组的一些高阶函数,比如forEach、map,第一个参数是函数,第二个参数是thisArgs(可选),如果第二个参数没传,就相当于独立函数调用,第一个函数参数中的this就是window。如果第二个参数传了,就会把第二个参数绑定为this到第一个函数参数中,类似call的调用。

this面试题

面试题1:

var name = "window";

var person = {
  name: "person",
  sayName: function () {
    console.log(this.name);
  }
};

function sayName() {
  var sss = person.sayName;
  sss(); // window: 默认绑定, 独立函数调用
  person.sayName(); // person: 隐式绑定
  (person.sayName)(); // person: 隐式调用,前面的()加不加都一样的
  (b = person.sayName)(); // window: 赋值表达式会返回sayName函数(独立函数调用)
}

sayName();

面试题2:

var name = 'window'

var person1 = {
  name: 'person1',
  foo1: function () {
    console.log(this.name)
  },
  foo2: () => console.log(this.name),
  foo3: function () {
    return function () {
      console.log(this.name)
    }
  },
  foo4: function () {
    return () => {
      console.log(this.name)
    }
  }
}

var person2 = { name: 'person2' }

场景1:对象里面的函数调用
person1.foo1(); // 对象调用是隐式绑定,所以person1
person1.foo1.call(person2); // 显示绑定优先级大于隐式绑定,所以person2

场景2:对象里有个箭头函数
person1.foo2(); // 拿到箭头函数直接调用,所以window(不绑定作用域,上层作用域是全局)
person1.foo2.call(person2); // 拿到箭头函数通过call调用,所以还是window

场景3:foo3函数会返回一个函数
person1.foo3()(); // 拿到foo3的结果,是个函数,进行调用,所以是window(独立函数调用)
person1.foo3.call(person2)(); // foo3外层函数的this是person2,但是返回了一个函数,进行调用,所以还是window(独立函数调用)
person1.foo3().call(person2); // 外层函数返回了一个函数,通过call显示绑定,所以是person2

场景3:foo4函数会返回一个箭头函数
person1.foo4()(); // 返回的箭头函数直接调用,箭头函数不绑定this, 上层作用域this是person1,所以是person1 
person1.foo4.call(person2)(); // 上层作用域被显示的绑定了一个person2,返回的箭头函数调用,会去上层作用域找,所以是person2
person1.foo4().call(person2); // 返回的箭头函数通过call调用,会去上层作用域找,所以是person1

面试题3:

var name = 'window'

function Person (name) {
  this.name = name
  this.foo1 = function () {
    console.log(this.name)
  },
  this.foo2 = () => console.log(this.name),
  this.foo3 = function () {
    return function () {
      console.log(this.name)
    }
  },
  this.foo4 = function () {
    return () => {
      console.log(this.name)
    }
  }
}

var person1 = new Person('person1')
var person2 = new Person('person2')

场景1:对象里面的函数调用
person1.foo1() // 对象直接调用,是隐式绑定,所以是person1
person1.foo1.call(person2) // 显示高于隐式绑定,所以是person2

场景2:对象里有个箭头函数
person1.foo2() // 拿到箭头函数直接调用,去上层作用域找,上层作用域是构造函数,所以是person1
person1.foo2.call(person2) // 拿到箭头函数通过call调用,和上面一样,所以是person1
 
场景3:foo3函数会返回一个函数
person1.foo3()() // 拿到返回的函数进行调用,是独立函数调用,所以是window
person1.foo3.call(person2)() // 通过call拿到返回的函数进行调用,是独立函数调用,所以是window
person1.foo3().call(person2) // 拿到返回的函数通过call调用,所以是person2

场景3:foo4函数会返回一个箭头函数
person1.foo4()() // 返回的是个箭头函数,所以去上层作用域找,上层作用域是foo4,foo4绑定是person1,所以是person1
person1.foo4.call(person2)() // 通过call调用显示给foo4绑定person2,最后返回了箭头函数然后调用,所以最后是person2
person1.foo4().call(person2) // 返回箭头函数,通过call调用,去上层找,找到了person1

面试题4:

var name = 'window'

function Person (name) {
  this.name = name
  this.obj = {
    name: 'obj',
    foo1: function () {
      return function () {
        console.log(this.name)
      }
    },
    foo2: function () {
      return () => {
        console.log(this.name)
      }
    }
  }
}

var person1 = new Person('person1')
var person2 = new Person('person2')

场景1:返回函数
person1.obj.foo1()() // 拿到返回的函数直接调用,是独立函数调用,所以是window
person1.obj.foo1.call(person2)() // 通过call拿到返回的函数直接调用,是独立函数调用,所以是window
person1.obj.foo1().call(person2) // 拿到返回的函数,通过call调用,所以是person2

场景2:返回箭头函数
person1.obj.foo2()() // 返回的是箭头函数,所以去上层作用域找,上层作用域是foo2,foo2是被obj调用的,所以是obj
person1.obj.foo2.call(person2)() // 返回的是箭头函数,所以去上层作用域找,上层作用域foo2被显示绑定了person2,所以是person2
person1.obj.foo2().call(person2) // 返回箭头函数通过call调用,所以去上层作用域找,上层作用域是obj,所以是obj

补充:分号引起的问题

// 争论: 代码规范 ;
var obj1 = {
  name: "obj1",
  foo: function() {
    console.log(this)
  }
}

var obj2 = {
  name: "obj2"
}; // 这里要加分号

(obj2.bar = obj1.foo)() // 打印window

// 1. 首先obj2.bar = obj1.foo表达式会返回foo函数,然后我们调用foo函数是没问题的
// 2. 但是如果上面不加分号,代码会报错,因为JS引擎会把
var obj2 = {
  name: "obj2"
}(obj2.bar = obj1.foo)当成一个整体然后调用,所以会报错,加上分号就不会报错了
// 3. 加上分号后不报错,对于foo函数的调用就是独立函数的调用,所以上面打印window

同理,你不知道的JavaScript这本书中也有如下代码:

function foo(el) {
  console.log(el,this.id)
}

var obj = {
  id:'awesome'
}

[1,2,3].forEach(foo,obj) // 代码会报错

// 因为没有分号,所以JS引擎会把
var obj = {
  id:'awesome'
}[1,2,3]
// 当成一个整体,所以会报错

// 两个解决办法,如下:

function foo(el) {
  console.log(el,this.id)
}

var obj = {
  id:'awesome'
}; // 办法一:加上分号

var nums = [1,2,3] // 办法二:将数组通过一个变量接收,就不会报错了
nums.forEach(foo,obj)

自定义call、apply、bind的实现

自定义call的实现
// 给所有的函数添加一个hycall的方法
// 参数使用ES6的剩余参数,args是个数组
Function.prototype.hycall = function(thisArg, ...args) {
  // 1.获取需要被执行的函数
  var fn = this

  // 2.对thisArg转成对象类型(防止它传入的是非对象类型,比如数字)
  thisArg = (thisArg !== null && thisArg !== undefined) ? Object(thisArg): window

  // 3.将需要调用的函数添加到thisArg上,然后再调用
  thisArg.fn = fn
  var result = thisArg.fn(...args) // 展开运算符展开数组
  delete thisArg.fn  // 调用后再删除这个属性

  // 4.将最终的结果返回出去
  return result
}
自定义apply的实现
// 自己实现hyapply
Function.prototype.hyapply = function(thisArg, argArray) {
  // 1.获取到要执行的函数
  var fn = this

  // 2.处理绑定的thisArg
  thisArg = (thisArg !== null && thisArg !== undefined) ? Object(thisArg): window

  // 3.执行函数
  thisArg.fn = fn
  // 如果第二个参数没传,默认就是undefined,解构的时候会出错,所以第二个参数不传的时候我们给空数组
  // argArray = argArray ? argArray: []
  argArray = argArray || []
  var result = thisArg.fn(...argArray)
  delete thisArg.fn

  // 4.返回结果
  return result
}
自定义bind的实现
Function.prototype.hybind = function(thisArg, ...argArray) {
  // 1.获取到真实需要调用的函数
  var fn = this

  // 2.绑定this
  thisArg = (thisArg !== null && thisArg !== undefined) ? Object(thisArg): window

  function proxyFn(...args) {
    // 3.将函数放到thisArg中进行调用
    thisArg.fn = fn
    // 特殊: 对两个传入的参数进行合并,bind传的参数在前面,新函数传的参数在后面
    var finalArgs = [...argArray, ...args]
    var result = thisArg.fn(...finalArgs)
    delete thisArg.fn

    // 4.返回结果
    return result
  }

  return proxyFn
}

上面代码其实还可以优化,因为传入的对象中有可能也有fn,我们可以使用Symbol来解决。

关于arguments

什么是arguments

  1. arguments是函数中的一个类数组(array-like)对象,保存在函数对应的AO对象中。arguments可以可以获取函数参数的一些信息,常见的操作有:
    • 参数的长度
    • 根据索引获取参数
    • 根据arguments.callee获取参数所在的函数
  2. array-like意味着它不是一个数组类型,而是一个对象类型,但是它却拥有数组的一些特性,比如说length,比如可以通过index索引来访问。但是它却没有数组的一些方法,比如forEach、map等。我们可以通过遍历或者Array.from(arguments)将类数组转成数组。
  3. 箭头函数是不绑定arguments的,所以我们在箭头函数中使用arguments会去上层作用域查找。

将类数组转成数组的三种方式:

function foo(num1, num2) {
  // 方式1: 通过遍历
  var newArr = []
  for (var i = 0; i < arguments.length; i++) {
    newArr.push(arguments[i] * 10)
  }
  console.log(newArr)

  // 方式2.1: 通过slice方法
  // Array.prototype.slice将arguments转成array
  // 因为slice方法的内部就是通过遍历arguments,然后返回新数组
  var newArr2 = Array.prototype.slice.call(arguments)
  console.log(newArr2)

  // 方式2.2: 当然我们这样写也是可以的
  var newArr3 = [].slice.call(arguments)
  console.log(newArr3)

  // 方式3.1: ES6的语法
  var newArr4 = Array.from(arguments)
  console.log(newArr4)
  // 方式3.2: ES6的语法,通过数组结构
  var newArr5 = [...arguments]
  console.log(newArr5)
}

注意:箭头函数是不绑定arguments的,所以我们在箭头函数中使用arguments会去上层作用域查找。

注意:在全局作用域下:

  • 对于浏览器,没有arguments
  • 对于Node,有arguments。这是因为在Node中一个js文件就是一个模块,Node会加载这个模块,编译其中的代码,然后将编译之后的代码放到一个函数中,然后这个函数再通过call({})调用,所以最后绑定的this是空对象。通过call({})调用函数的时候,后面的参数就会被放到这个函数对应的arguments里面。

由于全局作用域下没有arguments,如果我们又想拿到所有参数,怎么办呢?

可以使用...args剩余参数,所以ES6之后我们推荐使用剩余参数代替arguments。

剩余参数和arguments有什么区别?

  1. 剩余参数只包含那些没有对应形参的实参,而 arguments 对象包含了传给函数的所有实参;
  2. arguments对象不是一个真正的数组,而剩余参数是一个真正的数组,可以进行数组的所有操作;
  3. arguments是早期的ECMAScript中为了方便去获取所有的参数提供的一个数据结构,而剩余参数是ES6中提供并且希望以此来替代arguments的;
  4. 剩余参数必须放到最后

JS函数式编程

  • 为什么说JS中函数是一等公民?

一等公民就是非常重要的意思,JS中的函数可以作为另外一个函数的参数,也可以作为另外一个函数的返回值来使用,也自己编写高阶函数,或者使用内置的高阶函数。

  • 什么是高阶函数?

把一个函数如果接受另外一个函数作为参数, 或者该函数会返回另外一个函数作为返回值的函数, 那么这个函数就称之为是一个高阶函数。

  • 什么是纯函数?

纯函数有两个条件:1. 相同的输入,一定会产生相同的输出 2. 纯函数在执行过程中,不能产生副作用。

副作用就是比如修改了全局变量,修改参数等等。

splice不是一个纯函数,因为会对原数组进行修改。slice就是一个纯函数,不会修改传入的参数,所以是个纯函数。

  • 纯函数的优势?

有了纯函数我们只需要关心函数的参数和返回值,不用关心函数引用了外部什么变量以及函数是否修改了参数等信息,这样我们可以有更多的精力放到业务上来。

  • 什么是函数的柯里化?

函数的柯里化就是把接收多个参数的函数,转成几个函数,一个函数只接收一个参数或者部分参数,然后返回这个函数去处理剩余的参数的技术。

  • 函数的柯里化的好处
  1. 柯里化可以让函数的职责单一,也就是一个函数处理一个参数,而不是一个函数处理完所有参数,函数处理完一个参数后,剩下的参数会由新函数再处理
  2. 柯里化的函数可以更好的让我们来复用。最经典的就是makeAdder的例子。
// function sum(m, n) {
//   return m + n
// }

// // 假如在程序中,我们经常需要把5和另外一个数字进行相加
// console.log(sum(5, 10))
// console.log(sum(5, 14))
// console.log(sum(5, 1100))
// console.log(sum(5, 555))

function makeAdder(count) {
  count = count * count

  return function(num) {
    return count + num
  }
}

// var result = makeAdder(5)(10)
// console.log(result)
var adder5 = makeAdder(5)
adder5(10)
adder5(14)
adder5(1100)
adder5(555)
// 这时候adder5其实就是对count = count * count的复用
  • 什么是组合函数?

组合(Compose)函数是在JavaScript开发过程中一种对函数的使用技巧、模式。比如我们现在需要对某一个数据进行函数的调用,执行两个函数fn1和fn2,这两个函数是依次执行的,前面函数接受参数,调用后返回值会作为后面函数的参数。那么如果每次我们都需要进行两个函数的调用,操作上就会显得重复,我们可以将这两个函数组合起来,自动依次调用,这个过程就是对函数的组合,最后返回的新函数称之为组合函数(Compose Function)。

JS面向对象

属性描述符

当我们直接将属性定义到对象内部, 我们就无法对属性进行精准控制, 比如无法控制属性是否可以删除, 是否可以遍历, 如果我们将精准控制属性, 就需要属性描述符, 我们可以使用Object.defineProperty定义属性描述符。

Object.defineProperty() 方法接收三个参数: 分别是1. 定义属性的对象 2. 属性的key 3. 属性描述符,是个对象

属性描述符分为两种:数据属性描述符、存取属性描述符

  1. 数据属性描述符,有4个值:
  • [[Configurable]]:设置为false表示属性不可删除,也不可重新定义属性描述符
  • [[Enumerable]]:设置为false表示属性不可通过for-in或者Object.keys()遍历的到该属性;
  • [[Writable]]:设置为false表示不可修改属性的值;
  • [[value]]:属性的value值,读取属性时会返回该值,修改属性时,会对其进行修改;
  • 当我们直接在一个对象上定义某个属性时,这个属性的Configurable、Enumerable、Writable默认是true、value默认是我们赋值的value。
  • 当我们通过属性描述符定义一个属性时,这个属性的Configurable、Enumerable、Writable默认是false、value默认是undefined。
  1. 存取属性描述符,有4个值:
  • [[Configurable]]:设置为false表示属性不可删除,也不可重新定义属性描述符
  • [[Enumerable]]:设置为false表示属性不可通过for-in或者Object.keys()遍历的到该属性;
  • [[get]]:获取属性时会执行的函数。默认为undefined
  • [[set]]:设置属性时会执行的函数。默认为undefined

存取属性描述符使用场景:

  1. 隐藏某一个私有属性不希望直接被外界使用和赋值
  2. 如果我们希望截获某一个属性它访问和设置值的过程时, 也会使用存取属性描述符

如何获取对象的属性描述符?

Object.getOwnPropertyDescriptor(obj, "name") // 获取对象某个属性的属性描述符
Object.getPrototypeOf(obj) // 获取对象对应的原型对象
Object.setPrototypeOf(newObj, o) // 将newObj的原型对象设置为传进来的对象
obj.hasOwnProperty('name') // 对象是否有某一个属于自己的属性(不是在原型上的属性)
'name' in obj // 判断某个属性是否在某个对象或者对象的原型上(不管在哪都算)
stu instanceof Student // 用于检测构造函数的pototype,是否出现在某个实例对象的原型链上
obj.isPrototypeOf(info) // 判断obj对象是否在info对象的原型链上

创建对象的几种方案?

  1. 字面量
  2. new Object()
  3. 工厂函数,缺点获取不到对象最真实的类型
  4. 构造函数,如果这么一个普通的函数被使用new操作符来调用了,那么这个函数就称之为是一个构造函数,一般首字母大写

new操作符有哪些作用?

  1. 内存中创建一个新的对象;
  2. this指向创建出来的新对象;
  3. 执行函数体代码
  4. 返回创建出来的新对象;
  5. 这个对象内部的__proto__属性会被赋值为该构造函数的prototype属性;

使用构造函数创建对象,对象的类型就确定了,其实就是构造函数对应的原型对象的constructor属性。但是构造函数也有浪费内存的弊端。

为什么构造函数会浪费内存? 以及怎么解决?

通过new调用构造函数的时候, 它会为每个对象的函数去创建一个函数对象实例;对于对象的属性,每个对象的属性值都不一样,占用不同的内存是没事的,但是对于函数,如果还占用不同的内存,就显得没必要了。解决办法就是, 我们可以将这些函数放到Person.prototype对象上即可。

直接赋值构造函数的prototype为其他对象,可以吗?有什么弊端?

可以,但是有两个弊端:

  1. 新对象中就没有constructor属性了,我们可以将新对象中添加一个constructor属性指向原来的构造函数。
  2. 原来的原型对象的Enumerable属性是false不可枚举的,直接添加constructor属性默认的Enumerable属性是true,所以我们一般通过Object.defineProperty方式添加constructor属性。

说一下构造函数,原型对象,对象实例的关系

说一下原型链以及它的作用

  1. 当访问一个对象的属性(包括方法)时,首先查找这个对象自身有没有该属性。
  2. 如果没有,就查找它的__proto__指向的原型对象。
  3. 如果最后还没有就查找Object原型对象。
  4. 依此类推一直找到 Object 为止(null)。

__proto__的意义就在于为对象成员查找机制提供一个方向,或者说一条路线。

注意:一般我们称__proto__为隐式原型,称prototype为显式原型。

说一下普通构造函数,Function()构造函数,Object()构造函数之间的__proto__指向关系

  • 普通构造函数也是一个对象,函数对象都是通过new Function()创建的,所以他们的__proto__都指向Function()构造函数的原型对象
  • 普通构造函数的原型对象,是通过new Object()创建的,所以它的原型对象的__proto__指向Object()构造函数的原型对象
  • Function()构造函数也是一个对象,它也是通过new Function()创建的,所以它的__proto__也是指向Function()构造函数的原型对象
  • Function()构造函数的原型对象,是通过new Object()创建的,所以它的原型对象的__proto__指向Object()构造函数的原型对象
  • Object()构造函数也是一个对象,它也是通过new Function()创建的,所以它的__proto__也是指向Function()构造函数的原型对象
  • Object()构造函数的原型对象也是一个对象,它的__proto__指向null

总结:构造函数作为对象,都是通过new Function()创建的,普通对象都是通过new Object()创建的。

类可对象的关系

比如new Person(),JS中一般我们称为构造函数,但是其他语言一般称Person为类,因为类可以创建对象,其实都可以。ES6之后,通过class Person{}定义类,这时候我们将Person称为类其实更合适,其实通过class定义类就是构造函数的语法糖。

面向对象的三大特性

  1. 封装:将属性和方法封装到一个类中,就称为封装
  2. 继承:可以抽取公共代码到父类中,减少重复代码,是多态的前提
  3. 多态:不同的数据类型进行同一操作,表现出不同的行为,就是多态的体现

传统面向对象的多态有三个前提:

  1. 必须有继承(是多态的前提)
  2. 必须有重写(子类重写父类的方法)
  3. 必须有父类指针指向子类对象

JS中的多态就没这三个前提,就比如sum(m, n) 当传入不同的数据类型进行同一操作的时候,表现出来的结果也是不一样的,所以也是多态的体现。

ES5实现继承的三种方式

1. 通过原型链继承

通过原型链继承就是直接将子类的原型指向父类的一个实例。

// 父类: 公共属性和方法
function Person() {
  this.name = "why"
  this.age = 18
  this.friends = [] //引用的属性
}

Person.prototype.eating = function() {
  console.log(this.name + " eating~")
}

// 子类: 特有属性和方法
function Student() {
  this.sno = 111
}

var p = new Person()
Student.prototype = p

Student.prototype.studying = function() {
  console.log(this.name + " studying~")
}

// name/sno
var stu = new Student()

console.log(stu.name) //why
stu.eating()
stu.studying()
  1. 打印子类对象,继承父类的属性是看不到的,因为子类没有
  2. 如果父类有个引用属性,直接修改两个子类对象的这个属性会相互影响
  3. 子类对象创建的时候无法传递参数

三个弊端出现的根本原因就是属于子类的属性被放到父类中了,所以我们想办法将属于子类的属性放到子类中,所以出现了借用构造函数继承。

2. 借用构造函数继承(经典继承)

借用构造函数继承就是在子类的构造函数中,通过call调用父类的构造方法,并绑定this为子类。

// 子类: 特有属性和方法
function Student(name, age, friends, sno) {
  // 调用父类的构造函数,并绑定this为子类
  Person.call(this, name, age, friends)
  this.sno = 111
}

可以解决上面三个问题,但是还有弊端:弊端的根本原因就是子类的原型对象是通过new父类实例创建的,就会造成两个问题。

  1. 父类的构造函数被调用了两次,一次是Person.call()的时候调用,一次是new Person()的时候调用
  2. 正因为父类的构造函数被调用了两次,第一次Person.call()的时候子类有这些属性了,第二次new Person()的时候,父类实例也有这些属性(只不过是undefined),但是子类的原型对象上的这些属性是不应该存在的

为了解决上面的问题,那么能不能直接将父类的原型对象赋值给子类的原型对象呢?

不可以,因为如果在子类的原型对象上加东西,那么父类的原型对象上也有了。

3. 寄生组合式继承(最终方案)

寄生组合式继承,子类的原型对象就不使用父类的实例了,我们自己创建一个新对象,让这个新对象的原型对象指向父类的原型对象。这样就不会调用new Person()了,上面的问题也解决了。

Objec.create()函数会返回一个新对象,并且新对象的原型对象会指向我们传进去的参数。

function inheritPrototype(SubType, SuperType) {
  // 创建一个新对象作为子类的原型,并且将新对象的原型指向SuperType.prototype
  SubType.prototype = Objec.create(SuperType.prototype)
  Object.defineProperty(SubType.prototype, "constructor", {
    enumerable: false,
    configurable: true,
    writable: true,
    value: SubType
  })
}

// 然后我们在子类的原型对象上添加子类特有的方法就行了
Student.prototype.studying = function() {
  console.log("studying~")
}

ES6新增特性

ES6之后新增了哪些东西?

  1. 新增了let、const、Symbol、BigInt、Set、Map等
  2. 空值合并运算符代替逻辑或运算符,可选链很好用
  3. Promise、Reflect、生成器迭代器、async、await

新增的数据结构

ES5的var和ES6的let、const有什么区别?

var函数级作用域有变量的作用域提升
let、const块级作用域不存在变量的作用域提升
  1. var是函数级作用域,会存在循环变量变成全局变量的问题,let和const是块级作用域不存在这个问题。
  2. 在全局通过var来声明一个变量,事实上会在window上添加一个属性(其实就是VO对应的GO),但是let、const是不会给window上添加任何属性的,通过let、const声明的属性会添加VE中,对应的是一个VariableMap对象中,它其实是一个hashmap。

JS数据类型有哪些?

  • 基本数据类型:String、Number、Boolean、undefined、null、(ES6新增了)Symbol、BigInt
  • 引用类型:对象(Object)、数组(Array)、函数(Function)、Set类似数组、Map类似对象

区别:

  • 基本数据类型,直接保存在栈中,是值传递
  • 引用类型,对象实例存放在堆中,是指针传递,指针保存在栈中

undefined和null的区别:

相同点:基本数据类型,把保存在栈中。

不同点:

  • undefined表示一个没有设置值的变量,比如我们只声明一个变量,那么它的值是undefined,类型是undefined,typeof 这个变量会返回 undefined。
  • null表示一个空对象引用,比如我们把一个对象设置为null,它的值是null,类型是Object,typeof一个空对象会返回Object。

Symbol的详细使用

  1. 通过Symbol()定义一个Symbol,通过[s1]作为key来使用。也可以传入描述符Symbol(description),可以通过symbol.description拿到这个描述符
  2. 获取的时候通过obj[s1]这种方式,不能通过点语法获取,如果通过点语法,会把s1当成字符串来获取
  3. 使用Symbol定义的keys只能通过Object.getOwnPropertySymbols(obj)拿到,然后再通过forof遍历数组,拿到value
  4. Symbol有两个方法,Symbol.for("key")和Symbol.keyFor(symbol),通过Symbol.for("key")创建symbol的时候会先检查给定的描述是否已经存在,如果不存在才会新建一个值,否则返回旧值。所以我们可以通过Symbol.for("key")传入相同的描述,创建相同的symbol。Symbol.keyFor()是用来检测该字符串参数作为名称的 Symbol值是否已被登记,返回一个已登记的 Symbol 类型值的key
  5. description属性和Symbol.keyFor()方法的区别是: description能返回所有Symbol类型数据的描述,而Symbol.keyFor()只能返回Symbol.for()在全局注册过的描述

BigInt的使用

当我们表示比Number最大值还大的值的时候可能会失去精度,这时候我们就可以使用BigInt,创建方式有两种,直接数值后面加n,或者使用BigInt()创建。当我们使用数值的时候,再通过Number(bigInt)将BigInt转成Number。

Set的基本使用

ES6之前保存数据的结构主要由:数组、对象。ES6之后新增了两个新的数据结构,Set、Map,以及WeakSet、WeakMap。

Set类似数组,但是不是一个数组,它的元素不能重复,所以一般我们使用Set来进行数组去重。

  1. 通过new Set()创建set,通过set.add()添加元素
  2. Set常见的属性和方法:size、add、delete、has、clear
  3. 进行数组去重:arr -> new Set(arr)获取arrSet -> Array.from(arrSet)获取newArr

Set和WeakSet的区别

相同点:都是元素不能重复

不同点:

  1. Set里面可以存放基本数据类型和对象类型
  2. WeakSet中只能存放对象类型,而且对对象是弱引用,如果没有其他对象引用这个对象,那么GC可以对该对象进行回收

Map的基本使用

Map类似对象,但是对象的key只能是字符串或者symbol,Map的key可以是任何数据类型。

  1. 通过new Map()创建map,通过map.set(obj,value)添加属性
  2. Map常见的属性和方法:size、set、get、delete、has、clear
  3. 通过new Map创建map,参数是数组里面嵌套数组。因为如果使用对象的那种方式会把obj1当成字符串。

Map和WeakMap的区别

  1. Map的key可以是任何数据类型
  2. WeakMap的key只能使用对象,而且对象的引用是弱引用,如果没有其他引用引用这个对象,那么GC可以回收该对象以及对应的value;

WeakMap的应用场景

Vue3响应式原理就使用了WeakMap,比如我们有一个响应式对象obj,首先创建一个WeakMap,WeakMap的key就是obj对象,WeakMap的value是个Map,Map的key就是obj的key,Map的value就是对应的render函数。

当我们界面的数据发生改变的时候,先在WeakMap里面通过obj找到对应的Map,然后再根据obj的key找到对应的render函数,然后再执行render函数。

为什么外层使用WeakMap呢?就是如果有一天obj对象不使用了,那么它就会被销毁了,并且对应保存的Map也会被销毁的。

typeof和instanceof的区别

  • 相同点:typeof和instanceof都是用来判断数据类型的。
  • typeof后面跟一个操作数,获取表示操作数的类型,返回一个字符串,取值有"string","number","boolean","undefined","object","function","symbol",,"bigint"。
  • instanceof表示构造函数的原型对象是否在某个实例对象的原型链上,返回true或者false。

如何判断是否是个对象?

:使用typeof判断不为null,并且是object或者function。

typeof对象、数组、null,返回的是object,typeof函数,返回的是function。

// 判断是不是对象
function isObject(value) {
  const valueType = typeof value
  return (value !== null) && (valueType === "object" || valueType === "function")
}

如何判断是否是个数组?

:使用Object.prototype.toString.call(arr)

var arr = [1, 2, 3]
console.log(Object.prototype.toString.call(arr)) // [object Array]

Object的原型对象上的toString方法调用的时候会打印 [Object Array]这样的字符串,这样我们就能知道类型了。

Promise

Promise翻译过来就是承诺, 当我们需要给予调用者回调数据的时候,就可以创建一个Promise的对象,在通过new创建Promise对象时,我们需要传入一个回调函数,这个回调函数会被立即执行,并且给传入另外两个回调函数resolve、reject,当我们调用resolve回调函数时,会执行Promise对象的then方法传入的回调函数,当我们调用reject回调函数时,会执行Promise对象的catch方法传入的回调函数。

Promise的三个状态:状态一旦确定就不能修改。

  1. 待定(pending): 初始状态
  2. 已兑现(fulfilled): 操作完成
  3. 已拒绝(rejected): 操作失败

resolve不同值的区别

  1. resolve传入一个普通的值或者对象,那么这个值会作为then回调的参数。
  2. 如果resolve中传入的是另外一个Promise,那么这个新Promise会决定原Promise的状态,相当于进行了Promise状态移交。
  3. 如果resolve中传入的是一个thenable对象,那么会执行该then方法,并且根据then方法的结果来决定Promise的状态。

注意:reject没有传入三种值的情况,reject无论传入什么都会被当做catch回调的参数。

Promise实例方法

关于then方法:它其实是放在Promise的原型上的方法 Promise.prototype.then。直接打印看不到,因为这些方法是不可遍历的,可以通过Object.getOwnPropertyDescriptors(Promise.prototype)查看这些方法。

  1. then方法接收两个参数:fulfilled的回调函数,reject的回调函数
  2. then方法可以被多次调用,当Promise的状态变成fulfilled的时候,这些回调函数都会被执行(注意:这里说的不是链式调用)
  3. then方法本身是有返回值的,它的返回值是一个Promise,所以我们可以进行如下的链式调用:
    • 情况一:返回一个普通的值,如果我们返回的是一个普通值(数值/字符串/普通对象/undefined(没有返回值,默认就是undefined)), 那么这个普通的值被作为一个新的Promise的resolve值
    • 情况二:返回一个Promise;
    • 情况三:返回一个thenable对象;

对于拒绝的捕获,我们可以使用then方法的第二个参数,但是这样写代码的可读性比较差,所以我们有了catch方法,catch方法其实就是个语法糖。

关于catch方法:它也是放在Promise的原型上的方法 Promise.prototype.catch

  1. catch方法接收一个参数,当Promise的状态变成rejected或者抛出异常的时候,回调函数会被执行。
  2. catch方法也是可以被多次调用的,当Promise的状态变成rejected或者抛出异常的时候,这些回调函数都会被执行。
  3. catch方法也会返回一个Promise,这和then是一样的,返回值的类型也和then一样。

注意点1:如下代码,看起来代码是捕获then返回的那个promise的异常,但是catch内部做了处理,它会优先捕获promise的异常,promise没有异常的时候才会捕获then返回的那个promise的异常。

promise.then(res => {
  // return new Promise((resolve, reject) => {
  //   reject("then rejected status")
  // })
  throw new Error("error message")
}).catch(err => {
  console.log("err:", err)
})

注意点2:下面代码会报错,因为第一个promise.then没有处理异常,两种解决办法:1. .then的第二个参数传入回调函数用来处理异常 2. 按照上面的方式添加.catch

const promise = new Promise((resolve, reject) => {
  reject("111111")
  // resolve()
})

promise.then(res => {
  
})

promise.catch(err => {

})

注意点3:catch方法的返回值也是一个promise,所以catch里面返回值的时候会走下面的then,而不是catch

// catch方法的返回值
const promise = new Promise((resolve, reject) => {
  reject("111111")
})

promise.then(res => {
  console.log("res:", res)
}).catch(err => {
  console.log("err:", err)
  return "catch return value"
}).then(res => {
  // 这一行会打印
  console.log("res result:", res)
}).catch(err => {
  console.log("err result:", err)
})

finally方法:

因为无论前面是fulfilled状态,还是reject状态,它都会执行。

Promise类方法

  1. Promise.resolve:相当于new一个Promise,然后调用resolve传入参数(当然参数也是三种,这里就不在赘述),返回这个promise
  2. Promise.reject:相当于new一个Promise,然后调用reject传入参数,返回这个promise
  3. Promise.all:参数是个promise数组,等到所有promise都是fulfilled的时候,返回一个promise,我们可以在返回的promise里面通过.then拿到结果,结果也是个数组。如果在拿到所有结果之前, 有一个promise变成了rejected, 那么整个返回的promise是rejected,我们可以在.catch里面拿到错误信息。弊端就是我们拿不到其他promise的结果了。
  4. Promise.allSettled:参数是个promise数组,该方法会在所有的Promise都有结果(settled),无论是fulfilled,还是reject时,才会有最终的状态;而且这个promise的结果一定是fullfilled。结果是个数组,数组里面是个对象,对象的status表示promise的状态,对象的value(或者reason)代表promise的结果。
  5. Promise.race:参数是个promise数组,竞赛的意思,只要有一个promise有结果,那么就返回这个结果,如果是fulfilled,那么会在.then方法里面拿到结果,如果是rejected,那么会在.catch里面拿到错误信息。弊端就是我们可能拿不到成功的结果了。
  6. Promise.any:参数是个promise数组,any方法至少要等到一个fulfilled状态,才会决定新Promise的状态。如果所有的Promise都是reject的,那么也会等到所有的Promise都变成rejected状态,如果所有的Promise都是reject的,那么会执行catch,会报一个AggregateError的错误。

如何手写Promise

  1. 定义promise的基本结构
  2. 使用微队列,确保then的回调被保存
  3. 使用数组,达到多次调用的目的
  4. then返回promise,达到链式调用的目的
  5. 回调函数数组中不直接放回调函数,放回调函数的执行,从而可以拿到函数的执行结果
  6. 实现catch方法,处理then的两个参数的边界情况,必须保证then的两个参数都有值
const PROMISE_STATUS_PENDING = 'pending'
const PROMISE_STATUS_FULFILLED = 'fulfilled'
const PROMISE_STATUS_REJECTED = 'rejected'

// 获取执行结果的工具函数
function execFunctionWithCatchError(execFn, value, resolve, reject) {
  try {
    // 第一个promise的then的两个回调函数,不管是成功还是失败,只要返回了,都会走下一个promise的then
    // 只要有返回值,就会走then, 除非抛出异常,才会走catch
    const result = execFn(value)
    resolve(result)
  } catch(err) {
    reject(err)
  }
}

class HYPromise {
  constructor(executor) {
    this.status = PROMISE_STATUS_PENDING // 初始化状态
    this.value = undefined // resolve的参数,用来传给then回调函数
    this.reason = undefined // reject的参数有,用来传给catch回调函数
    this.onFulfilledFns = [] //数组里面放的是嵌套一层函数的函数
    this.onRejectedFns = []

    const resolve = (value) => {
      if (this.status === PROMISE_STATUS_PENDING) { // 只有待定状才执行
        // 注意点1:添加微任务, 保证promise.then方法已经执行了
        queueMicrotask(() => {
          // 如果其他微任务先执行了,就直接return
          if (this.status !== PROMISE_STATUS_PENDING) return
          this.status = PROMISE_STATUS_FULFILLED
          this.value = value
          this.onFulfilledFns.forEach(fn => {
            // 调用.then的回调函数,将结果传递过去
            fn(this.value)
          })
        });
      }
    }

    const reject = (reason) => {
      if (this.status === PROMISE_STATUS_PENDING) { // 只有待定状才执行
        // 添加微任务
        queueMicrotask(() => {
          if (this.status !== PROMISE_STATUS_PENDING) return
          this.status = PROMISE_STATUS_REJECTED
          this.reason = reason
          this.onRejectedFns.forEach(fn => {
            // 调用.catch的回调函数,将结果传递过去
            fn(this.reason)
          })
        })
      }
    }

    // 如果在new Promise里面直接抛出异常,那么我们应该捕获异常,然后将错误信息放到reject中
    try {
      executor(resolve, reject)
    } catch (err) {
      reject(err)
    }
  }

  then(onFulfilled, onRejected) {
   	// then的两个参数必须都有值
  	// 如果第一个参数没有值,那么就将value的值直接返回,交给下一个promise的then处理
  	// 如果第二个参数没有值,那么就直接抛出异常,下一个promise的catch也会处理
    const defaultOnFulfilled = value => { return value }
    onFulfilled = onFulfilled || defaultOnFulfilled

    // 当第一个promise的then的第二个回调没有的时候,我们直接抛出异常,那么就会调用第二个promise的catch了
    // 就是实现上面的注意点1
    const defaultOnRejected = err => { throw err }
    onRejected = onRejected || defaultOnRejected

    // 返回promise 
    return new HYPromise((resolve, reject) => {
      // 1.如果在then调用的时候, 状态已经确定下来, 那是可以直接执行的
      // 比如我们在定时器里面调用了promise.then,那么这个then回调函数也要执行的
      if (this.status === PROMISE_STATUS_FULFILLED) {
        // 直接拿到结果,然后调用resolve或者reject
        // 只有抛出异常的时候才调用reject,其他都是调用resolve
        // try {
        //   const value = onFulfilled(this.value)
        //   resolve(value)
        // } catch(err) {
        //   reject(err)
        // }
        execFunctionWithCatchError(onFulfilled, this.value, resolve, reject)
      }
      if (this.status === PROMISE_STATUS_REJECTED) {
        execFunctionWithCatchError(onRejected, this.reason, resolve, reject)
      } 

      // 2.将成功回调和失败的回调放到数组中
      if (this.status === PROMISE_STATUS_PENDING) {
        // 这里push的时候嵌套一层函数,是为了拿到执行的结果
        // 有可能是catch调用, 所以第一个参数可能没有值
        this.onFulfilledFns.push(() => {
          execFunctionWithCatchError(onFulfilled, this.value, resolve, reject)
        })
        this.onRejectedFns.push(() => {
          execFunctionWithCatchError(onRejected, this.reason, resolve, reject)
        })
      }
    })
  }

  catch(onRejected) {
    return this.then(undefined, onRejected)
  }

  finally(onFinally) {
    // 不管成功还是失败,最终都会调用onFinally
    this.then(() => {
      onFinally()
    }, () => {
      onFinally()
    })
  }
}

const promise = new HYPromise((resolve, reject) => {
  console.log("状态pending")
  resolve(1111) // resolved/fulfilled
  // reject(2222)
})

// 调用then方法多次调用
promise.then(res => {
  console.log("res1:", res)
  return "aaaaa"
}).then(res => {
  console.log("res2:", res)
}).catch(err => {
  console.log("err:", err)
}).finally(() => {
  console.log("finally")
})

关于then第一个参数必须有的解释:

// 如果then的第一个参数是undefined, 那么会有链式调用断层的问题
// 解决办法就是如果第一个参数是undefined,那么就将结果传递给下一个promise

// 去掉第一个参数的默认值,打印如下
// 状态pending
// res1: 1111

// 加上第一个参数的默认值,打印如下,这才是正确的
// 状态pending
// res1: 1111
// thenaaaaa
const promise = new HYPromise((resolve, reject) => {
  console.log("状态pending")
  resolve(1111) // resolved/fulfilled
  // reject(2222)
})

// promise1的成功回调有,失败回调是抛出异常
// 如果promise2的成功回调没有,失败回调有,然后
// 当catch方法return的时候,会调用promise2的成功回调,拿到成功的结果,再调用resolve,这样thenaaaaa才会打印
// 但是如果promise2都没有成功的回调,自然没法调用resolve,那么下一个promise的then自然不会调用了,所以不会打印thenaaaaa

// 如果把第一个then参数加上,当catch方法return的时候,会调用promise2的成功回调,然后通过resolve将结果传给下一个promise,所以会打印thenaaaaa

promise.then(res => { //promise1
  console.log("res1:", res)
  return "aaaaa"
}).catch(err => { //promise2
  console.log("err:", err)
}).then((res) => {
  console.log("then" + res)
})

迭代器和生成器

什么是迭代器

迭代器是遵守迭代器协议的对象,用来帮助我们遍历其他容器对象。迭代器协议要求我们实现next方法,next方法要求返回一个对象,对象有done和value属性,done表示是否迭代结束,value表示迭代出来的值。

// 数组
const names = ["abc", "cba", "nba"]
let index = 0
// 创建一个迭代器对象来访问数组
const namesIterator = {
  next: function() {
    if (index < names.length) {
      return { done: false, value: names[index++] }
    } else {
      return { done: true, value: undefined }
    }
  }
}

console.log(namesIterator.next())
console.log(namesIterator.next())
什么是可迭代对象

当一个对象实现迭代器协议的时候,那么这个对象就是可迭代对象,在对象中要求我们实现iterator方法,iterator方法返回的就是迭代器对象。其实就是把迭代器放到对象中,那么这个对象就是可迭代对象。

// 创建一个迭代器对象来访问数组
const iterableObj = {
  names: ["abc", "cba", "nba"],
  [Symbol.iterator]: function() {
    let index = 0
    return {
      // next函数要使用箭头函数,这样this才是info对象。
      next: () => {
        if (index < this.names.length) {
          return { done: false, value: this.names[index++] }
        } else {
          return { done: true, value: undefined }
        }
      }
    }
  }
}

// iterableObj对象就是一个可迭代对象
// console.log(iterableObj[Symbol.iterator])

// 调用iterableObj[Symbol.iterator]函数拿到迭代器
const iterator = iterableObj[Symbol.iterator]()
console.log(iterator.next())
console.log(iterator.next())
for...in和for...of的区别
  • for...in可用来遍历数组和对象,对于数组来说,遍历的是数组的下标,对于对象来说,遍历的是对象的key。
  • for…of语法是ES6新引入的语法,for…of语法用于遍历可迭代(iterable)对象,所以forof我们一般用来遍历数组,forof其实就是语法糖,它的本质就是一次一次调用迭代器,然后通过.value拿到迭代的值。
什么是生成器

生成器是由生成器函数返回的,生成器函数是普通函数的function后面加*,然后通过yield关键字来控制函数的分步执行,最后返回一个生成器。生成器事实上是一种特殊的迭代器,所以我们也可以调用生成器的next()方法实现分步执行。

function* foo(num) {
  console.log("函数开始执行~")

  const value1 = 100 * num // 这里的num就是5
  console.log("第一段代码:", value1)
  const n = yield value1 

  const value2 = 200 * n // 这里的n是10
  console.log("第二段代码:", value2)
  const count = yield value2 

  const value3 = 300 * count // 这里的count是25
  console.log("第三段代码:", value3) 
  yield value3

  console.log("函数执行结束~")
  return "123"
}

// 生成器上的next方法可以传递参数
// 1. 第一段代码的参数是调用生成器函数的时候传入的
// 2. 其他段代码的参数是上一个yield的返回值

const generator = foo(5)
// 第一段代码
console.log(generator.next())
// 第二段代码
console.log(generator.next(10))
// 第三段代码
console.log(generator.next(25))


// 第一段代码:500
// {value: 500 , done: false}
// 第一段代码:2000
// {value: 2000 , done: false}
// 第一段代码:7500
// {value: 7500 , done: false}
// 函数执行结束~
// {value: 123 , done: true}

使用Promise和生成器的方式,我们可以将我们的异步代码写的更简洁。

// request.js
function requestData(url) {
  // 异步请求的代码会被放入到executor中
  return new Promise((resolve, reject) => {
    // 模拟网络请求
    setTimeout(() => {
      // 拿到请求的结果
      resolve(url)
    }, 2000);
  })
}

// 3.第三种方案: Promise + generator实现
function* getData() {
  const res1 = yield requestData("why")
  const res2 = yield requestData(res1 + "aaa")
  const res3 = yield requestData(res2 + "bbb")
  const res4 = yield requestData(res3 + "ccc")
  console.log(res4)
}

// 递归调用函数
function execGenerator(genFn) {
  const generator = genFn()
  function exec(res) {
    const result = generator.next(res)
    if (result.done) {
      return result.value
    }
    result.value.then(res => {
      exec(res)
    })
  }
  exec()
}

execGenerator(getData)
// 打印:whyaaabbbccc

async、await其实就是Promise和生成器的语法糖,如下:

// 4.第四种方案: async/await
async function getData() {
  const res1 = await requestData("why")
  const res2 = await requestData(res1 + "aaa")
  const res3 = await requestData(res2 + "bbb")
  const res4 = await requestData(res3 + "ccc")
  console.log(res4)
}

getData()

async和await

async

  1. 普通函数加上async就是异步函数
  2. 异步函数有返回值,那么返回值一定是个Promise,那么返回的Promise的状态由于什么决定呢?
    • 如果返回的是一个Promise, 就由Promise决定
    • 如果返回的是一个值, 那么返回值会被包裹到Promise.resolve中;
    • 如果返回的是一个thenable对象, 那么会由对象的then方法来决定;

await

  1. await只能使用在异步函数中
  2. await后面跟Promise
    • await后面跟Promise,await会等到Promise的状态变成fulfilled状态,拿到Promise返回值的结果,之后继续执行异步函数。如果await后面的Promise是reject的状态,那么会将这个reject结果直接作为异步函数的Promise的reject值,不会继续执行异步函数。
    • 如果await后面是一个普通的值,那么会直接返回这个值;
    • 如果await后面是一个thenable的对象,那么会根据对象的then方法调用来决定后续的值;

异步函数和普通函数的区别

  1. 异步函数中如果没有其他代码(比如Promise),那么它和普通函数的执行顺序是一样的。
  2. 普通函数中抛出异常,不捕获会报错。异步函数中抛出异常,不捕获不会报错,因为异常信息会作为Promise的reject值来传递。

浏览器的事件循环

进程和线程

进程: 计算机已经运行的程序, 比如微信,QQ,但是一个软件不一定只有一个进程,比如浏览器,一个tab页就是一个进程。

线程: 通常被包含在进程中, 是操作系统能够进行调度的最小单位, 只有在线程中才能执行代码。

操作系统是如何做到同时让多个进程(边听歌、边写代码、边查阅资料)同时工作呢?

这是因为CPU的运算速度非常快,它可以快速的在多个进程之间迅速的切换,当我们进程中的线程获取到时间片时,就可以快速执行我们编写的代码,对于用户来说是感受不到这种快速的切换的。

为什么说浏览器是多进程,JS是单线程

  • 浏览器是多进程的,当我们打开一个tab页面时就会开启一个新的进程,这是为了防止一个页面卡死而造成所有页面无法响应,整个浏览器需要强制退出。
  • 每个进程中又有很多的线程,其中就包括一个专门执行JS代码的线程,其他的耗时操作(比如网络请求、定时器)是在其他线程执行的,然后又结果后再回调给JS线程的。

说一下浏览器的事件循环

  1. JS线程在执行代码的时候,比如发现有一个定时器,这个耗时操作肯定不是JS线程来执行的,这时候会交给其他线程进行计时操作,这时候 JS线程中的代码会继续执行,不会被阻塞,当时间达到的时候就会把定时器的回调函数保存到事件队列中。
  2. JS引擎会不断地查看队列中是否有事件,当JS引擎发现事件队列里有任务的时候,就会把任务取出来,然后再放到JS线程中去执行。
  3. JS线程 -> 其他线程 -> 事件队列 -> JS线程,整个闭环就是浏览器的一个事件循环

微任务和宏任务

浏览器维护了两个队列,分别是微任务队列和宏任务队列,在执行任何一个宏任务之前保证微任务队列是空的

  1. 微任务队列(microtask queue):queueMicrotask()、Promise的then回调,$.nextick
  2. 宏任务队列(macrotask queue):ajax、定时器、DOM、UI相关回调

下面代码执行打印顺序是什么?

setTimeout(function () { // 定时器加到宏任务里面
  console.log("setTimeout1"); // 立即执行
  new Promise(function (resolve) {
    resolve();
  }).then(function () {
    new Promise(function (resolve) {
      resolve();
    }).then(function () {
      console.log("then4"); // 加入到微任务里面
    });
    console.log("then2"); // 立即执行
  });
});

new Promise(function (resolve) { // promise的构造函数代码不会加入到微任务里面,会立即执行
  console.log("promise1");
  resolve();
}).then(function () { // 加入到微任务里面
  console.log("then1");
});

setTimeout(function () { // 加入到宏任务里面
  console.log("setTimeout2");
});

console.log(2); // 立即执行

queueMicrotask(() => { // 加入到微任务里面
  console.log("queueMicrotask1")
});

new Promise(function (resolve) {
  resolve();
}).then(function () { // 加入到微任务里面
  console.log("then3");
});

// promise1
// 2
// then1
// queueMicrotask1
// then3
// setTimeout1
// then2
// then4
// setTimeout2
async function async1 () {
  console.log('async1 start') // 直接执行
  await async2();
  // 相当于.then回调, 会被加入到微任务队列里面
  console.log('async1 end')
}

async function async2 () {
  console.log('async2') // 直接执行
}

console.log('script start') // 直接执行

setTimeout(function () {
  console.log('setTimeout') // 加入宏任务队列
}, 0)
 
async1();
 
new Promise (function (resolve) {
  console.log('promise1') // 直接执行
  resolve();
}).then (function () {
  console.log('promise2') // 微任务队列
})

console.log('script end') // 直接执行

// script start
// async1 start
// async2
// promise1
// script end
// async1 end
// promise2
// setTimeout

总结:第二道面试题的核心是:await后面的代码相当于.then回调,会被加入到微任务队列里面

是模块化开发

什么是模块化开发?

JS模块化开发就是将我们写的代码划分成一个个小的文件,每个文件是一个模块,在文件中我们将变量导出,其他模块使用的时候再导入就行,模块化开发避免了变量命名冲突、作用域相互影响产生的等等问题。

但是JS在ES6之后才推出自己的模块化方案,在此之前,AMD、CMD、CommonJS这些模块化方案用的最广泛。AMD、CMD我们基本上不使用了,现在在浏览器中我们使用的就是官方的ESModule,如果是在Node中,使用最多的还是CommonJS

CommonJS

Node中对CommonJS进行了支持和实现,在Node中每一个js文件都是一个单独的模块。我们使用module.exports、exports进行导出,使用require进行导入。

module.exports导出

// 模块1
const name = "why"
const age = 18
function sum(num1, num2) {
  return num1 + num2
}

// 导出方案 module.exports
module.exports = {
  name,
  age,
  sum
}

// 模块2
// 使用另外一个模块导出的对象, 那么就要进行导入 require
const { name, age, sum } = require("./why.js")
console.log(name)
console.log(age)
console.log(sum(20, 30))

它内部的原理其实就是,require函数返回的对象就是我们通过module.exports导出的对象,它们是指向同一个对象,也就是 { name, age, sum }对象,内存地址都是一样的。

exports导出

使用exports导出的时候一定要用一个一个添加属性的方式,如下:

const name = "why"
const age = 18
function sum(num1, num2) {
  return num1 + num2
}

// 第二种导出方式
exports.name = name
exports.age = age
exports.sum = sum

为什么一定要用一个一个添加属性的方式呢?因为node的源码如下:

// 源码
module.exports = {}
exports = module.exports

先给module.exports创建一个空对象,然后让exports指向module.exports,如果我们让exports指向了一个新的对象,但是最终导出的都是module.exports,所以这种导出是不成功的。如下:

// 这种代码不会进行导出
exports = {
  name,
  age,
  sum
}

module.exports和exports的关系:他们是指向同一个对象,但是最终导出的一定是module.exports,所以我们一般都使用module.exports。

require(X)查找规则

  • 如果X是核心模块,直接返回核心模块
  • 如果X是路径,就在这个路径下先找文件,后找文件夹,找不到就报错。
  • 如果X既不是核心模块也不是路径,那么会在代码所在的文件夹中找node_modules,然后在node_modules里面再查找X,同样也是先找找文件,再找文件夹。如果找不到node_modules,那么就再去上层文件夹查找node_modules,以此类推,找不到就报错。

ES Module

ES Module是JS官方的模块化规范,使用import导入和export导出,并且对于一些重要的的东西还可以使用默认导出export default。

ES Module的解析流程

  1. 构建阶段:就是下载js文件以及js文件引用的其他js文件,解析成Module Record
  2. 实例化阶段:就是对每个js文件进行实例化,然后解析其中的导入和导出。
  3. 运行阶段:才会真正执行js文件中的代码

为什么模块导入不能放在代码执行中,怎么解决?

因为模块导入是在第一阶段,代码运行是在第三阶段,代码运行的时候,这个模块还没构建呢,所以会报错。

但是某些情况下,我们确确实实希望动态的来加载某一个模块,如果根据不同的条件,动态来选择加载模块的路径,这个时候我们需要使用 import() 函数来动态加载。

什么是NPM

npm:Node Package Manager,也就是Node包管理器;但是目前已经不仅仅是Node包管理器了,在前端项目中我们也在使用它来管理依赖的包;比如vue、vue-router。

npm的配置文件

这个配置文件就是package.json,使用npm init –y手动生成,或者脚手架自动生成。

  • dependencies属性:无论开发环境还是生成环境都需要依赖的包
  • devDependencies属性:开发环境需要,生产环境不需要的包
  • peerDependencies属性(peer:美[pɪr] 同龄):还有一种项目依赖关系是对等依赖,也就是你依赖的一个包,它必须是以另外一个宿主包为前提的;比如element-plus是依赖于vue3的,安装element-plus就必须安装vue3,否则会报警告。

版本管理的 ^和~的区别:

semver版本规范是X.Y.Z,一个尖尖是X不变,两个尖尖是X和Y不变。

  • ^x.y.z:表示x是保持不变的,y和z永远安装最新的版本;
  • ~x.y.z:表示x和y保持不变的,z永远安装最新的版本;

package.json和package-lock.json版本记录有什么区别

package.json记录的是大致版本,package-lock.json记录的是确切版本,如果这两个文件都存在,那么安装的时候就先看看package-lock.json文件记录的版本是不是符合package.json文件记录的大致版本吧,如果符合,那就安装package-lock.json记录的版本,如果不符合,那么就会下载package.json的版本,然后更新package-lock.json文件。

如果你真的不想使用lock的版本了,那么就把package-lock.json文件删掉,重新执行npm install,那么就能安装符合package.json的最新的版本了。

npm install 命令

安装npm包分两种情况:

  • 全局安装(global install): npm install webpack -g;
  • 项目(局部)安装(local install): npm install webpack

全局安装一般是一些工具包比如,局部安装一般是一些源代码库,比如axios。

项目安装会在当前目录下生产一个 node_modules 文件夹,我们之前讲解require()查找顺序时有讲解过这个包在什么情况下被查找。

局部安装分为开发时依赖和生产时依赖。

// 默认安装开发和生产依赖
npm install axios
// 安装开发依赖
npm install webpack -D
// 根据package.json中的依赖包
npm install

npm install 原理

  1. 首先检测是否有package-lock.json文件
  2. 如果有lock文件,判断lock中包的版本是否和package.json中一致
    • 如果一致,先查缓存,查到就使用,查不到说明被删了,再重新下载(步骤3)
    • 如果不一致,重新下载(步骤3)
  3. 如果没有lock文件,说明没下过,就去下载
    • 就分析包的依赖关系,然后从registry仓库中下载压缩包
    • 对压缩包进行缓存,然后解压,放到node_modules文件夹中
    • 最后会生成package-lock.json文件

npm、yarn、cnpm、npx的区别

  • yarn的出现是因为早期的npm没有缓存,会导致下载速度很慢,所以国外各大公司联合推出了yarn,现在npm和yarn的功能基本上差不多了,使用哪个都可以。
  • cnpm的出现是因为npm的镜像在国外,这时候我们使用cnpm将镜像设置为淘宝镜像,这时候我们就有两个工具了,npm是官方的镜像,cnpm是淘宝的镜像,这时候我们就可以想用哪个就用那个了。
  • 安装node的时候会自带npm和npx命令, npx是到当前目录的node_modules/.bin目录下查找对应的命令。

同源政策

什么是同源

如果两个网站拥有相同的协议、域名和端口,那么这两个网站就属于同一个源,其中只要有一个不相同,就是不同源。

同源政策的目的

同源政策是为了保证用户信息安全,最初的同源政策是 A 网站在客户端设置的 Cookie,B网站是不能访问的。后来同源政策更加严格,比如无法向非同源地址发送Ajax 请求,其实这个请求是可以发送出去的,只不过浏览器拒绝接受服务器返回的结果,所以最终请求还是失败。

如何解决同源限制

  • jsonp:就是利用同源限制的漏洞,也就是script标签的src属性可以写任何地址,我们通过script标签发送请求,然后服务端返回函数的调用,客户端在函数中就可以拿到服务端的返回值。(客户端+服务端)
  • 跨域资源共享(CORS):当客户端向服务器端发送请求时,如果浏览器检测到这个请求是跨域的,就会自动在请求头中加入origin字段,origin字段包含当前发送请求的协议、域名、端口号信息,服务器端会根据这个字段的值来决定是否同意该请求,但无论服务器端是否同意该请求,服务器端都会给客户端一个正常的http响应,如果服务器同意该请求,会在响应头加入Access-Control-Allow-Origin字段,否则不会加入该字段。(服务端)
  • 服务端自己解决:服务器端是不存在同源政策限制。当我们想获取非同源网站中的数据时,可以让自己的服务端先获取非同源网站中的数据,然后自己服务端再将数据响应给自己的客户端,这样就绕过了浏览器的同源政策限制。(服务端一般使用nginx代理实现跨域)

什么是Cookie

Cookie是服务端为了辨别用户身份而存储在客户端的数据,Cookie主要是服务端设置的,在响应头里面,当然客户端也可以设置,当浏览器发现服务端的响应数据里面有cookie的时候,会自动将cookie存到客户端。当浏览器发送网络请求的时候会自动携带cookie,在请求头里面,我们也可以通过cookie获取一些信息。

  • 内存Cookie,保存在内存中,浏览器关闭时Cookie就会消失;
  • 硬盘Cookie,保存在硬盘中,手动清理或者过期时间到时,才会被清除;
  • 我们通过看这个Cookie有没有过期时间来判断是内存Cookie还是硬盘Cookie, 通过max-age设置过期的秒钟(), expires设置过期的时间(很少使用)

Cookie的缺点:

  • 明文传输,即使加密也与事无补,因为拦截者并不需要知道cookie的意义,他只要原样转发cookie就可以达到目。
  • 不同浏览器对cookie条数的限制不同,而且每个cookie长度不能超过4KB
  • 有些请求是不需要携带cookie的,这也造成了网络资源的浪费

什么是Cookie 隔离?(或者:请求资源的时候不要带cookie怎么做)

比如当我们请求静态资源的时候是不需要任何信息的,这时候如果携带Cookie就会造成网络资源的浪费,所以对于静态资源,我们可以放非主要域名下(比如CDN),这时候其实就是跨域了,就不会携带Cookie信息了,也会节省网络资源。

通过使用多个非主要域名来请求静态文件,如果静态文件都放在主域名下,那静态文件请求的时候带有的cookie的数据提交给server是非常浪费的,还不如隔离开。因为cookie有域的限制,因此不能跨域提交请求,故使用非主要域名的时候,请求头中就不会带有cookie数据,这样可以降低请求头的大小,降低请求时间,从而达到降低整体请求延时的目的。同时这种方式不会将cookie传入server,也减少了server对cookie的处理分析环节,提高了server的http请求的解析速度。

什么是WebStorage

WebStorage主要提供了一种机制,可以让浏览器提供一种比cookie更直观的key、value存储方式,包括:localStorage和sessionStorage

  1. 关闭网页再重新打开,localStorage会保留,而sessionStorage会被删除;
  2. 页面跳转打开了新的tab页,localStorage会保留,而sessionStorage会被删除;
  3. 页面跳转没有打开新的tab页,他们也都会保留

总结:其实一个tab页就是一个会话,同一个会话的sessionStorage会保存。

解释页面的回流与重绘

当渲染树中一些布局相关的东西发生改变的时候,就会触发回流,回流就是将渲染树中受影响的部分失效,然后重新渲染,完成回流后,再通过重绘将这部分内容重新绘制到屏幕上,这个过程就是重绘。

当非布局相关的属性发生改变的时候也会引起重绘,回流一定会引起重绘,而重绘不一定会引起回流。

图片的懒加载和预加载

图片的预加载

预加载就是提前加载图片。比如看漫画。

方式一:设置一个用户看不到的标签,设置标签的背景图,通过css预加载图片,当需要使用图片的时候,直接从浏览器缓存中拿。

方式二:通过JS下载图片,然后再回调函数中使用

function loadImage(url, callback){
    var img = new Image(); //创建一个Image对象,实现图片预下载
    img.src = url;
    if (img.complete){
         // 如果图片已经存在于浏览器缓存,直接调用回调函数
        callback.call(img);
        return; // 直接返回,不用再处理onload事件
    }
    img.onload = function (){
    	//图片下载完毕时异步调用callback函数。
    	callback.call(img);//将回调函数的this替换为Image对象
    };
}

图片的懒加载

懒加载就是需要的时候再加载图片。比如电商图片。

方式一:是纯粹的延迟加载,使用setTimeOut、setInterval进行加载延迟。

方式二:即仅加载用户可以看到的区域,这个主要由监控滚动条来实现,一般会在距用户看到某图片前一定距离遍开始加载,这样能保证用户拉下时正好能看到图片。

JS 中 == 和 === 区别是什么?

== 和 === 都是用来判断是否相等的,区别就是 == 只判断值是否相等,=== 不但要求值相等,类型也要相等。其实他们的核心区别就是 == 有隐式转换,=== 没有隐式转换。

什么是事件委托/代理

事件委托代理就是利用事件冒泡的特性,将本应该绑定在多个元素上的事件绑定在他们的父元素上,这样就减少了一些事件绑定,可以提高程序性能,减小内存浪费。特别是在动态添加子元素的时候,非常方便。

e.target、e.currentTarget 的区别

  • e.target是事件触发的元素,也就是真正点击的元素。
  • e.currentTarget 是绑定事件处理函数的元素。

一般情况下,e.target和e.currentTarget一样的,只有事件冒泡的时候才不一样,比如父元素里面包含子元素,父子元素都有点击事件,点击子元素的时候,子元素事件处理函数中的e.target和e.currentTarget都是子元素,但是父元素的事件处理函数中的e.target是子元素,e.currentTarget是父元素。

事件的三个阶段:

捕获阶段 - 当前目标阶段 - 冒泡阶段

如何添加捕获事件

默认我们添加的事件是冒泡事件,捕获事件用addEventListener(event,fn,useCapture),其中第3个参数useCapture设置为true,表示在事件捕获时触发。

手写防抖、节流、深拷贝函数

手写防抖函数

防抖的意思就是不管你抖动多少次,我都在延迟一定时间后才执行。

场景:

  1. 频繁点击按钮, 触发事件
  2. 输入框频繁输入内容, 进行搜索
function debounce(fn, delay) {
  // 1.定义一个定时器, 保存上一次的定时器
  let timer = null

  // 2.input事件真正执行的函数, 里面有参数
  const _debounce = function(...args) {
    // 取消上一次的定时器
    // 延迟执行
    timer = setTimeout(() => {
      // 调用外部传入的函数,将this绑定,args传递进去
      fn.apply(this, args)
    }, delay)
  }
  
   // 封装取消功能
  _debounce.cancel = function() {
    if (timer) clearTimeout(timer)
    timer = null
  }

  return _debounce
}

使用:

// 定义我们的事件
let count = 0
const inputChange = function(event) {
  console.log(`发送了第${++counter}次网络请求`, this, event)
}

inputEl.input = debounce(inputChange ,2000)

手写节流函数

节流就是在固定时间内,我只触发一次,节省流水。

场景:比如打飞机,无论用户按下多少次空格,1S中之内只会发送一次子弹

function throttle(fn, interval) {
  // 1.记录上一次事件触发的时间
  let lastTime = 0

  // 2.事件触发时, 真正执行的函数
  const _throttle = function(...args) {
    // 2.1.获取当前事件触发时的时间
    const nowTime = new Date().getTime()
    // 2.2.使用当前触发的时间和之前的时间间隔以及上一次开始的时间, 计算出还剩余多长事件需要去触发函数
    const remainTime = interval - (nowTime - lastTime)
    if (remainTime <= 0) {
      // 2.3.真正触发函数
      fn.apply(this, args)
      // 2.4.保留上次触发的时间
      lastTime = nowTime
    }
  }
  return _throttle
}
// 默认第一个时间间隔一定会触发,因为第一次是个很大的负值

自定义深拷贝函数

浅拷贝我们可以使用Object.assign()或者展开运算符,深拷贝我们可以使用JSON.stringify,但是这种深拷贝有弊端,如下:

  1. 对函数、Symbol没有任何处理
  2. 如果存在对象的循环引用,也会报错,因为不能将循环的结构转成JSON(比如对象里面有个属性指向自己,其实这是可以的,比如window里面就有一个window指向自己,所以我们可以一直window.window.window)

自定义深拷贝函数步骤如下:

  1. 自定义深拷贝的基本功能
  2. 优化值是数组,函数
  3. 优化值是Set、Map、Symbol(包括key是Symbol)
  4. 优化循环引用
function isObject(value) {
 const valueType = typeof value
 return (value !== null) && (valueType === "object" || valueType === "function")
}

function deepClone(originValue, map = new WeakMap()) {
 // map放到全局中肯定不行,因为把所有拷贝信息放到一个map中肯定是不行的
 // map放到局部也是不行,因为每次递归调用都会创建一个新的map,那我们就拿不到上层的map信息了
 // 所以我们把map放到参数中,并且第二次调用deepClone再把这个map传过去
 
 // 判断如果是函数类型, 那么直接使用同一个函数
 if (typeof originValue === "function") {
   return originValue
 }
 
 // 判断是否是一个Set类型
 if (originValue instanceof Set) {
   return new Set([...originValue])
 }
 // 判断是否是一个Map类型
 if (originValue instanceof Map) {
   return new Map([...originValue])
 }
 // 这里对Set、Map其实是浅层拷贝,开发中其实也足够用了

 // 判断如果是Symbol的value, 那么创建一个新的Symbol
 if (typeof originValue === "symbol") {
   return Symbol(originValue.description)
 }

 // 判断传入的originValue是否是一个对象类型
 if (!isObject(originValue)) {
   return originValue
 }
 // 如果原来已经有值了,那么直接使用原来创建的值
 if (map.has(originValue)) {
   return map.get(originValue)
 }

 // 判断传入的对象是数组, 还是对象,因为数组也是对象
 const newObject = Array.isArray(originValue) ? []: {}
 // 1. 将每个值对应的拷贝的对象先保存起来
 map.set(originValue, newObject)
 for (const key in originValue) {
   newObject[key] = deepClone(originValue[key], map)
 }

 // 对Symbol作为key进行特殊的处理
 const symbolKeys = Object.getOwnPropertySymbols(originValue)
 for (const sKey of symbolKeys) {
   // const newSKey = Symbol(sKey.description)
   newObject[sKey] = deepClone(originValue[sKey], map)
 }
 
 return newObject
}