Пользователь
0,0
рейтинг
10 марта 2015 в 15:31

Разработка → Анимация против лагов, или лучшая битва та, которой не было из песочницы

image

Замечали, когда только открываете веб-сайт, первые секунды всё тормозит? Скрол как-то не ровно работает, параллаксы скачут, а из анимации словно вырезали львиную долю кадров. Но очень скоро всё нормализуется. Не замечали такого? Взгляните на демо-страницу плагина, и сразу поймёте, о чем я.

Проблема в том, что динамика не может нормально работать, пока страница лагает. В качестве решения предлагаю плагин «Afterlag.js». Плагин позволяет отслеживать событие окончания лагов. Лаги прошли, включайте анимацию, тормозить не будет. А пока страница лагает, нечего и динамику запускать, только вид портить.

Как использовать


Подключаете JS файл с плагином, пишете:
$.afterlag(function() {
  console.log(‘Ура, лаги прошли!’);
});

Как только на странице кончатся лаги, ваша консоль начнёт выражать радость по этому поводу. Если jQuery использовать не хотите, подключите нативный плагин и напишите:
afterlag = new Afterlag(); 
afterlag.do(function() {
  console.log(‘Ура, лаги прошли!’);
});

Результат будет тот же.

Это самое простое, что может сделать плагин. На самом деле у плагина есть API и куча других способов вызова, всё это в полном объеме описано в ридми репозитория на гитхабе. Там же и способы подключения, ссылки на CDN, название плагина в bower и модуля npm.

Почему всё тормозит


Давайте разберёмся, почему анимация лагает на старте. Рассмотрим анимацию кругляшка на демо-странице, его задача плавно подниматься вверх, затем опускаться вниз.

Анимация движения реализована с помощью метода animate библиотеки джэйквери. Анимируем мы единственный CSS атрибут top, его значение определяется заданной функцией от времени. Запустив анимацию, джэйквери старается каждое мгновение обновлять значение top. Если бы у джэйквери действительно получалось обновлять это значение так часто, то анимация была бы шикарно гладкой. Но у jQuery не получается.

Не получается, потому что JS исполняет все события в одном потоке. JS бежит по списку того, что нужно сделать, и делает это. Не все дела встают в конец списка. Если вы кликнули куда-то, тем самым вызвав какое-то событие, задачу по его исполнению воткнут в начало списка, чтобы выполнить его как можно скорее. Скорее, но не в тот же момент. Если суть не ухватываете, можете прочитать эту короткую статью, она более подробно рассказывает о том, как работают интервалы и таймауты в свете однопоточности JS. И не забывайте, что на самом деле все задачи исполняет браузер, и список дел, переданный от JS, еще разбавляется прочими задачами браузера.

Так вот, джэйквери хотела почаще обновлять значение top для нашего кругляшка. Но в момент загрузки страницы было очень много более приоритетных дел: страничку отрендерить, видео с ютуба подтянуть и так далее. Бедняга браузер был так занят, что успевал обновлять значение top только раз в 200–300 миллисекунд. За это время кругляшок исходя из заданной функции смещался уже пикселей на 60. В итоге круг не плавно подходит к своему новому положению, а телепортируется туда, создавая ощущение потерянных кадров, дёрганости и тормознутости. Потом, когда браузер доделал все свои важные дела, он начал своевременно обновлять значение top для круга, и анимация стала ровной.

Как работает «Afterlag.js»


Афтерлагу предстоит узнать, когда браузер перестанет быть таким занятым и сможет достаточно часто выполнять код, необходимый для гладкого воспроизведения анимации. Скажем, нас устроит, если задача анимации будет исполняться хотя бы раз в 50 миллисекунд (frequency). При инициализации афтерлаг запомнит текущее время и запустит интервал в 50 мс. Спустя 50 мс опять узнаем текущее время. Сравниваем текущее время с прежде зафиксированным, если действительно прошло 50 мс, значит лаги кончились. А если на самом деле прошло не 50 мс, а, скажем, 100 мс, значит на странице всё еще лаги и процедуру надо повторять до тех пор, пока интервал не начнёт работать так, как мы этого ожидаем.

