Actualizar el estilo de un componente onScroll en React.js

133

He creado un componente en React que se supone que actualiza su propio estilo en el desplazamiento de la ventana para crear un efecto de paralaje.

El rendermétodo del componente se ve así:

  function() {
    let style = { transform: 'translateY(0px)' };

    window.addEventListener('scroll', (event) => {
      let scrollTop = event.srcElement.body.scrollTop,
          itemTranslate = Math.min(0, scrollTop/3 - 60);

      style.transform = 'translateY(' + itemTranslate + 'px)');
    });

    return (
      <div style={style}></div>
    );
  }

Esto no funciona porque React no sabe que el componente ha cambiado y, por lo tanto, el componente no se vuelve a procesar.

He intentado almacenar el valor de itemTranslateen el estado del componente y llamar setStatea la devolución de llamada de desplazamiento. Sin embargo, esto hace que el desplazamiento sea inutilizable ya que es terriblemente lento.

¿Alguna sugerencia sobre cómo hacer esto?

Alejandro Pérez
fuente
9
Nunca enlace un controlador de eventos externo dentro de un método de representación. Los métodos de representación (y cualquier otro método personalizado desde el que llame renderen el mismo hilo) solo deben ocuparse de la lógica relacionada con la representación / actualización del DOM real en React. En cambio, como se muestra a continuación en @AustinGreco, debe usar los métodos de ciclo de vida React para crear y eliminar el enlace de su evento. Esto lo hace autónomo dentro del componente y asegura que no haya fugas al asegurarse de que se elimine el enlace del evento si / cuando el componente que lo usa está desmontado.
Mike Driver

Respuestas:

232

Deberías vincular al oyente en componentDidMount , de esa manera solo se crea una vez. Debería poder almacenar el estilo en estado, el oyente probablemente fue la causa de los problemas de rendimiento.

Algo como esto:

componentDidMount: function() {
    window.addEventListener('scroll', this.handleScroll);
},

componentWillUnmount: function() {
    window.removeEventListener('scroll', this.handleScroll);
},

handleScroll: function(event) {
    let scrollTop = event.srcElement.body.scrollTop,
        itemTranslate = Math.min(0, scrollTop/3 - 60);

    this.setState({
      transform: itemTranslate
    });
},
Austin Greco
fuente
26
Descubrí que setState'ing dentro del evento de desplazamiento para la animación es entrecortado. Tuve que configurar manualmente el estilo de los componentes usando referencias.
Ryan Rho
1
¿A qué se señalaría el "this" dentro de handleScroll? En mi caso es "ventana", no componente. Terminé pasando el componente como parámetro
yuji
10
@yuji puede evitar tener que pasar el componente al vincular esto en el constructor: this.handleScroll = this.handleScroll.bind(this)lo vinculará handleScrollal componente, en lugar de a la ventana.
Matt Parrilla
1
Tenga en cuenta que srcElement no está disponible en Firefox.
Paulin Trognon
2
no funcionó para mí, pero lo que hizo fue configurar scrollTop paraevent.target.scrollingElement.scrollTop
George
31

Puede pasar una función al onScrollevento en el elemento React: https://facebook.github.io/react/docs/events.html#ui-events

<ScrollableComponent
 onScroll={this.handleScroll}
/>

Otra respuesta que es similar: https://stackoverflow.com/a/36207913/1255973

Con Antonakos
fuente
55
¿Hay algún beneficio / inconveniente en este método en comparación con agregar manualmente un detector de eventos al elemento de ventana mencionado por @AustinGreco?
Dennis
2
@Dennis Una ventaja es que no tiene que agregar / eliminar manualmente los oyentes de eventos. Si bien este podría ser un ejemplo simple si administra manualmente varios oyentes de eventos en toda su aplicación, es fácil olvidar eliminarlos adecuadamente en las actualizaciones, lo que puede provocar errores de memoria. Siempre usaría la versión incorporada si fuera posible.
F Lekschas
44
Vale la pena señalar que esto adjunta un controlador de desplazamiento al componente en sí, no a la ventana, que es una cosa muy diferente. @Dennis Los beneficios de onScroll son que es más multi-navegador y más eficiente. Si puedes usarlo, probablemente deberías, pero puede que no sea útil en casos como el del OP
Beau
20

