jquery-timeago icon indicating copy to clipboard operation
jquery-timeago copied to clipboard

Performance Issue: setTimeout/setInterval

Open theonlypwner opened this issue 10 years ago • 7 comments
trafficstars

It makes more sense to use setTimeout instead of setInterval.

If this is done, the function can determine when the text is actually going to change and not waste calls that don't do anything. If the text says 2 years ago, it's probably not going to change in a minute, so it makes more sense to calculate the time delay.

Also, this would be more efficient for a modified version that shows %d seconds ago and needs to update at 1000 ms intervals only before it becomes a minute. Then it can change the interval to 60000 ms.

theonlypwner avatar Oct 12 '15 23:10 theonlypwner

I see this as a genuine performance issue on a page which uses timeago on many elements.

edwh avatar Oct 01 '16 08:10 edwh

Here's a version (based on a slightly older base) which does this, and also doesn't keep the timer running if the element is no longer in the DOM.

/*!
 * Timeago is a jQuery plugin that makes it easy to support automatically
 * updating fuzzy timestamps (e.g. "4 minutes ago" or "about 1 day ago").
 *
 * @name timeago
 * @version 1.4.0
 * @requires jQuery v1.2.3+
 * @author Ryan McGeary
 * @license MIT License - http://www.opensource.org/licenses/mit-license.php
 *
 * For usage and examples, visit:
 * http://timeago.yarp.com/
 *
 * Copyright (c) 2008-2013, Ryan McGeary (ryan -[at]- mcgeary [*dot*] org)
 * 
 * Modified by EH
 */

var timeAgoId = 1;

