Una nota sobre cómo funcionan los ganchos en React





¡Buen dia amigos!



Quiero compartir con ustedes algunas ideas sobre cómo funciona React, es decir, suposiciones sobre por qué los ganchos no se pueden usar en ifs, bucles, funciones regulares, etc. ¿Y realmente no se pueden usar de esta manera?



La pregunta es: ¿por qué se pueden usar los ganchos solo en el nivel superior? Esto es lo que dice la documentación oficial al respecto.



Comencemos con las reglas para usar ganchos .



Use ganchos solo en el nivel superior (resalte los puntos clave a los que debe prestar atención):



“No llame a ganchos dentro de bucles, condicionales o funciones anidadas. En su lugar, siempre use ganchos solo dentro de las funciones de React, antes de devolver cualquier valor de ellas. Esta regla asegura que los ganchos se llamen en la misma secuencia cada vez que se procese el componente . Esto permitirá que React persista correctamente el estado del gancho entre múltiples llamadas a useState y useEffect. (Si está interesado, a continuación encontrará una explicación detallada) ".



Estamos interesados, ver más abajo.



Explicación (ejemplos omitidos por brevedad):



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



¿Claro? Sí, de alguna manera no mucho. ¿Qué quieres decir con "React se basa en el orden en que se llaman los ganchos"? ¿Cómo lo hace? ¿Qué es este "algún tipo de estado interior"? ¿Cuáles son los errores causados ​​por perder un gancho al volver a renderizar? ¿Son estos errores críticos para que la aplicación funcione?



¿Hay algo más en la documentación sobre esto? Hay una sección especial "Ganchos: respuestas a preguntas" . Allí encontramos lo siguiente.



¿Cómo vincula React las llamadas de gancho a un componente?



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



Ya algo. Una lista interna de ubicaciones de memoria asociadas con componentes y que contienen algunos datos. El gancho lee el valor de la celda actual y mueve el puntero a la siguiente. ¿A qué estructura de datos te recuerda esto? Quizás estemos hablando de una lista enlazada (enlazada) .



Si este es realmente el caso, entonces la secuencia de ganchos que genera React cuando se renderiza por primera vez se ve así (imagina que los rectángulos son ganchos, cada gancho contiene un puntero al siguiente):





Genial, tenemos una hipótesis de trabajo que parece más o menos razonable. ¿Cómo podemos comprobarlo? Una hipótesis es una hipótesis, pero quiero hechos. Y para conocer los hechos, debe ir a GitHub, al repositorio de fuentes de React .



No creas que de inmediato me decidí a dar un paso tan desesperado. Por supuesto, primero, en busca de respuestas a mis preguntas, recurrí al omnisciente Google. Esto es lo que encontramos:





Todas estas fuentes se refieren a las fuentes de React. Tuve que escarbar un poco en ellos. Entonces, la tesis y el ejemplo de "useState".



UseState () y otros ganchos se implementan en ReactHooks.js :



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

      
      





Un despachador se usa para llamar a useState () (y otros ganchos). Al principio del mismo archivo, vemos lo siguiente:



import ReactCurrentDispatcher from './ReactCurrentDispatcher'

function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current

  return ((dispatcher: any): Dispatcher)
}

      
      





El despachador usado para llamar a useState () (y otros ganchos) es el valor de la propiedad "actual" del objeto "ReactCurrentDispatcher", que se importa de ReactCurrentDispatcher.js :



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

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

export default ReactCurrentDispatcher

      
      





ReactCurrentDispatcher es un objeto vacío con una propiedad "actual". Esto significa que se inicializa en otro lugar. ¿Pero dónde exactamente? Sugerencia: las importaciones de tipo "Dispatcher" indican que el dispatcher actual tiene algo que ver con los componentes internos de React. De hecho, esto es lo que encontramos en ReactFiberHooks.new.js (el número en el comentario es el número de línea):



// 118
const { ReactCurrentDispatcher, ReactCurrentBatchConfig } = ReactSharedInternals

      
      





Sin embargo, en ReactSharedInternals.js nos encontramos con "datos internos secretos que podrían activarse por usar":



const ReactSharedInternals =
  React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED

export default ReactSharedInternals

      
      





¿Eso es todo? ¿Ha terminado nuestra búsqueda antes de que pueda comenzar? Realmente no. No conoceremos los detalles de la implementación interna de React, pero no lo necesitamos para entender cómo React maneja los ganchos. De vuelta en ReactFiberHooks.new.js:



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

      
      





El despachador utilizado para llamar a los hooks es en realidad dos despachadores diferentes: HooksDispatcherOnMount (en el montaje) y HooksDispatcherOnUpdate (en la actualización, volver a renderizar).



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

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

      
      





La separación de montaje / actualización se mantiene en el nivel del gancho.



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 función "updateReducer" se usa para actualizar el estado, por lo que decimos que useState usa internamente useReducer o que useReducer es una implementación de nivel inferior 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]
}

      
      





Hasta ahora, solo hemos visto cómo funcionan los propios ganchos. ¿Dónde está la lista? Sugerencia: los ganchos de montaje / actualización se crean utilizando las funciones "mountWorkInProgressHook" y "updateWorkInProgressHook", respectivamente.



// 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
}

      
      





Creo que se ha confirmado nuestra hipótesis de que se utiliza una lista enlazada para controlar los ganchos. Descubrimos que cada gancho tiene una propiedad "siguiente", cuyo valor es un enlace al siguiente gancho. Aquí hay una buena ilustración de esta lista del artículo anterior:







Para aquellos de ustedes que se pregunten, así es como se ve la implementación de JavaScript más simple de una lista enlazada unidireccional:



Un poco de código
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

      
      







Resulta que cuando se vuelve a renderizar con menos (o más) ganchos, updateWorkInProgressHook () devuelve un gancho que no coincide con su posición en la lista anterior, es decir, a la nueva lista le faltará un nodo (o aparecerá un nodo adicional). Y en el futuro, se utilizará el estado memorizado incorrecto para calcular el nuevo estado. Por supuesto, este es un problema grave, pero ¿qué importancia tiene? ¿React no sabe cómo reconstruir la lista de anzuelos sobre la marcha? ¿Y hay alguna forma de implementar ganchos condicionales? Averigüemos esto.



Sí, antes de ir desde el código fuente, buscaremos un linter que refuerce las reglas para el uso de ganchos. 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 })
  }
}

      
      





No entraré en detalles sobre cómo se determina la diferencia entre el número de anzuelos. Y aquí se explica cómo definir que una función es un gancho:



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
  }
}

      
      





Esbocemos un componente en el que tiene lugar el uso condicional de ganchos y veamos qué sucede cuando se renderiza.



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

      
      





En el ejemplo anterior, tenemos dos ganchos personalizados: useText () y useCount (). Estamos tratando de usar este o aquel gancho dependiendo del estado de la variable "activa". Hacer. Obtenemos el error "React Hook 'useText' se llama condicionalmente. Los Hooks de React deben llamarse exactamente en el mismo orden en cada renderizado de componentes ", lo que dice que los hooks deben llamarse en el mismo orden en cada renderizado.



Tal vez no se trate tanto de React como de ESLint. Intentemos desactivarlo. Para hacer esto, agregue / * eslint-disable * / al comienzo del archivo. El componente de contenido ahora se está procesando, pero el cambio entre ganchos no funciona. Entonces es React, después de todo. ¿Qué más puedes hacer?



¿Qué pasa si hacemos funciones regulares de hooks personalizados? Difícil:



function getText() {
  // ...
}

function getCount() {
  // ...
}

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

      
      





El resultado es el mismo. El componente se representa con getCount (), pero no es posible cambiar entre funciones. Por cierto, sin / * eslint-disable * / obtenemos el error "React Hook" se llama a "useState" en la función "getText" que no es un componente de la función React ni una función personalizada de React Hook. Los nombres de los componentes de React deben comenzar con una letra mayúscula ", lo que dice que el gancho se llama dentro de una función que no es un componente ni un gancho personalizado. Hay una pista en este error.



¿Y si hacemos componentes de nuestras funciones?



function Text() {
  // ...
}

function Count() {
  // ...
}

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

      
      





Ahora todo funciona como se esperaba, incluso con el linter encendido. Esto se debe a que en realidad implementamos la representación condicional de los componentes. Obviamente, React usa un mecanismo diferente para implementar el renderizado condicional en los componentes. ¿Por qué no se puede aplicar este mecanismo a los ganchos?



Hagamos un experimento más. Sabemos que en el caso de representar una lista de elementos, se agrega un atributo "clave" a cada elemento, lo que permite a React realizar un seguimiento del estado de la lista. ¿Qué pasa si usamos este atributo en nuestro ejemplo?



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} />
    </>
  )
}

      
      





Obtenemos un error con el linter. Sin linter ... ¡todo funciona! ¿Pero por qué? Quizás React considere Content con useText () y Content con useCount () como dos componentes diferentes y renderice condicionalmente los componentes según el estado activo. Sea como fuere, encontramos una solución. Otro ejemplo:



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

      
      





En el ejemplo anterior, tenemos un gancho personalizado "useNum" que cada segundo devuelve un entero aleatorio en el rango de 100 a 1000. Lo envolvemos en el componente "NumWrapper", que no devuelve nada (más precisamente, devuelve nulo ), pero ... debido al uso setNum del componente principal, se eleva el estado. Por supuesto, de hecho, hemos vuelto a implementar la representación condicional del componente. Sin embargo, esto muestra que, si se desea, todavía es posible lograr un uso condicional de ganchos.



El código de ejemplo está aquí .



Salvadera:





Resumamos. React usa una lista vinculada para administrar los ganchos. Cada gancho (actual) contiene un puntero al siguiente gancho, o nulo (en la propiedad "siguiente"). Por eso es importante seguir el orden en el que se invocan los ganchos en cada render.



Aunque puede lograr el uso condicional de ganchos mediante la representación condicional de componentes, no debe hacer esto: las consecuencias pueden ser impredecibles.



Un par de observaciones más relacionadas con las fuentes de React: las clases prácticamente no se usan y las funciones y sus composiciones son lo más simples posible (incluso el operador ternario rara vez se usa); los nombres de funciones y variables son bastante informativos, aunque debido a la gran cantidad de variables se hace necesario utilizar los prefijos "base", "actual", etc., lo que genera cierta confusión, pero dado el tamaño del código base , esta situación es bastante natural; hay comentarios detallados, incluido TODO.



Sobre los derechos de autopromoción: para aquellos que quieran aprender o comprender mejor las herramientas utilizadas en el desarrollo de aplicaciones web modernas (React, Express, Mongoose, GraphQL, etc.), les sugiero que echen un vistazo a este repositorio .



Espero que te haya resultado interesante. Los comentarios constructivos en los comentarios son bienvenidos. Gracias por su atención y que tenga un buen día.



All Articles