js面试——原理篇(一)

这是我第23篇简书。

为什么简书没有目录功能啊,要不是在这这么久了都想转去某金了。。

本章内容:

1、执行上下文
2、js内存空间
3、闭包
4、作用域链
5、构造函数
6、原型与原型链
7、this

1、执行上下文

(1)分类
  • ① 全局执行上下文
    只有一个,浏览器中的全局对象就是 window 对象,this 指向这个全局对象。
  • ② 函数执行上下文
    存在无数个,只有在函数被调用的时候才会被创建,每次调用函数都会创建一个新的执行上下文。
  • ③ Eval 函数执行上下文
    指的是运行在 eval 函数中的代码,很少用而且不建议使用。
(2)声明提升

JS是单线程的语言,执行顺序肯定是顺序执行,但是JS 引擎并不是一行一行地分析和执行程序,而是一段一段地分析执行,会先进行编译阶段然后才是执行阶段。
① 变量提升(是个前端都知道)
② 函数提升(很脑残,没人会这么用,只有笔试题能见到)

foo();  // bbb
function foo() {
    console.log('aaa');
}

foo();  // bbb

function foo() {
    console.log('bbb');
}

foo(); // bbb

③ 声明优先级,函数 > 变量

此例子划重点:

foo();  // bbb
var foo = function() {
    console.log('aaa');
}

foo();  // aaa,函数声明先提前,然后后变量声明foo重新赋值

function foo() {
    console.log('bbb');
}

foo(); // aaa
(3)函数上下文

在函数上下文中,用活动对象(activation object, AO)来表示变量对象。

【 活动对象和变量对象的区别在于:

  • ①变量对象(VO)是规范上或者是JS引擎上实现的,并不能在JS环境中直接访问。
  • ②当进入到一个执行上下文后,这个变量对象才会被激活,所以叫活动对象(AO),这时候活动对象上的各种属性才能被访问。】

调用函数时,会为其创建一个Arguments对象,并自动初始化局部变量arguments,指代该Arguments对象。所有作为参数传入的值都会成为Arguments对象的数组元素。

(4)执行上下文栈

因为JS引擎创建了很多的执行上下文,所以JS引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文。
当 JavaScript 初始化的时候会向执行上下文栈压入一个全局执行上下文,我们用 globalContext 表示它,并且只有当整个应用程序结束的时候,执行栈才会被清空,所以程序结束之前, 执行栈最底部永远有个 globalContext。

(5)执行过程

进入执行上下文
此时的变量对象会包括(如下顺序初始化):

  • 函数的所有形参 (only函数上下文):没有实参,属性值设为undefined。
  • 函数声明:如果变量对象已经存在相同名称的属性,则完全替换这个属性。(声明提升中有提到)
  • 变量声明:如果变量名称跟已经声明的形参函数相同,则变量声明不会干扰已经存在的这类属性。
function foo(a) {
  var b = 2;
  function c() {}
  var d = function() {};

  b = 3;
}

foo(1);

这时的AO是:
AO = {
  // 形参
  arguments: {
    0: 1,
    length: 1
  },
  a: 1, 
  b: undefined,
  c: reference to function c(){},
  d: undefined
}

代码执行
这个阶段会顺序执行代码,修改变量对象的值,执行完成后AO如下:

AO = {
  arguments: {
      0: 1,
      length: 1
  },
  a: 1,
  b: 3,
  c: reference to function c(){},
  d: reference to FunctionExpression "d"
}
(6)总结
  • 全局上下文的变量对象初始化是全局对象window

  • 函数上下文的变量对象初始化只包括 Arguments 对象

  • 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值

  • 在代码执行阶段,会再次修改变量对象的属性值

2、js内存空间

(1)变量的存放

基本类型 --> 保存在栈内存中,因为这些类型在内存中分别占有固定大小的空间,通过按值来访问。基本类型一共有6种:UndefinedNullBooleanNumberStringSymbol

引用类型 --> 保存在堆内存中,因为这种值的大小不固定,因此不能把它们保存到栈内存中,而是保存在堆内存中。要注意的是,在栈内存中存放的只是该对象的访问地址,所以当查询引用类型的变量时, 先从栈中读取内存地址, 然后再通过地址找到堆中的值。对于这种,我们把它叫做按引用访问。

(2)数据结构

