js-dojo

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.cjs

Source

// ---------------------------------------------------------------------
// 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.');

Solution