Được biết đến như một ngôn ngữ lập trình phổ biến và mạnh mẽ, JavaScript là nền tảng chính cho việc phát triển web hiện đại. Tuy nhiên, không ít lập trình viên gặp phải những vấn đề khó khăn khi làm việc với JavaScript. Dù bạn là người mới bắt đầu hay đã có kinh nghiệm, việc nhận diện và hiểu rõ những vấn đề phổ biến này là rất quan trọng để nâng cao kỹ năng và hiệu quả công việc. 

Trong bài viết này, chúng ta sẽ cùng khám phá 10 vấn đề JavaScript phổ biến nhất mà các lập trình viên thường phải đối mặt, từ những lỗi cơ bản đến những thách thức phức tạp, và cách khắc phục chúng. 

1. Tham chiếu không đúng đến this

Một nhầm lẫn phổ biến của các lập trình viên là về từ khóa this trong JavaScript.

Khi các kỹ thuật lập trình JavaScript và các design pattern trở nên phức tạp hơn qua thời gian, sự gia tăng của các phạm vi tự tham chiếu trong các hàm callback và closure cũng tăng theo, và đây là một nguyên nhân phổ biến gây ra "sự nhầm lẫn về this" dẫn đến các vấn đề trong JavaScript.

Hãy xem xét ví dụ dưới đây:

 

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();    // What is "this"?
    }, 0);
};

const myGame = new Game();
myGame.restart();

 

Nếu bạn chạy đoạn mã trên thì sẽ hiện lỗi như sau:

 

Uncaught TypeError: this.clearBoard is not a function

 

Lý do gặp lỗi đó là vì khi sử dụng setTimeout(), thực ra bạn đang gọi window.setTimeout(). Tuy nhiên, có một vấn đề nhỏ ở đây: đoạn code mà bạn muốn thực hiện sau đó (trong trường hợp này là clearBoard()) lại được hiểu là thuộc về một đối tượng khác, đó là window. Mà window thì không có phương thức clearBoard() nên mới xảy ra lỗi.

Điều này xảy ra do cách JavaScript hiểu về this. Khi một hàm được gọi, this sẽ trỏ đến một đối tượng khác nhau tùy thuộc vào ngữ cảnh. Trong trường hợp của setTimeout(), this thường trỏ đến window. Vì vậy, khi hàm ẩn danh bên trong setTimeout() được thực thi, this trong đó sẽ là window chứ không phải là đối tượng mà bạn mong muốn.

Để giải quyết vấn đề này, chúng ta cần lưu giá trị của this hiện tại vào một biến khác, ví dụ là that. Biến that này sẽ giữ nguyên giá trị của this ban đầu và có thể được sử dụng bên trong hàm ẩn danh. Ví dụ:

 

Game.prototype.restart = function () {
    this.clearLocalStorage();
    const 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);
};

 

Hoặc là, trong các trình duyệt mới hơn, bạn có thể sử dụng phương thức bind() để truyền tham chiếu đúng.

 

Game.prototype.restart = function () {
    this.clearLocalStorage();
    this.timer = setTimeout(this.reset.bind(this), 0);  // Bind to 'this'
};

Game.prototype.reset = function(){
    this.clearBoard();    // OK, back in the context of the right 'this'!
};

 

2. Nghĩ rằng có Phạm vi Khối (Block-level Scope)

Nhiều người mới bắt đầu lập trình với JavaScript thường nghĩ rằng biến được khai báo trong một khối lệnh (như trong câu lệnh if, for, while) sẽ chỉ có phạm vi trong khối đó (block-level scope). Mặc dù điều này đúng trong nhiều ngôn ngữ khác, nhưng nó không đúng trong JavaScript. Hãy xem xét đoạn mã sau:

 

for (var i = 0; i < 10; i++) {
    /* ... */
}
console.log(i);  // What will this output?

 

Nếu bạn đoán rằng lệnh console.log() sẽ in ra undefined hoặc báo lỗi, thì bạn đã đoán sai. 

Trong hầu hết các ngôn ngữ khác, đoạn mã trên sẽ gây ra lỗi vì phạm vi của biến i sẽ bị giới hạn trong khối for. Tuy nhiên, trong JavaScript biến i vẫn tồn tại ngay cả sau khi vòng lặp for kết thúc, giữ nguyên giá trị cuối cùng của nó sau khi thoát khỏi vòng lặp. (Hành vi này được gọi là variable hoisting.)