① 栈数据结构
栈的结构采取后进先出,先进后出原则。用来存放基本类型:UndefinedNullBooleanNumberStringSymbol
处于盒子中最顶层的乒乓球5,它一定是最后被放进去,但可以最先被使用。而我们想要使用底层的乒乓球1,就必须将上面的4个乒乓球取出来,让乒乓球1处于盒子顶层。


② 堆数据结构
堆数据结构是一种树状结构。主要用来存放Object
它的存取数据的方式与书架和书非常相似。我们只需要知道书的名字就可以直接取出书了,并不需要把上面的书取出来。JSON格式的数据中,我们存储的key-value可以是无序的,因为顺序的不同并不影响我们的使用,我们只需要关心书的名字。

③ 比较
在计算机的数据结构中,栈的运算速度 > 堆的运算速度。
JS内存空间分为栈、堆、池(一般也会归类为栈中)。 其中栈存放变量堆存放复杂对象池存放常量,所以也叫常量池。
Object是一个复杂的结构且可以扩展:数组可扩充,对象可添加属性,都可以增删改查。将他们放在堆中是为了不影响栈的效率,而是通过引用的方式查找到堆中的实际对象再进行操作。所以查找引用类型值的时候先去栈查找再去堆查找。

(3)内存生命周期

分配你所需要的内存 --> 使用分配到的内存(读、写)--> 不需要时将其释放、归还

JavaScript有自动垃圾收集机制,最常用的是通过标记清除的算法来找到哪些对象是不再继续使用的,实际上使用a = null其实仅仅只是做了一个释放引用的操作,让 a 原本对应的值失去引用,脱离执行环境,这个值会在下一次垃圾收集器执行操作时被找到并释放。

在局部作用域中,当函数执行完毕,局部变量也就没有存在的必要了,因此垃圾收集器很容易做出判断并回收。但是全局变量什么时候需要自动释放内存空间则很难判断,因此在开发中,需要尽量避免使用全局变量。(回答为什么要尽少使用全局变量)

(4)内存泄漏

对于持续运行的服务进程(daemon),必须及时释放不再用到的内存。否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。 对于不再用到的内存,没有及时释放,就叫做内存泄漏。

1)四种常见的JS内存泄漏:

① 意外的全局变量
未定义的变量会在全局对象创建一个新变量。
解决方法:
在 JavaScript 文件头部加上 'use strict',使用严格模式避免意外的全局变量,此时的this指向undefined。如果必须使用全局变量存储大量数据时,确保用完以后把它设置为 null 或者重新定义。

② 被遗忘的计时器或回调函数

③ 脱离 DOM 的引用
如果代码中保存了表格某一个 <td> 的引用。将来决定删除整个表格的时候,直觉认为 GC 会回收除了已保存的 <td> 以外的其它节点。实际情况并非如此:此 <td> 是表格的子节点,子元素与父元素是引用关系。由于代码保留了 <td> 的引用,导致整个表格仍待在内存中。所以保存 DOM 元素引用的时候,要小心谨慎。

闭包
常驻内存中,会增大内存的使用量,使用不当会造成内存泄露;另外js对闭包的处理速度会低于普通函数,过度使用闭包也会降低脚本性能。
闭包的关键是匿名函数可以访问父级作用域的变量。
解决方法:
在退出函数之前,将不使用的局部变量全部删除。

2)内存泄漏识别方法:
① 浏览器方法

  • 打开开发者工具,选择 Memory
  • 在右侧的Select profiling type字段里面勾选 timeline
  • 点击左上角的录制按钮。
  • 在页面上进行各种操作,模拟用户的使用情况。
  • 一段时间后,点击左上角的 stop 按钮,面板上就会显示这段时间的内存占用情况。

② 命令行方法
使用 Node 提供的 process.memoryUsage 方法。

console.log(process.memoryUsage());
// 输出
{ 
  rss: 27709440,        // resident set size,所有内存占用,包括指令区和堆栈
  heapTotal: 5685248,   // "堆"占用的内存,包括用到的和没用到的
  heapUsed: 3449392,    // 用到的堆的部分
  external: 8772        // V8 引擎内部的 C++ 对象占用的内存
}

判断内存泄漏,以heapUsed字段为准

3)ES6新数据结构:

ES6 新出的两种数据结构:WeakSetWeakMap,表示这是弱引用,它们对于值的引用都是不计入垃圾回收机制的。

const wm = new WeakMap();
const element = document.getElementById('example');

wm.set(element, 'some information');
wm.get(element) // "some information"

