Là một trong những ngôn ngữ lập trình phổ biến nhấn hiện nay, JavaScript  trở thành một yếu tố quyết định đối với sự thành công của lập trình viên. Đối với những cá nhân có kinh nghiệm, đặc biệt là ở cấp độ senior, am hiểu  sâu sắc về ngôn ngữ này không chỉ là một lợi thế mà còn là một yêu cầu cần thiết.

Vì vậy, trong bài viết này VietnamWorks inTECH sẽ giải thích cặn kẽ 8 Kỹ thuật về Javascript nâng cao và quan trọng mà cấp senior hay gặp phải trong quá trình phỏng vấn.

1. Các toán tử + và -

console.log(1 + '1' - 1); 

Khi JavaScript gặp phải biểu thức 1 + '1', nó xử lý biểu thức này bằng toán tử +. Một đặc tính thú vị của toán tử + là nó ưa chuộng việc nối chuỗi, khi một trong hai toán hạng là chuỗi. Trong trường hợp trên, '1' là một chuỗi, nên JavaScript tự động chuyển giá trị số 1 thành chuỗi. Do đó, 1 + '1' trở thành '1' + '1', kết quả là chuỗi '11'.

Bây giờ, phương trình của chúng ta là '11' - 1. Hành vi của toán tử - hoàn toàn ngược lại. Nó ưu tiên phép trừ số học, bất kể loại của các toán hạng. Khi các toán hạng không phải kiểu số, JavaScript thực hiện chuyển đổi tự động để chuyển chúng thành số. Trong trường hợp này, '11' được chuyển đổi thành giá trị số 11, và biểu thức trở thành 11 - 1.

Tổng hợp lại:

'11' - 1 = 11 - 1 = 10

2. Sao chép các phần tử mảng

Hãy xem đoạn mã JavaScript sau 

function duplicate(array) {
  for (var i = 0; i < array.length; i++) {
    array.push(array[i]);
  }
  return array;
}

const arr = [1, 2, 3];
const newArr = duplicate(arr);
console.log(newArr);

Trong đoạn mã này, chúng ta cần tạo một mảng mới chứa các phần tử trùng lặp của mảng đầu vào. Sau khi kiểm tra ban đầu, mã có vẻ sẽ tạo ra một mảng mới newArr bằng cách nhân đôi mỗi phần tử từ mảng gốc arr. Tuy nhiên, có một vấn đề quan trọng phát sinh trong chính hàm duplicate.

Hàm duplicate sử dụng một vòng lặp để duyệt qua từng phần tử trong mảng được cung cấp. Nhưng bên trong vòng lặp, nó thêm một phần tử mới vào cuối mảng, sử dụng phương thức push(). Điều này làm cho mảng dài hơn, tạo ra một vấn đề khiến vòng lặp không bao giờ dừng lại. Điều kiện vòng lặp (i < array.length) luôn đúng vì mảng cứ lớn dần, khiến vòng lặp chạy mãi mãi, dẫn đến chương trình bị treo.

Để giải quyết vấn đề vòng lặp vô hạn do chiều dài mảng tăng lên, bạn có thể lưu chiều dài ban đầu của mảng trong một biến trước khi nhập vào vòng lặp. Sau đó, bạn có thể sử dụng chiều dài ban đầu này làm giới hạn cho vòng lặp. Như vậy, vòng lặp chỉ chạy cho các phần tử ban đầu trong mảng và không bị ảnh hưởng bởi sự tăng trưởng của mảng do các bản sao được thêm vào. Đây là phiên bản được sửa đổi của mã

Đầu ra sẽ hiển thị các phần tử trùng lặp ở cuối mảng và sẽ không dẫn đến vòng lặp vô hạn:

function duplicate(array) {
var initialLength = array.length; // Store the initial length
for (var i = 0; i < initialLength; i++) {
array.push(array[i]); // Push a duplicate of each element
}
return array;
}

const arr = [1, 2, 3];
const newArr = duplicate(arr);
console.log(newArr);

3. Sự khác biệt giữa prototype và__proto__

