:徹底吃透JavaScript函數(shù)調(diào)用機制)
上周給幾個準備校招的朋友過了一遍手寫 apply發(fā)現(xiàn)一個很有意思的現(xiàn)象凡是能順利寫出來的都是真正想通了 this 綁定的人凡是卡住的基本都被“怎么讓這個函數(shù)用我指定的 this 去調(diào)用”這句話繞暈了。這道題在面試里出現(xiàn)頻率高不是因為它難而是它把 JavaScript 的函數(shù)調(diào)用機制、原型鏈、參數(shù)處理、異常清理全部串在了一塊。這篇文章就拿 call 當對照組把 apply 從最小實現(xiàn)到健壯版本逐步拆開。我不繞彎子直接給你看完整結(jié)論apply 和 call 的區(qū)別只有參數(shù)傳入方式不同其余機制完全同源。理解了這一點手寫 apply 的難度會瞬間下降一個量級。文章里我會把每一行代碼為什么這么寫、常見邊界怎么處理、動手測試會遇到哪些坑都展開講適合正在準備面試的開發(fā)者也適合想弄明白 this 到底怎么工作的人。1. 動手之前先搞清楚 this 到底是怎么決定的1.1 四條綁定規(guī)則重點先看“方法調(diào)用”JavaScript 里的 this 不是在定義函數(shù)時確定的而是在函數(shù)被調(diào)用的一瞬間由調(diào)用方式?jīng)Q定。日常見到的 this 綁定規(guī)則可以濃縮成四種默認綁定直接裸調(diào)fn()非嚴格模式下 this 指向全局對象嚴格模式下是undefined。隱式綁定通過對象屬性調(diào)用obj.fn()this 指向obj。顯式綁定用fn.call(obj)、fn.apply(obj)、fn.bind(obj)手動指定。new 綁定構(gòu)造函數(shù)被new調(diào)用時this 指向新創(chuàng)建的對象。四條規(guī)則的優(yōu)先級是new 顯式綁定 隱式綁定 默認綁定。這四條里手寫 apply 真正用到的是“隱式綁定”。有個現(xiàn)象很多人天天見卻沒回過味來obj.method()之所以 this 指向 obj是因為函數(shù)調(diào)用時點號左邊的對象會作為基準把 this 綁定到它身上。這不是什么魔法而是 JS 語言規(guī)范對“成員表達式調(diào)用”的固定處理。既然引擎面對obj.method()會把 this 指向 obj那我們能不能反過來用這個機制把任何一個函數(shù)臨時變成某個對象的屬性再用obj.fn()的形式調(diào)用this 是不是就自然等于那個對象了這就是手寫 apply 的底層邏輯。1.2 手寫 apply 的本質(zhì)把函數(shù)“借”給對象當方法用一次理解了隱式綁定apply 的實現(xiàn)思路其實一句話就能說清把被調(diào)用的函數(shù)臨時掛到指定對象上當作對象的方法調(diào)用一次調(diào)用完立刻刪除這個臨時屬性。寫得更流程化一點拿到目標函數(shù)它此時是this。把目標函數(shù)賦值到context對象上例如context.fn this。用context.fn(...args)的方式調(diào)用this 自動就是context。拿到返回值后刪除context.fn這個臨時屬性。把返回值拋給調(diào)用者。這個過程可以類比成“借鄰居的廚房做一頓飯”你手里有烤箱目標函數(shù)鄰居家有廚房context 對象你把烤箱搬進鄰居家、固定在臺面上context.fn this做完菜、取走菜拿到 return 值再把烤箱搬走delete 臨時屬性。有一個細節(jié)必須留意整個過程中臨時掛上去的那個屬性不能被原對象已有的屬性“撞車”也不能污染原對象。所以后來 ES6 時代我們普遍用Symbol生成唯一 key而不是隨便起一個字符串名字。這個是后話第 3 章會專門展開?,F(xiàn)在再回頭看為什么手寫 apply 要寫在Function.prototype上。因為 apply 是所有函數(shù)共用的能力掛在原型上任何一個函數(shù)都可以通過fn.myApply(...)訪問到此時方法內(nèi)部的this就是調(diào)用它的那個函數(shù)。如果你想放在普通函數(shù)里也行但那就無法做到“任何函數(shù)都能直接用”所以原型擴展是唯一合理位置。2. call 與 apply 的差異一個傳參數(shù)列表一個傳參數(shù)數(shù)組2.1 原生行為對比代碼幾乎一樣就差一手參數(shù)展開很多人會把 call 和 apply 當成兩個孤立的 API 去背其實它們的內(nèi)部骨架完全一致。用同一個加法函數(shù)舉例function sum(a, b, c) { return a b c; } // call參數(shù)一個個列出來 sum.call(null, 1, 2, 3); // 6 // apply參數(shù)放在數(shù)組或類數(shù)組里 sum.apply(null, [1, 2, 3]); // 6用表格看幾點核心對比對比維度callapply手寫時關(guān)注點thisArg 綁定支持支持兩者一致處理 null/undefined 的方式相同參數(shù)傳入形式不定長參數(shù)列表數(shù)組 / 類數(shù)組對象apply 多做一步“展開數(shù)組”是否立即執(zhí)行是是返回值都來自原函數(shù)的 return典型場景參數(shù)個數(shù)固定參數(shù)動態(tài)生成、存在數(shù)組里ES6 之前 apply 幾乎無法被替代在 ES5 時代apply 有一個殺手級場景把一個數(shù)組元素批量展開傳給函數(shù)。Math.max.apply(null, [1, 2, 3])是當年取數(shù)組最大值的標準寫法因為Math.max不接收數(shù)組、只接收不定長參數(shù)而 apply 恰好能把數(shù)組“拆”成一個個參數(shù)送進去。同樣的場景還有arr1.push.apply(arr1, arr2)。到了 ES6展開運算符出來之后這兩個場景都能直接寫成Math.max(...[1, 2, 3])和arr1.push(...arr2)。所以現(xiàn)在 apply 在日常代碼里的出鏡率確實低了不少但面試題依然愛考因為它考的不是“你會不會用”而是“你是否理解函數(shù)調(diào)用的本質(zhì)”。2.2 為什么“基于 call 對比”是吃透 apply 的最短路徑這里有個很關(guān)鍵的理解call 本質(zhì)上可以被 apply 實現(xiàn)反之則不行至少在 ES5 下不行。先看正向推導(dǎo)。手寫一個 myCall 只需要一行Function.prototype.myCall function (context, ...rest) { return this.myApply(context, rest); };為什么能這么寫因為 rest 參數(shù)收集到的本身就是數(shù)組把它直接作為 apply 的第二個參數(shù)就完成了“參數(shù)列表 → 參數(shù)數(shù)組”的轉(zhuǎn)換。反過來假如先手寫 call再想從 call 推導(dǎo)出 apply就卡住了call 接收的是arg1, arg2, arg3...這種獨立參數(shù)你當然可以用arguments拿到偽數(shù)組但問題是 ES5 沒有展開運算符你無法把一個數(shù)組“拆”成獨立參數(shù)傳給函數(shù)。也就是說apply 比 call 多繞了一道“參數(shù)數(shù)組展開”這個操作在 ES6 之前沒有語法層面的原生支持只能靠 apply 自己實現(xiàn)。所以標題說“5 分鐘吃透”的底氣從這里來你只要記住 call 的 this 綁定機制apply 只是在參數(shù)處理上多了一個數(shù)組展開動作其余完全沒有區(qū)別。當你把差異定位到這一點手寫 apply 就不是背代碼而是順理成章地把 call 的實現(xiàn)延伸一下。3. 手寫 myApply從最小實現(xiàn)到健壯版3.1 最小實現(xiàn)核心邏輯只有幾行先把核心邏輯跑通。有一版“最小實現(xiàn)”長這樣Function.prototype.myApply function (context, args) { context context null ? globalThis : Object(context); const fnKey Symbol(臨時方法); context[fnKey] this; const result context[fnKey](...args); delete context[fnKey]; return result; };這段代碼已經(jīng)能 work 于大部分常規(guī)情況指定 this、傳數(shù)組參數(shù)、返回原函數(shù)結(jié)果、清理臨時屬性。但注意它還遠不夠健壯。至少存在三個明顯問題如果args不是數(shù)組比如傳了undefined或者類數(shù)組對象...args行為就會偏離原生 apply。如果原函數(shù)執(zhí)行過程中拋出異常delete context[fnKey]永遠不會執(zhí)行臨時屬性殘留。如果調(diào)用 myApply 的this根本不是函數(shù)代碼不會給出明確報錯。這些問題不是考試刁難而是真實環(huán)境里遲早會撞上的邊界。所以下面給出完整版。3.2 健壯版逐行說明“為什么這么寫”Function.prototype.myApply function (context, argsArray) { // 1. 保證調(diào)用者是一個函數(shù) if (typeof this ! function) { throw new TypeError(this is not callable); } // 2. 處理 thisArgnull/undefined 指向全局對象原始類型裝箱 const ctx context null ? globalThis : Object(context); // 3. 生成唯一 key避免覆蓋原對象已有屬性 const fnKey Symbol(臨時方法); // 4. 把當前函數(shù)掛到目標對象上 ctx[fnKey] this; // 5. 參數(shù)數(shù)組標準化null/undefined 視為無參類數(shù)組轉(zhuǎn)為真數(shù)組 let argsList; if (argsArray null) { argsList []; } else if (typeof argsArray object || typeof argsArray function) { argsList Array.from(argsArray); } else { throw new TypeError(CreateListFromArrayLike called on non-object); } // 6. 調(diào)用原函數(shù)無論是否拋錯都要清理臨時屬性 try { return ctx[fnKey](...argsList); } finally { delete ctx[fnKey]; } };逐段拆解第一步的類型檢查。Function.prototype.myApply這個方法本身可能被任何值調(diào)用比如你把一個普通對象當成函數(shù)來用const fake {}; fake.myApply(...)此時方法內(nèi)部的this是{}不是函數(shù)。原生 apply 面對這種情況會拋TypeError我們手寫版也應(yīng)該給出一致的行為。這也解釋了為什么很多手寫題會包含typeof this ! function判斷——它不是可有可無的防御代碼而是為了向原生行為對齊。第二步的 context 處理。這里用了context null這種寫法注意是寬松等于它一次性覆蓋了context null和context undefined兩種情況。為什么它們要指向全局對象因為原生 apply 的非嚴格模式語義就是如此顯式傳入 null 或 undefined 時this 默認綁定全局對象。globalThis是 ES2020 之后跨環(huán)境的通用全局對象在瀏覽器里是window在 Node 里是global。Object(context)這一步解決的是原始類型問題。如果你myApply(hello, ...)Object(hello)會返回一個 String 包裝對象這樣屬性才能掛上去。這一步不是過度設(shè)計如果你不加字符串、數(shù)字、布爾值這些原始類型會讓你踩進一個隱蔽的坑第 4 章會專門演示。第三步的 Symbol key。為什么不用__fn__這種字符串兩個原因。第一普通字符串可能和對象已有的屬性撞車比如對象本身就有__fn__屬性那你就把人家覆蓋了。第二Symbol 每次創(chuàng)建都是全新的、唯一的不可能和已有屬性撞名字還能保證delete時刪的一定是自己剛掛上去的那個屬性。順帶補充一個歷史背景ES5 時代沒有 Symbol那時候手寫 call/apply 的 polyfill 通常用隨機字符串模擬const key __tempFn_ Date.now() _ Math.random();這種做法只能“大概率不沖突”并不能絕對保證。Symbol 出來后這個隱患才算徹底解決。所以現(xiàn)在做手寫題用 Symbol 是比隨機字符串更正確的方案。第五步的參數(shù)處理。原生 apply 的第二個參數(shù)規(guī)范層面的定義是“數(shù)組或類數(shù)組對象”也就是要有l(wèi)ength屬性和數(shù)字下標。arguments對象、DOM 節(jié)點列表、{ 0: a, 1: b, length: 2 }這類結(jié)構(gòu)原生 apply 都能處理。Array.from恰好能把類數(shù)組轉(zhuǎn)成真數(shù)組這樣后面展開時就能直接使用...argsList。這里要特別提一下Array.from對字符串也有效但原生 apply 接收原始字符串會拋TypeError。所以完整版里先用typeof argsArray object || typeof argsArray function做了前置校驗把字符串、數(shù)字這類原始值擋在門外行為才能和原生對齊。第六步的 try/finally。這是很多手寫教程不講、但實際價值很高的細節(jié)。如果原函數(shù)執(zhí)行到一半拋錯沒有 finally 的情況下delete ctx[fnKey]會跳過臨時屬性會永遠留在那個對象上。用try/finally保證無論函數(shù)是否正常返回清理動作都會執(zhí)行。你可以在控制臺這樣驗證殘留問題const obj {}; function boom() { throw new Error(xx); } // 沒有 finally 的實現(xiàn) try { boom.myApplyNoFinally(obj, []); } catch (e) {} console.log(Object.getOwnPropertySymbols(obj)); // 能看到殘留的 Symbol 屬性有 finally 的版本再測結(jié)果會是空數(shù)組。3.3 為什么不直接寫context[fnKey](...args)而要多包一層有人可能會問既然 args 本來就是數(shù)組Array.from不是多余嗎這里必須考慮到 apply 的第二個參數(shù)不一定是數(shù)組。真實的調(diào)用可能是add.myApply(obj, arguments); // 函數(shù)內(nèi)部拿到的 arguments 類數(shù)組 add.myApply(obj, { 0: 1, 1: 2, length: 2 }); add.myApply(obj, undefined); // 不傳第二個參數(shù)如果只寫...args參數(shù)為 undefined 時直接展開是允許的結(jié)果相當于無參調(diào)用但遇到?jīng)]有l(wèi)ength的對象時展開行為就不受控了。為了對原生行為負責(zé)參數(shù)標準化的步驟不該省。4. 實測中的三個坑手寫版與原生 apply 差在哪4.1 坑 1context 是凍結(jié)對象賦值會失敗這是手寫方案和原生方案最本質(zhì)的差別說句大實話手寫版在某些極端情況下不可能 100% 等同原生。原生 apply 是引擎內(nèi)部操作不需要真的往對象上掛屬性我們的模擬方案必須寫屬性那就繞不開對象是否可擴展、是否凍結(jié)的問題。const frozenCtx Object.freeze({ x: 1 }); function getX() { return this.x; } getX.apply(frozenCtx); // 原生1正常工作 getX.myApply(frozenCtx, []); // 手寫嚴格模式下拋 TypeError原因很簡單ctx[fnKey] this試圖給凍結(jié)對象新增屬性嚴格模式下直接報錯。想完全模擬原生行為就得徹底換思路比如用WeakMap做映射、把函數(shù)和 context 關(guān)聯(lián)起來不往對象上掛任何屬性。這種實現(xiàn)更接近原生的“旁路”思路但底層原理就不再是“臨時借用方法調(diào)用”了。作為面試和原理理解知道這個邊界就足夠了——真實業(yè)務(wù)里你不會給凍結(jié)對象調(diào) apply。4.2 坑 2原始類型 context 不裝箱屬性根本掛不住這個坑比上一個隱蔽得多??催@段常見錯誤寫法Function.prototype.badApply function (context, args) { const key Symbol(fn); context[key] this; // 假設(shè) context 是 hello return context[key](...args); };當你執(zhí)行someFn.badApply(hello)時hello是一個原始字符串。第一行的context[key] this確實會執(zhí)行但這里有個微妙的機制點運算符訪問原始值時會臨時創(chuàng)建一個包裝對象屬性實際上賦值到了這個臨時對象上。到了第二行再訪問context[key]時字符串又臨時創(chuàng)建了一個新的包裝對象這個新對象上沒有你剛掛的屬性于是調(diào)用直接失敗。這在非嚴格模式下不會報錯但結(jié)果永遠是undefined is not a function而且特別難排查。解決辦法就是前面說的Object(context)先把原始值裝箱成同一個包裝對象然后在同一個對象上完成賦值、調(diào)用、刪除。const ctx context null ? globalThis : Object(context);Object(hello)返回的是字符串包裝對象后續(xù)所有屬性操作都在這個對象上問題自然解除。4.3 坑 3第二個參數(shù)不是數(shù)組行為就跑偏原生 apply 對第二個參數(shù)有一套完整的類型校驗null/undefined 接受數(shù)組和類數(shù)組對象接受其他類型拋TypeError。手寫版很容易把校驗漏掉。比如你傳一個數(shù)字5給 apply 的第二個參數(shù)原生直接報錯手寫版如果簡單寫成context[fnKey](...args)...5會靜默展開成空參數(shù)列表沒有任何異常提示。這類行為差異在單元測試里很容易漏掉所以完整版里前置的typeof argsArray檢查是必要的。還有一個容易忽略的類數(shù)組邊界Array.from會把字符串轉(zhuǎn)成字符數(shù)組但原生 apply 對原始字符串是拒絕的。完整版用typeof argsArray object || typeof argsArray function把字符串擋在外面才算和原生對齊。4.4 附帶提醒不要直接改寫 Function.prototype.apply練習(xí)歸練習(xí)生產(chǎn)環(huán)境里不要去覆蓋原生Function.prototype.apply。很多框架內(nèi)部的工具函數(shù)、第三方庫的兼容層都依賴原生行為你覆蓋之后輕則行為不一致重則導(dǎo)致運行時報錯。想驗證自己的實現(xiàn)掛一個獨立名字的myApply最安全。如果你真要寫 polyfill也要嚴格對齊規(guī)范并且把它放在業(yè)務(wù)代碼加載之前同時保留對原生方法的引用以便降級。不過說實話以現(xiàn)在瀏覽器的兼容性apply polyfill 的應(yīng)用場景已經(jīng)非常小了它更多是一個學(xué)習(xí)道具。5. 一舉三得從 myApply 瞬間推導(dǎo) call 和 bind5.1 call真的只需要一行前面已經(jīng)提過myCall 可以基于 myApply 直接實現(xiàn)Function.prototype.myCall function (context, ...rest) { return this.myApply(context, rest); };這里能成立的原因不復(fù)雜myApply 的第二個參數(shù)是數(shù)組而 rest 收集到的正好是真數(shù)組。你甚至可以認為 call 就是 apply 的“語法糖”——只是把“整理數(shù)組”這一步交給了調(diào)用者。實測一下const obj { base: 10 }; function add(a, b) { return this.base a b; } console.log(add.myCall(obj, 1, 2)); // 13 console.log(add.myApply(obj, [1, 2])); // 13兩個結(jié)果都是 13。這就很有意思你已經(jīng)在這篇文章里一口氣手寫了兩道高頻面試題。5.2 bind理解原理不追求當場手寫完整版bind 和 call/apply 的最大區(qū)別是不立即執(zhí)行它返回一個新函數(shù)等調(diào)用時再把參數(shù)合并好傳進去。最小實現(xiàn)長這樣Function.prototype.myBind function (context, ...presetArgs) { const fn this; return function (...laterArgs) { return fn.apply(context, presetArgs.concat(laterArgs)); }; };可以看到bind 內(nèi)部調(diào)用了 apply 完成最終調(diào)用。所以如果你把 apply 吃透了bind 的思路也就不難理解。但要注意完整版 bind 還涉及“返回的函數(shù)可以被 new 調(diào)用此時 this 綁定失效”以及原型鏈處理復(fù)雜度明顯更高。面試一般不會要求現(xiàn)場完整手寫 bind能把最小實現(xiàn)講明白、說出和 apply 的關(guān)系已經(jīng)足夠說明你掌握了這個家族的核心機制。5.3 自己驗證 myApply一套可以直接跑的測試用例寫完之后怎么確認自己沒寫錯我建議至少跑一遍下面這些用例// 1. 基本功能this 綁定 數(shù)組傳參 const obj { base: 10 }; function add(a, b) { return this.base a b; } console.log(add.myApply(obj, [1, 2])); // 13 // 2. 不傳第二個參數(shù)不報錯等價于無參調(diào)用 function getContext() { return this; } console.log(getContext.myApply(obj) obj); // true // 3. null / undefined context非嚴格模式下 this 指向全局對象 function checkThis() { return typeof this; } console.log(checkThis.myApply(null)); // object console.log(checkThis.myApply(undefined)); // object // 4. 類數(shù)組對象作為第二個參數(shù) console.log(add.myApply(obj, { 0: 1, 1: 2, length: 2 })); // 13 // 5. 原函數(shù)拋錯時臨時屬性仍然清干凈 function boom() { throw new Error(boom); } try { boom.myApply(obj, []); } catch (e) {} console.log(Object.getOwnPropertySymbols(obj)); // [] // 6. 非函數(shù)調(diào)用 myApply 時拋 TypeError const notFunc {}; try { notFunc.myApply(obj, []); } catch (e) { console.log(e instanceof TypeError); // true }這六組用例基本覆蓋了核心行為、邊界條件和異常清理。你在本地環(huán)境跑一遍全綠就說明這個手寫版立住了。最后分享一個我反復(fù)跟人講過的體會手寫 apply 這件事最大的收獲不是“記住了一段模板代碼”而是你因此把 this 綁定的直覺練出來了。我現(xiàn)在看代碼看到fn()就知道 this 大概率是全局或 undefined看到obj.fn()就知道 this 必然是 obj這些都是當年反復(fù)手寫 call/apply 之后才自然形成的條件反射。建議你在跑完上面測試之后順手用同樣的思路把手寫 call、手寫 bind 也各實現(xiàn)一遍原理類知識早晚會從“背過答案”變成“長在身上”。