Une note sur le fonctionnement des hooks dans React





Bonne journée, mes amis!



Je souhaite partager avec vous quelques idées sur le fonctionnement de React, à savoir les hypothèses sur les raisons pour lesquelles les hooks ne peuvent pas être utilisés dans les ifs, les boucles, les fonctions régulières, etc. Et ne peuvent-ils vraiment pas être utilisés de cette manière?



La question est la suivante: pourquoi les crochets ne peuvent-ils être utilisés qu’au plus haut niveau? Voici ce que dit la documentation officielle à ce sujet.



Commençons par les règles d'utilisation des hooks .



Utilisez des crochets uniquement au niveau supérieur (mettez en évidence les points clés auxquels faire attention):



«N'appelez pas de hooks dans des boucles, des conditionnelles ou des fonctions imbriquées. Au lieu de cela, utilisez toujours les hooks uniquement dans les fonctions React, avant de renvoyer une valeur à partir de celles-ci. Cette règle garantit que les hooks sont appelés dans la même séquence chaque fois que le composant est rendu . Cela permettra à React de conserver correctement l'état de hook entre plusieurs appels à useState et useEffect. (Si vous êtes intéressé, vous trouverez ci-dessous une explication détaillée.) "



Nous sommes intéressés, voir ci-dessous.



Explication (exemples omis par souci de concision):



"… React useState? : React .… , React . , ?… . React , useState. React , persistForm, , . , , , , .… .… , ..."



Dégager? Oui, en quelque sorte pas beaucoup. Que voulez-vous dire, "React repose sur l'ordre dans lequel les hooks sont appelés"? Comment fait-il? Qu'est-ce que cette «sorte d'état intérieur»? Quelles sont les erreurs causées par l'absence d'un hook lors du re-rendu? Ces erreurs sont-elles essentielles au fonctionnement de l'application?



Y a-t-il autre chose dans la documentation à ce sujet? Il y a une section spéciale "Hooks: Answers to Questions" . Là, nous trouvons ce qui suit.



Comment React lie-t-il les appels de hook à un composant?



«React , .… , . JavaScript-, . , useState(), ( ) . useState() .»



Déjà quelque chose. Une liste interne des emplacements de mémoire associés aux composants et contenant certaines données. Le hook lit la valeur de la cellule actuelle et déplace le pointeur vers la suivante. Quelle structure de données cela vous rappelle-t-il? Peut-être parlons-nous d'une liste liée (liée) .



Si tel est effectivement le cas, alors la séquence de crochets que React génère lors du premier rendu ressemble à ceci (imaginez que les rectangles sont des crochets, chaque crochet contient un pointeur vers le suivant):





Super, nous avons une hypothèse de travail qui semble plus ou moins raisonnable. Comment le vérifions-nous? Une hypothèse est une hypothèse, mais je veux des faits. Et pour les faits, vous devez vous rendre sur GitHub, dans le référentiel des sources de React .



Ne pensez pas que j'ai immédiatement décidé de faire un pas aussi désespéré. Bien sûr, d'abord, à la recherche de réponses à mes questions, je me suis tourné vers l'omniscient Google. Voici ce que nous avons trouvé:





Toutes ces sources font référence aux sources React. J'ai dû creuser un peu dedans. Donc, la thèse et l'exemple de "useState".



UseState () et d'autres hooks sont implémentés dans ReactHooks.js :



export function useState<S>(
  initialState: (() => S) | S
): [S, Dispatch<BasicStateAction<S>>] {
  const dispatcher = resolveDispatcher()
  return dispatcher.useState(initialState)
}

      
      





Un répartiteur est utilisé pour appeler useState () (et d'autres hooks). Au début du même fichier, nous voyons ce qui suit:



import ReactCurrentDispatcher from './ReactCurrentDispatcher'

function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current

  return ((dispatcher: any): Dispatcher)
}

      
      





