L'avenir de JavaScript: les classes





Bonne journée, mes amis!



Aujourd'hui je souhaite vous parler de trois propositions liées aux classes JavaScript qui sont en 3 étapes de réflexion:





Étant donné que ces propositions sont entièrement conformes à la logique du développement ultérieur des classes et utilisent la syntaxe existante, vous pouvez être sûr qu'elles seront standardisées sans aucun changement majeur. Ceci est également démontré par l'implémentation des «fonctionnalités» nommées dans les navigateurs modernes.



Rappelons-nous quelles classes sont en JavaScript.



Pour la plupart, les classes sont ce que l'on appelle du «sucre syntaxique» (abstraction ou, plus simplement, un wrapper) pour les fonctions constructeurs. Ces fonctions sont utilisées pour implémenter le modèle de conception Constructor. Ce modèle, à son tour, est implémenté (en JavaScript) à l'aide du modèle d'héritage prototypique. Le modèle d'héritage prototypique est parfois défini comme un modèle «Prototype» autonome. Vous pouvez en savoir plus sur les modèles de conception ici .



Qu'est-ce qu'un prototype? C'est un objet qui agit comme un plan ou un plan pour d'autres objets - des instances. Un constructeur est une fonction qui vous permet de créer des objets d'instance basés sur un prototype (classe, superclasse, classe abstraite, etc.). Le processus de transmission des propriétés et des fonctions d'un prototype à une instance est appelé héritage. Les propriétés et les fonctions de la terminologie de classe sont généralement appelées champs et méthodes, mais, de facto, elles sont identiques.



À quoi ressemble une fonction constructeur?



//      
'use strict'
function Counter(initialValue = 0) {
  this.count = initialValue
  //   ,   this
  console.log(this)
}

      
      





Nous définissons une fonction "Counter" qui prend un paramètre "initialValue" avec une valeur par défaut de 0. Ce paramètre est affecté à la propriété d'instance "count" lorsque l'instance est initialisée. Le contexte "this" dans ce cas est l'objet créé (retourné) par la fonction. Afin de dire à JavaScript d'appeler non seulement une fonction, mais une fonction constructeur, vous devez utiliser le mot-clé "new":



const counter = new Counter() // { count: 0, __proto__: Object }

      
      





Comme nous pouvons le voir, la fonction constructeur retourne un objet avec une propriété que nous avons définie "count" et un prototype (__proto__) comme un objet global "Object", auquel les chaînes prototypes de presque tous les types (données) en JavaScript remontent (sauf pour les objets sans prototype créés à l'aide d'Object.create (null)). C'est pourquoi ils disent qu'en JavaScript "tout est un objet".



L'appel d'une fonction constructeur sans "new" lèvera une "TypeError" (erreur de type) indiquant que "la propriété 'count' ne peut pas être assignée indéfinie":



const counter = Counter() // TypeError: Cannot set property 'count' of undefined

//   
const counter = Counter() // Window

      
      





Cela est dû au fait que la valeur "this" à l'intérieur d'une fonction est "indéfinie" en mode strict et l'objet global "Window" en mode non strict.



Ajoutons des méthodes distribuées (partagées, communes à toutes les instances) à la fonction constructeur pour augmenter, diminuer, réinitialiser et obtenir la valeur du compteur:



Counter.prototype.increment = function () {
  this.count += 1
  //  this,        
  return this
}

Counter.prototype.decrement = function () {
  this.count -= 1
  return this
}

Counter.prototype.reset = function () {
  this.count = 0
  return this
}

Counter.prototype.getInfo = function () {
  console.log(this.count)
  return this
}

      
      





Si vous définissez des méthodes dans la fonction constructeur elle-même, et non dans son prototype, alors pour chaque instance, ses propres méthodes seront créées, ce qui peut rendre difficile la modification ultérieure de la fonctionnalité des instances. Auparavant, cela pouvait également entraîner des problèmes de performances.



