Apprendre JavaScript de zéro jusqu'au niveau expert : syntaxe moderne, DOM, asynchrone et event loop, modules, prototypes, métaprogrammation, performance et mémoire.
JavaScript n'est pas un langage à classes. C'est un langage à prototypes : un objet délègue ce qu'il ne sait pas faire à un autre objet. Le mot-clé class, arrivé en 2015, n'est qu'une syntaxe plus familière posée sur ce mécanisme. Et this, souvent présenté comme imprévisible, suit en réalité quatre règles précises.
Chaque objet a un lien interne [[Prototype]] vers un autre objet (ou null). Quand on lit une propriété absente, JavaScript la cherche en remontant cette chaîne.
const animal = {
respirer() {
return `${this.nom} respire`
},
}
const chien = Object.create(animal) // chien → animal → Object.prototype → null
chien.nom = 'Rex'
chien.aboyer = function () {
return 'Wouf'
}
chien.respirer() // 'Rex respire' — trouvé sur animal
Object.getPrototypeOf(chien) === animal // true
Object.hasOwn(chien, 'respirer') // falseL'écriture, elle, ne remonte pas : chien.respirer = ... crée une propriété propre qui masque celle du prototype.
Les tableaux héritent de Array.prototype, qui hérite de Object.prototype :
const arr = [1, 2]
Object.getPrototypeOf(arr) === Array.prototype // true
Object.getPrototypeOf(Array.prototype) === Object.prototype // trueC'est pour cela que arr.map existe sans être défini sur arr.
Avant class, on écrivait :
function Personne(nom) {
this.nom = nom
}
Personne.prototype.saluer = function () {
return `Je suis ${this.nom}`
}
const ada = new Personne('Ada')new fait quatre choses : crée un objet vide, lie son prototype à Personne.prototype, appelle la fonction avec this = ce nouvel objet, renvoie l'objet.
La syntaxe class produit exactement la même structure, avec des garde-fous en plus (appel sans new interdit, mode strict).
class Compte {
static #nbComptes = 0 // champ statique privé
#solde = 0 // champ privé
titulaire
constructor(titulaire, soldeInitial = 0) {
this.titulaire = titulaire
this.#solde = soldeInitial
Compte.#nbComptes++
}
deposer(montant) {
if (montant <= 0) throw new RangeError('Montant positif requis')
this.#solde += montant
return this
}
get solde() {
return this.#solde
}
static get total() {
return Compte.#nbComptes
}
toString() {
return `${this.titulaire} : ${this.#solde} €`
}
}
const c = new Compte('Ada').deposer(100).deposer(50)
c.solde // 150
c.#solde // SyntaxError — réellement privé
Compte.total // 1
typeof Compte // 'function'Les champs # sont vraiment inaccessibles de l'extérieur, contrairement à la convention _prive.
class CompteEpargne extends Compte {
constructor(titulaire, taux) {
super(titulaire) // obligatoire avant d'utiliser this
this.taux = taux
}
appliquerInterets() {
return this.deposer(this.solde * this.taux)
}
toString() {
return `${super.toString()} (épargne ${this.taux * 100} %)`
}
}class HttpError extends Error {
constructor(status, message, options) {
super(message, options) // options.cause pour chaîner
this.name = 'HttpError'
this.status = status
}
}
try {
throw new HttpError(404, 'Article introuvable')
} catch (err) {
if (err instanceof HttpError && err.status === 404) afficher404()
else throw err
}this n'est pas lié à l'endroit où la fonction est écrite, mais à la façon dont elle est appelée. Sauf pour les fléchées.
| Appel | Valeur de this |
|---|---|
new F() | le nouvel objet |
f.call(obj), f.apply(obj), f.bind(obj) | obj |
obj.f() | obj |
f() | undefined en mode strict (globalThis sinon) |
| fonction fléchée | this de la portée englobante, figé |
class Minuteur {
secondes = 0
demarrer() {
setInterval(function () {
this.secondes++ // TypeError : this est undefined
}, 1000)
}
}La fonction est appelée par setInterval, sans objet devant le point. Trois corrections :
// 1. Fonction fléchée — this hérité de demarrer()
setInterval(() => this.secondes++, 1000)
// 2. bind
setInterval(function () { this.secondes++ }.bind(this), 1000)
// 3. Champ de classe fléché — utile pour les handlers passés en callback
class Bouton {
clics = 0
handleClick = () => {
this.clics++
}
}
el.addEventListener('click', new Bouton().handleClick)Même problème en extrayant une méthode :
const { deposer } = c
deposer(10) // TypeError : this est undefinedfunction presenter(salutation, ponctuation) {
return `${salutation}, je suis ${this.nom}${ponctuation}`
}
const ada = { nom: 'Ada' }
presenter.call(ada, 'Bonjour', '!')
presenter.apply(ada, ['Bonjour', '!'])
const presenterAda = presenter.bind(ada, 'Salut')
presenterAda('.') // 'Salut, je suis Ada.'const produit = {
_prix: 10,
get prixTTC() {
return this._prix * 1.2
},
set prix(v) {
if (v < 0) throw new RangeError('Prix négatif')
this._prix = v
},
}
Object.defineProperty(produit, 'id', {
value: 42,
writable: false,
enumerable: false,
configurable: false,
})
Object.getOwnPropertyDescriptor(produit, 'id')Chaque propriété a un descripteur : value, writable, enumerable, configurable (ou get / set). Object.freeze passe simplement toutes les propriétés en non modifiables.
L'héritage simple limite. Pour partager des comportements entre classes sans lien, une fonction qui renvoie une classe :
const Serialisable = Base => class extends Base {
toJSON() {
return { type: this.constructor.name, ...this }
}
}
const Horodate = Base => class extends Base {
cree = new Date()
}
class Article extends Serialisable(Horodate(Object)) {
constructor(titre) {
super()
this.titre = titre
}
}En pratique, je préfère la composition simple : un objet qui contient d'autres objets, plutôt qu'une pile de classes.
Implémenter une classe avec on, off, once et emit.
class Emitter {
#handlers = new Map()
on(event, fn) {
if (!this.#handlers.has(event)) this.#handlers.set(event, new Set())
this.#handlers.get(event).add(fn)
return () => this.off(event, fn) // renvoie une fonction de désinscription
}
off(event, fn) {
this.#handlers.get(event)?.delete(fn)
}
once(event, fn) {
const off = this.on(event, (...args) => {
off()
fn(...args)
})
return off
}
emit(event, ...args) {
for (const fn of [...(this.#handlers.get(event) ?? [])]) fn(...args)
}
}
const bus = new Emitter()
const stop = bus.on('message', m => console.log('reçu', m))
bus.once('message', m => console.log('une seule fois', m))
bus.emit('message', 'a') // reçu a, une seule fois a
bus.emit('message', 'b') // reçu b
stop()
bus.emit('message', 'c') // rienLa copie [...set] dans emit évite de modifier l'ensemble pendant qu'on le parcourt, ce qui arrive justement avec once.
Sous la syntaxe class il n'y a que des objets qui délèguent à d'autres objets. Et this dépend de l'appel, pas de la définition. Ces deux faits expliquent la quasi-totalité des surprises orientées objet en JavaScript.