Chúng hỗ trợ cho phạm vi khối trong JavaScript có sẵn thông qua từ khóa let. Từ khóa let đã được hỗ trợ rộng rãi bởi các trình duyệt và các công cụ JavaScript phía máy chủ như Node.js trong nhiều năm nay. Nếu đây là thông tin mới đối với bạn, bạn nên dành thời gian để đọc về phạm vi, nguyên mẫu và nhiều hơn nữa.

3. Tạo ra các lỗ hổng bộ nhớ

Rò rỉ bộ nhớ gần như là vấn đề không thể tránh khỏi trong JavaScript nếu bạn không chủ động lập trình để ngăn chặn chúng. Có nhiều cách để rò rỉ bộ nhớ xảy ra, tuy nhiên dưới đây tác giả sẽ chỉ nêu hai trường hợp phổ biến.

3.1. Tham chiếu treo (Dangling References) đến các đối tượng đã hủy

Lưu ý: Ví dụ này chỉ áp dụng cho các công cụ JavaScript cũ - các công cụ hiện đại có tính năng garbage collectors (GC) đủ thông minh để xử lý trường hợp này.

 

var theThing = null;
var replaceThing = function () {
  var priorThing = theThing;  // Hold on to the prior thing
  var unused = function () {
    // 'unused' is the only place where 'priorThing' is referenced,
    // but 'unused' never gets invoked
    if (priorThing) {
      console.log("hi");
    }
  };
  theThing = {
    longStr: new Array(1000000).join('*'),  // Create a 1MB object
    someMethod: function () {
      console.log(someMessage);
    }
  };
};
setInterval(replaceThing, 1000);    // Invoke 'replaceThing' once every second

 

Nếu chạy đoạn mã trên và theo dõi việc sử dụng bộ nhớ, bạn sẽ thấy rằng bạn đang gặp một rò rỉ bộ nhớ nghiêm trọng—một megabyte mỗi giây! Và ngay cả khi sử dụng trình garbage collector thủ công cũng không giúp được gì. Có vẻ như chúng ta đang bị rò rỉ longStr mỗi khi replaceThing được gọi. Nhưng tại sao lại như vậy?

Để hiểu điều gì đang xảy ra, chúng ta cần hiểu rõ hơn về cách hoạt động bên trong của JavaScript. Closures thường được thực hiện bằng cách mỗi đối tượng hàm liên kết với một đối tượng kiểu từ điển đại diện cho phạm vi từ vựng của nó. Nếu cả hai hàm được định nghĩa bên trong replaceThing đều sử dụng priorThing, thì cả hai đều nhận được cùng một đối tượng, ngay cả khi priorThing được gán lại liên tục để cả hai hàm chia sẻ cùng một môi trường từ vựng. Nhưng ngay khi một biến được sử dụng bởi bất kỳ closure nào, nó sẽ nằm trong môi trường từ vựng chia sẻ bởi tất cả các closure trong phạm vi đó. Và chính sự khác biệt nhỏ này dẫn đến tình trạng rò rỉ bộ nhớ nghiêm trọng này.

3.2. Tham chiếu tuần hoàn (Circular References)

 

function addClickHandler(element) {
    element.click = function onClick(e) {
        alert("Clicked the " + element.nodeName)
    }
}

 

Trong đoạn code này, onClick là một hàm có closure giữ tham chiếu đến đối tượng element (thông qua element.nodeName). Bằng cách gán onClick cho element.click, một tham chiếu tuần hoàn được tạo ra tức là, elementonClickelementonClickelement. Vì thế, ngay cả khi element bị xóa khỏi DOM, tham chiếu tuần hoàn giữa element và hàm onClick sẽ gây ra một vài vấn đề dẫn đến rò rỉ bộ nhớ.

3.3. Cách tránh rò rỉ bộ nhớ

  • Giải phóng tham chiếu: Khi một đối tượng không còn được sử dụng, hãy gán giá trị null cho các biến tham chiếu đến nó để giúp trình garbage collector nhận biết.

  • Tránh tham chiếu tuần hoàn: Cân nhắc sử dụng các kỹ thuật như WeakMap để phá vỡ các vòng tròn tham chiếu.

  • Quản lý closure một cách cẩn thận: Chỉ tạo closure khi cần thiết và đảm bảo rằng các biến bên trong closure được giải phóng khi không còn sử dụng.

  • Gỡ bỏ event listener: Luôn gỡ bỏ các event listener khi không cần thiết nữa để tránh các đối tượng bị giữ lại.

  • Sử dụng các công cụ phát hiện rò rỉ bộ nhớ: Các công cụ như Chrome DevTools có thể giúp bạn tìm và sửa các rò rỉ bộ nhớ.

  • Viết code sạch và dễ bảo trì: Code rõ ràng và có cấu trúc tốt giúp dễ dàng phát hiện và sửa lỗi rò rỉ bộ nhớ.