Mi solución para hacer una barra de navegación receptiva (posición: 'relativa' cuando no se desplaza y fija cuando se desplaza y no en la parte superior de la página)

componentDidMount() {
    window.addEventListener('scroll', this.handleScroll);
}

componentWillUnmount() {
    window.removeEventListener('scroll', this.handleScroll);
}
handleScroll(event) {
    if (window.scrollY === 0 && this.state.scrolling === true) {
        this.setState({scrolling: false});
    }
    else if (window.scrollY !== 0 && this.state.scrolling !== true) {
        this.setState({scrolling: true});
    }
}
    <Navbar
            style={{color: '#06DCD6', borderWidth: 0, position: this.state.scrolling ? 'fixed' : 'relative', top: 0, width: '100vw', zIndex: 1}}
        >

No hay problemas de rendimiento para mí.

robins_
fuente
También puede usar un encabezado falso que esencialmente es solo un marcador de posición. Entonces tienes tu encabezado fijo y debajo tienes tu falso marcador de posición con posición: relativo.
robins_
No hay problemas de rendimiento porque esto no aborda el desafío de paralaje en la pregunta.
juanitogan
19

Para ayudar a cualquier persona que haya notado los problemas de comportamiento / rendimiento lentos cuando usa la respuesta de Austins, y quiere un ejemplo usando las referencias mencionadas en los comentarios, aquí hay un ejemplo que estaba usando para alternar una clase para un icono de desplazamiento hacia arriba / abajo:

En el método de renderizado:

<i ref={(ref) => this.scrollIcon = ref} className="fa fa-2x fa-chevron-down"></i>

En el método del controlador:

if (this.scrollIcon !== null) {
  if(($(document).scrollTop() + $(window).height() / 2) > ($('body').height() / 2)){
    $(this.scrollIcon).attr('class', 'fa fa-2x fa-chevron-up');
  }else{
    $(this.scrollIcon).attr('class', 'fa fa-2x fa-chevron-down');
  }
}

Y agregue / elimine sus controladores de la misma manera que Austin mencionó:

componentDidMount(){
  window.addEventListener('scroll', this.handleScroll);
},
componentWillUnmount(){
  window.removeEventListener('scroll', this.handleScroll);
},

documentos en las referencias .

adrian_reimer
fuente
44
¡Salvaste mi día! Para la actualización, en realidad no necesita usar jquery para modificar el nombre de clase en este punto, porque ya es un elemento DOM nativo. Entonces simplemente podrías hacer this.scrollIcon.className = whatever-you-want.
southp
2
esta solución rompe la encapsulación React, aunque todavía no estoy seguro de una forma de evitar esto sin un comportamiento lento: tal vez un evento de desplazamiento sin rebote (tal vez 200-250 ms) sería una solución aquí
Jordan
el evento de desplazamiento sin rebote de nope solo ayuda a que el desplazamiento sea más suave (en un sentido sin bloqueo), pero las actualizaciones tardan entre 500 ms y un segundo en aplicarse en el DOM: /
Jordania
También utilicé esta solución, +1. Estoy de acuerdo en que no necesitas jQuery: solo usa classNameo classList. Además, no necesitabawindow.addEventListener() : ¡solo usé React's onScroll, y es tan rápido, siempre y cuando no actualices accesorios / estado!
Benjamin
13

Descubrí que no puedo agregar con éxito el detector de eventos a menos que pase true de la siguiente manera:

componentDidMount = () => {
    window.addEventListener('scroll', this.handleScroll, true);
},
Jean-Marie Dalmasso
fuente
Esta funcionando. Pero, ¿puedes entender por qué tenemos que pasar booleanos verdaderos a este oyente?
Shah Chaitanya
2
Desde w3schools: [ w3schools.com/jsref/met_document_addeventlistener.asp] userCapture : Opcional. Un valor booleano que especifica si el evento debe ejecutarse en la captura o en la fase de burbujeo. Valores posibles: verdadero: el controlador de eventos se ejecuta en la fase de captura falso: predeterminado. El controlador de eventos se ejecuta en la fase de burbujeo
Jean-Marie Dalmasso
12