Thuộc tính prototype là một thuộc tính liên quan đến các hàm tạo trong JavaScript. Các hàm tạo được sử dụng để tạo ra các đối tượng trong JavaScript. Khi bạn định nghĩa một hàm tạo, bạn cũng có thể đính kèm các thuộc tính và phương thức vào thuộc tính prototype của nó. Những thuộc tính và phương thức này có thể truy cập được đối với tất cả các phiên bản của đối tượng được tạo từ hàm tạo đó. Vì vậy, thuộc tính prototype được sử dụng như một kho chứa chung cho các phương thức và thuộc tính được chia sẻ giữa các trường hợp.

Hãy xem đoạn mã sau:

// Constructor function
function Person(name) {
this.name = name;
}

// Adding a method to the prototype
Person.prototype.sayHello = function() {
console.log(`Hello, my name is ${this.name}.`);
};

// Creating instances
const person1 = new Person("Haider Wain");
const person2 = new Person("Omer Asif");

// Calling the shared method
person1.sayHello(); // Output: Hello, my name is Haider Wain.
person2.sayHello(); // Output: Hello, my name is Omer Asif.

Trong ví dụ này, chúng ta có một hàm tạo đối tượng được gọi là Person. Bằng cách mở rộng Person.prototype với một phương thức như sayHello, chúng ta đang thêm phương thức này vào chuỗi nguyên mẫu của tất cả các phiên bản Person. Điều này cho phép mỗi phiên bản của Person truy cập và sử dụng phương thức được chia sẻ, thay vì có bản sao riêng của từng phương thức.

Mặt khác, thuộc tính __proto__, thường được phát âm là “dunder proto,” tồn tại trong mọi đối tượng JavaScript. Trong JavaScript, mọi thứ, trừ các loại nguyên thủy, đều có thể được coi là một đối tượng. Mỗi đối tượng này đều có một nguyên mẫu, đóng vai trò là một tham chiếu đến một đối tượng khác. Thuộc tính __proto__ đơn giản chỉ là một tham chiếu đến đối tượng nguyên mẫu này. Đối tượng nguyên mẫu được sử dụng như một nguồn dự phòng cho các thuộc tính và phương thức khi đối tượng gốc không sở hữu chúng. Theo mặc định, khi bạn tạo một đối tượng, nguyên mẫu của nó được đặt thành Object.prototype.

Khi bạn cố gắng truy cập một thuộc tính hoặc phương thức trên một đối tượng, JavaScript sẽ tuân theo quy trình tra cứu để tìm thấy nó. Quá trình này bao gồm hai bước chính:

  • Thuộc tính riêng của đối tượng:  Trước tiên, JavaScript kiểm tra xem bản thân đối tượng có trực tiếp sở hữu thuộc tính hoặc phương thức mong muốn hay không. Nếu thuộc tính được tìm thấy trong đối tượng, nó sẽ được truy cập và sử dụng trực tiếp.

  • Tra cứu chuỗi nguyên mẫu: Nếu thuộc tính không được tìm thấy trong đối tượng, JavaScript sẽ xem xét nguyên mẫu của đối tượng (được tham chiếu bởi thuộc tính __proto__ ) và tìm kiếm thuộc tính ở đó. Quá trình này tiếp tục đệ quy lên chuỗi nguyên mẫu cho đến khi thuộc tính được tìm thấy hoặc cho đến khi tìm kiếm đến Object.prototype

Nếu thuộc tính không được tìm thấy ngay cả trong Object.prototype, JavaScript sẽ trả về undefined, cho biết thuộc tính không tồn tại.

4. Scopes (Phạm vi)

Khi viết mã JavaScript, hiểu khái niệm scope là rất quan trọng. Scope đề cập đến khả năng truy cập hoặc khả năng hiển thị của các biến trong các phần khác nhau mã của bạn. 

Cùng theo dõi đoạn ví dụ dưới đây:

function foo() {
console.log(a);
}

function bar() {
var a = 3;
foo();
}

var a = 5;
bar();