L'ajout de plusieurs méthodes au prototype d'une fonction constructeur peut être optimisé comme suit:



;(function () {
  this.increment = function () {
    this.count += 1
    return this
  }

  this.decrement = function () {
    this.count -= 1
    return this
  }

  this.reset = function () {
    this.count = 0
    return this
  }

  this.getInfo = function () {
    console.log(this.count)
    return this
  }
//     -
// https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/Function/call
}.call(Counter.prototype))

      
      





Ou vous pouvez le rendre encore plus facile:



//   ,     
Object.assign(Counter.prototype, {
  increment() {
    this.count += 1
    return this
  },

  decrement() {
    this.count -= 1
    return this
  },

  reset() {
    this.count = 0
    return this
  },

  getInfo() {
    console.log(this.count)
    return this
  }
})

      
      





Utilisons nos méthodes:



counter
  .increment()
  .increment()
  .getInfo() // 2
  .decrement()
  .getInfo() // 1
  .reset()
  .getInfo() // 0

      
      





La syntaxe de la classe est plus concise:



class _Counter {
  constructor(initialValue = 0) {
    this.count = initialValue
  }

  increment() {
    this.count += 1
    return this
  }

  decrement() {
    this.count -= 1
    return this
  }

  reset() {
    this.count = 0
    return this
  }

  getInfo() {
    console.log(this.count)
    return this
  }
}

const _counter = new _Counter()
_counter
  .increment()
  .increment()
  .getInfo() // 2
  .decrement()
  .getInfo() // 1
  .reset()
  .getInfo() // 0

      
      





Examinons un exemple plus complexe pour illustrer le fonctionnement de l'héritage JavaScript. Créons une classe "Person" et sa sous-classe "SubPerson".



La classe Person définit les propriétés firstName, lastName et age, ainsi que getFullName (obtenir le prénom et le nom), getAge (obtenir l'âge) et saySomething »(énonçant une phrase).



La sous-classe SubPerson hérite de toutes les propriétés et méthodes de Person, et définit également de nouveaux champs pour le style de vie, les compétences et les intérêts, ainsi que de nouvelles méthodes getInfo pour obtenir le nom complet en appelant la méthode héritée par les parents "getFullName" et lifestyle), " getSkill "(obtenir une compétence)," getLike "(obtenir un passe-temps) et" setLike "(définir un passe-temps).



Fonction constructeur:



const log = console.log

function Person({ firstName, lastName, age }) {
  this.firstName = firstName
  this.lastName = lastName
  this.age = age
}

;(function () {
  this.getFullName = function () {
    log(`   ${this.firstName} ${this.lastName}`)
    return this
  }
  this.getAge = function () {
    log(`  ${this.age} `)
    return this
  }
  this.saySomething = function (phrase) {
    log(`  : "${phrase}"`)
    return this
  }
}.call(Person.prototype))

const person = new Person({
  firstName: '',
  lastName: '',
  age: 30
})

person.getFullName().getAge().saySomething('!')
/*
      
    30 
    : "!"
*/

function SubPerson({ lifestyle, skill, ...rest }) {
  //   Person   SubPerson    
  Person.call(this, rest)
  this.lifestyle = lifestyle
  this.skill = skill
  this.interest = null
}

//   Person  SubPerson
SubPerson.prototype = Object.create(Person.prototype)
//      
Object.assign(SubPerson.prototype, {
  getInfo() {
    this.getFullName()
    log(` ${this.lifestyle}`)
    return this
  },

  getSkill() {
    log(` ${this.lifestyle}  ${this.skill}`)
    return this
  },

  getLike() {
    log(
      ` ${this.lifestyle} ${
        this.interest ? ` ${this.interest}` : '  '
      }`
    )
    return this
  },

  setLike(value) {
    this.interest = value
    return this
  }
})

