浅析new、call、apply、bind的底层逻辑

543 阅读15分钟

【这是我参与更文挑战的第9天,活动详情查看: 更文挑战

Tasking:

  • apply、call、bind 这三个方法之间有什么区别?
  • 怎样实现一个 apply 或者 call 的方法?

方法的基本介绍

new MDN文档

原理介绍

new 关键词的主要作用就是执行一个构造函数、返回一个实例对象,在 new 的过程中,根据构造函数的情况,来确定是否可以接受参数的传递。 语法:new constructor[([arguments])] 参数: constructor 一个指定对象实例的类型的类或函数 arguments 一个用于被 constructor 调用的参数列表 tips:> > 关于对象的 > constructor> ,参见 > Object.prototype.constructor

function Person(name, age, sex) {
   this.name = name;
   this.age = age;
   this.sex = sex;
}

const rand = new Person("Rand McNally", 33, "M");
const ken = new Person("Ken Jones", 39, "M");
new 关键字会进行如下操作:
  • 创建一个空的对象(即{})
  • 链接该对象(设置该对象的constructor)到另一个对象
  • 将新创建的对象作为this的上下文
  • 如果该函数没有返回对象,则返回this
创建一个自定义对象所需执行的流程:
  • 通过编写函数定义对象类型
  • 通过new来创建对象实例

创建一个对象类型,需要创建一个指定其名称和属性的函数;对象的属性可以指向其他对象

当代码new Person(...)执行时,会发生以下事情:
  • 一个继承自Person.prototype的新对象被创建
  • 使用指定的参数调用构造函数Person,并将this绑定到新创建的对象;new Person等同于new Person(),也就是没有指定参数列表,Person不带任何参数调用的情况
  • 有构造函数返回的对象就是new表达式的结果;如果构造函数没有显示返回一个对象,则使用步骤1创建的对象。(一般情况下,构造函数不返回值,但是开发者可以选择主动返回对象,来覆盖正常的对象创建步骤)

如果你没有使用 new 运算符,构造函数会像其他的常规函数一样被调用, 并_不会创建一个对象_在这种情况****下,this的指向也是不一样的。

call MDN文档

原理介绍

call() 方法使用一个指定的 this 值和单独给出的一个或多个参数来调用一个函数。 语法:function.call(thisArg, arg1, arg2, arg3, ....) 参数: thisArg 可选的。在 function 函数运行时使用的 this 值。请注意,this可能不是该方法看到的实际值:如果这个函数处于非严格模式下,则指定为 nullundefined 时会自动替换为指向全局对象,原始值会被包装。 arg1, arg2, arg3,... 指定的参数列表;如果函数不需要传参则可以不用传参

function Product(name, price) {
    this.name = name;
    this.price = price;
}

function Food(name, price) {
    Product.call(this, name, price);
    this.category = 'food';
}

console.log(new Food('cheese', 5).name); // cheese
call 返回值

使用调用者提供的 this 值和参数调用该函数的返回值。若该方法没有返回值,则返回 undefined

描述

call() 允许为不同的对象分配和调用属于一个对象的函数/方法 call() 提供新的this值给当前调用的函数/方法。可使用call()来实现继承:写一个方法,然后让另一个新的对象来继承它(而不是在新对象中再写一次这个方法)

实践
  • 使用call方法调用腹肌构造函数(函数)
function Product(name, price) {
    this.name = name;
    this.price = price;
}

function Food(name, price) {
    Product.call(this, name, price);
    this.category = 'food';
}

function Toy(name, price) {
    Product.call(this, name, price);
    this.category = 'toy';
}

const cheese = new Food('feta', 5);
const fun = new Toy('robot', 40);

console.log(cheese);    // Food {name: "feta", price: 5, category: "food"}
console.log(fun);       // Toy {name: "robot", price: 40, category: "toy"}

实例化了两个构造函数都分别实现了继承父级构造函数的属性(也就是在当前作用于内改变this的指向);使用Food和Toy构造函数创建的对象实例都会拥有在product构造函数中添加name属性和price属性,但category属性是在各自的构造函数中定义的;

  • 使用call方法调用匿名函数