(function(factory){
    if(typeof define === 'function' && define.amd){
        // AMD. Register as an anonymous module.
        define(['jquery'], factory);
    }else{
        // Browser globals
        factory(jQuery);
    }
}(function($){
    $.timeago = function(timestamp){
        if(timestamp instanceof Date){
            return inWords(timestamp);
        }else if(typeof timestamp === "string"){
            return inWords($.timeago.parse(timestamp));
        }else if(typeof timestamp === "number"){
            return inWords(new Date(timestamp));
        }else{
            return inWords($.timeago.datetime(timestamp));
        }
    };
    var $t = $.timeago;

    $.extend($.timeago, {
        settings: {
            refreshMillis: 60000,
            allowPast    : true,
            allowFuture  : false,
            localeTitle  : false,
            cutoff       : 0,
            strings      : {
                prefixAgo    : null,
                prefixFromNow: null,
                suffixAgo    : "ago",
                suffixFromNow: "from now",
                inPast       : 'any moment now',
                seconds      : "less than a minute",
                minute       : "about a minute",
                minutes      : "%d minutes",
                hour         : "about an hour",
                hours        : "about %d hours",
                day          : "a day",
                days         : "%d days",
                month        : "about a month",
                months       : "%d months",
                year         : "about a year",
                years        : "%d years",
                wordSeparator: " ",
                numbers      : []
            }
        },

        inWords: function(distanceMillis){
            if(!this.settings.allowPast && !this.settings.allowFuture){
                throw 'timeago allowPast and allowFuture settings can not both be set to false.';
            }

            var $l = this.settings.strings;
            var prefix = $l.prefixAgo;
            var suffix = $l.suffixAgo;
            if(this.settings.allowFuture){
                if(distanceMillis < 0){
                    prefix = $l.prefixFromNow;
                    suffix = $l.suffixFromNow;
                }
            }

            if(!this.settings.allowPast && distanceMillis >= 0){
                return this.settings.strings.inPast;
            }

            var seconds = Math.abs(distanceMillis) / 1000;
            var minutes = seconds / 60;
            var hours = minutes / 60;
            var days = hours / 24;
            var years = days / 365;

            function substitute(stringOrFunction, number){
                var string = $.isFunction(stringOrFunction) ? stringOrFunction(number, distanceMillis) : stringOrFunction;
                var value = ($l.numbers && $l.numbers[number]) || number;
                return string.replace(/%d/i, value);
            }

            var words = seconds < 45 && substitute($l.seconds, Math.round(seconds)) ||
                seconds < 90 && substitute($l.minute, 1) ||
                minutes < 45 && substitute($l.minutes, Math.round(minutes)) ||
                minutes < 90 && substitute($l.hour, 1) ||
                hours < 24 && substitute($l.hours, Math.round(hours)) ||
                hours < 42 && substitute($l.day, 1) ||
                days < 30 && substitute($l.days, Math.round(days)) ||
                days < 45 && substitute($l.month, 1) ||
                days < 365 && substitute($l.months, Math.round(days / 30)) ||
                years < 1.5 && substitute($l.year, 1) ||
                substitute($l.years, Math.round(years));

            var separator = $l.wordSeparator || "";
            if($l.wordSeparator === undefined){
                separator = " ";
            }
            return $.trim([prefix, words, suffix].join(separator));
        },

        parse   : function(iso8601){
            var s = $.trim(iso8601);
            s = s.replace(/\.\d+/, ""); // remove milliseconds
            s = s.replace(/-/, "/").replace(/-/, "/");
            s = s.replace(/T/, " ").replace(/Z/, " UTC");
            s = s.replace(/([\+\-]\d\d)\:?(\d\d)/, " $1$2"); // -04:00 -> -0400
            s = s.replace(/([\+\-]\d\d)$/, " $100"); // +09 -> +0900
            return new Date(s);
        },
        datetime: function(elem){
            var iso8601 = $t.isTime(elem) ? $(elem).attr("datetime") : $(elem).attr("title");
            return $t.parse(iso8601);
        },
        isTime  : function(elem){
            // jQuery's `is()` doesn't play well with HTML5 in IE
            return $(elem).get(0).tagName.toLowerCase() === "time"; // $(elem).is("time");
        }
    });

    // functions that can be called via $(el).timeago('action')
    // init is default when no action is given
    // functions are called with context of a single element
    var functions = {
        init         : function(){
            this.id = timeAgoId++;
            var refresh_el = $.proxy(refresh, this);
            refresh_el(true);
        },
        update       : function(time){
            var parsedTime = $t.parse(time);
            $(this).data('timeago', { datetime: parsedTime });
            if($t.settings.localeTitle) $(this).attr("title", parsedTime.toLocaleString());
            refresh.apply(this);
        },
        updateFromDOM: function(){
            $(this).data('timeago', { datetime: $t.parse($t.isTime(this) ? $(this).attr("datetime") : $(this).attr("title")) });
            refresh.apply(this);
        },
        dispose      : function(){
            if(this._timeagoInterval){
                window.clearInterval(this._timeagoInterval);
                this._timeagoInterval = null;
            }
        }
    };

    $.fn.timeago = function(action, options){
        var fn = action ? functions[action] : functions.init;
        if(!fn){
            throw new Error("Unknown function name '" + action + "' for timeago");
        }
        // each over objects here and call the requested function
        this.each(function(){
            fn.call(this, options);
        });
        return this;
    };

    function refresh(first){
        if (first || $(this).closest('body').length) {
            var data = prepareData(this);
            var $s = $t.settings;
            var nextTime = $s.refreshMillis;

            if(!isNaN(data.datetime)){
                var dist = distance(data.datetime);

                if($s.cutoff == 0 || dist < $s.cutoff){
                    $(this).text(inWords(data.datetime));
                }

                var seconds = Math.abs(dist) / 1000;
                var minutes = seconds / 60;
                var hours = minutes / 60;
                var days = hours / 24;
                var years = days / 365;

                // If the next change is a long time away, set the timer appropriately.
                if (years > 1) {
                    nextTime = 365 * 24 * 60 * 60 * 1000 / 2;
                } else if (days > 1) {
                    nextTime = 24 * 60 * 60 * 1000 / 2;
                } else if (hours > 1) {
                    nextTime = 60 * 60 * 1000 / 2;
                } else if (minutes > 1) {
                    nextTime = 60 * 1000 / 2;
                } 
            }

            var refresh_el = $.proxy(refresh, this);
            var $s = $t.settings;
            if($s.refreshMillis > 0){
                this._timeagoInterval = setTimeout(refresh_el, nextTime);
            }
        }

        return this;
    }

    function prepareData(element){
        element = $(element);
        if(!element.data("timeago")){
            element.data("timeago", { datetime: $t.datetime(element) });
            var text = $.trim(element.text());
            if($t.settings.localeTitle){
                element.attr("title", element.data('timeago').datetime.toLocaleString());
            }else if(text.length > 0 && !($t.isTime(element) && element.attr("title"))){
                element.attr("title", text);
            }
        }
        return element.data("timeago");
    }

    function inWords(date){
        return $t.inWords(distance(date));
    }

    function distance(date){
        return (new Date().getTime() - date.getTime());
    }

    // fix for IE6 suckage
    document.createElement("abbr");
    document.createElement("time");
}));

