Vue centralize scroll- and resize-events (avoiding repetition in components)

Viewed 861

I have a Vue-structure like this:

App
 |--{ data: { scrollInfo {...} } }
 |
 |--Component1
 |      |--{ data: { sidebarWidth: 500 }
 |
 |--Component2
 |      |--{ data: { overlayItemWidth: 389 }
 |
 |--Component3
 ...
 ...

In each of my components, I have elements that has variables that should change on scroll and resize (such as sidebarWidth or overlayItemWidth ). I found this post here that shows this way of adding a listener for scroll and resize.

I've added it to the main instance, like so:

data: {
  scrollInfo: {
    scrollFromTop: 0,
    viewportHeight: 0,
    viewportWidth: 0,
    pageHeight: 0
  }
},
created() {
  window.addEventListener( 'scroll', this.calculateScroll );
  window.addEventListener( 'resize', this.calculateViewport );
  window.addEventListener( 'resize', this.calculatePageScrollSpecs );
  this.calculateViewport();
  this.calculateScroll();
  this.calculatePageScrollSpecs();
},
destroyed() {
  window.removeEventListener( 'scroll', this.calculateScroll );
  window.removeEventListener( 'resize', this.calculateViewport );
  window.removeEventListener( 'resize', this.calculatePageScrollSpecs );
}

I wont show the contents of the methods (calculateScroll, calculateViewport, ... ), since it's not relevant to this question.

Now... In my components I have variables that should change and reevaluate on scroll and on resize as well. But every time I have such a variable, then I currently add those same listeners in created and destroyed, and then add the same event-listeners to the given component, and then do the calculations from a new method in there. It seems long and clumbsy.

Are there a way that I can get around having those window.AddEventListeners in each component, but only have those in my root instance?

... I was thinking, if I had an array of 'things-that-need-to-be-recalculated-on-scroll-or-resize' in my main instance, but I'm not sure if it would get cluttered, since the variables for the components then actually wouldn't be kept in the component, but instead be referenced with this.$root.sidebarWidth. And this would also make my main instance massive.

Any suggestions?

3 Answers

Okay... No answers at all, - so I dived down into it myself. This is the best I could come up with. I hope it helps others in the need of the same.

I was torn between using extends (source) or mixins (source).

After a bit of research, I ended up using mixins (this video hit the nail on the head regarding what I was trying to achieve and how to solve it). If you want to make the mixin global, for all components, then look at this video around 4:11 and onwards. I'm not doing that in below-written explanation.

Please note, that I'm using webpack alongside laravel-mix with below-written solution. It's used in a WordPress-installation.

Disclosure

This is a mix of a bazillion different answers and articles. It would take forever to credit it all to the correct owners and sources, so I'm not even gonna try. Sorry.

The mixins-file (./mixins/scrollAndResize.js).

export const scrollAndResizeMixin = {
  created() {
    console.log( 'scrollAndResizes loaded' );
    this.calcScroll();
    this.calcPageAndViewport();
  },
  data: function() {
    return {
      scrollFromTop: 0,
      viewportHeight: 0,
      viewportWidth: 0,
      pageHeight: 0
    }
  },
  methods: {
    calcScroll: function (){
      let doc = document.documentElement;
      this.scrollFromTop = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0);

      // DEBUG (uncomment and scroll, to check if it works)
      // console.log( (window.pageYOffset || doc.scrollTop) );
      // console.log( (doc.clientTop || 0) );
    },
    calcPageAndViewport: function(){
      // Viewport info
      this.viewportHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
      this.viewportWidth = Math.min(document.documentElement.clientWidth, window.innerWidth || 0);

      // Page length
      var body = document.body;
      var html = document.documentElement;
      this.pageHeight = Math.max(
        body.scrollHeight,
        body.offsetHeight,
        html.clientHeight,
        html.scrollHeight,
        html.offsetHeight
      );
    },

  }
};

The main js-file (./app.js).

// Vue
import Vue from 'vue';

// Mixins
import { scrollAndResizeMixin } from './mixins/scrollAndResize';

// The Application
const app = new Vue({
  mixins: [
    scrollAndResizeMixin
  ],
  computed: {
    mobile: function() {
      return this.viewportWidth < 992; // This uses the mixin
    }
  },
  created() {
    window.addEventListener( 'scroll', this.calcScroll );
    window.addEventListener( 'resize', this.calcPageAndViewport );
  },
  destroyed() {
    window.removeEventListener( 'scroll', this.calcScroll );
    window.removeEventListener( 'resize', this.calcPageAndViewport );
  }
}); 

And/or use it only in a component like so...

<template>

  <div>
    <p v-if="viewportWidth > 992">
      Desktop
    </p>
    <p v-else>
      Mobile
    </p>   
  </div>

</template>



<script>
  import { scrollAndResizeMixin } from '../mixins/scrollAndResize';

  export default {
    mounted() {
    },
    mixins: [
      scrollAndResizeMixin
    ],
    created() {
      window.addEventListener('scroll', this.calcScroll);
      window.addEventListener('resize', this.calcPageAndViewport);
    },
    destroyed() {
      window.removeEventListener('scroll', this.calcScroll);
      window.removeEventListener('resize', this.calcPageAndViewport);
    }
  }
</script>

Glad you found a solution for yourself, but for future visitors who may want a little more comfortable/general solution: I had the same problem some months ago and put a Vue mixin on npm that does exactly this — handling global data once-per-lifetime, not once per mounted component.

It's called Reactivator and this is how you'd create a global handler for window.resize with it:

// viewport-width.js

export default {
  // Reactivator uses `getInitialState` to fetch the
  // initial value before any listener was attached.
  getInitialState() {
    return window.innerWidth
  },

  // Reactivator calls `listen` when the first component using
  // it is created and executes the returned cleanup callback
  // when it's the last component using it is destroyed.
  listen(set) {
    const resizeHandler = () => set(window.innerWidth)

    window.addEventListener('resize', resizeHandler)

    return () => window.removeEventListener('resize', resizeHandler)
  }
}


// component.js

import reactivator from 'vue-reactivator'
import viewportWidth from './viewport-width.js'

export default {
  mixins: [
    // `viewportWidth` will now be a reactive property in this component!
    reactivator({ viewportWidth })
  ]
}

That said, watching viewport size is such a common case that I have put it (and other useful things like scroll position or mouse position) in an extra package of common Reactivator implementations. You can find it on npm as vue-browser-state.

You should be making a custom component that can accept as props the things that change and that does all the operations needed (such as adding and removing event listeners).

Related