Deep JavaScript
Bitte unterstützen Sie dieses Buch: kaufen Sie es oder spenden Sie
(Werbung, bitte nicht blockieren.)

6 Objekte und Arrays kopieren



In diesem Kapitel lernen wir, wie man Objekte und Arrays in JavaScript kopiert.

6.1 Shallow Copying vs. Deep Copying

Es gibt zwei „Tiefen“, mit denen Daten kopiert werden können

Die nächsten Abschnitte behandeln beide Arten des Kopierens. Bedauerlicherweise hat JavaScript nur eingebaute Unterstützung für Shallow Copying. Wenn wir Deep Copying benötigen, müssen wir es selbst implementieren.

6.2 Shallow Copying in JavaScript

Betrachten wir mehrere Möglichkeiten, Daten flach zu kopieren.

6.2.1 Kopieren von einfachen Objekten und Arrays durch Spreizen

Wir können in Objektliterale und in Array-Literale spreizen, um Kopien zu erstellen

const copyOfObject = {...originalObject};
const copyOfArray = [...originalArray];

Leider hat das Spreizen mehrere Probleme. Diese werden in den folgenden Unterabschnitten behandelt. Einige davon sind echte Einschränkungen, andere sind lediglich Besonderheiten.

6.2.1.1 Das Prototype wird beim Objektspreizen nicht kopiert

Zum Beispiel

class MyClass {}

const original = new MyClass();
assert.equal(original instanceof MyClass, true);

const copy = {...original};
assert.equal(copy instanceof MyClass, false);

Beachten Sie, dass die folgenden beiden Ausdrücke äquivalent sind

obj instanceof SomeClass
SomeClass.prototype.isPrototypeOf(obj)

Daher können wir dies beheben, indem wir der Kopie dasselbe Prototype wie dem Original geben

class MyClass {}

const original = new MyClass();

const copy = {
  __proto__: Object.getPrototypeOf(original),
  ...original,
};
assert.equal(copy instanceof MyClass, true);

Alternativ können wir das Prototype der Kopie nach deren Erstellung über Object.setPrototypeOf() setzen.

6.2.1.2 Viele eingebaute Objekte haben spezielle „interne Slots“, die beim Objektspreizen nicht kopiert werden

Beispiele für solche eingebauten Objekte sind reguläre Ausdrücke und Datumsangaben. Wenn wir eine Kopie davon erstellen, verlieren wir die meisten darin gespeicherten Daten.

6.2.1.3 Nur eigene (nicht geerbte) Eigenschaften werden beim Objektspreizen kopiert

Angesichts der Funktionsweise von Prototype-Ketten ist dies normalerweise der richtige Ansatz. Aber wir müssen uns dessen trotzdem bewusst sein. Im folgenden Beispiel ist die geerbte Eigenschaft .inheritedProp von original in copy nicht verfügbar, da wir nur eigene Eigenschaften kopieren und das Prototype nicht beibehalten.

const proto = { inheritedProp: 'a' };
const original = {__proto__: proto, ownProp: 'b' };
assert.equal(original.inheritedProp, 'a');
assert.equal(original.ownProp, 'b');

const copy = {...original};
assert.equal(copy.inheritedProp, undefined);
assert.equal(copy.ownProp, 'b');
6.2.1.4 Nur aufzählbare Eigenschaften werden beim Objektspreizen kopiert

Zum Beispiel ist die eigene Eigenschaft .length von Array-Instanzen nicht aufzählbar und wird nicht kopiert. Im folgenden Beispiel kopieren wir das Array arr durch Objektspreizen (Zeile A)

const arr = ['a', 'b'];
assert.equal(arr.length, 2);
assert.equal({}.hasOwnProperty.call(arr, 'length'), true);

const copy = {...arr}; // (A)
assert.equal({}.hasOwnProperty.call(copy, 'length'), false);

Auch dies ist selten eine Einschränkung, da die meisten Eigenschaften aufzählbar sind. Wenn wir nicht aufzählbare Eigenschaften kopieren müssen, können wir Object.getOwnPropertyDescriptors() und Object.defineProperties() zum Kopieren von Objekten verwenden (wie das geht, wird später erklärt)

Weitere Informationen zur Aufzählbarkeit finden Sie in §12 „Aufzählbarkeit von Eigenschaften“.

6.2.1.5 Eigenschaftsattribute werden beim Objektspreizen nicht immer originalgetreu kopiert

Unabhängig von den Attributen einer Eigenschaft wird ihre Kopie immer eine Dateneigenschaft sein, die beschreibbar und konfigurierbar ist.