4. Nhầm lẫn về sự bình đẳng (Equality)

Một sự tiện lợi của JavaScript là nó sẽ tự động ép kiểu bất kỳ giá trị nào được tham chiếu trong một ngữ cảnh boolean thành một giá trị boolean. Nhưng có những trường hợp mà điều này có thể gây nhầm lẫn cho quá trình chạy chương trình. Ví dụ:

 

// All of these evaluate to 'true'!
console.log(false == '0');
console.log(null == undefined);
console.log(" \t\r\n" == 0);
console.log('' == 0);

// And these do too!
if ({}) // ...
if ([]) // ...

 

Về hai trường hợp cuối, mặc dù {}[] đều rỗng (điều này có thể khiến bạn nghĩ rằng chúng sẽ đánh giá thành false), nhưng cả hai đều là đối tượng trong JavaScript. Theo quy định của ECMA-262, bất kỳ đối tượng nào được chuyển đổi thành giá trị boolean thì sẽ ra output là true.

Như những ví dụ này cho thấy, các quy tắc về chuyển đổi kiểu dữ liệu đôi khi có thể rất khó hiểu. Do đó, trừ khi bạn cần chuyển đổi kiểu dữ liệu một cách rõ ràng, thường thì nên sử dụng ===!== (thay vì ==!=) để tránh bất kỳ hiệu ứng phụ không mong muốn nào từ việc chuyển đổi kiểu dữ liệu. (== và != tự động thực hiện chuyển đổi kiểu khi so sánh hai giá trị, trong khi ===!== thực hiện cùng một so sánh mà không cần chuyển đổi kiểu.)

Vì chúng ta đang nói về chuyển đổi kiểu và so sánh, nên cũng lưu ý rằng việc so sánh NaN với bất kỳ giá trị nào (kể cả NaN!) luôn trả về false. Do đó, bạn không thể sử dụng các toán tử so sánh (==, ===, !=, !==) để xác định một giá trị có phải là NaN hay không. Thay vào đó, hãy sử dụng hàm toàn cục isNaN() tích hợp sẵn.

 

console.log(NaN == NaN);    // False
console.log(NaN === NaN);   // False
console.log(isNaN(NaN));    // True

 

5. Quản lý DOM Kém Hiệu Quả

JavaScript làm cho việc thao tác DOM (tức là thêm, sửa đổi và xóa các phần tử) trở nên tương đối dễ dàng, nhưng không có cơ chế nào trong JavaScript giúp bạn tự động làm điều đó một cách tối ưu. 

Một ví dụ phổ biến là mã thêm từng phần tử DOM một cách riêng lẻ. Việc thêm một phần tử vào DOM là một thao tác tốn thời gian, và nếu bạn thêm nhiều phần tử liên tiếp như vậy, hiệu suất sẽ giảm và mã của bạn có thể không hoạt động tốt.

Một phương pháp thay thế hiệu quả khi cần thêm nhiều phần tử DOM là sử dụng các đoạn tài liệu (document fragments)

Ví dụ:

 

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));

 

Ngoài việc cải thiện hiệu quả vốn có của phương pháp này, việc tạo các phần tử DOM ngay từ đầu có thể tốn kém. Theo đó, việc tạo và sửa đổi chúng ở trạng thái riêng lẻ rồi mới gắn kết vào DOM sau đó sẽ mang lại hiệu suất tốt hơn nhiều.

6. Sử dụng sai các định nghĩa hàm bên trong vòng lặp for

 

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);
    };
}

 

Dựa trên đoạn mã trên, nếu có 10 phần tử input, việc nhấp vào bất kỳ phần tử nào trong số đó sẽ hiển thị “This is element #10”! Điều này xảy ra vì khi onclick được gọi cho bất kỳ phần tử nào, vòng lặp for trên đã hoàn tất và giá trị của i sẽ là 10 (cho tất cả các phần tử).

Dưới đây là cách sửa lỗi JavaScript này để đạt được kết quả mong muốn:

 

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

 

