25 Proxies und Reflections

Mit ECMAScript 2015 (ES6) wurden zwei leistungsstarke API-Konstrukte eingeführt: Proxies und Reflection. Diese Mechanismen bieten Möglichkeiten zur Metaprogrammierung in JavaScript – sie erlauben es, das Standardverhalten von Objekten zu überwachen, zu modifizieren und zu erweitern. In diesem Abschnitt werden wir beide Konzepte detailliert erkunden, ihre Anwendungsfälle untersuchen und praktische Beispiele für ihre Implementierung betrachten.

25.1 Proxies

Ein Proxy in JavaScript ist ein Objekt, das als Vermittler zwischen Operationen auf einem Zielobjekt und dem Zielobjekt selbst fungiert. Mit Proxies kann man Aspekte des Standardverhaltens von Objekten wie Eigenschaftszugriff, Zuweisung, Aufzählung, Funktionsaufruf und mehr abfangen und anpassen.

25.1.1 Grundlegende Syntax

Die grundlegende Syntax für die Erstellung eines Proxys sieht folgendermaßen aus:

const proxy = new Proxy(target, handler);

25.1.2 Wichtige Fallstricke (Traps)

Hier sind einige der wichtigsten Fallstricke, die in einem Handler definiert werden können:

25.1.3 Beispiel 1: Einfacher Eigenschafts-Zugriff und -Zuweisung

const person = {
  name: "Max Mustermann",
  age: 30
};

const personProxy = new Proxy(person, {
  get(target, property) {
    console.log(`Zugriff auf Eigenschaft '${property}'`);
    return property in target ? target[property] : `Eigenschaft '${property}' nicht gefunden`;
  },
  
  set(target, property, value) {
    console.log(`Setzen der Eigenschaft '${property}' auf den Wert '${value}'`);
    
    // Validierung für das Alter
    if (property === "age" && typeof value !== "number") {
      throw new TypeError("Das Alter muss eine Zahl sein");
    }
    
    target[property] = value;
    return true; // Erfolgreiche Zuweisung
  }
});

// Eigenschaftszugriff
console.log(personProxy.name); // "Zugriff auf Eigenschaft 'name'" und "Max Mustermann"
console.log(personProxy.beruf); // "Zugriff auf Eigenschaft 'beruf'" und "Eigenschaft 'beruf' nicht gefunden"

// Eigenschaftszuweisung
personProxy.name = "Maria Musterfrau"; // "Setzen der Eigenschaft 'name' auf den Wert 'Maria Musterfrau'"
personProxy.age = "dreißig"; // TypeError: Das Alter muss eine Zahl sein

25.1.4 Beispiel 2: Funktionsaufruf und Konstruktor

function sum(a, b) {
  return a + b;
}

const sumProxy = new Proxy(sum, {
  apply(target, thisArg, args) {
    console.log(`Funktion aufgerufen mit Argumenten: ${args}`);
    
    // Stelle sicher, dass alle Argumente Zahlen sind
    args.forEach(arg => {
      if (typeof arg !== "number") {
        throw new TypeError("Alle Argumente müssen Zahlen sein");
      }
    });
    
    return target.apply(thisArg, args);
  },
  
  construct(target, args) {
    console.log(`Konstruktor aufgerufen mit Argumenten: ${args}`);
    
    // Annahme: Die Funktion soll Objekte mit .result-Eigenschaft erstellen
    const instance = new target(...args);
    instance.createdAt = new Date();
    return instance;
  }
});

// Funktionsaufruf
console.log(sumProxy(5, 3)); // "Funktion aufgerufen mit Argumenten: 5,3" und 8
try {
  sumProxy(5, "3"); // "Funktion aufgerufen mit Argumenten: 5,3" und TypeError
} catch (e) {
  console.error(e.message);
}

// Konstruktoraufruf
function Sum(a, b) {
  this.result = a + b;
}
const SumProxy = new Proxy(Sum, { construct: sumProxy.handler.construct });