Hier erstellen wir zum Beispiel die Eigenschaft original.prop, deren Attribute writable und configurable false sind

const original = Object.defineProperties(
  {}, {
    prop: {
      value: 1,
      writable: false,
      configurable: false,
      enumerable: true,
    },
  });
assert.deepEqual(original, {prop: 1});

Wenn wir .prop kopieren, sind writable und configurable beide true

const copy = {...original};
// Attributes `writable` and `configurable` of copy are different:
assert.deepEqual(
  Object.getOwnPropertyDescriptors(copy),
  {
    prop: {
      value: 1,
      writable: true,
      configurable: true,
      enumerable: true,
    },
  });

Folglich werden auch Getter und Setter nicht originalgetreu kopiert

const original = {
  get myGetter() { return 123 },
  set mySetter(x) {},
};
assert.deepEqual({...original}, {
  myGetter: 123, // not a getter anymore!
  mySetter: undefined,
});

Die oben erwähnten Object.getOwnPropertyDescriptors() und Object.defineProperties() übertragen eigene Eigenschaften immer mit allen Attributen intakt (wie später gezeigt).

6.2.1.6 Das Kopieren ist flach

Die Kopie enthält neue Versionen jedes Schlüssel-Wert-Eintrags im Original, aber die Werte des Originals werden nicht selbst kopiert. Zum Beispiel

const original = {name: 'Jane', work: {employer: 'Acme'}};
const copy = {...original};

// Property .name is a copy: changing the copy
// does not affect the original
copy.name = 'John';
assert.deepEqual(original,
  {name: 'Jane', work: {employer: 'Acme'}});
assert.deepEqual(copy,
  {name: 'John', work: {employer: 'Acme'}});

// The value of .work is shared: changing the copy
// affects the original
copy.work.employer = 'Spectre';
assert.deepEqual(
  original, {name: 'Jane', work: {employer: 'Spectre'}});
assert.deepEqual(
  copy, {name: 'John', work: {employer: 'Spectre'}});

Wir werden uns später in diesem Kapitel mit Deep Copying befassen.

6.2.2 Shallow Copying durch Object.assign() (optional)

Object.assign() funktioniert weitgehend wie das Spreizen in Objekte. Das heißt, die folgenden beiden Kopierarten sind weitgehend äquivalent

const copy1 = {...original};
const copy2 = Object.assign({}, original);

Die Verwendung einer Methode anstelle von Syntax hat den Vorteil, dass sie mit einer Bibliothek auf älteren JavaScript-Engines nachgerüstet werden kann.

Object.assign() ist jedoch nicht vollständig wie das Spreizen. Es unterscheidet sich in einem relativ subtilen Punkt: Es erstellt Eigenschaften anders.

Unter anderem ruft die Zuweisung eigene und geerbte Setter auf, während die Definition dies nicht tut (weitere Informationen zu Zuweisung vs. Definition). Dieser Unterschied ist selten spürbar. Der folgende Code ist ein Beispiel, aber er ist konstruiert

const original = {['__proto__']: null}; // (A)
const copy1 = {...original};
// copy1 has the own property '__proto__'
assert.deepEqual(
  Object.keys(copy1), ['__proto__']);

const copy2 = Object.assign({}, original);
// copy2 has the prototype null
assert.equal(Object.getPrototypeOf(copy2), null);

Durch die Verwendung eines berechneten Eigenschaftsschlüssels in Zeile A erstellen wir .__proto__ als eigene Eigenschaft und rufen nicht den geerbten Setter auf. Wenn Object.assign() diese Eigenschaft kopiert, ruft es jedoch den Setter auf. (Weitere Informationen zu .__proto__ finden Sie in „JavaScript for impatient programmers“.)

6.2.3 Shallow Copying durch Object.getOwnPropertyDescriptors() und Object.defineProperties() (optional)

JavaScript ermöglicht es uns, Eigenschaften über Eigenschaftsdeskriptoren zu erstellen, Objekte, die Eigenschaftsattribute spezifizieren. Zum Beispiel über Object.defineProperties(), das wir bereits in Aktion gesehen haben. Wenn wir diese Methode mit Object.getOwnPropertyDescriptors() kombinieren, können wir originalgetreuer kopieren

function copyAllOwnProperties(original) {
  return Object.defineProperties(
    {}, Object.getOwnPropertyDescriptors(original));
}

Das eliminiert zwei Probleme beim Kopieren von Objekten durch Spreizen.

Erstens werden alle Attribute eigener Eigenschaften korrekt kopiert. Daher können wir jetzt eigene Getter und eigene Setter kopieren

