系統,紮實的 javascript 語言基礎是一個優秀的前端工程師必須具有的。在看了一些關於 call,apply,bind 的文章後,我仍是打算寫下這篇總結,緣由其實有好幾個。首先,在現在 ES6 大行其道的今天,不少文章中講述的它們的應用場景其實用 ES6 能夠更優雅的解決。再則,講它們的實現原理的文章很少,本文將把它們經過代碼一一模擬實現,讓它們再也不神祕。不謙虛的說,關於 call,apply,bind 的知識,看這一篇文章就夠了。javascript
咱們知道在 javascript 的 function 中有 this
,arguments
等關鍵字。本文不討論 this 指向問題,那個均可以單獨整一篇文章了。一個常見的使用場景是當你使用 .
來調用一個函數的時候,此時函數中 this 指向 .
前面的調用者:前端
const person = { name: 'YuTengjing', age: 22, introduce() { console.log(`Hello everyone! My name is ${this.name}. I'm ${this.age} years old.`); } }; // this 此時指向 person console.log(person.introduce()); // => Hello everyone! My name is YuTengjing. I'm 22 years old.
經過 call,apply,bind 這三兄弟能夠改變 introduce
中 this 的指向。java
const myFriend = { name: 'dongdong', age: 21, }; console.log(person.introduce.call(myFriend)); // => Hello everyone! My name is dongdong. I'm 21 years old.
經過上面代碼咱們能夠看出 introduce
這個函數中的 this 指向被改爲了 myFriend。Function.prototype.call 的函數簽名是 fun.call(thisArg, arg1, arg2, ...)
。第一個參數爲調用函數時 this 的指向,隨後的參數則做爲函數的參數並調用,也就是 fn(arg1, arg2, ...)。git
apply 和 call 的區別只有一個,就是它只有兩個參數,並且第二個參數爲調用函數時的參數構成的數組。函數簽名:func.apply(thisArg, [argsArray])
。若是不用給函數傳參數,那麼他倆就實際上是徹底同樣的,須要傳參數的時候注意它的應該將參數轉換成數組形式。github
一個簡單的例子:面試
function displayHobbies(...hobbies) { console.log(`${this.name} likes ${hobbies.join(', ')}.`); } // 下面兩個等價 displayHobbies.call({ name: 'Bob' }, 'swimming', 'basketball', 'anime'); // => // => Bob likes swimming, basketball, anime. displayHobbies.apply({ name: 'Bob' }, ['swimming', 'basketball', 'anime']); // => Bob likes swimming, basketball, anime.
有些 API 好比 Math.max 它的參數爲多參數,當咱們有多參數構成的數組使或者說參數不少時該怎麼辦呢?chrome
// Math.max 參數爲多參數 console.log(Math.max(1, 2, 3)); // => 3 // 如今已知一個很大的元素爲隨機大小的整數數組 const bigRandomArray = [...Array(10000).keys()].map(num => Math.trunc(num * Math.random())); // 怎樣使用 Math.max 獲取 bigRandomArray 中的最大值呢?Math.max 接受的是多參數而不是數組參數啊! // 思考下面的寫法 console.log(Math.max.apply(null, bigRandomArray)); // => 9936
能夠上 ES6 的話就簡單了,使用擴展運算符便可,優雅簡潔。數組
console.log(Math.max(...bigRandomArray));
bind 和上面兩個用途差異仍是比較大,如同字面意思(綁定),是用來綁定 this 指向的,返回一個原函數被綁定 this 後的新函數。一個簡單的例子:瀏覽器
const person = { name: 'YuTengjing', age: 22, }; function introduce() { console.log(`Hello everyone! My name is ${this.name}. I'm ${this.age} years old.`); } const myFriend = { name: 'dongdong', age: 21 }; person.introduce = introduce.bind(myFriend); // person.introduce 的 this 已經被綁定到 myFriend 上了 console.log(person.introduce()); // => Hello everyone! My name is dongdong. I'm 21 years old. console.log(person.introduce.call(person)); // => Hello everyone! My name is dongdong. I'm 21 years old.
bind 的函數簽名是 func.bind(thisArg, arg1, arg2, ...)
。春招的時候被問過 bind 的第二個參數是幹嗎用的,由於我以前寫代碼自己不怎麼用這幾個 API,用的時候我也只用第一個參數,因此當時面試的時候被問這個問題的時候我仍是愣了一下。不過其實若是能夠傳多個參數的話,猜也能猜得出來是幹嗎用的,我當時就猜對了φ(* ̄0 ̄)。前端工程師
咱們學習知識的時候不能只是停留在理解層面,須要去思考它們有什麼用,應用場景有哪些。這樣的話,當你處在這種場景中,你就能很天然的想出解決方案。
javascript 中有不少 API 是接受多個參數的好比以前提過的 Math.max,還有不少例如 Math.min,Array.prototype.push 等它們都是接受多個參數的 API,可是有時候咱們只有多個參數構成的數組,並且可能還特別大,這個時候就能夠利用 apply 巧妙的來轉換。
下面是利用 apply 來巧妙的合併數組:
let arr1 = [1, 2, 3]; let arr2 = [4, 5, 6]; Array.prototype.push.apply(arr1, arr2); console.log(arr1); // [1, 2, 3, 4, 5, 6]
可是,其實用 ES6 能夠很是的簡潔:
arr1.push(...arr2);
因此,忘了這種用法吧( ̄︶ ̄)↗ 。
JavaScript類型化數組是一種相似數組的對象,它們有數組的一些屬性,可是若是你用 Array.isArray() 去測試會返回 false,常見的像 arguments,NodeList 等。
function testArrayLike() { // 有 length 屬性沒有 slice 屬性 console.log(arguments.length); // => 3 console.log(arguments.slice); // => undefined // 類數組不是數組 console.log(Array.isArray(arguments)); // => false console.log(arguments); // => { [Iterator] 0: 'a', 1: 'b', 2: 'c', [Symbol(Symbol.iterator)]: [λ: values] } const array = Array.prototype.slice.call(arguments); console.log(Array.isArray(array)); // => true console.log(array); // => [ 'a', 'b', 'c' ] } testArrayLike('a', 'b', 'c');
其實 把 slice 換成 concat,splice 等其它 API 也是能夠的。思考:爲何經過 Array.prototype.slice.call(arrayLike) 能夠轉換類數組爲數組?
我沒有研究過 slice 的具體實現,猜想是下面這樣的:
Array.prototype.mySlice = function(start=0, end) { const array = this; const end = end === undefined ? array.length : end; const resultArray = []; if (array.length === 0) return resultArray; for (let index = start; index < end; index++) { resultArray.push(array[index]); } return resultArray; }
我想 slice 內部實現可能就是會像我上面的代碼同樣只須要一個 length 屬性,遍歷元素返回新數組,因此調用 slice 時將其 this 指向類數組能正常工做。
其實,這個用法也能夠忘了,用 ES6 來轉換不造多簡單,ES6 大法好😂。
可使用 Array.from(arrayLike):
const array = Array.from(arguments);
還可使用擴展運算符:
const array = [...arguments];
ES6 class 出現以前,我的認爲比較完美的繼承是使用原型鏈加組合的繼承方式,之前研究原型繼承寫的代碼在這:prototypeExtends。這裏不展開講 javascript 的繼承,那會又是一個巨坑。
組合繼承其實很好理解,這個組合指的是子類的實例屬性組合了父類的實例屬性,看代碼:
function Animal(type) { this.type = type; } function Bird(type, color) { Animal.call(this, type); this.color = color; } const bird = new Bird('bird', 'green'); console.log(bird); // => Bird { type: 'bird', color: 'green' }
組合繼承核心代碼就是那句 Animal.call(this, type),經過調用父類構造器並修改其 this 指向爲子類實例來達到子類實例上組合父類的實例屬性目的。
實現 call 主要有兩種思路,一種是經過在 thisArg 上臨時添加 func,而後直接調用 thisArg.func()。另一種是利用 func.toString() 替換 this 爲 thisArg,再 eval 來實現。
下面這個版本主要爲了說明思路,實際上是有不少缺陷的:
Function.prototype.myCall = function(thisArg, ...args) { // 這裏的 this 其實就是 func.myCall(thisArg, ...args) 中的 func,由於 myCall 是經過 func 調用的嘛 const func = this; // 在 thisArg 上臨時綁定 func thisArg.tempFunc = func; // 經過 thisArg 調用 func 來達到改變 this 指向的做用 const result = thisArg.tempFunc(...args); // 刪除臨時屬性 delete thisArg.tempFunc; return result; } function printName() { console.log(this.name); } console.log(printName.myCall({ name: 'ly' })); // => ly
上面的代碼中有一些缺陷:
myCall 的第一個參數可能被傳入非對象參數,要對不一樣類型的 thisArg 分別處理。MDN 中對 thisArg 的描述:
在 fun 函數運行時指定的this
值 。須要注意的是,指定的this
值並不必定是該函數執行時真正的this
值,若是這個函數在非嚴格模式
下運行,則指定爲null
和undefined
的this
值會自動指向全局對象(瀏覽器中就是 window 對象),同時值爲原始值(數字,字符串,布爾值)的this
會指向該原始值的自動包裝對象。
因此完善後的 myCall 是醬紫:
Function.prototype.myCall = function(thisArg, ...args) { if (thisArg === undefined || thisArg === null) { // 若是 thisArg 是 undefined 或則 null,this 指向全局對象,直接調用就能夠達到指向全局對象的目的了 return tempFunc(...args); } // 這裏的 this 其實就是 func.myCall(thisArg, ...args) 中的 func,由於 myCall 是經過 func 調用的嘛 const func = this; const tempFunc = Symbol('Temp property'); // 在 thisArg 上臨時綁定 func thisArg[tempFunc] = func; // 經過 thisArg 調用 func 來達到改變 this 指向的做用 const result = thisArg[tempFunc](...args); // 刪除臨時屬性 delete thisArg[tempFunc]; return result; } function printName() { console.log(this.name); } console.log(printName.myCall({ name: 'ly' })); // => ly
將第二中方式以前,先來聊聊其它的一些相關的東西。
調用一個函數的 toString 方法返回的是這個函數定義時代碼字符:
我故意在 console.log('hello world');
上下插了一個空行,func 左右多打了幾個空格,能夠看到 func.toString() 返回的字符串徹底是我定義 func 時的樣子,多餘的空行和空格依然存在,沒有格式化。
eval 函數可讓咱們將一個字符串看成代碼來運行:
const ctx = { name: 'Bob' }; eval('console.log(ctx.name)'); // Bob
因此看到這裏思路已經很清晰了,先經過 func.toString 拿到 func 的代碼字符串,再替換其中的 this 爲 thisArg,再使用 eval 獲取替換 this 後的臨時函數(函數名顯然和 func 同樣)並執行。代碼實現就是醬紫:
Function.prototype.myCall = function (thisArg, ...args) { if (thisArg === undefined || thisArg === null) { // 若是 thisArg 是 undefined 或則 null,this 指向全局對象,直接調用就能夠達到指向全局對象的目的了 return tempFunc(); } // 這裏的 this 其實就是 func.myCall(thisArg, ...args) 中的 func,由於 myCall 是經過 func 調用的嘛 const func = this; const funcString = func.toString(); // 替換 this 爲 thisArg const tempFuncString = funcString.replace('this', 'thisArg'); // 經過 eval 構造一個臨時函數並執行 const tempFunc = eval(`(${tempFuncString})`); // 調用 tempFunc 並傳入參數 return tempFunc(...args); } function printName() { console.log(this.name); } console.log(printName.myCall({ name: 'ly' })); // => ly
添加一些打印語句後在 chrome 中的執行狀況:
可是,這種實現方式實際上是很扯淡的。它有不少不能容忍並且無解的缺陷:
(${tempFuncString})
) 時聲明瞭一個和 func 同名的臨時函數,它的做用域是 myCall 這個函數做用域,而 func 的做用域顯然在 myCall 外。因此,相對而言,第一種實現更靠譜。
call 和 apply 除了參數不同以外沒什麼區別。因此稍微調整 myCall 中的參數和調用 func 時的調用形式便可。
Function.prototype.myApply = function(thisArg, args) { if (thisArg === undefined || thisArg === null) { // 若是 thisArg 是 undefined 或則 null,this 指向全局對象,直接調用就能夠達到指向全局對象的目的了 return tempFunc(args); } // 這裏的 this 其實就是 func.myCall(thisArg, ...args) 中的 func,由於 myCall 是經過 func 調用的嘛 const func = this; const tempFunc = Symbol('Temp property'); // 在 thisArg 上臨時綁定 func thisArg[tempFunc] = func; // 經過 thisArg 調用 func 來達到改變 this 指向的做用 const result = thisArg[tempFunc](args); // 刪除臨時屬性 delete thisArg[tempFunc]; return result; } function printName() { console.log(this.name); } console.log(printName.myCall({ name: 'ly' })); // => ly
第二種方式就不寫了,其實也很簡單,不寫主要時由於第二種實現沒什麼實用性,介紹它的就是爲了擴展一下思路。
使用 call 來實現 bind 是一個比較常見的面試題,相似於使用 map 實現 reduce,其實仍是考察你 javascript 掌握的怎麼樣。若是面試被問到閉包有哪些實際應用你其實也能夠說可使用閉包來實現 bind,對吧,面試仍是有些技巧的。
思路我上面其實已經說了,就是利用閉包和 call 就能夠了。
Function.prototype.myBind = function(thisArg, ...args) { const func = this; // bind 返回的是一個新函數 return function(...otherArgs) { // 執行函數時 this 始終爲外層函數中的 thisArg,前面的調用參數也被綁定爲 args return func.call(thisArg, ...args, ...otherArgs) }; } function printThisAndAndArgs() { console.log(`This is ${JSON.stringify(this)}, arguments is ${[...arguments].join(', ')}`); } const boundFunc = printThisAndAndArgs.myBind({ name: 'Lily' }, 1, 2, 3) boundFunc(4, 5, 6); // => This is {"name":"Lily"}, arguments is 1, 2, 3, 4, 5, 6
按照慣例,上面實現的版本確定是有些問題的ㄟ( ▔, ▔ )ㄏ。
第一個問題是沒處理當使用 new 調用的狀況:
function Student(name, age) { this.name = name; this.age = age; } const BoundStudent1 = Student.bind({ name: 'Taylor' }, 'ly'); console.log(new BoundStudent1(22)); // => Student { name: 'ly', age: 22 } const BoundStudent2 = Student.myBind({ name: 'Taylor' }, 'ly'); console.log(new BoundStudent2(22)); // => {}
能夠看到 ES5 新增的 bind 當返回的函數被使用 new 調用時, thisArg 被忽略,此時 bind 函數的做用只是起到了綁定構造函數參數的做用。當前版本的 myBind 只是返回了一個空對象,沒有在返回的實例對象上綁定屬性。
這裏補充一下 new 操做符的實現原理。我有一個項目javascript-code-lab上保存我探索原生 js 奧祕的一些代碼,有興趣能夠看看。其中我 new 操做符的實現是這樣的:
const _new = (fn, ...args) => { const target = Object.create(fn.prototype); const result = fn.call(target, ...args); const isObjectOrFunction = (result !== null && typeof result === 'object') || typeof result === 'function'); return isObjectOrFunction ? result : target; }
其實很好理解,當咱們調用 new fn(arg1, arg2, ...) 的時候,其實至關於執行了 _new(fn, arg1, arg2, ...)。具體內部的執行步驟是這樣的:
首先構造一個空對象 target,它的原型應該爲 fn.prototype,這裏我使用了 ES6 的 Object.create 來實現。
而後咱們須要在 target 上綁定你在 fn 中經過 this.key = value 來綁定到實例對象的屬性。具體作法就是執行 fn 而且將其 this 指向 target,也就是 const result = fn.call(target, ...args);。
最後還要注意的就是當 fn 的返回值 result 是對象或者函數的時候,new fn(arg1, arg2, ...) 返回的就是指行 fn 的返回值而不是 target,不然直接返回 target,也就是實例對象。
若是有人問你有哪些方式能夠修改函數的 this 指向,其實 new 操做符也能夠修改構造函數的指向,沒毛病吧。
瞭解了 new 操做符的原理以後,咱們再來看看上面咱們實現的 myBind 爲何會在 new 時工做不正常。當咱們調用 new BoundStudent2(22) 時,根據我上面講的 new 的原理知道,在構造出一個以 BoundStudent.prototype 爲原型的空對象 target 後,會調用 BoundStudent.call(target) 。可是,觀察咱們實現的 myBind,做爲 myBind(thisArg) 的返回值的 BoundStudent2,它內部執行時始是調用 func.call(thisArg, ...args, ...otherArgs),也就是說 this 始終是 thisArg,因此纔沒有綁定 name,age 屬性到 target 上,實際上是被綁定到了 thisArg 上去了。並且因爲 BoundStudent.call(target) 返回值爲 undefined,因此 new BoundStudent2(22) 的結果就是 target。
上面咱們分析了 new 調用 myBind 綁定的函數產生的問題的緣由,那麼該如何解決呢?想要解決這個問題咱們必須得可以區分出調用 BoundFunc2 時是不是經過 new 來調用的。可使用 ES6 中 new.target 來區分。
new.target屬性容許你檢測函數或構造方法是不是經過new運算符被調用的。在經過new運算符被初始化的函數或構造方法中,new.target
返回一個指向構造方法或函數的引用。在普通的函數調用中,new.target
的值是undefined
。
Function.prototype.myBind = function (thisArg, ...args) { const func = this; // bind 返回的是一個新函數,若是使用 new 調用了被綁定後的函數,其中的 this 便是 new 最後返回的實例對象,也就是 target return function (...otherArgs) { // 當 new.target 爲 func,不爲空時,綁定 this,而不是 thisArg return func.call(new.target ? this : thisArg, ...args, ...otherArgs) }; } function Student(name, age) { this.name = name; this.age = age; } const BoundStudent1 = Student.bind({ name: 'Taylor' }, 'ly'); console.log(new BoundStudent1(22)); // => Student { name: 'ly', age: 22 } const BoundStudent2 = Student.myBind({ name: 'Taylor' }, 'ly'); console.log(new BoundStudent2(22)); // => { name: 'ly', age: 22 }
當前版本的 myBind 沒有處理原型鏈,BoundStudent2 new 出來的實例沒法訪問 Student 原型鏈上的屬性。修改以下:
Function.prototype.myBind = function (thisArg, ...args) { const func = this; // bind 返回的是一個新函數,若是使用 new 調用了被綁定後的函數,其中的 this 便是 new 最後返回的實例對象,也就是 target const boundFunc = function (...otherArgs) { // 當 new.target 爲 func,不爲空時,綁定 this,而不是 thisArg return func.call(new.target ? this : thisArg, ...args, ...otherArgs) }; boundFunc.prototype = Object.create(func.prototype); boundFunc.prototype.constructor = boundFunc; return boundFunc; } function Student(name, age) { this.name = name; this.age = age; } Student.prototype.type = 'student'; const BoundStudent2 = Student.myBind({ name: 'Taylor' }, 'ly'); console.log(new BoundStudent2(22).type); // => student
返回的函數畢竟是一個新的函數,它的有些屬性須要咱們修改。咱們在處理一下 name 和 length 屬性。若是一個函數 func 被綁定了英文叫 bound,那麼 func.name 應該是 bound func
。
function func() {} const boundFunc = func.bind({}); console.log(boundFunc.name); // bound func
func.length 表示函數的參數個數,可是 BoundFunc 的參數個數和 func 的參數個數可不同,因此咱們須要調整 func.length。值得注意的是 Function.prototype.name 和 Function.prototype.length 是不可寫的,因此要經過 Object.defineProperties 來修改。
最終版:
Function.prototype.myBind = function (thisArg, ...args) { const func = this; // bind 返回的是一個新函數,若是使用 new 調用了被綁定後的函數,其中的 this 便是 new 最後返回的實例對象,也就是 target const boundFunc = function (...otherArgs) { // 當 new.target 爲 func,不爲空時,綁定 this,而不是 thisArg return func.call(new.target ? this : thisArg, ...args, ...otherArgs) }; boundFunc.prototype = Object.create(func.prototype); boundFunc.prototype.constructor = boundFunc; Object.defineProperties(boundFunc, { name: { value: `bound ${func.name}` }, length: { value: func.length } }); return boundFunc; } function Student(name, age) { this.name = name; this.age = age; } Student.prototype.type = 'student'; const BoundStudent2 = Student.myBind({ name: 'Taylor' }, 'ly'); console.log(new BoundStudent2(22).type); // => student console.log(BoundStudent2.name); // => bound Student console.log(BoundStudent2.length); // => 2
不能,被綁定後,後續再次使用 bind 綁定沒有做用。最後執行函數 fn 時,this 始終時被指向第一次 bind 時的 thisArg。
不能,緣由和上面同樣。
function test() { console.log(this); } let boundTest = test.bind({ name: 'ly' }); boundTest(); // => { name: 'ly' } boundTest = boundTest.bind({ name: 'dongdong' }); boundTest(); // => { name: 'ly' } boundTest.call({ name: 'yinyin' }); // => { name: 'ly' }
其實最近看過有些公司前端面試還考了偏函數的知識,其實也用到了 bind。這裏我不打算講偏函數了,偏函數我有空再寫一篇文章單獨講。
參考資料:
若是文章內容有什麼錯誤或者不當之處,歡迎在評論區指出。感謝您的閱讀,若是文章對您有所幫助或者啓發,不妨點個贊,關注一下唄。
本文爲原創內容,首發於我的博客,轉載請註明出處。