
壹 ❀ 引
我在 五種綁定策略徹底弄懂this 一文中,我們提到call,apply,bind
屬於顯示綁定,這三個方法都能直接修改this指向。其中call與apply比較特殊,它們在修改this的同時還會直接執行方法,而bind只是返回一個修改完this的boundFunction
並未執行,那么今天我們來講講如果通過JavaScript模擬實現call與apply方法。
貳 ❀ 關於call與apply1
貳 ✿ 壹 call與apply區別
除了都能改變this指向並執行函數,call與apply唯一區別在於參數不同,具體如下:
var fn = function (arg1, arg2) {
// do something
};
fn.call(this, arg1, arg2); // 參數散列
fn.apply(this, [arg1, arg2]) // 參數使用數組包裹
call第一參數為this指向,后續散列參數均為函數調用所需形參,而在apply中這些參數被包裹在一個數組中。
貳 ✿ 貳 使用場景
call與apply在日常開發中非常實用,我們在此列舉幾個實用的例子。
檢驗數據類型:
function type(obj) {
var regexp = /\s(\w+)\]/;
var result = regexp.exec(Object.prototype.toString.call(obj))[1];
return result;
};
console.log(type([123]));//Array
console.log(type('123'));//String
console.log(type(123));//Number
console.log(type(null));//Null
console.log(type(undefined));//Undefined
數組取最大/小值:
var arr = [11, 1, 0, 2, 3, 5];
// 取最大
var max1 = Math.max.call(null, ...arr);
var max2 = Math.max.apply(null, arr);
// 取最小
var min1 = Math.min.call(null, ...arr);
var min2 = Math.min.apply(null, arr);
console.log(max1); //11
console.log(max2); //11
console.log(min1); //0
console.log(min2); //0
函數arguments類數組操作:
var fn = function () {
var arr = Array.prototype.slice.call(arguments);
console.log(arr); //[1, 2, 3, 4]
};
fn(1, 2, 3, 4);
關於這兩個方法實用簡單說到這里,畢竟本文的核心主旨是手動實現call與apply方法,我們接着說。
叄 ❀ 實現一個call方法
我們從一個簡單的例子解析call方法
var name = '時間跳躍';
var obj = {
name: '聽風是風'
};
function fn() {
console.log(this.name);
};
fn(); //時間跳躍
fn.call(obj); //聽風是風
在這個例子中,call方法主要做了兩件事:
- 修改了
this
指向,比如fn()
默認指向window
,所以輸出時間跳躍
- 執行了函數
fn
叄 ✿ 壹 改變this並執行方法
先說第一步改變this
怎么實現,其實很簡單,只要將方法fn
添加成對象obj
的屬性不就好了。所以我們可以這樣:
//模擬call方法
Function.prototype.call_ = function (obj) {
obj.fn = this; // 此時this就是函數fn
obj.fn(); // 執行fn
delete obj.fn; //刪除fn
};
fn.call_(obj); // 聽風是風
注意,這里的call_
是我們模擬的call
方法,我們來解釋模擬方法中做了什么。
- 我們通過
Function.prototype.call_
的形式綁定了call_
方法,所以所有函數都可以直接訪問call_
。 fn.call_
屬於this隱式綁定,所以在執行時call_
時內部this
指向fn
,這里的obj.fn = this
就是將方法fn
賦予成了obj
的一條屬性。obj
現在已經有了fn
方法,執行obj.fn
,因為隱式綁定的問題,fn
內部的this
指向obj
,所以輸出了聽風是風
。- 最后通過
delete
刪除了obj
上的fn
方法,畢竟執行完不刪除會導致obj
上的屬性越來越多。
叄 ✿ 貳 傳參
我們成功改變了this指向並執行了方法,但仍有一個問題待解決,call_
無法接受參數。
其實也不難,我們知道函數有一個arguments
屬性,代指函數接收的所有參數,它是一個類數組,比如下方例子:
Function.prototype.call_ = function (obj) {
console.log(arguments);
};
fn.call_(obj, 1, 2, 3);// [{name:'聽風是風'},1,2,3...]
很明顯arguments
第一位參數是我們需要讓this指向的對象,所以從下標1開始才是真正的函數參數,這里就得對arguments
進行加工,將下標1之后的參數剪切出來。
有同學肯定就想到了arguments.splice
,前面說了arguments並非數組,所以不支持Array
方法。沒關系,不是還有Array.prototype.slice.call(arguments)
嗎,轉一次數組再用。很遺憾,我們現在是在模擬call
方法,也不行。那就用最保險的for
循環吧,如下:
Function.prototype.call_ = function (obj) {
var args = [];
// 注意i從1開始
for (var i = 1, len = arguments.length; i < len; i++) {
args.push(arguments[i]);
};
console.log(args);// [1, 2, 3]
};
fn.call_(obj, 1, 2, 3);
數組也不能直接作為參數傳遞給函數,有同學可能想到array.join
字符拼接方法,這也存在一個問題,比如我們是希望傳遞參數1 2 3
三個參數進去,但經過join
方法拼接,它會變成一個參數"1,2,3"
,函數此時接受的就只有一個參數了。
所以這里我們不得不借用惡魔方法eval,看個簡單的例子:
var fn = function (a, b, c) {
console.log(a + b + c);
};
var arr = [1, 2, 3];
fn(1, 2, 3);//6
eval("fn(" + arr + ")");//6
你一定有疑問,為什么這里數組arr
都不分割一下,fn
在執行時又如何分割數組呢?其實eval
在執行時會將變量轉為字符串,這里隱性執行了arr.toString()
。來看個有趣的對比:
console.log([1, 2, 3].toString()); //"1,2,3"
console.log([1, 2, 3].join(',')); //"1,2,3"
可以看出``eval幫我們做了數組處理,這里就不需要再使用
join方法了,因此
eval("fn(" + arr + ")")可以看成
eval("fn(1,2,3)")`。
我們整理下上面的思路,改寫后的模擬方法就是這樣:
var name = '時間跳躍';
var obj = {
name: '聽風是風'
};
function fn(a, b, c) {
console.log(a + b + c + this.name);
};
//模擬call方法
Function.prototype.call_ = function (obj) {
var args = [];
// 注意i從1開始
for (var i = 1, len = arguments.length; i < len; i++) {
args.push(arguments[i]);
};
obj.fn = this; // 此時this就是函數fn
eval("obj.fn(" + args + ")"); // 執行fn
delete obj.fn; //刪除fn
};
fn.call_(obj, "我的", "名字", "是");
可以了嗎?很遺憾,這段代碼會報錯。因為我們傳遞的后三個參數都是字符串。在args.push(arguments[i])
這一步我們提前將字符串進行了解析,這就導致eval
在執行時,表達式變成了eval("obj.fn(我的,名字,是)");
設想一下我們普通調用函數的形式是這樣obj.fn("我的","名字","是")
,所以對於eval而言就像傳遞了三個沒加引號的字符串,無法進行解析。
不信我們可以傳遞三個數字,比如:
fn.call_(obj, 1,2,3); // 6聽風是風
因為數字不管加不加引號,作為函數參數都是可解析的,而字符串不加引號,那就被認為是一個變量,而不存在我的
這樣的變量,自然就報錯了。
怎么辦呢?其實我們可以在args.push(arguments[i])
這里先不急着解析,改寫成這樣:
args.push("arguments[" + i + "]");
遍歷完成的數組args最終就是這個樣子["arguments[1]","arguments[2]","arguments[3]"]
,當執行eval
時,arguments[1]
此時確實是作為一個變量存在不會報錯,於是被eval
解析成了一個真正的字符傳遞給了函數。
所以改寫后的call_
應該是這樣:
var name = '時間跳躍';
var obj = {
name: '聽風是風'
};
function fn(a, b, c) {
console.log(a + b + c + this.name);
};
//模擬call方法
Function.prototype.call_ = function (obj) {
var args = [];
// 注意i從1開始
for (var i = 1, len = arguments.length; i < len; i++) {
args.push("arguments[" + i + "]");
};
obj.fn = this; // 此時this就是函數fn
eval("obj.fn(" + args + ")"); // 執行fn
delete obj.fn; //刪除fn
};
fn.call_(obj, "我的", "名字", "是"); // 我的名字是聽風是風
叄 ✿ 叄 考慮特殊this指向
我們知道,當call
第一個參數為undefined
或者null
時,this默認指向window
,所以上面的方法還不夠完美,我們進行最后一次改寫,考慮傳遞參數是否是有效對象:
var name = '時間跳躍';
var obj = {
name: '聽風是風'
};
function fn(a, b, c) {
console.log(a + b + c + this.name);
};
//模擬call方法
Function.prototype.call_ = function (obj) {
//判斷是否為null或者undefined,同時考慮傳遞參數不是對象情況
obj = obj ? Object(obj) : window;
var args = [];
// 注意i從1開始
for (var i = 1, len = arguments.length; i < len; i++) {
args.push("arguments[" + i + "]");
};
obj.fn = this; // 此時this就是函數fn
eval("obj.fn(" + args + ")"); // 執行fn
delete obj.fn; //刪除fn
};
fn.call_(obj, "我的", "名字", "是"); // 我的名字是聽風是風
fn.call_(null, "我的", "名字", "是"); // 我的名字是時間跳躍
fn.call_(undefined, "我的", "名字", "是"); // 我的名字是時間跳躍
那么到這里,對於call
方法的模擬就完成了。
肆 ❀ 實現一個apply方法
apply
方法因為接受的參數是一個數組,所以模擬起來就更簡單了,理解了call
實現,我們就直接上代碼:
var name = '時間跳躍';
var obj = {
name: '聽風是風'
};
function fn(a, b, c) {
console.log(a + b + c + this.name);
};
//模擬call方法
Function.prototype.apply_ = function (obj, arr) {
obj = obj ? Object(obj) : window;
obj.fn = this;
if (!arr) {
obj.fn();
} else {
var args = [];
// 注意這里的i從0開始
for (var i = 0, len = arr.length; i < len; i++) {
args.push("arr[" + i + "]");
};
eval("obj.fn(" + args + ")"); // 執行fn
};
delete obj.fn; //刪除fn
};
fn.apply_(obj, ["我的", "名字", "是"]); // 我的名字是聽風是風
fn.apply_(null, ["我的", "名字", "是"]); // 我的名字是時間跳躍
fn.apply_(undefined, ["我的", "名字", "是"]); // 我的名字是時間跳躍
伍 ❀ 總
上述代碼總有些繁雜,我們來總結下這兩個方法:
// call模擬
Function.prototype.call_ = function (obj) {
//判斷是否為null或者undefined,同時考慮傳遞參數不是對象情況
obj = obj ? Object(obj) : window;
var args = [];
// 注意i從1開始
for (var i = 1, len = arguments.length; i < len; i++) {
args.push("arguments[" + i + "]");
};
obj.fn = this; // 此時this就是函數fn
var result = eval("obj.fn(" + args + ")"); // 執行fn
delete obj.fn; //刪除fn
return result;
};
// apply模擬
Function.prototype.apply_ = function (obj, arr) {
obj = obj ? Object(obj) : window;
obj.fn = this;
var result;
if (!arr) {
result = obj.fn();
} else {
var args = [];
// 注意這里的i從0開始
for (var i = 0, len = arr.length; i < len; i++) {
args.push("arr[" + i + "]");
};
result = eval("obj.fn(" + args + ")"); // 執行fn
};
delete obj.fn; //刪除fn
return result;
};
如果允許使用ES6,使用拓展運算符會簡單很多,實現如下:
// ES6 call
Function.prototype.call_ = function (obj) {
obj = obj ? Object(obj) : window;
obj.fn = this;
// 利用拓展運算符直接將arguments轉為數組
let args = [...arguments].slice(1);
let result = obj.fn(...args);
delete obj.fn
return result;
};
// ES6 apply
Function.prototype.apply_ = function (obj, arr) {
obj = obj ? Object(obj) : window;
obj.fn = this;
let result;
if (!arr) {
result = obj.fn();
} else {
result = obj.fn(...arr);
};
delete obj.fn
return result;
};
那么到這里,關於call與apply模擬實現全部結束。bind實現存在部分不同,我另起了一篇文章,詳情請見js 手動實現bind方法,超詳細思路分析!
這篇文章也是第一篇我使用markdown書寫的文章,為了統一樣式,我也專門修改了博客樣式。