JS: Снег на сайте
Тут поговорим о том как решить очень популярную, почти новогоднюю задачу - “Сделать снег на сайте”. Данное решение не потребует ни каких внешних зависимостей и дополнительных библиотек чем обычно “грешат” современные JS решения на сайтах.
Риски
При создании графических эффектов которые в целом не привносят на сайт никакой полезной функциональности нужно осознавать риски, что любая графика и анимации потребляют ресурсы клиентских устройств, а значит при посещении сайтов “со снегом” пользователи могут получить неприятный опыт взаимодействия с сайтом. Возможно он будет подтормаживать, разряжать батарею, мешать чтению и взаимодействию с контентом. Возможно хорошей идеей было бы включать этот эффект только для тех клиентов у которых все хорошо с производительностью и размерами экрана, а так же важно, сделать так чтобы снег не загораживал контент и не мешал взаимодействию с сайтом.
Способы разделения слабых и мощных клиентских устройств это еще одна интересная задача которая возможно будет описана в следующих статьях.
Варианты
Для тех кто очень спешит я сразу хочу предложить перейти к исходному коду .
В лоб
В целом есть множество способов решить эту задачу, кажется самый простой и в лоб это применить знания о позиционировании HTML элементов и анимациях. Но у этого способа есть ряд недостатков
1. Что бы нарисовать одну снежинку нам нужно будет либо искать изображение снежинки или ее части или же нужно будет рисовать снежинку с помощью множества блоков. Дополнительное изображение нужно будет загружать в браузер и не факт что это изображение будет храниться на сервере.
2. Если мы будем использовать изображение снежинки то это приведет к тому, что все снежинки будут очень похожи. В этом случае анимация получится не правдоподобной, а сами снежинки будут однообразными.
3. Отрисовка множества движущихся элементов это " тяжелый труд " для браузера клиента. Особенно это актуально если рисовать одну снежинку множеством HTML элементов.
Кстати о разнообразии, кажется это именно то, что делает этот графический эффект интересным и чем его будет больше, тем более интересными и эффектными будут снежинки. Поэтому в случае “снежинки картинки” мы не сможем создавать разнообразные формы снежинок, это могут быть только разные размеры, повороты, цвета, прозрачности.
Попробуем рассмотреть другой метод который позволит создавать форму своих собственных снежинок.
Canvas 2D
В спецификации HTML есть один очень особенный тег canvas, по сути это “холст” на котором можно рисовать свои собственные изображения средствами JS.
Применяя этот тег мы можем рисовать снежинки той формы, которой позволит воображение и вычислительная мощность клиентского устройства.
Но тут так же остаются проблемы связанные с производительностью если чем сложнее форма снежинок и больше их количество тем больше нагрузка на устройство клиента сайта.
Но в случае с canvas отрисовка простых снежинок на нем будет значительно проще чем отрисовка полноценных HTML элементов, так как браузеру на каждый “тик” анимации, не нужно рассчитывать CSS свойства , позиционирование, вложенность и прочие свойства и атрибуты присущие html элементам. Хоть он и делает это значительно быстрее JS кода, но объем работы может быть не сопоставимым.
Canvas 3D
Тегом canvas так же можно манипулировать по разному, точки зрения смысла это картинка но под капотом у этого тега скрываются мощные и низкоуровневые способы отрисовки изображений. Помимо вышеупомянутого подхода с JS, на canvas’е можно рисовать изображения с использованием так называемых шейдеров (shader).
Главной особенностью шейдеров является их параллельная работа над множеством пикселей сразу, которая выполняется непосредственно на видеокарте устройства.
По этому используя этот способ отрисовки можно добиться самых высоких скоростей отрисовки. Использование canvas 3D дает самые лучшие характеристики в плане отрисовки. Тут мы получаем самые плавные анимации потребляя значительно меньше батареи и других ресурсов клиентского устройства.
Но у этого способа тоже есть недостатки. Попробую перечислить несколько основных.
Во первых это специальный язык программирования шейдеров который заточен на работу с графикой, а не JS.
Во вторых размер кода который нужно написать и передать по сети значительно больше, чем canvas 2D или HTML вариант, так как в этом случае нужно иметь программу на 2 языках программирования и осуществлять их связку.
В третьих поддерживается еще меньшим количеством браузеров и устройств.
Имплементация
В рамках этой статьи я попробую реализовать 2 способ с использованием Canvas 2D, поскольку мне кажется что он имеет хорошее соотношение “объем кода” / “потребление ресурсов”.
Для того что бы нарисовать снежинки, нам нужен будет HTML элемент canvas, а что бы создать этот элемент нужно быть уверенным HTML страница загрузилась мы готовы его куда либо вставить.
На самом деле существует вариант в котором canvas уже существует на изначальной странице, но в этом случае нужно менять HTML в нескольких местах. Во первых нужно добавить сам JS скрипт, во вторых добавить canvas элемент в HTML страницу. И в случае если “зимнее настроение” закончится (нужно убрать снежинки с сайта) нужно будет опять таки редактировать HTML шаблон в 2х местах.
Если использовать только один JS файл то достаточно будет сделать его пустым или убрать из него логику про снег и можно вовсе не изменять стартовый HTML код.
Выделение блока
Итак первым этапом нужно вставить скрипт на в HTML страницу.
|
|
Обратите внимание на async атрибут скрипта из-за этой особенности необходимо следующее действие. Затем в этом скрипте необходимо дождаться загрузки страницы
|
|
Что же тут происходит?
1. Тут происходит подписка на событие с названием load (название события это первый аргумент функции addEventListener) это значит, что функция переданная вторым аргументом будет выполнена после того как все содержимое страницы будет загружено. Если этого не сделать то появится зависимость между тем, где находится вставка самого скрипта и контентом сайта, который может ожидать внутри себя скрипт.
В целом это хорошая практика, делать так чтобы скрипт оставался работоспособным если сам скрипт вставляют в начале страницы или в конце страницы.
2. Скрипт выбирает все HTML элементы (метод querySelectorAll ) которых подходят под селектор ".js-header", кажется очевидным что если используется селектор по классу то таких элементов может быть множество. Записывает их в переменную tags. На самом деле в этой переменной нет никакой необходимости, она используется тут для того чтобы код было удобно и приятно читать. Кто то может возразить, что дополнительные переменные требуют дополнительных вычислительных ресурсов и памяти, и это на самом деле так, но есть множество различных видов оптимизаций, которые выполняются и могут быть выполнены браузером и при окончательной “сборке” и “выкладке” этих скриптов. Тема оптимизаций очень обширна и возможно будет описана в дальнейших статьях. На данном этапе мы не будем их рассматривать следуя простому принципу “избегать преждевременных оптимизации”, на этом этапе лучше задуматься над тем чтобы было понятно, а не быстро.
3. Далее происходит преобразование типов. В переменной tag содержится вывод функции querySelectorAll , а она в свою очередь возвращает некую коллекцию HTML тегов, мы не можем знать заранее ее размер - будет там миллион тегов или ноль, пока не известно содержимое страницы. Работать с коллекцией HTML тегов работать не очень удобно в плане компактности кода, так для итераций по такой коллекций придется создавать циклы новые переменные и замыкания. Если же приоритетом будет не компактность решения, а скорость то хорошей идеей тут будет использовать классический цикл for Но в данном конкретном примере используется компактное решение с приведением типов из коллекции HTML тегов( NodeList ) в список ( Array ). Это преобразование типов достигается применением статического метода from из класса Array , а ссылка на сам класс берется из глобальной переменной window. На самом деле, если не указать глобальную переменную window код так же отработает, так как JS использует window как глобальную область видимости, но в целом это отдельная интересная тема про “замыкания” в JS, которая может быть раскрыта в следующих статьях. У списков (Array) в отличии от NodeList есть множество компактных способов итерироваться (проходить по всем элементам коллекции). В данном примере используется метод forEach - что буквально переводится с английского как “для каждого” имеется ввиду для каждого элемента коллекции выполнить некую функцию.
4. Создается “некая функция” на основе уже существующей функции с названием initCanvas. Эта функция используется в качестве аргумента для forEach метода списка нод. Про исходную initCanvas функцию детали будут ниже. На данном этапе скорее можно видеть интересное применение функции bind . Она используется здесь для “частичного” выполнения функции. Функция initCanvas на вход принимает 2 аргумента window - глобальный объект и Node - HTML нода которая была найдена в документе. С помощью “трюка” с bind мы смогли передать во все последующие вызовы, которые произойдут на итерациях forEach первый аргумент - глобальный объект window. Связи с этим напрашивается вопрос - “Зачем передавать глобальный объект в какие бы то ни было функции? Ведь он глобальный он доступен из любой функции”. В целом да это корректное замечание, но все же есть несколько причин зачем это может понадобиться. Во первых это может быть полезно в целях образования, на примере этого действия можно показать как работает функция bind и во-вторых есть правила написания “хороших” и “плохих” функций. Одно из основополагающих правил написания “хороших” функции гласит, что функция внутри себя должна работать с минимальным количеством внешних зависимостей - минимальным количеством “замыканий”, минимальных количеством аргументов. Хорошие функции значительно проще тестировать, типизировать, читать и ревьювить, а главное понимать, что в них происходит. В целом если программа состоит из множества маленьких но понятных и изолированных кусочков, то ее надежность скорее растет.
В дальнейшем я постараюсь использовать только “хорошие” функции, которые не используют глобальную область видимости чтобы их код был более читабельным и понятным. В случае когда объект window нужен он будет передан в качестве аргумента функции, а не будет взят из замыкания. К сожалению не всегда это будет получаться.
Инициализация полотна
Рассмотрим что же происходит в функции initCanvas
|
|
А в ней происходит
1. Создание нового HTML элемента “холста” (canvas) с помощью функции createElement ссылка на которую находиться в глобальном контексте window, в котором есть объект document.
2. Определение стилей отображения холста. Это сделано статической функцией assign из класса Object таким образом что сразу изменить несколько свойств. Самое интересное правило в стиле отображения тут pointerEvents , а интересно оно тем что позволяет решить одну из задач поставленных выше “Снег не должен мешать чтению и взаимодействию с контентом”. Если не будет этого свойства, то может получится так что снег будет перехватывать клики и наведения мышью на себя, благодаря этому CSS правилу холст (canvas) становится абсолютно прозрачным с точки зрения взаимодействия с содержимым за ним. Остальные же свойства нужны чтобы спозиционировать canvas внутри исходной HTML ноды. Так же чтобы абсолютное позиционирование работало только относительно исходной HTML ноды для нее определяется relative позиционирование
3. Вставка свеже созданного и стилизованного HTML элемента canvas в HTML ноду (узел), которая приходит в функцию в качестве аргументов. Тут используется метод prepend он в отличии от очень популярного append немного помедленнее, так как изменяет порядок всех последующих нод в DOM ветке дерева, но при этом вставляет HTML в начало среди остальных дочерних элементов. При изменении структуры HTML дерева всегда следует держать в голове, что существуют CSS селекторы которые могут использовать эту конкретную структуру и в этом случае “что то может пойти не так”
4. Скрипт получает итоговые размеры HTML тега canvas с помощью метода getBoundingClientRect и на их основе определяет “внутренние размеры” холста. Это важно делать чтобы размер / плотность пикселей внутри холста соответствовала его актуальной ширине и высоте. Так же важно, что бы внутренний размер canvas был пропорционален размеру HTML тега чтобы сохранить пропорции изображений на canvas. Как известно в интернете у клиентов встречается огромное разнообразие экранов и разрешений, поэтому для того чтобы использовать всю мощь таких экранов было бы здорово определять плотность пикселей на стороне браузера и подбирать в этой части специальные коэффициенты учитывающие особенности экрана клиента. В данном примере этого не было сделано в угоду компактности решения, поэму размеры переносятся 1 к 1 без учета плотности пикселей пользовательского экрана.
5. Далее создается из заполняется список снежинок, которые будут отображаться на экране. Помощью классического цикла for и функции initSnowflake - о которой будет рассказано ниже.
6. Создается новый контекст для рисования в режиме 2D двухмерной графики getContext который в дальнейшем используется внутри функции отрисовки draw.
Снежинки создание
Рассмотрим функцию создания снежинки и ее зависимости
|
|
На вход она принимает некое состояние которое можно описать типом
|
|
Где
position - это список из двух координат x и y которые нужны для определения максимального значения положения снежинке в блоке currentPosition - это опять таки список из координат x и y но на этот раз координаты нужны для определения текущего положения снежинки относительно левого верхнего угла canvas angle - это цифра которая характеризует угол наклона снежинки. size - это список из 2х цифр ширина “лепестка” снежинки и его длина. slides - количество “лепестков” снежинки. subSlides - количество суб лепестков у снежинки. rotation - это boolean флаг в какую сторону крутиться снежинка. Она может быть 2 состояниях, крутиться либо вправо, либо влево
В итоге initSnowflake сохраняет curentPosition из переданного состояния вызывает функцию randomazeSnowflake и если поле curentPosition было определено в исходном состоянии то восстанавливает его в изначальное состояние.
Зачем это может понадобится? Это связано с особенностями работы функции randomazeSnowflake ее главная задача сгенерировать случайное состояние снежинки в том числе и ее позицию
|
|
Она работает таким образом, что изменяет состояние в том числе и curentPosition, тем самым “перетирая” исходное положение снежинки на экране. Новое положение снежинки находится за пределами видимой области холста, то есть если не восстанавливать положение снежинок при инициализации они изначально генерируются не в середине экрана как это было задумано в initCanvas, а за его пределами сверху.
Также randomazeSnowflake и initCanvas активно используют вспомогательную функцию randomRange
|
|
Эта функция генерирует случайные числа в заданном интервале, который приходит в аргументах функции, если внимательно присмотрется здесь снова происходит обращение к глобальному объекту window, так как внутри этой функции используется статический метод random класса Math по аналогии с Object и Array они доступны в глобальной области видимости поэтому эта функция не может считаться хорошей - чистой.
Функция initSnowflake возвращает в результате своей работы некую новую функцию, которая на вход принимает опять таки функцию и возвращает результат вызова, той самой переданной функции, передавая в нее ссылку на состояние снежинки. Такие функции которые принимают на вход другие функции называются функциями высшего порядка . В данном случае использование таких функций дает возможность одновременно управлять выводом результирующей функции, а также иметь ссылку на состояние снежинки. В дальнейшем мы увидим как это работает на примерах.
Основной цикл отрисовки
Далее рассмотрим функцию рисования снежинок draw. Как было показано выше эта функция вызывается в функции инициализации полотна initCanvas.
|
|
В целом эта функция занимается непосредственной отрисовкой, как следует из ее названия.
Сначала мы очищаем все предыдущее состояние холста методом clearRect
Затем для каждой снежинки “анимируем” ее состояние и отрисовываем ее содержимое. Подробнее об этом поговорим чуть ниже.
Далее применяется знаменитый трюк " рекурсии " то есть внутри функции которая объявляется есть ее же вызов, так же что бы не писать кодом новые функции используется функция bindArgs которая создает новую функцию из уже имеющейся draw закрепляя к ней заранее все аргументы. Результат работы bindArgs отправляется в функцию requestAnimationFrame интересно что это очень популярная функция для работы с холстами (тегом canvas) не случайно у нее такое название, но по своей сути она очень похоже на setTimeout она откладывает выполнение переданной функции во времени. Но в отличии от setTimeout тут нельзя указать время на сколько отложить выполнение функции. Поскольку функция называется requestAnimationFrame она откладывает выполнение колбека до тех пор пока браузер не будет готов отрисовать новый кадр.
Отрисовка снежинки
Выше в функции draw упоминалось, что каждая снежинка анимируется и отрисовывается.
|
|
Тут используется та самая функция которую возвращает initSnowflake. В результате вызова snowflake(animateSnowflake) произойдет следующее: функция полученная из после создания снежинки вызовет переданную функцию с ссылкой на состояние созданной снежинки. Поскольку состояние снежинки это просто объект - значит его можно мутировать и передавать дальше без проблем. Сама же функция animateSnowflake работает с состоянием таким образом, чтобы снежинка между кадрами медленно падала вниз и крутилась. Там тоже есть свои нюансы которые мы рассмотрим ниже.
|
|
По сути тут происходит всего 3 действия
-
Из состояния снежинки забираются нужные для анимации данные позиция снежинки на экране x, y размеры блока в котором рисуется снежинка и прочие подробно разобранные в initSnowflake
-
Если состояние снежинки говорит о том что снежинка находится з а областью видимости, то мы заново генерим ее состояние функцией randomazeSnowflake и завершаем на этом выполнение функции*.* Если же проблем с состоянием нет то переходим к пункту 3
-
Изменяем положение и угол наклона снежинки в зависимости от ее размеров и направления вращения. Эти “формулы” slides/width/2 и len/10000 не несут в себе какого то верного математического смысла, они скорее подобраны “опытным” путем, так чтобы оно смотрелось естественно!
Для отрисовки снежинки так же как и для изменения ее состояния, используется функция полученная от initSnowflake. Но в отличии в функции animateSnowflake функции drawSnowflake помимо состояния необходима ссылка на контест полотна на котором должна быть нарисована снежинка, для того чтобы прокинуть в функцию этот дополнительный аргумент, тут используется “частичный” вызов функции через другую функцию bindArg - это аналог функции bindArgs рассмотренной выше, но в отличии от него эта функция может пробросить всего 1 аргумент вместо нескольких как это делает bindArgs.
|
|
Тут так же как в функции анимации мы забираем нужные данные из состояния снежинки и используя контекст полотна canvasCtx рисуем на нем снежинку в нужной точке холста определенной состоянием и углом поворота от туда же.
Снежинка на этом уровне отрисовки представляет из себя “лучи” выходящие из ее центра
Чтобы нарисовать эти лучи функция drawSnowflake в зависимости от количества лучей (slides) внутри себя запускает цикл который проходит по каждому лучу в отдельности предварительно рассчитав угол между лучами const step = 360 / slides чтобы не делать это на каждой итерации цикла.
Луч из себя представляет полупрозрачный белый прямоугольник canvasCtx.fillStyle = “rgba(255,255,255,0.3)” - где 0.3 - это его прозрачность, а 255 его цвет, нарисованный методом fillRect из нуля координат по горизонтали и -1/2 от высоты по вертикали, так что горизонтальная ось проходит точно посередине прямоугольника.
Этого явно было бы не достаточно, если просто рисовать прямоугольник из начала координат что эффекта лучей не получится. Для того что бы учесть положение снежинки из состояния применяется метод translate после вызова которого ко всем координатам в контексте отрисовки будут прибавлены дополнительные значения переданные аргументами в этот метод canvasCtx.translate(centerX, centerY) - тем самым осуществляется сдвиг снежинки от нуля координат до ее текущей позиции от нуля координат.
Тоже самое происходит и для поворота. Используя метод rotate холст поворачивается относительно нуля координат на указанный угол в радианах. Но в отличии от расположения мы имеем тут 2 источника для расчета итогового угла это во-первых цикл по лучам (angle) а во вторых это данные о повороте снежинки (snowflakeAngle) из состояния. Итоговым значением поворота полотна будет их сумма angle + snowflakeAngle.
Если ли же оставить все как описано выше и не использовать методы сохранения ( save ) и восстановления ( restore ) состояния (контекста) холста то при рисовании второй снежинки случилась бы проблема. Угол и координаты были бы не относительно левой верхней точки экрана, а относительно предыдущей снежинки. Что бы этого не произошло здесь во первых используется метод save который позволяет сохранить текущее “нулевое” состояние холста, а затем метод restore, который позволяет сразу вернуться к нему. Так же интересно, что сохранения можно наслаивать друг на друга и эта особенность используется в следующей функции drawSnowflakeInner - отрисовки снежинок 2 уровня вызов которой происходит внутри drawSnowflake
|
|
Главная задача этой функции отрисовать новые лучи, но на этот раз они исходят не из центра снежинки, а прямо из луча, который отрисовывается на данной итерации цикла, здесь совсем нет новых функций. Но здесь снова используется метод save который сохраняет текущее положение, но теперь получается что идет вызов двух save под ряд, значит ли это что “сохранение” может быть только одно ? Оказывается, что нет - каждый вызов save добавляет текущее состояние в “колоду” (stack) как карту сверху затем restore достает эту первую карту сверху восстанавливая состояние из нее.
Это была последняя функция, которая необходима для создания эффекта в итоге верхнеуровнево функция initCanvas создает снежинки - initSnowflake и запускает анимации - draw - вызывает себя на каждый кадр анимации вместе с animateSnowflake и drawSnowflake. animateSnowflake - меняет состояние снежинки на каждый кадр, drawSnowflake отрисовывает снежинку в соответствии с ее состоянием. Вот и вся магия!
Буду рад комментариям идеям лайкам, всему тому что поможет сделать эту статью лучше. Поможет вам и другим людям, что прочтут их.