Đoạn mã định nghĩa 2 hàm foo()bar() và một biến a với giá trị là 5. Tất cả các khai báo này đều xảy ra trong phạm vi toàn cục. Bên trong hàm bar(), một biến a được khai báo và gán giá trị là 3. Vậy khi hàm bar() được gọi, bạn nghĩ giá trị của a sẽ là gì?

Khi trình duyệt JavaScript thực thi đoạn mã này, biến toàn cục a được khai báo và gán giá trị là 5. Sau đó, hàm bar() được gọi. Bên trong hàm bar(), một biến cục bộ a được khai báo và gán giá trị là 3. Biến cục bộ a này khác biến toàn cục a trước. Sau đó, hàm foo() được gọi từ bên trong hàm bar().

Bên trong hàm foo(), câu lệnh console.log(a) cố gắng ghi lại giá trị của a. Vì không có biến cục bộ a được định nghĩa trong phạm vi của hàm foo(), JavaScript tìm kiếm chuỗi phạm vi để tìm biến có tên gần nhất là a. Chuỗi phạm vi đề cập đến tất cả các phạm vi khác nhau mà một hàm có quyền truy cập khi nó cố gắng tìm và sử dụng các biến.

Bây giờ, hãy giải quyết câu hỏi về nơi JavaScript sẽ tìm kiếm biến a. Liệu nó sẽ tìm trong phạm vi của hàm bar(), hay sẽ khám phá phạm vi toàn cục? Như đã biết, JavaScript sẽ tìm kiếm trong phạm vi toàn cục, và hành vi này được thúc đẩy bởi một khái niệm gọi là ‘lexical scope’.

Lexical scope đề cập đến phạm vi của một hàm hoặc biến vào thời điểm nó được viết. Khi chúng ta định nghĩa hàm foo, nó được truy cập vào cả phạm vi cục bộ của nó và phạm vi toàn cục. Đặc điểm này vẫn nhất quán bất kể chúng ta gọi hàm foo ở đâu - cho dù trong hàm bar hoặc nếu chúng ta xuất nó sang một module khác và chạy nó ở đó. Lexical scope không được xác định bởi nơi chúng ta gọi hàm.

Kết quả của điều này là đầu ra sẽ luôn giống nhau: giá trị của a được tìm thấy trong phạm vi toàn cục, trong trường hợp này là 5.

Tuy nhiên, nếu chúng ta đã định nghĩa hàm foo bên trong hàm bar, sẽ xảy ra một tình huống khác:

Trong trường hợp này, lexical scope của foo sẽ bao gồm ba phạm vi khác nhau: phạm vi cục bộ của nó, phạm vi của hàm bar và phạm vi toàn cục. Phạm vi từ vựng được xác định bởi vị trí bạn đặt mã của mình trong mã nguồn trong quá trình biên dịch.

Khi mã này chạy, foo được đặt trong hàm bar, sự sắp xếp này làm thay đổi động lực của phạm vi. Bây giờ, khi foo cố gắng truy cập biến a, nó sẽ tìm kiếm trước trong phạm vi cục bộ của nó. Vì nó không tìm thấy a ở đó, nó sẽ mở rộng tìm kiếm của mình đến phạm vi của hàm bar. Thật bất ngờ, a tồn tại ở đó với giá trị là 3. Kết quả là, câu lệnh console sẽ in ra 3.

5. Object Coercion

Một khía cạnh thú vị để khám phá là cách JavaScript xử lý việc chuyển đổi giữa các đối tượng thành các giá trị nguyên thủy như chuỗi, số, hoặc boolean. Đây là một câu hỏi kiểm tra khả năng hiểu về cách chuyển đổi hoạt động với đối tượng.

Chuyển đổi này rất quan trọng khi làm việc với đối tượng trong các tình huống như nối chuỗi hoặc thực hiện các phép toán số học. Để đạt được điều này, JavaScript sử dụng hai phương thức đặc biệt: valueOftoString.

