1. 程式人生 > 其它 >手寫call、apply和bind函式

手寫call、apply和bind函式

手寫call、apply和bind函式

chicABoo  2021年04月08日 17:15 ·  閱讀 637

前言

提到改變 this 的指向,首先想到的方式就是 call、apply 和 bind。對於每種方式底層是如何實現,大多數人不太清楚,如果你還不清楚他們的用法,請移步callapplybind。本文會簡單講解他們的用法,底層實現思路,及模擬實現 call、apply、bind。

call

1、定義: 使用一個指定的 this 值和單獨給出一個或多個引數來呼叫一個函式。

    function.call(this, arg1, arg2, arg3, ...)
複製程式碼

根據定義我們知道,call()方法有兩個作用,一個是改變 this 指向,另外一個傳遞引數,如下:

const obj = {
  value: 1,
}

function fn(arg) {
  console.log(this.value, arg); // 1, 2
}

fn.call(obj, 2);
複製程式碼

上面的例子,使用 call()方法使函式fn的 this 指向了obj,所以 this.value 的值為 1。那麼如果不使用 call()方法,該如何實現呢?

2、call 實現思路

不考慮使用 call、apply、bind 方法,上面例子 fn 函式如何能拿到 obj 裡面的 value 值呢?改造一下上面的例子

const obj = {
  value: 1,
  fn: function() {
    console.log(this.value); // 1
  }
}
obj.fn();
複製程式碼

這樣一改,this 就指向了 obj,根據這個思路,可以封裝一個方法,將傳入的 this,轉換成這樣的方式,那麼當前 this 的指向就是我們想要的結果。需要注意fn函式不能寫成箭頭函式,因為箭頭函式沒有this。所以模擬的步驟為:

  1. 將函式設定為傳入物件的屬性;
  2. 執行該函式;
  3. 刪除該屬性; 上面的例子就可以改寫為:
    // 給obj新增屬性func
    obj.func = fn;
    // 執行函式
    obj.func();
    // 刪除新增的屬性
    delete obj.func;
複製程式碼

3、模擬 call 方法

根據上面的思路,來模擬實現一版 call()方法。

Function.prototype.call1 = function (context) {
  context.func = this;
  context.func();
  delete context.func;
}
複製程式碼

實現了簡化版 call 方法,來試驗下是否能正確改變 this。

const obj = {
  value: 1,
}

function fn(arg) {
  console.log(this.value, arg); // 1, undefined
}

fn.call1(obj, 2);
複製程式碼

根據上面例子,已經能正確改變 this 的指向,但是傳入的值卻沒有拿到,該怎麼辦呢?考慮的傳入的值是不確定的,只能藉助Arguments 物件。通過它可以拿到所有傳入的引數。

4、模擬 call 方法第二版

上面提到可以通過 arguments 解決傳入引數不定長問題,如下:

const res = [];
// 因為第一個引數是傳入的this,故這裡從i = 1開始遍歷
for (let i = 1; i < arguments.length; i++) {
    res.push(arguments[i]);
}
// 或者
const args = [...arguments].splice(0, 1);
複製程式碼

這樣就能拿到所有的引數,接下來我們是不是將拿到的引數放到函式裡面執行就可以了嗎?先試一下:

Function.prototype.call1 = function(context) {
    // 初始化儲存函式引數
    const res = [];
    // 改變當前函式的this指向
    context.fn = this;

    for (let i = 1; i < arguments.length; i++) {
        res.push(arguments[i]);
    }

    context.fn(res.join(','));
    delete context.fn;
}
const obj = {
    value: 1,
}
function fn(val1, val2) {
    console.log(this.value, ...arguments); // 1 "1,2,200,[object Object]"
}
fn.call1(obj, [1,2], 200, {a: 1});
複製程式碼

結果和我們期望的不一致,函式的引數被轉換成了一個字串,那麼怎麼出來才能達到想要的結果呢?這兒可以考慮兩種方式處理

第一種方式 es6 的“...”操作符,如下:

Function.prototype.call1 = function(context) {
    // 初始化儲存函式引數
    const res = [];
    // 改變當前函式的this指向
    context.fn = this;

    for (let i = 1; i < arguments.length; i++) {
        res.push(arguments[i]);
    }

    context.fn(...res);
    delete context.fn;
}
const obj = {
    value: 1,
}
function fn(val1, val2) {
    console.log(this.value, ...arguments);  // 1 [1, 2] 200 {a: 1}
}
fn.call1(obj, [1,2], 200, {a: 1});
複製程式碼

