iT邦幫忙

2022 iThome 鐵人賽

DAY 10
2

前言

雖然 Day8 的文章有提到 call()、bind()、apply() 這三個函式,不過也就只是淺淺帶過它們都能改變 this 指向,所以接下來的幾篇文章要來詳細的介紹它們,今天會先介紹 call() 和 apply()。


語法介紹

call()

語法: fn.call(thisArg, arg1, arg2..., argn)

第一個參數傳入的值會作為該函式 this 指向,假設沒有傳入,在瀏覽器環境下指向 window 物件。

第二個參數以後的參數會作為參數傳進目標函式中,如果函式不需要參數則不要傳入即可。

apply()

語法: fn.apply(thisArg, [arg1, arg2..., argn])

和 call() 用法幾乎一樣,差異在於它的第二個參數是陣列,第二個參數可加可不加。


使用 call() & apply() 範例

call() & apply() 的應用眾多,這裡舉幾個例子來說明。

使用範例1:

透過 call() 去借用其他物件的函式:

Function Borrowing

const wizard = {
  name: "Jack",
  health: 100,
  heal(num1, num2) {
    return (this.health += num1 + num2);
  }
};

const warrior = {
  name: "Reiner",
  health: 30
};

console.log(warrior); // health: 30
wizard.heal.call(warrior, 50, 20);
console.log(warrior); // health: 100

使用範例2:

字串當作 call 的參數傳入,這種情況只適合用在不會修改到字串的純粹函式:

const data1 = 'Jordan Smith';

const data2 = [].filter.call(data1, function(ele, index) {
  return index > 6;
});

console.log(data2); // ["S", "m", "i", "t", "h"]

使用範例3:

MDN 介紹 Object.prototype.toString() 的文件有段落提到可以使用 Object.prototype.toString() 搭配 call() 去偵測一個值的類別

const toString = Object.prototype.toString;

toString.call(new Date);    // [object Date]
toString.call(new String);  // [object String]
toString.call(Math);        // [object Math]

// Since JavaScript 1.8.5
toString.call(undefined);   // [object Undefined]
toString.call(null);        // [object Null]

根據上段程式碼,我們可以寫出幾個判斷型別的函式:

const isObject = (obj) => Object.prototype.toString.call(obj) === '[object Object]';

const isArray = (obj) => Object.prototype.toString.call(obj) === '[object Array]';

const isNumber = (obj) => Object.prototype.toString.call(obj) === '[object Number]';

透過高階函式改寫:

const isType = type => obj => Object.prototype.toString.call(obj) === '[object ' + type + ']';

isType('String')('123');	// true
isType('Array')([1, 2, 3]);	// true
isType('Number')(123);		// true

使用範例4:

將一個陣列的元素加到另一個陣列:

const arr1 = ['a', 'b'];
const arr2 = [0, 1, 2];

arr1.push.apply(arr1, arr2);
console.log(arr1); // ["a", "b", 0, 1, 2]

實作 call()

透過重複造輪子的過程能幫助自己更了解 JS,所以以下要來實作 call() 這個函式,首先我們先看一段使用 call() 的程式碼,經觀察後可以知道兩點:

  1. call() 改變 this 的指向,變成了 person,而後面的參數 Jerry 變成 showName 的參數
  2. 函式 showName 被執行
function showName(...args) {
  console.log(`${args} ${this.name}.`);
}

const person = { name: 'Harry' };

showName.call(person, 'My name is'); // 'My name is Harry.'

以下的內容說明為了不占篇幅,會省略 showName 函式和 person 物件的程式碼。

第一個版本的 myCall() 函式

根據上述兩點,先初步寫出 myCall() 函式,帶入兩個參數,並且透過兩個 console 可以看到 myCall() 函式裡印出的值,如以下註解:

Function.prototype.myCall = function(thisArg, ...args) {
  console.log(thisArg); // {name: 'Harry'}
  console.log(this); // function showName {...}
  // this 指向調用該函式的物件,showName 函式也是物件
}

showName.call(person, 'My name is');

接著思考: 如果 person 物件有 showName 函式的話,是不是就可以順利的印出想要的結果?

const person = {
  name: 'Harry',
  showName(...args) {
    console.log(`${args} ${this.name}.`);
  }
};

