大家好,這里是大家的林語冰。
免責(zé)聲明
本文屬于是語冰的直男翻譯了屬于是,僅供粉絲參考,英文原味版請臨幸 The 10 Most Common Javascript Issues Developers Face。
今時(shí)今日,JS(Javascript)幾乎是所有現(xiàn)代 Web App 的核心。這就是為什么 JS 出問題,以及找到導(dǎo)致這些問題的錯(cuò)誤,是 Web 開發(fā)者的最前線。
用于 SPA(單頁應(yīng)用程序)開發(fā)、圖形和動(dòng)畫以及服務(wù)器端 JS 平臺(tái)的給力的 JS 庫和框架不足為奇。JS 在 Web App 開發(fā)領(lǐng)域早已無處不在,因此是一項(xiàng)越來越需要加點(diǎn)的技能樹。
乍一看,JS 可能很簡單。事實(shí)上,對于任何有經(jīng)驗(yàn)的軟件開發(fā)者而言,哪怕它們是 JS 初學(xué)者,將基本的 JS 功能構(gòu)建到網(wǎng)頁中也是舉手之勞。
雖然但是,這種語言比大家起初認(rèn)為的要更微妙、給力和復(fù)雜。事實(shí)上,一大坨 JS 的微妙之處可能導(dǎo)致一大坨常見問題,無法正常工作 —— 我們此處會(huì)討論其中的 10 個(gè)問題。在成為 JS 大神的過程中,了解并避免這些問題十分重要。
問題 1:this
引用失真
JS 開發(fā)者對 JS 的 this
關(guān)鍵字不乏困惑。
多年來,隨著 JS 編碼技術(shù)和設(shè)計(jì)模式越來越復(fù)雜,回調(diào)和閉包中自引用作用域的延伸也同比增加,此乃導(dǎo)致 JS “this
混淆”問題的“萬惡之源”。
請瞄一眼下述代碼片段:
const Game = function () {
this.clearLocalStorage = function () {
console.log('Clearing local storage...')
}
this.clearBoard = function () {
console.log('Clearing board...')
}
}
Game.prototype.restart = function () {
this.clearLocalStorage()
this.timer = setTimeout(function () {
this.clearBoard() // this 是什么鬼物?
}, 0)
}
const myGame = new Game()
myGame.restart()
執(zhí)行上述代碼會(huì)導(dǎo)致以下錯(cuò)誤:
未捕獲的類型錯(cuò)誤: this.clearBoard 不是函數(shù)
為什么呢?這與上下文有關(guān)。出現(xiàn)該錯(cuò)誤的原因是,當(dāng)您執(zhí)行 setTimeout()
時(shí),您實(shí)際是在執(zhí)行 window.setTimeout()
。因此,傳遞給 setTimeout()
的匿名函數(shù)定義在 window
對象的上下文中,該對象沒有 clearBoard()
方法。
一個(gè)傳統(tǒng)的、兼容舊瀏覽器的技術(shù)方案是簡單地將您的 this
引用保存在一個(gè)變量中,然后可以由閉包繼承,舉個(gè)栗子:
Game.prototype.restart = function () {
this.clearLocalStorage()
const self = this // 當(dāng) this 還是 this 的時(shí)候,保存 this 引用!
this.timer = setTimeout(function () {
self.clearBoard() // OK,我們可以知道 self 是什么了!
}, 0)
}
或者,在較新的瀏覽器中,您可以使用 bind()
方法傳入正確的引用:
Game.prototype.restart = function () {
this.clearLocalStorage()
this.timer = setTimeout(this.reset.bind(this), 0) // 綁定 this
}
Game.prototype.reset = function () {
this.clearBoard() // OK,回退到正確 this 的上下文!
}
問題 2:認(rèn)為存在塊級作用域
JS 開發(fā)者之間混淆的“萬惡之源”之一(因此也是 bug 的常見來源)是,假設(shè) JS 為每個(gè)代碼塊創(chuàng)建新的作用域。盡管這在許多其他語言中是正確的,但在 JS 中卻并非如此。舉個(gè)栗子,請瞄一眼下述代碼:
for (var i = 0; i < 10; i++) {
/* ... */
}
console.log(i) // 輸出是什么鬼物?
如果您猜到調(diào)用 console.log()
會(huì)輸出 undefined
或報(bào)錯(cuò),那么恭喜您猜錯(cuò)了。信不信由你,它會(huì)輸出 10
。為什么呢?
在大多數(shù)其他語言中,上述代碼會(huì)導(dǎo)致錯(cuò)誤,因?yàn)樽兞?i
的“生命”(即作用域)將被限制在 for
區(qū)塊中。雖然但是,在 JS 中,情況并非如此,即使在循環(huán)完成后,變量 i
仍保留在范圍內(nèi),在退出 for
循環(huán)后保留其最終值。(此行為被稱為變量提升。)
JS 對塊級作用域的支持可通過 let
關(guān)鍵字獲得。多年來,let
關(guān)鍵字一直受到瀏覽器和后端 JS 引擎(比如 Node.js)的廣泛支持。如果這對您來說是新知識(shí),那么值得花時(shí)間閱讀作用域、原型等。
問題3:創(chuàng)建內(nèi)存泄漏
如果您沒有刻意編碼來避免內(nèi)存泄漏,那么內(nèi)存泄漏幾乎不可避免。它們有一大坨觸發(fā)方式,因此我們只強(qiáng)調(diào)其中兩種更常見的情況。
示例 1:失效對象的虛空引用
注意:此示例僅適用于舊版 JS 引擎,新型 JS 引擎具有足夠機(jī)智的垃圾回收器(GC)來處理這種情況。
請瞄一眼下述代碼:
var theThing = null
var replaceThing = function () {
var priorThing = theThing // 保留之前的東東
var unused = function () {
// unused 是唯一引用 priorThing 的地方,
// 但 unused 從未執(zhí)行
if (priorThing) {
console.log('hi')
}
}
theThing = {
longStr: new Array(1000000).join('*'), // 創(chuàng)建一個(gè) 1MB 的對象
someMethod: function () {
console.log(someMessage)
}
}
}
setInterval(replaceThing, 1000) // 每秒執(zhí)行一次 replaceThing
如果您運(yùn)行上述代碼并監(jiān)視內(nèi)存使用情況,就會(huì)發(fā)現(xiàn)嚴(yán)重的內(nèi)存泄漏 —— 每秒有一整兆字節(jié)!即使是手動(dòng)垃圾收集器也無濟(jì)于事。所以看起來每次調(diào)用 replaceThing
時(shí)我們都在泄漏 longSte
。但是為什么呢?
如果您沒有刻意編碼來避免內(nèi)存泄漏,那么內(nèi)存泄漏幾乎不可避免。
讓我們更詳細(xì)地檢查一下:
每個(gè) theThing
對象都包含自己的 1MB longStr
對象。每一秒,當(dāng)我們調(diào)用 replaceThing
時(shí),它都會(huì)保留 priorThing
中之前的 theThing
對象的引用。但我們?nèi)匀徊徽J(rèn)為這是一個(gè)問題,因?yàn)槊看蜗惹耙玫?priorThing
都會(huì)被取消引用(當(dāng) priorThing
通過 priorThing = theThing;
重置時(shí))。此外,它僅在 replaceThing
的主體中和 unused
函數(shù)中被引用,這實(shí)際上從未使用過。
因此,我們再次想知道為什么這里存在內(nèi)存泄漏。
要了解發(fā)生了什么事,我們需要更好地理解 JS 的內(nèi)部工作原理。閉包通常由鏈接到表示其詞法作用域的字典風(fēng)格對象(dictionary-style)的每個(gè)函數(shù)對象實(shí)現(xiàn)。如果 replaceThing
內(nèi)部定義的兩個(gè)函數(shù)實(shí)際使用了 priorThing
,那么它們都得到相同的對象是很重要的,即使 priorThing
逐次賦值,兩個(gè)函數(shù)也共享相同的詞法環(huán)境。但是,一旦任何閉包使用了變量,它就會(huì)進(jìn)入該作用域中所有閉包共享的詞法環(huán)境中。而這個(gè)小小的細(xì)微差別就是導(dǎo)致這種粗糙的內(nèi)存泄漏的原因。
示例 2:循環(huán)引用
請瞄一眼下述代碼片段:
function addClickHandler(element) {
element.click = function onClick(e) {
alert('Clicked the ' + element.nodeName)
}
}
此處,onClick
有一個(gè)閉包,它保留了 element
的引用(通過 element.nodeName
)。通過同時(shí)將 onClick
賦值給 element.click
,就創(chuàng)建了循環(huán)引用,即 element
-> onClick
-> element
-> onClick
-> element
......
有趣的是,即使 element
從 DOM 中刪除,上述循環(huán)自引用也會(huì)阻止 element
和 onClick
被回收,從而造成內(nèi)存泄漏。
避免內(nèi)存泄漏:要點(diǎn)
JS 的內(nèi)存管理(尤其是它的垃圾回收)很大程度上基于對象可達(dá)性(reachability)的概念。
假定以下對象是可達(dá)的,稱為“根”:
只要對象可以通過引用或引用鏈從任何根訪問,那么它們至少會(huì)保留在內(nèi)存中。
瀏覽器中有一個(gè)垃圾回收器,用于清理不可達(dá)對象占用的內(nèi)存;換而言之,當(dāng)且僅當(dāng) GC 認(rèn)為對象不可達(dá)時(shí),才會(huì)從內(nèi)存中刪除對象。不幸的是,很容易得到已失效的“僵尸”對象,這些對象不再使用,但 GC 仍然認(rèn)為它們可達(dá)。
問題 4:混淆相等性
JS 的便捷性之一是,它會(huì)自動(dòng)將布爾上下文中引用的任何值強(qiáng)制轉(zhuǎn)換為布爾值。但在某些情況下,這可能既香又臭。
舉個(gè)栗子,對于一大坨 JS 開發(fā)者而言,下列表達(dá)式很頭大:
// 求值結(jié)果均為 true!
console.log(false == '0');
console.log(null == undefined);
console.log(" \t\r\n" == 0);
console.log('' == 0);
// 這些也是 true!
if ({}) // ...
if ([]) // ...
關(guān)于最后兩個(gè),盡管是空的(這可能會(huì)讓您相信它們求值為 false
),但 {}
和 []
實(shí)際上都是對象,并且 JS 中任何對象都將被強(qiáng)制轉(zhuǎn)換為 true
,這與 ECMA-262 規(guī)范一致。
正如這些例子所表明的,強(qiáng)制類型轉(zhuǎn)換的規(guī)則有時(shí)可以像泥巴一樣清晰。因此,除非明確需要強(qiáng)制類型轉(zhuǎn)換,否則通常最好使用 ===
和 !==
(而不是 ==
和 !=
)以避免強(qiáng)制類型轉(zhuǎn)換的任何意外副作用。(==
和 !=
比較兩個(gè)東東時(shí)會(huì)自動(dòng)執(zhí)行類型轉(zhuǎn)換,而 ===
和 !==
在不進(jìn)行類型轉(zhuǎn)換的情況下執(zhí)行同款比較。)
由于我們談?wù)摰氖菑?qiáng)制類型轉(zhuǎn)換和比較,因此值得一提的是,NaN
與任何事物(甚至 NaN
自己!)進(jìn)行比較始終會(huì)返回 false
。因此您不能使用相等運(yùn)算符( ==
,===
,!=
,!==
)來確定值是否為 NaN
。請改用內(nèi)置的全局 isNaN()
函數(shù):
console.log(NaN == NaN) // False
console.log(NaN === NaN) // False
console.log(isNaN(NaN)) // True
問題 5:低效的 DOM 操作
JS 使得操作 DOM 相對容易(即添加、修改和刪除元素),但對提高操作效率沒有任何作用。
一個(gè)常見的示例是一次添加一個(gè) DOM 元素的代碼。添加 DOM 元素是一項(xiàng)代價(jià)昂貴的操作,連續(xù)添加多個(gè) DOM 元素的代碼效率低下,并且可能無法正常工作。
當(dāng)需要添加多個(gè) DOM 元素時(shí),一個(gè)有效的替代方案是改用文檔片段(document fragments),這能提高效率和性能。
舉個(gè)栗子:
const div = document.getElementById('my_div')
const fragment = document.createDocumentFragment()
const elems = document.queryselectorAll('a')
for (let e = 0; e < elems.length; e++) {
fragment.appendChild(elems[e])
}
div.appendChild(fragment.cloneNode(true))
除了這種方法固有的提高效率之外,創(chuàng)建附加的 DOM 元素代價(jià)昂貴,而在分離時(shí)創(chuàng)建和修改它們,然后附加它們會(huì)產(chǎn)生更好的性能。
問題 6:在 for
循環(huán)中錯(cuò)誤使用函數(shù)定義
請瞄一眼下述代碼:
var elements = document.getElementsByTagName('input')
var n = elements.length // 我們假設(shè)本例有 10 個(gè)元素
for (var i = 0; i < n; i++) {
elements[i].onclick = function () {
console.log('This is element #' + i)
}
}
根據(jù)上述代碼,如果有 10 個(gè)輸入元素,單擊其中任何一個(gè)都會(huì)顯示“This is element #10”!這是因?yàn)?,在為任何元素調(diào)用 onclick
時(shí),上述 for
循環(huán)將完成,并且 i
的值已經(jīng)是 10(對于所有元素)。
以下是我們?nèi)绾渭m正此問題,實(shí)現(xiàn)所需的行為:
var elements = document.getElementsByTagName('input')
var n = elements.length // 我們假設(shè)本例有 10 個(gè)元素
var makeHandler = function (num) {
// 外部函數(shù)
return function () {
// 內(nèi)部函數(shù)
console.log('This is element #' + num)
}
}
for (var i = 0; i < n; i++) {
elements[i].onclick = makeHandler(i + 1)
}
在這個(gè)修訂版代碼中,每次我們通過循環(huán)時(shí),makeHandler
都會(huì)立即執(zhí)行,每次都會(huì)接收當(dāng)時(shí) i + 1
的值并將其綁定到作用域的 num
變量。外部函數(shù)返回內(nèi)部函數(shù)(也使用此作用域的 num
變量),元素的 onclick
會(huì)設(shè)置為該內(nèi)部函數(shù)。這確保每個(gè) onclick
接收和使用正確的 i
值(通過作用域的 num
變量)。
問題 7:誤用原型式繼承
令人驚訝的是,一大坨 JS 愛好者無法完全理解和充分利用原型式繼承的特性。
下面是一個(gè)簡單的示例:
BaseObject = function (name) {
if (typeof name !== 'undefined') {
this.name = name
} else {
this.name = 'default'
}
}
這似乎一目了然。如果您提供一個(gè)名稱,請使用該名稱,否則將名稱設(shè)置為“default”。舉個(gè)栗子:
var firstObj = new BaseObject()
var secondObj = new BaseObject('unique')
console.log(firstObj.name) // -> 結(jié)果是 'default'
console.log(secondObj.name) // -> 結(jié)果是 'unique'
但是,如果我們這樣做呢:
delete secondObj.name
然后我們會(huì)得到:
console.log(secondObj.name) // -> 結(jié)果是 'undefined'
騷然但是,將其恢復(fù)為“default”不是更好嗎?如果我們修改原始代碼以利用原型式繼承,這很容易實(shí)現(xiàn),如下所示:
BaseObject = function (name) {
if (typeof name !== 'undefined') {
this.name = name
}
}
BaseObject.prototype.name = 'default'
在此版本中,BaseObject
從其 prototype
對象繼承該 name
屬性,其中該屬性(默認(rèn))設(shè)置為 'default'
。因此,如果調(diào)用構(gòu)造函數(shù)時(shí)沒有名稱,那么名稱將默認(rèn)為 default
。同樣,如果從 BaseObject
的實(shí)例刪除該 name
屬性,那么會(huì)搜索原型鏈,并從 prototype
對象中檢索值仍為 'default'
的 name
屬性。所以現(xiàn)在我們得到:
var thirdObj = new BaseObject('unique')
console.log(thirdObj.name) // -> 結(jié)果是 'unique'
delete thirdObj.name
console.log(thirdObj.name) // -> 結(jié)果是 'default'
問題 8:創(chuàng)建對實(shí)例方法的錯(cuò)誤引用
讓我們定義一個(gè)簡單對象,并創(chuàng)建它的實(shí)例,如下所示:
var MyObjectFactory = function () {}
MyObjectFactory.prototype.whoAmI = function () {
console.log(this)
}
var obj = new MyObjectFactory()
現(xiàn)在,為了方便起見,讓我們創(chuàng)建一個(gè) whoAmI
方法的引用,大概這樣我們就可以通過 whoAmI()
訪問它,而不是更長的 obj.whoAmI()
:
var whoAmI = obj.whoAmI
為了確保我們存儲(chǔ)了函數(shù)的引用,讓我們打印出新 whoAmI
變量的值:
console.log(whoAmI)
輸出:
function () { console.log(this); }
目前它看起來不錯(cuò)。
但是瞄一眼我們調(diào)用 obj.whoAmI()
與便利引用 whoAmI()
時(shí)的區(qū)別:
obj.whoAmI() // 輸出 "MyObjectFactory {...}" (預(yù)期)
whoAmI() // 輸出 "window" (啊這?。?/p>
哪里出了問題?我們的 whoAmI()
調(diào)用位于全局命名空間中,因此 this
設(shè)置為 window
(或在嚴(yán)格模式下設(shè)置為 undefined
),而不是 MyObjectFactory
的 obj
實(shí)例!換而言之,該 this
值通常取決于調(diào)用上下文。
箭頭函數(shù)((params) => {}
而不是 function(params) {}
)提供了靜態(tài) this
,與常規(guī)函數(shù)基于調(diào)用上下文的 this
不同。這為我們提供了一個(gè)技術(shù)方案:
var MyFactoryWithStaticThis = function () {
this.whoAmI = () => {
// 請注意此處的箭頭符號
console.log(this)
}
}
var objWithStaticThis = new MyFactoryWithStaticThis()
var whoAmIWithStaticThis = objWithStaticThis.whoAmI
objWithStaticThis.whoAmI() // 輸出 "MyFactoryWithStaticThis" (同往常一樣)
whoAmIWithStaticThis() // 輸出 "MyFactoryWithStaticThis" (箭頭符號的福利)
您可能已經(jīng)注意到,即使我們得到了匹配的輸出,this
也是對工廠的引用,而不是對實(shí)例的引用。與其試圖進(jìn)一步解決此問題,不如考慮根本不依賴 this
(甚至不依賴 new
)的 JS 方法。
問題 9:提供一個(gè)字符串作為 setTimeout
or setInterval
的首參
首先,讓我們在這里明確一點(diǎn):提供字符串作為首個(gè)參數(shù)給 setTimeout
或者 setInterval
本身并不是一個(gè)錯(cuò)誤。這是完全合法的 JS 代碼。這里的問題更多的是性能和效率。經(jīng)常被忽視的是,如果將字符串作為首個(gè)參數(shù)傳遞給 setTimeout
或 setInterval
,它將被傳遞給函數(shù)構(gòu)造函數(shù)以轉(zhuǎn)換為新函數(shù)。這個(gè)過程可能緩慢且效率低下,而且通常非必要。
將字符串作為首個(gè)參數(shù)傳遞給這些方法的替代方法是傳入函數(shù)。讓我們舉個(gè)栗子。
因此,這里將是 setInterval
和 setTimeout
的經(jīng)典用法,將字符串作為首個(gè)參數(shù)傳遞:
setInterval('logTime()', 1000)
setTimeout("logMessage('" + msgValue + "')", 1000)
更好的選擇是傳入一個(gè)函數(shù)作為初始參數(shù),舉個(gè)栗子:
setInterval(logTime, 1000) // 將 logTime 函數(shù)傳給 setInterval
setTimeout(function () {
// 將匿名函數(shù)傳給 setTimeout
logMessage(msgValue) // (msgValue 在此作用域中仍可訪問)
}, 1000)
問題 10:禁用“嚴(yán)格模式”
“嚴(yán)格模式”(即在 JS 源文件的開頭包含 'use strict';
)是一種在運(yùn)行時(shí)自愿對 JS 代碼強(qiáng)制執(zhí)行更嚴(yán)格的解析和錯(cuò)誤處理的方法,也是一種使代碼更安全的方法。
誠然,禁用嚴(yán)格模式并不是真正的“錯(cuò)誤”,但它的使用越來越受到鼓勵(lì),省略它越來越被認(rèn)為是不好的形式。
以下是嚴(yán)格模式的若干主要福利:
更易于調(diào)試。本來會(huì)被忽略或靜默失敗的代碼錯(cuò)誤現(xiàn)在將生成錯(cuò)誤或拋出異常,更快地提醒您代碼庫中的 JS 問題,并更快地將您定位到其源代碼。
防止意外全局變量。如果沒有嚴(yán)格模式,將值賦值給給未聲明的變量會(huì)自動(dòng)創(chuàng)建同名全局變量。這是最常見的 JS 錯(cuò)誤之一。在嚴(yán)格模式下,嘗試這樣做會(huì)引發(fā)錯(cuò)誤。
消除 this 強(qiáng)制類型轉(zhuǎn)換。如果沒有嚴(yán)格模式,對 null
或 undefined
值的 this
引用會(huì)自動(dòng)強(qiáng)制轉(zhuǎn)換到 globalThis
變量。這可能會(huì)導(dǎo)致一大坨令人沮喪的 bug。在嚴(yán)格模式下,null
或 undefined
值的 this
引用會(huì)拋出錯(cuò)誤。
禁止重復(fù)的屬性名或參數(shù)值。嚴(yán)格模式在檢測到對象中的重名屬性(比如 var object = {foo: "bar", foo: "baz"};
)或函數(shù)的重名參數(shù)(比如 function foo(val1, val2, val1){}
)時(shí)會(huì)拋出錯(cuò)誤,從而捕獲代碼中幾乎必然出錯(cuò)的 bug,否則您可能會(huì)浪費(fèi)大量時(shí)間進(jìn)行跟蹤。
更安全的 eval()
。嚴(yán)格模式和非嚴(yán)格模式下 eval()
的行為存在某些差異。最重要的是,在嚴(yán)格模式下,eval()
語句中聲明的變量和函數(shù)不會(huì)在其包裹的作用域中創(chuàng)建。(它們在非嚴(yán)格模式下是在其包裹的作用域中創(chuàng)建的,這也可能是 JS 問題的常見來源。)
delete
無效使用時(shí)拋出錯(cuò)誤。delete
運(yùn)算符(用于刪除對象屬性)不能用于對象的不可配置屬性。當(dāng)嘗試刪除不可配置屬性時(shí),非嚴(yán)格代碼將靜默失敗,而在這種情況下,嚴(yán)格模式將拋出錯(cuò)誤。
使用更智能的方法緩解 JS 問題
與任何技術(shù)一樣,您越能理解 JS 奏效和失效的原因和方式,您的代碼就會(huì)越可靠,您就越能有效地利用語言的真正力量。
相反,缺乏 JS 范式和概念的正確理解是許多 JS 問題所在。徹底熟悉語言的細(xì)微差別和微妙之處是提高熟練度和生產(chǎn)力的最有效策略。
作者:人貓神話
鏈接:https://juejin.cn/post/7306040473542508556
來源:稀土掘金
著作權(quán)歸作者所有。商業(yè)轉(zhuǎn)載請聯(lián)系作者獲得授權(quán),非商業(yè)轉(zhuǎn)載請注明出處。
該文章在 2023/11/28 15:26:37 編輯過