JavaScript

# 数据类型

# undefined与void 0

undefined代表的是未定义,undefined是由null派生来的,因此将它们定义为相等,但不全等。增加这个特殊值的目的就是为了正式明确空对象指针(null)和未初始化变量的区别

undefined 在JavaScript中不是一个保留字,这意味着可以使用undefined作为一个变量来使用赋值。

undefined 在ES5中已经是全局对象的一个只读属性了,它不能被重写,但是在局部作用域中还是可以被重写的。它会影响对undefined值的判断,我们可以用void 0作为undefined的替代,因为void运算符能对给定表达式进行求值,然后返回undefined。也就是说void后面可以随便给定表达式,但 void 0 是表达式中最短的,且能节省字节。

# null是Object吗?

null 代表的含义是空对象,且typeof null==="object",但这只是一个JS历史遗留的bug。

在JS的最初版本中使用的是32位系统,为了性能考虑使用低位存储变量的类型信息,其中 000 开头代表是对象,然而 null 表示为全零,所以被错误的判定为object。虽然现在的内部类型判断代码已经改变,但是这个bug一直存在。

# 关于NaN

typeof NaN === "number"

isNaN和Number.isNaN函数的区别

  • 函数 isNaN 接收参数后,会尝试将这个参数转换为数值,任何不能被转换为数值的的值都会返回 true,因此非数字值传入也会返回 true ,会影响 NaN 的判断。
  • 函数 Number.isNaN 会首先判断传入参数是否为数字,如果是数字再继续判断是否为 NaN ,不会进行数据类型的转换,这种方法对于 NaN 的判断更为准确。

# typeof与instanceof

typeof 对于原始类型来说,除了null会显示成object,其他都可正常显示;对于对象来说,除了函数都会显示object。所以说typeof并不能准确判断变量到底是什么类型的。

如果想判断一个对象的正确类型,可以考虑使用 instanceof,其内部机制是通过判断对象的原型链中是不是能找到类型的 prototype。自定义实现:

function myInstanceof(left, right) {
  // 原始类型直接返回false
  if(left === null || (typeof left!= "object" && typeof left!= "function")) return false;
  // 获取对象的原型
  let proto = Object.getPrototypeOf(left);
  // 获取构造函数的 prototype 对象
  let prototype = right.prototype; 
 
  // 判断构造函数的 prototype 对象是否在对象的原型链上
  while (true) {
    if (!proto) return false;
    if (proto === prototype) return true;
    // 如果没有找到,就继续从其原型上找,Object.getPrototypeOf方法用来获取指定对象的原型
    proto = Object.getPrototypeOf(proto);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

对于原始类型来说,想直接通过 instanceof 来判断类型是不行的,但改一下instanceof还是可以判断原始类型的:

class PrimitiveString {
  static [Symbol.hasInstance](x) {
    return typeof x === 'string'
  }
}
console.log('hello world' instanceof PrimitiveString) // true
1
2
3
4
5
6

Symbol.hasInstance 是一个能让我们自定义 instanceof 行为的东西,以上代码等同于 typeof 'hello world' === 'string',所以结果自然是 true 了。

这其实也侧面反映了一个问题, instanceof 也不是百分之百可信的。

# 为什么会有BigInt的提案?

JavaScript中Number.MAX_SAFE_INTEGER表示最大安全数字,计算结果是9007199254740991(2^53-1),即在这个数范围内不会出现精度丢失(小数除外)。但是⼀旦超过这个范围,js就会出现计算不准确的情况,这在大数计算的时候不得不依靠⼀些第三方库进行解决,因此官方提出了BigInt来解决此问题。

# 类型转换

转Boolean:在条件判断时,除了 undefinednullfalseNaN''0-0,其他所有值都转为 true,包括所有对象。

**"=="**运算符:

当两个操作数的类型不相同时

  • 如果一个值是null,另一个值是undefined,则它们相等
  • 如果一个值是数字,另一个值是字符串,先将字符串转换为数学,然后使用转换后的值进行比较。
  • 如果其中一个值是true,则将其转换为1再进行比较。如果其中的一个值是false,则将其转换为0再进行比较。
  • 如果一个值是对象,另一个值是数字或字符串,则将对象转换为原始值,再进行比较。

这里有三道题:

[]==![] // true
// 首先先执行的是![],它会得到false,然后[]==false,返回true。

[]==[] // false
{}=={} // false
// 类型一致,它们是引用类型,地址是不一样的,所以为false!
1
2
3
4
5
6

包装类型:在 JavaScript 中,基本类型是没有属性和方法的,但是为了便于操作基本类型的值,在调用基本类型的属性或方法时 JavaScript 会在后台隐式地将基本类型的值转换为对象,再访问其属性。

隐式类型转换ToPrimitive(需要转换的对象,期望的结果类型)方法是 JavaScript 中每个值隐含的自带的方法,用来将值 (无论是基本类型值还是对象)转换为基本类型值。如果值为基本类型,则直接返回值本身;如果值为对象,就进行对象转原始类型。

JavaScript 中的隐式类型转换主要发生在+、-、*、/以及==、>、<这些运算符之间。而这些运算符只能操作基本类型值,所以在进行这些运算前的第一步就是将两边的值用ToPrimitive转换成基本类型,再进行操作。

对象转原始类型

对象在转换类型的时候,会调用内置的 [[ToPrimitive]] 函数,对于该函数来说,算法逻辑一般来说如下:

  • 如果已经是原始类型了,那就不需要转换了
  • 如果需要转字符串类型就先调用 x.toString(),结果不是基础类型的话再调用valueOf,转换为基础类型的话就返回转换的值。
  • 如果需要转数值类型的话就先调用 valueOf,结果不是基础类型的话再调用 toString
  • 如果都没有返回原始类型,就会抛 TypeError 异常。
  • 如果对象为 Date 对象,则type默认为string;其他情况下,type默认为number

当然可以重写 Symbol.toPrimitive ,该方法在转原始类型时调用优先级最高。

let a = {
  valueOf() {
    return 0
  },
  toString() {
    return '1'
  },
  [Symbol.toPrimitive]() {
    return 2
  }
}
1 + a // => 3
1
2
3
4
5
6
7
8
9
10
11
12

加减乘除运算符

加法运算符不同于其他几个运算符,它有以下几个特点:

  • 运算中其中一方为字符串,那么就会把另一方也转换为字符串
  • 如果一方不是字符串或者数字,那么会将它转换为数字或者字符串
1 + '1' // '11'
true + true // 2
4 + [1,2,3] // "41,2,3"
1
2
3

如果你对于答案有疑问的话,请看解析:

  • 对于第一行代码来说,触发特点一,所以将数字 1 转换为字符串,得到结果 '11'
  • 对于第二行代码来说,触发特点二,所以将 true 转为数字 1
  • 对于第三行代码来说,触发特点二,所以将数组通过 toString 转为字符串 1,2,3,得到结果 41,2,3

另外对于加法还需要注意这个表达式 'a' + + 'b'

'a' + + 'b' // -> "aNaN"
1

因为 + 'b' 等于 NaN,所以结果为 "aNaN"

对于除了加法的运算符来说,只要其中一方是数字,那么另一方就会被转为数字

4 * '3' // 12
4 * [] // 0
4 * [1, 2] // NaN
1
2
3

比较运算符

  1. 如果是对象,就通过 toPrimitive 转换对象
  2. 如果是字符串,就通过 unicode 字符索引来比较
let a = {
  valueOf() {
    return 0
  },
  toString() {
    return '1'
  }
}
a > -1 // true
1
2
3
4
5
6
7
8
9

在以上代码中,因为 a 是对象,所以会通过 valueOf 转换为原始类型再比较值。

# 判断数组

var arr = []
console.log(arr instanceof Array) // true
console.log(Array.prototype.isPrototypeOf(arr)) // true
console.log(arr.constructor === Array) // true
console.log(Object.prototype.toString.call(arr) === "[object Array]") // true
console.log(Array.isArray(arr)) // true
console.log(Object.getPrototypeOf(arr) === Array.prototype) // true
1
2
3
4
5
6
7

# 数组去重

# for in、for of、forEach、map

  • map:数组方法,不改变原数组,返回新数组,可以使用break中断,可以return到外层函数
  • forEach:数组方法,不可以使用break中断,不可以return到外层函数
  • for-in:用于遍历数组和对象,遍历对象键值(key),或者数组下标,不推荐循环一个数组;如果要迭代的变量是null或undefined,则不执行循环体。
  • for-of:遍历Arrays(数组),Strings(字符串),Maps(映射),Sets(集合)等可迭代的数据结构,按照可迭代对象的 next() 方法产生值的顺序迭代元素。在 ES6 中引入的 for-of 循环,以替代 for-in 和 forEach() ,并支持新的迭代协议。

for-in和for-of比较

  • 对于数组的遍历,for-in循环出的是key,for-of循环出的是value。for-in 会返回数组中所有可枚举的属性(包括原型链上可枚举的属性),for-of 只返回数组的下标对应的属性值。
  • 对于对象的遍历,for-in 和 Object.keys(myObject) 都用于枚举对象属性,但for-in遍历是会包括原型方法和属性(性能非常差不推荐使用),Object.keys()不包括。

# 0.1+0.2!=0.3?

# 原型和原型链

每一个JavaScript对象(null除外)在创建的时候就会与之关联另一个对象,这个对象就是我们所说的原型,每一个对象都会以其原型为模板、从原型继承方法和属性。原型对象也可能拥有原型,并从中继承方法和属性,一层一层以此类推,这种关系常被称为原型链,原型链的尽头是Object.prototype

prototype:每个函数都有一个prototype属性,这个属性指向的是对象原型。

__proto____proto__ 是一个非 es 标准的属性,它对应的是 es 标准中的 [[prototype]],因为 [[prototype]] 是一个内部属性,无法直接访问,所以 es6 中提供了 Object.getPrototypeOf/Object.setPrototypeOf 来读取、操作 [[prototype]],所以说 __proto__ 实际是 getter/setter,即

obj.__proto__
 ===> 
get __proto__ = function() { return Object.getPrototypeOf(this) }
set __proto__ = function(newPrototype) { return Object. setPrototypeOf(this, newPrototype) }
1
2
3
4

constructor:每一个原型都有一个constructor属性指向关联的构造函数。

原型链的指向

function Person(name) {
  this.name = name;
}
var p = new Person('hello');
/** 
 * 正常的原型链都会终止于 Object 的原型对象
 * Object 原型的原型是 null 
 * 构造函数 Person
 * 构造函数的原型对象 Person.prototype
 * 实例的原型对象 p.__proto__
 * 原型对象指回构造函数 Person.prototype.constructor===Person
 * Person.prototype===p.__proto__
 */ 

p.__proto__  // Person.prototype
Person.prototype.__proto__  // Object.prototype
p.__proto__.__proto__ //Object.prototype
p.__proto__.constructor.prototype.__proto__ // Object.prototype
Person.prototype.constructor.prototype.__proto__ // Object.prototype
p.__proto__.constructor // Person
Person.prototype.constructor  // Person
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

image-20220303161336181

需要注意的地方:

  1. 在传统的OOP中,首先定义"类",再创建对象实例时,类中定义的所有属性和方法都被复制到实例中。而JS中的对象是通过引用来传递的,创建的每个新对象实体中并没有一份属于自己的原型副本,而是在对象实例和它的构造器之间建立一个链接(__proto__属性,是从构造函数的prototype属性派生的),之后通过上溯原型链,在构造器中找到这些属性和方法。当修改原型时,与之相关的对象也会继承这一改变

  2. Object.prototype.__proto__ === null 跟 Object.prototype 没有原型,其实表达的是一个意思。

  3. function Person() {}
    var person = new Person();
    console.log(person.constructor === Person); // true
    
    1
    2
    3

    当获取 person.constructor 时,其实 person 中并没有 constructor 属性,当不能读取到 constructor 属性时,会从 person 的原型也就是 Person.prototype 中读取,正好原型中有该属性,所以打印true。

# 词法作用域与动态作用域

作用域是指程序源代码中定义变量的区域。它规定了如何查找变量,即确定了当前执行代码对变量的访问权限。JavaScript采用词法作用域,也就是静态作用域

  • 静态作用域:函数的作用域在函数定义时就决定了,理解了这句话就能理解闭包
  • 动态作用域:函数的作用域是在函数调用的时候才决定的

看个栗子:

var value = 1;
function foo() {
    console.log(value);
}
function bar() {
    var value = 2;
    foo();
}
bar();// 结果是 ???
1
2
3
4
5
6
7
8
9

假设JavaScript采用静态作用域,让我们分析下执行过程:

执行 foo 函数,先从 foo 函数内部查找是否有局部变量 value,如果没有,就根据书写的位置,查找上面一层的代码,也就是 value 等于 1,所以结果会打印 1。

假设JavaScript采用动态作用域,让我们分析下执行过程:

执行 foo 函数,依然是从 foo 函数内部查找是否有局部变量 value。如果没有,就从调用函数的作用域,也就是 bar 函数内部查找 value 变量,所以结果会打印 2。

因为JavaScript采用的是静态作用域,所以这个例子的结果是 1。

再看个栗子:

// 1
var scope = "global scope";
function checkscope(){
  var scope = "local scope";
  function f(){
    return scope;
  }
  return f();
}
checkscope();

// 2
var scope = "global scope";
function checkscope(){
  var scope = "local scope";
  function f(){
    return scope;
  }
  return f;
}
checkscope()();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

两段代码都会打印:local scope,因为JavaScript采用的是词法作用域,函数的作用域基于函数创建的位置。

而引用《JavaScript权威指南》的回答就是:JavaScript 函数的执行用到了作用域链,这个作用域链是在函数定义的时候创建的。嵌套的函数 f() 定义在这个作用域链里,其中的变量 scope 一定是局部变量,不管何时何地执行函数 f(),这种绑定在执行 f() 时依然有效。

虽然两段代码执行的结果一样,但不同之处就在于执行上下文栈的变化不一样。第一段代码执行上下文栈有两层;而第二段代码栈只有一层(这里没有考虑全局上下文)。

# 执行上下文栈

JS引擎并非一行一行地分析和执行程序,而是一段一段地分析执行。当执行一段代码的时候,会进行一个“准备工作”,比如变量提升,函数提升等。那么这一段一段的”段“怎么划分?JS引擎遇到一段怎么样的代码才会做“准备工作”呢?

可执行代码:JS的可执行代码就四种:全局代码、函数代码、eval代码、module代码。例如,当执行到一个函数时,就会进行准备工作,而这里的准备工作其实就是执行上下文。我们可能创建很多的函数,所以JS引擎创建了**执行上下文栈(ECS)**来管理执行上下文。

全局上下文是最外层的上下文。当代码执行流进入函数时,函数的上下文就被推到一个上下文栈上,在函数执行完之后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文。JavaScript是单线程的,JavaScript程序的执行流就是通过这个上下文栈进行控制的

执行上下文分为创建阶段执行阶段,以函数执行上下文为例:

  1. 当调用函数时,在还没执行里面代码前,即创建阶段,执行上下文会创建变量对象作用域链和决定this值
  2. 进入执行阶段,会顺序执行函数中每一行代码,进行变量赋值、函数引用等动作

image-20220304153134341

# 变量对象

不同的执行上下文中的变量对象稍有不同,主要介绍一下全局上下文和函数上下文中的变量对象。

全局上下文

  • 全局上下文的变量对象就是全局对象
  • 全局对象是由Object构造函数实例化的一个对象。在客户端JS中,全局对象就是Window对象。
  • 全局对象是预定义的对象,作为 JavaScript 的全局函数和全局属性的占位符。通过使用全局对象,可以访问所有其他所有预定义的对象、函数和属性,比如Math、String、Date等。
  • 在顶层JS代码中,可以用关键字 this 引用全局对象。
  • 因为全局对象是作用域链的头,所以在顶层JS代码中声明的所有变量都将成为全局对象的属性。

函数上下文

  • 在函数上下文中,我们用活动对象(AO)来表示变量对象。
  • 活动对象和变量对象其实是一个对象,只是处于执行上下文的不同生命周期。变量对象是规范上或者说是引擎实现上的,不可在JS环境中访问,只有到进入一个执行上下文中(执行阶段),这个执行上下文的变量对象才会被激活,所以叫活动对象。而只有被激活的变量对象,也就是活动对象上的各种属性才能被访问

也就是说,在执行上下文的创建阶段会创建变量对象;在执行阶段,变量对象会被激活为活动对象。

变量对象包括

  1. 函数的所有形参(如果是函数上下文):
    • 由名称和对应值组成的一个变量对象的属性被创建
    • 没有实参,属性值设为undefined
  2. 函数声明
    • 由名称和对应值(函数对象(function-object))组成一个变量对象的属性被创建
    • 如果变量对象已经存在相同名称的属性,则完全替换这个属性
  3. 变量声明
    • 由名称和对应值(undefined)组成一个变量对象的属性被创建
    • 如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性(不会覆盖)
    • let/const声明的变量也会提升,但是不会给它赋值undefined,也就是说虽然变量存在,但是没有赋值,还是不能访问,这就是常说的暂时性死区

举个栗子:

function foo(a) {
  var b = 2;
  function c() {}
  var d = function() {};
  b = 3;
}
foo(1);
1
2
3
4
5
6
7

进入执行上下文后,分析得到的AO是:

AO = {
  arguments: {
    0: 1,
    length: 1
  },
  a: 1,
  b: undefined,
  c: reference to function c(){},
  d: undefined
}
1
2
3
4
5
6
7
8
9
10

进入执行阶段,根据代码,修改变量对象的值,执行完后的AO是:

AO = {
  arguments: {
    0: 1,
    length: 1
  },
  a: 1,
  b: 3,
  c: reference to function c(){},
  d: reference to FunctionExpression "d"
}
1
2
3
4
5
6
7
8
9
10

话不多说,看题🧐:

function foo() {
  console.log(a);
  a = 1;
}

foo(); // ???

function bar() {
  a = 1;
  console.log(a);
}
bar(); // ???
1
2
3
4
5
6
7
8
9
10
11
12

第一段会报错:Uncaught ReferenceError: a is not defined;第二段会打印:1

第一段执行时,因为函数foo中的"a"没有通过 var 或其他关键字声明,所以不会被存放在AO中。去全局中也没有找到,所以报错。

第二段执行时,全局对象已经被赋予了"a"属性,这时候就可以从全局中找到"a",打印1。

# 作用域链

当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链

函数创建

前面说到,函数的作用域在函数定义的时候就决定了。这是因为函数有一个内部属性[[scope]],当函数创建的时候,就会保存所有父变量对象到其中,可以理解为[[scope]]就是所有父变量对象的层级链,但并不代表完整的作用域链!

举个栗子:

 function foo() {
    function bar() {
        ...
    }
}

// 函数创建时,各自的[[scope]]为:
foo.[[scope]] = [
  globalContext.VO
];

bar.[[scope]] = [
    fooContext.AO,
    globalContext.VO
];
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

函数激活

当函数激活时,进入函数上下文,创建VO/AO后,就会将活动对象添加到作用域链的前端。假设此时执行上下文的作用域链命名为Scope:

Scope = [AO].concat([[Scope]]);
1

至此,作用域链创建完毕。

来捋一下整个过程:

var scope = "global scope";
function checkscope(){
  var scope2 = 'local scope';
  return scope2;
}
checkscope();
1
2
3
4
5
6
  1. checkscope 函数被创建,保存父级变量对象到内部属性[[scope]]
  2. 调用 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈
  3. checkscope 函数并不立刻执行,开始做准备工作,第一步:复制函数[[scope]]属性创建作用域链
  4. 第二步:用 arguments 创建变量对象,随后初始化变量对象,加入形参、函数声明、变量声明
  5. 第三步:将变量对象压入 checkscope 作用域链顶端
  6. 准备工作做完,开始执行函数,这里变量对象被激活为活动对象,随着函数的执行,修改 AO 的属性值
  7. 查找到 scope2 的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出

# this

先看一道题:

function foo() {
    console.log(this.a)
}
var a = 1
foo(); // 1
const obj = {
    a: 2,
    foo: foo
}
obj.foo() // 2
const c = new foo() // 3
1
2
3
4
5
6
7
8
9
10
11
  1. 直接调用foo:不管foo函数被放在了地方,this 一定是 window。
  2. 作为对象属性obj.foo():谁调用了函数,this就指向谁,所以在这里foo函数的this就是obj对象。
  3. 使用new调用:this被绑定在了实例 c 上面,使用bind无法改变其指向,但call、apply可以。

这几种场景是最常见的,接下来看看箭头函数中的this:

function a() {
    return () => {
        return () => {
            console.log(this)
        }
    }
}
console.log(a()()())
1
2
3
4
5
6
7
8

箭头函数其实是没有this的,箭头函数中的this永远指向上级作用域的this。在这个例子中,箭头函数的this指向函数 a 的this,而函数 a 的this就是window,所以此时的this就是window。另外对箭头函数使用bind、call和apply函数是无效的,即箭头函数的this一旦被绑定,就不会被任何方式改变

当以上多个规则同时出现时,根据优先级最高的来决定 this 最终的指向:

使用new调用 > bind这类函数 > 作为对象属性obj.foo()调用 > 直接调用foo

# 闭包

闭包是指那些能够访问自由变量的函数。自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量(父级变量)。

从理论上讲,所有函数都是闭包,因为它们在创建的时候就将上层上下文的数据保存起来了。

从实践角度讲,满足以下条件的函数才属于闭包:

  1. 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
  2. 在代码中引用了自由变量

简单来说就是,闭包就是在定义这个函数的词法作用域外还能被调用的函数。闭包在函数创建时创建。

var scope = "global scope";
function checkscope(){
  var scope = "local scope";
  function f(){
    return scope;
  }
  return f;
}

var foo = checkscope();
foo();
1
2
3
4
5
6
7
8
9
10
11

在这个栗子中,checkscope 函数上下文出栈后,foo 函数上下文入栈执行,但依旧可以读取到checkscope作用域下scope的值。这是因为foo执行上下文维护了一个作用域链,即使checkscope的上下文被销毁了,foo的变量对象依旧在内存中,foo函数依然可以通过其作用域链找到scope,从而实现了闭包这个概念。

到做题的时候了:

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

不出意外,将打印出5个6,因为 setTimeout 是异步函数,所以会先把循环全部执行完毕,这个时候i就是6了。有下面3种解决办法:

  1. 使用闭包

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

    为什么将i传进函数就行了呢?因为j是匿名函数的形参,会被包含进执行上下文创建的变量对象中,而j中保存了此时i的值,所以setTimeout里面代码执行的时候能打印出预期答案。

  2. 使用setTimeout第三个参数

    for (var i = 1; i <= 5; i++) {
      setTimeout(
        function timer(j) {
          console.log(j)
        }, i * 1000, i
      )// 第三个参数会被当成timer函数的参数传入
    }
    
    1
    2
    3
    4
    5
    6
    7
  3. 使用let定义i

闭包的应用场景

  1. 使用闭包来模拟私有方法

如果不是某些特定任务需要使用闭包,在其他函数中创建闭包是不明智的,因为闭包在处理速度和内存消耗方面对脚本性能具有负面影响

# call、apply、bind

这三个方法都是Function.prototype下的方法,均用于改变函数运行时上下文(this的指向)。

call和apply作用都是相同的,只是传参方式不同,除了第一个参数外,call可以接收一个参数列表,apply只接收一个参数数组。

bind和其他两个方法作用一致,但bind会返回一个函数。

# 实现call

  1. 参数:第一个是this绑定的对象(不填默认为window);后面的其余参数是传入调用call的函数执行的参数
  2. 实现思路就是将调用call的函数加入到对象的属性中,这样就改变了this指向,为了不影响对象的值,执行完后再删除这个加上去的属性
Function.prototype.myCall = function(context) {
  // 判断调用对象是否为函数
  if (typeof this !== "function") {
    console.error("type error");
  }
  // 获取参数
  let args = [...arguments].slice(1),
      result = null;
  // 判断 context 是否传入,如果未传入则设置为 window
  context = context || window;
  // 将调用函数设为对象的方法
  context.fn = this;
  // 调用函数
  result = context.fn(...args);// 这个fn就是调用call的函数bar,如果bar有返回值,则存入result中
  // 将属性删除
  delete context.fn;
  return result;
};

// 测试
var value = 2;
var obj = {
  value: 1
}
function bar(name, age) {
  console.log(this.value);
  return {
    value: this.value,
    name: name,
    age: age
  }
}
bar.myCall(null); // 2
console.log(bar.myCall(obj, 'kevin', 18));
// 1
// Object {
//    value: 1,
//    name: 'kevin',
//    age: 18
// }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

# 实现apply

  1. apply接收两个参数:第一个参数指定this绑定的对象;第二个参数是个数组
//思路一致
Function.prototype.myApply=function(context, args = []){
  if(typeof this !=='function'){
    throw new TypeError('error');
  }
  context=context||window;
  context.fn=this;
  let result;
  // 参数处理有区别,第二个参数是一个数组
  result=context.fn(...args);
  delete context.fn;
  return result;
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 实现bind

  • bind内部要返回一个函数,而fun.bind(obj,arg1,...)返回一个原函数的拷贝,并拥有指定的this值和初始参数。
  • bind返回的函数不会马上执行,且还可以再传入参数!
Function.prototype.myBind=function(context){
  if (typeof this !== 'function') {
    throw new TypeError('error');
  }
  const fn=this;// this是调用bind的函数
  const args=[...arguments].slice(1);
  return function Fn(){
    // 如果使用 new 来调用函数,则不改this的执行,因为使用new的方式,任何方法都不能改变其this指向
    return fn.apply(
      this instanceof Fn? this : context,
      args.concat(...arguments)// 这里的arguments是指传入 Fn 的参数,将之前传的参数合并
    );
  }
}

// 测试
function test() {...}
let boundTest = test.myBind({name: 'doubao'}, 1, 2, 3);
let boundRes = boundTest(4);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 应用

  1. 使用Object.prototype.toString.call()来判断类型

    因为 Object.prototype.toString() 方法会返回对象的类型字符串,输出 "[object Object]"。其中第二个 Object 是传入参数的构造函数。所以使用 call 就可以指定任意的值和结合 toString 将组成的构造函数类型返回来判断类型。同样道理换成 apply/bind 同样也可以判断。如Object.prototype.toString.call([])。

  2. 使用call()实现将类数组转化成数组

    let array = [12, 23, 45, 65, 32]
    function fn(array){
        var args = [].slice.call(arguments)
        // 或使用Array.prototype.slice()
        return args[0]
    }
    fn(array)   // [12, 23, 45, 65, 32]
    
    1
    2
    3
    4
    5
    6
    7

    上面利用 call 改变了 slice 的 this 指向 arguments 来遍历输出(slice的this原本指向[],转变后类似于[12, 23, 45, 65, 32].slice()的效果)。

# new与构造函数

new操作符的执行过程

  1. 创建了一个新的空对象
  2. 设置原型,将对象的原型设置为函数的prototype对象(实例与原型的关联)
  3. 让函数的this指向这个对象,执行构造函数的代码(为这个新对象添加属性)
  4. new操作符执行完后会返回一个对象
  5. 判断构造函数是否有返回值。如果返回值为基本类型或null,返回new中新创建的对象;如果是引用类型,就返回这个引用类型的对象(二选一)

所以在使用new构造函数时,尽量不要写返回值,如果返回值为对象,那new就没有原本的作用了。

new的模拟实现

因为new是关键字,无法像bind函数一样直接覆盖,所以模拟实现时使用函数,命名为newObj。

function newObj() {
  let Constructor = [].shift.call(arguments);// 拿到构造函数
  let args = [...arguments].slice(1); // 拿到传给构造函数的参数
  let obj = Object.create(Constructor.prototype);//create中的函数是要创建对象的原型
  let ret = Constructor.apply(obj, args);// apply会绑定this,并执行函数
  return ret instanceof Object ? ret : obj; 
}
1
2
3
4
5
6
7

# 类数组对象与arguments

类数组对象就是拥有一个length属性和若干索引属性的对象。类数组对象可以像数组一样读写、获取长度及遍历,但是类数组对象不能调用数组的方法。常见的类数组对象有arguments、DOM方法的返回结果。

使用call和apply间接调用数组方法

var arrayLike = {0: 'name', 1: 'age', 2: 'sex', length: 3 }
Array.prototype.join.call(arrayLike, '&'); // name&age&sex
Array.prototype.slice.call(arrayLike, 0); // ["name", "age", "sex"] 
// slice可以做到类数组转数组
Array.prototype.map.call(arrayLike, function(item){
    return item.toUpperCase();
}); 
// ["NAME", "AGE", "SEX"]
1
2
3
4
5
6
7
8

类数组转数组的4种方法

var arrayLike = {0: 'name', 1: 'age', 2: 'sex', length: 3 }
// 1. slice
Array.prototype.slice.call(arrayLike); // ["name", "age", "sex"] 
// 2. splice
Array.prototype.splice.call(arrayLike, 0); // ["name", "age", "sex"] 
// 3. ES6 Array.from
Array.from(arrayLike); // ["name", "age", "sex"] 
// 4. concat
Array.prototype.concat.apply([], arrayLike)
1
2
3
4
5
6
7
8
9

# Arguments对象

Arguments 对象只定义在函数体中,包括了函数的参数和其他属性。在函数体中,arguments 指代该函数的 Arguments 对象。

举个栗子:

function foo(name, age, sex) {
    console.log(arguments);
}
foo('name', 'age', 'sex')
1
2
3
4

打印结果:

image-20220305150515582

length属性:表示函数实参的个数,而不是形参,传了多少就是多少。

callee属性:通过它可以调用函数自身。可以解决闭包和递归函数中引用丢失的情况。

func.caller表示的是调用当前函数的函数,全局下返回null。callee和caller在严格模式下都会抛出错误。

// 闭包
var data = [];
for (var i = 0; i < 3; i++) {
  (data[i] = function () {
    console.log(arguments.callee.i) 
  }).i = i;
}
data[0]();// 0
data[1]();// 1
data[2]();// 2

// 递归
function factorial(num) {
  if(num <= 1) return 1
  return num * arguments.callee(num - 1);
}
let fn = factorial;
factorial = null;
console.log(fn(4)); // 24
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 防抖和节流

在前段开发中会遇到一些频繁的事件触发,比如:

  1. window的resize、scroll
  2. mousedown、mousemove
  3. keyup、keydown
  4. ...

如果是复杂的回调函数或ajax被频繁触发呢?假设1秒触发了60次,每个回调就必须在 1000/60=16.67ms内完成,否则就会有卡顿。为了解决这个问题,一般有两种解决方案:

  1. debounce 防抖
  2. throttle 节流

防抖是虽然事件持续触发,但只有等事件停止触发后 n 秒才执行函数,节流是持续触发的时候,每 n 秒执行一次函数。

# 防抖

特点:

  1. 对于在事件被触发n秒后在执行回调,即延迟n秒执行
  2. 如果在这n秒内再触发事件,就重新开始计时
  3. 如果传入了第三个参数,事件就会立即执行,在n秒后恢复立即执行,特点2也适用
function debounce(func, wait, immediate) {// 第三个参数表示是否立即执行第一次
  var timeout, result;

  var debounced = function () {
    var self = this;
    // 这里的this指向的是下面例子中的container,让要调用的函数的this也指向container,不然它会指向全局
    var args = arguments;

    if (timeout) clearTimeout(timeout); // 如果有定时器就清除定时器,因为后面需要重新计时
    // 1. 立即执行的情况
    if (immediate) {
      var callNow = !timeout;
      // 重新计时:n秒后置定时器为null,这样又可以触发执行了
      timeout = setTimeout(function(){
        timeout = null;
      }, wait)
      if (callNow) result = func.apply(self, args)
      // 如果定时器为空,说明已经过了n秒,立即执行且重新计时
      // 如果不为空,说明还没有到n秒时间,重新计时
    }
    // 2. 延迟执行的情况
    else { 
      timeout = setTimeout(function(){
        func.apply(self, args) // func()的进化版,因为考虑func可能有返回值
      }, wait);
    }
    return result;// 返回func函数返回值
  };

  debounced.cancel = function() {
    clearTimeout(timeout);
    timeout = null;
  };

  return debounced;
}


// 使用:输出输入框的值
var container = document.getElementById('container');
function get() {
  console.log(this.value);
}
var db = debounce(get, 1000, true);
// 输入框每次输入都会执行返回函数debounced,因为形成了闭包,所以可以保留上次定时器
container.oninput = db;
document.getElementById("btn").addEventListener('click', function(){
    db.cancel();
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

html部分:

<input type="text" id="container" />
<button id="btn">取消debounce</button>
1
2

需要注意的几个问题:

  1. 为什么要用apply绑定this? 假设func中有用到this,比如通过this.value来获取输入框的值,那么如果不使用apply的话,this会指向全局,而这里要求func指向调用这个函数的对象。

  2. immediate立即执行是什么意思? 不希望是在事件被触发后n秒执行,而是只要被触发就立即执行,但是执行后,必须等到n秒后才能再次触发执行,如果n秒内再次触发,则不执行且重新计时。

  3. clearTimeout(timer)和timer=null有什么区别?

前者只是清除了定时器,而不是清除timer为空值;而后者是将timer置为空,而没有清除定时器。

# 节流

特点:

  1. 如果持续触发事件,每隔一段时间,只执行一次事件。
  2. 有两种情况:首次事件触发是否立即执行;停止触发后是否要再执行一次
  3. 关于节流的实现,有两种主流方式:
    • 时间戳:当触发事件的时候,我们取出当前的时间戳,然后减去之前的时间戳(最一开始值设为 0 ),如果大于设置的时间周期,就执行函数,然后更新时间戳为当前的时间戳,如果小于,就不执行。
    • 设置定时器:当触发事件的时候,我们设置一个定时器,再触发事件的时候,如果定时器存在,就不执行,直到定时器执行,然后执行函数,清空定时器,这样就可以设置下个定时器。
/* 
第三个参数可选择想要的效果:
1. leading:false 表示禁用第一次执行
2. trailing: false 表示禁用停止触发的回调
*/
function throttle(func, wait, options) {
  var timeout, context, args, result;
  var previous = 0;// 时间戳
  if (!options) options = {};// 如果没有传这个参数就设为空对象

  var later = function() {
    previous = options.leading === false ? 0 : new Date().getTime();
    timeout = null;
    func.apply(context, args);
    if (!timeout) context = args = null;
  };

  var throttled = function() {
    var now = new Date().getTime();// 获取当前时间戳
    if (!previous && options.leading === false) previous = now; //只在previous为0,且需要首次执行的时候才执行
    //下次触发 func 剩余的时间(每次触发都要计算)
    var remaining = wait - (now - previous);
    context = this;
    args = arguments;
    // 如果没有剩余的时间了或者你改了系统时间
    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout);
        timeout = null;
      }
      previous = now;
      func.apply(context, args);
      if (!timeout) context = args = null;
    } else if (!timeout && options.trailing !== false) {
      timeout = setTimeout(later, remaining);
    }
  };
  
  throttled.cancel = function() {
    clearTimeout(timeout);
    previous = 0;
    timeout = null;
  }
  
  return throttled;
}

// 使用,默认第三个选项都为true
container.oninput = throttle(get, 1000);
container.oninput = throttle(get, 1000, {
    leading: false
});
container.oninput = throttle(get, 1000, {
    trailing: false
});
/*
leading:false 和 trailing: false 不能同时设置。
如果同时设置的话,比如当你将鼠标移出的时候,因为 trailing 设置为 false,停止触发的时候不会设置定时器,所以只要再过了设置的时间,再移入的话,就会立刻执行,就违反了 leading: false,bug 就出来了。
*/ 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59

# 深拷贝与浅拷贝

# 浅拷贝

特点:如果数组元素是基本类型,就会拷贝一份,互不影响。而如果是对象或者数组,就会只拷贝对象和数组的引用,这样我们无论在新旧数组进行了修改,两者都会发生变化。

// 对象的浅拷贝
let a = {
  age: 1
}
// 1. Object.assign 拷贝所有属性值到新对象中,如果属性值是对象的话,拷贝地址
let b = Object.assign({}, a)
// 2. 展开运算符
let b = {...a}

// 数组的浅拷贝
var arr = ['old', 1, true, null, undefined];
// 1. concat
var new_arr = arr.concat();
// 2. slice
var new_arr = arr.slice();
new_arr[0] = 'new';
console.log(arr) // ["old", 1, true, null, undefined]
console.log(new_arr) // ["new", 1, true, null, undefined]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

实现

var shallowCopy = function(obj) {
  // 只拷贝对象
  if (typeof obj !== 'object') return;
  // 根据obj的类型判断是新建一个数组还是对象
  var newObj = obj instanceof Array ? [] : {};
  // 遍历obj,并且判断是obj的属性才拷贝
  for (var key in obj) {
    if (obj.hasOwnProperty(key)) {
      newObj[key] = obj[key];
    }
  }
  return newObj;
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 深拷贝

特点:完全的拷贝一个对象,即使嵌套了对象,两者也相互分离,修改一个对象的属性,也不会影响另一个。

这个问题通常可以通过JSON.parse(JSON.stringify(object))来解决,但也有局限性:

  1. 会忽略 undefined 和 symbol
  2. 不能序列化函数
  3. 不能解决循环引用的问题

loader中可以使用deepclone解决。

实现

var deepCopy = function(obj) {
  if (typeof obj !== 'object') return;
  var newObj = obj instanceof Array ? [] : {};
  for (var key in obj) {
    if (obj.hasOwnProperty(key)) {
      newObj[key] = typeof obj[key] === 'object' ? deepCopy(obj[key]) : obj[key];
    }
  }
  return newObj;
}
1
2
3
4
5
6
7
8
9
10

# 偏函数、柯里化

# Promise

# AJAX请求

# 面向对象