Un ejemplo usando classNames , React hooks useEffect , useState y styled-jsx :

import classNames from 'classnames'
import { useEffect, useState } from 'react'

const Header = _ => {
  const [ scrolled, setScrolled ] = useState()
  const classes = classNames('header', {
    scrolled: scrolled,
  })
  useEffect(_ => {
    const handleScroll = _ => { 
      if (window.pageYOffset > 1) {
        setScrolled(true)
      } else {
        setScrolled(false)
      }
    }
    window.addEventListener('scroll', handleScroll)
    return _ => {
      window.removeEventListener('scroll', handleScroll)
    }
  }, [])
  return (
    <header className={classes}>
      <h1>Your website</h1>
      <style jsx>{`
        .header {
          transition: background-color .2s;
        }
        .header.scrolled {
          background-color: rgba(0, 0, 0, .1);
        }
      `}</style>
    </header>
  )
}
export default Header
giovannipds
fuente
1
Tenga en cuenta que debido a que useEffect no tiene un segundo argumento, se ejecutará cada vez que se muestre el Encabezado.
Jordan
2
@ Jordan tienes razón! Mi error al escribir esto aquí. Editaré la respuesta. Muchas gracias.
giovannipds
8

con ganchos

import React, { useEffect, useState } from 'react';

function MyApp () {

  const [offset, setOffset] = useState(0);

  useEffect(() => {
    window.onscroll = () => {
      setOffset(window.pageYOffset)
    }
  }, []);

  console.log(offset); 
};
Sodio unido
fuente
Exactamente lo que necesitaba. ¡Gracias!
aabbccsmith
Esta es, con mucho, la respuesta más efectiva y elegante de todas. Gracias por esto.
Chigozie Orunta
Esto necesita más atención, perfecto.
Anders Kitson
6

Ejemplo de componente de función usando useEffect:

Nota : Debe eliminar el detector de eventos devolviendo una función de "limpieza" en useEffect. Si no lo hace, cada vez que el componente se actualice tendrá un detector de desplazamiento de ventana adicional.

import React, { useState, useEffect } from "react"

const ScrollingElement = () => {
  const [scrollY, setScrollY] = useState(0);

  function logit() {
    setScrollY(window.pageYOffset);
  }

  useEffect(() => {
    function watchScroll() {
      window.addEventListener("scroll", logit);
    }
    watchScroll();
    // Remove listener (like componentWillUnmount)
    return () => {
      window.removeEventListener("scroll", logit);
    };
  }, []);

  return (
    <div className="App">
      <div className="fixed-center">Scroll position: {scrollY}px</div>
    </div>
  );
}
Ricardo
fuente
Tenga en cuenta que debido a que useEffect no tiene un segundo argumento, se ejecutará cada vez que el componente se procese.
Jordan
Buen punto. ¿Deberíamos agregar una matriz vacía a la llamada useEffect?
Richard
Eso es lo que haría :)
Jordan
5

Si lo que le interesa es un componente secundario que se desplaza, entonces este ejemplo podría ser útil: https://codepen.io/JohnReynolds57/pen/NLNOyO?editors=0011

class ScrollAwareDiv extends React.Component {
  constructor(props) {
    super(props)
    this.myRef = React.createRef()
    this.state = {scrollTop: 0}
  }

  onScroll = () => {
     const scrollTop = this.myRef.current.scrollTop
     console.log(`myRef.scrollTop: ${scrollTop}`)
     this.setState({
        scrollTop: scrollTop
     })
  }

  render() {
    const {
      scrollTop
    } = this.state
    return (
      <div
         ref={this.myRef}
         onScroll={this.onScroll}
         style={{
           border: '1px solid black',
           width: '600px',
           height: '100px',
           overflow: 'scroll',
         }} >
        <p>This demonstrates how to get the scrollTop position within a scrollable 
           react component.</p>
        <p>ScrollTop is {scrollTop}</p>
     </div>
    )
  }
}
usuario2312410
fuente
1

