10個最常見的JavaScript問題

千鋒IT教育發表於2022-12-02

如今,JavaScript幾乎是所有現代web應用程式的核心。這就是為什麼JavaScript問題以及找出導致這些問題的錯誤是web開發人員的首要任務。

用於單頁應用程式(SPA)開發、圖形和動畫以及伺服器端JavaScript平臺的強大的基於JavaScript的庫和框架並不是什麼新鮮事。JavaScript在web應用程式開發的世界中確實變得無處不在,因此它是一項越來越重要的技能。

起初,JavaScript可能看起來很簡單。事實上,將基本的JavaScript功能構建到網頁中對於任何有經驗的軟體開發人員來說都是一項相當簡單的任務,即使他們是JavaScript新手。

然而,這種語言比人們最初認為的要微妙、有力和複雜得多。事實上,JavaScript的許多微妙之處導致了許多常見問題,這些問題阻礙了它的工作——我們在這裡討論了其中的10個問題,在成為一名優秀的JavaScript開發人員的過程中,需要注意和避免這些問題。

問題1:不正確的引用 this

隨著JavaScript編碼技術和設計模式多年來變得越來越複雜,回撥和閉包中的自引用作用域也相應增加,這是造成JavaScript問題的 "this/that 混亂 "的一個相當普遍的來源。

考慮下面程式碼:

Game.prototype.restart = function () {
    this.clearLocalStorage();
    this.timer = setTimeout(function() {
    this.clearBoard();    // What is "this"?
    }, 0);
};

執行上述程式碼會出現以下錯誤:

Uncaught TypeError: undefined is not a function

上述錯誤的原因是,當呼叫 setTimeout()時,實際上是在呼叫 window.setTimeout()。因此,傳遞給setTimeout()的匿名函式是在window物件的上下文中定義的,它沒有clearBoard()方法。

傳統的、符合老式瀏覽器的解決方案是將 this 引用儲存在一個變數中,然後可以被閉包繼承,如下所示:

Game.prototype.restart = function () {
    this.clearLocalStorage();
    var self = this;   // Save reference to 'this', while it's still this!
    this.timer = setTimeout(function(){
    self.clearBoard();    // Oh OK, I do know who 'self' is!
    }, 0);
};

另外,在較新的瀏覽器中,可以使用bind()方法來傳入適當的引用:

Game.prototype.restart = function () {
    this.clearLocalStorage();
    this.timer = setTimeout(this.reset.bind(this), 0);  // Bind to 'this'
};
Game.prototype.reset = function(){
    this.clearBoard();    // Ahhh, back in the context of the right 'this'!
};

問題2:認為存在塊級作用域

JavaScript開發者中常見的混亂來源(也是常見的錯誤來源)是假設JavaScript為每個程式碼塊建立一個新的作用域。儘管這在許多其他語言中是對的,但在JavaScript中卻不是。考慮一下下面的程式碼:

for (var i = 0; i < 10; i++) {
    /* ... */
}
console.log(i);  // 輸出什麼?

如果你猜測console.log()的呼叫會輸出 undefined 或者丟擲一個錯誤,那你就猜錯了。答案是輸出10。為什麼呢?

在大多數其他語言中,上面的程式碼會導致一個錯誤,因為變數i的 "生命"(即使作用域)會被限制在for塊中。