第二種方式,藉助eval方法,eval 會將傳入的字串當做 JavaScript 程式碼執行。那麼我們可以考慮將要執行的函式,拼裝成字串,然後通過 eval 執行即可。思路如下:

 Function.prototype.call1 = function(context) {
    // 初始化,用於存放參數
    const args = [];
    // 在傳入的物件上設定屬性為待執行函式
    context.fn = this;

    for (let i = 1; i < arguments.length; i++) {
        // 將引數從第二個開始,拼裝成待執行的字串引數列表
        args.push(`arguments[${i}]`);
    }

    eval(`context.fn(${args})`);

    delete context.fn;
 }
  const obj = {
    value: 1,
  };
  function fn(val1, val2) {
    console.log(this.value, ...arguments); // 1 [1, 2] 200 {a: 1}
  }
  fn.call1(obj, [1, 2], 200, { a: 1 });
複製程式碼

到這裡,通過模擬實現 call 方法,已經能實現改變 this,傳入引數,是不是就完了呢?可能會有這樣一種情況,如果函式本身會有返回值,還是用嗎?如下:

  const obj = {
    value: 1,
  };
  function fn() {
    const args = [...arguments];
    console.log(this.value, ...args); // 1 [1, 2] 200 {a: 1}
    return {
      c: args,
    };
  }
  console.log(fn.call1(obj, [1, 2], 200, { a: 1 })); // undefined
複製程式碼

此時,執行函式的返回值為undefined,解決這個問題很好辦,在封裝的 call 方法裡面,將執行的函式結果存下來,return 出來即可,如下:

Function.prototype.call1 = function (context) {
    // 初始化,用於存放參數
    const args = [];
    // 在傳入的物件上設定屬性為待執行函式
    context.fn = this;

    for (let i = 1; i < arguments.length; i++) {
      // 將引數從第二個開始,拼裝成待執行的字串引數列表
      args.push(`arguments[${i}]`);
    }

    eval(`context.fn(${args})`);
    delete context.fn;
  };

  const obj = {
    value: 1,
  };
  function fn() {
    const args = [...arguments];
    console.log(this.value, ...args); // 1 [1, 2] 200 {a: 1}
    return {
      c: args[0],
    };
  }
  console.log(fn.call1(obj, [1, 2], 200, { a: 1 })); // {c: [1, 2]}
複製程式碼

模擬 call 終版

上面的方式都是使用 eval 來實現 call 方法,es6 提供了很多語法糖,個人比較喜歡 es6 的實現方式,比較簡潔,故終版使用的 es6 的實現方式。

Function.prototype.call1 = function () {
    // 初始化,獲取傳入的this物件和後續所有引數
    const [context, ...args] = [...arguments];
    // 在傳入的物件上設定屬性為待執行函式
    context.fn = this;
    // 執行函式
    const res = context.fn(args);
    // 刪除屬性
    delete context.fn;

    // 返回執行結果
    return res;
  };
複製程式碼

到此,模擬實現了 call 方法。

模擬 apply 方法

定義: 呼叫一個具有給定 this 值的函式,及以一個數組的形式提供的引數。

func.apply(thisArg, [argsArray]);
複製程式碼

從定義上知道,apply 相比於 call 方法,區別在與 this 後面的引數,call 後面的有一個或多個引數,而 apply 只有兩個引數,第二個引數是一個數組,如下:

const obj = {
    value: 1,
};

function fn() {
    console.log(this.value); // 1

    return [...arguments]
}

console.log(fn.apply(obj, [1, 2])); // [1, 2]
複製程式碼

思路

apply 的實現思路和 call 一樣,需要考慮的是 apply 只有兩個引數,因此,根據 call 的思路實現如下:

Function.prototype.apply1 = function (context, args) {
    // 給傳入的物件新增屬性,值為當前函式
    context.fn = this;

    // 判斷第二個引數是否存在,不存在直接執行,否則拼接引數執行,並存儲函式執行結果
    let res = !args ? context.fn() : context.fn(...args)

    // 刪除新增屬性
    delete context.fn;

    // 返回函式執行結果
    return res;
}

const obj = {
    value: 1,
};

function fn() {
    console.log(this.value); // 1

    return [...arguments]
}

console.log(fn.apply(obj, [1, 2])); // [1, 2]
複製程式碼

模擬 bind

定義:建立一個新的函式,在 bind 被呼叫時,這個新函式的 this 被指定為 bind()的第一個引數,而其餘引數將作為新函式的引數,供呼叫時使用。

function.bind(thisArg[, arg1[, arg2[, ...]]])
複製程式碼

bind 相比於 call、apply 有較大的區別,bind 方法會建立一個新的函式,返回一個函式,並允許傳入引數。首先看一個例子。

const obj = {
  value: 1,
  fn: function () {
    return this.value;
  },
};

const func = obj.fn;
console.log(func()); // undefined
複製程式碼

為什麼值是 undefined 呢?這會涉及到 this 的問題,不清楚的可以看這裡,簡單來講,函式的呼叫決定 this 的值,即執行時繫結。這裡聲明瞭 func 用於存放 obj.fn,再執行 func()方法時,當前的 this 指向的是 window 是,故值為 undefined。改如何處理才能達到預期的值呢?這時 bind 即將登場。