const instance = new SumProxy(5, 3);
console.log(instance.result); // 8
console.log(instance.createdAt); // Aktuelles Datum und Uhrzeit

25.1.5 Beispiel 3: Privater Eigenschaftsschutz

function createProtectedObject(target, privateProps) {
  return new Proxy(target, {
    get(target, property) {
      if (privateProps.includes(property)) {
        throw new Error(`Zugriff auf private Eigenschaft '${property}' verweigert`);
      }
      return target[property];
    },
    
    set(target, property, value) {
      if (privateProps.includes(property)) {
        throw new Error(`Änderung der privaten Eigenschaft '${property}' verweigert`);
      }
      target[property] = value;
      return true;
    },
    
    deleteProperty(target, property) {
      if (privateProps.includes(property)) {
        throw new Error(`Löschen der privaten Eigenschaft '${property}' verweigert`);
      }
      return delete target[property];
    },
    
    ownKeys(target) {
      return Reflect.ownKeys(target).filter(key => !privateProps.includes(key));
    }
  });
}

const user = {
  name: "Max",
  _password: "geheim123",
  email: "max@beispiel.de",
  _securityQuestion: "Name meines ersten Haustiers"
};

const secureUser = createProtectedObject(user, ["_password", "_securityQuestion"]);

// Versuche, auf private Eigenschaften zuzugreifen
console.log(secureUser.name); // "Max"
console.log(secureUser.email); // "max@beispiel.de"

try {
  console.log(secureUser._password); // Error: Zugriff auf private Eigenschaft '_password' verweigert
} catch (e) {
  console.error(e.message);
}

// Auflistung der Eigenschaften
console.log(Object.keys(secureUser)); // ["name", "email"] - private Eigenschaften ausgeschlossen

25.2 Praktische Anwendungsfälle für Proxies

25.2.1 Datenvalidierung

function createValidator(target, validationRules) {
  return new Proxy(target, {
    set(target, property, value) {
      if (validationRules.hasOwnProperty(property)) {
        const validator = validationRules[property];
        if (!validator.validate(value)) {
          throw new Error(`Ungültiger Wert für ${property}: ${validator.message}`);
        }
      }
      
      target[property] = value;
      return true;
    }
  });
}

const personSchema = {
  name: {
    validate: value => typeof value === 'string' && value.length >= 2,
    message: 'Name muss ein String mit mindestens 2 Zeichen sein'
  },
  age: {
    validate: value => typeof value === 'number' && value >= 0 && value <= 120,
    message: 'Alter muss eine Zahl zwischen 0 und 120 sein'
  },
  email: {
    validate: value => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
    message: 'Ungültiges E-Mail-Format'
  }
};

const person = createValidator({}, personSchema);

person.name = "Max"; // OK
person.age = 30; // OK
person.email = "max@beispiel.de"; // OK

try {
  person.age = 150; // Error: Ungültiger Wert für age: Alter muss eine Zahl zwischen 0 und 120 sein
} catch (e) {
  console.error(e.message);
}

25.2.2 Beobachtbare Objekte (Observable Objects)

function createObservable(target) {
  const handlers = {};
  
  function on(property, handler) {
    if (!handlers[property]) {
      handlers[property] = [];
    }
    handlers[property].push(handler);
  }
  
  function notify(property, oldValue, newValue) {
    if (handlers[property]) {
      handlers[property].forEach(handler => 
        handler({ property, oldValue, newValue }));
    }
    
    // Alle Handler für '*' aufrufen (reagieren auf alle Änderungen)
    if (handlers['*']) {
      handlers['*'].forEach(handler => 
        handler({ property, oldValue, newValue }));
    }
  }
  
  const proxyTarget = new Proxy(target, {
    set(target, property, newValue) {
      const oldValue = target[property];
      
      if (oldValue !== newValue) {
        target[property] = newValue;
        notify(property, oldValue, newValue);
      }
      
      return true;
    }
  });
  
  // API zum Beobachten von Änderungen
  proxyTarget.on = on;
  
  return proxyTarget;
}

