手寫call、apply和bind函式
手寫call、apply和bind函式
chicABoo 2021年04月08日 17:15 · 閱讀 637前言
提到改變 this 的指向,首先想到的方式就是 call、apply 和 bind。對於每種方式底層是如何實現,大多數人不太清楚,如果你還不清楚他們的用法,請移步call、apply、bind。本文會簡單講解他們的用法,底層實現思路,及模擬實現 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
。所以模擬的步驟為:
- 將函式設定為傳入物件的屬性;
- 執行該函式;
- 刪除該屬性; 上面的例子就可以改寫為:
// 給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 會進行如下的操作:
- 建立一個空的簡單的 JavaScript 物件(如{});
- 設定該物件的 constructor 到另外一個物件;
- 將步驟 1 建立的物件作為 this 的上下文;
- 如果函式沒有返回物件則返回 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()》
[4]《JavaScript深入之call和apply的模擬實現》
分類: 前端 標籤: JavaScript