Trong phiên bản được sửa đổi này, makeHandler được thực thi ngay lập tức mỗi khi chúng ta đi qua vòng lặp, mỗi lần nhận được giá trị hiện tại của i + 1 và liên kết nó với biến num trong phạm vi. Hàm bên ngoài trả về hàm bên trong (cũng sử dụng biến num có phạm vi này) và onclick của phần tử được đặt thành hàm bên trong đó. Điều này đảm bảo rằng mỗi onclick nhận và sử dụng giá trị i chính xác (thông qua biến num có phạm vi).

7. Không tận dụng đúng cách kế thừa nguyên mẫu (Prototype)

 

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

 

Điều này có vẻ khá đơn giản. Nếu bạn cung cấp một tên, hãy sử dụng nó; nếu không, hãy đặt tên thành ‘default’. Ví dụ:

 

var firstObj = new BaseObject();
var secondObj = new BaseObject('unique');

console.log(firstObj.name);  // -> Results in 'default'
console.log(secondObj.name); // -> Results in 'unique'

 

Nhưng nếu chúng ta làm thế này thì sao:

 

delete secondObj.name;

 

Khi đó chúng ta sẽ có:

 

console.log(secondObj.name); // -> Results in 'undefined'

 

Nhưng liệu có phải sẽ tốt hơn nếu việc này trở về ‘default’? Điều này có thể dễ dàng thực hiện nếu chúng ta sửa mã gốc để tận dụng kế thừa prototype, như sau:

 

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

BaseObject.prototype.name = 'default';

 

Với phiên bản này, BaseObject kế thừa thuộc tính name từ đối tượng prototype của nó, nơi nó được đặt theo mặc định là 'default'. Do đó, nếu hàm tạo được gọi mà không có tên, tên sẽ mặc định là 'default'. Tương tự, nếu thuộc tính name bị xóa khỏi một instance của BaseObject, chuỗi prototype sau đó sẽ được tìm kiếm và thuộc tính name sẽ được lấy từ đối tượng prototype nơi giá trị của nó vẫn là 'default'. Vì vậy bây giờ chúng ta có:

 

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

delete thirdObj.name;
console.log(thirdObj.name);  // -> Results in 'default'

 

8. Tạo tham chiếu không đúng đến phương thức Instance

Hãy định nghĩa một đối tượng đơn giản và tạo một instance của nó, như sau:

 

var MyObjectFactory = function() {}
 
MyObjectFactory.prototype.whoAmI = function() {
    console.log(this);
};


var obj = new MyObjectFactory();

 

Giờ đây, để tiện lợi, hãy tạo một tham chiếu đến phương thức whoAmI, nhằm mục đích chúng ta có thể truy cập nó bằng cách gọi whoAmI() thay vì phải dùng cú pháp dài hơn là obj.whoAmI():

 

var whoAmI = obj.whoAmI;

 

Và để chắc chắn rằng chúng ta đã lưu trữ một tham chiếu đến hàm, hãy in ra giá trị của biến whoAmI mới tạo:

 

console.log(whoAmI);

 

Outputs:

 

function () {
    console.log(this);
}

 

Cho đến giờ thì có vẻ ổn.

Nhưng hãy xem sự khác biệt khi chúng ta gọi obj.whoAmI() so với tham chiếu tiện lợi whoAmI():

 

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

 

Điều gì đã xảy ra? Lời gọi whoAmI() của chúng ta ở trong không gian toàn cục, vì vậy this được thiết lập thành window (hoặc, trong chế độ nghiêm ngặt, thành undefined), chứ không phải là instance obj của MyObjectFactory! Nói cách khác, giá trị của this thường phụ thuộc vào ngữ cảnh gọi.

