Giao diện
Scope, closure, this, prototype
Bốn khái niệm này quyết định một dòng code JavaScript nhìn thấy biến nào, và this trỏ tới đâu. Chúng hay bị hỏi trong phỏng vấn qua những đoạn code ngắn như vòng lặp setTimeout in ra 3, 3, 3, và cũng là nguồn gốc của những bug thật: callback mất this, closure giữ bộ nhớ không giải phóng được. Trang này đi qua từng khái niệm theo cách JavaScript thực sự làm.
Scope: var, let, const
Scope là vùng mà một biến được nhìn thấy. JavaScript tìm biến theo lexical scope, tức theo vị trí viết trong code: một hàm nhìn thấy biến của chính nó, rồi biến của hàm bao quanh, cứ thế ra tới ngoài cùng. Nơi hàm được gọi không ảnh hưởng tới việc tìm biến.
var | let | const | |
|---|---|---|---|
| Phạm vi | Cả hàm | Khối { } | Khối { } |
| Dùng trước khi khai báo | Được, giá trị là undefined | ReferenceError | ReferenceError |
| Gán lại | Được | Được | TypeError |
| Khai báo lại cùng tên trong một phạm vi | Được | Lỗi cú pháp | Lỗi cú pháp |
Hoisting nghĩa là JavaScript xử lý mọi khai báo trong một phạm vi trước khi chạy dòng đầu tiên. Với var, biến được tạo sẵn với giá trị undefined, nên đọc trước khi khai báo không lỗi mà ra undefined, một dạng bug khó thấy. Với let và const, biến cũng được tạo trước, nhưng từ đầu phạm vi tới dòng khai báo nó nằm trong temporal dead zone: đọc vào lúc đó báo ReferenceError: Cannot access 'y' before initialization. Lỗi rõ ràng như vậy tốt hơn một giá trị undefined âm thầm.
Function declaration (function f() {}) được hoist cả phần thân, nên gọi được trước dòng khai báo. Function expression gán vào const (const g = () => …) thì không.
const chỉ cấm gán lại biến, không cấm sửa object mà biến trỏ tới: const o = { n: 1 }; o.n = 2 chạy bình thường. Muốn object không sửa được thì dùng Object.freeze (chỉ đóng băng cấp ngoài cùng).
Quy tắc thực tế: dùng const mặc định, let khi cần gán lại, không dùng var.
Closure
Closure là một hàm cùng với các biến của những phạm vi bao quanh mà nó tham chiếu tới. Khi hàm ngoài đã chạy xong, những biến đó vẫn còn sống, vì hàm bên trong còn giữ tham chiếu tới chúng. Điều quan trọng là closure giữ chính biến đó, không phải bản chép giá trị tại lúc tạo.
Ví dụ kinh điển trong phỏng vấn:
js
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i), 0) // 3, 3, 3
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i), 0) // 0, 1, 2Với var, cả hàm chỉ có một biến i, và ba callback cùng trỏ tới nó. Khi timer chạy thì vòng lặp đã xong và i đã là 3. Với let, mỗi vòng lặp có một biến i riêng, và mỗi callback trỏ tới biến của vòng nó được tạo. Đi từng bước để thấy các ô biến:
for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0)}// sau khi vòng lặp xong, timer mới chạy
ô S
i = 0
callback —
…output
1 / 8bước
var i có phạm vi cả hàm: chỉ có một biến i.
Closure có rất nhiều ứng dụng hằng ngày:
- Factory giữ trạng thái riêng.
makeCounter()trả về các hàm dùng chung một biếnn; mỗi lần gọi factory là một biếnnmới, nên hai counter không ảnh hưởng nhau. - Memoize, cache. Một
Mapnằm trong closure, chỉ hàm được trả về mới chạm được vào. - Middleware, handler có cấu hình.
rateLimit({ max: 100 })trả về một middleware nhớ cấu hình đó. - Callback, handler của event đều là closure, đọc biến của nơi chúng được viết.
Closure giữ bộ nhớ ngoài ý muốn
Vì closure giữ biến sống, một closure tồn tại lâu (gắn vào event listener, timer, cache toàn cục) giữ luôn mọi thứ nó tham chiếu. Cái bẫy khó thấy hơn là các closure tạo ra trong cùng một hàm dùng chung một scope. V8 chỉ giữ những biến mà ít nhất một closure dùng tới, nhưng giữ chung cho tất cả. Trên lab, một hàm tạo một mảng 8 MB big và hai closure: một closure dùng big, một closure chỉ dùng số nguyên small. Bỏ closure dùng big đi, chỉ giữ closure dùng small, mà 20 lần gọi vẫn giữ lại 160 MB, vì big vẫn nằm trong scope dùng chung. Nếu không có closure nào dùng big thì V8 không giữ nó, và heap gần như không tăng. Cách tránh là đừng để dữ liệu lớn trong cùng phạm vi với closure sống lâu, hoặc gán null cho biến lớn khi dùng xong. Tìm leak loại này bằng heap snapshot (Bộ nhớ & GC).
this
this không theo lexical scope. Nó được quyết định lúc gọi hàm, theo cách gọi:
| Cách gọi | this |
|---|---|
obj.f() | obj, object đứng trước dấu chấm |
f() gọi trơn | undefined trong strict mode (mặc định ở ES module và class); globalThis ở code không strict |
f.call(x), f.apply(x, args) | x |
f.bind(x) | Tạo hàm mới với this cố định là x; không đổi được nữa |
new F() | Object mới được tạo |
| Arrow function | Không có this riêng: lấy this của phạm vi bao quanh lúc tạo; call, bind không đổi được |
Bug hay gặp nhất là truyền method làm callback. setTimeout(obj.method, 0), [1].map(btn.click) hay app.get('/', controller.handle) đều tách hàm khỏi object, và khi được gọi trơn thì this là undefined. Có ba cách sửa: obj.method.bind(obj), bọc trong arrow function (...args) => obj.method(...args), hoặc khai báo method dạng arrow function trong class (handle = (req, res) => { … }). Bên trong một method, dùng arrow function cho callback (setTimeout(() => this.save())) để giữ this của method; dùng function thường thì this lại bị đổi (trong Node, this của callback setTimeout là chính object Timeout).
Thử từng cách gọi:
function regular() { return this }
const user = { name: 'user', regular }
const other = { name: 'other' }
const holder = { name: 'holder', make() { return () => this } }
class Btn { constructor() { this.name = 'btn' } click() { return this } }
const btn = new Btn()
const f = user.regular
f()undefinedthis bên trong hàm
Gọi trơn f(): this là undefined (strict mode, mặc định trong ES module và class). Hàm không nhớ nó từng thuộc về object nào.
Chạy thật bằng JavaScript của trình duyệt, ở strict mode.
Prototype
Mỗi object có một liên kết ẩn tới một object khác gọi là prototype. Khi đọc một thuộc tính không có trên object, JavaScript tìm tiếp trên prototype, rồi prototype của prototype, cho tới null. Chuỗi đó là prototype chain. Nhờ vậy mọi object đều có toString, hasOwnProperty: chúng nằm trên Object.prototype.
class chỉ là cú pháp gọn hơn cho cơ chế này. typeof Dog là "function". Method khai báo trong class nằm trên Dog.prototype, không nằm trên từng instance, nên d.hasOwnProperty('speak') là false. extends nối Dog.prototype vào Animal.prototype. Hệ quả là thêm method vào prototype thì mọi instance, kể cả đã tạo từ trước, đều có ngay.
Hai chỗ prototype ảnh hưởng tới backend:
invà vòngfor…inđi theo cả prototype chain.'toString' in {}làtrue. Kiểm tra thuộc tính của chính object bằngObject.hasOwn(obj, key). Dùng object thường làm từ điển với key do người dùng nhập là nguy hiểm, vì key như__proto__,constructortrùng với thứ có sẵn trên prototype. DùngMap, hoặcObject.create(null)(object không có prototype).- Prototype pollution. Nếu code gộp object (
merge,settheo đường dẫn) từ dữ liệu người dùng mà không chặn key__proto__, kẻ tấn công sửa đượcObject.prototype, và mọi object trong process bị ảnh hưởng (Bảo mật Node.js).
Ghi nhớ
- Dùng
constmặc định,letkhi cần gán lại, không dùngvar.let/constcó temporal dead zone nên đọc trước khi khai báo báo lỗi rõ ràng. - Closure giữ chính biến, không chép giá trị:
for (var i…)vớisetTimeoutin3, 3, 3,for (let i…)in0, 1, 2. - Các closure tạo trong cùng một hàm dùng chung scope: một closure sống lâu có thể giữ cả dữ liệu lớn mà nó không dùng.
thisquyết định lúc gọi:obj.f()là obj, gọi trơn làundefined. Truyền method làm callback thì mấtthis; sửa bằngbindhoặc arrow function.classlà cú pháp trên prototype. DùngObject.hasOwnvàMapthay vìinvà object thường cho key do người dùng nhập.
Nguồn tham khảo
Kiểm bằng lab: mọi kết quả trong trang (Node 24, ES module). Từ kiến thức chung, chưa đối chiếu lần này: temporal dead zone và lexical scope theo đặc tả ECMAScript; cách V8 dùng chung một context cho các closure trong cùng một hàm (kết quả lab khớp: 160 MB bị giữ); this của callback setTimeout trong Node là object Timeout.
Số đo chi tiết
Kết quả chạy lab/scope-closure/scope.mjs (Node 24.14, --expose-gc)
| Phép thử | Kết quả |
|---|---|
Đọc var trước khi khai báo | undefined |
Đọc let trước khi khai báo | ReferenceError: Cannot access 'y' before initialization |
Gọi const g = () => … trước khi khai báo | ReferenceError |
for (var…) / for (let…) với setTimeout | 3, 3, 3 / 0, 1, 2 |
const f = user.regular; f() | undefined (this là undefined) |
setTimeout(function () { this… }) trong method | this là object Timeout |
[1].map(btn.click) | undefined |
d.hasOwnProperty('speak') với method của class | false |
| Thêm method vào prototype sau khi tạo instance | instance cũ gọi được ngay |
| 20 closure chỉ dùng số nhỏ, không closure nào dùng mảng 8 MB | heap tăng khoảng 0 MB |
| 20 closure chỉ dùng số nhỏ, scope từng có closure dùng mảng 8 MB | heap giữ lại 160 MB |
Code đã dùng để chạy thử
Chạy bằng npm run lab -- scope-closure, chỉ cần Node.js. Xem Lab.
lab/scope-closure/scope.mjs
js
// Trang nodejs/scope-closure: hoisting, TDZ, closure trong vòng lặp, this, prototype, closure giữ bộ nhớ.
const log = (label, v) => console.log(`${label.padEnd(52)} ${v}`)
const run = (fn) => { try { return fn() } catch (e) { return `LỖI ${e.constructor.name}: ${e.message}` } }
console.log('\n=== Hoisting và temporal dead zone ===')
log('Đọc var trước khi khai báo', run(() => { const r = typeof x; var x = 1; return `${r} (giá trị undefined)` }))
log('Đọc let trước khi khai báo', run(() => { const r = y; let y = 1; return r }))
log('Gọi function declaration trước khi khai báo', run(() => { const r = f(); function f() { return 'chạy được' } return r }))
log('Gọi function expression (const) trước khi khai báo', run(() => { const r = g(); const g = () => 'x'; return r }))
log('Gán lại const', run(() => { const z = 1; z = 2; return z }))
log('Sửa thuộc tính của object khai báo const', run(() => { const o = { n: 1 }; o.n = 2; return `o.n = ${o.n}` }))
console.log('\n=== Closure trong vòng lặp với setTimeout ===')
await new Promise((done) => {
const outVar = [], outLet = []
for (var i = 0; i < 3; i++) setTimeout(() => outVar.push(i), 0)
for (let j = 0; j < 3; j++) setTimeout(() => outLet.push(j), 0)
setTimeout(() => { log('for (var i…) setTimeout(() => push(i))', outVar.join(', ')); log('for (let j…) setTimeout(() => push(j))', outLet.join(', ')); done() }, 5)
})
console.log('\n=== Closure: factory và memoize ===')
function makeCounter() { let n = 0; return { inc: () => ++n, get: () => n } }
const a = makeCounter(), b = makeCounter()
a.inc(); a.inc(); b.inc()
log('Hai counter từ cùng một factory', `a = ${a.get()}, b = ${b.get()} (mỗi lần gọi factory có biến n riêng)`)
function memoize(fn) { const cache = new Map(); return (k) => (cache.has(k) ? cache.get(k) : (cache.set(k, fn(k)), cache.get(k))) }
let calls = 0
const slowSquare = memoize((x) => { calls++; return x * x })
slowSquare(4); slowSquare(4); slowSquare(5)
log('memoize: gọi 3 lần với 4, 4, 5', `hàm gốc chạy ${calls} lần`)
console.log('\n=== this ===')
const user = {
name: 'An',
regular() { return this?.name },
arrow: () => typeof this, // this của module (ES module: undefined)
later() { return new Promise((r) => setTimeout(function () { r(this?.name ?? `this là ${this?.constructor?.name ?? this}`) }, 0)) },
laterArrow() { return new Promise((r) => setTimeout(() => r(this.name), 0)) }
}
log('user.regular()', run(() => user.regular()))
const detached = user.regular
log('const f = user.regular; f()', run(() => detached()))
log('f.call({ name: "Bình" })', run(() => detached.call({ name: 'Bình' })))
log('const g = f.bind(user); g()', run(() => detached.bind(user)()))
log('user.arrow() (arrow function lấy this của nơi khai báo)', run(() => user.arrow()))
log('setTimeout(function () { this.name }) trong method', await user.later())
log('setTimeout(() => this.name) trong method', await user.laterArrow())
class Btn { constructor() { this.label = 'Lưu' } click() { return this?.label } }
const btn = new Btn()
const handler = btn.click
log('Truyền btn.click làm callback rồi gọi', run(() => handler()))
log('[1].map(btn.click)', run(() => [1].map(btn.click)[0]))
console.log('\n=== Prototype ===')
class Animal { speak() { return 'kêu' } }
class Dog extends Animal { speak() { return 'gâu' } }
const d = new Dog()
log('typeof Dog', typeof Dog)
log('Object.getPrototypeOf(d) === Dog.prototype', Object.getPrototypeOf(d) === Dog.prototype)
log('Object.getPrototypeOf(Dog.prototype) === Animal.prototype', Object.getPrototypeOf(Dog.prototype) === Animal.prototype)
log('d.hasOwnProperty("speak")', d.hasOwnProperty('speak'))
Animal.prototype.sleep = () => 'ngủ'
log('Thêm Animal.prototype.sleep sau khi tạo d, d.sleep()', d.sleep())
const obj = {}
log('"toString" in {} (đi theo prototype)', 'toString' in obj)
log('Object.hasOwn({}, "toString")', Object.hasOwn(obj, 'toString'))
log('Object.create(null) có toString không', 'toString' in Object.create(null))
console.log('\n=== Closure giữ bộ nhớ ngoài ý muốn ===')
const handlers = []
function attach() {
const big = new Array(1_000_000).fill(1) // khoảng 8 MB
const small = big.length
handlers.push(() => small) // chỉ dùng small, nhưng V8 có thể giữ cả scope
}
global.gc?.()
const h0 = process.memoryUsage().heapUsed
for (let i = 0; i < 20; i++) attach()
global.gc?.()
log('20 closure chỉ dùng số nguyên small, heap tăng', `${((process.memoryUsage().heapUsed - h0) / 1e6).toFixed(1).replace('.', ',')} MB`)
handlers.length = 0
const keep = []
function attachBoth() {
const big = new Array(1_000_000).fill(1)
const small = big.length
keep.push(() => small)
return () => big.length // một closure khác dùng big: hai closure dùng chung một scope
}
const others = []
global.gc?.()
const h1 = process.memoryUsage().heapUsed
for (let i = 0; i < 20; i++) others.push(attachBoth())
others.length = 0 // bỏ closure dùng big, chỉ giữ closure dùng small
global.gc?.()
log('Như trên nhưng scope từng có closure khác dùng big', `${((process.memoryUsage().heapUsed - h1) / 1e6).toFixed(1).replace('.', ',')} MB`)