如上示例,先new一个 Weakmap 实例,然后将一个 DOM 节点作为键名存入该实例,并将一些附加信息作为键值,一起存放在 WeakMap 里面。这时,WeakMap 里面对element的引用就是弱引用,不会被计入垃圾回收机制

(5)队列

队列是一种先进先出(FIFO)的数据结构,它是事件循环(Event Loop)的基础结构。


3、闭包

  • 定义:
    指有权访问另外一个函数作用域中的变量的函数。
    在函数外却能够读取函数内部的变量,它是连接函数内外的桥梁。

  • 特性:
    ① 闭包可以访问当前函数以外的变量;
    ② 即使外部函数已经返回,闭包仍能访问外部函数定义的变量;
    ③ 闭包可以更新外部变量的值

  • 好处与坏处::
    读取函数内部的变量,并且能让变量的值始终保持在内存中,函数执行完毕后不会被释放。

function createClosure(){
    var name = "jack";
    return {
        setStr:function(){
            name = "rose";
        },
        getStr:function(){
            return "Hello:" + name;
        }
    }
}
var builder = new createClosure();
builder.setStr();
console.log(builder.getStr()); // Hello:rose

上面在函数中返回了两个闭包,这两个闭包都维持着对外部作用域的引用。闭包中会将外部函数的自由对象添加到自己的作用域链中,所以可以通过内部函数访问外部函数的属性,并且改变了函数外变量的值。

4、作用域链

当访问一个变量时,解释器会首先在当前作用域查找标示符,如果没有找到,就去父作用域找,直到找到该变量的标示符或者不在父作用域中,这就是作用域链。

作用域链和原型继承查找时的区别:
如果去查找一个普通对象的属性,在当前对象和其原型中都找不到时,会返回undefined;查找的属性在作用域链中不存在的话会抛出ReferenceError。

5、构造函数

(1)constructor 属性返回创建实例对象时构造函数的引用。
在Javascript 语言中,constructor属性是专门为 function 而设计的,它存在于每一个function 的prototype 属性中。这个constructor 保存了指向 function 的一个引用。
此属性的值是对函数本身的引用,而不是一个包含函数名称的字符串。
构造函数本身就是一个函数,与普通函数没有任何区别,不过为了规范一般将其首字母大写。
构造函数和普通函数的区别在于:使用 new 生成实例的函数就是构造函数,直接调用的就是普通函数。

对于基本类型来说constructor 是只读的,对于引用类型来说 constructor 属性值是可以修改的。

function Parent(age) {
    this.age = age;
}

var person = new Parent(45);
person.constructor === Parent;  // true
person.constructor === Object;  // false

(2)Symbol
Symbol 是基本数据类型,但作为构造函数来说它并不完整,因为它不支持语法 new Symbol(),Chrome 认为其不是构造函数,如果要生成实例直接使用 Symbol() 即可。虽然是基本数据类型,但 Symbol() 实例可以获取 constructor 属性值。这里的 constructor 属性来自 Symbol的 原型,即 Symbol.prototype.constructor 返回创建实例原型的函数, 默认为 Symbol 函数。
所以Symbol不是构造函数,但是可以获取 constructor 属性值

(3)new的模拟实现

function create() {
    // 1、创建一个空的对象
    var obj = new Object(),
    // 2、获得构造函数,同时删除 arguments 中第一个参数
    Con = [].shift.call(arguments);
    // 3、链接到原型,obj 可以访问构造函数原型中的属性
    Object.setPrototypeOf(obj, Con.prototype);
    // 4、绑定 this 实现继承,obj 可以访问到构造函数中的属性
    var ret = Con.apply(obj, arguments);
    // 5、优先返回构造函数返回的对象
    return ret instanceof Object ? ret : obj;
};
  • ① 用new Object()的方式新建了一个对象obj
  • ② 取出第一个参数,就是我们要传入的构造函数。此外因为 shift会修改原数组,所以 arguments会被去除第一个参数
  • ③ 将obj的原型指向构造函数,这样obj就可以访问到构造函数原型中的属性
  • 使用apply,改变构造函数this 的指向到新建的对象,这样obj就可以访问到构造函数中的属性
  • ⑤ 返回 obj

6、原型与原型链

(1)原型(prototype)

JavaScript 是一种基于原型的语言 ,这个和 Java 等基于类的语言不一样。js的每个对象拥有一个原型对象,对象以其原型为模板,从原型继承方法和属性,这些属性和方法定义在对象的构造器函数的 prototype 属性上,而非对象实例本身