Только я написал плагин по указанной выше схеме и решил, что он достаточно работоспособный. Но нет. Случается такое, что интервалу просто повезло. Бывало так, что первые две итерации ожидаемое значение прошедшего времени разнилось с реально прошедшим, на третью итерацию совпадало, а на четвертую опять разнилось. Решаем задачу в лоб: пускай ожидаемое время совпадёт с реально прошедшим 10 раз подряд (iterations).

«Ну вот, теперь всё наверняка работает как надо» —подумал я, и ошибся. Если установить не достаточно большие значения для (frequency) и (iterations), например 30 и 3, в первые моменты может случится такое, что браузер еще не был занят первые 90 мс, то есть 3 итерации по 30 мс, афтерлаг решал, что лаги кончились, а они на самом деле только начинались. Решаем опять в лоб: установим значение времени в течение которого нельзя доверять афтерлагу (delay) с запасом — 200 мс.

Обновление статьи (12 марта 2015):
По совету webmasterx была добавлена настройка (need_lags). Цитирую документацию: «При значении false афтерлаг сработает либо, если лаги закончатся, либо, если они даже не начнутся. Значение true разрешает афтерлагу сработать только после окончания лагов, то есть если лагов не было, афтелаг не сработает. Устанавливая значение true не забудьте также установить значение для timeout, в противно случае, если лагов не будет, афтерлаг так и не сработает.»
Конец обновления.

Теперь всё работает как надо. Есть еще один параметр, который я учел сразу, но решил сказать только сейчас. Это допустимая погрешность при сверке ожидаемого и реально прошедшего времени (scatter) — 5 мс. Не будем с браузером очень уж строгими.

Все числа указанные в этом разделе используются афтерлагом по умолчанию, но могут быть изменены разработчиком при инициализации.

Заключение


Анимация, реализованная при помощи CSS, гораздо меньше подвержена подобного рода лагам. Афтерлаг полезен только тогда, когда динамика сайта связана с JS, в том числе плавные параллаксы, анимация передвижения скрола и прочее. Мне было бы интересно узнать ваше мнение о полезности плагина.