const obj = {
  value: 1,
  fn: function () {
    return this.value;
  },
};

const func = obj.fn;
const bindFunc = func.bind(obj);
console.log(bindFunc()); // 1
複製程式碼

模擬 bind 第一版

首先解決 bind 的第一個問題,返回一個函式,可通過閉包的模式實現,改變 this 指向問題,可以使用 call 和 apply 方法,可參照上面的實現方式。

Function.prototype.bind1 = function (context) {
  // 將當前函式的this存放起來
  const _self = this;

  return function () {
    // 改變this
    return _self.apply(context);
  };
};
複製程式碼

this 改變了後,需要考慮第二個問題,傳參,bind 的引數,從第二個到第 n 個函式,存在引數不定的情況,結合上面 call 的實現方式,解決這個問題如下:

// 方法1, 從第二個引數開始
const args = Array.prototype.slice.call(arguments, 1);

// 方法2
const [context, ...args] = [...arguments];
複製程式碼

引數取到後,將引數傳入即可,如下:

Function.prototype.bind1 = function (context) {
  // 將當前函式的this存放起來
  const _self = this;
  // 繫結bind傳入的引數,從第二個開始
  const args = Array.prototype.slice.call(arguments, 1);

  return function () {
    // 繫結bind返回新的函式,執行所帶的引數
    const bindArgs = Array.prototype.slice.apply(arguments);
    // 改變this
    return _self.apply(context, [...args, ...bindArgs]);
  };
};

const obj = {
  value: 1,
  fn: function () {
    return {
      value: this.value,
      args: [...arguments],
    };
  },
};

const func = obj.fn;
const bindFunc = func.bind1(obj, 1, 2);
console.log(bindFunc(3)); // { value: 1, args: [1, 2, 3]}
複製程式碼

到這裡,bind 的模擬已經完成一半,為什麼說完成一半呢?功能已經實現,考慮到有這樣一種情況,將繫結的 bind 返回的新函式作為建構函式使用,使用new操作符去建立一個由目標函式建立的新例項。當繫結函式是用來構建一個值的,原來提供的 this 就會被忽略。什麼意思呢?先看下面例子:

var value = 1;
var obj = {
  value: 100,
};
function Person(name, age) {
  this.name = name;
  this.age = age;

  console.log(this.value); // undefined
  console.log(name); // jack
  console.log(age); // 35
}

var bindPerson = Person.bind1(obj, "jack");
var bp = new bindPerson(35);
複製程式碼

從上面的結果可以看出,儘管已經在全域性和 obj 上定義了 value 值,但是建構函式 Person 中拿到的 this.value 仍然是 undefined 值,說明 this 的繫結失效了,為什麼會出現這樣的情況呢? 出現這樣的情況是因為關鍵字new造成的,當程式遇到 new 會進行如下的操作:

  1. 建立一個空的簡單的 JavaScript 物件(如{});
  2. 設定該物件的 constructor 到另外一個物件;
  3. 將步驟 1 建立的物件作為 this 的上下文;
  4. 如果函式沒有返回物件則返回 this; 這樣就明白為什麼 this.value 的值為 undefined 了,當前的 this 指向的是 bp,bp 上並沒有 value 屬性,所以為 undefined。

模擬 bind 終版

Function.prototype.bind1 = function (context) {
  // 將當前函式的this存放起來
  var _self = this;
  // 繫結bind傳入的引數,從第二個開始
  var args = Array.prototype.slice.call(arguments, 1);

  // 宣告一個空的建構函式
  function fNOP() {}

  var fBound = function () {
    // 繫結bind返回新的函式,執行所帶的引數
    const bindArgs = Array.prototype.slice.apply(arguments);
    // 合併陣列
    args.push.apply(args, bindArgs);
    // 作為普通函式,this指向Window
    // 作為建構函式,this指向例項
    return _self.apply(this instanceof fNOP ? this : context, args);
  };

  if (this.prototype) {
    // 修改返回函式的prototype為繫結函式的prototype,例項就可以繼承繫結函式的原型中的值
    fNOP.prototype = this.prototype;
  }

  // FNOP繼承fBound
  fBound.prototype = new fNOP();

  return fBound;
};
複製程式碼

在這個方法裡面用到了原型與原型鏈、繼承等知識,不清楚的可以轉到到《JavaScript 之深入原型與原型鏈》《JavaScript 之深入多種繼承方式及優缺點》。 到這裡,還需要思考呼叫 bind 不是函式怎麼辦?報個錯就好了

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

這裡模擬的 bind 函式不是最終版,在 CDN 上有bind 實現;

參考文獻

[0]《Function.prototype.call()》

[1]《Function.prototype.bind()》

[2]《Function.prototype.apply()》

[3]《JavaScript深入之bind的模擬實現》

[4]《JavaScript深入之call和apply的模擬實現》

分類: 前端 標籤: JavaScript