Các hàm mũi tên ((params) => {} thay vì function(params) {} cung cấp một this tĩnh không dựa trên ngữ cảnh gọi như this đối với các hàm thông thường. Điều này cung cấp cho chúng ta một giải pháp thay thế:

 

var MyFactoryWithStaticThis = function() {
    this.whoAmI = () => { // Note the arrow notation here
        console.log(this);
    };
}

var objWithStaticThis = new MyFactoryWithStaticThis();
var whoAmIWithStaticThis = objWithStaticThis.whoAmI;

objWithStaticThis.whoAmI();  // Outputs "MyFactoryWithStaticThis" (as usual)
whoAmIWithStaticThis();      // Outputs "MyFactoryWithStaticThis" (arrow notation benefit)

 

9. Cung cấp một chuỗi như là đối số đầu tiên cho setTimeout hoặc setInterval

Trước tiên, hãy làm rõ một điều: Việc cung cấp một chuỗi ký tự như tham số đầu tiên cho setTimeout hoặc setInterval không phải là một lỗi về mặt cú pháp. Đó là mã JavaScript hoàn toàn hợp lệ. Vấn đề ở đây chủ yếu là về hiệu suất và hiệu quả. Điều thường bị bỏ qua là nếu bạn truyền một chuỗi ký tự làm tham số đầu tiên cho setTimeout hoặc setInterval, nó sẽ được chuyển đến hàm constructor để chuyển đổi thành một hàm mới. Quá trình này có thể chậm và kém hiệu quả, và hầu hết là không cần thiết.

Thay vì truyền một chuỗi ký tự làm tham số đầu tiên cho các phương thức này, bạn có thể truyền một hàm. Hãy xem ví dụ sau

Dưới đây là một cách sử dụng khá điển hình của setInterval và setTimeout, với việc truyền một chuỗi ký tự làm tham số đầu tiên:

 

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

 

Lựa chọn tốt hơn sẽ là truyền vào một hàm làm đối số ban đầu, ví dụ:

 

setInterval(logTime, 1000);   // Passing the logTime function to setInterval
    
setTimeout(function() {       // Passing an anonymous function to setTimeout
    logMessage(msgValue);     // (msgValue is still accessible in this scope)
}, 1000);

 

10. Không sử dụng “Strict Mode”

Strict Mode là một tính năng đặc biệt trong JavaScript giúp tăng cường tính nghiêm ngặt của mã, giúp phát hiện và ngăn chặn một số loại lỗi phổ biến. Khi bạn khai báo "use strict" ở đầu một script hoặc một function, tất cả code bên trong đó sẽ được thực thi trong chế độ nghiêm ngặt. Mặc dù việc không sử dụng Strict Mode không thực sự là một "sai lầm", nhưng việc sử dụng nó ngày càng được khuyến khích bởi vì nó mang lại rất nhiều lợi ích:

- Phát hiện lỗi sớm: Strict Mode giúp phát hiện nhiều loại lỗi hơn so với chế độ bình thường, chẳng hạn như:

  • Biến chưa khai báo: Gán giá trị cho một biến chưa được khai báo sẽ gây ra lỗi.

  • Tên biến trùng lặp: Khai báo biến với cùng tên trong cùng một scope sẽ gây ra lỗi.

  • Tham số hàm trùng lặp: Định nghĩa hàm với các tham số trùng tên sẽ gây ra lỗi.

  • Sử dụng từ khóa bị cấm: Một số từ khóa được bảo lưu cho các phiên bản JavaScript sau này sẽ không thể sử dụng làm tên biến.

- Ngăn chặn các hành vi không mong muốn: Strict Mode giúp ngăn chặn một số hành vi không mong muốn có thể dẫn đến các lỗi khó tìm, chẳng hạn như:

  • Ép kiểu this: Trong chế độ bình thường, this có thể bị ép kiểu thành đối tượng toàn cục. Strict Mode sẽ giúp ngăn chặn điều này.

  • Xóa thuộc tính không thể xóa: Cố gắng xóa một thuộc tính không thể xóa sẽ gây ra lỗi.

- Tăng tính bảo mật: Strict Mode giúp tăng cường tính bảo mật của mã bằng cách hạn chế một số hành vi có thể bị khai thác.

Lời kết

Như với bất kỳ công nghệ nào, việc hiểu rõ lý do và cách JavaScript hoạt động (cũng như không hoạt động) sẽ giúp code của bạn an toàn hơn và bạn có thể khai thác hiệu quả sức mạnh thực sự của ngôn ngữ này. Ngược lại, việc thiếu hiểu biết đúng đắn về các khái niệm và mô hình của JavaScript sẽ là nguyên nhân chính gây ra nhiều vấn đề khi coding. VietnamWorks inTECH hy vọng, thông qua những chia sẻ ở trên các bạn đã có thể tránh được những lỗi phổ biến từ đó nâng cao kỹ năng và tăng năng suất của bạn.

VietnamWorks inTECH

 

TẠO TÀI KHOẢN MỚI: XEM FULL “1 TÁCH CODEFEE” - NHẬN SLOT TƯ VẤN CV TỪ CHUYÊN GIA - CƠ HỘI RINH VỀ VOUCHER 200K