您不知道的 JS with 語句,讓我來告訴您!
大家好,這里是大家的林語冰。
JS 的 with() 語句已被腰斬,且強烈建議直接禁用。雖然但是,這合理嗎喵?
with 語句實際已被棄用,且在嚴格模式下無法奏效,但即使綜合考慮其“18 禁”的原因,它仍然令人雞凍。讓我們花一些時間回首往昔,with() 的超能力是什么、其飽受爭議又是為何,以及本人對這些差評的反對意見。
訴諸 with() 讀寫屬性
當(dāng)我們讀寫 JS 對象的屬性時,我們幾乎總是需要訴諸標識符來限定這些屬性,這樣引擎才知道哪里可以找到對應(yīng)的值。唯一的例外是全局變量。如果該名稱對應(yīng)的變量在作用域鏈中不存在,那么會將其作為 window 或 globalThis 的屬性進行檢查。如下所示:
const name = '林語冰'
const cat = {
name: '薛定諤',
isSingle: true
}
// 搜索 cat 對象:
console.log(cat.name) // '薛定諤'
// 搜索作用域鏈,然后繼續(xù)上溯搜索 window/globalThis:
console.log(name) // '林語冰'
在沒有限定標識符的情況下,with 語句也能讀寫對象的屬性。我們只需將它們作為獨立的變量引用即可。
const cat = {
name: '薛定諤',
isSingle: true
}
with (cat) {
console.log(name) // '薛定諤'
console.log(isSingle) // `true`
}
這能奏效,因為 with() 把 cat 插入作用域鏈的開頭,這意味著,在繼續(xù)上溯搜索之前,這首先會搜索目標對象的值。順便一提,您仍然可以從更寬泛的作用域讀寫變量 —— 您只需要依賴該顯式標識符:
window.name = '林語冰'
with (cat) {
console.log(window.name) // "林語冰"
}
在某些方面,它提供了類似于解構(gòu)賦值的人體工程學(xué)福利。這不需要重復(fù)標識符,語法脂肪會稍減。但另一個福利是,with() 內(nèi)執(zhí)行的代碼包含在不同的區(qū)塊作用域中。
為什么要棄用 with?
如果我們偷瞄 TC39 文檔,我們會發(fā)現(xiàn),with() 語句被標記為“歷史包袱”,且不鼓勵使用,但它并沒有深入說明原因。雖然但是,如果我們偷瞄其他資料,就會發(fā)現(xiàn)若干主要原因。(順便一提,我很可能遺漏了其他某些關(guān)于 with 的關(guān)鍵差評。如果您了解這些差評,請不吝賜教。)
1. 可讀性感人
如果沒有顯式標識符,那么可能會寫下某些難以閱讀的“代媽屎山”。請瞄一下這個函數(shù):
function doSomething(name, obj) {
with (obj) {
console.log(name)
}
console.log(name)
}
doSomething('林語冰', { name: '薛定諤' })
// '薛定諤'
// '林語冰'
乍一看,并不清楚 name 是什么鬼物 —— 是 obj 上的屬性,還是傳遞給函數(shù)的參數(shù)。并且相同的變量名在整個函數(shù)體中引用了截然不同的值。這很令人懵逼,并且可能會讓您搬起石頭砸到自己的貓。畢竟,根據(jù)該變量的使用位置,解析其作用域的執(zhí)行方式一龍一豬。
這是一個有意義的差評,但在我看來,這并非致命的批評。寫下這樣的代碼是開發(fā)者(糟糕的)選擇,并且很大程度上似乎可以通過教育來解決。
2. 作用域泄漏/意外屬性讀寫
除此之外,由于其設(shè)計,我們可能會因為讀寫不打算處理的不同作用域內(nèi)的屬性,而無意中遭遇 bug。假設(shè)我們有一個處理包含了戀愛史的 cat 對象的函數(shù)。
const cat = {
history: ['girl1', 'lady2']
}
function processHistory(cat) {
with (cat) {
// 使用 history 屬性搞事情
}
}
processHistory(cat)
直到您傳遞一個沒有 history 屬性的 cat 變量之前,這都能奏效。否則,history 將回退到 window.history(或作用域鏈上存在的其他某些 history 變量),導(dǎo)致意外 bug。
在這個簡單的示例中,如果 history 是必需屬性,那就問題不大(如果對象是通過對象字面量創(chuàng)建的,那么 TS 可以提供輔助),但我們可以看到在更復(fù)雜的場景中出現(xiàn)其他意外。我們正在修改作用域鏈。在某些時候,奇奇怪怪的事情肯定會發(fā)生。
3. 性能挑戰(zhàn)
當(dāng)與性能相關(guān)時,事情會變得更有趣。在我看來,這是我見過的最義憤填膺的差評。
當(dāng)在 with 語句中讀寫屬性時,不僅會在給定對象的頂層屬性中搜索它的值,還會在其整個原型鏈中搜索。如果在原型鏈中搜索不到,它就會從一個作用域上溯另一個作用域繼續(xù)搜索。每次屬性讀寫都必然會遵循這種搜索順序。根據(jù)具體需求的不同,這可能會導(dǎo)致某些緩慢的搜索和高性能雷區(qū)。
此處簡述一下下,我們使用 with 讀寫 me 的屬性,該屬性位于原型鏈的底部:
const creature = { name: '碳基生物', planet: '地球' }
const mammal = Object.create(creature, { name: { value: '哺乳動物' } })
const human = Object.create(mammal, { name: { value: '人族' } })
const me = Object.create(human, { name: { value: '林語冰' } })
function outerFunction() {
const outer = 'outer'
function innerFunction() {
const inner = 'inner'
with (me) {
console.log(name, planet, inner, outer)
// '林語冰' '地球' 'inner' 'outer'
}
}
innerFunction()
}
outerFunction()
由于 name 直接存儲在 me 對象上,因此搜索它沒有太多開銷。粉絲請記住 —— 目標對象的頂層屬性優(yōu)先被搜索。但 planet 不并非如此,它不在 me 對象上,因此原型鏈中的每個對象都會搜索一個值,直到找到為止。
普遍推薦的備胎方案
為了獲得相同的“干凈”變量規(guī)避此風(fēng)險,通常建議使用解構(gòu)賦值。我們重構(gòu)之前的繼承示例:
const creature = { name: '碳基生物', planet: '地球' }
const mammal = Object.create(creature, { name: { value: '哺乳動物' } })
const human = Object.create(mammal, { name: { value: '人族' } })
const me = Object.create(human, { name: { value: '林語冰' } })
const { name, planet } = me
console.log(name, planet)
// `林語冰` `地球`
我理解這為什么這會成為建議的替代方案:
- 關(guān)于變量的來源相對清晰。
- 編譯器可以對讀寫屬性的位置做出更好的假設(shè)(和優(yōu)化),這有利于性能(由于仍需要搜索原型鏈,因此成本同樣存在)。
- 您仍然可以使用沒有標識符的變量。
但從可讀性的角度來看,我并不完全相信它是一個有價值的選擇。
為何 with() 有時優(yōu)于解構(gòu)賦值
使用 with() 的吸引力不僅僅在于“干凈”的變量。它就在控制結(jié)構(gòu)中。由于其周圍的語法,很容易在 with() 語句中有意識地“存儲”特定任務(wù)。該代碼在詞法作用域和動機上都與其余代碼分開,更易于推理。
請想象一下,您正在處理一個 HTTP 請求,該請求在請求中傳遞某些信息,并且您以某種方式在 data 變量中讀寫它。您的目標是使用特定屬性將記錄保存到數(shù)據(jù)庫中。以下是使用解構(gòu)屬性的方案:
const { imageUrl, width, height } = data
await saveToDb({
imageUrl,
width,
height
})
這很好,但是需要多一行代碼來處理這些變量。另外,它們現(xiàn)在都是區(qū)塊作用域,并且可能與方法中涉及的任何其他事情發(fā)生沖突。此時可能會有若干意見:
“只需將代碼移動到它自己的方法中即可?!蔽也挥憛挻艘庖姟?OOP(面向?qū)ο缶幊蹋┰O(shè)計的角度來看,這甚至可能是一件好事 —— 父方法會更精簡和集中。但此建議感覺也像是針對使用解構(gòu)賦值而引入的問題的解決方案,并且可以使用 with() 的語義來緩解。根據(jù)發(fā)生的其他情況,我可能不想創(chuàng)建一個不同的方法,但仍然希望有一個不同的作用域。
“將其全部包裹在一個臨時區(qū)塊中?!眂onst 是區(qū)塊作用域,這意味著,可以通過使用大括號創(chuàng)建新的塊作用域來包含它:
const imageUrl = 'different-image.jpg'
{
const { imageUrl, width, height } = data
await saveToDb({
imageUrl,
width,
height
})
}
這里有某些值得褒獎的奇技淫巧,但您無法讓我相信它更具可讀性。這不是黑客攻擊,但目測有點像黑客攻擊。
訴諸 with() 重構(gòu)
現(xiàn)在,針對同樣的需求,這一次我們訴諸 with 語句來重構(gòu):
with (data) {
await saveToDb({
imageUrl,
width,
height
})
}
- 與 with 配對的控制結(jié)構(gòu)一目了然,與 data 相關(guān)的特定事情會發(fā)生,并將某些內(nèi)容保存到數(shù)據(jù)庫中。
- 由于屬性名稱簡寫,我不需要先解構(gòu) data 的值,然后再將它們傳遞給我的方法。為我節(jié)省了一行代碼。
- 任何變量都不能污染此方法的其他部分。
我依然認為,解構(gòu)賦值是一個給力的功能(我經(jīng)常使用它),但至少在可讀性和語義方面,它并不能完全作為 with() 的替代方案。
性能考量
根據(jù) with() 設(shè)計操作的本質(zhì),它絕對不是處理對象屬性的最嚴格的最佳實踐。但我有所質(zhì)疑,鑒于代碼中實際發(fā)生的情況,以及與人體工程學(xué)和易讀性收益的權(quán)衡,這種擔(dān)憂到底有多嚴重。
請考慮此栗子。在我見過的大多數(shù) with() 案例中,大家使用的對象并不復(fù)雜。它們通常只是簡單的鍵值對。因此,與大多數(shù)此類案例相比,我制作了一個相當(dāng)大的案例。這是美國每個州的列表:
const states = {
alabama: 'AL',
alaska: 'AK',
arizona: 'AZ',
arkansas: 'AR',
california: 'CA'
//... 其余的州府
}
然后,我運行了一個快速基準測試來打印每個值。一項測試使用了 with():
with (states) {
console.log(alabama, alaska /* ...其余的屬性 */)
}
另一項測試使用解構(gòu)賦值:
const { alabama, alaska /* ...其余屬性 */ } = states
console.log(alabama, alaska /* ...其余屬性 */)
預(yù)料之內(nèi),with() 速度較慢,總體慢了約 23%:
圖片
但如果您仔細觀察這些數(shù)字,并將其置于現(xiàn)實世界中,就會發(fā)現(xiàn)此差異幾乎是“沒事找事”。畢竟,您在一秒鐘內(nèi)要處理數(shù)萬個操作。
別誤會我的意思。在對執(zhí)行性能高度敏感的環(huán)境中,這可能會產(chǎn)生十分有意義的變化。但這些場景可能很少,而且它們可能不應(yīng)該使用 JS。顯然,它們是用 PHP 編寫的。
最重要的是,值得指出的是,with() 遠不是唯一一個在使用不當(dāng)時,可能會導(dǎo)致性能適得其反的 JS 功能。僅舉一個栗子:擴展運算符寫起來確實很好,但如果在我們代碼的其余部分中沒有小心使用它,事情就會變得十分敏感。
with 可以更快嗎?
我很樂意看到一個“更好”版本的 with() 卷土從來,并進行某些調(diào)整提高性能。
我不介意看到的一個十分具體的變化是不再搜索原型鏈。新版改進的 with() 只會考慮從 Object.getOwnPropertyNames() 返回的對象的頂層屬性。此更改將減少解析變量所需的時間,尤其是當(dāng)您完全訪問 with() 上下文之外的變量時。
一個與此相關(guān)的有前途的基準測試。我制作了一個具有 100 個鍵/值對的對象,其原型鏈有 100 層深。每個層級都有相同的一組鍵/值對。這是用來制作它的零碎代碼:
function makeComplicatedObject() {
const obj = Object.fromEntries(
Array.from({ length: 100 }).map((_, index) => [
`key_${index}`,
`value_${index}`
])
)
return obj
}
// 結(jié)果:100 個鍵值對,原型鏈 100 層深度
const deeplyNestedObject = Array.from({ length: 100 }).reduce(
(prevObj, _current, index) => {
let newObject = makeComplicatedObject()
Object.setPrototypeOf(newObject, prevObj)
return newObject
},
makeComplicatedObject()
)
然后,我在該深層嵌套對象和具有相同鍵/值對但沒有巨大原型鏈的另一個對象之間運行了基準測試。每個代碼片段都會簡單地記錄另一個局部變量。雖然但是,由于它位于 with() 內(nèi)部,因此它必須首先等待這些對象被爬取。
結(jié)果應(yīng)該不足為奇。該對象的“扁平”版本的搜索速度大約快 36%。
圖片
這就說得通了。它并沒有讓 with() 遍歷那個令人討厭的原型鏈。我敢打賭,99.99% 的使用 with() 的現(xiàn)實代碼也不需要這樣做。
個人專屬限量版 with
順便一提,我在構(gòu)建自己的更“限量版”的 with() 時度過了一段有趣的時光。它使用一個簡單的 Proxy 來使 with() 認為該對象僅在它是頂級鍵之一時“具有”屬性:
function limitedWith(obj, cb) {
const keys = Object.getOwnPropertyNames(obj)
const scopedObj = new Proxy(obj, {
has(_target, key) {
return keys.includes(key)
}
})
return eval(`
with(scopedObj) {
(${cb}.bind(this))();
}
`)
}
盡管使用 JS 來解決驅(qū)動引擎的原生代碼無疑可以青出于藍勝于藍,但基準測試結(jié)果也不算太糟糕:
圖片
當(dāng)然,這只是桌面上的一項優(yōu)化。我也不討厭能夠?qū)⒛繕俗饔糜騻鬟f到 with() 的想法。默認情況下,它將在作用域鏈中一直搜索變量。但如果傳遞特定值,它將被限制在該作用域內(nèi):
with ((someObject, { scope: 'module' })) {
// 在 someObject 的外部,
// 當(dāng)且僅當(dāng)此模塊作用域會被搜索
}
您并不了解所有用例
拋開這些問題不談,當(dāng)大家不鼓勵使用某功能時,它們很可能會在有限范圍的情況下思考,并在此過程中做出若干假設(shè)。它們可能包括但不限于:
- 執(zhí)行速度茲事體大。
- 這是完成任務(wù)的低效方法。
- 該代碼將始終在主線程上運行。
- 還有更多......
順便一提,這樣的假設(shè)通常是準確的,值得牢記在心。但它們并不總是準確的。它們經(jīng)常忽視工程師被迫做出的特定權(quán)衡、它們正在構(gòu)建的工具的目的或其他因素。
最好的證據(jù)是這樣一個事實:由真正聰明、有洞察力的工程師編寫的真正優(yōu)秀、信譽良好的庫,今天在它們的代碼庫中仍然有 with()。
有的庫的大部分內(nèi)容都不會在主線程中執(zhí)行,因此它與大多數(shù)其他庫相比,在一組根本不同的性能約束上運行。這可以說是一個特例,但至少與 with() 應(yīng)該被撤銷的主張相悖。畢竟,您必須有一個非常非常好的案例,才能移除某個功能,尤其是當(dāng)它已經(jīng)成為該語言的一部分這么久的情況下。
長話短說
- 使用 with() 存在某些獨特的挑戰(zhàn)和風(fēng)險(盡管它們并不像 ES2015 之前那么糟糕)。
- 推薦的備胎方案還不夠好。
- 無論如何,這些挑戰(zhàn)和風(fēng)險往往被夸大了。
- 盡管如此,我們或許可以構(gòu)建更負責(zé)任的版本來取代它。
- 您可能沒有理由普遍阻止一項已經(jīng)存在很長時間的功能。