Le répartiteur utilisé pour appeler useState () (et d'autres hooks) est la valeur de la propriété "current" de l'objet "ReactCurrentDispatcher", qui est importé depuis ReactCurrentDispatcher.js :



import type { Dispatcher } from 'react-reconciler/src/ReactInternalTypes'

const ReactCurrentDispatcher = {
  current: (null: null | Dispatcher)
}

export default ReactCurrentDispatcher

      
      





ReactCurrentDispatcher est un objet vide avec une propriété "courante". Cela signifie qu'il est initialisé ailleurs. Mais où exactement? Astuce: les importations de type "Dispatcher" indiquent que le répartiteur actuel a quelque chose à voir avec les composants internes de React. En effet, c'est ce que l'on trouve dans ReactFiberHooks.new.js (le numéro dans le commentaire est le numéro de ligne):



// 118
const { ReactCurrentDispatcher, ReactCurrentBatchConfig } = ReactSharedInternals

      
      





Cependant, dans ReactSharedInternals.js, nous rencontrons des "données internes secrètes qui pourraient être déclenchées pour l'utilisation":



const ReactSharedInternals =
  React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED

export default ReactSharedInternals

      
      





Est-ce tout? Notre quête a-t-elle pris fin avant de pouvoir commencer? Pas vraiment. Nous ne connaîtrons pas les détails de l'implémentation interne de React, mais nous n'en avons pas besoin pour comprendre comment React gère les hooks. De retour dans ReactFiberHooks.new.js:



// 405
ReactCurrentDispatcher.current =
  current === null || current.memoizedState === null
    ? HooksDispatcherOnMount
    : HooksDispatcherOnUpdate

      
      





Le répartiteur utilisé pour appeler les hooks est en fait deux répartiteurs différents - HooksDispatcherOnMount (lors du montage) et HooksDispatcherOnUpdate (lors de la mise à jour, de nouveau rendu).



// 2086
const HooksDispatcherOnMount: Dispatcher = {
  useState: mountState,
  //     -
}

// 2111
const HooksDispatcherOnUpdate: Dispatcher = {
  useState: updateState,
  //     -
}

      
      





La séparation montage / mise à jour est maintenue au niveau du crochet.



function mountState<S>(
  initialState: (() => S) | S
): [S, Dispatch<BasicStateAction<S>>] {
  //   
  const hook = mountWorkInProgressHook()
  //      
  if (typeof initialState === 'function') {
    initialState = initialState()
  }
  //       
  //          
  hook.memoizedState = hook.baseState = initialState
  //        
  //     
  const queue = (hook.queue = {
    pending: null,
    interleaved: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any)
  })
  //   -     (setState)
  const dispatch: Dispatch<
    BasicStateAction<S>
  > = (queue.dispatch = (dispatchAction.bind(
    null,
    currentlyRenderingFiber,
    queue
  ): any))
  //  ,     ,      
  return [hook.memoizedState, dispatch]
}

// 1266
function updateState<S>(
  initialState: (() => S) | S
): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, (initialState: any))
}

      
      





La fonction "updateReducer" est utilisée pour mettre à jour l'état, nous disons donc que useState utilise useReducer en interne ou que useReducer est une implémentation de niveau inférieur de useState.



function updateReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: (I) => S
): [S, Dispatch<A>] {
  //  ,       (!)
  const hook = updateWorkInProgressHook()
  //  
  const queue = hook.queue
  //        
  queue.lastRenderedReducer = reducer

  const current: Hook = (currentHook: any)

  //   , ,     
  let baseQueue = current.baseQueue

  //        
  if (baseQueue !== null) {
    const first = baseQueue.next
    let newState = current.baseState

    let newBaseState = null
    let newBaseQueueFirst = null
    let newBaseQueueLast = null
    let update = first
    do {
      //    
    } while (update !== null && update !== first)

    //     
    hook.memoizedState = newState
    hook.baseState = newBaseState
    hook.baseQueue = newBaseQueueLast

    //         
    queue.lastRenderedState = newState
  }

  //  
  const dispatch: Dispatch<A> = (queue.dispatch: any)
  //     
  return [hook.memoizedState, dispatch]
}

      
      