const original = {
  get myGetter() { return 123 },
  set mySetter(x) {},
};
assert.deepEqual(copyAllOwnProperties(original), original);

Zweitens werden dank Object.getOwnPropertyDescriptors() auch nicht aufzählbare Eigenschaften kopiert

const arr = ['a', 'b'];
assert.equal(arr.length, 2);
assert.equal({}.hasOwnProperty.call(arr, 'length'), true);

const copy = copyAllOwnProperties(arr);
assert.equal({}.hasOwnProperty.call(copy, 'length'), true);

6.3 Deep Copying in JavaScript

Nun ist es an der Zeit, uns dem Deep Copying zu widmen. Zuerst werden wir manuell kopieren, dann werden wir generische Ansätze untersuchen.

6.3.1 Manuelles Deep Copying durch verschachteltes Spreizen

Wenn wir das Spreizen verschachteln, erhalten wir tiefe Kopien

const original = {name: 'Jane', work: {employer: 'Acme'}};
const copy = {name: original.name, work: {...original.work}};

// We copied successfully:
assert.deepEqual(original, copy);
// The copy is deep:
assert.ok(original.work !== copy.work);

6.3.2 Hack: Generisches Deep Copying durch JSON

Dies ist ein Hack, bietet aber im Notfall eine schnelle Lösung: Um ein Objekt original tief zu kopieren, konvertieren wir es zuerst in einen JSON-String und parsen diesen JSON-String

function jsonDeepCopy(original) {
  return JSON.parse(JSON.stringify(original));
}
const original = {name: 'Jane', work: {employer: 'Acme'}};
const copy = jsonDeepCopy(original);
assert.deepEqual(original, copy);

Der erhebliche Nachteil dieses Ansatzes ist, dass wir nur Eigenschaften mit Schlüsseln und Werten kopieren können, die von JSON unterstützt werden.

Einige nicht unterstützte Schlüssel und Werte werden einfach ignoriert

assert.deepEqual(
  jsonDeepCopy({
    // Symbols are not supported as keys
    [Symbol('a')]: 'abc',
    // Unsupported value
    b: function () {},
    // Unsupported value
    c: undefined,
  }),
  {} // empty object
);

Andere führen zu Ausnahmen

assert.throws(
  () => jsonDeepCopy({a: 123n}),
  /^TypeError: Do not know how to serialize a BigInt$/);

6.3.3 Implementierung von generischem Deep Copying

Die folgende Funktion kopiert einen Wert original generisch und tief

function deepCopy(original) {
  if (Array.isArray(original)) {
    const copy = [];
    for (const [index, value] of original.entries()) {
      copy[index] = deepCopy(value);
    }
    return copy;
  } else if (typeof original === 'object' && original !== null) {
    const copy = {};
    for (const [key, value] of Object.entries(original)) {
      copy[key] = deepCopy(value);
    }
    return copy;
  } else {
    // Primitive value: atomic, no need to copy
    return original;
  }
}

Die Funktion behandelt drei Fälle

Lassen Sie uns deepCopy() ausprobieren

const original = {a: 1, b: {c: 2, d: {e: 3}}};
const copy = deepCopy(original);

// Are copy and original deeply equal?
assert.deepEqual(copy, original);

// Did we really copy all levels
// (equal content, but different objects)?
assert.ok(copy     !== original);
assert.ok(copy.b   !== original.b);
assert.ok(copy.b.d !== original.b.d);

Beachten Sie, dass deepCopy() nur ein Problem des Spreizens löst: Shallow Copying. Alle anderen Probleme bleiben bestehen: Prototypen werden nicht kopiert, spezielle Objekte werden nur teilweise kopiert, nicht aufzählbare Eigenschaften werden ignoriert, die meisten Eigenschaftsattribute werden ignoriert.

Eine vollständige generische Implementierung des Kopierens ist im Allgemeinen unmöglich: Nicht alle Daten sind ein Baum, manchmal möchte man nicht alle Eigenschaften kopieren usw.

6.3.3.1 Eine prägnantere Version von deepCopy()

Wir können unsere vorherige Implementierung von deepCopy() prägnanter gestalten, wenn wir .map() und Object.fromEntries() verwenden

function deepCopy(original) {
  if (Array.isArray(original)) {
    return original.map(elem => deepCopy(elem));
  } else if (typeof original === 'object' && original !== null) {
    return Object.fromEntries(
      Object.entries(original)
        .map(([k, v]) => [k, deepCopy(v)]));
  } else {
    // Primitive value: atomic, no need to copy
    return original;
  }
}

6.4 Weiterführende Lektüre