Object spread vs. Object.assign
Par Delicious Insights • Publié le 18 mai 2020

En avant pour le quinzième article de notre série quotidienne « 19 pépites de JS pur », pour remettre au clair les rôles et spécificités respectives de la syntaxe object spread officialisée avec ES2018, en comparaison de l’API Object.assign(…) formalisée dans ES2015. Car ce n’est pas « bonnet blanc, blanc bonnet ».

Dans la série…

Extrait de la liste des articles :

  1. const is the new var
  2. Utiliser des captures nommées
  3. Object spread vs. Object.assign (cet article)
  4. Convertir un objet en Map et réciproquement
  5. La boucle for-of : s’il ne devait en rester qu’une…
  6. …au-delà, c’est la surprise ! (mais la liste est déjà calée)…

De extend(…) à assign(…)

On a toujours eu besoin, en JavaScript, de pouvoir copier des propriétés (et leurs valeurs) d’un objet vers un autre. Que ce soit pour construire une série d’options, caler un descripteur ou d’autres cas d’utilisation, c’est un scénario fréquent.

Vu que c’est un peu pénible à bien faire manuellement (boucle for…in, garde-fou hasOwnProperty(…), et j’en passe), on a rapidement vu des fonctions utilitaires pour ça dans les bibliothèques tierces, et finalement dans la bibliothèque standard du langage.

L’histoire de Object.assign(…) est déjà ancienne :

  1. Dès 2004, Prototype.js popularise Object.extend(…).
  2. On retrouve ça rapidement avec jQuery 1.0 et jQuery.extend(…) (le plus souvent appelé $.extend(…)), qui généralise la signature pour autoriser non plus un seul objet source, mais autant de sources qu’on veut, ce qui deviendra la signature de référence ; jQuery étend rapidement (1.1.4) sa sémantique en permettant sur demande une fusion récursive en plus de celle par défaut, superficielle.
  3. Underscore et Lodash proposent évidemment _.extend(…), là aussi avec un nombre quelconque d’objets sources (chez Lodash, on a aussi _.assign(…), beaucoup plus ancien et plus proche de ce que standardisera ES2015).
  4. En 2015, la bibliothèque standard du langage récupère, avec ES2015, Object.assign(…), qui copie toutes les propriétés énumérables propres (own enumerable properties) des sources vers la destination (en ayant le bon goût d’ignorer les arguments sources undefined, ce qui facilite notre code au passage).

Exemples rapides d’appels :

function run(options) {
  // Consolide dans un nouvel objet `options` (qui est vide,
  // `{}`, par défaut) les options à utiliser réellement, en
  // commençant par y copier les options par défaut et en
  // surchargeant par celles éventuellement passées.
  options = Object.assign({}, DEFAULT_OPTIONS, options)
  // …
}

const desc = {
  url: '/api/v1/register,
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json'
  }
}
// Étend / remplace les en-têtes de requête avec ceux
// éventuellement passés, mais en garantissant _in fine_
// certains en-têtes obligatoires (ex. relatifs à CORS).
Object.assign(desc.headers, customHeaders, ensuredHeaders)

ES2018 et le object spread

En concevant sa syntaxe JSX, React a proposé une extension vite très populaire : le spread des props, qui permet d'exploiter sous forme de props individuelles un objet les contenant :

function CoolBeans(props) {
  return (
    <button {...props} className="cool-btn">
      Cool beans!
    </button>
  )
}

ES2015 permettait certes le spread sur itérable (notamment sur Array), mais pas sur objet quelconque : il a fallu attendre ES2018 pour les Rest/Spread Properties. La syntaxe est utilisable exclusivement au sein d’un littéral objet, qui crée donc forcément un nouvel objet. Comme le spread sur itérable, on peut l’utiliser plusieurs fois au sein du littéral, et l’ordre n’a d’importance qu’en cas d’homonymie de propriétés spreadées : le dernier spread gagne.

const bruce = { first: 'John', last: 'McLane', city: 'NYC' }
const eddie = { first: 'Axel', city: 'Beverly Hills', birthday: '1959-08-25' }

const mashup = { ...bruce, ...eddie, birthday: '1977-11-04' }
// => { first: 'Axel', last: 'McLane', city: 'Beverly Hills',
//      birthday: '1977-11-04' }

Avec le spread sur objet, il devient facile de créer rapidement un objet dérivé d’un autre, ce qui rend cette syntaxe particulièrement populaire dans l’écriture de réducteurs immuables, par exemple avec Redux :

function reduceOverall(state, { type, payload }) {
  switch (type) {
    case TALLY_DAY:
      return {
        ...state,
        history: tally(state),
        progresses: {},
        today: dateToISO(),
      }
  }
}

La différence subtile

Tu pourrais croire que les deux codes ci-dessous sont, au final, équivalents :

let result = {
  /* … */
}
// v1…
Object.assign(result, {
  persisted: true,
  signature: hmac(result),
})
// …ou v2 ?
result = { ...result, persisted: true, signature: hmac(result) }

Dans cet exemple précis, tu n’auras en effet pas de différence opérationnelle. La cible du Object.assign(…) est un nouvel objet vide de type Object, sans accesseurs écrivains particuliers.

En revanche, lorsque ton code à base de Object.assign(…) écrit dans un objet plus avancé, doté d’accesseurs écrivains, ça n’a plus rien à voir : la deuxième version crée un nouvel objet de type Object, elle n’écrit pas dans result. Du coup, non seulement tu changes peut-être le type technique de l’objet (qui pouvait être une instance d’une classe à toi), mais tu zappes les garanties et comportements éventuels que ses écrivains mettaient en place.

class Person {
  constructor(first, last) {
    this.first = first
    this.last = last
  }

  get fullName() {
    return `${this.first} ${this.last}`
  }

  set fullName(value) {
    ;[this.first, this.last] = value.split(/\s+/)
  }
}

let result = new Person('Amélie', 'Poulain')
Object.assign(result, { fullName: 'Nino Quincampoix' })
result // => Person { first: 'Nino', last: 'Quincampoix' }
result.fullName // => 'Nino Quincampoix'
result instanceof Person // => true

result = { ...result, fullName: 'Raymond Dufayel' }
result
// => { first: 'Nino', last: 'Quincampoix', fullName: 'Raymon Dufayel' }
result instanceof Person // => false

Alors voilà, c’est pas tout à fait pareil. Mais c’est rarement un problème. Garde juste à l’esprit qu’un object spread te renvoie toujours un nouvel objet, de type Object, et qui ignorera donc superbement le type et les écrivains de l’objet d’origine.

Tu as aussi le cas où tu dois modifier l’objet d’origine, parce que le reste du code repose sur des comparaisons d’identité ; auquel cas, seul assign(…) est envisageable…

À chaque situation son approche : assign(…) ou spread.

C’est dispo où ?!

Pour Object.assign(…), à partir de Chrome 45, Firefox 34, Opera 32, Edge 12, Safari 9 et Node 4.

Pour le spread sur objet, à partir de Chrome 60, Firefox 55, Opera 47, Edge 79, Safari 11.1 et Node 8.3.

Naturellement, core-js et polyfill.io polyfillent le premier, et Babel transpile le second.

Découvrez notre cours vidéo : JavaScript : this is it ! 🖥

Tout savoir sur le fonctionnement de this en JavaScript, des règles fondamentales aux ajustements des API, en passant par les fonctions fléchées, le binding et bien plus encore…