2 fields vs prototype methods
GOAL
Learn to tell a prototype method apart from a class field that holds an arrow function: are they the same object, what do they cost in memory, and what happens when one is pulled off its instance and invoked as a bare callback (the live-hit-over-the-network case).
CONCEPT
`takeHit(h) {...}` in a class body → ONE function on the prototype, shared by every instance, `this` decided at CALL time. `onHit = (h) => this.takeHit(h)` → a NEW closure created per instance inside the constructor, stored as an OWN property, `this` frozen to the instance forever. 5 000 players ⇒ 5 000 closures — but detach-safe.
HINT
For part B: a socket-style emitter invokes your handler bare — `handler(hit)` — there is no receiver, so a prototype method loses `this`. Either give it the arrow field, or `player.takeHit.bind(player)`.
MIRRORS
Any event emitter that fires listeners without a receiver: `socket.on('hit', player.takeHit)` quietly drops `this` and blows up on the first event. UI frameworks dodge this every day with class-field arrows for handlers; game engines tend to keep methods on prototypes (memory: one method for thousands of entities) and pay with explicit binds wherever events plug in.
Run
node 02-fields-vs-prototype-methods.cjsSource
// ---------------------------------------------------------------------
// 03-classes-desugared / 02-fields-vs-prototype-methods.cjs
//
// GOAL: Learn to tell a prototype method apart from a class field that
// holds an arrow function: are they the same object, what do they
// cost in memory, and what happens when one is pulled off its
// instance and invoked as a bare callback (the
// live-hit-over-the-network case).
//
// CONCEPT: `takeHit(h) {...}` in a class body → ONE function on the
// prototype, shared by every instance, `this` decided at CALL
// time. `onHit = (h) => this.takeHit(h)` → a NEW closure
// created per instance inside the constructor, stored as an
// OWN property, `this` frozen to the instance forever. 5 000
// players ⇒ 5 000 closures — but detach-safe.
//
// HINT: For part B: a socket-style emitter invokes your handler bare —
// `handler(hit)` — there is no receiver, so a prototype method
// loses `this`. Either give it the arrow field, or
// `player.takeHit.bind(player)`.
//
// MIRRORS: Any event emitter that fires listeners without a receiver:
// `socket.on('hit', player.takeHit)` quietly drops `this` and
// blows up on the first event. UI frameworks dodge this every
// day with class-field arrows for handlers; game engines tend
// to keep methods on prototypes (memory: one method for
// thousands of entities) and pay with explicit binds wherever
// events plug in.
// Run: node 02-fields-vs-prototype-methods.cjs
// ---------------------------------------------------------------------
'use strict';
const assert = require('node:assert');
// -- Setup (do not edit) ----------------------------------------------
class NetworkedPlayer {
constructor(name) {
this.name = name;
this.lastHit = null;
}
takeHit(hit) { // prototype method — shared, late-bound `this`
this.lastHit = hit;
return this;
}
onHit = (hit) => { // class field — per-instance, `this` locked in
return this.takeHit(hit);
};
}
const knight = new NetworkedPlayer('knight');
const rogue = new NetworkedPlayer('rogue');
// A tiny network link that (like most emitters) invokes handlers BARE —
// no receiver.
function makeNetwork() {
const handlers = [];
return {
subscribe(fn) { handlers.push(fn); },
emit(hit) {
const errors = [];
for (const fn of handlers) {
try { fn(hit); } catch (e) { errors.push(e); }
}
return errors;
},
};
}
// -- TODO 1: predictions ----------------------------------------------
// Replace each `null` with `true` or `false`. Think, don't run-and-guess.
const predictions = {
// Is knight.takeHit the very same function object as rogue.takeHit?
takeHitShared: null,
// Is knight.onHit the very same function object as rogue.onHit?
onHitShared: null,
// Is `onHit` an OWN property of the instance (hasOwnProperty)?
onHitIsOwnProperty: null,
// Is `takeHit` an OWN property of the instance?
takeHitIsOwnProperty: null,
// Detached call: const f = knight.takeHit; f({...}) — does it THROW?
detachedTakeHitThrows: null,
// Detached call: const g = knight.onHit; g({...}) — does it THROW?
detachedOnHitThrows: null,
};
// -- TODO 2: fix the subscription wiring ------------------------------
// The naive wiring below hands the network a raw prototype method —
// when the
// network calls it bare, `this` is undefined (class bodies are strict). Change
// the wiring so BOTH players receive hits without an error. Two idiomatic
// fixes: pass the arrow field, or bind the prototype method. Use one of each
// so you have typed both with your own hands.
function wireUp(network, knightPlayer, roguePlayer) {
network.subscribe(knightPlayer.takeHit); // ← broken on purpose
network.subscribe(roguePlayer.takeHit); // ← broken on purpose
}
// -- Checks (do not edit) ---------------------------------------------
assert.strictEqual(predictions.takeHitShared, knight.takeHit === rogue.takeHit,
'TODO 1 takeHitShared: a prototype method is ONE function object living on ' +
'NetworkedPlayer.prototype — every instance sees the same reference');
assert.strictEqual(predictions.onHitShared, knight.onHit === rogue.onHit,
'TODO 1 onHitShared: a class field re-runs per construction — each instance ' +
'gets its own fresh closure');
assert.strictEqual(predictions.onHitIsOwnProperty,
Object.prototype.hasOwnProperty.call(knight, 'onHit'),
'TODO 1 onHitIsOwnProperty: fields are installed ON the instance, like ' +
'assignments in the constructor');
assert.strictEqual(predictions.takeHitIsOwnProperty,
Object.prototype.hasOwnProperty.call(knight, 'takeHit'),
'TODO 1 takeHitIsOwnProperty: methods stay on the prototype — the instance ' +
'only reaches them through the chain');
const detached = knight.takeHit;
let detachedTakeHitThrew = false;
try { detached({ at: 1090, damage: 9 }); } catch { detachedTakeHitThrew = true; }
assert.strictEqual(predictions.detachedTakeHitThrows, detachedTakeHitThrew,
'TODO 1 detachedTakeHitThrows: `this` for a normal method is chosen by the ' +
'CALL SITE (receiver before the dot); a bare call in strict mode gets ' +
'this === undefined');
const detachedArrow = knight.onHit;
let detachedArrowThrew = false;
try { detachedArrow({ at: 1090, damage: 9 }); } catch { detachedArrowThrew = true; }
assert.strictEqual(predictions.detachedOnHitThrows, detachedArrowThrew,
'TODO 1 detachedOnHitThrows: an arrow closed over `this` at construction — ' +
'no call site can take that away');
// Part B — the network.
knight.lastHit = null;
rogue.lastHit = null;
const network = makeNetwork();
wireUp(network, knight, rogue);
const hit = { at: 10915, damage: 12 };
const errors = network.emit(hit);
assert.strictEqual(errors.length, 0,
'TODO 2: the network called your handler bare and it lost `this` ' +
`(${errors[0] ? errors[0].message : ''}) — hand the network something whose ` +
'`this` cannot be lost');
assert.strictEqual(knight.lastHit, hit,
'TODO 2: knight never received the hit — wire knight with the arrow field or a bind');
assert.strictEqual(rogue.lastHit, hit,
'TODO 2: rogue never received the hit — wire rogue with the arrow field or a bind');
// Memory implication, made concrete: own function-valued props per instance.
const ownFns = Object.getOwnPropertyNames(knight)
.filter((k) => typeof knight[k] === 'function');
assert.deepStrictEqual(ownFns, ['onHit'],
'sanity: exactly one per-instance closure exists per player — now imagine ' +
'5000 players × 20 handlers as fields vs 20 shared prototype methods');
console.log('OK — 02-fields-vs-prototype-methods: identity understood, wiring fixed.');