Resolví el problema usando y modificando variables CSS. De esta manera, no tengo que modificar el estado del componente que causa problemas de rendimiento.

index.css

:root {
  --navbar-background-color: rgba(95,108,255,1);
}

Navbar.jsx

import React, { Component } from 'react';
import styles from './Navbar.module.css';

class Navbar extends Component {

    documentStyle = document.documentElement.style;
    initalNavbarBackgroundColor = 'rgba(95, 108, 255, 1)';
    scrolledNavbarBackgroundColor = 'rgba(95, 108, 255, .7)';

    handleScroll = () => {
        if (window.scrollY === 0) {
            this.documentStyle.setProperty('--navbar-background-color', this.initalNavbarBackgroundColor);
        } else {
            this.documentStyle.setProperty('--navbar-background-color', this.scrolledNavbarBackgroundColor);
        }
    }

    componentDidMount() {
        window.addEventListener('scroll', this.handleScroll);
    }

    componentWillUnmount() {
        window.removeEventListener('scroll', this.handleScroll);
    }

    render () {
        return (
            <nav className={styles.Navbar}>
                <a href="/">Home</a>
                <a href="#about">About</a>
            </nav>
        );
    }
};

export default Navbar;

Navbar.module.css

.Navbar {
    background: var(--navbar-background-color);
}
empresario web
fuente
1

Mi apuesta aquí es usar componentes de función con nuevos ganchos para resolverlo, pero en lugar de usar useEffectcomo en respuestas anteriores, creo que la opción correcta sería useLayoutEffectpor una razón importante:

La firma es idéntica a useEffect, pero se dispara sincrónicamente después de todas las mutaciones DOM.

Esto se puede encontrar en la documentación de React . Si usamos en su useEffectlugar y volvemos a cargar la página ya desplazada, el desplazamiento será falso y nuestra clase no se aplicará, causando un comportamiento no deseado.

Un ejemplo:

import React, { useState, useLayoutEffect } from "react"

const Mycomponent = (props) => {
  const [scrolled, setScrolled] = useState(false)

  useLayoutEffect(() => {
    const handleScroll = e => {
      setScrolled(window.scrollY > 0)
    }

    window.addEventListener("scroll", handleScroll)

    return () => {
      window.removeEventListener("scroll", handleScroll)
    }
  }, [])

  ...

  return (
    <div className={scrolled ? "myComponent--scrolled" : ""}>
       ...
    </div>
  )
}

Una posible solución al problema podría ser https://codepen.io/dcalderon/pen/mdJzOYq

const Item = (props) => { 
  const [scrollY, setScrollY] = React.useState(0)

  React.useLayoutEffect(() => {
    const handleScroll = e => {
      setScrollY(window.scrollY)
    }

    window.addEventListener("scroll", handleScroll)

    return () => {
      window.removeEventListener("scroll", handleScroll)
    }
  }, [])

  return (
    <div class="item" style={{'--scrollY': `${Math.min(0, scrollY/3 - 60)}px`}}>
      Item
    </div>
  )
}
Calderón
fuente
Tengo curiosidad por el useLayoutEffect. Estoy tratando de ver lo que has mencionado.
giovannipds
Si no le importa, ¿podría proporcionar un ejemplo repo + visual de esto? Simplemente no pude reproducir lo que has mencionado como un problema useEffectaquí en comparación con useLayoutEffect.
giovannipds
¡Por supuesto! https://github.com/calderon/uselayout-vs-uselayouteffect . Esto me sucedió ayer con un comportamiento similar. Por cierto, soy un novato de reacción y posiblemente estoy totalmente equivocado: D: D
Calderón
En realidad, he estado intentando esto muchas veces, volviendo a cargar mucho, y a veces aparece encabezado en rojo en lugar de azul, lo que significa que a .Header--scrolledveces aplica la clase, pero un 100% de las veces .Header--scrolledLayoutse aplica correctamente gracias a useLayoutEffect.
Calderón
Moví el
Calderón
1