Афтерлаг чаще всего срабатывает верно, но не даёт 100% гарантии. При изменении настроек приходится ловить баланс: большая надёжность и более долгое ожидание или меньшая надёжность и более короткое ожидание. Плагин придётся по душе тем разработчикам, которые любят, когда динамика работает красиво и так, как она была задумана.
Сергей Дмитриев @iserdmi
карма
18,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (39)

  • 0
    Если это действительно будет работать, то это неимоверно круто. Обязательно проверю на следующем проекте.
    В исходники пока не заглядывал, поэтом спрошу вас: callback из afterlag.do(callback) будет ведь выполнен только раз?.. То есть, если у меня динамический контент, то после конца загрузки и вставки контента на страницу я должен создать новый инстанс afterlag, верно?
    • +1
      Да. Колбэк выполняется один раз. Если так случится, что лаги пройдут после создания объекта new Afterlag(), но до создания колбэка, колбэк будет вызван без задержек.

      Если контент динамический, создавайте новый инстанс. Я думал о том, чтобы сделать возможность постоянно проверять есть ли лаги, и всякий раз после их окончания вызывать колбэки. Но есть вероятность, что лаги не появятся, и колбэки не вызовутся. А вы их так ждали. Это же лаги, они не надежны. Создавая новый инстанс, даже если лагов нет, колбэки вызовутся.

      А как вы в комментарии выделили код строчно, а не блочно?
      • 0
        Через тег <code />.

        Всякий раз вызывать коллбек не нужно, в том и суть плагина, как мне кажется, что он делает что-то один раз, как какой-нибудь imagesLoaded-плагин.
  • +3
    а вы пробывали окончанием лагов считать три успешных совпадения времени после обнаружения первого попавшегося лага?
    т.е. сначала ждать появления лагов, а потом уже считать совпадения во времени?
    • 0
      Нет. Хорошая идея. Отлично будет сочетаться с передаваемым в настройке значением timeout, который говорит о том, сколько максимум можно ждать окончания лагов. Добавлю такой функционал в скором времени.
      • 0
        Спасибо!
        • –1
          Сам себя не похвалишь, никто не похвалит.
    • 0
      Я ваш совет реализовал. И чуток поменял дефолтные настройки. Описание нового свойства в статье выделил как «Обновление статьи».
  • 0
    Возможно, было бы еще неплохо сделать что-то типа такого?
    afterlag.on('begin', function() {
        console.log('начались лаги, ждем...');
    });
    
    afterlag.on('end', function() {
        console.log('лаги прекратились, исправляем...');
    });
    
    Это на случай динамического контента и тому подобного. И да, возможно, лучше было бы использовать requestAnimationFrame? Если кадры пропускаются — значит, анимации точно будет плохо. А если не пропускаются — может, не все так печально?
    • 0
      Чтобы on('begin', ...) имело смысл, нужно оставлять афтерлаг все время включенным. Афтерлаг почти в состоянии сам вызвать лаги, я не хотел бы оставлять включенным всё время работы сайта. Ведь при создании нового инстанса и так можно сказать, что лаги начались, ведь мы их ожидаем. А даже если их нет, это скорее всего никак не повлияет на наши действия. Я стремился сделать плагин максимально казуальным, и в идеале подобрать такие настройки, чтобы никому не пришлось с ними играться.

      Если контент динамический, разработчик знает, когда стоит ожидать лагов, после какого именно события. В такой момент стоит создать новый инстанс и ждать прекращения лагов. А on('end', ...) сейчас работает как do(...).

      Я слабо разбираюсь в «requestAnimationFrame». Пробежался по этой статье. Похоже, что он в этом плагине будет делать тоже самое, что сейчас делают интервалы. Почему вы считаете, что лучше использовать «requestAnimationFrame»?
      • +1
        Честно говоря, у меня нет простого ответа на этот вопрос. Наверное, потому что создатели браузеров сошлись на том, что requestAnimationFrame лучше подходит для анимаций, т.к. ограничен фреймрейтом и вызывается перед тем, как браузер будет перерисовывать кадр. Все или почти все анимации используют эту функцию, поэтому планировщик браузера может вместо кучи событий таймеров обработать только одно. Поэтому, если лагает эта функция, то лагают и анимации. Если эта функция не лагает, анимации работают плавно. А если лагает setTimeout, это не всегда имеет негативный эффект на анимациях — возможно как раз, что браузер только анимациями и занят.
        • 0
          Звучит убедительно. Подразберусь с «requestAnimationFrame», если там всё действительно так хорошо, заменю интервалы. Спасибо!
          • +2
            Да, кстати, пара комментариев к коду, если вам интересно.

            1) свежий gulp сам понимает Gulpfile.coffee, поэтому нет необходимости в промежуточном .js-файле. Он сам вызовет coffee-script/register.
            2) @constructor.defaults не самая хорошая конструкция. Может, лучше использовать просто @defaults?
            3) for key of first_object — почему не for own key of first_object?
            4) new Date().getTime() можно заменить на do Date.now
            5) fn = self; self = @ можно заменить на [fn, self] = [self, @]
            • +1
              Конечно, интересно!
              1) Не знал.
              2) Так не получится. Здесь в любом случае нужно использовать или @constructor.defaults или Afterlag.defaults
              # Сейчас в коде так:
              class Afterlag
                @defaults:
                  delay: 100
                  # ...
              
              # Если бы было вот так, нужно было бы использовать @defaults.
              class Afterlag
                defaults:
                  delay: 100
                  # ...
              

              3) Косяк.
              4) Забыл про это, давно не работал с датой в JS. Однако, Date.new работает с только с девятого IE. А «requestAnimationFrame», кстати, с десятого. С одной стороны черт бы сними, но все же. А интервалы любой бразуер тянет. Но я все равно покапаю в сторону «requestAnimationFrame».
              5) Это синтаксический сахар кофескрипта?
              • +1
                4) браузерная совместимость requestAnimationFrame всегда делается через fallback:
                window.requestAnimFrame = (function(){
                      return  window.requestAnimationFrame       || 
                              window.webkitRequestAnimationFrame || 
                              window.mozRequestAnimationFrame    || 
                              window.oRequestAnimationFrame      || 
                              window.msRequestAnimationFrame     || 
                              function(){
                                window.setTimeout(callback, 1000 / 60);
                              };
                    })();
                
              • 0
                2) Да, но зачем в первую очередь использовать @defaults: ...? Если вы посмотрите билд при defaults: ..., там будет Afterlag.prototype.defaults, что вполне себе так же хорошо и здорово, как и Afterlag.defaults, но при обращении к первому варианту не нужно прибегать к дереференсу конструктора. Да, кстати, Afterlag.prototype.defaults еще можно писать, как Afterlag::defaults.

                5) Это destructuring assignment, есть в том числе в ванильном ES6.
                [a, b, ..., c] = [1, 2, 3, 4, 5, 6, 7] # a == 1, b == 2, c == 7
                {a, b, c} = {a: 10, foo: 99, b: 30} # a == 10, b == 30, c == undefined
                {a: foo, b: bar} = {a: 265, b: 42} # foo == 265, bar == 42
                {@foo, @bar, @quux} = options # @foo == options.foo, @bar == options.bar, @quux == options.quux
                
                • 0
                  2) Мне нравится, что дефолтные значения принадлежат именно к классу, а не к объекту класса. Благодаря этому, я могу в самом начале кода написать Afterlag.defaults.iterations = 5. И потом при создании новых объектов мне не придется каждый раз указывать в настройках, что я хочу 5 итераций. Они возьмутся из нового дефолтного значения.

                  5) Круто.
                  • +1
                    2) не совсем уловил разницу между классом и объектом класса. Если вы имеете в виду объект класса (конструктор) и экземпляр класса, то смею вас уверить, свойства в Afterlag.prototype не копируются в экземпляры, а именно наследуются. К примеру,

                    JavaScript
                    function Class(foo) {
                    	if (foo != null) {
                    		this.foo = foo;
                    	}
                    }
                    Class.prototype.foo = 42;
                    
                    a = new Class();
                    b = new Class(100);
                    
                    console.log('a ->', a.foo); // 42
                    console.log('b ->', b.foo); // 100
                    console.log();
                    
                    Class.prototype.foo = 666;
                    
                    a2 = new Class();
                    b2 = new Class(100);
                    
                    console.log('a ->', a.foo); // 666
                    console.log('b ->', b.foo); // 100
                    console.log('a2 ->', a2.foo); // 666
                    console.log('b2 ->', b2.foo); // 100
                    console.log();
                    CoffeeScript
                    class Class
                    	foo: 42
                    	
                    	constructor: (foo) ->
                    		@foo = foo if foo?
                    
                    a = new Class
                    b = new Class 100
                    
                    console.log 'a ->', a.foo # 42
                    console.log 'b ->', b.foo # 100
                    do console.log
                    
                    Class::foo = 666;
                    
                    a2 = new Class
                    b2 = new Class 100
                    
                    console.log 'a ->', a.foo # 666
                    console.log 'b ->', b.foo # 100
                    console.log 'a2 ->', a2.foo # 666
                    console.log 'b2 ->', b2.foo # 100
                    do console.log


                    Как видите, после обновления prototype, поменялись все экземпляры, у которых есть свойство foo, и которые не переприсвоили его в свой контекст. Если обновление уже существующих объектов нежелательно, то можно написать вот так:
                    JavaScript
                    function Class(foo) {
                    	this.foo = (foo == null) ? this.foo : foo;
                    }
                    CoffeeScript
                    class Class
                    	foo: 42
                    	
                    	constructor: (@foo = @foo) ->
                    • 0
                      Я ерунду написал. Имел ввиду класс и экземпляр класса, а не объект класса. Как я понимаю, ваш аргумент за defaults: ... заключается в том, что мне в коде самого плагина не придется писать @constructor.defaults. А в остальном все в порядке?

                      Я конструкцию @constructor.defaults в коде плагина использую только один раз. Зато все пользователи плагина, если захотят изменить дефолтное значение, будут писать Afterlag.defaults.iterations = ..., а в вашем случае придется писать Afterlag.prototype.defaults.iterations = .... Это длиннее, и выглядит не так интуитивно понятно.

                      Спасибо, что разъяснили про наследование. И примеры у вас замечательные. И комментарии интересные.
                      • 0
                        Я конструкцию constructor.defaults в коде плагина использую только один раз
                        Тут, скорее, речь о том, что вы используете неявно определенные свойства, т.е. это эдакие протекающие абстракции. Ведь constructor, к которому вы обращаетесь, и constructor в объявлении класса — это вообще разные вещи, и совпадают у них названия абсолютно случайно. CoffeeScript мог назвать свой конструктор ctor, или так же, как имя класса, или вообще считать конструктором первую определенную в классе безымянную фукнцию (как в LiveScript — рекомендую попробовать). Тогда как @constructor ссылается на свойство конструктора, которое назначается рантаймом автоматически после инстанцирования объекта:
                        function Class() { }
                        var a = new Class;
                        console.log(a.constructor); // [Function: Class]


                        Спасибо, что разъяснили про наследование. И примеры у вас замечательные. И комментарии интересные.
                        Сарказм? :) Учитывая, что комментарии просто максимально информативны — # 666, # 100 и # 42.
                        • 0
                          Я вас в пердыдущем комментарии совершенно искренне благодарил и делал комплименты. Я действительно из ваших комментариев много нового узнал и старого закрепил. Так что вы про меня плохо не думайте, это я вам от чистого сердца!

                          А по поводу @constructor.default я понимал, что он не связан тем конструктором, который в объявлении класса. Собственно по-этому я его в коде верно и использовал. Если бы вместо @constructor.defaults я использовал Afterlag.defaults никаких неявно определенных свойств и протекающих абстракций бы не было. Но мне не хотелось внутри класса обращаться к классу по имени, я хотел обратиться через какой-то универсальный указатель. Сейчас посмотрел документацию к кофескрипту, там, и вправду, нигде не говорят, что так можно делать. Значит я этот способ где-то в другом месте вычитал. Может и стоит заменить это на Afterlag.defaults, но мне по прежнему не хочется к классу обращаться по имени внутри этого же класса.
                          • 0
                            Да, я понимаю ваш дискомфорт от этого — я сам такой же. Вы не рассматривали вариант с дефолтными объявлениями не в отдельном объекте, а прямо в теле класса? Поясню на примере (без JS, он тут уже будет некрасивым):
                            class MyClass
                            	foo: 42
                            	bar: null
                            	buzz: 'meow'
                            	quux: 3.14
                            	
                            	constructor: (opts) ->
                            		do => @[key] = value for own key, value of opts # merge options
                            		
                            		# do something useful...
                            	
                            	toString: -> "foo: #{@foo}, bar: #{@bar}, buzz: #{@buzz}, quux: #{@quux}"
                            
                            a = new MyClass
                            console.log "#{a}" # foo: 42, bar: null, buzz: meow, quux: 3.14
                            
                            b = new MyClass foo: 666
                            console.log "#{b}" # foo: 666, bar: null, buzz: meow, quux: 3.14
                            
                            c = new MyClass foo: null
                            console.log "#{c}" # foo: null, bar: null, buzz: meow, quux: 3.14

                            Обновления дефолтов, соответственно, производить через MyClass::bar = 'okay, new bar field'.
                            • 0
                              Не думал об этом, но мне таксой способ не нравится, да и мой способ устраивает на все 100. Я плагины начинаю писать с их вызова. Представляю, что есть какой-то идеальный плагин, который используется именно так, как я хочу его использовать. Пишу пару примеров использования еще не существующего плагина. А потом пишу этот плагин, ограничивая себя рамки того, как я хочу его использовать. Так вот в моём идеальном плагине дефолтные настройки не должны изменяться так: Afterlag.prototype.defaults.iterations = 5. Слишком длинная строка.
          • 0
            Да, кстати, если будете разбираться — посмотрите заодно, setTimeout в DOM Worker-е как-то коррелирует с лагами браузера? :)
            • 0
              Worker'ы в отдельном потоке отработают и отдадут результат. В итоге основной поток не будет ззатронут «лагами» Worker'ов.
              • 0
                Тут, скорее, о том, чтобы получить более независимую оценку общебраузерных лагов. Возможно, что если лаги дотянулись до воркера, то плохи дела у самого браузера.
                • –1
                  Так лаги-то в большинстве случаев не из-за того, что браузер плохо спроектирован, а из-за того, что скрипты кое-как написаны. Костыль, описанный в топике, скорее добавляет этих самых лагов. Нужно не оборачивать проблему в красивую обертку, а решать ее. :)
        • 0
          Наткнулся на очень понятную статью про «requestAnimationFrame». С этой штукой хорошо создавать анимацию, потому что как говорится в статье:
          1. Анимация будет более плавной, так как браузер может оптимизировать её
          2. Анимация в неактивной вкладке будет приостановлена, позволяя процессору отдохнуть
          3. Более энергоэкономна.

          Таким образом использовать его в «Afterlag.js» не нужно. Если афтерлаг будет видеть лаги чуть дольше, чем они есть, ничего страшного. А вот если афтерлагу раньше времени покажется, что лаги кончились, всё будет очень плохо. С «requestAnimationFrame» хорошо создавать плавную анимацию, а я буду использовать более глючный «setInterval» для поиска лагов.
  • +4
    Offtop: однажды я выключил javascript в настройках браузера и поразился тому, насколько быстро стали отображаться все сайты. Мораль: поменьше анимаций, параллаксов и прочих рюшечек, назначение которых раздражать пользователя и затормаживать браузер.
    • 0
      Рюшечки это ведь далеко не всегда прихоть программиста или дизайнера. Иногда заказчик вложив $XXX отказывается от простого «фасада». Конечно в дальнейшем все эти рюшечки убираются под гнетом негативного фидбэка, но первую версию почти всегда приходится делать обвешанной.
      • 0
        Рюшечки бывают очень даже полезными. И для юзабилити, и для красоты. Главное, чтобы они были созданы не только для того, что быть, а несли в себе какой-то смысл, имели цель своего существования.
  • 0
    Если не ошибаюсь, в Angular и Angular Light специально стоит задержка пока приложение не инициализируется, отключающая директиву (ng|al)-animate. Очень удобно.
    Правда я для виртуализированного списка еще отключаю al-animate на время обновления контейнера с элементами, благо в scope.$scan есть callback
  • 0
    А если комп медленный, одноядерный и замученный фоновыми процессами — оно вообще не включится?
    • 0
      В настройках можно передать параметр timeout, время в миллисекундах которое вы готовы ждать до окончания лагов. То есть, даже если лаги не кончились афтерлаг запустит все колбэки, если лаги длятся дольше, чем значение преданное в timeout
  • 0
    На самом деле JavaScript не совсем однопоточный, а только в пределах одной страницы, так ведь?
    • 0
      Это, кстати, тема для большой и сложной статьи, на самом деле. Жаваскрипт всех страниц, как правило, работает в одном потоке, потому что DOM Worker-ы — исключение. Но при вызове какой-то из страниц метода типа alert или confirm происходит магия, при которой блокирующий вызов в контексте страницы становится блокирующим только для страницы, но сам браузер не блокируется.
      • 0
        s/потому что/но/
      • 0
        Напишите, интересно.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.