Jusqu'à présent, nous n'avons vu que le fonctionnement des crochets eux-mêmes. Où est la liste? Astuce: les hooks de montage / mise à jour sont créés à l'aide des fonctions "mountWorkInProgressHook" et "updateWorkInProgressHook", respectivement.



// 592
function mountWorkInProgressHook(): Hook {
  //  
  const hook: Hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,

    //     (?!)
    next: null
  }

  //  workInProgressHook  null, ,      
  if (workInProgressHook === null) {
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook
  } else {
    //   ,     
    workInProgressHook = workInProgressHook.next = hook
  }
  return workInProgressHook
}

// 613
function updateWorkInProgressHook(): Hook {
  //      ,     
  //  ,      (current hook),    (. ),  workInProgressHook   ,
  //     
  //    ,    ,   
  let nextCurrentHook: null | Hook
  if (currentHook === null) {
    const current = currentlyRenderingFiber.alternate
    if (current !== null) {
      nextCurrentHook = current.memoizedState
    } else {
      nextCurrentHook = null
    }
  } else {
    nextCurrentHook = currentHook.next
  }

  let nextWorkInProgressHook: null | Hook
  if (workInProgressHook === null) {
    nextWorkInProgressHook = currentlyRenderingFiber.memoizedState
  } else {
    nextWorkInProgressHook = workInProgressHook.next
  }

  if (nextWorkInProgressHook !== null) {
    //   workInProgressHook
    workInProgressHook = nextWorkInProgressHook
    nextWorkInProgressHook = workInProgressHook.next

    currentHook = nextCurrentHook
  } else {
    //   

    //     ,     ,    
    // ,   ,      ,   
    //    ,        ?
    //      ,   "" ?
    invariant(
      nextCurrentHook !== null,
      'Rendered more hooks than during the previous render.'
    )
    currentHook = nextCurrentHook

    const newHook: Hook = {
      memoizedState: currentHook.memoizedState,

      baseState: currentHook.baseState,
      baseQueue: currentHook.baseQueue,
      queue: currentHook.queue,

      next: null
    }

    //  workInProgressHook  null, ,      
    if (workInProgressHook === null) {
      currentlyRenderingFiber.memoizedState = workInProgressHook = newHook
    } else {
      //     
      workInProgressHook = workInProgressHook.next = newHook
    }
  }
  return workInProgressHook
}

      
      





Je crois que notre hypothèse selon laquelle une liste chaînée est utilisée pour contrôler les hooks a été confirmée. Nous avons découvert que chaque hook a une propriété "next", dont la valeur est un lien vers le hook suivant. Voici une bonne illustration de cette liste tirée de l'article ci-dessus:







Pour ceux d'entre vous qui se demandent, voici à quoi ressemble l'implémentation JavaScript la plus simple d'une liste liée à sens unique:



Un peu de code
class Node {
  constructor(data, next = null) {
    this.data = data
    this.next = next
  }
}

class LinkedList {
  constructor() {
    this.head = null
  }

  insertHead(data) {
    this.head = new Node(data, this.head)
  }

  size() {
    let counter = 0
    let node = this.head

    while (node) {
      counter++
      node = node.next
    }

    return counter
  }

  getHead() {
    return this.head
  }

  getTail() {
    if (!this.head) return null

    let node = this.head

    while (node) {
      if (!node.next) return node
      node = node.next
    }
  }

  clear() {
    this.head = null
  }

  removeHead() {
    if (!this.head) return
    this.head = this.head.next
  }

  removeTail() {
    if (!this.head) return

    if (!this.head.next) {
      this.head = null
      return
    }

    let prev = this.head
    let node = this.head.next

    while (node.next) {
      prev = node
      node = node.next
    }

    prev.next = null
  }