person.showName('My name is'); // 'My name is Harry.'

將想法加入到實作的 myCall() 函式內:

Function.prototype.myCall = function(thisArg, ...args) {
  thisArg.fn = this; // person 物件加入 showName 函式
  thisArg.fn(...args); // 呼叫 showName 函式
  delete thisArg.fn; // 最後將原本不存在 person 物件的 showName 函式移除
}

showName.myCall(person, 'My name is'); // 'My name is Harry.'

這樣就完成我們初步版本的 call() 了!

第二個版本的 myCall() 函式

但上面版本的 myCall() 函式有三個狀況不能處理:

  1. myCall() 的第一個參數若傳入 null 或是 undefined 會出錯
  2. 不能處理原始型別,例如數字
  3. 沒有處理到呼叫函式的回傳值

讀者可以觀看以下範例,就知道上面三點在程式運作上的問題。

function showName(...args) {
  return `${args} ${this.name}.`; // 改寫成回傳字串
}

const person = { name: 'Harry' };

Function.prototype.myCall = function(thisArg, ...args) {
  thisArg.fn = this;
  thisArg.fn(...args);
  delete thisArg.fn;
}

// 1. myCall() 的第一個參數若傳入 null 或是 undefined 會出錯
showName.myCall(null, 'My name is'); // Cannot set properties of null

// 2. 不能處理原始型別,例如數字
const isNumber = (obj) => Object.prototype.toString.myCall(obj) === '[object Number]';
console.log(isNumber(2)); // Cannot read properties of undefined

// 3. 沒有處理到呼叫函式的回傳值
const result = showName.myCall(person, 'My name is');
console.log(result); // undefined

接著就來動手改良吧!

處理第一點的問題

因為原生的 call() 函式的第一個參數 thisArg 傳入 null 或是 undefined 的話,this 會指向 window,所以我們可以判斷傳入的參數是否存在,不存在的話就將 thisArg 賦值 window。

Function.prototype.myCall = function(thisArg, ...args) {
  thisArg = thisArg ? thisArg : window;
  thisArg.fn = this;
  thisArg.fn(...args);
  delete thisArg.fn;
}

處理第二點的問題

將上面的那行 thisArg = thisArg ? thisArg : window; 改寫成 thisArg = thisArg ? Object(thisArg) : window;,透過 Object() 去將傳入的參數轉成物件,像有問題的範例中 isNumber(2) 傳入數字 2,轉換後變成截圖的樣子:

處理第三點的問題

最後就是處理呼叫函式沒回傳的值,所以將執行結果存成變數回傳即可。

Function.prototype.myCall = function(thisArg, ...args) {
  thisArg = thisArg ? Object(thisArg) : window;
  thisArg.fn = this;
  const result = thisArg.fn(...args);
  delete thisArg.fn;
  return result;
}

第三個版本的 myCall() 函式

到第二版的 myCall() 函式已經能處理大部分情況了,但當然還有可以再進一步考慮優化的。

當嚴格模式下 thisArg 值為 null/undefined 時,this 不應該讓它指向 window 而是 undefined,所以要加上判斷是否嚴格模式的程式碼。

判斷是否嚴格模式可以用這段程式碼:
const isStrict = (function(){ return this === undefined }())

然後如果試著在嚴格模式下用原生 call() ,並且第一個參數為 null/undefined 時去呼叫函式的話 showName.call(null, 'My name is');,會跳出錯誤 Cannot read properties of...

所以加上 if (!thisArg) this(...args);,直接呼叫函式,此範例的話就是直接呼叫 showName 函式。

Function.prototype.myCall = function(thisArg, ...args) {
  const isStrict = (function(){ return this === undefined }());

  thisArg = thisArg ? Object(thisArg) : (isStrict ? undefined : window);  
  if (!thisArg) this(...args);

  thisArg.fn = this;
  const result = thisArg.fn(...args);
  delete thisArg.fn;
  return result;
}

最後這就是完成的樣子,apply() 的實作也打同小異,這裡就交給讀者試試囉~明天將要來介紹 bind()。


上一篇
Day9-箭頭函式與 this
下一篇
Day11-bind() 函式介紹 & 實作
系列文
強化 JavaScript 之 - 程式語感是可以磨練成就的30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言