const developer = new SubPerson({
  firstName: '',
  lastName: '',
  age: 25,
  lifestyle: '',
  skill: '   JavaScript'
})

developer
  .getInfo()
  .getAge()
  .saySomething(' -  !')
  .getSkill()
  .getLike()
/*
      
   
    25 
    : " -  !"
        JavaScript
      
*/

developer.setLike(' ').getLike()
//     

      
      





Classe:



const log = console.log

class _Person {
  constructor({ firstName, lastName, age }) {
    this.firstName = firstName
    this.lastName = lastName
    this.age = age
  }

  getFullName() {
    log(`   ${this.firstName} ${this.lastName}`)
    return this
  }

  getAge() {
    log(`  ${this.age} `)
    return this
  }

  saySomething(phrase) {
    log(`  : "${phrase}"`)
    return this
  }
}

const _person = new Person({
  firstName: '',
  lastName: '',
  age: 30
})

_person.getFullName().getAge().saySomething('!')
/*
      
    30 
    : "!"
*/

class _SubPerson extends _Person {
  constructor({ lifestyle, skill /*, ...rest*/ }) {
    //  super()    Person.call(this, rest)
    // super(rest)
    super()
    this.lifestyle = lifestyle
    this.skill = skill
    this.interest = null
  }

  getInfo() {
    // super.getFullName()
    this.getFullName()
    log(` ${this.lifestyle}`)
    return this
  }

  getSkill() {
    log(` ${this.lifestyle}  ${this.skill}`)
    return this
  }

  get like() {
    log(
      ` ${this.lifestyle} ${
        this.interest ? ` ${this.interest}` : '  '
      }`
    )
  }

  set like(value) {
    this.interest = value
  }
}

const _developer = new SubPerson({
  firstName: '',
  lastName: '',
  age: 25,
  lifestyle: '',
  skill: '   JavaScript'
})

_developer
  .getInfo()
  .getAge()
  .saySomething(' -  !')
  .getSkill().like
/*
      
   
    25 
    : " -  !"
        JavaScript
      
*/

developer.like = ' '
developer.like
//     

      
      





Je pense que tout est clair ici. Passer à autre chose.



Le principal problème d'héritage dans JavaScript était et est toujours le manque d'héritage multiple intégré, c'est-à-dire la capacité d'une sous-classe d'hériter des propriétés et des méthodes de plusieurs classes en même temps. Bien sûr, puisque tout est possible en JavaScript, nous pouvons simuler l'héritage multiple, par exemple, en utilisant ce mixin:



// https://www.typescriptlang.org/docs/handbook/mixins.html
function applyMixins(derivedCtor, constructors) {
  constructors.forEach((baseCtor) => {
    Object.getOwnPropertyNames(baseCtor.prototype).forEach((name) => {
      Object.defineProperty(
        derivedCtor.prototype,
        name,
        Object.getOwnPropertyDescriptor(baseCtor.prototype, name) ||
          Object.create(null)
      )
    })
  })
}

class A {
  sayHi() {
    console.log(`${this.name} : "!"`)
  }
  sameName() {
    console.log('  ')
  }
}

class B {
  sayBye() {
    console.log(`${this.name} : "!"`)
  }
  sameName() {
    console.log('  B')
  }
}

class C {
  name = ''
}

applyMixins(C, [A, B])

const c = new C()

//  ,    A
c.sayHi() //  : "!"

//  ,    B
c.sayBye() //  : "!"

//     
c.sameName() //   B

      
      





Cependant, ce n'est pas une solution complète et c'est juste un hack pour insérer JavaScript dans le cadre de la programmation orientée objet.



Passons directement aux innovations offertes par les propositions indiquées en début d'article.



Aujourd'hui, compte tenu des fonctionnalités standardisées, la syntaxe de la classe ressemble à ceci:



const log = console.log

class C {
  constructor() {
    this.publicInstanceField = '  '
    this.#privateInstanceField = '  '
  }