  insertTail(data) {
    const last = this.getTail()

    if (last) last.next = new Node(data)
    else this.head = new Node(data)
  }

  getAt(index) {
    let counter = 0
    let node = this.head

    while (node) {
      if (counter === index) return node
      counter++
      node = node.next
    }
    return null
  }

  removeAt(index) {
    if (!this.head) return

    if (index === 0) {
      this.head = this.head.next
      return
    }

    const prev = this.getAt(index - 1)

    if (!prev || !prev.next) return

    prev.next = prev.next.next
  }

  insertAt(index, data) {
    if (!this.head) {
      this.head = new Node(data)
      return
    }

    const prev = this.getAt(index - 1) || this.getTail()

    const node = new Node(data, prev.next)

    prev.next = node
  }

  forEach(fn) {
    let node = this.head
    let index = 0

    while (node) {
      fn(node, index)
      node = node.next
      index++
    }
  }

  *[Symbol.iterator]() {
    let node = this.head

    while (node) {
      yield node
      node = node.next
    }
  }
}

//  
const chain = new LinkedList()

chain.insertHead(1)
console.log(
  chain.head.data, // 1
  chain.size(), // 1
  chain.getHead().data // 1
)

chain.insertHead(2)
console.log(chain.getTail().data) // 1

chain.clear()
console.log(chain.size()) // 0

chain.insertHead(1)
chain.insertHead(2)
chain.removeHead()
console.log(chain.size()) // 1

chain.removeTail()
console.log(chain.size()) // 0

chain.insertTail(1)
console.log(chain.getTail().data) // 1

chain.insertHead(2)
console.log(chain.getAt(0).data) // 2

chain.removeAt(0)
console.log(chain.size()) // 1

chain.insertAt(0, 2)
console.log(chain.getAt(1).data) // 2

chain.forEach((node, index) => (node.data = node.data + index))
console.log(chain.getTail().data) // 3

for (const node of chain) node.data = node.data + 1
console.log(chain.getHead().data) // 2

//   
function middle(list) {
  let one = list.head
  let two = list.head

  while (two.next && two.next.next) {
    one = one.next
    two = two.next.next
  }

  return one
}

chain.clear()
chain.insertHead(1)
chain.insertHead(2)
chain.insertHead(3)
console.log(middle(chain).data) // 2

//   
function circular(list) {
  let one = list.head
  let two = list.head

  while (two.next && two.next.next) {
    one = one.next
    two = two.next.next

    if (two === one) return true
  }

  return false
}

chain.head.next.next.next = chain.head
console.log(circular(chain)) // true

      
      







Il s'avère que lors du re-rendu avec moins (ou plus) de hooks, updateWorkInProgressHook () renvoie un hook qui ne correspond pas à sa position dans la liste précédente, c'est-à-dire il manquera un nœud à la nouvelle liste (ou un nœud supplémentaire apparaîtra). Et à l'avenir, le mauvais état mémorisé sera utilisé pour calculer le nouvel état. Bien sûr, c'est un problème grave, mais dans quelle mesure est-il critique? React ne sait-il pas reconstruire la liste des hameçons à la volée? Et existe-t-il un moyen d'implémenter des hooks conditionnels? Découvrons ceci.



Oui, avant de partir de la source, nous chercherons un linter qui applique les règles d'utilisation des hooks. RulesOfHooks.js :



if (isDirectlyInsideComponentOrHook) {
  if (!cycled && pathsFromStartToEnd !== allPathsFromStartToEnd) {
    const message =
      `React Hook "${context.getSource(hook)}" is called ` +
      'conditionally. React Hooks must be called in the exact ' +
      'same order in every component render.' +
      (possiblyHasEarlyReturn
        ? ' Did you accidentally call a React Hook after an' + ' early return?'
        : '')
    context.report({ node: hook, message })
  }
}

      
      





