引言
JS
系列暫定 27 篇,從基礎,到原型,到非同步,到設計模式,到架構模式等。
本篇是JS
系列中第 5 篇,文章主講 JS 中 call
、 apply
、 bind
、箭頭函式以及柯里化,著重介紹它們之間的區別、對比使用,深入瞭解 call
、 apply
、 bind
。
一、Function.prototype.call()
call()
方法呼叫一個函式, 其具有一個指定的 this
值和多個引數(引數的列表)。
func.call(thisArg, arg1, arg2, ...)
複製程式碼
它執行 func
,提供的第一個引數 thisArg
作為 this
,後面的作為引數。
1. func 與 func.call
先看一個例子:
func(1, 2, 3);
func.call(obj, 1, 2, 3)
複製程式碼
他們都呼叫的是 func
,引數是 1
,2
和 3
。
唯一的區別是 func.call
也將 this
設定為 obj
。
需要注意的是,設定的 thisArg 值並不一定是該函式執行時真正的 this
值,如果這個函式處於非嚴格模式下,則指定為 null
和 undefined
的 this
值會自動指向全域性物件(瀏覽器中就是 window 物件),同時值為原始值(數字,字串,布林值)的 this
會指向該原始值的自動包裝物件。
2. func.call 繫結上下文
例如,在下面的程式碼中,我們在物件的上下文中呼叫 sayWord.call(bottle)
執行 sayWord
,並 bottle
傳遞為 sayWord
的 this
:
function sayWord() {
var talk = [this.name, 'say', this.word].join(' ');
console.log(talk);
}
var bottle = {
name: 'bottle',
word: 'hello'
};
// 使用 call 將 bottle 傳遞為 sayWord 的 this
sayWord.call(bottle);
// bottle say hello
複製程式碼
3. 使用 func.call 時未指定 this
非嚴格模式
// 非嚴格模式下
var bottle = 'bottle'
function say(){
// 注意:非嚴格模式下,this 為 window
console.log('name is %s',this.bottle)
}
say.call()
// name is bottle
複製程式碼
嚴格模式
// 嚴格模式下
'use strict'
var bottle = 'bottle'
function say(){
// 注意:在嚴格模式下 this 為 undefined
console.log('name is %s',this.bottle)
}
say.call()
// Uncaught TypeError: Cannot read property 'bottle' of undefined
複製程式碼
4. call 在 JS 繼承中的使用: 構造繼承
基本思想:在子型別的建構函式內部呼叫父型別建構函式。
注意:函式只不過是在特定環境中執行程式碼的物件,所以這裡使用 apply/call 來實現。
使用父類的建構函式來增強子類例項,等於是複製父類的例項屬性給子類(沒用到原型)
// 父類
function SuperType (name) {
this.name = name; // 父類屬性
}
SuperType.prototype.sayName = function () { // 父類原型方法
return this.name;
};
// 子類
function SubType () {
// 呼叫 SuperType 建構函式
// 在子類建構函式中,向父類建構函式傳參
SuperType.call(this, 'SuperType');
// 為了保證子父類的建構函式不會重寫子類的屬性,需要在呼叫父類建構函式後,定義子類的屬性
this.subName = "SubType";
// 子類屬性
};
// 子類例項
let instance = new SubType();
// 執行子類建構函式,並在子類建構函式中執行父類建構函式,this繫結到子類
複製程式碼
5. 解決 var 作用域問題
var bottle = [
{name: 'an', age: '24'},
{name: 'anGe', age: '12'}
];
for (var i = 0; i < bottle.length; i++) {
// 匿名函式
(function (i) {
setTimeout(() => {
// this 指向了 bottle[i]
console.log('#' + i + ' ' + this.name + ': ' + this.age);
}, 1000)
}).call(bottle[i], i);
// 呼叫 call 方法,同時解決了 var 作用域問題
}
複製程式碼
列印結果:
#0 an: 24
#1 anGe: 12
複製程式碼
在上面例中的 for
迴圈體內,我們建立了一個匿名函式,然後通過呼叫該函式的 call
方法,將每個陣列元素作為指定的 this
值立即執行了那個匿名函式。這個立即執行的匿名函式的作用是列印出 bottle[i]
物件在陣列中的正確索引號。
二、Function.prototype.apply()
apply()
方法呼叫一個具有給定 this
值的函式,以及作為一個陣列(或[類似陣列物件)提供的引數。
func.apply(thisArg, [argsArray])
複製程式碼
它執行 func
設定 this = context
並使用類陣列物件 args
作為引數列表。
例如,這兩個呼叫幾乎相同:
func(1, 2, 3);
func.apply(context, [1, 2, 3])
複製程式碼
兩個都執行 func
給定的引數是 1,2,3
。但是 apply
也設定了 this = context
。
call
和 apply
之間唯一的語法區別是 call
接受一個引數列表,而 apply
則接受帶有一個類陣列物件。
需要注意:Chrome 14 以及 Internet Explorer 9 仍然不接受類陣列物件。如果傳入類陣列物件,它們會丟擲異常。
1. call、apply 與 擴充套件運算子
我們已經知道了JS 基礎之: var、let、const、解構、展開、函式 一章中的擴充套件運算子 ...
,它可以將陣列(或任何可迭代的)作為引數列表傳遞。因此,如果我們將它與 call
一起使用,就可以實現與 apply
幾乎相同的功能。
這兩個呼叫結果幾乎相同:
let args = [1, 2, 3];
func.call(context, ...args); // 使用 spread 運算子將陣列作為引數列表傳遞
func.apply(context, args); // 與使用 call 相同
複製程式碼
如果我們仔細觀察,那麼 call
和 apply
的使用會有一些細微的差別。
- 擴充套件運算子
...
允許將 可迭代的引數列表
作為列表傳遞給call
。 apply
只接受 類陣列一樣的引數列表
。
2. apply 函式轉移
apply
最重要的用途之一是將呼叫傳遞給另一個函式,如下所示:
let wrapper = function() {
return anotherFunction.apply(this, arguments);
};
複製程式碼
wrapper
通過 anotherFunction.apply
獲得了上下文 this
和 anotherFunction
的引數並返回其結果。
當外部程式碼呼叫這樣的 wrapper
時,它與原始函式的呼叫無法區分。
3. apply 連線陣列
array.push.apply
將陣列新增到另一陣列上:
var array = ['a', 'b']
var elements = [0, 1, 2]
array.push.apply(array, elements)
console.info(array) // ["a", "b", 0, 1, 2]
複製程式碼
4. apply 來連結構造器
Function.prototype.constructor = function (aArgs) {
var oNew = Object.create(this.prototype);
this.apply(oNew, aArgs);
return oNew;
};
複製程式碼
5. apply 和內建函式
/* 找出陣列中最大/小的數字 */
let numbers = [5, 6, 2, 3, 7]
/* 應用(apply) Math.min/Math.max 內建函式完成 */
let max = Math.max.apply(null, numbers)
/* 基本等同於 Math.max(numbers[0], ...) 或 Math.max(5, 6, ..) */
let min = Math.min.apply(null, numbers)
console.log('max: ', max)
// max: 7
console.log('min: ', min)
// min: 2
複製程式碼
它相當於:
/* 程式碼對比: 用簡單迴圈完成 */
let numbers = [5, 6, 2, 3, 7]
let max = -Infinity, min = +Infinity
for (var i = 0; i < numbers.length; i++) {
if (numbers[i] > max)
max = numbers[i]
if (numbers[i] < min)
min = numbers[i]
}
console.log('max: ', max)
// max: 7
console.log('min: ', min)
// min: 2
複製程式碼
但是:如果用上面的方式呼叫 apply
,會有超出 JavaScript 引擎的引數長度限制的風險。更糟糕的是其他引擎會直接限制傳入到方法的引數個數,導致引數丟失。
所以,當資料量較大時
function minOfArray(arr) {
var min = Infinity
var QUANTUM = 32768 // JavaScript 核心中已經做了硬編碼 引數個數限制在65536
for (var i = 0, len = arr.length; i < len; i += QUANTUM) {
var submin = Math.min.apply(null, arr.slice(i, Math.min(i + QUANTUM, len)))
min = Math.min(submin, min)
}
return min
}
var min = minOfArray([5, 6, 2, 3, 7])
// max 同樣也是如此
複製程式碼
三、Function.prototype.bind()
JavaScript 新手經常犯的一個錯誤是將一個方法從物件中拿出來,然後再呼叫,希望方法中的 this
是原來的物件(比如在回撥中傳入這個方法)。如果不做特殊處理的話,一般 this
就丟失了。
例如:
let bottle = {
nickname: "bottle",
sayHello() {
console.log(`Hello, ${this.nickname}!`)
},
sayHi(){
setTimeout(function(){
console.log('Hello, ', this.nickname)
}, 1000)
}
};
// 問題一
bottle.sayHi();
// Hello, undefined!
// 問題二
setTimeout(bottle.sayHello, 1000);
// Hello, undefined!
複製程式碼
問題一的 this.nickname 是 undefined ,原因是 this 指向是在執行函式時確定的,而不是定義函式時候確定的,再因為 sayHi 中 setTimeout 在全域性環境下執行,所以 this 指向 setTimeout 的上下文:window。
問題二的 this.nickname 是 undefined ,是因為 setTimeout 僅僅只是獲取函式 bottle.sayHello 作為 setTimeout 回撥函式,this 和 bottle 物件分離了。
問題二可以寫為:
// 在這種情況下,this 指向全域性作用域
let func = bottle.sayHello;
setTimeout(func, 1000);
// 使用者上下文丟失
// 瀏覽器上,訪問的實際上是 Window 上下文
複製程式碼
那麼怎麼解決這兩個問題喃?
解決方案一: 快取 this 與包裝
首先通過快取 this 解決問題一 bottle.sayHi();
:
let bottle = {
nickname: "bottle",
sayHello() {
console.log(`Hello, ${this.nickname}!`)
},
sayHi(){
var _this = this // 快取this
setTimeout(function(){
console.log('Hello, ', _this.nickname)
}, 1000)
}
};
bottle.sayHi();
// Hello, bottle
複製程式碼
那問題二 setTimeout(bottle.sayHello, 1000);
喃?
let bottle = {
nickname: "bottle",
sayHello() {
console.log(`Hello, ${this.nickname}!`);
}
};
// 加一個包裝層
setTimeout(() => {
bottle.sayHello()
}, 1000);
// Hello, bottle!
複製程式碼
這樣看似解決了問題二,但如果我們在 setTimeout
非同步觸發之前更新 bottle
值又會怎麼樣呢?
var bottle = {
nickname: "bottle",
sayHello() {
console.log(`Hello, ${this.nickname}!`);
}
};
setTimeout(() => {
bottle.sayHello()
}, 1000);
// 更新 bottle
bottle = {
nickname: "haha",
sayHello() {
console.log(`Hi, ${this.nickname}!`)
}
};
// Hi, haha!
複製程式碼
bottle.sayHello()
最終列印為 Hi, haha!
,那麼怎麼解決這種事情發生喃?
解決方案二: bind
bind()
最簡單的用法是建立一個新繫結函式,當這個新繫結函式被呼叫時,this
鍵值為其提供的值,其引數列表前幾項值為建立時指定的引數序列,繫結函式與被調函式具有相同的函式體(ES5中)。
let bottle = {
nickname: "bottle",
sayHello() {
console.log(`Hello, ${this.nickname}!`);
}
};
// 未繫結,“this” 指向全域性作用域
let sayHello = bottle.sayHello
console.log(sayHello())
// Hello, undefined!
// 繫結
let bindSayHello = sayHello.bind(bottle)
// 建立一個新函式,將 this 繫結到 bottle 物件
console.log(bindSayHello())
// Hello, bottle!
複製程式碼
所以,從原來的函式和原來的物件建立一個繫結函式,則能很漂亮地解決上面兩個問題:
let bottle = {
nickname: "bottle",
sayHello() {
console.log(`Hello, ${this.nickname}!`);
},
sayHi(){
// 使用 bind
setTimeout(function(){
console.log('Hello, ', this.nickname)
}.bind(this), 1000)
// 或箭頭函式
setTimeout(() => {
console.log('Hello, ', this.nickname)
}, 1000)
}
};
// 問題一:完美解決
bottle.sayHi()
// Hello, bottle
// Hello, bottle
let sayHello = bottle.sayHello.bind(bottle); // (*)
sayHello();
// Hello, bottle!
// 問題二:完美解決
setTimeout(sayHello, 1000);
// Hello, bottle!
// 更新 bottle
bottle = {
nickname: "haha",
sayHello() {
console.log(`Hi, ${this.nickname}!`)
}
};
複製程式碼
問題一,可以通過 bind
或箭頭函式完美解決。
最終更新 bottle
後, setTimeout(sayHello, 1000);
列印依然是 Hello, bottle!
, 問題二完美解決!
1. bind 與 new
再看一個例子:
this.nickname = 'window'
let bottle = {
nickname: 'bottle'
}
function sayHello() {
console.log('Hello, ', this.nickname)
}
let bindBottle = sayHello.bind(bottle) // this 指向 bottle
console.log(bindBottle())
// Hello, bottle
console.log(new bindBottle()) // this 指向 sayHello {}
// Hello, undefined
複製程式碼
上面例子中,執行結果 this.nickname
輸出為 undefined
,這不是全域性 nickname
, 也不是 bottle
物件中的 nickname
,這說明 bind
的 this
物件失效了,new
的實現中生成一個新的物件,這個時候的 this
指向的是 sayHello
。
注意 :繫結函式也可以使用 new
運算子構造:這樣做就好像已經構造了目標函式一樣。提供的 this 值將被忽略,而前置引數將提供給模擬函式。
2. 二次 bind
function sayHello() {
console.log('Hello, ', this.nickname)
}
sayHello = sayHello.bind( {nickname: "Bottle"} ).bind( {nickname: "AnGe" } );
sayHello();
// Hello, Bottle
複製程式碼
輸出依然是 Hello, Bottle
,這是因為 func.bind(...)
返回的外來的繫結函式物件僅在建立的時候記憶上下文(如果提供了引數)。
一個函式不能作為重複繫結。
2. 偏函式
當我們確定一個函式的一些引數時,返回的函式(更加特定)被稱為偏函式。我們可以使用 bind
來獲取偏函式:
function list() {
return Array.prototype.slice.call(arguments);
}
var list1 = list(1, 2, 3); // [1, 2, 3]
var leadingThirtysevenList = list.bind(undefined, 37);
var list2 = leadingThirtysevenList(); // [37]
var list3 = leadingThirtysevenList(1, 2, 3); // [37, 1, 2, 3]
複製程式碼
當我們不想一遍又一遍重複相同的引數時,偏函式很方便。
3. 作為建構函式使用的繫結函式
function Bottle(nickname) {
this.nickname = nickname;
}
Bottle.prototype.sayHello = function() {
console.log('Hello, ', this.nickname)
};
let bottle = new Bottle('bottle');
let BindBottle = Bottle.bind(null, 'bindBottle');
let b1 = new BindBottle('b1');
b1 instanceof Bottle; // true
b1 instanceof BindBottle; // true
new Bottle('bottle1') instanceof BindBottle; // true
b1.sayHello()
// Hello, bindBottle
複製程式碼
四、柯里化
在電腦科學中,柯里化(Currying)是把接受多個引數的函式變換成接受一個單一引數(最初函式的第一個引數)的函式,並且返回接受餘下的引數且返回結果的新函式的技術。這個技術由 Christopher Strachey 以邏輯學家 Haskell Curry 命名的,儘管它是 Moses Schnfinkel 和 Gottlob Frege 發明的。
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
複製程式碼
這裡定義了一個 add
函式,它接受一個引數並返回一個新的函式。呼叫 add
之後,返回的函式就通過閉包的方式記住了 add
的第一個引數。所以說 bind
本身也是閉包的一種使用場景。
柯里化是將 f(a,b,c)
可以被以 f(a)(b)(c)
的形式被呼叫的轉化。JavaScript 實現版本通常保留函式被正常呼叫和在引數數量不夠的情況下返回偏函式這兩個特性。
五、擴充套件:箭頭函式
1. 沒有 this
let bottle = {
nickname: "bottle",
sayHi(){
setTimeout(function(){
console.log('Hello, ', this.nickname)
}, 1000)
// 或箭頭函式
setTimeout(() => {
console.log('Hi, ', this.nickname)
}, 1000)
}
};
bottle.sayHi()
// Hello, undefined
// Hi, bottle
複製程式碼
報錯是因為 Hello, undefined
是因為執行時 this=Window
, Window.nickname
為 undefined
。
但箭頭函式就沒事,因為箭頭函式沒有 this
。在外部上下文中,this
的查詢與普通變數搜尋完全相同。this
指向定義時的環境。
2. 不可 new 例項化
不具有 this
自然意味著另一個限制:箭頭函式不能用作建構函式。他們不能用 new
呼叫。
3. 箭頭函式 vs bind
箭頭函式 =>
和正常函式通過 .bind(this)
呼叫有一個微妙的區別:
.bind(this)
建立該函式的 “繫結版本”。- 箭頭函式
=>
不會建立任何繫結。該函式根本沒有this
。在外部上下文中,this
的查詢與普通變數搜尋完全相同。
4. 沒有 arguments 物件
箭頭函式也沒有 arguments
變數。
因為我們需要用當前的 this
和 arguments
轉發一個呼叫,所有這對於裝飾者來說非常好。
例如,defer(f, ms)
得到一個函式,並返回一個包裝函式,以 毫秒
為單位延遲呼叫:
function defer(f, ms) {
return function() {
setTimeout(() => f.apply(this, arguments), ms)
};
}
function sayHi(who) {
alert('Hello, ' + who);
}
let sayHiDeferred = defer(sayHi, 2000);
sayHiDeferred("John"); // 2 秒後列印 Hello, John
複製程式碼
沒有箭頭功能的情況如下所示:
function defer(f, ms) {
return function(...args) {
let ctx = this;
setTimeout(function() {
return f.apply(ctx, args);
}, ms);
};
}
複製程式碼
在這裡,我們必須建立額外的變數 args
和 ctx
,以便 setTimeout
內部的函式可以接收它們。
5. 總結
- this 指向定義時的環境
- 不可 new 例項化
- this 不可變
- 沒有 arguments 物件
六、參考
七、系列文章
- JS 系列一:var、let、const、解構、展開、new、this、class、函式
- JS 系列二:深入 constructor、prototype、proto、[[Prototype]] 及 原型鏈
- 一文帶你深入剖析 instanceof 運算子
- JS 系列四:深入剖析 instanceof 運算子
想看更過系列文章,點選前往 github 部落格主頁
八、走在最後
1. ❤️玩得開心,不斷學習,並始終保持編碼。??
2. 如有任何問題或更獨特的見解,歡迎評論或直接聯絡瓶子君(公眾號回覆 123 即可)!??
3. ?歡迎關注:前端瓶子君,每日更新!?