  publicInstanceMethod() {
    log('  ')
  }

  //     
  getPrivateInstanceField() {
    log(this.#privateInstanceField)
  }

  static publicClassMethod() {
    log('  ')
  }
}

const c = new C()

console.log(c.publicInstanceField) //   

//         
// console.log(c.#privateInstanceField) // SyntaxError: Private field '#privateInstanceField' must be declared in an enclosing class

c.getPrivateInstanceField() //   

c.publicInstanceMethod() //   

C.publicClassMethod() //   

      
      





Il s'avère que nous pouvons définir des champs publics et privés et des méthodes publiques d'instances, ainsi que des méthodes publiques d'une classe, mais nous ne pouvons pas définir des méthodes privées d'instances, ainsi que des champs publics et privés d'une classe. Eh bien, en fait, il est toujours possible de définir un champ public d'une classe:



C.publicClassField = '  '
console.log(C.publicClassField) //   

      
      





Mais, vous devez admettre que cela n'a pas l'air très bien. Il semble que nous soyons de retour au travail avec des prototypes.



La première proposition vous permet de définir des champs d'instance publics et privés sans utiliser de constructeur:



publicInstanceField = '  '
#privateInstanceField = '  '

      
      





La deuxième proposition vous permet de définir des méthodes d'instance privée:



#privateInstanceMethod() {
  log('  ')
}

//    
getPrivateInstanceMethod() {
  this.#privateInstanceMethod()
}

      
      





Et enfin, la troisième proposition vous permet de définir des champs publics et privés (statiques), ainsi que des méthodes privées (statiques) d'une classe:



static publicClassField = '  '
static #privateClassField = '  '

static #privateClassMethod() {
  log('  ')
}

//     
static getPrivateClassField() {
  log(C.#privateClassField)
}

//    
static getPrivateClassMethod() {
  C.#privateClassMethod()
}

      
      





Voici à quoi ressemblera l'ensemble complet (en fait, il a déjà l'air):



const log = console.log

class C {
  // class field declarations
  // https://github.com/tc39/proposal-class-fields
  publicInstanceField = '  '

  #privateInstanceField = '  '

  publicInstanceMethod() {
    log('  ')
  }

