Mettre à jour le style d'un composant onScroll dans React.js

133

J'ai construit un composant dans React qui est censé mettre à jour son propre style sur le défilement de la fenêtre pour créer un effet de parallaxe.

La renderméthode du composant ressemble à ceci:

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

Cela ne fonctionne pas car React ne sait pas que le composant a changé, et par conséquent, le composant n'est pas rendu.

J'ai essayé de stocker la valeur de itemTranslatedans l'état du composant et d'appelersetState le rappel de défilement. Cependant, cela rend le défilement inutilisable car il est terriblement lent.

Une suggestion sur la façon de procéder?

Alejandro Pérez
la source
9
Ne liez jamais un gestionnaire d'événements externe à l'intérieur d'une méthode de rendu. Les méthodes de rendu (et toutes les autres méthodes personnalisées que vous appelez renderdans le même thread) ne devraient être concernées que par la logique relative au rendu / mise à jour du DOM réel dans React. Au lieu de cela, comme indiqué par @AustinGreco ci-dessous, vous devez utiliser les méthodes de cycle de vie React données pour créer et supprimer votre liaison d'événement. Cela le rend autonome à l'intérieur du composant et garantit l'absence de fuite en s'assurant que la liaison d'événement est supprimée si / lorsque le composant qui l'utilise est démonté.
Mike Driver

Réponses:

232

Vous devez lier l'auditeur componentDidMount, de cette façon, il n'est créé qu'une seule fois. Vous devriez pouvoir stocker le style dans l'état, l'auditeur était probablement la cause de problèmes de performances.

Quelque chose comme ça:

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
la source
26
J'ai trouvé que setState'ing à l'intérieur de l'événement de défilement pour l'animation est saccadé. J'ai dû définir manuellement le style des composants à l'aide de références.
Ryan Rho
1
Sur quoi pointerait le "this" inside handleScroll? Dans mon cas, il s'agit d'une "fenêtre" et non d'un composant. Je finis par passer le composant comme paramètre
yuji
10
@yuji vous pouvez éviter d'avoir à passer le composant en le liant dans le constructeur: this.handleScroll = this.handleScroll.bind(this)le liera à l'intérieur handleScrollau composant, au lieu de window.
Matt Parrilla
1
Notez que srcElement n'est pas disponible dans Firefox.
Paulin Trognon
2
n'a pas fonctionné pour moi, mais ce qui a fait était de mettre scrollTop àevent.target.scrollingElement.scrollTop
George
31

Vous pouvez passer une fonction à l' onScrollévénement sur l'élément React: https://facebook.github.io/react/docs/events.html#ui-events

<ScrollableComponent
 onScroll={this.handleScroll}
/>

Une autre réponse similaire: https://stackoverflow.com/a/36207913/1255973

Con Antonakos
la source
5
Y a-t-il un avantage / inconvénient à cette méthode par rapport à l'ajout manuel d'un écouteur d'événement à l'élément de fenêtre @AustinGreco mentionné?
Dennis
2
@Dennis Un avantage est que vous n'avez pas à ajouter / supprimer manuellement les écouteurs d'événements. Bien que cela puisse être un exemple simple si vous gérez manuellement plusieurs écouteurs d'événements dans toute votre application, il est facile d'oublier de les supprimer correctement lors des mises à jour, ce qui peut entraîner des bogues de mémoire. J'utiliserais toujours la version intégrée si possible.
F Lekschas
4
Il convient de noter que cela attache un gestionnaire de défilement au composant lui-même, pas à la fenêtre, ce qui est très différent. @Dennis Les avantages de onScroll sont qu'il est plus multi-navigateurs et plus performant. Si vous pouvez l'utiliser, vous devriez probablement, mais cela peut ne pas être utile dans des cas comme celui de l'OP
Beau
20

Ma solution pour faire une barre de navigation responsive (position: 'relative' quand on ne défile pas et fixe lors du défilement et non en haut de la page)

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

Aucun problème de performance pour moi.

robins_
la source
Vous pouvez également utiliser un faux en-tête qui n'est essentiellement qu'un espace réservé. Donc, vous avez votre en-tête fixe et en dessous, vous avez votre fakeheader d'espace réservé avec position: relative.
robins_
Aucun problème de performances car cela ne résout pas le problème de parallaxe dans la question.
juanitogan le
19

pour aider tous ceux qui ont remarqué les problèmes de comportement / performances lents lors de l'utilisation de la réponse Austins, et qui veulent un exemple utilisant les références mentionnées dans les commentaires, voici un exemple que j'utilisais pour faire basculer une classe pour une icône de défilement haut / bas:

Dans la méthode de rendu:

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

Dans la méthode du gestionnaire:

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');
  }
}

Et ajoutez / supprimez vos gestionnaires de la même manière qu'Austin l'a mentionné:

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

docs sur les réf .

adrian_reimer
la source
4
Tu as sauvé ma journée! Pour la mise à jour, vous n'avez en fait pas besoin d'utiliser jquery pour modifier le nom de classe à ce stade, car il s'agit déjà d'un élément DOM natif. Vous pouvez donc simplement le faire this.scrollIcon.className = whatever-you-want.
sud
2
cette solution rompt l'encapsulation React bien que je ne sois toujours pas sûr d'un moyen de contourner cela sans comportement laggy - peut-être qu'un événement de défilement non rebondi (à peut-être 200-250 ms) serait une solution ici
Jordanie
Non, l'événement de défilement debounce ne permet que de rendre le défilement plus fluide (dans un sens non bloquant), mais il faut 500 ms à une seconde pour que les mises à jour s'appliquent dans le DOM: /
Jordan
J'ai également utilisé cette solution, +1. Je suis d'accord que vous n'avez pas besoin de jQuery: utilisez simplement classNameou classList. De plus, je n'en avais pas besoinwindow.addEventListener() : je viens d'utiliser React's onScroll, et c'est aussi rapide, tant que vous ne mettez pas à jour les props / state!
Benjamin le
13