但在JavaScript中,情況並非如此,即使在for迴圈完成後,變數i仍然在作用域內,在退出迴圈後仍保留其最後的值。(順便說一下,這種行為被稱為變數提升(variable hoisting)。

JavaScript中對塊級作用域的支援是透過let關鍵字實現的。Let關鍵字已經被瀏覽器和Node.js等後端JavaScript引擎廣泛支援了多年。

問題3:建立記憶體洩漏

如果沒有有意識地編寫程式碼來避免記憶體洩漏,那麼記憶體洩漏幾乎是不可避免的JavaScript問題。它們的發生方式有很多種,所以我們只重點介紹幾種比較常見的情況。

記憶體洩漏例項1:對不存在的物件的懸空引用

考慮以下程式碼:

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"。

如果你執行上述程式碼並監測記憶體使用情況,你會發現你有一個明顯的記憶體洩漏,每秒洩漏整整一兆位元組!而即使是手動垃圾收集器(GC)也無濟於事。

因此,看起來我們每次呼叫 replaceThing 都會洩漏 longStr。但是為什麼呢?

每個theThing物件包含它自己的1MB longStr物件。每一秒鐘,當我們呼叫 replaceThing 時,它都會在 priorThing 中保持對先前 theThing 物件的引用。

但是我們仍然認為這不會是一個問題,因為每次透過,先前引用的priorThing將被取消引用(當priorThing透過priorThing = theThing;被重置時)。

而且,只在 replaceThing 的主體和unused的函式中被引用,而事實上,從未被使用。

因此,我們又一次想知道為什麼這裡會有記憶體洩漏。

為了理解發生了什麼,我們需要更好地理解JavaScript的內部工作。實現閉包的典型方式是,每個函式物件都有一個連結到代表其詞法作用域的字典式物件。

如果在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被收集,因此會出現記憶體洩漏。

避免記憶體洩漏:要點

JavaScript的記憶體管理(尤其是垃圾回收)主要是基於物件可達性的概念。

以下物件被認為是可達的,被稱為 "根":

  • 從當前呼叫堆疊的任何地方引用的物件(即當前被呼叫的函式中的所有區域性變數和引數,以及閉包作用域內的所有變數)
  • 所有全域性變數

只要物件可以透過引用或引用鏈從任何一個根部訪問,它們就會被保留在記憶體中。

瀏覽器中有一個垃圾收集器,它可以清理被無法到達的物件所佔用的記憶體;換句話說,當且僅當GC認為物件無法到達時,才會將其從記憶體中刪除。不幸的是,很容易出現不再使用的 "殭屍 "物件,但GC仍然認為它們是 "可達的"。

問題4:雙等號的困惑

JavaScript 的一個便利之處在於,它會自動將布林上下文中引用的任何值強制為布林值。

但在有些情況下,這可能會讓人困惑,因為它很方便。例如,下面的一些情況對許多JavaScript開發者來說是很麻煩的。

// 下面結果都是 'true'
console.log(false == '0');
console.log(null == undefined);
console.log(" \t\r\n" == 0);
console.log('' == 0);
// 下面也都成立
if ({}) // ...
if ([]) // ...

關於最後兩個,儘管是空的(大家可能會覺得他們是 false),{}和[]實際上都是物件,任何物件在JavaScript中都會被強制為布林值 "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操作

使用 JavaScript 操作DOM(即新增、修改和刪除元素)是相對容易,但操作效率卻不怎麼樣。

比如,每次新增一系列DOM元素。新增一個DOM元素是一個昂貴的操作。連續新增多個DOM元素的程式碼是低效的。

當需要新增多個DOM元素時,一個有效的替代方法是使用 document fragments來代替,從而提高效率和效能。

var div = document.getElementsByTagName("my_div");
    
var fragment = document.createDocumentFragment();
for (var e = 0; e < elems.length; e++) {  // elems previously set to list of elements
    fragment.appendChild(elems[e]);
}
div.appendChild(fragment.cloneNode(true));

除了這種方法固有的效率提高外,建立附加的DOM元素是很昂貴的,而在分離的情況下建立和修改它們,然後再將它們附加上,就會產生更好的效能。

問題6:在迴圈內錯誤使用函式定義

考慮下面程式碼:

var elements = document.getElementsByTagName('input');
var n = elements.length;    // Assume we have 10 elements for this example
for (var i = 0; i < n; i++) {
    elements[i].onclick = function() {
        console.log("This is element #" + i);
    };
}

根據上面的程式碼,如果有10個 input 元素,點選任何一個都會顯示 "This is element #10"。

這是因為,當任何一個元素的onclick被呼叫時,上面的for迴圈已經結束,i的值已經是10了(對於所有的元素)。

我們可以像下面這樣來解決這個問題:

var elements = document.getElementsByTagName('input');
var n = elements.length;   
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 是一個外部函式,並返回一個內部函式,這樣就會形成一個閉包,num 就會呼叫時傳進來的的當時值,這樣在點選元素時,就能顯示正確的序號。

問題7:未能正確利用原型繼承

考慮下面程式碼:

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'

當使用 delete 刪除該屬性時,就會返回一個 undefined,那麼如果我們也想返回 default 要怎麼做呢?利用原型繼承,如下所示:

BaseObject = function (name) {
    if(typeof name !== "undefined") {
        this.name = name;
    }
};
BaseObject.prototype.name = 'default';

BaseObject 從它的原型物件中繼承了name 屬性,值為 default。因此,如果建構函式在沒有 name 的情況下被呼叫,name 將預設為 default。同樣,如果 name 屬性從BaseObject的一個例項中被移除,那麼會找到原型鏈的 name,,其值仍然是default。所以'

var thirdObj = new BaseObject('unique');
console.log(thirdObj.name);  // -> Results in 'unique'
delete thirdObj.name;
console.log(thirdObj.name);  // -> Results in 'default'

問題8:為例項方法建立錯誤的引用

考慮下面程式碼:

var MyObject = function() {}
    
MyObject.prototype.whoAmI = function() {
    console.log(this === window ? "window" : "MyObj");
};
var obj = new MyObject();

現在,為了操作方便,我們建立一個對whoAmI方法的引用,這樣透過whoAmI()而不是更長的obj.whoAmI()來呼叫。

var whoAmI = obj.whoAmI;

為了確保沒有問題,我們把 whoAmI 列印出來看一下:

console.log(whoAmI);

輸出:

function () {
    console.log(this === window ? "window" : "MyObj");
}

Ok,看起來沒啥問題。

接著,看看當我們呼叫obj.whoAmI() 和 whoAmI() 的區別。

obj.whoAmI();  // Outputs "MyObj" (as expected)
whoAmI();      // Outputs "window" (uh-oh!)

什麼地方出錯了?當我們進行賦值時 var whoAmI = obj.whoAmI,新的變數whoAmI被定義在全域性名稱空間。

結果,this的值是 window,而不是 MyObject 的 obj 例項!

因此,如果我們真的需要為一個物件的現有方法建立一個引用,我們需要確保在該物件的名字空間內進行,以保留 this值。一種方法是這樣做:

var MyObject = function() {}
    
MyObject.prototype.whoAmI = function() {
    console.log(this === window ? "window" : "MyObj");
};
var obj = new MyObject();
obj.w = obj.whoAmI;   // Still in the obj namespace
obj.whoAmI();  // Outputs "MyObj" (as expected)
obj.w();       // Outputs "MyObj" (as expected)

問題9:為 setTimeout 或 setInterval 提供一個字串作為第一個引數

首先,需要知道的是為 setTimeout 或 setInterval 提供一個字串作為第一個引數,這本身並不是一個錯誤。它是完全合法的JavaScript程式碼。這裡的問題更多的是效能和效率的問題。

很少有人解釋的是,如果你把字串作為setTimeout或setInterval的第一個引數,它將被傳遞給函式構造器,被轉換成一個新函式。這個過程可能很慢,效率也很低,而且很少有必要。

將一個字串作為這些方法的第一個引數的替代方法是傳入一個函式。

setInterval("logTime()", 1000);
setTimeout("logMessage('" + msgValue + "')", 1000);

更好的選擇是傳入一個函式作為初始引數:

setInterval(logTime, 1000); 
    
setTimeout(function() {      
    logMessage(msgValue);     
}, 1000);

問題10:未使用 "嚴格模式"

"嚴格模式"(即在JavaScript原始檔的開頭包括 "use strict";)是一種自願在執行時對JavaScript程式碼執行更嚴格的解析和錯誤處理的方式,同時也使它更安全。

但是,不使用嚴格模式本身並不是一個 "錯誤",但它的使用越來越受到鼓勵,不使用也越來越被認為是不好的形式。

以下是嚴格模式的一些主要好處:

  • 使得除錯更容易。原本會被忽略或無感知的程式碼錯誤,現在會產生錯誤或丟擲異常,提醒我們更快地發現程式碼庫中的JavaScript問題,並引導更快地找到其來源。
  • 防止意外的全域性變數。在沒有嚴格模式的情況下,給一個未宣告的變數賦值會自動建立一個具有該名稱的全域性變數。這是最常見的JavaScript錯誤之一。在嚴格模式下,試圖這樣做會產生一個錯誤。
  • 消除this 強迫性。在沒有嚴格模式的情況下,對 null 或 undefined 的 this 值的引用會自動被強制到全域性。在嚴格模式下,引用null或undefined的this值會產生錯誤。
  • 不允許重複的屬性名或引數值。嚴格模式在檢測到一個物件中的重複命名的屬性(例如,var object = {foo: "bar", foo: "baz"};)或一個函式的重複命名的引數(例如,function foo(val1, val2, val1){})時丟擲一個錯誤,從而捕捉到你的程式碼中幾乎肯定是一個錯誤,否則你可能會浪費很多時間去追蹤。
  • 使得eval()更加安全。eval()在嚴格模式和非嚴格模式下的行為方式有一些不同。最重要的是,在嚴格模式下,在eval()語句中宣告的變數和函式不會在包含的範圍內建立。(在非嚴格模式下,它們是在包含域中建立的,這也可能是JavaScript問題的一個常見來源)。
  • 在無效使用delete的情況下丟擲錯誤。delete 運算子(用於從物件中刪除屬性)不能用於物件的非可配置屬性。當試圖刪除一個不可配置的屬性時,非嚴格的程式碼將無聲地失敗,而嚴格模式在這種情況下將丟擲一個錯誤。

寫在最後

以上就是我今天跟你分享的10個JavaScript中最常見的問題,不知道這10個問題中有沒有你不知道?如果有的話,請認真學習,如沒有的話,請當作複習。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70023145/viewspace-2926533/,如需轉載,請註明出處,否則將追究法律責任。

相關文章