const user = createObservable({ name: "Max", age: 30 });

// Handler für bestimmte Eigenschaft
user.on('name', ({ oldValue, newValue }) => {
  console.log(`Name geändert von "${oldValue}" auf "${newValue}"`);
});

// Handler für alle Änderungen
user.on('*', ({ property, oldValue, newValue }) => {
  console.log(`Eigenschaft "${property}" geändert von "${oldValue}" auf "${newValue}"`);
});

user.name = "Maria"; // Beide Handler werden ausgelöst
user.age = 31; // Nur der '*'-Handler wird ausgelöst

25.2.3 Automatisches Logging

function createLoggingProxy(target, logger) {
  return new Proxy(target, {
    get(target, property) {
      const value = target[property];
      logger.log(`GET: ${property} => ${value}`);
      return value;
    },
    
    set(target, property, value) {
      logger.log(`SET: ${property} = ${value}`);
      target[property] = value;
      return true;
    },
    
    deleteProperty(target, property) {
      logger.log(`DELETE: ${property}`);
      delete target[property];
      return true;
    },
    
    apply(target, thisArg, args) {
      logger.log(`CALL: ${target.name}(${args.join(', ')})`);
      return target.apply(thisArg, args);
    }
  });
}

// Logger-Implementierung
const logger = {
  log(message) {
    console.log(`[${new Date().toISOString()}] ${message}`);
  }
};

// Objekt mit Logging
const user = createLoggingProxy({ name: "Max", age: 30 }, logger);
user.name = "Maria";
console.log(user.age);
delete user.age;

// Funktion mit Logging
const calculateTotal = createLoggingProxy(
  function(price, quantity) { return price * quantity; },
  logger
);

calculateTotal(19.99, 3);

25.2.4 Verzögerte Initialisierung (Lazy Initialization)

function createLazyProxy(initializer) {
  let instance = null;
  
  return new Proxy({}, {
    get(target, property) {
      if (!instance) {
        instance = initializer();
      }
      
      return instance[property];
    },
    
    set(target, property, value) {
      if (!instance) {
        instance = initializer();
      }
      
      instance[property] = value;
      return true;
    }
  });
}

// Beispiel: Teure Datenbankverbindung, die nur bei Bedarf initialisiert wird
const db = createLazyProxy(() => {
  console.log("Initialisiere Datenbankverbindung...");
  
  // Simuliere aufwändige Initialisierung
  const connection = {
    query(sql) {
      console.log(`Führe SQL aus: ${sql}`);
      return ["Ergebnis1", "Ergebnis2"];
    },
    
    close() {
      console.log("Schließe Datenbankverbindung");
    }
  };
  
  return connection;
});

// Keine Initialisierung bis hier
console.log("Anwendung gestartet");

// Initialisierung bei erstem Zugriff
const results = db.query("SELECT * FROM users");
console.log(results);

25.3 Reflection

Das Reflection-API, implementiert durch das globale Reflect-Objekt, bietet Methoden für Operationen, die normalerweise durch Operatoren ausgeführt werden, sowie für einige Meta-Programmierungsfunktionen. Das Reflect-Objekt ist nicht konstruierbar und alle seine Methoden sind statisch.

25.3.1 Hauptfunktionen des Reflect-Objekts

Die Methoden des Reflect-Objekts spiegeln die Handler-Methoden von Proxies wider:

25.3.2 Reflect vs. entsprechende Object-Methoden