如上图所示,Parent 对象有一个原型对象 Parent.prototype,其上有两个属性,分别是 constructor__proto__


  • __proto__
    上图可以看到 Parent 原型( Parent.prototype )上有__proto__属性,这是一个访问器属性(即 getter 函数和 setter 函数),通过它可以访问到对象的内部 Prototype (一个对象或 null )。__proto__ 最先被 Firefox使用,后来在 ES6 被列为 Javascript 的标准内建属性。 注意:__proto__ 属性在 ES6 时才被标准化,以确保 Web 浏览器的兼容性,但是不推荐使用,除了标准化的原因之外还有性能问题。为了更好的支持,推荐使用 Object.getPrototypeOf()
    ( 通过改变一个对象的 Prototype 属性来改变和继承属性会对性能造成非常严重的影响,并且性能消耗的时间也不是简单的花费在 obj.proto = ... 语句上, 它还会影响到所有继承自该 Prototype 的对象,如果你关心性能,你就不应该修改一个对象的 Prototype

如果要读取或修改对象的 Prototype属性,建议使用如下方案

// 获取
Object.getPrototypeOf()
Reflect.getPrototypeOf()

// 修改
Object.setPrototypeOf()
Reflect.setPrototypeOf()

如果要创建一个新对象,同时继承另一个对象的 Prototype ,推荐使用 Object.create()

function Parent() {
    age: 50
};
var p = new Parent();
var child = Object.create(p);

优化第5点构造函数的模拟实现new:

function create() {
    // 1、获得构造函数,同时删除 arguments 中第一个参数
    Con = [].shift.call(arguments);
    // 2、创建一个空的对象并链接到原型,obj 可以访问构造函数原型中的属性
    var obj = Object.create(Con.prototype);
    // 3、绑定 this 实现继承,obj 可以访问到构造函数中的属性
    var ret = Con.apply(obj, arguments);
    // 4、优先返回构造函数返回的对象
    return ret instanceof Object ? ret : obj;
};

接着上图分析:

function Parent() {}
var p = new Parent();
p.__proto__ === Parent.prototype
// true

这里用 p.__proto__获取对象的原型,__proto__ 是每个实例上都有的属性prototype是构造函数的属性,这两个并不一样,但p.__proto__Parent.prototype指向同一个对象。

每个对象都有__proto__,而prototype只有函数对象才有
另,所有函数对象(注意区别普通对象)的__proto__都指向Function.prototype,也就是说它是所有函数对象的原型对象,而且它是一个空函数(Empty function)。所有的构造器(构造函数)都来自于 Function.prototype,甚至包括根构造器Object及Function自身,所以所有构造器都继承了Function.prototype的属性及方法。故,函数是唯一一个typeof 结果是function 的类型。

构造函数 Parent 有一个指向原型的指针,原型 Parent.prototype 有一个指向构造函数的指针 Parent.prototype.constructor,其实就是一个循环引用,所下图所示。


记住这张图
(2)原型链

每个对象拥有一个原型对象,通过__proto__指针指向上一个原型 ,并从中继承方法和属性,同时原型对象也可能拥有原型,这样一层一层,最终指向 null。这种关系被称为原型链 (prototype chain),通过原型链一个对象会拥有定义在其他对象中的属性和方法。

function Foo(name) {
    this.name = name;
}
Foo.prototype.getName = function() {
    return this.name;
}
Foo.prototype.length = 3;
let foo = new Foo('dxl'); // 相当于 foo.__proto__ = Foo.prototype
console.dir(foo);
这。。。

最后,看一个实例:

function Person(name) {
    this.name = name
}
var p2 = new Person('king');

核心点:__proto__是求原型对象的,也就是求构造器的prototype属性 ===>原型对象是构造器的一个属性,本身是个对象
    
constructor 是求构造器的 ====> 构造器的prototype属性的对象集合里也有constructor,这个prototype里的constructor指向构造器自己

console.log(p2.__proto__)//Person.prototype
console.log(p2.__proto__.__proto__)//结合上题,也就是Person.prototype的__proto__,Person.prototype本身是个对象,所以这里输出:Object.prototype
console.log(p2.__proto__.__proto__.__proto__)//同理,这里是求Object.prototype的__proto__,这里输出:null
console.log(p2.__proto__.__proto__.__proto__.__proto__)//null后面没有了,报错
console.log(p2.__proto__.__proto__.__proto__.__proto__.__proto__)//null后面没有了,报错

console.log(p2.constructor)//Person
console.log(p2.prototype)//undefined p2是实例对象,不是函数对象,是没有prototype属性滴

console.log(Person.constructor)//Function 一个空函数
console.log(Person.prototype)//打印出Person.prototype这个对象里所有的方法和属性

console.log(Person.prototype.constructor)//Person
console.log(Person.prototype.__proto__)//Person.prototype是对象,所以输出:Object.prototype
console.log(Person.__proto__)//Function.prototype

console.log(Function.prototype.__proto__)//Object.prototype
console.log(Function.__proto__)//Function.prototype

console.log(Object.__proto__)//Function.prototype
console.log(Object.prototype.__proto__)//null


7、this

(1)调用位置

调用位置就是函数在代码中被调用的位置(而不是声明的位置)。

查找方法:

  • 分析调用栈
    调用位置就是当前正在执行的函数的前一个调用中
  • 用开发者工具得到调用栈
    设置断点或者插入debugger;语句,运行时调试器会在那个位置暂停,同时展示当前位置的函数调用列表,这就是调用栈。找到栈中的第二个元素,这就是真正的调用位置。
function dong() {
    // 当前调用栈是:dong
    // 因此,当前调用位置是全局作用域
    
    console.log( "dong" );
    bar(); // <-- bar的调用位置
}

function bar() {
    // 当前调用栈是:dong --> bar
    // 因此,当前调用位置在dong中
    
    console.log( "bar" );
    foo(); // <-- foo的调用位置
}

function foo() {
    // 当前调用栈是:dong --> bar --> foo
    // 因此,当前调用位置在bar中
    
    console.log( "foo" );
}

dong(); // <-- dong的调用位置
(2)this绑定规则

① 默认绑定

  • 独立函数调用
    可以把默认绑定看作是无法应用其他规则时的默认规则,this指向全局对象。
  • 严格模式下
    不能将全局对象用于默认绑定,因为this会绑定到undefined。只有函数运行在非严格模式下,默认绑定才能绑定到全局对象。在严格模式下调用函数则不影响默认绑定。

② 隐式绑定
当函数引用有上下文对象时,隐式绑定规则会把函数中的this绑定到这个上下文对象。对象属性引用链中只有上一层或者说最后一层在调用中起作用。

  • 被隐式绑定的函数特定情况下会丢失绑定对象,应用默认绑定,把this绑定到全局对象或者undefined上。
// 虽然bar是obj.foo的一个引用,但是实际上,它引用的是foo函数本身。
// bar()是一个不带任何修饰的函数调用,应用默认绑定。
function foo() {
    console.log( this.a );
}

var obj = {
    a: 2,
    foo: foo
};

var bar = obj.foo; // 函数别名

var a = "oops, global"; // a是全局对象的属性

bar(); // "oops, global"
  • 参数传递就是一种隐式赋值,传入函数时也会被隐式赋值。回调函数丢失this绑定是非常常见的。
function foo() {
    console.log( this.a );
}

function doFoo(fn) {
    // fn其实引用的是foo
    
    fn(); // <-- 调用位置!
}

var obj = {
    a: 2,
    foo: foo
};

var a = "oops, global"; // a是全局对象的属性

doFoo( obj.foo ); // "oops, global"

③显式绑定
通过callapply方法直接指定this的绑定对象。
call()apply()方法第一个参数是一个对象,在调用函数时将这个对象绑定到this。

function foo() {
    console.log( this.a );
}

var obj = {
    a: 2
};

foo.call( obj ); // 2  调用foo时强制把foo的this绑定到obj上

但是,显示绑定无法解决丢失绑定问题
解决方案:

  • 硬绑定
    创建一个包裹函数,强制把foo的this绑定到obj
function foo() {
    console.log( this.a );
}

var obj = {
    a: 2
};

var bar = function() {
    foo.call( obj );
};

bar(); // 2

// 硬绑定的bar不可能再修改它的this
bar.call( window ); // 2
  • 通过API调用的“上下文”绑定this
    JS许多内置函数提供了一个可选参数,被称之为“上下文”(context),其作用和bind()一样,确保回调函数使用指定的this。这些函数实际上是通过call()和apply()实现了显式绑定。
    例如forEach
function foo(el) {
    console.log( el, this.id );
}

var obj = {
    id: "001dxl"
}

var myArray = [1, 2, 3]
// 调用foo()时把this绑定到obj
myArray.forEach( foo, obj );
// 1 001dxl 2 001dxl 3 001dxl

④ new绑定
使用new来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。

  • 创建(或者说构造)一个新对象。
  • 这个新对象会被执行Prototype连接。
  • 这个新对象会绑定到函数调用的this
  • 如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。

(说到new,需要排除的是:

  • 在JS中,构造函数只是使用new操作符时被调用的普通函数,他们不属于某个类,也不会实例化一个类。
  • 包括内置对象函数(比如Number())在内的所有函数都可以用new来调用,这种函数调用被称为构造函数调用。
  • 实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用” )
function foo(a) {
    this.a = a;
}

var bar = new foo(2); // bar和foo(..)调用中的this进行绑定
console.log( bar.a ); // 2

使用new来调用foo(..)时,会构造一个新对象并把它(bar)绑定到foo(..)调用中的this

⑤ 箭头函数绑定
箭头函数无法使用上述四条规则,而是根据外层(函数或者全局)作用域(词法作用域)来决定this。且箭头函数的绑定无法被修改(new也不行)。

function foo() {
    // 返回一个箭头函数
    return (a) => {
        // this继承自foo()
        console.log( this.a );
    };
}

var obj1 = {
    a: 1
};

var obj2 = {
    a: 2
}

var bar = foo.call( obj1 );
bar.call( obj2 ); // 1,不是2!

其实大部分情况下可以用一句话来概括,this总是指向调用该函数的对象。
但是对于箭头函数并不是这样,是根据外层(函数或者全局)作用域(词法作用域)来决定this。

总结:

  • 箭头函数不绑定this,箭头函数中的this相当于普通变量
  • 箭头函数的this寻值行为与普通变量相同,在作用域中逐级寻找
  • 箭头函数的this无法通过bindcallapply来直接修改(可以间接修改)
  • 改变作用域中this的指向可以改变箭头函数的this

var name = 'window'

var person1 = {
  name: 'person1',
  show1: function () {
    console.log(this.name)
  },
  show2: () => console.log(this.name),
  show3: function () {
    return function () {
      console.log(this.name)
    }
  },
  show4: function () {
    return () => console.log(this.name)
  }
}
var person2 = { name: 'person2' }

person1.show1() // person1,隐式绑定,this指向调用者 person1 
person1.show1.call(person2) // person2,显式绑定,this指向 person2

person1.show2() // window,箭头函数绑定,this指向外层作用域,即全局作用域
person1.show2.call(person2) // window,箭头函数绑定,this指向外层作用域,即全局作用域

person1.show3()() // window,默认绑定,这是一个高阶函数,调用者是window
                  // 类似于`var func = person1.show3()` 执行`func()`
person1.show3().call(person2) // person2,显式绑定,this指向 person2
person1.show3.call(person2)() // window,默认绑定,调用者是window

person1.show4()() // person1,箭头函数绑定,this指向外层作用域,即person1函数作用域
person1.show4().call(person2) // person1,箭头函数绑定,
                              // this指向外层作用域,即person1函数作用域
person1.show4.call(person2)() // person2
(3)call和apply

call() 和 apply()的区别在于:call()方法接受的是若干个参数的列表,而apply()方法接受的是一个包含多个参数的数组。

1)使用场景:

① 合并两个数组

var array1= ['aa', 'bb'];
var array2= ['cc', 'dd'];

// 将第二个数组融合进第一个数组
Array.prototype.push.apply(array1, array2);
// 4

console.log(vegetables);
// ['aa', 'bb', 'cc', 'dd']

但是当第二个数组(如示例中的 array2)太大时不要使用这个方法来合并数组,因为一个函数能够接受的参数个数是有限制的。不同的引擎有不同的限制,JS核心限制在 65535,有些引擎会抛出异常,有些不抛出异常但丢失多余参数。
如何解决呢?方法就是将参数数组切块后循环传入目标方法

function concatOfArray(arr1, arr2) {
    var QUANTUM = 32768;
    for (var i = 0, len = arr2.length; i < len; i += QUANTUM) {
        Array.prototype.push.apply(
            arr1, 
            arr2.slice(i, Math.min(i + QUANTUM, len) )
        );
    }
    return arr1;
}

// 验证代码
var arr1 = [-3, -2, -1];
var arr2 = [];
for(var i = 0; i < 1000000; i++) {
    arr2.push(i);
}

Array.prototype.push.apply(arr1, arr2);
// Uncaught RangeError: Maximum call stack size exceeded

concatOfArray(arr1, arr2);
// (1000003) [-3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]

