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