Je n'entrerai pas dans les détails sur la façon dont la différence entre le nombre d'hameçons est déterminée. Et voici comment définir qu'une fonction est un hook:



function isHookName(s) {
  return /^use[A-Z0-9].*$/.test(s)
}

function isHook(node) {
  if (node.type === 'Identifier') {
    return isHookName(node.name)
  } else if (
    node.type === 'MemberExpression' &&
    !node.computed &&
    isHook(node.property)
  ) {
    const obj = node.object
    const isPascalCaseNameSpace = /^[A-Z].*/
    return obj.type === 'Identifier' && isPascalCaseNameSpace.test(obj.name)
  } else {
    return false
  }
}

      
      





Esquissons un composant dans lequel l'utilisation conditionnelle de hooks a lieu et voyons ce qui se passe lorsqu'il est rendu.



import { useEffect, useState } from 'react'

//   
function useText() {
  const [text, setText] = useState('')

  useEffect(() => {
    const id = setTimeout(() => {
      setText('Hello')
      const _id = setTimeout(() => {
        setText((text) => text + ' World')
        clearTimeout(_id)
      }, 1000)
    }, 1000)
    return () => {
      clearTimeout(id)
    }
  }, [])

  return text
}

//   
function useCount() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const id = setInterval(() => {
      setCount((count) => count + 1)
    }, 1000)
    return () => {
      clearInterval(id)
    }
  }, [])

  return count
}

// ,           
const Content = ({ active }) => <p>{active ? useText() : useCount()}</p>

function ConditionalHook() {
  const [active, setActive] = useState(false)

  return (
    <>
      <button onClick={() => setActive(!active)}> </button>
      <Content active={active} />
    </>
  )
}

export default ConditionalHook

      
      





Dans l'exemple ci-dessus, nous avons deux hooks personnalisés - useText () et useCount (). Nous essayons d'utiliser tel ou tel hook en fonction de l'état de la variable "active". Rendre. Nous obtenons l'erreur "React Hook 'useText' est appelé conditionnellement. Les hooks de réaction doivent être appelés exactement dans le même ordre dans chaque rendu de composant ", ce qui indique que les hooks doivent être appelés dans le même ordre sur chaque rendu.



Peut-être qu'il ne s'agit pas tant de React que d'ESLint. Essayons de le désactiver. Pour ce faire, ajoutez / * eslint-disable * / au début du fichier. Le composant Content est maintenant en cours de rendu, mais le basculement entre les hooks ne fonctionne pas. Donc c'est React, après tout. Que pouvez vous faire d'autre?



Et si nous créons des fonctions régulières de hooks personnalisés? En essayant:



function getText() {
  // ...
}

function getCount() {
  // ...
}

const Content = ({ active }) => <p>{active ? getText() : getCount()}</p>

      
      





Le résultat est le même. Le composant est rendu avec getCount (), mais il n'est pas possible de basculer entre les fonctions. Au fait, sans / * eslint-disable * / nous obtenons l'erreur «React Hook« useState »est appelé dans la fonction« getText »qui n'est ni un composant de fonction React ni une fonction React Hook personnalisée. Les noms des composants React doivent commencer par une lettre majuscule ", ce qui indique que le hook est appelé dans une fonction qui n'est ni un composant ni un hook personnalisé. Il y a un indice dans cette erreur.



Et si nous fabriquions nos fonctions comme des composants?



function Text() {
  // ...
}

function Count() {
  // ...
}

const Content = ({ active }) => <p>{active ? <Text /> : <Count />}</p>

      
      





Maintenant, tout fonctionne comme prévu, même avec le linter allumé. C'est parce que nous avons en fait implémenté le rendu conditionnel des composants. De toute évidence, React utilise un mécanisme différent pour implémenter le rendu conditionnel sur les composants. Pourquoi ce mécanisme ne pourrait-il pas être appliqué aux hameçons?