数组去重合并:

function combine(){ 
    let arr = [].concat.apply([], arguments);  //没有去重复的新数组 
    return Array.from(new Set(arr));
} 

var m = [1, 2, 2], n = [2,3,3]; 
console.log(combine(m,n));    // [1, 2, 3]

② 获取数组中的最大值和最小值

var numbers = [5, 458 , 120 , -215 ]; 
Math.max.apply(Math, numbers);   //458    
Math.max.call(Math, 5, 458 , 120 , -215); //458

// ES6
Math.max.call(Math, ...numbers); // 458

为什么要这么用呢,因为数组 numbers 本身没有 max方法,但是 Math有呀,所以这里就是借助call / apply 使用 Math.max方法。

③ 验证是否是数组

function isArray(obj){ 
    return Object.prototype.toString.call(obj) === '[object Array]';
}
isArray([1, 2, 3]);
// true

// 直接使用 toString()
[1, 2, 3].toString();   // "1,2,3"
"123".toString();       // "123"
123.toString();         // SyntaxError: Invalid or unexpected token
Number(123).toString(); // "123"
Object(123).toString(); // "123"

虽然可以通过toString()来获取每个对象的类型,但是不同对象的 toString()有不同的实现,所以通过 Object.prototype.toString() 来检测,需要以 call() / apply() 的形式来调用,传递要检查的对象作为第一个参数。

