Apprendre JavaScript de zéro jusqu'au niveau expert : syntaxe moderne, DOM, asynchrone et event loop, modules, prototypes, métaprogrammation, performance et mémoire.
Ce chapitre couvre les mécanismes qui permettent à un objet de s'intégrer au langage lui-même : être parcouru par for...of, réagir à chaque lecture de propriété, se comporter comme une primitive. Vue 3 construit toute sa réactivité sur Proxy. Redux-Saga repose sur les générateurs. Les comprendre, c'est pouvoir lire ces bibliothèques.
Un Symbol est une valeur primitive unique, utilisable comme clé de propriété sans risque de collision.
const ID = Symbol('id')
const user = { nom: 'Ada', [ID]: 42 }
user[ID] // 42
Object.keys(user) // ['nom'] — les symboles sont ignorés
JSON.stringify(user) // '{"nom":"Ada"}'
Symbol('id') === Symbol('id') // false
Symbol.for('app.id') === Symbol.for('app.id') // true — registre globalLes symboles bien connus permettent de brancher un objet sur les mécanismes du langage :
| Symbole | Utilisé par |
|---|---|
Symbol.iterator | for...of, spread, déstructuration |
Symbol.asyncIterator | for await...of |
Symbol.toPrimitive | conversions +obj, `${obj}` |
Symbol.toStringTag | Object.prototype.toString |
Symbol.hasInstance | instanceof |
class Monnaie {
constructor(centimes, devise) {
this.centimes = centimes
this.devise = devise
}
[Symbol.toPrimitive](hint) {
return hint === 'number' ? this.centimes / 100 : `${(this.centimes / 100).toFixed(2)} ${this.devise}`
}
}
const prix = new Monnaie(1999, 'EUR')
String(prix) // '19.99 EUR'
+prix // 19.99Un objet est itérable s'il possède une méthode [Symbol.iterator] qui renvoie un itérateur : un objet avec une méthode next() renvoyant { value, done }.
class Intervalle {
constructor(debut, fin, pas = 1) {
Object.assign(this, { debut, fin, pas })
}
[Symbol.iterator]() {
let courant = this.debut
const { fin, pas } = this
return {
next: () =>
courant <= fin
? { value: (courant += pas) - pas, done: false }
: { value: undefined, done: true },
}
}
}
for (const n of new Intervalle(1, 10, 3)) console.log(n) // 1, 4, 7, 10
const liste = [...new Intervalle(0, 4)] // [0, 1, 2, 3, 4]Une fonction function* renvoie un itérateur. yield suspend l'exécution et rend une valeur ; l'appel suivant à next() reprend juste après.
class Intervalle2 {
constructor(debut, fin, pas = 1) {
Object.assign(this, { debut, fin, pas })
}
*[Symbol.iterator]() {
for (let n = this.debut; n <= this.fin; n += this.pas) yield n
}
}Beaucoup plus court. Les générateurs sont paresseux : ils ne calculent que ce qu'on consomme.
function* naturels() {
let n = 0
while (true) yield n++
}
function* filtrer(iter, pred) {
for (const x of iter) if (pred(x)) yield x
}
function* prendre(iter, n) {
for (const x of iter) {
if (n-- <= 0) return
yield x
}
}
[...prendre(filtrer(naturels(), n => n % 7 === 0), 5)] // [0, 7, 14, 21, 28]Une suite infinie, filtrée et tronquée, sans jamais construire de tableau intermédiaire.
Les itérateurs ont désormais des méthodes natives :
naturels()
.filter(n => n % 7 === 0)
.map(n => n * n)
.take(5)
.toArray() // [0, 49, 196, 441, 784]next(valeur) injecte une valeur comme résultat du yield en cours :
function* dialogue() {
const nom = yield 'Comment tu t\'appelles ?'
const age = yield `Enchanté ${nom}, quel âge ?`
return `${nom}, ${age} ans`
}
const g = dialogue()
g.next().value // 'Comment tu t'appelles ?'
g.next('Ada').value // 'Enchanté Ada, quel âge ?'
g.next(36).value // 'Ada, 36 ans'C'est exactement le mécanisme qu'utilisaient les bibliothèques comme co pour simuler async/await avant qu'il existe.
async function* paginer(url) {
while (url) {
const res = await fetch(url)
const { items, next } = await res.json()
yield* items
url = next
}
}
for await (const article of paginer('/api/articles?page=1')) {
console.log(article.titre)
if (article.id === cible) break // les pages suivantes ne sont jamais chargées
}Un Proxy enveloppe un objet et intercepte les opérations fondamentales : lecture, écriture, suppression, in, appel de fonction…
const cible = { nom: 'Ada' }
const proxy = new Proxy(cible, {
get(obj, prop, receiver) {
console.log(`lecture de ${String(prop)}`)
return Reflect.get(obj, prop, receiver)
},
set(obj, prop, valeur, receiver) {
console.log(`écriture de ${String(prop)} = ${valeur}`)
return Reflect.set(obj, prop, valeur, receiver)
},
})
proxy.nom // lecture de nom
proxy.age = 36 // écriture de age = 36Reflect fournit l'implémentation par défaut de chaque opération. L'utiliser dans les pièges garantit un comportement correct avec les getters et l'héritage.
function schema(regles) {
return new Proxy({}, {
set(obj, prop, valeur) {
const regle = regles[prop]
if (!regle) throw new TypeError(`Propriété inconnue : ${prop}`)
if (!regle(valeur)) throw new TypeError(`Valeur invalide pour ${prop} : ${valeur}`)
obj[prop] = valeur
return true
},
})
}
const user = schema({
email: v => /^[^@\s]+@[^@\s]+$/.test(v),
age: v => Number.isInteger(v) && v >= 0,
})
user.email = 'ada@example.com'
user.age = -1 // TypeError: Valeur invalide pour age : -1
user.role = 'admin' // TypeError: Propriété inconnue : roleLe cœur de la réactivité de Vue 3 tient en quelques lignes :
let effetActif = null
const dependances = new WeakMap() // objet → Map(propriété → Set(effets))
function suivre(obj, prop) {
if (!effetActif) return
let parProp = dependances.get(obj)
if (!parProp) dependances.set(obj, (parProp = new Map()))
let effets = parProp.get(prop)
if (!effets) parProp.set(prop, (effets = new Set()))
effets.add(effetActif)
}
function declencher(obj, prop) {
dependances.get(obj)?.get(prop)?.forEach(effet => effet())
}
function reactive(obj) {
return new Proxy(obj, {
get(o, p, r) {
suivre(o, p)
return Reflect.get(o, p, r)
},
set(o, p, v, r) {
const ok = Reflect.set(o, p, v, r)
declencher(o, p)
return ok
},
})
}
function effect(fn) {
effetActif = fn
fn()
effetActif = null
}
const etat = reactive({ compteur: 0 })
effect(() => console.log(`compteur = ${etat.compteur}`)) // compteur = 0
etat.compteur++ // compteur = 1
etat.compteur++ // compteur = 2L'effet enregistre les propriétés qu'il lit. Toute écriture sur l'une d'elles le relance. La comparaison des modèles de réactivité de Vue et React est détaillée dans React contre Vue.
Une WeakMap n'empêche pas ses clés (des objets) d'être libérées par le ramasse-miettes. Idéal pour associer des métadonnées à un objet sans provoquer de fuite mémoire.
const metadonnees = new WeakMap()
function marquer(element, info) {
metadonnees.set(element, info)
}
// quand element est retiré du DOM et plus référencé, l'entrée disparaîtPas de .size, pas d'itération : c'est le prix de cette propriété.
WeakRef et FinalizationRegistry vont plus loin mais leur comportement dépend du ramasse-miettes et n'est pas déterministe. Je les réserve aux caches de bibliothèques bas niveau.
Les décorateurs sont en phase finale de standardisation et déjà utilisables avec TypeScript 5+ et Babel :
function journaliser(methode, contexte) {
return function (...args) {
console.log(`→ ${contexte.name}(${args.join(', ')})`)
return methode.apply(this, args)
}
}
class Calcul {
@journaliser
additionner(a, b) {
return a + b
}
}Écrire une fonction observable(obj, onChange) qui renvoie un proxy profond : une modification à n'importe quelle profondeur appelle onChange(chemin, valeur).
function observable(obj, onChange, chemin = []) {
return new Proxy(obj, {
get(o, p, r) {
const v = Reflect.get(o, p, r)
return v !== null && typeof v === 'object' ? observable(v, onChange, [...chemin, p]) : v
},
set(o, p, v, r) {
const ok = Reflect.set(o, p, v, r)
onChange([...chemin, p].join('.'), v)
return ok
},
})
}
const state = observable({ user: { prefs: { theme: 'clair' } } }, (c, v) => console.log(c, '→', v))
state.user.prefs.theme = 'sombre' // user.prefs.theme → sombreAmélioration à faire : mettre les proxies en cache dans une WeakMap pour ne pas recréer un proxy à chaque lecture.
Itérateurs pour le parcours, Proxy pour l'interception, WeakMap pour les métadonnées. Ce sont les briques avec lesquelles les frameworks sont construits. On les écrit rarement dans du code applicatif, mais on les rencontre partout dès qu'on ouvre le code d'une bibliothèque.