How can I improve performance on my parallax scroll script?
Asked Answered
K

4

9

I'm using Javascript & jQuery to build a parallax scroll script that manipulates an image in a figure element using transform:translate3d, and based on the reading I've done (Paul Irish's blog, etc), I've been informed the best solution for this task is to use requestAnimationFrame for performance reasons.

Although I understand how to write Javascript, I'm always finding myself uncertain of how to write good Javascript. In particular, while the code below seems to function correctly and smoothly, I'd like to get a few issues resolved that I'm seeing in Chrome Dev Tools.

$(document).ready(function() {
    function parallaxWrapper() {
        // Get the viewport dimensions
        var viewportDims = determineViewport();         
        var parallaxImages = [];
        var lastKnownScrollTop;

        // Foreach figure containing a parallax 
        $('figure.parallax').each(function() {
            // Save information about each parallax image
            var parallaxImage = {};
            parallaxImage.container = $(this);
            parallaxImage.containerHeight = $(this).height();
            // The image contained within the figure element
            parallaxImage.image = $(this).children('img.lazy');
            parallaxImage.offsetY = parallaxImage.container.offset().top;

            parallaxImages.push(parallaxImage);
        });

        $(window).on('scroll', function() {
            lastKnownScrollTop = $(window).scrollTop();
        });

        function animateParallaxImages() {
            $.each(parallaxImages, function(index, parallaxImage) {
                var speed = 3;
                var delta = ((lastKnownScrollTop + ((viewportDims.height - parallaxImage.containerHeight) / 2)) - parallaxImage.offsetY) / speed;
                parallaxImage.image.css({ 
                    'transform': 'translate3d(0,'+ delta +'px,0)'
                });
            });     
            window.requestAnimationFrame(animateParallaxImages);
        }
        animateParallaxImages();
    }

    parallaxWrapper();
});

Firstly, when I head to the 'Timeline' tab in Chrome Dev Tools, and start recording, even with no actions on the page being performed, the "actions recorded" overlay count continues to climb, at a rate of about ~40 per second.

Secondly, why is an "animation frame fired" executing every ~16ms, even when I am not scrolling or interacting with the page, as shown by the image below?

Thirdly, why is the Used JS Heap increasing in size without me interacting with the page? As shown in the image below. I have eliminated all other scripts that could be causing this.

Chrome dev tools.

Can anyone help me with some pointers to fix the above issues, and give me suggestions on how I should improve my code?

Kasey answered 29/12, 2014 at 8:55 Comment(6)
You need to present more code in order to get a more detailed answer.Alamo
Could you create a jsfiddle.net working example?Phonics
are you using modernizr, by chance?Puerperal
@markE: This is the entirety of the code. There is no more.Kasey
@Todd: I am not using Modernizr with this. Thanks!Kasey
I plan to try and help, but as a quick pointer, Modernizr makes my advanced web development projects go muchh more sanely. So, it'd be a helpful addition, especially when considering your design across devices.Puerperal
S
3

Edit: I had not seen the answers from @user1455003 and @mpd at the time I wrote this. They answered while I was writing the book below.

requestAnimationFrame is analogous to setTimeout, except the browser wont fire your callback function until it's in a "render" cycle, which typically happens about 60 times per second. setTimeout on the other hand can fire as fast as your CPU can handle if you want it to.

Both requestAnimationFrame and setTimeout have to wait until the next available "tick" (for lack of a better term) until it will run. So, for example, if you use requestAnimationFrame it should run about 60 times per second, but if the browsers frame rate drops to 30fps (because you're trying to rotate a giant PNG with a large box-shadow) your callback function will only fire 30 times per second. Similarly, if you use setTimeout(..., 1000) it should run after 1000 milliseconds. However, if some heavy task causes the CPU to get caught up doing work, your callback won't fire until the CPU has cycles to give. John Resig has a great article on JavaScript timers.

So why not use setTimeout(..., 16) instead of request animation frame? Because your CPU might have plenty of head room while the browser's frame rate has dropped to 30fps. In such a case you would be running calculations 60 times per second and trying to render those changes, but the browser can only handle half that much. Your browser would be in a constant state of catch-up if you do it this way... hence the performance benefits of requestAnimationFrame.

For brevity, I am including all suggested changes in a single example below.

The reason you are seeing the animation frame fired so often is because you have a "recursive" animation function which is constantly firing. If you don't want it firing constantly, you can make sure it only fires while the user is scrolling.

The reason you are seeing the memory usage climb has to do with garbage collection, which is the browsers way of cleaning up stale memory. Every time you define a variable or function, the browser has to allocate a block of memory for that information. Browsers are smart enough to know when you are done using a certain variable or function and free up that memory for reuse - however, it will only collect the garbage when there is enough stale memory worth collecting. I can't see the scale of the memory graph in your screenshot, but if the memory is increasing in kilobyte size amounts, the browser may not clean it up for several minutes. You can minimize the allocation of new memory by reusing variable names and functions. In your example, every animation frame (60x second) defines a new function (used in $.each) and 2 variables (speed and delta). These are easily reusable (see code).

If your memory usage continues to increase ad infinitum, then there is a memory leak problem elsewhere in your code. Grab a beer and start doing research as the code you've posted here is leak-free. The biggest culprit is referencing an object (JS object or DOM node) which then gets deleted and the reference still hangs around. For example, if you bind a click event to a DOM node, delete the node, and never unbind the event handler... there ya go, a memory leak.

$(document).ready(function() {
    function parallaxWrapper() {
        // Get the viewport dimensions
        var $window = $(window),
            speed = 3,
            viewportDims = determineViewport(),
            parallaxImages = [],
            isScrolling = false,
            scrollingTimer = 0,
            lastKnownScrollTop;

        // Foreach figure containing a parallax 
        $('figure.parallax').each(function() {
            // The browser should clean up this function and $this variable - no need for reuse
            var $this = $(this);
            // Save information about each parallax image
            parallaxImages.push({
                container = $this,
                containerHeight: $this.height(),
                // The image contained within the figure element
                image: $this.children('img.lazy'),
                offsetY: $this.offset().top
            });
        });

        // This is a bit overkill and could probably be defined inline below
        // I just wanted to illustrate reuse...
        function onScrollEnd() {
            isScrolling = false;
        }

        $window.on('scroll', function() {
            lastKnownScrollTop = $window.scrollTop();
            if( !isScrolling ) {
                isScrolling = true;
                animateParallaxImages();
            }
            clearTimeout(scrollingTimer);
            scrollingTimer = setTimeout(onScrollEnd, 100);
        });

        function transformImage (index, parallaxImage) {
            parallaxImage.image.css({ 
                'transform': 'translate3d(0,' + (
                     (
                         lastKnownScrollTop + 
                         (viewportDims.height - parallaxImage.containerHeight) / 2 - 
                         parallaxImage.offsetY
                     ) / speed 
                ) + 'px,0)'
            });
        }

        function animateParallaxImages() {
            $.each(parallaxImages, transformImage);
            if (isScrolling) {    
                window.requestAnimationFrame(animateParallaxImages);
            }
        }
    }

    parallaxWrapper();
});
Sev answered 6/1, 2015 at 22:32 Comment(0)
A
5

(1 & 2 -- same answer) The pattern you are using creates a repeating animating loop which attempts to fire at the same rate as the browser refreshes. That's usually 60 time per second so the activity you're seeing is the loop executing approximately every 1000/60=16ms. If there's no work to do, it still fires every 16ms.

(3) The browser consumes memory as needed for your animations but the browser does not reclaim that memory immediately. Instead it occasionally reclaims any orphaned memory in a process called garbage collection. So your memory consumption should go up for a while and then drop in a big chunk. If it doesn't behave that way, then you have a memory leak.

Alamo answered 29/12, 2014 at 17:51 Comment(2)
What would be a way to avoid what is occurring with 1 & 2? I am aware of window.cancelAnimationFrame but I'm unsure of how to apply it to this context. Re: 3, the memory usage continues to rise regardless of time.Kasey
Keep the animation going forever (an animation loop that does no work is not expensive). You could set/clear a flag (doAnimate) that indicates if the animation loop need to do work then in animateParallaxImages: if(doAnimate) {...animate stuff...}. About your memory leak...you'll just have to use the dev tools to track down the problem. Good luck with your project!Alamo
S
3

Edit: I had not seen the answers from @user1455003 and @mpd at the time I wrote this. They answered while I was writing the book below.

requestAnimationFrame is analogous to setTimeout, except the browser wont fire your callback function until it's in a "render" cycle, which typically happens about 60 times per second. setTimeout on the other hand can fire as fast as your CPU can handle if you want it to.

Both requestAnimationFrame and setTimeout have to wait until the next available "tick" (for lack of a better term) until it will run. So, for example, if you use requestAnimationFrame it should run about 60 times per second, but if the browsers frame rate drops to 30fps (because you're trying to rotate a giant PNG with a large box-shadow) your callback function will only fire 30 times per second. Similarly, if you use setTimeout(..., 1000) it should run after 1000 milliseconds. However, if some heavy task causes the CPU to get caught up doing work, your callback won't fire until the CPU has cycles to give. John Resig has a great article on JavaScript timers.

So why not use setTimeout(..., 16) instead of request animation frame? Because your CPU might have plenty of head room while the browser's frame rate has dropped to 30fps. In such a case you would be running calculations 60 times per second and trying to render those changes, but the browser can only handle half that much. Your browser would be in a constant state of catch-up if you do it this way... hence the performance benefits of requestAnimationFrame.

For brevity, I am including all suggested changes in a single example below.

The reason you are seeing the animation frame fired so often is because you have a "recursive" animation function which is constantly firing. If you don't want it firing constantly, you can make sure it only fires while the user is scrolling.

The reason you are seeing the memory usage climb has to do with garbage collection, which is the browsers way of cleaning up stale memory. Every time you define a variable or function, the browser has to allocate a block of memory for that information. Browsers are smart enough to know when you are done using a certain variable or function and free up that memory for reuse - however, it will only collect the garbage when there is enough stale memory worth collecting. I can't see the scale of the memory graph in your screenshot, but if the memory is increasing in kilobyte size amounts, the browser may not clean it up for several minutes. You can minimize the allocation of new memory by reusing variable names and functions. In your example, every animation frame (60x second) defines a new function (used in $.each) and 2 variables (speed and delta). These are easily reusable (see code).

If your memory usage continues to increase ad infinitum, then there is a memory leak problem elsewhere in your code. Grab a beer and start doing research as the code you've posted here is leak-free. The biggest culprit is referencing an object (JS object or DOM node) which then gets deleted and the reference still hangs around. For example, if you bind a click event to a DOM node, delete the node, and never unbind the event handler... there ya go, a memory leak.

$(document).ready(function() {
    function parallaxWrapper() {
        // Get the viewport dimensions
        var $window = $(window),
            speed = 3,
            viewportDims = determineViewport(),
            parallaxImages = [],
            isScrolling = false,
            scrollingTimer = 0,
            lastKnownScrollTop;

        // Foreach figure containing a parallax 
        $('figure.parallax').each(function() {
            // The browser should clean up this function and $this variable - no need for reuse
            var $this = $(this);
            // Save information about each parallax image
            parallaxImages.push({
                container = $this,
                containerHeight: $this.height(),
                // The image contained within the figure element
                image: $this.children('img.lazy'),
                offsetY: $this.offset().top
            });
        });

        // This is a bit overkill and could probably be defined inline below
        // I just wanted to illustrate reuse...
        function onScrollEnd() {
            isScrolling = false;
        }

        $window.on('scroll', function() {
            lastKnownScrollTop = $window.scrollTop();
            if( !isScrolling ) {
                isScrolling = true;
                animateParallaxImages();
            }
            clearTimeout(scrollingTimer);
            scrollingTimer = setTimeout(onScrollEnd, 100);
        });

        function transformImage (index, parallaxImage) {
            parallaxImage.image.css({ 
                'transform': 'translate3d(0,' + (
                     (
                         lastKnownScrollTop + 
                         (viewportDims.height - parallaxImage.containerHeight) / 2 - 
                         parallaxImage.offsetY
                     ) / speed 
                ) + 'px,0)'
            });
        }

        function animateParallaxImages() {
            $.each(parallaxImages, transformImage);
            if (isScrolling) {    
                window.requestAnimationFrame(animateParallaxImages);
            }
        }
    }

    parallaxWrapper();
});
Sev answered 6/1, 2015 at 22:32 Comment(0)
Z
1

@markE's answer is right on for 1 & 2

(3) Is due to the fact that your animation loop is infinitely recursive:

 function animateParallaxImages() {
        $.each(parallaxImages, function(index, parallaxImage) {
            var speed = 3;
            var delta = ((lastKnownScrollTop + ((viewportDims.height - parallaxImage.containerHeight) / 2)) - parallaxImage.offsetY) / speed;
            parallaxImage.image.css({ 
                'transform': 'translate3d(0,'+ delta +'px,0)'
            });
        });     
        window.requestAnimationFrame(animateParallaxImages); //recursing here, but there is no base base
    }
    animateParallaxImages(); //Kick it off

If you look at the example on MDN:

var start = null;
var element = document.getElementById("SomeElementYouWantToAnimate");

function step(timestamp) {
  if (!start) start = timestamp;
  var progress = timestamp - start;
  element.style.left = Math.min(progress/10, 200) + "px";
  if (progress < 2000) {
    window.requestAnimationFrame(step);
  }
} 

window.requestAnimationFrame(step);

I would suggest either stopping recursion at some point, or refactor your code so functions/variables aren't being declared in the loop:

 var SPEED = 3; //constant so only declare once
 var delta; // declare  outside of the function to reduce the number of allocations needed
 function imageIterator(index, parallaxImage){
     delta = ((lastKnownScrollTop + ((viewportDims.height - parallaxImage.containerHeight) / 2)) - parallaxImage.offsetY) / SPEED;
     parallaxImage.image.css({ 
         'transform': 'translate3d(0,'+ delta +'px,0)'
     });
 }

 function animateParallaxImages() {
    $.each(parallaxImages, imageIterator);  // you could also change this to a traditional loop for a small performance gain for(...)
     window.requestAnimationFrame(animateParallaxImages); //recursing here, but there is no base base
 }
 animateParallaxImages(); //Kick it off
Zahn answered 6/1, 2015 at 22:30 Comment(0)
I
0

Try getting rid of the animation loop and putting the scroll changes in the 'scroll' function. This will prevent your script from doing transforms when lastKnownScrollTop is unchanged.

$(window).on('scroll', function() {
    lastKnownScrollTop = $(window).scrollTop();
    $.each(parallaxImages, function(index, parallaxImage) {
        var speed = 3;
        var delta = ((lastKnownScrollTop + ((viewportDims.height - parallaxImage.containerHeight) / 2)) - parallaxImage.offsetY) / speed;
        parallaxImage.image.css({ 
            'transform': 'translate3d(0,'+ delta +'px,0)'
        });
    }); 
});
Immersion answered 6/1, 2015 at 22:18 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.