④ 利用call()让类数组对象使用数组的方法

类数组对象是一个对象。JS中存在一种名为类数组的对象结构,比如 arguments对象,还有DOM API 返回的NodeList 对象都属于类数组对象,类数组对象不能使用 push/pop/shift/unshift 等数组方法,通过 Array.prototype.slice.call 将类数组对象转换成真正的数组,就可以使用 Array下所有方法。(原理:slice 将 类数组对象通过下标操作放进了新的 Array 里面)

类数组对象的两个特性:

  • 具有:指向对象元素的数字索引下标和 length 属性
  • 不具有:比如 pushshiftforEach 以及 indexOf等数组对象具有的方法
var domNodes = document.getElementsByTagName("*");
domNodes.unshift("h1");
// TypeError: domNodes.unshift is not a function

var domNodeArrays = Array.prototype.slice.call(domNodes);
domNodeArrays.unshift("h1"); // 505 不同环境下数据不同

// 上面代码等同于
var arr = [].slice.call(arguments);

ES6:
let arr = Array.from(arguments);
let arr = [...arguments];

Array.from() 可以将类数组对象可遍历对象(包括ES6新增的数据结构 Set 和 Map)转化为真正的数组。对Array.from()不熟悉的可看我第11篇简书:数组操作

⑤ 调用父构造函数实现继承

function  SuperType(){
    this.color=["red", "green", "blue"];
}
function  SubType(){
    // 核心代码,继承自SuperType
    SuperType.call(this);
}

var instance1 = new SubType();
instance1.color.push("black");
console.log(instance1.color);
// ["red", "green", "blue", "black"]

var instance2 = new SubType();
console.log(instance2.color);
// ["red", "green", "blue"]

在子构造函数中,通过调用父构造函数的call方法来实现继承,于是SubType的每个实例都会将SuperType 中的属性复制一份。

缺点:

  • 只能继承父类的实例属性和方法,不能继承原型属性/方法
  • 无法实现复用,每个子类都有父类实例函数的副本,影响性能
2)模拟实现call和apply

① call的模拟实现

ES3:

Function.prototype.call = function (context) {
    context = context ? Object(context) : window; 
    context.fn = this;

    var args = [];
    for(var i = 1, len = arguments.length; i < len; i++) {
        args.push('arguments[' + i + ']');
    }
    var result = eval('context.fn(' + args +')');

    delete context.fn
    return result;
}

ES6:

Function.prototype.call = function (context) {
  context = context ? Object(context) : window; 
  context.fn = this;

  let args = [...arguments].slice(1);
  let result = context.fn(...args);

  delete context.fn
  return result;
}

② apply的模拟实现
ES3:

Function.prototype.apply = function (context, arr) {
    context = context ? Object(context) : window; 
    context.fn = this;

    var result;
    // 判断是否存在第二个参数
    if (!arr) {
        result = context.fn();
    } else {
        var args = [];
        for (var i = 0, len = arr.length; i < len; i++) {
            args.push('arr[' + i + ']');
        }
        result = eval('context.fn(' + args + ')');
    }

    delete context.fn
    return result;
}

ES6:

Function.prototype.apply = function (context, arr) {
    context = context ? Object(context) : window; 
    context.fn = this;
  
    let result;
    if (!arr) {
        result = context.fn();
    } else {
        result = context.fn(...arr);
    }
      
    delete context.fn
    return result;
}
(4)bind

bind()方法会创建一个新函数,当这个新函数被调用时,它的 this值是传递给 bind() 的第一个参数,传入bind方法的第二个以及以后的参数加上绑定函数运行时本身的参数按照顺序作为原函数的参数来调用原函数。bind返回的绑定函数也能使用new操作符创建对象:这种行为就像把原函数当成构造器,提供的this值被忽略,同时调用时的参数被提供给模拟函数。
bind 方法与 call / apply 最大的不同就是前者返回一个绑定上下文的函数,而后两者是直接执行了函数。

语法:fun.bind(thisArg[, arg1[, arg2[, ...]]])

var value = 2;

var foo = {
    value: 1
};

function bar(name, age) {
    return {
        value: this.value,
        name: name,
        age: age
    }
};

bar.call(foo, "Jack", 20); // 直接执行了函数
// {value: 1, name: "Jack", age: 20}

var bindFoo1 = bar.bind(foo, "Jack", 20); // 返回一个函数
bindFoo1();
// {value: 1, name: "Jack", age: 20}

var bindFoo2 = bar.bind(foo, "Jack"); // 返回一个函数
bindFoo2(20);
// {value: 1, name: "Jack", age: 20}
1)使用场景

① 业务场景

经常有如下的业务场景:

var nickname = "Kitty";
function Person(name){
    this.nickname = name;
    this.distractedGreeting = function() {
       
        setTimeout(function(){
            console.log("Hello, my name is " + this.nickname);
        }, 500);
    }
}
 
var person = new Person('msguan');
person.distractedGreeting();
//Hello, my name is Kitty

这里输出的nickname是全局的,并不是我们创建 person 时传入的参数,因为 setTimeout 在全局环境中执行),所以 this指向的是window

解决方案1:缓存 this值
很常见:var self = this

解决方案2:使用 bind

var nickname = "Kitty";
function Person(name){
    this.nickname = name;
    this.distractedGreeting = function() {
        // var self = this;
        setTimeout(function(){
            console.log("Hello, my name is " + this.nickname);
        }.bind(this), 500);
    }
}
 
var person = new Person('msguan');
person.distractedGreeting();
// Hello, my name is msguan

② 验证是否是数组
上面的call方法也有讲解此例。
可以通过toString() 来获取每个对象的类型,但是不同对象的toString()有不同的实现,所以通过 Object.prototype.toString() 来检测。

var toStr = Function.prototype.call.bind(Object.prototype.toString);
function isArray(obj){ 
    return toStr(obj) === '[object Array]';
}
isArray([1, 2, 3]);
// true

// 使用改造后的 toStr
toStr([1, 2, 3]);   // "[object Array]"
toStr("123");       // "[object String]"
toStr(123);         // "[object Number]"
toStr(Object(123)); // "[object Number]"

③ 柯里化(curry)
柯里化:只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。
可以一次性地调用柯里化函数,也可以每次只传一个参数分多次调用。

var add = function(x) {
  return function(y) {
    return x + y;
  };
};

var increment = add(1);
var addTen = add(10);

increment(2);
// 3

addTen(2);
// 12

add(1)(2);
// 3
2)模拟实现bind
Function.prototype.bind2 = function (context) {

    if (typeof this !== "function") {
      throw new Error("Function.prototype.bind - what is trying to be bound is not callable");
    }

    var self = this;
    var args = Array.prototype.slice.call(arguments, 1);

    var fNOP = function () {};

    var fBound = function () {
        var bindArgs = Array.prototype.slice.call(arguments);
        return self.apply(this instanceof fNOP ? this : context, args.concat(bindArgs));
    }

    fNOP.prototype = this.prototype;
    fBound.prototype = new fNOP();
    return fBound;
}