Phương thức valueOf là một phần cơ bản của cơ chế chuyển đổi đối tượng của JavaScript. Khi một đối tượng được sử dụng trong ngữ cảnh yêu cầu một giá trị nguyên thủy, JavaScript đầu tiên tìm kiếm phương thức valueOf bên trong đối tượng. Trong những trường hợp phương thức valueOf không tồn tại hoặc không trả về một giá trị nguyên thủy phù hợp, JavaScript sẽ chuyển sang phương thức toString. Phương thức này chịu trách nhiệm cung cấp một biểu diễn chuỗi của đối tượng.

Xem đoạn code sau:

const obj = {
valueOf: () => 42,
toString: () => 27
};

console.log(obj + '');

Khi chúng ta chạy đoạn mã này, đối tượng obj được chuyển đổi thành một giá trị nguyên thủy. Trong trường hợp này, phương thức valueOf trả về giá trị 42, sau đó tự động chuyển đổi thành chuỗi do được nối với một chuỗi trống. Do đó, kết quả của đoạn mã sẽ là 42.

Tuy nhiên, trong những trường hợp phương thức valueOf không tồn tại hoặc không trả về một giá trị nguyên thủy phù hợp, JavaScript sẽ fallback (rơi về phương thức thay thế) vào phương thức toString.

Ở đây, chúng ta loại bỏ phương thức valueOf, chỉ giữ lại phương thức toString, mà trả về số 27. Trong tình huống này, JavaScript sẽ sử dụng phương thức toString để chuyển đổi đối tượng.

6. Hiểu Object Keys (Khóa đối tượng)

Khi làm việc với các đối tượng trong JavaScript, điều quan trọng là phải nắm được cách xử lý và gán các khóa trong ngữ cảnh của các đối tượng khác. Hãy cùng xem xét đoạn mã sau:

let a = {};
let b = { key: 'test' };
let c = { key: 'test' };

a[b] = '123';
a[c] = '456';

console.log(a);

Thoạt nhìn, có vẻ như đoạn mã này nên tạo ra một đối tượng 'a' với hai cặp key-value khác nhau. Tuy nhiên, kết quả lại khác biệt do cách JavaScript xử lý các khóa của đối tượng.

JavaScript sử dụng phương thức toString() mặc định để chuyển đổi các khóa đối tượng thành chuỗi. Nhưng tại sao lại như vậy? Trong JavaScript, các khóa đối tượng luôn luôn là chuỗi (hoặc biểu tượng), hoặc chúng sẽ tự động chuyển đổi thành chuỗi thông qua sự ép buộc ngầm. Khi bạn sử dụng bất kỳ giá trị nào khác chuỗi (ví dụ như số, đối tượng, hoặc biểu tượng) làm khóa trong một đối tượng, JavaScript sẽ chuyển đổi giá trị đó thành biểu diễn chuỗi của nó trước khi sử dụng nó làm khóa.

Do đó, khi chúng ta sử dụng các đối tượng 'b''c' làm khóa trong đối tượng 'a', cả hai đều được chuyển đổi thành cùng một biểu diễn chuỗi: [object Object]. Do hành vi này, phép gán thứ hai, a[b] = '123'; sẽ ghi đè lên phép gán đầu tiên a[c] = '456';. Hãy cùng phân tích từng bước:

- let a = {};: Khởi tạo một đối tượng rỗng a.

- let b = { key: 'test' };: Tạo một đối tượng b với thuộc tính key có giá trị là ‘test’.

- let c = { key: 'test' };: Xác định một đối tượng khác c có cùng cấu trúc với b.

- a[b] = '123';: Đặt giá trị '123' cho thuộc tính bằng khóa [object Object] trong đối tượng a.

- a[c] = '456';: Cập nhật giá trị '456' cho cùng một thuộc tính bằng khóa [object Object] trong đối tượng a, thay thế giá trị trước đó.

Cả hai đều sử dụng chuỗi khóa giống hệt nhau [object Object]. Kết quả là phép gán thứ hai sẽ ghi đè giá trị được đặt bởi phép gán đầu tiên.

Khi log đối tượng a, đầu ra sẽ như sau:

{ '[object Object]': '456' }

7. The Double Equals Operator (Toán tử hai dấu bằng)

Đoạn này sẽ khá phức tạp một chút, vì vậy hãy cùng đánh giá ví dụ theo từng bước một. Đầu tiên, hãy bắt đầu bằng cách xem xét kiểu dữ liệu của cả hai toán hạng:

typeof([]) // "object"
typeof(![]) // "boolean"

Đối với [], đó là một đối tượng, điều này là dễ hiểu. Bởi vì trong JavaScript, mọi thứ, kể cả mảng và hàm, đều là đối tượng. Nhưng làm thế nào mà toán hạng ![] lại có kiểu boolean? Hãy cố gắng hiểu điều này, khi bạn sử dụng ! với một giá trị nguyên thủy, các chuyển đổi sau sẽ xảy ra:

  • Các giá trị Falsy: Nếu giá trị gốc là một giá trị falsy (như false, 0, null, undefined, NaN, hoặc một chuỗi rỗng ''), việc áp dụng ! sẽ chuyển đổi nó thành true.

  • Các giá trị Truthy: Nếu giá trị gốc là một giá trị truthy (bất kỳ giá trị nào không phải là falsy), việc áp dụng ! sẽ chuyển đổi nó thành false.

Trong trường hợp của chúng ta, [] là một mảng rỗng, là một giá trị truthy trong JavaScript. Vì [] là truthy, ![] trở thành false. Do đó, biểu thức của chúng ta trở thành:

[] == ![]
[] == false

Bây giờ chúng ta hãy tiếp tục và hiểu về toán tử ==. Khi 2 giá trị được so sánh bằng toán tử ==, JavaScript thực hiện thuật toán So Sánh Tương Đối Trừu Tượng (Abstract Equality Comparison Algorithm). Thuật toán này có các bước sau:

Như bạn có thể thấy, thuật toán này tính đến các loại giá trị được so sánh và thực hiện các chuyển đổi cần thiết.

Đối với trường hợp của chúng ta, hãy ký hiệu x là [] và y là ![]. Chúng ta đã kiểm tra các loại và tìm thấy x dưới dạng đối tượng và y dưới dạng boolean. Vì y là một boolean và x là một đối tượng, điều kiện 7 từ thuật toán so sánh tương đối trừu tượng được áp dụng:

Nếu Kiểu (y) là Boolean, thì trả về kết quả của so sánh x == ToNumber(y).

Nghĩa là nếu một trong các kiểu là boolean, chúng ta cần chuyển đổi nó thành một số trước khi so sánh. Giá trị của ToNumber(y) là gì? Như chúng ta đã thấy, [] là một giá trị truthy, phủ định nó sẽ trở thành false. Do đó, ToNumber(false) là 0

[] == false
[] == Number(false)
[] == 0 

Dựa trên điều kiện này, nếu một trong các toán hạng là một đối tượng, chúng ta phải chuyển đổi nó thành giá trị nguyên thủy. Đây là lúc thuật toán ToPrimitive phát huy tác dụng. Chúng ta cần chuyển đổi x, là [], thành một giá trị nguyên thủy. Mảng là các đối tượng trong JavaScript. Như chúng ta đã thấy trước đó, khi chuyển đối tượng thành nguyên thủy, các phương thức valueOf và toString được sử dụng. Trong trường hợp này, valueOf trả về chính mảng đó và không phải là một giá trị nguyên thủy hợp lệ. Do đó, chúng ta chuyển sang toString để có kết quả. Áp dụng phương thức toString cho một mảng trống dẫn đến việc thu được một chuỗi rỗng, là một giá trị nguyên thủy hợp lệ:

[] == 0
[].toString() == 0
"" == 0

Chuyển đổi mảng rỗng thành chuỗi sẽ tạo ra một chuỗi rỗng "", và bây giờ chúng ta đối mặt với sự so sánh: "" == 0.

Với một toán hạng là kiểu string và toán hạng còn lại là kiểu số, điều kiện 5 được áp dụng:

Nếu Kiểu(x) là String và Kiểu(y) là Number, trả về kết quả của so sánh ToNumber(x) == y.

Vì vậy, chúng ta cần chuyển đổi chuỗi rỗng "" thành số, điều này mang lại cho chúng ta giá trị là 0.

"" == 0
ToNumber("") == 0
0 == 0