Faisons une autre expérience. Nous savons que dans le cas du rendu d'une liste d'éléments, un attribut "clé" est ajouté à chaque élément, permettant à React de garder une trace de l'état de la liste. Et si nous utilisons cet attribut dans notre exemple?



function useText() {
  // ...
}

function useCount() {
  // ...
}

const Content = ({ active }) => <p>{active ? useText() : useCount()}</p>

function ConditionalHook() {
  const [active, setActive] = useState(false)

  return (
    <>
      <button onClick={() => setActive(!active)}> </button>
      {/*  key */}
      <Content key={active} active={active} />
    </>
  )
}

      
      





Nous obtenons une erreur avec le linter. Sans linter ... tout fonctionne! Mais pourquoi? Peut-être que React considère Content avec useText () et Content avec useCount () comme deux composants différents et rend conditionnellement les composants en fonction de l'état actif. Quoi qu'il en soit, nous avons trouvé une solution de contournement. Un autre exemple:



import { useEffect, useState } from 'react'

const getNum = (min = 100, max = 1000) =>
  ~~(min + Math.random() * (max + 1 - min))

//  
function useNum() {
  const [num, setNum] = useState(getNum())

  useEffect(() => {
    const id = setInterval(() => setNum(getNum()), 1000)
    return () => clearInterval(id)
  }, [])

  return num
}

// -
function NumWrapper({ setNum }) {
  const num = useNum()

  useEffect(() => {
    setNum(num)
  }, [setNum, num])

  return null
}

function ConditionalHook2() {
  const [active, setActive] = useState(false)
  const [num, setNum] = useState(0)

  return (
    <>
      <h3>  ? <br /> ,  </h3>
      <button onClick={() => setActive(!active)}>  </button>
      <p>{active && num}</p>
      {active && <NumWrapper setNum={setNum} />}
    </>
  )
}

export default ConditionalHook2

      
      





Dans l'exemple ci-dessus, nous avons un hook personnalisé "useNum" qui renvoie chaque seconde un entier aléatoire compris entre 100 et 1000. Nous l'enveloppons dans le composant "NumWrapper", qui ne renvoie rien (plus précisément, il retourne null ), mais ... en raison de l'utilisation setNum du composant parent, l'état est déclenché. Bien sûr, en fait, nous avons à nouveau implémenté le rendu conditionnel du composant. Néanmoins, cela montre que, si on le souhaite, il est encore possible de réaliser une utilisation conditionnelle des crochets.



L'exemple de code est ici .



Bac à sable:





Résumons. React utilise une liste chaînée pour gérer les hooks. Chaque hook (actuel) contient un pointeur vers le hook suivant, ou null (dans la propriété "next"). C'est pourquoi il est important de suivre l'ordre dans lequel les hooks sont appelés à chaque rendu.



Bien que vous puissiez obtenir une utilisation conditionnelle des hooks via le rendu conditionnel des composants, vous ne devez pas le faire: les conséquences peuvent être imprévisibles.



Quelques observations supplémentaires concernant les sources React: les classes ne sont pratiquement pas utilisées, et les fonctions et leurs compositions sont aussi simples que possible (même l'opérateur ternaire est rarement utilisé); les noms des fonctions et des variables sont assez informatifs, même si en raison du grand nombre de variables il devient nécessaire d'utiliser les préfixes «base», «courant», etc., ce qui prête à confusion, mais étant donné la taille de la base de code , cette situation est tout à fait naturelle; il y a des commentaires détaillés, y compris TODO.



Sur les droits d'auto-promotion: pour ceux qui veulent apprendre ou mieux comprendre les outils utilisés dans le développement d'applications web modernes (React, Express, Mongoose, GraphQL, etc.), je propose de jeter un œil à ce référentiel .



J'espère que vous l'avez trouvé intéressant. Les commentaires constructifs dans les commentaires sont les bienvenus. Merci pour votre attention et bonne journée.



All Articles