則與ES6箭頭函數(shù))
四年前我第一次在團隊里給人講JavaScript的this臺下有個同事直接舉手“你別講規(guī)則了就告訴我寫了ES6箭頭函數(shù)之后是不是就永遠不用再擔心this了”我當場沒回答因為這個問題本身就是個坑。后來我做技術(shù)分享、帶新人、面試候選人幾乎每次都會碰到關(guān)于this的討論。從ES5到ES6this的規(guī)則并沒有消失只是多了箭頭函數(shù)這個“特例”。這篇文章我打算把ES5的完整綁定規(guī)則、ES6箭頭函數(shù)的行為差異、真實項目里的踩坑場景放在一起講如果你能把這篇消化掉以后看到this就不會再靠猜了。1. ES5里this的指向到底由什么決定很多人記this的方式是“誰調(diào)用它this就指向誰”。這句話在ES5的大多數(shù)場景下能成立但一旦遇到回調(diào)函數(shù)、事件綁定、變量提取它就失靈了。原因很簡單this的綁定發(fā)生在調(diào)用階段而不是定義階段。你得看“這個函數(shù)到底以什么方式被調(diào)起來”而不是看“它在哪段代碼里被寫出來”。ES5里完整的this綁定規(guī)則一共四條默認綁定、隱式綁定、顯式綁定、new綁定。一條條拆開看比背口訣靠譜得多。1.1 默認綁定函數(shù)裸調(diào)用的兜底規(guī)則當一個函數(shù)不帶任何修飾、直接調(diào)用時就是默認綁定。非嚴格模式下這個this指向全局對象。瀏覽器里是windowNode.js里是global。嚴格模式下默認綁定的this是undefined。function showThis() { console.log(this); } showThis(); // 瀏覽器非嚴格模式下輸出 window如果文件開了use strict同樣一行代碼輸出的是undefined。這里有個特別容易被忽略的坑默認綁定到底是不是嚴格模式取決于函數(shù)體內(nèi)部的this有沒有處于嚴格模式而不是調(diào)用方有沒有嚴格模式。比如你從一個非嚴格模塊里調(diào)用一個嚴格模塊里的函數(shù)該函數(shù)的this依然是undefined。實際開發(fā)中我習慣把嚴格模式下this undefined當成一道保護線。很多框架的源碼里會寫if (this undefined) throw new TypeError(...)目的就是防止函數(shù)被裸調(diào)后污染全局狀態(tài)。1.2 隱式綁定對象方法調(diào)用時最常見的this隱式綁定發(fā)生在“通過對象的屬性去調(diào)用函數(shù)”這種形態(tài)里比如obj.method()。此時this指向那個對象。const user { name: 張三, getName() { return this.name; } }; console.log(user.getName()); // 張三這個規(guī)則很好理解但真正的坑往往出現(xiàn)在“隱式丟失”。舉個例子const fn user.getName; fn(); // undefinedthis不再指向user你以為只是把方法存到一個變量里但它本質(zhì)上變成了“函數(shù)裸調(diào)用”this就落回了默認綁定。類似的情況還出現(xiàn)在回調(diào)傳參的時候setTimeout(user.getName, 100); // 執(zhí)行時this不是user這背后的原因很直白user.getName()里user是引用載體setTimeout(user.getName)里引用的是函數(shù)本身user這個上下文沒有被傳過去。我踩過最慘的一次是前端用對象封裝了一批接口請求方法然后在數(shù)組循環(huán)里把方法當成回調(diào)傳給了事件監(jiān)聽器。結(jié)果方法內(nèi)的this全程指向window所有接口參數(shù)全變成undefined。后來我給對象方法統(tǒng)一補了bind問題才消失。所以記住只要方法脫離了原本的對象引用隱式綁定就失效。1.3 顯式綁定call、apply、bind統(tǒng)一接管this顯式綁定是ES5里最可靠的控制手段三個函數(shù)都是用來指定this的區(qū)別只在傳參方式和后處理方式上。fn.call(thisArg, arg1, arg2, ...)調(diào)用函數(shù)同時指定this參數(shù)逐個傳。fn.apply(thisArg, argsArray)調(diào)用函數(shù)同時指定this參數(shù)通過類數(shù)組或數(shù)組傳。fn.bind(thisArg, ...boundArgs)不調(diào)用函數(shù)返回一個全新函數(shù)這個函數(shù)無論之后怎么被調(diào)用this都會被鎖定為thisArg。前兩個是“臨時認領(lǐng)”第三個是“永久綁定”。比如function introduce(company, years) { console.log(${this.name} 在 ${company} 工作了 ${years} 年); } const person { name: 李四 }; introduce.call(person, 某科技公司, 3); introduce.apply(person, [某科技公司, 3]); const boundIntroduce introduce.bind(person); boundIntroduce(某科技公司, 3);這里有個實用細節(jié)bind返回的新函數(shù)不能通過再次bind改變指向。對一個已綁定的函數(shù)再做bind第一次的this仍然生效。我還遇到過有人用bind去“預(yù)填參數(shù)”這個特性倒是很好用相當于柯里化的一部分function multiply(a, b) { return a * b; } const double multiply.bind(null, 2); console.log(double(5)); // 101.4 new綁定構(gòu)造函數(shù)里this指向新對象當函數(shù)前面加了new調(diào)用函數(shù)內(nèi)部會發(fā)生一套完整的構(gòu)造流程。this指向新創(chuàng)建的實例對象默認情況下函數(shù)return的原始值會被忽略返回的是這個新對象。function Person(name) { this.name name; } const p1 new Person(王五); console.log(p1.name); // 王五有一個“冷知識”是如果構(gòu)造函數(shù)手動返回了一個對象那么這個對象會替代this成為new表達式的結(jié)果。ES5年代經(jīng)常有人靠這個特性實現(xiàn)“更安全的構(gòu)造器”但我建議你在日常代碼里不要這么寫可讀性差坑多。1.5 四條規(guī)則的優(yōu)先級怎么排序一旦函數(shù)調(diào)用同時符合多條規(guī)則就得按優(yōu)先級判斷。ES5里確定的順序是new綁定 顯式綁定 隱式綁定 默認綁定所以常見的判斷思路是這樣的const obj { name: obj, getName() { return this.name; } }; const newObj new obj.getName(); // new綁定生效 obj.getName.call(someOther); // call顯式綁定生效 obj.getName(); // 隱式綁定生效 const fn obj.getName; fn(); // 默認綁定生效我也見過一些寫得很繞的“孤兒代碼”把new、call、bind、對象調(diào)用全揉在一起面試的時候確實能考倒人但日常開發(fā)基本不會遇到。你只要掌握優(yōu)先級規(guī)則后面無論代碼多繞都能拆出結(jié)果。2. ES6箭頭函數(shù)為什么讓this“變簡單”了ES6引入箭頭函數(shù)最大的變化并不是“簡寫”而是它直接廢掉了自己綁定this的能力。箭頭函數(shù)內(nèi)部沒有獨立的this它的this是在定義時從外層作用域捕獲的而且是詞法上的捕獲。換句話說箭頭函數(shù)里的this就是它在寫代碼時所在那個作用域的this。const user { name: 趙六, sayLater() { setTimeout(() { console.log(this.name); }, 100); } }; user.sayLater(); // 趙六因為sayLater是普通方法調(diào)用時this指向user箭頭函數(shù)定義在sayLater內(nèi)部所以它捕獲了user這個this。如果是普通函數(shù)setTimeout里的this就會丟。2.1 箭頭函數(shù)捕獲this的方式更像變量查找很多人把箭頭函數(shù)理解成“this永遠不變”這個說法非常危險。它并不是“鎖死”了一個值而是像普通變量一樣沿著作用域鏈向上查找。function outer() { return () { console.log(this); }; } const objA { fn: outer() }; const objB { fn: outer.call(objB) };第一次調(diào)用outer()外層this是默認綁定的window箭頭函數(shù)捕獲的就是window之后無論你怎么用objA.fn()調(diào)用它都不會變成objA。第二次調(diào)用outer.call(objB)外層this是objB箭頭函數(shù)捕獲的就是objB。所以箭頭函數(shù)里的this不是一個“常量”它只是跟隨外層函數(shù)當時的this取值。外層this變了它也會跟著變。2.2 箭頭函數(shù)與普通函數(shù)的差異不止this除了this箭頭函數(shù)還有幾個硬性差異很容易在實際項目里觸發(fā)報錯它沒有arguments對象。如果在箭頭函數(shù)里寫arguments拿到的是外層函數(shù)的arguments不是自己的。它不能當構(gòu)造函數(shù)不能用new調(diào)用。它沒有prototype屬性。它內(nèi)部沒有super綁定。它不能被call/apply/bind改寫this因為根本沒有自己的this。傳進去的thisArg會被忽略。第5條的存在意味著即使你寫了arrowFn.call(obj)箭頭函數(shù)里的this還是原來的捕獲值。這不是bug而是語言設(shè)計上的取舍要么讓你自由綁定要么讓你詞法捕獲不能兩全。const getGlobal () this; console.log(getGlobal.call({ name: 無效 })); // 依舊是外層this不是這個對象2.3 箭頭函數(shù)也解決不了的方法this歸屬箭頭函數(shù)最大的副作用是會誤導(dǎo)一部分開發(fā)者在“對象方法”里也使用它然后踩進另一個坑。const user { name: 錢七, intro: () { return 我是${this.name}; } }; user.intro(); // 我是undefined原因很簡單這個箭頭函數(shù)定義在對象字面量里但對象字面量本身并不會創(chuàng)建一個“方法作用域”它內(nèi)部的大括號不是函數(shù)作用域箭頭函數(shù)捕獲的是外層作用域的this。在全局環(huán)境下定義對象捕獲到的就是window。所以我的經(jīng)驗是對象的方法該用普通函數(shù)就用普通函數(shù)箭頭函數(shù)主要用于“回調(diào)”和“需要保留外層this”的函數(shù)表達式而不是作為對象方法、類方法的主體。類方法里的箭頭函數(shù)屬于另一套邏輯我們放到后面細說。3. 實戰(zhàn)這些高頻場景中this到底指向誰光講規(guī)則不夠得回到真實場景里驗證。我把日常開發(fā)里最容易踩的this場景挑出來逐個說明this到底是誰以及對應(yīng)的處理方式。3.1 DOM事件監(jiān)聽里的this與currentTarget在原生DOM事件監(jiān)聽中普通函數(shù)作為監(jiān)聽器時函數(shù)內(nèi)的this指向綁定事件的元素本身。注意這里不是觸發(fā)事件的target而是綁定監(jiān)聽器的那個元素兩者在事件委托時會不同。document.querySelector(.btn).addEventListener(click, function () { console.log(this); // .btn元素 console.log(event.currentTarget); // .btn元素 console.log(event.target); // 真正被點擊的元素 });如果用箭頭函數(shù)作為監(jiān)聽器this就會變成外層上下文通常是window。很多新手在這里栽跟頭是因為看到別人用箭頭函數(shù)寫回調(diào)就順手用在事件監(jiān)聽上結(jié)果想通過this獲取當前元素時拿到了window。處理方式有兩種一種是堅持用普通函數(shù)保持this語義另一種是箭頭函數(shù)里用event.currentTarget替代this。我更推薦后一種尤其當你還需要在外層閉包里使用其他數(shù)據(jù)時箭頭函數(shù)反而更順手。3.2 setTimeout、setInterval和異步回調(diào)里的this異步回調(diào)是this丟失的重災(zāi)區(qū)。比如const order { amount: 100, pay() { setTimeout(function () { console.log(支付金額${this.amount}); }, 0); } }; order.pay(); // 支付金額undefinedsetTimeout執(zhí)行回調(diào)時并不會把order作為this傳進去普通函數(shù)在非嚴格模式下指向全局對象嚴格模式下為undefined。解決方案基本就三選一外部變量暫存this比如const self this調(diào)用外包一層箭頭函數(shù)對回調(diào)函數(shù)執(zhí)行bind。我個人現(xiàn)在很少用const self this這種方式了。ES6環(huán)境下直接箭頭函數(shù)一行解決代碼還干凈。但有一種情況要注意如果你寫的是一個會被外部動態(tài)調(diào)用、且無法控制封裝的庫函數(shù)那么bind可能比箭頭函數(shù)更穩(wěn)妥因為箭頭函數(shù)會提前把this鎖成定義時的值而bind是在函數(shù)準備好之后再指定上下文。3.3 對象方法鏈式調(diào)用和“方法提取”的坑對象方法連續(xù)調(diào)用時this的隱式綁定依然成立const calculator { total: 0, add(n) { this.total n; return this; }, multiply(n) { this.total * n; return this; }, getTotal() { return this.total; } }; calculator.add(5).multiply(3).getTotal(); // 15這種鏈式寫法依賴每個方法返回this。但如果你把add方法提取出來單獨調(diào)用this就丟了。項目里常見的問題是從模塊對象里解構(gòu)出方法然后當成獨立函數(shù)使用。const { add } calculator; add(5); // TypeError或者this指向全局解決方案是在模塊導(dǎo)出或?qū)ο蠖x階段就把方法綁定到固定this或者干脆不用對象方法而是用獨立函數(shù)顯式參數(shù)。這個設(shè)計決策越早做越好否則代碼庫大了以后到處是“丟失this再想辦法救回”的補丁。3.4 類class中this的綁定與事件回調(diào)ES6的class本質(zhì)上還是函數(shù)this的規(guī)則和ES5構(gòu)造函數(shù)一脈相承但有一個額外的特點類聲明和類體的代碼默認處于嚴格模式。這導(dǎo)致類方法被裸調(diào)時this不是window而是undefined一調(diào)用就會報錯反而不容易“靜默出錯”。class Counter { constructor() { this.count 0; } increment() { this.count; } } const counter new Counter(); const increment counter.increment; increment(); // TypeError: Cannot read property count of undefinedReact開發(fā)里最常見的做法是給方法手動綁定this或者直接用類字段箭頭函數(shù)。箭頭函數(shù)作為類字段時它捕獲的是實例構(gòu)造上下文里的this也就是實例本身因此可以安全地作為事件回調(diào)傳出去。class Counter { count 0; increment () { this.count; }; }這種寫法的代價是每個實例都會單獨創(chuàng)建一份函數(shù)不像原型方法那樣全局共享內(nèi)存上略高。但對于組件類應(yīng)用來說這種代價通??梢越邮軗Q來的是事件回調(diào)不丟this。如果你的場景極其追求性能且不需要把方法傳給外部那就還是用普通方法加bind。3.5 Map、數(shù)組遍歷回調(diào)中的thisArgES6的Map、Set以及數(shù)組的某些遍歷方法都支持傳入第二個參數(shù)作為回調(diào)的thisArg。比如Map.prototype.forEach(callback, thisArg)。很多人不知道這點以為回調(diào)里this只能用箭頭函數(shù)解決。const map new Map([[name, 周八]]); const handler { prefix: 用戶, show(key, value) { console.log(${this.prefix}${value}); } }; map.forEach(handler.show, handler); // 用戶周八這里如果不傳第二個參數(shù)handlerhandler.show被取出來以后this就是undefined。我和同事寫過一版數(shù)據(jù)清洗代碼里面大量使用了arr.forEach當時統(tǒng)一走了箭頭函數(shù)后來需要動態(tài)改變上下文才注意到forEach原生支持thisArg反而比bind更輕量。碰到這類方法時先查一下API簽名能少寫不少冗余代碼。4. 用call、apply、bind精準控制this的實戰(zhàn)技巧前面的場景說明了一個現(xiàn)實即使你理解規(guī)則代碼在傳遞過程中還是會有無數(shù)種辦法讓this丟失。所以工程上真正需要的是“主動控制this”的能力。這一章我系統(tǒng)講一下call、apply、bind在不同場景下的取舍以及常見誤用。4.1 call與apply的參數(shù)差異和經(jīng)典用法call和apply本質(zhì)一樣差別只在參數(shù)傳遞效率。需要傳參數(shù)量少、且能逐個列出時用call參數(shù)本身就以數(shù)組形式存在時用apply。一個經(jīng)典應(yīng)用是“借用數(shù)組方法”。比如要把類數(shù)組arguments轉(zhuǎn)成真數(shù)組最原始的方式是function toArray() { return Array.prototype.slice.call(arguments); }這里的關(guān)鍵是slice本意是按索引復(fù)制數(shù)組它并不關(guān)心this是不是真正的Array實例只要對象有l(wèi)ength和數(shù)字索引就能工作。這種“鴨子類型”的思路在JavaScript里特別常見。ES6以后我更喜歡Array.from(arguments)可讀性更強。但同類“借用API”的思想依然有用比如字符串方法借用、正則方法借用等。還有一個常見的apply用法不用循環(huán)求數(shù)組最大值。const nums [1, 3, 2, 8, 5]; Math.max.apply(null, nums); // 8ES6里可以寫成Math.max(...nums)更簡潔。但如果你在維護老代碼看到apply別慌它的作用就是“把數(shù)組展開成參數(shù)列表”。4.2 偏函數(shù)利用bind預(yù)置this和參數(shù)bind不只是綁this它還支持預(yù)置參數(shù)。這個能力在處理事件處理器時特別好用比如同一個函數(shù)要響應(yīng)不同狀態(tài)function updateState(type, event) { console.log(觸發(fā)了${type}event是${event.type}); } document.querySelector(.btn1).addEventListener(click, updateState.bind(null, 類型A)); document.querySelector(.btn2).addEventListener(click, updateState.bind(null, 類型B));注意這里bind的第一個參數(shù)是null因為原函數(shù)根本不依賴this。預(yù)置參數(shù)后事件對象會自動作為最后一個參數(shù)傳進來。這個模式在開發(fā)組件庫、工具函數(shù)時非常常見比包一層箭頭函數(shù)更省內(nèi)存因為每次bind都會返回一個固定函數(shù)不會每次render都重新創(chuàng)建。4.3 多次bind和丟失bind的迷思一個我經(jīng)常在面試里問的問題函數(shù)先bind一次再bind一次this到底是誰答案是最先綁定的this。因為第一次bind返回的已經(jīng)是一個bound function它的this被鎖死了第二次bind試圖再去改這個bound function的this不會生效。function sayName() { return this.name; } const objA { name: A }; const objB { name: B }; const boundA sayName.bind(objA); const boundB boundA.bind(objB); console.log(boundB()); // A別把bind理解成“包裹一層還能再改”它的實現(xiàn)邏輯是第一次bind后的函數(shù)內(nèi)部替身會記錄[[BoundThis]]之后無論如何調(diào)用都直接使用這個值。但是有一個細節(jié)如果第一次bind的this值是null之后是否可以再次生效要看運行環(huán)境新版JavaScript里bound函數(shù)再次綁定依然不會改寫原始鎖定的this值。另一種迷思是認為bind之后的函數(shù)沒有prototype、不能new。事實上bound function也可以被new這時原始函數(shù)的構(gòu)造邏輯會生效而不是把this鎖到綁定的對象上。這種場景日常非常少見知道就行別用來寫騷操作。4.4 什么時候該用箭頭函數(shù)什么時候該用bind我的選擇標準很清楚你只是想“繼承外層this”回調(diào)嵌套層級多希望寫起來自然首選箭頭函數(shù)。你需要在函數(shù)創(chuàng)建之后、某個確定時機再指定上下文或者需要重復(fù)復(fù)用一個綁定函數(shù)并傳遞出去用bind。你需要臨時調(diào)用一次并傳參用call或者apply。你在寫一個不依賴this的純函數(shù)三種都可以但盡量保持純函數(shù)風格不要依賴this。另外不要在對象方法上用箭頭函數(shù)去“解決this”那是南轅北轍。對象方法丟失this的根源往往是“方法被提取后調(diào)用”解決之道是bind或者箭頭函數(shù)做包裝不是把方法本身改成箭頭函數(shù)。5. 面試和日常中判斷this的一套心法最后這套心法是我這幾年面試候選人和自己排查bug時反復(fù)驗證出來的。遇到一個帶this的函數(shù)先別慌按下面流程一步一走基本不會判斷錯。5.1 四步定位法看函數(shù)本身是不是箭頭函數(shù)。如果是箭頭函數(shù)不去看調(diào)用形態(tài)直接向上找外層作用域的this。如果不是箭頭函數(shù)看它是怎么被調(diào)用的new調(diào)用就查構(gòu)造函數(shù)this指向新對象。有沒有call、apply、bind有則this指向顯式傳入的對象。如果前三條都不成立再看它是不是通過某個對象屬性調(diào)用的obj.fn()是則this指向obj否則默認綁定非嚴格模式是全局對象嚴格模式是undefined。這個順序不僅適用于讀代碼也適用于調(diào)試。哪一步卡住了就在這里打斷點或者打印日志比滿屏console.log瞎猜高效得多。5.2 三道高頻題驗證一下題一var name globalName; const obj { name: objName, getName() { return this.name; } }; const fn obj.getName; console.log(fn());答案globalName。因為fn是裸調(diào)用隱式綁定丟失落到默認綁定。注意非嚴格模式下this是全局對象取到全局name。題二const length 10; function fn() { console.log(this.length); } const obj { length: 5, method(fn) { fn(); arguments[0](); } }; obj.method(fn);這里第一行fn()是裸調(diào)用非嚴格模式下this是全局對象length是10第二行arguments[0]()屬于隱式綁定this指向arguments這個類數(shù)組對象arguments.length是2因為method傳了一個參數(shù)。所以輸出兩個值10和2。這道題考的是“arguments也是對象方法從里面取出來再調(diào)用會綁定arguments”。題三const obj { name: obj, says: () console.log(this.name) }; obj.says();答案取決于外層通常是undefined或全局name。因為箭頭函數(shù)不綁定obj它捕獲全局作用域的this。別把對象方法直接箭頭化。這三道題覆蓋了默認綁定、隱式綁定、詞法綁定三類情況每次面試我都喜歡拿著三類組合出題能刷掉一批背口訣的候選人。5.3 項目里的三條約定代碼規(guī)范比個人理解更重要。我?guī)У捻椖坷锿ǔ<s定普通方法定義統(tǒng)一用“方法簡寫語法”對象和類里不要用箭頭函數(shù)定義方法。類的實例方法如果會被解構(gòu)或作為回調(diào)統(tǒng)一在構(gòu)造器里bind或者使用類字段箭頭函數(shù)但要團隊內(nèi)統(tǒng)一風格。回調(diào)函數(shù)的this需要繼承外層時統(tǒng)一箭頭函數(shù)不要用const that this這種老寫法。凡是與DOM事件綁定的回調(diào)優(yōu)先使用event.currentTarget不要依賴普通函數(shù)的this語義這樣函數(shù)體可以被安全提取和復(fù)用。這些約定不是憑空拍腦袋每一類問題都對應(yīng)上面講過的某個坑。團隊里只要共同遵守this相關(guān)的bug會減少一大半。5.4 我自己的分析與調(diào)試習慣我調(diào)試this相關(guān)問題時最常用的工具不是console.log一個this就完事而是在函數(shù)執(zhí)行那一步打印調(diào)用棧。Chrome DevTools里每個棧幀會顯示當前這個函數(shù)是怎么被調(diào)起來的只要點開Call Stack就能直觀看到“是obj.method調(diào)用還是method裸調(diào)用”。另外遇到隱蔽的this問題我會臨時把函數(shù)內(nèi)容整體包一層Function.prototype.call去調(diào)整調(diào)用形態(tài)快速驗證當前this到底是誰。比如寫const suspectedThis { name: 測試 }; fn.call(suspectedThis);如果函數(shù)能正常運行說明它內(nèi)部確實依賴this那么問題就出在“傳入的上下文不對”如果換成call也沒用說明問題根本不是this而是函數(shù)內(nèi)部其他變量作用域出錯。這種二分排查法比瘋狂加日志要快很多。說到最后this的真正難點不在于規(guī)則多而在于JavaScript的函數(shù)“調(diào)用形態(tài)”多。ES6箭頭函數(shù)不是消滅了this而是給了你一條繞開動態(tài)綁定的路徑。我的個人建議是先精通ES5的默認綁定、隱式綁定、顯式綁定和new綁定再去用箭頭函數(shù)的詞法綁定這兩個知識體系缺一不可。你越是能準確說出“這一分this是哪里來的”后面的代碼就越不容易出暗病。