Cuối cùng, cả hai toán hạng đều có cùng loại và đúng điều kiện 1. Vì cả hai đều có cùng giá trị nên kết quả cuối cùng là:

0 == 0 // true

8. Closures 

Đây là một trong những câu hỏi phỏng vấn phổ biến liên quan đến Closure

const arr = [10, 12, 15, 21];
for (var i = 0; i < arr.length; i++) {
setTimeout(function() {
console.log('Index: ' + i + ', element: ' + arr[i]);
}, 3000);
}

Thoạt nhìn, dường như đoạn mã này sẽ cho chúng ta kết quả là

Index: 0, element: 10
Index: 1, element: 12
Index: 2, element: 15
Index: 3, element: 21

Tuy nhiên kết quả thực tế sẽ khác do liên quan đến  khái niệm về closures và cách JavaScript xử lý phạm vi biến. Khi các callback của setTimeout được thực thi sau thời gian trễ là 3000 mili giây, tất cả chúng sẽ tham chiếu đến cùng một biến i, mà sẽ có giá trị cuối cùng là 4 sau khi vòng lặp đã hoàn thành. Do đó, kết quả của đoạn mã sẽ là:

Index: 4, element: undefined
Index: 4, element: undefined
Index: 4, element: undefined
Index: 4, element: undefined

Hiện tượng này xảy ra do từ khóa var không tạo ra phạm vi block, và các callback của setTimeout nắm giữ tham chiếu đến cùng một biến i. Khi các callback được thực thi, chúng đều nhìn thấy giá trị cuối cùng của i, tức là 4, và cố gắng truy cập arr[4], nhưng arr chỉ có độ dài là 4.

Để đạt được kết quả mong muốn, bạn có thể sử dụng từ khóa let để tạo một phạm vi mới cho mỗi lần lặp của vòng lặp, đảm bảo rằng mỗi callback nắm giữ giá trị đúng của i:

const arr = [10, 12, 15, 21];
for (let i = 0; i < arr.length; i++) {
setTimeout(function() {
console.log('Index: ' + i + ', element: ' + arr[i]);
}, 3000);
}

Dưới đây là kết quả của đầu ra:

Index: 0, element: 10
Index: 1, element: 12
Index: 2, element: 15
Index: 3, element: 21

Việc sử dụng let sẽ tạo ra một ràng buộc mới cho i mỗi lần lặp, đảm bảo rằng mỗi lệnh gọi lại đều đề cập đến giá trị chính xác.

Thông thường, lập trình viên đã quen với giải pháp liên quan đến từ khóa let. Tuy nhiên, các cuộc phỏng vấn đôi khi có thể tiến thêm một bước và thách thức bạn giải quyết vấn đề mà không cần sử dụng let. Trong những trường hợp như vậy, một cách tiếp cận thay thế là tạo ra một closure bằng cách gọi ngay một hàm (IIFE) bên trong vòng lặp. Như vậy, mỗi lời gọi hàm có bản sao riêng của biến i. Dưới đây là cách bạn có thể thực hiện điều này:

const arr = [10, 12, 15, 21];
for (var i = 0; i < arr.length; i++) {
(function(index) {
setTimeout(function() {
console.log('Index: ' + index + ', element: ' + arr[index]);
}, 3000);
})(i);
}

Trong mã này, hàm được gọi ngay lập tức (function(index) { ... })(i); sẽ tạo một phạm vi mới cho mỗi lần lặp, ghi lại giá trị hiện tại i và chuyển nó làm index tham số. Điều này đảm bảo rằng mỗi hàm gọi lại có giá trị index riêng, ngăn chặn sự cố liên quan đến việc closure và mang lại cho bạn kết quả như mong đợi:

Index: 0, element: 10
Index: 1, element: 12
Index: 2, element: 15
Index: 3, element: 21

Lời kết

VietnamWorks inTECH hy vọng bài viết này hữu ích trong hành trình chuẩn bị phỏng vấn của bạn. Nếu thấy hay, đừng quên chia sẻ cho bạn bè cùng biết nhé.

VietnamWorks inTECH