Während viele Reflect-Methoden Entsprechungen im Object-Konstruktor haben, bieten sie mehrere Vorteile:

  1. Konsistente Rückgabewerte: Reflect-Methoden haben konsistentere Rückgabewerte (oft Booleans für Erfolg/Misserfolg)
  2. Bessere Fehlerbehandlung: Einige Object-Methoden werfen Fehler, wo Reflect-Methoden false zurückgeben
  3. Funktionsaufruf-Syntax: Reflect-Methoden verwenden eine Funktionsaufruf-Syntax statt einer Objekt-Methoden-Syntax
  4. Receiver-Parameter: Einige Reflect-Methoden akzeptieren einen “receiver”-Parameter, um den this-Wert zu kontrollieren

25.3.3 Beispiel: Reflect-API

const obj = {
  x: 1,
  y: 2,
  
  get sum() {
    return this.x + this.y;
  }
};

// Eigenschaftszugriff
console.log(Reflect.get(obj, 'x')); // 1

// Eigenschaftszugriff mit anderem this-Wert
const anotherObj = { x: 10, y: 20 };
console.log(Reflect.get(obj, 'sum', anotherObj)); // 30

// Eigenschaft setzen
Reflect.set(obj, 'z', 3);
console.log(obj.z); // 3

// Eigenschaft prüfen
console.log(Reflect.has(obj, 'x')); // true
console.log(Reflect.has(obj, 'toString')); // true (geerbt)

// Eigenschaft löschen
Reflect.deleteProperty(obj, 'z');
console.log(obj.z); // undefined

// Eigenschaften auflisten
console.log(Reflect.ownKeys(obj)); // ['x', 'y', 'sum']

// Funktionsaufruf
function greet(name) {
  return `Hallo, ${name}! Ich bin ${this.title} ${this.name}.`;
}

const person = { title: "Herr", name: "Mustermann" };
console.log(Reflect.apply(greet, person, ["Max"])); // "Hallo, Max! Ich bin Herr Mustermann."

// Konstruktoraufruf
class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
}

const instance = Reflect.construct(Person, ["Max", 30]);
console.log(instance); // Person { name: "Max", age: 30 }

25.4 Proxies und Reflect zusammen verwenden

Die Kombination von Proxies und Reflect bietet eine besonders mächtige Lösung für Meta-Programmierung. Es ist üblich, Reflect-Methoden innerhalb von Proxy-Traps zu verwenden:

const target = {
  name: "Original",
  greet() {
    return `Hallo, ich bin ${this.name}`;
  }
};

const handler = {
  get(target, property, receiver) {
    console.log(`Zugriff auf Eigenschaft: ${property}`);
    
    // Verwendung von Reflect.get mit receiver für korrektes this-Binding
    return Reflect.get(target, property, receiver);
  },
  
  set(target, property, value, receiver) {
    console.log(`Setzen der Eigenschaft: ${property} = ${value}`);
    
    // Verwendung von Reflect.set mit receiver
    return Reflect.set(target, property, value, receiver);
  },
  
  apply(target, thisArg, args) {
    console.log(`Funktionsaufruf mit Argumenten: ${args}`);
    
    // Verwendung von Reflect.apply
    return Reflect.apply(target, thisArg, args);
  }
};

const proxy = new Proxy(target, handler);

// Eigenschaftszugriff
console.log(proxy.name); // Logs: "Zugriff auf Eigenschaft: name" und "Original"

// Eigenschaftszuweisung
proxy.name = "Proxy"; // Logs: "Setzen der Eigenschaft: name = Proxy"

// Funktionsaufruf
console.log(proxy.greet()); // Logs: "Zugriff auf Eigenschaft: greet" und "Funktionsaufruf mit Argumenten: " und "Hallo, ich bin Proxy"

25.5 Fortgeschrittene Anwendungen von Proxies und Reflect

25.5.1 Virtuelle Eigenschaften und berechnete Werte