let animals = [
    { species: 'Lion', name: 'King' },
    { species: 'Whale', name: 'Fail' }
];

for (let i = 0; i < animals.length; i++) {
    ((i) => {
        this.print = () => {
            console.log(`#${i} ${this.species}:${this.name}`)
        }
        this.print();
    }).call(animals[i], i);
}

console.log(animals)

以上打印结果下图所示:

image.png

通过以上代码可以看出,在for循环体内,创建了一个匿名函数,然后通过该函数的call方法,将每个数组元素作为指定的this值执行了那个匿名函数。这个匿名函数的主要目的是给每个数组元素对象添加一个print方法,这个print方法可以打印出各元素在数组中的正确索引号。当然,这里不是必须得让数组元素作为this值传入那个匿名函数(普通参数就可以)。

  • 使用call方法调用函数并且指定上下文的this
const obj = {
    animal: 'cats', sleepDuration: '12 and 16 hours'
};
function greet() {
    const reply = [this.animal, 'typically sleep between', this.sleepDuration].join(' ');
    console.log(reply); // cats typically sleep between 12 and 16 hours
}
greet.call(obj); 

当调用greet() 方法的时候,该方法的this值会绑定obj对象,从而改变了this的指向

  • 使用call方法调用函数并且不指定第一个参数
const sData = 'Wisen';
function display() {
    console.log('sData value is %s ', this.sData); 
}

display.call();  // sData value is undefined

调用display方法,但并没有传递它的第一个参数;如果没有传递第一个参数,this的值将会绑定全局对象; 在严格模式下,this的值将会是undefined。

apply MDN文档

原理介绍:

apply()方法调用一个具有给定this值得函数,以及以一个数组的形式提供的参数 语法:function.apply(thisArg, [argsArray]) 参数: thisArg 必选项,在函数运行时使用的this值。 this可能不是该方法看到的实际值:如果这个函数处于非严格模式下,则指定为null和undefined时会         自动替换为指向全局对象,原始值会被包装。 argsArray 可选参数,一个数组和类数组对象,其中的数组元素将作为单独的参数传给function函数;如果该函数的值为null或undefined,则表示不需要传入任何参数。从ECMAScript开始可以使用类数组对象。

返回值

调用有指定this值和参数的函数的结果

描述

在调用一个存在的函数时,可以为其制定一个this对象;this指当前对象,也就是正在调用这个函数的对象;使用apply可以只写一次这个方法然后再另一个对象中继承它,而不用在新对象中重复写该方法。arguments对象作为argsArray参数;arguments是一个函数的局部变量;它可以被用作调用对象的所有未指定的参数;这样就可以在使用apply函数的时候就不需要知道被调用对象的所有参数,而是用arguments来吧所有的参数传递给被调用对象;被调用对象接下来就负责处理这些参数。 从es5开始,可以使用任何类型的类数组对象,也就是说只要有一个length属性(0..length-1)范围的整数属性;Chrome14和IE9及其以下版本任然不接收类数组对象,若果传入类数组对象,她们会抛出异常。

实践:
  • 用apply将数组各项添加到另一个数组
let array = ['a', 'b'];
let elements = [0, 1, 2];
// 使用apply方法
array.push.apply(array, elements);
// 不使用apply的方法
// array.push(...elements);
// array = array.concat(elements);
console.info(array); // ["a", "b", 0, 1, 2]
  • 使用apply和内置函数

比如:我们将用Math.max/Math.min求得数组中的最大小值

/* 找出数组中最大/小的数字 */
let numbers = [5, 6, 2, 3, 7];

/* 使用Math.min/Math.max以及apply 函数时的代码 */
let max = Math.max.apply(null, numbers); /* 基本等同于 Math.max(numbers[0], ...) 或 Math.max(5, 6, ..) */
let min = Math.min.apply(null, numbers);

/* 对比:简单循环算法 */
max = -Infinity;
min = +Infinity;

for (let i = 0; i < numbers.length; i++) {
    if (numbers[i] > max) max = numbers[i];
    if (numbers[i] < min) min = numbers[i];
}

console.log(max, min)