  // private methods and getter/setters
  // https://github.com/tc39/proposal-private-methods
  #privateInstanceMethod() {
    log('  ')
  }

  //     
  getPrivateInstanceField() {
    log(this.#privateInstanceField)
  }

  //    
  getPrivateInstanceMethod() {
    this.#privateInstanceMethod()
  }

  // static class features
  // https://github.com/tc39/proposal-static-class-features
  static publicClassField = '  '
  static #privateClassField = '  '

  static publicClassMethod() {
    log('  ')
  }

  static #privateClassMethod() {
    log('  ')
  }

  //     
  static getPrivateClassField() {
    log(C.#privateClassField)
  }

  //    
  static getPrivateClassMethod() {
    C.#privateClassMethod()
  }

  //         
  getPublicAndPrivateClassFieldsFromInstance() {
    log(C.publicClassField)
    log(C.#privateClassField)
  }

  //         
  static getPublicAndPrivateInstanceFieldsFromClass() {
    log(this.publicInstanceField)
    log(this.#privateInstanceField)
  }
}

const c = new C()

console.log(c.publicInstanceField) //   

//           
// console.log(c.#privateInstanceField) // SyntaxError: Private field '#privateInstanceField' must be declared in an enclosing class

c.getPrivateInstanceField() //   

c.publicInstanceMethod() //   

//          
// c.#privateInstanceMethod() // Error

c.getPrivateInstanceMethod() //   

console.log(C.publicClassField) //   

// console.log(C.#privateClassField) // Error

C.getPrivateClassField() //   

C.publicClassMethod() //   

// C.#privateClassMethod() // Error

C.getPrivateClassMethod() //   

c.getPublicAndPrivateClassFieldsFromInstance()
//   
//   

//        ,
//         
// C.getPublicAndPrivateInstanceFieldsFromClass()
// undefined
// TypeError: Cannot read private member #privateInstanceField from an object whose class did not declare it

      
      





Tout irait bien, mais il y a une nuance intéressante: les champs privés ne sont pas hérités. Dans TypeScript et d'autres langages de programmation, il existe une propriété spéciale, généralement appelée «protégée», qui n'est pas accessible directement, mais qui peut être héritée avec les propriétés publiques.



Il convient de noter que les mots «privé», «public» et «protégé» sont des mots réservés en JavaScript. Si vous essayez de les utiliser en mode strict, une exception est levée:



const private = '' // SyntaxError: Unexpected strict mode reserved word
const public = '' // Error
const protected = '' // Error

      
      





Par conséquent, l'espoir de l'implémentation de champs de classe protégés dans un avenir lointain demeure.



J'attire votre attention sur le fait que la technique d'encapsulation des variables, i.e. leur protection contre les accès extérieurs est aussi ancienne que JavaScript lui-même. Avant la standardisation des champs de classe privée, les fermetures étaient couramment utilisées pour masquer les variables, ainsi que les modèles de conception Factory et Module. Regardons ces modèles en utilisant l'exemple d'un panier d'achat.



Module:



const products = [
  {
    id: '1',
    title: '',
    price: 50
  },
  {
    id: '2',
    title: '',
    price: 150
  },
  {
    id: '3',
    title: '',
    price: 100
  }
]

const cartModule = (() => {
  let cart = []

  function getProductCount() {
    return cart.length
  }

  function getTotalPrice() {
    return cart.reduce((total, { price }) => (total += price), 0)
  }

  return {
    addProducts(products) {
      products.forEach((product) => {
        cart.push(product)
      })
    },
    removeProduct(obj) {
      for (const key in obj) {
        cart = cart.filter((prod) => prod[key] !== obj[key])
      }
    },
    getInfo() {
      console.log(
        `  ${getProductCount()} ()  ${
          getProductCount() > 1 ? ' ' : ''
        } ${getTotalPrice()} `
      )
    }
  }
})()

//       
console.log(cartModule) // { addProducts: ƒ, removeProduct: ƒ, getInfo: ƒ }

//    
cartModule.addProducts(products)
cartModule.getInfo()
//   3 ()    300 

//     2
cartModule.removeProduct({ id: '2' })
cartModule.getInfo()
//   2 ()    150 

//        
console.log(cartModule.cart) // undefined
// cartModule.getProductCount() // TypeError: cartModule.getProductCount is not a function

      
      





Usine:



function cartFactory() {
  let cart = []

  function getProductCount() {
    return cart.length
  }

  function getTotalPrice() {
    return cart.reduce((total, { price }) => (total += price), 0)
  }

  return {
    addProducts(products) {
      products.forEach((product) => {
        cart.push(product)
      })
    },
    removeProduct(obj) {
      for (const key in obj) {
        cart = cart.filter((prod) => prod[key] !== obj[key])
      }
    },
    getInfo() {
      console.log(
        `  ${getProductCount()} ()  ${
          getProductCount() > 1 ? ' ' : ''
        } ${getTotalPrice()} `
      )
    }
  }
}

const cart = cartFactory()

cart.addProducts(products)
cart.getInfo()
//   3 ()    300 

cart.removeProduct({ title: '' })
cart.getInfo()
//   2 ()   200 

console.log(cart.cart) // undefined
// cart.getProductCount() // TypeError: cart.getProductCount is not a function

      
      





Classe:



class Cart {
  #cart = []

  #getProductCount() {
    return this.#cart.length
  }

  #getTotalPrice() {
    return this.#cart.reduce((total, { price }) => (total += price), 0)
  }

  addProducts(products) {
    this.#cart.push(...products)
  }

  removeProduct(obj) {
    for (const key in obj) {
      this.#cart = this.#cart.filter((prod) => prod[key] !== obj[key])
    }
  }

  getInfo() {
    console.log(
      `  ${this.#getProductCount()} ()  ${
        this.#getProductCount() > 1 ? ' ' : ''
      } ${this.#getTotalPrice()} `
    )
  }
}

