Hello mọi người, lại là mình Tuấn đây. Bạn đang chuẩn bị cho một buổi phỏng vấn JavaScript sắp tới? Đừng lo, mình đã tổng hợp một bộ câu hỏi "xịn xò" nhất để giúp bạn "cân" mọi thử thách. Dù bạn là "junior" hay đã là "senior" trong ngành, bộ câu hỏi này sẽ giúp bạn ôn tập từ những khái niệm cơ bản đến những chủ đề nâng cao.
Trong bài viết này, mình đã tổng hợp hơn 30 câu hỏi phỏng vấn JavaScript quan trọng nhất kèm theo câu trả lời chi tiết và các ví dụ code cụ thể. Hãy cùng "đào sâu" vào bộ sưu tập này để "đánh bại" mọi buổi phỏng vấn JavaScrip nào.
Lưu ý: Câu hỏi và câu trả lời dưới đây được tập trung vào JavaScript, không bao gồm các câu hỏi về HTML, CSS hoặc các thư viện/framework JavaScript như React, Angular, hoặc Vue. À và cũng không phải lúc nào khi tham gia một buổi phỏng vấn js thì nhà tuyển dụng cũng sử dụng các câu hỏi như vậy, còn tùy thuộc vào vị trí công việc và cấp độ kỹ năng của ứng viên mà sẽ có câu hỏi phù hợp.
OK chúng ta bắt đầu thôi!
Cấp độ 1: Cơ bản
1. JavaScript có phải là ngôn ngữ đơn luồng không?
Đúng vậy, JavaScript là một ngôn ngữ đơn luồng. Điều này có nghĩa là nó chỉ có một call stack và một memory heap. Tại một thời điểm, chỉ có một tập lệnh được thực thi. JavaScript cũng là ngôn ngữ đồng bộ và chặn, nghĩa là code được thực thi từng dòng một và một tác vụ phải hoàn thành trước khi tác vụ tiếp theo bắt đầu.
Tuy nhiên, JavaScript cũng có khả năng xử lý bất đồng bộ, cho phép một số hoạt động được thực hiện độc lập với luồng thực thi chính. Điều này thường đạt được thông qua các cơ chế như callbacks, promises, async/await và event listeners. Các tính năng bất đồng bộ này cho phép JavaScript xử lý các tác vụ như lấy dữ liệu, xử lý đầu vào của người dùng và thực hiện các hoạt động I/O mà không chặn luồng chính, giúp xây dựng các ứng dụng web phản hồi nhanh và tương tác tốt.
2. Giải thích các thành phần chính của JavaScript Engine và cách nó hoạt động
Mỗi trình duyệt đều có một JavaScript Engine riêng để thực thi code JavaScript và chuyển đổi nó thành mã máy. Khi code JavaScript được thực thi, quá trình diễn ra như sau:
- Parser đọc code và tạo ra AST (Abstract Syntax Tree), lưu trữ nó trong bộ nhớ.
- Interpreter xử lý AST này và tạo ra bytecode hoặc mã máy để máy tính thực thi.
- Profiler theo dõi quá trình thực thi code.
- Optimizing compiler (hay JIT compiler) sử dụng bytecode cùng với dữ liệu từ profiler để tạo ra mã máy được tối ưu hóa cao.
Trong quá trình này, call stack theo dõi các hàm đang được thực thi, memory heap được sử dụng để cấp phát bộ nhớ. Cuối cùng, garbage collector đóng vai trò quản lý bộ nhớ bằng cách thu hồi bộ nhớ từ các đối tượng không còn được sử dụng.
Ví dụ, trong Google Chrome V8 Engine:
- Interpreter được gọi là "Ignition"
- Optimizing compiler được gọi là "TurboFan"
- Ngoài Parser, còn có "pre-parser" kiểm tra cú pháp và tokens
- "Sparkplug" là một compiler nhanh nằm giữa "Ignition" và "TurboFan"
3. Giải thích Event loop trong JavaScript
Event loop là một thành phần cốt lõi trong môi trường runtime của JavaScript. Nó chịu trách nhiệm lập lịch và thực thi các tác vụ bất đồng bộ. Event loop hoạt động bằng cách liên tục giám sát hai hàng đợi: call stack và event queue.
- Call stack là một cấu trúc dữ liệu dạng ngăn xếp (LIFO - Last In, First Out) lưu trữ các hàm đang được thực thi.
- Web APIs là nơi các hoạt động bất đồng bộ (như setTimeout, fetch requests, promises) cùng với callbacks của chúng chờ để hoàn thành.
- Job queue (hay microtasks) là một cấu trúc FIFO (First In, First Out) chứa các callbacks của async/await, promises, process.nextTick() sẵn sàng để thực thi.
- Task queue (hay macrotasks) là một cấu trúc FIFO chứa các callbacks của các hoạt động bất đồng bộ (như timer setInterval, setTimeout) sẵn sàng để thực thi.
Event loop liên tục kiểm tra xem call stack có trống không. Nếu call stack trống, event loop sẽ kiểm tra job queue hoặc task queue và đưa bất kỳ callback nào sẵn sàng thực thi vào call stack.
Câu hỏi phụ:
-
Hãy nêu một ứng dụng hoặc chức năng có sử dụng callStack trong javascript mà bạn đã từng gặp?
Call stack được sử dụng để theo dõi các hàm đang được thực thi trong JavaScript. Khi một hàm được gọi, nó được đẩy vào đỉnh của call stack. Khi hàm hoàn thành, nó được loại bỏ khỏi call stack. Call stack giúp JavaScript theo dõi thứ tự thực thi của các hàm và tránh việc xảy ra lỗi stack overflow. Hoặc khi gặp lỗi thì sẽ trace lỗi bằng cách xem call stack trace để biết được hàm nào gây ra lỗi. Khi hiểu được callStack làm việc theo cơ chế LIFO (Last In First Out) thì sẽ giúp chúng ta biết được thứ tự chính xác của các hàm đang được thực thi.
Ví dụ:
function firstFunction() { secondFunction(); } function secondFunction() { thirdFunction(); } function thirdFunction() { console.log('Hello from thirdFunction!'); } firstFunction();
Trong ví dụ trên, khi
firstFunction
được gọi, nó được đẩy vào đỉnh của call stack. Sau đó,secondFunction
được gọi và đẩy vào đỉnh của call stack. Cuối cùng,thirdFunction
được gọi và in ra "Hello from thirdFunction!". KhithirdFunction
hoàn thành, nó được loại bỏ khỏi call stack, tiếp theo làsecondFunction
và cuối cùng làfirstFunction
. -> Đây chính là một ví dụ điển hình của call stack, hàmfirstFunction
được gọi trước, nhưng hàmthirdFunction
được thực thi trước. -
Hãy cho biết đoạn code sau thì sẽ in ra kết quả gì?
console.log('Hi'); setTimeout(() => { console.log('there'); }, 0); console.log('from'); // kết quả: Hi from there
Trong đoạn code trên,
console.log('Hi')
được in ra đầu tiên. Sau đó,setTimeout
được gọi và callback function được đưa vào task queue. Tiếp theo,console.log('from')
được in ra. Khi call stack trống, event loop sẽ kiểm tra task queue và đưa callback function vào call stack để in ra "there". Do đó, kết quả cuối cùng sẽ là "Hi from there". Điều quan trọng là callback function củasetTimeout
được thực thi sau khi call stack trống, không phải ngay lập tức sau khi hàmsetTimeout
được gọi. Điều này giải thích tại sao "there" được in ra sau "from".
4. Sự khác biệt giữa var, let và const?
Trong JavaScript, var
, let
, và const
là ba từ khóa để khai báo biến, nhưng chúng có một số điểm khác biệt quan trọng:
-
Phạm vi (Scope):
var
: Có phạm vi hàm (function scope) hoặc phạm vi toàn cục (global scope).let
vàconst
: Có phạm vi khối (block scope).
if (true) { var x = 10; let y = 20; const z = 30; } console.log(x); // 10 console.log(y); // ReferenceError console.log(z); // ReferenceError
-
Hoisting:
var
: Được hoisted (nâng lên đầu phạm vi) và khởi tạo với giá trịundefined
.let
vàconst
: Cũng được hoisted nhưng không được khởi tạo, tạo ra "temporal dead zone".Temporal dead zone: là khoảng thời gian mà biến được khai báo nhưng chưa được khởi tạo, trong thời gian này, nếu truy cập biến sẽ gây ra lỗi ReferenceError. Khi chương trình thực thi đến dòng khai báo biến, biến sẽ được khởi tạo và có thể truy cập.
console.log(a); // undefined var a = 5; console.log(b); // ReferenceError let b = 10;
-
Khả năng gán lại giá trị:
var
vàlet
: Có thể gán lại giá trị.const
: Không thể gán lại giá trị sau khi đã khai báo.
var a = 1; a = 2; // Hợp lệ let b = 3; b = 4; // Hợp lệ const c = 5; c = 6; // TypeError
-
Khai báo lại:
var
: Có thể khai báo lại trong cùng một phạm vi.let
vàconst
: Không thể khai báo lại trong cùng một phạm vi.
var x = 1; var x = 2; // Hợp lệ let y = 3; let y = 4; // SyntaxError const z = 5; const z = 6; // SyntaxError
-
Liên kết với đối tượng toàn cục:
var
: Khi khai báo ở phạm vi toàn cục, sẽ được gắn vào đối tượngwindow
(trong trình duyệt).let
vàconst
: Không được gắn vào đối tượngwindow
.
var globalVar = 'Tôi là biến toàn cục'; console.log(window.globalVar); // 'Tôi là biến toàn cục' let globalLet = 'Tôi không phải biến toàn cục'; console.log(window.globalLet); // undefined
Hiểu rõ những khác biệt này sẽ giúp bạn viết code JavaScript an toàn và hiệu quả hơn, đồng thời tránh được những lỗi không mong muốn liên quan đến phạm vi và hoisting của biến.
5. Các kiểu dữ liệu khác nhau trong JavaScript?
JavaScript là một ngôn ngữ động (Dynamic Typing) và lỏng lẻo, hay còn gọi là "duck-typed". Điều này có nghĩa là chúng ta không cần phải chỉ định kiểu của biến vì JavaScript Engine sẽ tự động xác định kiểu dữ liệu của biến dựa trên giá trị của nó.
Ví dụ:
// Dynamic typing (trong JavaScript)
let a = 42; // a là một số nguyên
const b = 'Hello'; // b là một chuỗi
var c = true; // c là một boolean
// Static typing (trong java)
int a = 42; // a là một số nguyên
String b = 'Hello'; // b là một chuỗi
boolean c = true; // c là một boolean
Các kiểu dữ liệu nguyên thủy trong JavaScript là các kiểu dữ liệu cơ bản nhất đại diện cho các giá trị đơn. Chúng không thể thay đổi (immutable) và trực tiếp chứa một giá trị cụ thể.
Imutable là không thể thay đổi, nghĩa là không thể thay đổi giá trị của nó sau khi đã được khai báo. Ví dụ: string, number, boolean, null, undefined, symbol. Imutable ở đây muốn ám chỉ là giá trị chứ không phải là biến như let hay var hay const. VD:
let a = 5; a = 6;
immutable trong trường hợp này muốn nói tới là số 5 không thể thay đổi giá trị của nó thành số 6. Chứ không phải là biến a không thể thay đổi giá trị của nó từ giá trị 5 sang 6. Ví dụ trực quan hơn: Bạn có 1 cái xô đựng 1 cục đá, bản thân cục đá không thể thay đổi hình dạng của nó (nó có hình giống số 5 thì mãi mãi nó sẽ giống số 5), nhưng bạn có thể thay cục đá đó bằng cục đá khác (nghĩa là biến a có thể thay đổi giá trị của nó từ 5 sang 6.)
-
Number: Đại diện cho cả số nguyên và số thực.
let age = 25; let pi = 3.14;
-
String: Chuỗi ký tự.
let name = "Nguyễn Văn A"; let greeting = 'Xin chào!';
-
Boolean: Giá trị true hoặc false.
let isStudent = true; let hasJob = false;
-
Undefined: Biến được khai báo nhưng chưa được gán giá trị.
let undefinedVar; console.log(undefinedVar); // undefined
-
Null: Đại diện cho một giá trị không tồn tại hoặc không hợp lệ.
let emptyValue = null;
-
Symbol: Kiểu dữ liệu nguyên thủy được giới thiệu trong ECMAScript 6 (ES6), đại diện cho một giá trị duy nhất và không thể thay đổi.
const mySymbol = Symbol('key'); const obj = { [mySymbol]: 'Giá trị của Symbol' };
-
BigInt: Kiểu dữ liệu để biểu diễn các số nguyên lớn vượt quá giới hạn của kiểu Number.
const bigNumber = 1234567890123456789012345678901234567890n;
Ngoài ra, JavaScript còn có kiểu dữ liệu phức tạp:
-
Object: Kiểu dữ liệu phức tạp có thể chứa nhiều giá trị dưới dạng cặp key-value.
let person = { name: "Nguyễn Văn A", age: 30, isStudent: false };
-
Array: Một loại đối tượng đặc biệt để lưu trữ danh sách các giá trị.
let fruits = ["táo", "chuối", "cam"];
-
Function: Trong JavaScript, hàm cũng được coi là một kiểu dữ liệu.
function greet(name) { console.log("Xin chào, " + name + "!"); }
Hiểu rõ về các kiểu dữ liệu này sẽ giúp bạn làm việc hiệu quả hơn với JavaScript và tránh được những lỗi liên quan đến kiểu dữ liệu trong quá trình phát triển.
6. Callback function là gì và callback hell là gì?
Trong JavaScript, callback function (hàm gọi lại) là một hàm được truyền như một đối số cho một hàm khác và được thực thi sau khi hoàn thành một tác vụ cụ thể hoặc tại một thời điểm nhất định. Callbacks thường được sử dụng để xử lý các hoạt động bất đồng bộ.
Ví dụ về callback function:
function layDuLieu(url, xuLy) { // Giả lập việc lấy dữ liệu từ server setTimeout(() => { const duLieu = 'Dữ liệu từ ' + url; xuLy(duLieu); }, 1000);
} function xuLyDuLieu(duLieu) { console.log('Đang xử lý:', duLieu);
} layDuLieu('https://example.com/api', xuLyDuLieu);
Trong ví dụ này, xuLyDuLieu
là một callback function được truyền vào hàm layDuLieu
. Sau khi layDuLieu
hoàn thành việc lấy dữ liệu (được mô phỏng bằng setTimeout
), nó gọi callback function và truyền dữ liệu đã lấy được vào đó.
Callback Hell, còn được gọi là "Pyramid of Doom" (Kim tự tháp của số phận), là một thuật ngữ trong lập trình JavaScript để mô tả tình huống khi nhiều callback lồng nhau được sử dụng trong các hàm bất đồng bộ. "Nó xảy ra khi các hoạt động bất đồng bộ phụ thuộc vào kết quả của các hoạt động bất đồng bộ trước đó, dẫn đến code bị lồng sâu và thường khó đọc, khó debug."
Callback Hell là một anti-pattern với nhiều callback lồng nhau, làm cho code trở nên khó đọc và khó debug khi xử lý logic bất đồng bộ.
Ví dụ về Callback Hell:
fs.readFile('file1.txt', 'utf8', function (err, data) { if (err) { console.error(err); } else { fs.readFile('file2.txt', 'utf8', function (err, data) { if (err) { console.error(err); } else { fs.readFile('file3.txt', 'utf8', function (err, data) { if (err) { console.error(err); } else { // Tiếp tục với nhiều callback lồng nhau hơn... } }); } }); }
});
Trong ví dụ này, chúng ta đang đọc ba file tuần tự bằng hàm fs.readFile
, và mỗi hoạt động đọc file là bất đồng bộ. Kết quả là chúng ta phải lồng các callback vào nhau, tạo ra một cấu trúc hình kim tự tháp của các callback.
Để tránh Callback Hell, JavaScript hiện đại cung cấp các giải pháp thay thế như Promises và async/await. Dưới đây là cùng đoạn code trên nhưng sử dụng Promises:
const readFile = (file) => { return new Promise((resolve, reject) => { fs.readFile(file, 'utf8', (err, data) => { if (err) { reject(err); } else { resolve(data); } }); });
}; readFile('file1.txt') .then((data1) => { return readFile('file2.txt'); }) .then((data2) => { return readFile('file3.txt'); }) .then((data3) => { // Tiếp tục với nhiều code dựa trên promise... }) .catch((err) => { console.error(err); });
Bằng cách sử dụng Promises, chúng ta có thể tránh được việc lồng callback sâu và tạo ra một chuỗi các hoạt động bất đồng bộ dễ đọc và dễ quản lý hơn.
7. Promise và Promise chaining là gì?
Promise là một đối tượng trong JavaScript được sử dụng để xử lý các tác vụ bất đồng bộ. Nó đại diện cho kết quả của một hoạt động bất đồng bộ, có thể là thành công hoặc thất bại. Promise có ba trạng thái:
- Pending: Trạng thái ban đầu, khi Promise đang chờ kết quả.
- Fulfilled: Trạng thái khi Promise đã được giải quyết thành công.
- Rejected: Trạng thái khi Promise gặp lỗi hoặc bị từ chối.
Constructor của Promise nhận vào hai tham số là các hàm resolve
và reject
. Nếu tác vụ bất đồng bộ hoàn thành mà không có lỗi, ta gọi hàm resolve
với thông điệp hoặc dữ liệu đã lấy được. Nếu xảy ra lỗi, ta gọi hàm reject
và truyền lỗi vào đó.
Ta có thể truy cập kết quả của Promise bằng phương thức .then()
và bắt lỗi bằng .catch()
.
Ví dụ về việc tạo và sử dụng Promise:
// Tạo một Promise
const layDuLieu = new Promise((resolve, reject) => { // Giả lập việc lấy dữ liệu từ server setTimeout(() => { const duLieu = 'Dữ liệu từ server'; // Giải quyết Promise với dữ liệu đã lấy được resolve(duLieu); // Hoặc từ chối Promise với một lỗi // reject(new Error('Không thể lấy dữ liệu')); }, 1000);
}); // Sử dụng Promise
layDuLieu .then((duLieu) => { console.log('Dữ liệu đã lấy:', duLieu); }) .catch((loi) => { console.error('Lỗi khi lấy dữ liệu:', loi); });
Promise chaining là quá trình thực thi một chuỗi các tác vụ bất đồng bộ liên tiếp nhau bằng cách sử dụng Promise. Nó liên quan đến việc nối nhiều phương thức .then()
vào một Promise để thực hiện một loạt các tác vụ theo một thứ tự cụ thể.
Ví dụ về Promise chaining:
new Promise((resolve, reject) => { setTimeout(() => resolve(1), 1000);
})
.then((ketQua) => { console.log(ketQua); // 1 return ketQua * 2;
})
.then((ketQua) => { console.log(ketQua); // 2 return ketQua * 3;
})
.then((ketQua) => { console.log(ketQua); // 6 return ketQua * 4;
});
8. async/await là gì?
async/await là một cách tiếp cận hiện đại để xử lý code bất đồng bộ trong JavaScript. Nó cung cấp một cách viết ngắn gọn và dễ đọc hơn để làm việc với Promise và các hoạt động bất đồng bộ, giúp tránh được "Callback Hell" và cải thiện cấu trúc tổng thể của code bất đồng bộ.
Trong JavaScript, từ khóa async
được sử dụng để định nghĩa một hàm bất đồng bộ, hàm này sẽ trả về một Promise. Trong một hàm async
, từ khóa await
được sử dụng để "tạm dừng" việc thực thi hàm cho đến khi một Promise được giải quyết, cho phép viết code bất đồng bộ trông giống như code đồng bộ.
Ví dụ sử dụng async/await:
async function layDuLieu() { try { const response = await fetch('https://api.example.com/data'); const duLieu = await response.json(); return duLieu; } catch (loi) { throw loi; }
} // Sử dụng hàm async với Promise
layDuLieu() .then((duLieu) => { // Xử lý dữ liệu đã lấy được console.log(duLieu); }) .catch((loi) => { // Xử lý lỗi console.error(loi); });
Trong ví dụ này, hàm layDuLieu
được định nghĩa là một hàm async
, và nó sử dụng từ khóa await
để tạm dừng việc thực thi và đợi các hoạt động fetch
và json
hoàn thành, cho phép làm việc với Promise theo cách giống như code đồng bộ.
9. Sự khác biệt giữa toán tử == và === là gì?
Trong JavaScript, ==
(toán tử so sánh lỏng lẻo) và ===
(toán tử so sánh nghiêm ngặt) là hai toán tử so sánh với những đặc điểm khác nhau:
-
== (Toán tử so sánh lỏng lẻo):
- Thực hiện chuyển đổi kiểu dữ liệu trước khi so sánh.
- So sánh giá trị sau khi đã chuyển đổi kiểu.
- Có thể dẫn đến kết quả không mong muốn do chuyển đổi kiểu ngầm định.
-
=== (Toán tử so sánh nghiêm ngặt):
- Không thực hiện chuyển đổi kiểu dữ liệu.
- So sánh cả giá trị và kiểu dữ liệu.
- Cho kết quả chính xác hơn và dễ đoán định hơn.
Ví dụ minh họa sự khác biệt:
console.log(0 == false); // true (0 được chuyển thành false)
console.log(0 === false); // false (kiểu dữ liệu khác nhau) console.log(1 == "1"); // true (chuyển đổi kiểu)
console.log(1 === "1"); // false (kiểu dữ liệu khác nhau) console.log(null == undefined); // true
console.log(null === undefined); // false console.log('0' == false); // true (chuyển đổi kiểu)
console.log('0' === false); // false (kiểu dữ liệu khác nhau)
Nhìn chung, việc sử dụng ===
được khuyến khích hơn vì nó giúp tránh những so sánh không mong muốn do chuyển đổi kiểu ngầm định, giúp code an toàn và dễ đoán định hơn.
10. Các cách khác nhau để tạo Object trong JavaScript?
Trong JavaScript, có nhiều cách để tạo đối tượng. Dưới đây là một số phương pháp phổ biến:
a) Object Literals: Cách đơn giản nhất để tạo đối tượng là sử dụng object literals, định nghĩa các thuộc tính và phương thức của đối tượng trong một danh sách được phân tách bằng dấu phẩy và được bao quanh bởi dấu ngoặc nhọn.
let nguoi = { ho: 'Nguyễn', ten: 'Văn A', chaoHoi: function() { return 'Xin chào, tôi là ' + this.ho + ' ' + this.ten; }
};
b) Constructor Function: Hàm constructor có thể được sử dụng để tạo nhiều thể hiện của một đối tượng với từ khóa new
. Bên trong hàm constructor, các thuộc tính và phương thức được gán cho từ khóa this
.
function Nguoi(ho, ten) { this.ho = ho; this.ten = ten; this.chaoHoi = function() { return 'Xin chào, tôi là ' + this.ho + ' ' + this.ten; };
} let nguoi1 = new Nguoi('Nguyễn', 'Văn A');
let nguoi2 = new Nguoi('Trần', 'Thị B');
c) Object.create(): Phương thức Object.create()
cho phép bạn tạo một đối tượng mới với một đối tượng prototype được chỉ định. Phương thức này cung cấp nhiều quyền kiểm soát hơn đối với prototype của đối tượng mới được tạo.
let nguoiProto = { chaoHoi: function() { return 'Xin chào, tôi là ' + this.ho + ' ' + this.ten; }
}; let nguoi = Object.create(nguoiProto);
nguoi.ho = 'Nguyễn';
nguoi.ten = 'Văn A';
d) Class Syntax (ES6): Với sự ra đời của ES6, JavaScript hỗ trợ cú pháp class để định nghĩa đối tượng bằng từ khóa class
. Điều này cung cấp một cách quen thuộc và có cấu trúc hơn để tạo đối tượng và định nghĩa các thuộc tính và phương thức của chúng.
class Nguoi { constructor(ho, ten) { this.ho = ho; this.ten = ten; } chaoHoi() { return 'Xin chào, tôi là ' + this.ho + ' ' + this.ten; }
} let nguoi = new Nguoi('Nguyễn', 'Văn A');
e) Factory Functions: Factory functions là các hàm trả về một đối tượng. Phương pháp này cho phép bạn đóng gói quá trình tạo đối tượng và dễ dàng tạo nhiều thể hiện với các thuộc tính tùy chỉnh.
function taoNguoi(ho, ten) { return { ho: ho, ten: ten, chaoHoi: function() { return 'Xin chào, tôi là ' + this.ho + ' ' + this.ten; } };
} let nguoi1 = taoNguoi('Nguyễn', 'Văn A');
let nguoi2 = taoNguoi('Trần', 'Thị B');
Mỗi phương pháp có ưu và nhược điểm riêng, và việc lựa chọn phương pháp phù hợp phụ thuộc vào yêu cầu cụ thể của dự án và phong cách lập trình của bạn.
11. Rest và spread operator là gì?
Rest operator và spread operator trong JavaScript đều được biểu diễn bằng ba dấu chấm (...), nhưng chúng có các mục đích sử dụng khác nhau:
Rest Operator: Rest operator được sử dụng trong tham số của hàm để thu thập một số lượng biến đổi các đối số vào một mảng. Nó cho phép bạn truyền một số lượng tùy ý các đối số vào hàm mà không cần định nghĩa chúng như các tham số có tên.
Ví dụ:
function tinhTong(...cacSo) { return cacSo.reduce((tong, so) => tong + so, 0);
} console.log(tinhTong(1, 2, 3, 4)); // Kết quả: 10
Spread Operator: Spread operator được sử dụng để trải rộng các phần tử của một mảng hoặc đối tượng vào một mảng hoặc đối tượng khác. Nó cho phép bạn dễ dàng sao chép mảng, nối mảng và hợp nhất đối tượng.
Ví dụ với mảng:
const mang1 = [1, 2, 3];
const mang2 = [4, 5, 6];
const mangKetHop = [...mang1, ...mang2];
console.log(mangKetHop); // Kết quả: [1, 2, 3, 4, 5, 6]
Ví dụ với đối tượng:
const obj1 = { a: 1, b: 2 };
const obj2 = { b: 3, c: 4 };
const objKetHop = { ...obj1, ...obj2 };
console.log(objKetHop); // Kết quả: { a: 1, b: 3, c: 4 }
12. Higher-order function là gì?
Higher-order function trong JavaScript là một hàm mà hoặc nhận một hoặc nhiều hàm làm đối số, hoặc trả về một hàm như kết quả của nó. Nói cách khác, nó hoạt động trên các hàm, hoặc bằng cách lấy chúng làm đối số, trả về chúng, hoặc cả hai.
Ví dụ về higher-order function:
function thaoTacTrenMang(mang, thaoTac) { let ketQua = []; for (let phanTu of mang) { ketQua.push(thaoTac(phanTu)); } return ketQua;
} function nhanDoi(x) { return x * 2;
} let soNguyen = [1, 2, 3, 4];
let ketQuaNhanDoi = thaoTacTrenMang(soNguyen, nhanDoi);
console.log(ketQuaNhanDoi); // Kết quả: [2, 4, 6, 8]
Higher-order functions cho phép các kỹ thuật mạnh mẽ như kết hợp hàm, currying và các hoạt động bất đồng bộ dựa trên callback. Hiểu về higher-order functions là cần thiết để viết code JavaScript theo phong cách hàm và biểu đạt.
Cấp độ 2: Trung cấp
13. Closure là gì? Các trường hợp sử dụng của Closure?
Closure là một tính năng cho phép hàm có thể "nhớ" và truy cập vào môi trường (hoặc giữ quyền truy cập vào các biến từ phạm vi) nơi nó được định nghĩa, ngay cả sau khi phạm vi đó đã đóng. Có thể nói closure là sự kết hợp giữa một hàm và môi trường từ vựng nơi hàm đó được định nghĩa.
Nói cách khác, closure cho phép một hàm truy cập vào phạm vi của chính nó, phạm vi của hàm bên ngoài và phạm vi toàn cục, cho phép nó "nhớ" và tiếp tục truy cập các biến và tham số từ các phạm vi này.
function hamBenNgoai() { let bienBenNgoai = 'Tôi đến từ hàm bên ngoài'; return function hamBenTrong() { console.log(bienBenNgoai); // Truy cập bienBenNgoai từ phạm vi của hàm bên ngoài }
} let hamCuaToi = hamBenNgoai();
hamCuaToi(); // Kết quả: Tôi đến từ hàm bên ngoài
Closure được tạo ra mỗi khi một hàm được tạo tại thời điểm định nghĩa hàm và khi bạn định nghĩa một hàm bên trong một hàm khác.
Closure có một số trường hợp sử dụng quan trọng trong JavaScript:
-
Bảo mật dữ liệu và đóng gói: Closure có thể được sử dụng để tạo dữ liệu riêng tư và đóng gói chức năng trong một phạm vi hạn chế. Bằng cách định nghĩa các hàm bên trong một hàm khác, các hàm bên trong có quyền truy cập vào các biến của hàm bên ngoài, nhưng các biến này không thể truy cập trực tiếp từ bên ngoài hàm bên ngoài. Điều này cho phép tạo ra dữ liệu và phương thức riêng tư không thể truy cập trực tiếp từ bên ngoài, từ đó tăng cường tính bảo mật và đóng gói dữ liệu.
Ví dụ:
function taoCounter() { let counter = 0; return function() { return ++counter; } } let counter = taoCounter(); console.log(counter()); // Kết quả: 1 console.log(counter()); // Kết quả: 2 // Không thể truy cập trực tiếp vào biến counter từ bên ngoài // Chỉ có thể tăng giá trị của counter bằng cách gọi hàm counter
-
Duy trì trạng thái: Closure thường được sử dụng để duy trì trạng thái trong các hoạt động bất đồng bộ và xử lý sự kiện. Ví dụ, khi xử lý các tác vụ bất đồng bộ, closure có thể nắm bắt và giữ lại trạng thái của các biến qua nhiều hoạt động bất đồng bộ, đảm bảo rằng các biến chính xác được truy cập khi các tác vụ bất đồng bộ hoàn thành.
Ví dụ:
function demThoiGian() { let startTime = Date.now(); return function() { return Date.now() - startTime; } } let timer = demThoiGian(); console.log(timer()); // Kết quả: thời gian tính từ khi gọi hàm timer // Thời gian có thể được duy trì qua nhiều lần gọi hàm timer // mà không cần biến startTime được truyền qua các lần gọi
-
Currying và Partial Application: Closure tạo điều kiện cho các kỹ thuật lập trình hàm như currying và partial application. Bằng cách sử dụng closure để nắm bắt và ghi nhớ các tham số cụ thể và trả về một hàm mới sử dụng các tham số đã nắm bắt này, có thể thực hiện currying và partial application. Điều này cho phép tạo ra các hàm chuyên biệt với các đối số được thiết lập trước, cung cấp tính linh hoạt và khả năng tái sử dụng.
Ví dụ:
function tinhTong(a) { return function(b) { return a + b; } } let tinhTong5 = tinhTong(5); console.log(tinhTong5(3)); // Kết quả: 8 // Hàm tinhTong5 chỉ cần một tham số để thực hiện tác vụ cộng // với giá trị a đã được thiết lập trước
-
Mẫu Module: Closure là yếu tố cần thiết trong việc triển khai mẫu module trong JavaScript. Bằng cách sử dụng closure để tạo các biến riêng tư và chỉ hiển thị các phương thức công khai cần thiết, các nhà phát triển có thể tạo ra mã có tính module và có tổ chức, ngăn chặn việc truy cập và sửa đổi không mong muốn vào dữ liệu nội bộ của module.
Ví dụ:
let module = (function() { let bienRiengTu = 'Dữ liệu riêng tư'; return { phuongThucCongKhai: function() { return bienRiengTu; } }; })(); console.log(module.phuongThucCongKhai()); // Kết quả: Dữ liệu riêng tư // Không thể truy cập trực tiếp vào biến bienRiengTu từ bên ngoài module
-
Hàm Callback: Closure thường được sử dụng khi làm việc với các hàm callback. Một closure có thể được sử dụng để nắm bắt và duy trì trạng thái của các biến trong ngữ cảnh của một hoạt động bất đồng bộ, đảm bảo rằng các biến chính xác có thể truy cập được khi hàm callback được gọi.
Chú ý: Những điểm cần chú ý để không vô tình tạo ra các closure không mong muốn bao gồm việc tránh lặp lại các biến trong vòng lặp, tránh thay đổi giá trị của biến bên ngoài trong closure, và giữ cho closure nhỏ và dễ đọc để tránh các vấn đề về hiệu suất và bảo trì. Nếu thật sự cần, hãy chắc chắn rằng bạn hiểu rõ cách hoạt động của closure và cách quản lý chúng để tránh các vấn đề như leak memory...
14. Giải thích khái niệm hoisting trong JavaScript
Hoisting trong JavaScript là hành vi mặc định trong đó các khai báo biến và hàm được di chuyển lên đầu phạm vi chứa chúng trong giai đoạn biên dịch, trước khi thực thi mã thực tế. Điều này có nghĩa là bạn có thể sử dụng một biến hoặc gọi một hàm trước khi nó được khai báo trong mã của bạn.
Khi bạn khai báo một biến bằng var
, khai báo được đưa lên đầu hàm hoặc khối chứa nó và được khởi tạo với giá trị mặc định là "undefined".
console.log(x); // Kết quả: undefined
var x = 5;
Các biến được khai báo bằng let
và const
cũng được hoisting, nhưng chúng có một "temporal dead zone" (vùng chết tạm thời) trong đó chúng không thể truy cập được trước khi khai báo.
console.log(x); // Ném ra lỗi (ReferenceError)
let x = 5;
Khai báo hàm cũng được hoisting lên đầu phạm vi chứa chúng. Bạn có thể gọi một hàm trước khi nó được khai báo trong mã của bạn.
chaoHoi(); // Kết quả: "Xin chào, thế giới!" function chaoHoi() { console.log("Xin chào, thế giới!");
}
Hoisting không xảy ra với hàm mũi tên, biểu thức hàm, hoặc khởi tạo biến.
15. Temporal Dead Zone là gì?
Temporal Dead Zone (TDZ) là một khái niệm trong JavaScript liên quan đến khai báo biến bằng let
và const
. Khi bạn khai báo một biến bằng let
hoặc const
, nó được hoisting lên đầu phạm vi chứa nó. Tuy nhiên, không giống như var
, các biến được khai báo bằng let
và const
vẫn chưa được khởi tạo trong TDZ. Bất kỳ nỗ lực nào để truy cập hoặc sử dụng biến trước khi khai báo thực tế của nó trong phạm vi sẽ dẫn đến một ReferenceError.
Điều này nhằm ngăn chặn việc sử dụng các biến trước khi chúng được định nghĩa đúng cách. Hiểu về Temporal Dead Zone là quan trọng vì nó giúp ngăn chặn các lỗi liên quan đến việc sử dụng biến trước khi khởi tạo. Nó cũng khuyến khích các phương pháp tốt nhất trong lập trình JavaScript bằng cách khuyến khích khai báo biến đúng cách trước khi sử dụng.
16. Chuỗi prototype là gì? và phương thức Object.create()?
Trong JavaScript, mọi hàm và đối tượng đều có một thuộc tính tên là prototype theo mặc định. Mọi đối tượng trong JavaScript đều có một prototype. Một prototype là một đối tượng khác mà đối tượng hiện tại kế thừa các thuộc tính và phương thức từ đó. Bạn có thể nghĩ về prototype như một mẫu hoặc một đối tượng cha.
Chuỗi prototype là một cơ chế cho phép các đối tượng kế thừa các thuộc tính và phương thức từ các đối tượng khác. Khi bạn truy cập một thuộc tính hoặc phương thức trên một đối tượng, JavaScript đầu tiên tìm kiếm nó trên chính đối tượng đó. Nếu không tìm thấy, nó sẽ tìm kiếm lên chuỗi prototype cho đến khi tìm thấy thuộc tính hoặc phương thức đó. Quá trình này tiếp tục cho đến khi nó đạt đến Object.prototype ở đỉnh của chuỗi.
Phương thức Object.create()
cho phép bạn tạo một đối tượng mới với một đối tượng prototype được chỉ định. Điều này cung cấp nhiều quyền kiểm soát hơn đối với prototype của đối tượng mới được tạo.
let nguoiProto = { chaoHoi: function() { return 'Xin chào, tôi là ' + this.ho + ' ' + this.ten; }
}; let nguoi = Object.create(nguoiProto);
nguoi.ho = 'Nguyễn';
nguoi.ten = 'Văn A'; console.log(nguoi.chaoHoi()); // Kết quả: Xin chào, tôi là Nguyễn Văn A
17. Sự khác biệt giữa các phương thức Call, Apply và Bind là gì?
Call, Apply và Bind là ba phương thức trong JavaScript được sử dụng để thao tác với ngữ cảnh this
của một hàm. Chúng cho phép bạn gọi một hàm với một giá trị this
cụ thể và truyền các đối số.
-
Call: Phương thức
call()
gọi một hàm với một giá trịthis
được chỉ định và các đối số được truyền riêng lẻ dưới dạng các giá trị phân tách bằng dấu phẩy.const nguoi1 = { ten: 'Nguyễn Văn A' }; const nguoi2 = { ten: 'Trần Thị B' }; function chaoHoi(loiChao) { console.log(loiChao + ' ' + this.ten); } chaoHoi.call(nguoi1, 'Xin chào'); // Kết quả: Xin chào Nguyễn Văn A chaoHoi.call(nguoi2, 'Chào'); // Kết quả: Chào Trần Thị B
-
Apply: Phương thức
apply()
tương tự nhưcall()
, nhưng nó chấp nhận các đối số dưới dạng một mảng.const nguoi1 = { ten: 'Nguyễn Văn A' }; const nguoi2 = { ten: 'Trần Thị B' }; function chaoHoi(loiChao) { console.log(loiChao + ' ' + this.ten); } chaoHoi.apply(nguoi1, ['Xin chào']); // Kết quả: Xin chào Nguyễn Văn A chaoHoi.apply(nguoi2, ['Chào']); // Kết quả: Chào Trần Thị B // Ví dụ khác: const soLieu = [1, 2, 3, 4, 5]; const max = Math.max.apply(null, soLieu); console.log(max); // Kết quả: 5
-
Bind: Phương thức
bind()
tạo ra một hàm mới với một giá trịthis
được ràng buộc và cho phép bạn truyền bất kỳ số lượng đối số nào.const module = { x: 42, getX: function() { return this.x; } }; const layXKhongRangBuoc = module.getX; console.log(layXKhongRangBuoc()); // Kết quả: undefined const layXRangBuoc = layXKhongRangBuoc.bind(module); console.log(layXRangBuoc()); // Kết quả: 42
18. Lambda hoặc hàm mũi tên là gì?
Trong JavaScript, có hai loại hàm: Hàm thông thường và Hàm mũi tên (được giới thiệu trong ES6).
Hàm mũi tên, còn được gọi là lambda function, là một tính năng được giới thiệu trong JavaScript (ES6) cung cấp một cú pháp ngắn gọn hơn để viết biểu thức hàm. Chúng có cú pháp ngắn gọn hơn so với biểu thức hàm truyền thống và đặc biệt hữu ích cho việc tạo các hàm ẩn danh và làm việc với các khái niệm lập trình hàm.
Có một số điểm khác biệt giữa Hàm mũi tên và Hàm thông thường:
- Cú pháp
- Không có đối tượng
arguments
(arguments là đối tượng giống mảng) - Không có đối tượng prototype cho Hàm mũi tên
- Không thể gọi với từ khóa
new
(Không phải là hàm constructor) - Không có
this
riêng (call, apply và bind không hoạt động như mong đợi) - Không thể sử dụng làm Generator function
- Không cho phép các tham số trùng tên
Ví dụ về hàm mũi tên:
// Hàm mũi tên cơ bản
const chaoHoi = (ten) => { return `Xin chào, ${ten}!`;
}; // Hàm mũi tên với một biểu thức
const binhPhuong = x => x * x; // Hàm mũi tên với nhiều tham số và nhiều câu lệnh
const tinhTong = (a, b) => { let tong = a + b; return tong;
}; console.log(tinhTong(5, 3)); // Kết quả: 8
19. Hàm currying là gì?
Currying là một kỹ thuật trong lập trình hàm, biến đổi một hàm có nhiều tham số thành một chuỗi các hàm, mỗi hàm chỉ nhận một tham số duy nhất. Các hàm đã được curried có thể được kết hợp lại để xây dựng các hàm phức tạp hơn. Trong JavaScript, bạn có thể thực hiện currying bằng cách sử dụng closure và trả về các hàm.
// Hàm thông thường nhận hai tham số
function cong(x, y) { return x + y;
} // Phiên bản curried của hàm
function congCurry(x) { return function(y) { return x + y; };
} const cong5 = congCurry(5); // Áp dụng một phần, tạo ra một hàm mới
console.log(cong5(3)); // Kết quả: 8
Currying rất hữu ích trong lập trình hàm (Functional Programming) và có thể làm cho code trở nên module hóa và tái sử dụng hơn. Nó đặc biệt hữu ích trong các tình huống khi bạn muốn tạo các hàm với số lượng tham số thay đổi hoặc xây dựng các pipeline xử lý dữ liệu.
20. Các tính năng của ES6 là gì?
ES6, còn được gọi là ECMAScript 2015, đã giới thiệu nhiều tính năng và cải tiến mới cho JavaScript, mở rộng đáng kể khả năng của ngôn ngữ. Một số tính năng chính của ES6 bao gồm:
-
Arrow Functions: Cú pháp ngắn gọn hơn để định nghĩa hàm.
-
Block-Scoped Variables: Từ khóa
let
vàconst
để khai báo biến với phạm vi khối. -
Classes: Cú pháp class để định nghĩa đối tượng và kế thừa.
-
Modules: Hệ thống module tích hợp để tổ chức và chia sẻ code.
-
Template Literals: Cho phép nhúng biểu thức và chuỗi nhiều dòng sử dụng dấu backtick.
-
Default Parameters: Khả năng đặt giá trị mặc định cho tham số hàm.
-
Rest và Spread Operators: Cho phép làm việc với số lượng tham số không xác định.
-
Destructuring Assignment: Cú pháp để trích xuất dữ liệu từ mảng hoặc đối tượng.
-
Promises: Cơ chế xử lý bất đồng bộ cải tiến.
-
Map, Set, WeakMap, WeakSet: Các cấu trúc dữ liệu mới để xử lý tập hợp và cặp key-value hiệu quả hơn.
-
Iterators và Generators: Cơ chế mới để duyệt qua các tập hợp dữ liệu.
-
Enhanced Object Literals: Cú pháp ngắn gọn hơn để định nghĩa đối tượng.
Ví dụ về một số tính năng ES6:
// 1. Arrow function
const chaoHoi = (ten) => `Xin chào, ${ten}!`; // 2. Block-scoped variables
let x = 5;
if (true) { let x = 10; console.log(x); // Kết quả: 10
}
console.log(x); // Kết quả: 5 // 3. Classes
class Nguoi { constructor(ten) { this.ten = ten; } chaoHoi() { return `Xin chào, ${this.ten}!`; }
} // 4. Modules
// file: mymodule.js
export const PI = 3.14;
export function double(x) { return x * 2;
} // file: app.js
import { PI, double } from './mymodule'; // 5. Template literals
const ten = 'Nguyễn Văn A';
const chaoHoi = `Xin chào, ${ten}!`; // 6. Default parameters
function cong(x, y = 0) { return x + y;
} // 7. Rest and spread operators
function tinhTong(...so) { return so.reduce((tong, so) => tong + so, 0);
} const so = [1, 2, 3, 4, 5];
console.log(tinhTong(...so)); // Kết quả: 15 // 8. Destructuring assignment
const [a, b] = [1, 2]; // 9. Promises
const promise = new Promise((resolve, reject) => { setTimeout(() => resolve('Hoàn thành!'), 1000);
}); promise.then(console.log); // Kết quả: Hoàn thành! // 10. Map, Set, WeakMap, WeakSet
const set = new Set([1, 2, 3, 4, 5]);
set.add(6); // 11. Iterators and Generators
const iterable = { [Symbol.iterator]() { let step = 0; return { next() { step++; if (step <= 5) { return { value: step, done: false }; } return { value: undefined, done: true }; } }; }
}; for (const value of iterable) { console.log(value); // Kết quả: 1, 2, 3, 4, 5
} // 12. Enhanced object literals
const ten = 'Nguyễn Văn A';
const nguoi = { ten, chaoHoi() { return `Xin chào, ${this.ten}!`; }
}; console.log(nguoi.chaoHoi()); // Kết quả: Xin chào, Nguyễn Văn A!
Hiểu và sử dụng thành thạo các tính năng ES6 sẽ giúp bạn viết code JavaScript hiện đại, sạch sẽ và hiệu quả hơn.
Cấp độ 3: Nâng cao
21. Execution context, execution stack, variable object, và scope chain là gì?
-
Execution Context (Ngữ cảnh thực thi): Trong JavaScript, execution context là môi trường mà code được thực thi. Nó bao gồm phạm vi, biến object, và giá trị của từ khóa "this". Có ba loại execution context trong JavaScript:
- Global Execution Context
- Functional Execution Context
- Eval Function Execution Context
-
Execution Stack (Ngăn xếp thực thi): Còn được gọi là "call stack", là một cấu trúc dữ liệu LIFO (Last In, First Out) lưu trữ tất cả các execution context của các lời gọi hàm đang trong quá trình thực thi. Khi một hàm được gọi, một execution context mới được tạo ra và đẩy vào ngăn xếp. Khi hàm hoàn thành, context của nó được lấy ra khỏi ngăn xếp.
-
Variable Object (Đối tượng biến): Là một phần của execution context chứa tất cả các biến, khai báo hàm, và tham số được định nghĩa trong context đó.
-
Scope Chain (Chuỗi phạm vi): Là cơ chế để giải quyết giá trị của một biến trong JavaScript. Khi một biến được tham chiếu, JavaScript engine tìm kiếm biến đó trước tiên trong variable object của execution context hiện tại. Nếu không tìm thấy, nó tiếp tục tìm kiếm trong execution context bên ngoài tiếp theo, theo chuỗi phạm vi, cho đến khi tìm thấy biến hoặc đạt đến global execution context.
Ví dụ minh họa:
let globalVar = "Tôi là biến toàn cục"; function outerFunction() { let outerVar = "Tôi là biến của hàm ngoài"; function innerFunction() { let innerVar = "Tôi là biến của hàm trong"; console.log(innerVar); // Tìm trong variable object hiện tại console.log(outerVar); // Tìm trong scope chain console.log(globalVar); // Tìm trong global execution context } innerFunction();
} outerFunction();
Trong ví dụ này, khi innerFunction
được gọi, JavaScript engine sẽ tìm kiếm các biến theo thứ tự: variable object của innerFunction
, sau đó là outerFunction
, và cuối cùng là global execution context.
22. Thứ tự ưu tiên thực thi của callback, promise, setTimeout, process.nextTick() là gì?
Thứ tự ưu tiên thực thi có thể được hiểu dựa trên event loop và thứ tự mà các hoạt động bất đồng bộ khác nhau được xử lý:
-
process.nextTick(): Các callback được lên lịch bằng
process.nextTick()
có độ ưu tiên cao nhất. Khi bạn sử dụngprocess.nextTick()
, callback sẽ được thực thi ngay sau khi hoạt động hiện tại hoàn thành nhưng trước khi event loop chuyển sang giai đoạn tiếp theo. Điều này làm cho nó trở thành một cách để đảm bảo rằng một hàm được thực thi ở thời điểm sớm nhất có thể trong event loop. -
Promise: Promises thường được thực thi sau
process.nextTick()
. Tuy nhiên, chúng được ưu tiên hơn các callback được lên lịch vớisetTimeout()
. -
setTimeout(): Các callback được lên lịch với
setTimeout()
được đặt trong giai đoạn timer của event loop. Chúng sẽ được thực thi sau khi hoạt động hiện tại, promises, và bất kỳ callbacksetTimeout()
nào đã được lên lịch trước đó đã hoàn thành. -
Callback: Các callback thông thường (không được lên lịch bằng
process.nextTick()
) có độ ưu tiên thấp nhất. Chúng được thực thi sau khi event loop xử lýprocess.nextTick()
, promises, và các callbacksetTimeout()
.
Ví dụ minh họa:
console.log('Bắt đầu'); setTimeout(() => { console.log('setTimeout');
}, 0); Promise.resolve().then(() => { console.log('Promise');
}); process.nextTick(() => { console.log('nextTick');
}); console.log('Kết thúc');
Kết quả đầu ra sẽ là:
Bắt đầu
Kết thúc
nextTick
Promise
setTimeout
Hiểu rõ về thứ tự ưu tiên này rất quan trọng khi làm việc với các tác vụ bất đồng bộ trong JavaScript, đặc biệt là khi bạn cần kiểm soát chính xác thời điểm các callback hoặc promises được thực thi.
Ví dụ: Khi nào thì sử dụng process.nextTick()? Thường khi mình review code mình thấy một số bạn mới khi tiếp xúc với js và cũng có hiểu chút chút về even loop nên cũng biết sử dụng setTimeout0 để thực hiện render UI trong Angular chẳng hạn. Tuy nhiên có những trường hợp có nhiều hơn 1 logic cần thực hiện sau khi render UI xong, thì thường các bạn đó sẽ sắp xếp kiểu setTimeout(...,0), setTimeout(...,1), setTimeout(...,2) để đảm bảo thứ tự thực thi logic (Cách này không sai nhé). Nhưng thực tế thì không cần thiết, vì có process.nextTick().
23. Factory function và generator function là gì?
Factory Function:
Factory function trong JavaScript là một hàm trả về một đối tượng. Đây là một mẫu được sử dụng để tạo đối tượng một cách đơn giản và có tổ chức. Thay vì sử dụng hàm constructor và từ khóa new
để tạo đối tượng mới, factory function đóng gói quá trình tạo đối tượng và trả về một đối tượng mới.
Ví dụ về factory function:
function taoNguoi(ho, ten) { return { ho: ho, ten: ten, chaoHoi: function() { return `Xin chào, tôi là ${this.ho} ${this.ten}.`; } };
} const nguoi1 = taoNguoi('Nguyễn', 'Văn A');
const nguoi2 = taoNguoi('Trần', 'Thị B'); console.log(nguoi1.chaoHoi()); // Kết quả: Xin chào, tôi là Nguyễn Văn A.
console.log(nguoi2.chaoHoi()); // Kết quả: Xin chào, tôi là Trần Thị B.
Generator Function:
Generator function trong JavaScript là một loại hàm đặc biệt có thể được tạm dừng và tiếp tục trong quá trình thực thi. Một generator function tạo ra một chuỗi kết quả thay vì một giá trị duy nhất. Khi một generator function được gọi, nó trả về một đối tượng generator có thể được sử dụng để kiểm soát việc thực thi hàm bằng cách gọi phương thức next()
. Code trong hàm có thể được tạm dừng bên trong thân hàm bằng từ khóa yield
, và sau đó có thể tiếp tục từ chính xác điểm nơi nó đã tạm dừng.
Ví dụ về generator function:
function* taoSoNguyenTo() { yield 2; yield 3; yield 5; yield 7;
} const generator = taoSoNguyenTo(); console.log(generator.next().value); // Kết quả: 2
console.log(generator.next().value); // Kết quả: 3
console.log(generator.next().value); // Kết quả: 5
console.log(generator.next().value); // Kết quả: 7
Trong ví dụ này, taoSoNguyenTo
là một generator function tạo ra một chuỗi các số nguyên tố. Mỗi lần gọi next()
, generator sẽ trả về giá trị tiếp theo trong chuỗi.
Generator functions đặc biệt hữu ích khi làm việc với các tập dữ liệu lớn hoặc vô hạn, vì chúng cho phép bạn tạo ra các giá trị theo yêu cầu thay vì phải tính toán và lưu trữ tất cả các giá trị cùng một lúc.
24. Các cách khác nhau để clone (Shallow và deep copy của object) một đối tượng?
Trong JavaScript, có nhiều cách để tạo bản sao của một đối tượng. Chúng ta có thể phân loại thành hai loại chính: shallow copy (sao chép nông) và deep copy (sao chép sâu).
Shallow Copy (Sao chép nông):
Shallow copy tạo ra một đối tượng mới với các tham chiếu giống như đối tượng gốc. Điều này có nghĩa là nếu bạn thay đổi giá trị của một thuộc tính trong bản sao nông, nó cũng sẽ thay đổi giá trị của thuộc tính trong đối tượng gốc (đối với các thuộc tính là đối tượng hoặc mảng).
const nguoiDung = { ten: "Nguyễn Văn A", tuoi: 28, congViec: "Lập trình viên Web"
}
const banSao = nguoiDung;
Deep Copy (Sao chép sâu):
Deep copy tạo ra một bản sao hoàn toàn mới của đối tượng, bao gồm cả các đối tượng lồng nhau. Khi bạn thay đổi giá trị của một thuộc tính trong bản sao sâu, nó sẽ không ảnh hưởng đến đối tượng gốc.
Có nhiều cách để tạo deep copy của một đối tượng:
a) JSON.parse và JSON.stringify: Hữu ích cho cả đối tượng lồng nhau.
const doiTuongGoc = { ten: "Nguyễn Văn A", tuoi: 25 };
const banSaoSau = JSON.parse(JSON.stringify(doiTuongGoc));
b) structuredClone:
const banSaoSau = structuredClone(doiTuongGoc);
c) Spread Operator (...): Lưu ý rằng bất kỳ đối tượng nào có đối tượng lồng nhau sẽ không được sao chép sâu.
const doiTuongGoc = { ten: "Nguyễn Văn A", tuoi: 25 };
const banSaoSau = {...doiTuongGoc};
d) Object.assign(): Phương thức Object.assign() nên được sử dụng để sao chép sâu các đối tượng không có đối tượng lồng nhau.
const doiTuongGoc = { ten: "Nguyễn Văn A", tuoi: 25 };
const banSaoNong = Object.assign({}, doiTuongGoc);
e) Đệ quy:
function saoChepSau(obj) { if (typeof obj !== 'object' || obj === null) { return obj; } const objMoi = Array.isArray(obj) ? [] : {}; for (let key in obj) { if (Object.hasOwnProperty.call(obj, key)) { objMoi[key] = saoChepSau(obj[key]); } } return objMoi;
} const doiTuongGoc = { ten: "Nguyễn Văn A", lonNhau: { tuoi: 25 } };
const banSaoSau = saoChepSau(doiTuongGoc);
25. Làm thế nào để tạo một đối tượng bất biến? (phương thức seal và freeze)
Trong JavaScript, bạn có thể tạo một đối tượng bất biến bằng cách sử dụng các phương thức Object.seal()
và Object.freeze()
.
Object.freeze(): (Hoàn toàn bất biến) Phương thức này đóng băng một đối tượng, làm cho nó vừa được niêm phong vừa đánh dấu tất cả các thuộc tính của nó là chỉ đọc. Sau khi đóng băng một đối tượng, các thuộc tính của nó không thể bị sửa đổi, thêm mới hoặc xóa bỏ.
const obj = { ten: 'Nguyễn Văn A', tuoi: 25 };
Object.freeze(obj); obj.ten = 'Trần Thị B'; // Không được phép
obj.diaChi = '123 Đường ABC'; // Không được phép
delete obj.tuoi; // Không được phép console.log(obj); // { ten: 'Nguyễn Văn A', tuoi: 25 }
Object.seal(): (Bất biến một phần) Phương thức này niêm phong một đối tượng, ngăn chặn việc thêm thuộc tính mới và đánh dấu tất cả các thuộc tính hiện có là không thể cấu hình. Tuy nhiên, bạn vẫn có thể sửa đổi giá trị của các thuộc tính hiện có nếu chúng có thể ghi được.
const obj = { ten: 'Nguyễn Văn A', tuoi: 25 };
Object.seal(obj); obj.ten = 'Trần Thị B'; // Được phép
obj.diaChi = '123 Đường ABC'; // Không được phép (không thể thêm thuộc tính mới)
delete obj.tuoi; // Không được phép (không thể xóa thuộc tính hiện có) console.log(obj); // { ten: 'Trần Thị B', tuoi: 25 }
26. Event và event flow, event bubbling và event capturing là gì?
Trong JavaScript, Event flow là thứ tự mà một sự kiện như click hoặc keypress được nhận trên trang web hoặc được xử lý bởi trình duyệt web. Có hai giai đoạn trong event flow: event capturing và event bubbling.
Khi bạn click vào một phần tử được lồng trong nhiều phần tử khác, trước khi click của bạn thực sự đến được phần tử đích hoặc phần tử mục tiêu, nó phải kích hoạt sự kiện click cho mỗi phần tử cha của nó trước, bắt đầu từ trên cùng với đối tượng window toàn cục.
Event Capturing (Bắt sự kiện): Trong giai đoạn này, sự kiện đi từ phần tử cha ngoài cùng xuống phần tử con trong cùng. Đây là giai đoạn đầu tiên của event flow.
Event Bubbling (Nổi bọt sự kiện): Sau khi sự kiện đã đến phần tử mục tiêu, nó "nổi bọt" lên trở lại qua các phần tử cha, kích hoạt bất kỳ trình xử lý sự kiện nào được gắn vào các phần tử đó.
Ví dụ minh họa:
<div id="cha"> <div id="con"> <button id="nut">Click me!</button> </div>
</div> <script>
document.getElementById('cha').addEventListener('click', function() { console.log('Cha được click');
}, true); // Capturing phase document.getElementById('con').addEventListener('click', function() { console.log('Con được click');
}, true); // Capturing phase document.getElementById('nut').addEventListener('click', function() { console.log('Nút được click');
}); // Bubbling phase (mặc định) // Khi click vào nút, thứ tự log sẽ là:
// 1. "Cha được click"
// 2. "Con được click"
// 3. "Nút được click"
</script>
Trong ví dụ này, khi bạn click vào nút, sự kiện sẽ đi qua giai đoạn capturing từ cha
đến con
, sau đó đến nút
, và cuối cùng nổi bọt ngược lại.
27. Event delegation là gì?
Event delegation (ủy quyền sự kiện) là một kỹ thuật trong JavaScript để xử lý sự kiện hiệu quả. Thay vì gắn một trình xử lý sự kiện cho mỗi phần tử con, bạn gắn một trình xử lý sự kiện duy nhất cho một phần tử cha. Trình xử lý sự kiện này sau đó sử dụng thuộc tính target
của đối tượng sự kiện để xác định phần tử nào đã kích hoạt sự kiện.
Event delegation dựa trên nguyên tắc event bubbling, cho phép bạn tận dụng việc các sự kiện "nổi bọt" lên các phần tử cha.
Ưu điểm của event delegation:
- Giảm bộ nhớ sử dụng vì ít trình xử lý sự kiện hơn.
- Giảm thời gian khởi tạo trang.
- Không cần gỡ bỏ trình xử lý sự kiện từ các phần tử đã bị xóa.
- Dễ dàng thêm các phần tử mới mà không cần gắn thêm trình xử lý sự kiện.
Ví dụ minh họa:
<ul id="danhSach"> <li>Mục 1</li> <li>Mục 2</li> <li>Mục 3</li>
</ul> <script>
document.getElementById('danhSach').addEventListener('click', function(e) { if (e.target && e.target.nodeName === 'LI') { console.log('Đã click vào ' + e.target.textContent); }
});
</script>
Trong ví dụ này, thay vì gắn một trình xử lý sự kiện cho mỗi phần tử <li>
, chúng ta gắn một trình xử lý duy nhất cho phần tử <ul>
. Khi một phần tử <li>
được click, sự kiện sẽ nổi bọt lên <ul>
, và trình xử lý sự kiện sẽ kiểm tra xem phần tử mục tiêu có phải là <li>
không trước khi xử lý.
Dựa trên thông tin từ các nguồn tham khảo và yêu cầu của bạn, tôi sẽ thay thế ví dụ trong bài viết của bạn bằng một ví dụ hoàn chỉnh và có thể thực hiện được. Dưới đây là phần viết lại cho mục "Server-sent events là gì?":
28. Server-sent events là gì?
Server-sent events (SSE) là một công nghệ web cho phép máy chủ đẩy dữ liệu đến trình duyệt của người dùng mà không cần trình duyệt yêu cầu nó. Đây là một kênh truyền thông một chiều từ máy chủ đến trình duyệt, thích hợp cho các tình huống cần cập nhật dữ liệu thời gian thực như thông báo, cập nhật giá cổ phiếu, hoặc các bảng điều khiển trực tiếp.
Ưu điểm của SSE:
- Đơn giản hơn và nhẹ hơn so với WebSockets.
- Tự động kết nối lại khi mất kết nối.
- Sử dụng giao thức HTTP tiêu chuẩn.
Dưới đây là một ví dụ đơn giản về cách sử dụng SSE với Node.js và Express:
-
Tạo dự án:
mkdir sse-demo cd sse-demo npm init -y npm install express
-
Tạo file
server.js
:const express = require('express'); const path = require('path'); const app = express(); // Phục vụ các file tĩnh từ thư mục public app.use(express.static(path.join(__dirname, 'public'))); app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.get('/events', function (req, res) { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); const intervalId = setInterval(() => { res.write(`data: ${new Date().toLocaleTimeString()}\n\n`); }, 1000); req.on('close', () => { clearInterval(intervalId); }); }); app.listen(3000, () => console.log('Server đang chạy trên cổng 3000'));
-
Tạo thư mục
public
và fileindex.html
trong đó:<!DOCTYPE html> <html lang="vi"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>SSE Demo</title> <link rel="icon" href="data:,"> </head> <body> <h1>SSE Demo</h1> <div id="output"></div> <script> const eventSource = new EventSource('/events'); const output = document.getElementById('output'); eventSource.onmessage = function (event) { console.log('Nhận được dữ liệu:', event.data); const p = document.createElement('p'); p.textContent = `Thời gian nhận được: ${event.data}`; output.appendChild(p); }; eventSource.onerror = function (error) { console.error('Lỗi SSE:', error); eventSource.close(); }; </script> </body> </html>
Để chạy ví dụ này:
- Đảm bảo bạn đã cài đặt Node.js.
- Tạo một thư mục mới cho dự án và di chuyển vào đó.
- Chạy
npm init -y
để tạo filepackage.json
. - Cài đặt Express bằng lệnh
npm install express
. - Tạo file
server.js
và thư mụcpublic
với fileindex.html
như trên. - Chạy server bằng lệnh
node server.js
. - Mở trình duyệt và truy cập
http://localhost:3000
.
Trong ví dụ này, máy chủ gửi thời gian hiện tại mỗi giây đến trình duyệt của người dùng. Trình duyệt nhận dữ liệu và hiển thị nó trên trang web mà không cần làm mới trang.
Ví dụ này minh họa cách SSE hoạt động, cho phép máy chủ liên tục gửi dữ liệu đến trình duyệt mà không cần yêu cầu từ phía client. Điều này rất hữu ích cho các ứng dụng cần cập nhật dữ liệu thời gian thực.
29. Web worker và Service worker trong JavaScript là gì?
JavaScript thường chạy trên một luồng duy nhất, điều này có thể gây ra vấn đề hiệu suất khi thực hiện các tác vụ nặng. Để giải quyết vấn đề này, JavaScript cung cấp hai công nghệ quan trọng: Web Workers và Service Workers.
Web Workers
Web Workers là một tính năng của JavaScript cho phép chạy script trong nền mà không ảnh hưởng đến hiệu suất của trang web. Nó tạo ra một luồng riêng biệt để thực hiện các tác vụ nặng mà không làm chậm giao diện người dùng.
Đặc điểm chính của Web Workers:
- Chạy trong một luồng riêng biệt với luồng chính của JavaScript.
- Không có quyền truy cập trực tiếp vào DOM.
- Giao tiếp với luồng chính thông qua hệ thống gửi tin nhắn.
- Có thể thực hiện các tác vụ tính toán phức tạp mà không ảnh hưởng đến UI.
Ví dụ về Web Worker:
// main.js
const worker = new Worker('worker.js'); worker.postMessage('Bắt đầu tính toán'); worker.onmessage = function(event) { console.log('Kết quả từ worker:', event.data);
}; // worker.js
self.onmessage = function(event) { console.log('Nhận được tin nhắn:', event.data); // Giả lập một tác vụ nặng let result = 0; for (let i = 0; i < 1000000000; i++) { result += i; } self.postMessage(result);
};
Trong ví dụ này, worker thực hiện một vòng lặp lớn mà không ảnh hưởng đến hiệu suất của trang web.
Service Workers
Service Workers là một loại Web Worker đặc biệt hoạt động như một proxy giữa trình duyệt và mạng hoặc bộ nhớ cache. Nó cho phép bạn kiểm soát cách xử lý các yêu cầu mạng và quản lý bộ nhớ cache, cho phép tạo ra các ứng dụng web có thể hoạt động offline.
Đặc điểm chính của Service Workers:
- Có thể chặn và xử lý các yêu cầu mạng.
- Có khả năng lưu trữ tài nguyên trong bộ nhớ cache.
- Cho phép tạo ứng dụng web có thể hoạt động offline.
- Có thể nhận push notifications.
- Có thể thực hiện đồng bộ hóa nền.
Ví dụ về Service Worker:
// Đăng ký Service Worker
if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js') .then(function(registration) { console.log('Service Worker đã đăng ký thành công:', registration.scope); }) .catch(function(error) { console.log('Đăng ký Service Worker thất bại:', error); });
} // sw.js
const CACHE_NAME = 'my-site-cache-v1';
const urlsToCache = [ '/', '/styles/main.css', '/script/main.js'
]; self.addEventListener('install', function(event) { event.waitUntil( caches.open(CACHE_NAME) .then(function(cache) { console.log('Opened cache'); return cache.addAll(urlsToCache); }) );
}); self.addEventListener('fetch', function(event) { event.respondWith( caches.match(event.request) .then(function(response) { if (response) { return response; } return fetch(event.request); } ) );
});
Trong ví dụ này, Service Worker lưu trữ các tài nguyên cần thiết trong bộ nhớ cache khi được cài đặt và sau đó phục vụ các yêu cầu từ bộ nhớ cache nếu có, giúp ứng dụng có thể hoạt động offline.
So sánh Web Workers và Service Workers:
-
Mục đích: Web Workers chủ yếu dùng để thực hiện các tác vụ tính toán nặng, trong khi Service Workers được sử dụng để kiểm soát bộ nhớ cache và xử lý yêu cầu mạng.
-
Phạm vi: Web Workers chỉ hoạt động trong phạm vi của một trang web cụ thể, trong khi Service Workers có thể kiểm soát nhiều trang trong cùng một domain.
-
Vòng đời: Web Workers tồn tại trong suốt vòng đời của trang web, trong khi Service Workers có thể tồn tại và hoạt động ngay cả khi trang web đã đóng.
-
Khả năng offline: Service Workers có khả năng làm cho ứng dụng web hoạt động offline, trong khi Web Workers không có khả năng này.
Cả Web Workers và Service Workers đều là công cụ mạnh mẽ để cải thiện hiệu suất và trải nghiệm người dùng trong các ứng dụng web hiện đại. Việc sử dụng chúng một cách phù hợp có thể giúp tạo ra các ứng dụng web nhanh, mượt mà và có khả năng hoạt động offline.
30. Làm thế nào để so sánh 2 đối tượng JSON trong JavaScript?
Để so sánh hai đối tượng JSON trong JavaScript, bạn cần kiểm tra xem chúng có cùng cấu trúc và giá trị hay không. Dưới đây là một số cách để thực hiện điều này:
-
Sử dụng JSON.stringify(): Cách đơn giản nhất là chuyển đổi cả hai đối tượng thành chuỗi JSON và so sánh các chuỗi này.
function soSanhJSON(obj1, obj2) { return JSON.stringify(obj1) === JSON.stringify(obj2); } const json1 = { a: 1, b: 2 }; const json2 = { b: 2, a: 1 }; console.log(soSanhJSON(json1, json2)); // true
Lưu ý: Phương pháp này có thể không chính xác nếu các thuộc tính trong đối tượng không theo cùng một thứ tự.
-
Sử dụng hàm đệ quy: Để so sánh chính xác hơn, bạn có thể viết một hàm đệ quy để so sánh từng thuộc tính của đối tượng.
function soSanhJSON(obj1, obj2) { if (typeof obj1 !== 'object' || typeof obj2 !== 'object') { return obj1 === obj2; } const keys1 = Object.keys(obj1); const keys2 = Object.keys(obj2); if (keys1.length !== keys2.length) { return false; } for (let key of keys1) { if (!keys2.includes(key) || !soSanhJSON(obj1[key], obj2[key])) { return false; } } return true; } const json1 = { a: 1, b: { c: 2 } }; const json2 = { b: { c: 2 }, a: 1 }; console.log(soSanhJSON(json1, json2)); // true
Phương pháp này sẽ so sánh chính xác hơn, kể cả khi các thuộc tính không theo cùng một thứ tự hoặc có các đối tượng lồng nhau.
Lưu ý rằng các phương pháp này giả định rằng các đối tượng JSON không chứa các kiểu dữ liệu đặc biệt như hàm, Symbol, hoặc undefined, vì những kiểu này không được hỗ trợ trong JSON tiêu chuẩn.
Cuối cùng
Để kết thúc bài viết này, mình xin được chia sẻ một vài suy nghĩ như sau:
JavaScript là một ngôn ngữ lập trình đầy sức mạnh và linh hoạt, luôn phát triển không ngừng. Qua bộ câu hỏi phỏng vấn này, chúng ta đã cùng nhau khám phá những khía cạnh quan trọng của JavaScript, từ những khái niệm cơ bản đến những chủ đề nâng cao và phức tạp hơn.
Tuy nhiên, hành trình học tập và khám phá JavaScript không bao giờ kết thúc. Công nghệ luôn thay đổi, và JavaScript cũng vậy. Vì vậy, điều quan trọng là phải luôn giữ tinh thần học hỏi, tò mò và sẵn sàng đón nhận những kiến thức mới.
Đừng nản lòng nếu bạn chưa nắm vững tất cả các khái niệm được đề cập trong bài viết này. Mỗi lập trình viên đều có hành trình riêng của mình. Hãy xem mỗi buổi phỏng vấn, mỗi dự án mới như một cơ hội để học hỏi và phát triển.
Hãy nhớ rằng, việc hiểu sâu về JavaScript không chỉ giúp bạn vượt qua các cuộc phỏng vấn, mà còn giúp bạn trở thành một lập trình viên giỏi hơn, có khả năng xây dựng những ứng dụng web mạnh mẽ và sáng tạo.
Bạn đã đi được một chặng đường dài để đến đây. Hãy tự hào về những gì bạn đã học được và tiếp tục nuôi dưỡng niềm đam mê với lập trình. Mỗi dòng code bạn viết, mỗi vấn đề bạn giải quyết đều đưa bạn tiến gần hơn đến mục tiêu của mình.
Hãy nhớ rằng, không có giới hạn cho sự sáng tạo và đổi mới. Với JavaScript, bạn có trong tay một công cụ mạnh mẽ để biến ý tưởng của mình thành hiện thực. Vì vậy, hãy tiếp tục học hỏi, thử nghiệm và sáng tạo. Tương lai đang chờ đợi những đóng góp của bạn!
Chúc bạn thành công trên con đường trở thành một JavaScript Expert!