tips: 如果按照上面方式调用apply,有超出JavaScript引擎参数长度上线的风险;一个方法传入过多参数时的后果在不同JavaScript引擎中表现不同;(JavaScriptCore引擎中有被硬编码的参数个数上限:65536);这是因为此(实际上也是任何用到超大栈空间的行为的自然表现)限制是不明确的,一些引擎会抛出异常,更糟糕的是其他引擎会直接限制传入到方法的参数个数,导致参数丢失。比如:假设某个引擎的方法参数上线为4(实际上限当然要高得多),上面的代码执行后,真正被传递到apply的参数为5, 6, 2, 3,而不是完整的数组。

如果你的参数数组可能非常大,那么推荐使用下面这种混合策略:将数组切块后循环传入目标方法

function minOfArray(arr) {
    let min = Infinity;
    let QUANTUM = 32768;

    // for (let i = 0, len = arr.length; i < len; i += QUANTUM) {
    //     let submin = Math.min.apply(null, arr.slice(i, Math.min(i + QUANTUM, len)));
    //     min = Math.min(submin, min);
    // }

    for (const key in arr) {
        let submin = Math.min.apply(null, arr.slice(key, Math.min(key + QUANTUM, arr.length)));
        min = Math.min(submin, min);
    }

  	return min;
}

let min = minOfArray([5, 6, 2, 3, 7]);
console.log('min:', min); // min: 2
使用apply来链接构造器

创建一个全局Function 对象的construct方法 ,来使你能够在构造器中使用一个类数组对象而非参数列表。

Function.prototype.construct = function (aArgs) {
    var oNew = Object.create(this.prototype);
    this.apply(oNew, aArgs);
    return oNew;
};

注意: 上面使用的Object.create()方法相对来说比较新。另一种可选的方法,请考虑如下替代方法: Using Object.__proto__:

Function.prototype.construct = function (aArgs) {
    var oNew = {};
    oNew.__proto__ = this.prototype;
    this.apply(oNew, aArgs);
    return oNew;
};

使用闭包:

Function.prototype.construct = function(aArgs) {
  var fConstructor = this, fNewConstr = function() {
    fConstructor.apply(this, aArgs);
  };
  fNewConstr.prototype = fConstructor.prototype;
  return new fNewConstr();
};

使用 Function 构造器:

Function.prototype.construct = function (aArgs) {
  var fNewConstr = new Function("");
  fNewConstr.prototype = this.prototype;
  var oNew = new fNewConstr();
  this.apply(oNew, aArgs);
  return oNew;
};

使用示例:

function MyConstructor (arguments) {
    for (var nProp = 0; nProp < arguments.length; nProp++) {
        this["property" + nProp] = arguments[nProp];
    }
}
var myArray = [4, "Hello world!", false];
var myInstance = new MyConstructor(myArray); //Fix MyConstructor.construct is not a function
console.log(myInstance.property1);                // logs "Hello world!"
console.log(myInstance instanceof MyConstructor); // logs "true"
console.log(myInstance.constructor);              // logs "MyConstructor"

bind

原理介绍

**bind()** 方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。 语法:function.bind(thisArg[, arg1[,...]]) 参数: thisArg: 调用绑定函数作为this参数传递给目标函数的值。如果使用new 运算符构造绑定函数,则忽略改制。当使用bindsetTimeout中创建一个函数(作为对调提供)时,作为thisArg 传递的任何原始值都将转换为object。吐过bind 函数的参数列表为空,或者thisArgnullundefined,执行作用域的this将被视为新函数的thisArg arg1, arg2, ... 当目标函数被调用时,被预置入绑定函数的参数列表中的参数

返回值

返回一个原函数的拷贝,并拥有指定this 值和初始参数

描述

bind()函数会创建一个新的绑定函数(bound function, BF)。绑定函数是一个exotic function object(怪异函数对象,ECMAScript2015中的术语),它包装了原函数对象。调用绑定函数通常会导致执行包装函数。 绑定函数具有以下内部属性:

  • [[BoundTargetFunction]]--包装的函数对象
  • [[BoundThis]]--在调用包装函数时始终作为this值传递的值
  • [[BoundArguments]]--列表,在对包装函数做任何调用都会优先用列表元素填充参数列表
  • [[call]]--执行与此对象关联的代码。通过函数调用表达式调用。内部方法的参数是一个this值和一个包含通过调用表达式传递给函数的参数的列表