Aquí hay otro ejemplo usando HOOKS fontAwesomeIcon y Kendo UI React
[! [Captura de pantalla aquí] [1]] [1]

import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';


const ScrollBackToTop = () => {
  const [show, handleShow] = useState(false);

  useEffect(() => {
    window.addEventListener('scroll', () => {
      if (window.scrollY > 1200) {
        handleShow(true);
      } else handleShow(false);
    });
    return () => {
      window.removeEventListener('scroll');
    };
  }, []);

  const backToTop = () => {
    window.scroll({ top: 0, behavior: 'smooth' });
  };

  return (
    <div>
      {show && (
      <div className="backToTop text-center">
        <button className="backToTop-btn k-button " onClick={() => backToTop()} >
          <div className="d-none d-xl-block mr-1">Top</div>
          <FontAwesomeIcon icon="chevron-up"/>
        </button>
      </div>
      )}
    </div>
  );
};

export default ScrollBackToTop;```


  [1]: https://i.stack.imgur.com/ZquHI.png
jso1919
fuente
Esto es asombroso Tuve un problema en mi useEffect () cambiando el estado de mi barra de navegación en el desplazamiento usando window.onscroll () ... descubrí a través de esta respuesta que window.addEventListener () y window.removeEventListener () son el enfoque correcto para controlar mi sticky barra de navegación con un componente funcional ... ¡gracias!
Michael
1

Actualización para una respuesta con React Hooks

Estos son dos ganchos: uno para la dirección (arriba / abajo / ninguno) y otro para la posición real

Usar así:

useScrollPosition(position => {
    console.log(position)
  })

useScrollDirection(direction => {
    console.log(direction)
  })

Aquí están los ganchos:

import { useState, useEffect } from "react"

export const SCROLL_DIRECTION_DOWN = "SCROLL_DIRECTION_DOWN"
export const SCROLL_DIRECTION_UP = "SCROLL_DIRECTION_UP"
export const SCROLL_DIRECTION_NONE = "SCROLL_DIRECTION_NONE"

export const useScrollDirection = callback => {
  const [lastYPosition, setLastYPosition] = useState(window.pageYOffset)
  const [timer, setTimer] = useState(null)

  const handleScroll = () => {
    if (timer !== null) {
      clearTimeout(timer)
    }
    setTimer(
      setTimeout(function () {
        callback(SCROLL_DIRECTION_NONE)
      }, 150)
    )
    if (window.pageYOffset === lastYPosition) return SCROLL_DIRECTION_NONE

    const direction = (() => {
      return lastYPosition < window.pageYOffset
        ? SCROLL_DIRECTION_DOWN
        : SCROLL_DIRECTION_UP
    })()

    callback(direction)
    setLastYPosition(window.pageYOffset)
  }

  useEffect(() => {
    window.addEventListener("scroll", handleScroll)
    return () => window.removeEventListener("scroll", handleScroll)
  })
}

export const useScrollPosition = callback => {
  const handleScroll = () => {
    callback(window.pageYOffset)
  }

  useEffect(() => {
    window.addEventListener("scroll", handleScroll)
    return () => window.removeEventListener("scroll", handleScroll)
  })
}
dowi
fuente
0

Para ampliar la respuesta de @ Austin, debe agregar this.handleScroll = this.handleScroll.bind(this)a su constructor:

constructor(props){
    this.handleScroll = this.handleScroll.bind(this)
}
componentDidMount: function() {
    window.addEventListener('scroll', this.handleScroll);
},

componentWillUnmount: function() {
    window.removeEventListener('scroll', this.handleScroll);
},

handleScroll: function(event) {
    let scrollTop = event.srcElement.body.scrollTop,
        itemTranslate = Math.min(0, scrollTop/3 - 60);

    this.setState({
      transform: itemTranslate
    });
},
...

Esto da handleScroll() acceso al ámbito adecuado cuando se llama desde el detector de eventos.

También tenga en cuenta que no puede hacer el .bind(this)de las addEventListenero removeEventListenerlos métodos, ya que se no se eliminarán cada devuelven Referencias a diferentes funciones y el evento cuando los desmontajes de componentes.

nbwoodward
fuente