J'ai trouvé que je ne peux pas ajouter avec succès l'écouteur d'événements à moins que je passe true comme ceci:

componentDidMount = () => {
    window.addEventListener('scroll', this.handleScroll, true);
},
Jean-Marie Dalmasso
la source
Ça marche. Mais pouvez-vous comprendre pourquoi nous devons passer un vrai booléen à cet auditeur.
shah chaitanya
2
De w3schools: [ w3schools.com/jsref/met_document_addeventlistener.asp] userCapture : facultatif. Valeur booléenne qui spécifie si l'événement doit être exécuté dans la capture ou dans la phase de bullage. Valeurs possibles: true - Le gestionnaire d'événements est exécuté dans la phase de capture false - Par défaut. Le gestionnaire d'événements est exécuté dans la phase de bouillonnement
Jean-Marie Dalmasso
12

Un exemple utilisant classNames , les hooks React useEffect , useState et 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
la source
1
Notez que comme cet useEffect n'a pas de deuxième argument, il s'exécutera à chaque fois que l'en-tête sera rendu.
Jordanie
2
@Jordan vous avez raison! Mon erreur d'écrire ceci ici. Je vais modifier la réponse. Merci beaucoup.
giovannipds le
8

avec crochets

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

function MyApp () {

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

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

  console.log(offset); 
};
Sodium United
la source
Exactement ce dont j'avais besoin. Merci!
aabbccsmith le
C'est de loin la réponse la plus efficace et la plus élégante de toutes. Merci pour cela.
Chigozie Orunta
Cela nécessite plus d'attention, parfait.
Anders Kitson
6

Exemple de composant de fonction utilisant useEffect:

Remarque : vous devez supprimer l'écouteur d'événements en renvoyant une fonction de «nettoyage» dans useEffect. Sinon, à chaque mise à jour du composant, vous aurez un écouteur de défilement de fenêtre supplémentaire.

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>
  );
}
Richard
la source
Notez que comme cet useEffect n'a pas de deuxième argument, il s'exécutera à chaque fois que le composant sera rendu.
Jordanie
Bon point. Devrions-nous ajouter un tableau vide à l'appel useEffect?
Richard
C'est ce que je ferais :)
Jordan
5

Si ce qui vous intéresse est un composant enfant qui défile, cet exemple peut être utile: 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>
    )
  }
}
utilisateur2312410
la source
1

J'ai résolu le problème en utilisant et en modifiant les variables CSS. De cette façon, je n'ai pas à modifier l'état du composant, ce qui cause des problèmes de performances.

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);
}
webpreneur
la source
1

Mon pari ici est d'utiliser des composants Function avec de nouveaux crochets pour le résoudre, mais au lieu d'utiliser useEffectcomme dans les réponses précédentes, je pense que la bonne option serait useLayoutEffectpour une raison importante:

La signature est identique à useEffect, mais elle se déclenche de manière synchrone après toutes les mutations DOM.

Cela peut être trouvé dans la documentation React . Si nous utilisons à la useEffectplace et que nous rechargeons la page déjà défilée, le défilement sera faux et notre classe ne sera pas appliquée, provoquant un comportement indésirable.

Un exemple:

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

Une solution possible au problème pourrait être 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
la source
Je suis curieux de savoir useLayoutEffect. J'essaie de voir ce que vous avez mentionné.
giovannipds
Si cela ne vous dérange pas, pourriez-vous s'il vous plaît fournir un exemple de repo + visuel de ce qui se passe? Je ne pourrais tout simplement pas reproduire ce que vous avez mentionné comme un problème useEffectici par rapport à useLayoutEffect.
giovannipds
Sûr! https://github.com/calderon/uselayout-vs-uselayouteffect . Cela m'est arrivé hier avec un comportement similaire. BTW, je suis un débutant en réaction et peut-être que je me trompe totalement: D: D
Calderón
En fait, j'ai essayé cela plusieurs fois, en rechargeant beaucoup, et parfois il apparaît en-tête en rouge au lieu de bleu, ce qui signifie qu'il applique .Header--scrolledparfois une classe, mais 100% .Header--scrolledLayoutest appliqué correctement grâce à useLayoutEffect.
Calderón
J'ai déplacé le repo sur github.com/calderon/useeffect-vs-uselayouteffect
Calderón
1

Voici un autre exemple en utilisant CROCHET fontAwesomeIcon et Kendo UI React
[! [Capture d' écran ici] [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
la source
C'est génial. J'ai eu un problème dans mon useEffect () en changeant l'état de ma barre de navigation lors du défilement en utilisant window.onscroll () ... j'ai découvert grâce à cette réponse que window.addEventListener () et window.removeEventListener () sont la bonne approche pour contrôler mon sticky barre de navigation avec un composant fonctionnel ... merci!
Michael
1

Mise à jour pour une réponse avec React Hooks

Ce sont deux crochets - un pour la direction (haut / bas / aucun) et un pour la position réelle

Utilisez comme ceci:

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

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

Voici les crochets:

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
la source
0

Pour développer la réponse de @ Austin, vous devez ajouter this.handleScroll = this.handleScroll.bind(this)à votre constructeur:

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
    });
},
...

Cela donne handleScroll() accès à la portée appropriée lorsqu'il est appelé à partir de l'écouteur d'événements.

Il faut aussi savoir que vous ne pouvez pas faire la .bind(this)dans les addEventListenerou removeEventListenerméthodes , car ils chacun des références de retour à différentes fonctions et l'événement ne seront pas supprimés lorsque le composant démontages.

nbwoodward
la source