当调用绑定函数时, 它调用[[BoundTargetFunction]]上的内部方法[[call]],就像这样call(boundThis, args)。其中boundThis是[[boundTHis]], args是[[BoundArguments]]加上通过函数调用传入的参数列表

绑定函数也可以使用new运算符构造,他会表现为目标函数已经构建完毕似的,提供this值会被忽略,但前置参数仍会提供给模拟函数。

实践
  • 创建绑定函数

bind()最简单的用法是创建一个函数,不论是怎么调用,这个函数都有同样的this值。JavaScript新手经常犯的一个错误就是将一个方法从对象中拿出来,然后再调用,期望方法中的this是原来的对象(比如在回调中传入这个方法)。如果不做特殊处理的话,一般会丢失原来的对象;基于这个函数,用原始的对象创建一个绑定函数,巧妙的解决这个问题:

this.x = 9;    // 在浏览器中,this 指向全局的 "window" 对象
let module = {
    x: 81,
    getX: function () { return this.x; }
};

module.getX(); // 81

let retrieveX = module.getX;
retrieveX(); // 返回 9 - 因为函数是在全局作用域中调用的

// 创建一个新函数,把 'this' 绑定到 module 对象
// 新手可能会将全局变量 x 与 module 的属性 x 混淆
let boundGetX = retrieveX.bind(module);
boundGetX(); // 81
  • 偏函数

bind()的另一个最简单的用法是使一个函数拥有预设的初始参数。只要将这些参数(如果有的话)作为bind()的参数写在this后面。当绑定函数被调用时,这些参数会被插入到目标函数的参数列表的开始位置,传递绑定函数的参数会跟他们后面。

function list() {
    return Array.prototype.slice.call(arguments);
}

function addArguments(arg1, arg2) {
    return arg1 + arg2
}

let list1 = list(1, 2, 3); // [1, 2, 3]

let result1 = addArguments(1, 2); // 3

// 创建一个函数,它拥有预设参数列表。
let leadingThirtysevenList = list.bind(null, 37);

// 创建一个函数,它拥有预设的第一个参数
let addThirtySeven = addArguments.bind(null, 37);
let list2 = leadingThirtysevenList(); // [37]
let list3 = leadingThirtysevenList(1, 2, 3); // [37, 1, 2, 3]
let result2 = addThirtySeven(5); // 37 + 5 = 42
let result3 = addThirtySeven(5, 10); // 37 + 5 = 42 ,第二个参数被忽略
  • 配合setTimeout

在默认情况下,使用window.setTimeout()时, this关键字会指向window(或global)对象。当类的方法中需要this指向类的实例时,可能需要显示的把this 绑定到回调函数,就不会丢失该实例的引用

function LateBloomer() {
    this.petalCount = Math.ceil(Math.random() * 12) + 1;
}

// 在 1 秒钟后声明 bloom
LateBloomer.prototype.bloom = function () {
    window.setTimeout(this.declare.bind(this), 1000);
};

LateBloomer.prototype.declare = function () {
    console.log('I am a beautiful flower with ' + this.petalCount + ' petals!');
};

let flower = new LateBloomer();
flower.bloom();  // 一秒钟后, 调用 'declare' 方法
  • 作为构造函数使用的绑定函数

绑定函数自动适应与使用new操作符去构造一个有目标函数创建的新实例;当一个绑定函数是用来构建一个值得,原来提供的this就会被忽略;不过提供的参数列表仍然会插入到构造函数调用时的参数列表之前。

function Point(x, y) {
    this.x = x;
    this.y = y;
}

Point.prototype.toString = function () {
    return this.x + ',' + this.y;
};

let p = new Point(1, 2);
p.toString(); // '1,2'

let emptyObj = {};
let YAxisPoint = Point.bind(emptyObj, 0/*x*/);

// 本页下方的 polyfill 不支持运行这行代码,
// 但使用原生的 bind 方法运行是没问题的:

let YAxisPoint = Point.bind(null, 0/*x*/);

/*(译注:polyfill 的 bind 方法中,如果把 bind 的第一个参数加上,
即对新绑定的 this 执行 Object(this),包装为对象,
因为 Object(null) 是 {},所以也可以支持)*/