function createVirtualProperties(target, properties) {
  return new Proxy(target, {
    get(target, property, receiver) {
      // Ist es eine virtuelle Eigenschaft?
      if (property in properties) {
        if (typeof properties[property] === 'function') {
          // Berechnete Eigenschaft
          return properties[property](target);
        } else {
          // Statischer Wert
          return properties[property];
        }
      }
      
      // Ansonsten auf das Original zurückgreifen
      return Reflect.get(target, property, receiver);
    }
  });
}

const person = {
  firstName: "Max",
  lastName: "Mustermann",
  birthYear: 1990
};

const personWithVirtuals = createVirtualProperties(person, {
  fullName: target => `${target.firstName} ${target.lastName}`,
  age: target => new Date().getFullYear() - target.birthYear,
  type: "Person"
});

console.log(personWithVirtuals.firstName); // "Max" (Original-Eigenschaft)
console.log(personWithVirtuals.fullName); // "Max Mustermann" (berechnete virtuelle Eigenschaft)
console.log(personWithVirtuals.age); // Aktuelles Alter basierend auf birthYear
console.log(personWithVirtuals.type); // "Person" (statische virtuelle Eigenschaft)

25.5.2 Revocable Proxies

JavaScript erlaubt das Erstellen von widerrufbaren Proxies - Proxies, deren Zugriff später aufgehoben werden kann:

function createRevocableAccess(resource) {
  const { proxy, revoke } = Proxy.revocable(resource, {
    get(target, property) {
      if (property === "revoke") {
        return revoke;
      }
      return Reflect.get(target, property);
    }
  });
  
  // Methode zum Widerrufen direkt am Proxy
  proxy.revoke = revoke;
  
  return proxy;
}

const sensitiveDaten = {
  api_key: "geheim123",
  getData() {
    return "Sensible Daten: " + this.api_key;
  }
};

const temporaryAccess = createRevocableAccess(sensitiveDaten);

// Zugriff funktioniert
console.log(temporaryAccess.api_key); // "geheim123"
console.log(temporaryAccess.getData()); // "Sensible Daten: geheim123"

// Zugriff widerrufen
temporaryAccess.revoke();

// Nach Widerruf
try {
  console.log(temporaryAccess.api_key); // TypeError: Cannot perform 'get' on a proxy that has been revoked
} catch (e) {
  console.error(e.message);
}

25.5.3 Objektverschmelzung mit tiefem Zugriff

function createDeepProxy(target = {}) {
  const handler = {
    get(target, property, receiver) {
      if (typeof target[property] === 'object' && target[property] !== null) {
        return new Proxy(target[property], handler);
      }
      return Reflect.get(target, property, receiver);
    }
  };
  
  return new Proxy(target, handler);
}

function deepMerge(target, source) {
  const merged = { ...target };
  
  for (const key in source) {
    if (source[key] instanceof Object && !Array.isArray(source[key])) {
      merged[key] = deepMerge(merged[key] || {}, source[key]);
    } else {
      merged[key] = source[key];
    }
  }
  
  return merged;
}

// Basisobjekt
const config = createDeepProxy({
  server: {
    port: 3000,
    host: 'localhost'
  },
  database: {
    user: 'admin',
    password: 'admin123'
  }
});

// Zugriff auf verschachtelte Eigenschaften
console.log(config.server.port); // 3000

// Überschreiben der Konfiguration mit tiefem Merge
function updateConfig(newConfig) {
  Object.assign(config, deepMerge(config, newConfig));
}

updateConfig({
  server: {
    port: 8080
  },
  database: {
    password: 'sichererPasswort'
  }
});

console.log(config.server.port); // 8080
console.log(config.server.host); // 'localhost' (beibehalten)
console.log(config.database.password); // 'sichererPasswort'

25.5.4 Implementierung von Middleware-Patterns

