Skip to content

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.

varletconst
Phạm viCả hàmKhối { }Khối { }
Dùng trước khi khai báoĐược, giá trị là undefinedReferenceErrorReferenceError
Gán lạiĐượcĐượcTypeError
Khai báo lại cùng tên trong một phạm viĐượcLỗi cú phápLỗ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, 2

Vớ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ến n; mỗi lần gọi factory là một biến n mới, nên hai counter không ảnh hưởng nhau.
  • Memoize, cache. Một Map nằ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ọithis
obj.f()obj, object đứng trước dấu chấm
f() gọi trơnundefined 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 functionKhô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:

  • in và vòng for…in đi theo cả prototype chain. 'toString' in {} là true. Kiểm tra thuộc tính của chính object bằng Object.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__, constructor trùng với thứ có sẵn trên prototype. Dùng Map, hoặc Object.create(null) (object không có prototype).
  • Prototype pollution. Nếu code gộp object (merge, set theo đườ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 được Object.prototype, và mọi object trong process bị ảnh hưởng (Bảo mật Node.js).

Ghi nhớ

  1. Dùng const mặc định, let khi cần gán lại, không dùng var. let/const có temporal dead zone nên đọc trước khi khai báo báo lỗi rõ ràng.
  2. Closure giữ chính biến, không chép giá trị: for (var i…) với setTimeout in 3, 3, 3, for (let i…) in 0, 1, 2.
  3. 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.
  4. this quyết định lúc gọi: obj.f() là obj, gọi trơn là undefined. Truyền method làm callback thì mất this; sửa bằng bind hoặc arrow function.
  5. class là cú pháp trên prototype. Dùng Object.hasOwn và Map thay vì in và 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áoundefined
Đọc let trước khi khai báoReferenceError: Cannot access 'y' before initialization
Gọi const g = () => … trước khi khai báoReferenceError
for (var…) / for (let…) với setTimeout3, 3, 3 / 0, 1, 2
const f = user.regular; f()undefined (this là undefined)
setTimeout(function () { this… }) trong methodthis là object Timeout
[1].map(btn.click)undefined
d.hasOwnProperty('speak') với method của classfalse
Thêm method vào prototype sau khi tạo instanceinstance cũ gọi được ngay
20 closure chỉ dùng số nhỏ, không closure nào dùng mảng 8 MBheap tăng khoảng 0 MB
20 closure chỉ dùng số nhỏ, scope từng có closure dùng mảng 8 MBheap 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`)