const _cart = new Cart()

_cart.addProducts(products)
_cart.getInfo()
//   3 ()    300 

_cart.removeProduct({ id: '1', price: 100 })
_cart.getInfo()
//   1 ()    150 

console.log(_cart.cart) // undefined
// console.log(_cart.#cart) // SyntaxError: Private field '#cart' must be declared in an enclosing class
// _cart.getTotalPrice() // TypeError: cart.getTotalPrice is not a function
// _cart.#getTotalPrice() // Error

      
      





Comme on peut le voir, les motifs "Module" et "Factory" ne sont en aucun cas inférieurs à la classe, si ce n'est que la syntaxe de cette dernière est un peu plus concise, mais permet d'abandonner complètement l'utilisation du mot-clé "this" , dont le principal problème est la perte de contexte lorsqu'il est utilisé dans les fonctions fléchées et les gestionnaires d'événements. Cela nécessite de les lier à une instance dans le constructeur.



Enfin, regardons un exemple de création d'un composant Web de bouton en utilisant la syntaxe de classe (à partir du texte de l'une des phrases avec une légère modification).



Notre composant étend l'élément HTML intégré du bouton, ajoutant ce qui suit à sa fonctionnalité: lorsque le bouton est cliqué avec le bouton gauche, la valeur du compteur est augmentée de 1, lorsque le bouton est cliqué avec le bouton droit, la valeur du compteur est diminuée de 1. En même temps, nous pouvons utiliser n'importe quel nombre de boutons avec leur propre contexte et état:



// https://developer.mozilla.org/ru/docs/Web/Web_Components
class Counter extends HTMLButtonElement {
  #xValue = 0

  get #x() {
    return this.#xValue
  }

  set #x(value) {
    this.#xValue = value
    //     
    // https://developer.mozilla.org/ru/docs/DOM/window.requestAnimationFrame
    // https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/Function/bind
    requestAnimationFrame(this.#render.bind(this))
  }

  #increment() {
    this.#x++
  }

  #decrement(e) {
    //    
    e.preventDefault()
    this.#x--
  }

  constructor() {
    super()
    //     
    this.onclick = this.#increment.bind(this)
    this.oncontextmenu = this.#decrement.bind(this)
  }

  //    React/Vue ,  ,    DOM
  connectedCallback() {
    this.#render()
  }

  #render() {
    //    ,  0 -   
    this.textContent = `${this.#x} - ${
      this.#x < 0 ? '' : ''
    } ${this.#x & 1 ? '' : ''} `
  }
}

//  -
customElements.define('btn-counter', Counter, { extends: 'button' })

      
      





Résultat:







Il semble que, d'une part, les classes ne seront pas largement acceptées dans la communauté des développeurs tant qu'elles ne seront pas résolues, appelons cela «ce problème». Ce n'est pas par hasard qu'après une longue période d'utilisation des classes (composants de classe), l'équipe React les a abandonnées au profit de fonctions (hooks). Une tendance similaire est observée dans l'API Vue Composition. D'autre part, de nombreux développeurs ECMAScript, ingénieurs en composants Web chez Google et l'équipe TypeScript travaillent activement sur le développement du composant "orienté objet" de JavaScript, vous ne devriez donc pas réduire les cours au cours des prochaines années.



Tout le code de l'article est ici .



Vous pouvez en savoir plus sur JavaScript orienté objet ici .



L'article s'est avéré être légèrement plus long que prévu, mais j'espère que vous étiez intéressé. Merci pour votre attention et bonne journée.



All Articles