function createMiddlewareProxy(target) {
  const middlewares = {
    get: [],
    set: []
  };
  
  function addMiddleware(type, middleware) {
    if (middlewares[type]) {
      middlewares[type].push(middleware);
    }
  }
  
  const proxy = new Proxy(target, {
    get(target, property, receiver) {
      let result = Reflect.get(target, property, receiver);
      
      // Spezielle Methode zum Hinzufügen von Middleware
      if (property === 'use') {
        return addMiddleware;
      }
      
      // Middleware-Kette ausführen
      for (const middleware of middlewares.get) {
        const middlewareResult = middleware({
          target,
          property,
          receiver,
          value: result
        });
        
        if (middlewareResult !== undefined) {
          result = middlewareResult;
        }
      }
      
      return result;
    },
    
    set(target, property, value, receiver) {
      let currentValue = value;
      
      // Middleware-Kette ausführen
      for (const middleware of middlewares.set) {
        const middlewareResult = middleware({
          target,
          property,
          value: currentValue,
          receiver
        });
        
        if (middlewareResult !== undefined) {
          currentValue = middlewareResult;
        }
      }
      
      return Reflect.set(target, property, currentValue, receiver);
    }
  });
  
  return proxy;
}

// Beispiel für die Verwendung
const user = createMiddlewareProxy({
  name: "Max",
  email: "max@beispiel.de",
  role: "user"
});

// Middleware für Protokollierung
user.use('get', ({ property, value }) => {
  console.log(`Zugriff auf ${property}: ${value}`);
  // Kein Rückgabewert, also originaler Wert unverändert
});

// Middleware für Datentransformation
user.use('get', ({ property, value }) => {
  if (property === 'name') {
    return value.toUpperCase();
  }
});

// Middleware für Validierung beim Setzen
user.use('set', ({ property, value }) => {
  if (property === 'email' && !value.includes('@')) {
    console.error('Ungültige E-Mail-Adresse');
    return undefined; // Ursprünglichen Wert nicht ändern
  }
  // Kein expliziter Rückgabewert: ursprünglicher Wert wird verwendet
});

console.log(user.name); // "MAX" (transformiert durch Middleware)
console.log(user.role); // "user" (protokolliert, aber nicht transformiert)

user.email = "neuemail@beispiel.de"; // Gültig, wird gesetzt
user.email = "ungültige-email"; // Fehler, wird nicht gesetzt

25.6 Mögliche Fallstricke und Leistungsüberlegungen

Proxies bieten mächtige Funktionen, aber es gibt einige Überlegungen zu beachten:

  1. Leistungsauswirkungen: Proxies führen zusätzliche Indirektionen ein, die die Leistung beeinträchtigen können, insbesondere bei häufigen Zugriffen.

  2. Built-in-Objekte: Das Proxying von Built-in-Objekten (z.B. Arrays, Dates) kann zu unerwartetem Verhalten führen, da interne Methoden möglicherweise nicht korrekt abgefangen werden.

  3. Transparenz: Proxies sind nicht völlig transparent – sie können durch Methoden wie Object.getPrototypeOf() erkannt werden.

  4. Konfiguration von Eigenschaften: Die Konfiguration von Eigenschaften (writable, enumerable, configurable) kann beim Proxying kompliziert zu handhaben sein.

  5. Unterstützung: Obwohl moderne Browser Proxies unterstützen, können in älteren Umgebungen Polyfills erforderlich sein.

// Leistungsvergleich
function benchmarkProxy() {
  const iterations = 1000000;
  const target = { value: 42 };
  const proxy = new Proxy(target, {
    get(target, property) {
      return target[property];
    }
  });
  
  console.time('Direct access');
  for (let i = 0; i < iterations; i++) {
    const value = target.value;
  }
  console.timeEnd('Direct access');
  
  console.time('Proxy access');
  for (let i = 0; i < iterations; i++) {
    const value = proxy.value;
  }
  console.timeEnd('Proxy access');
}

benchmarkProxy();
// Beispielausgabe:
// Direct access: 3.157ms
// Proxy access: 10.872ms