let axisPoint = new YAxisPoint(5);
axisPoint.toString(); // '0,5'

axisPoint instanceof Point; // true
axisPoint instanceof YAxisPoint; // true
new YAxisPoint(17, 42) instanceof Point; // true

不需要做特别的处理就可以new操作符创建一个绑定函数。也就是说,不需要特别处理就可以创建一个可以被直接调用的绑定函数,即使希望绑定函数使用new操作符来调用

// ...接着上面的代码继续的话,
// 这个例子可以直接在你的 JavaScript 控制台运行

// 仍然能作为一个普通函数来调用
// (即使通常来说这个不是被期望发生的)
YAxisPoint(13);

emptyObj.x + ',' + emptyObj.y;   //  '0,13'

如果希望一个绑定函数要么只能用new操作符,要么只能直接调用,要么就必须在目标函数上显示规定这个限制

  • 快捷调用

当需要一个特定的this值得函数创建一个接近的时候,bind()也很好用;可以使用Array.prototype.slice来讲一个类似于数组的对象转换成一个真正的数组

let slice = Array.prototype.slice;

slice.apply(arguments);

用bind()可以是这个过程变得简单

let unboundSlice = Array.prototype.slice;
let slice = Function.prototype.apply.bind(unboundSlice);
slice([1, 2, 3, 4]);

如何实现它们呢?

这个问题也时很多公司面试的高频题目,下面来动手实践实践!

new 的实现

根据以上介绍,我们便可清晰的知道new的执行过程,那么实现则可现列出一个Tasking

Tasking
  • 让实例可以访问到私有属性
  • 让实例可以访问构造函数原型()所在原型链上的属性(proto
  • 构造函数返回的最后结果是引用类型
function _new(ctor, ...args) {
    if (typeof ctor !== 'function') {
        throw 'ctor must be a function';
    }
    let obj = new Object();
    obj.__proto__ = Object.create(ctor.prototype);
    let res = ctor.apply(obj, [...args]);

    let isObject = typeof res === 'object' && typeof res !== null;
    let isFunction = typeof res === 'function';
    return isObject || isFunction ? res : obj;
};

function calc() {
    console.log('calc')
}

_new(calc); // calc

具体图如下: image.png

call的实现

// 方法一
Function.prototype.ForestCall = function (context, ...args) {
    context = context || window;
    context.fn = this;
    let result = eval('context.fn(...args)');
    delete context.fn;
    return result;
};

// 方法二
Function.prototype.myCall = function (context) {
    // 判断context是否存在,不存在设置为window
    context = context ? Object(context) : window;
    // 处理参数
    const args = [...arguments].slice(1);
    // 要将this指向改为context,需要用context来调用
    context.fn = this; // 这里的this是原函数
    const result = context.fn(...args); // 执行原函数,此时因为是context调用,因此函数中的this指向了context
    delete context.fn;
    return result;
}

const obj = {
    name: 'banana',
    category: 'fruit'
}

function getCategory() {
    console.log(this.category)
}

getCategory.ForestCall(obj)
getCategory.myCall(obj)

**eval()**** **函数会将传入的字符串当做 JavaScript 代码进行执行。eval() MDN 语法: eval(string) 参数: string:一个表示JavaScript表达式、语句或一系列语句的字符串;表达式可包含变量与已存在对象的属性 返回值: 返回字符串中代码的返回值,如果返回值为空,则返回undefined 描述:

  • eval() 的参数是一个字符串。如果字符串表示的是表达式,eval()会对表达式进行求值;如果参数表示一个或者多个JavaScript语句;那么eval()就会执行这些语句;不需要用eval()来执行一个算术表达式:因为JavaScript可以自动为算术表达式求值;
  • 如果你以字符串的形式构造了算术表达式,那么可以在后面用 eval()对它求值。
  • 如果 eval() 的参数不是字符串, eval() 会将参数原封不动地返回。

apply的实现

let array = ['a', 'b'];
let elements = [0, 1, 2];
// 使用apply方法
array.push.ForestApply(array, elements);
console.log(array); // ["a", "b", 0, 1, 2]