edwh avatar Oct 01 '16 08:10 edwh

@edwh, I only took a quick look at your code, but I think it has a bug.

a year ago (1.4 years ago) becomes 2 years ago after 0.1 years (that's the current behavior because of rounding). However, it won't update until it becomes 2.4 years ago.

theonlypwner avatar Oct 01 '16 16:10 theonlypwner

Also, it might not work for future dates.

a year from now updates after 1 month, not 1 year, to 11 months from now.

theonlypwner avatar Oct 01 '16 16:10 theonlypwner

@theonlypwner Even with the / 2? I would expect that to cause it to check again after a further 0.5 years. But even then it should really update sooner than that if it was to preserve the switchover at 1.5 years, so you're right it's bugged.

For my immediate purposes that's ok - I'm only interested in something rough for past dates only, so if it's a minute or two out I don't much mind. The performance hit of having a zillion timers running was a killer - particularly using setInterval, because once you had more than a certain number of timers running it wouldn't be able to keep up and so would flatline in timer processing. I did wonder about trying to avoid having a per-usage timer at all, but, y'know, other fish to fry.

I may come back to this and do a less hasty fix, but don't hold your breath :-)

edwh avatar Oct 01 '16 17:10 edwh

All %d years dates are updated at the same time. It's probably better to group timers for yearly, monthly, daily, etc. updates. After a timer is run, it could possibly become an interval.

theonlypwner avatar Oct 01 '16 22:10 theonlypwner

Unless you can tolerate error of up to 1 year, disregard what I said about grouping timers, as 2 years ago for one timer could be supposed to update a few second after another timer that says the same. Updating all of the "year" timers at the same time could be more efficient (less timers), but less accurate.


The timeago.org plugin updates the timer ~~1 second after~~ immediately when it's supposed to change. ~~It makes sense to set the timer like this (but maybe 1 ms after the time instead of 1 entire second).~~

  /**
   * nextInterval: calculate the next interval time.
   * - diff: the diff sec between now and date to be formated.
   *
   * What's the meaning?
   * diff = 61 then return 59
   * diff = 3601 (an hour + 1 second), then return 3599
   * make the interval with high performace.
  **/
  function nextInterval(diff) {
    var rst = 1, i = 0, d = Math.abs(diff);
    for (; diff >= SEC_ARRAY[i] && i < SEC_ARRAY_LEN; i++) {
      diff /= SEC_ARRAY[i];
      rst *= SEC_ARRAY[i];
    }
    // return leftSec(d, rst);
    d = d % rst;
    d = d ? rst - d : rst;
    return Math.ceil(d);
  }

https://github.com/hustcc/timeago.js/blob/310864d574c89cb692c0395dd7fe650cfa12fba7/src/timeago.js#L80-L99

theonlypwner avatar Aug 17 '17 15:08 theonlypwner