Статьи

Как реализовать плавную прокрутку в Vanilla JavaScript

Эта статья была рецензирована Адрианом Санду , Крисом Перри , Джереми Хелайн и Мэллори ван Ачтербергом. Спасибо всем рецензентам SitePoint за то, что сделали контент SitePoint как можно лучше!

Плавная прокрутка — это шаблон пользовательского интерфейса, который постепенно расширяет возможности навигации по умолчанию на странице, анимируя изменение положения в поле прокрутки (область просмотра или прокручиваемый элемент) от местоположения активированной ссылки до местоположения элемента назначения. указано в хеш-фрагменте ссылки URL.

В этом нет ничего нового, так как этот шаблон известен уже много лет, посмотрите, например, эту статью SitePoint, которая датируется 2003 годом! Кроме того, эта статья имеет историческое значение, поскольку она показывает, как программирование JavaScript на стороне клиента, и в частности DOM, изменилось и эволюционировало за эти годы, что позволяет разрабатывать менее громоздкие ванильные решения JavaScript.

Существует много реализаций этого шаблона в экосистеме jQuery, использующих непосредственно jQuery или реализованных с помощью плагина, но в этой статье мы заинтересованы в чистом решении JavaScript. В частности, мы собираемся изучить и использовать библиотеку Jump.js.

После презентации библиотеки с обзором ее возможностей и характеристик мы внесем некоторые изменения в исходный код, чтобы адаптировать его к нашим потребностям. При этом мы освежим некоторые основные языковые навыки JavaScript, такие как функции и замыкания. Затем мы создадим страницу HTML, чтобы проверить поведение плавной прокрутки, которое затем будет реализовано в виде пользовательского сценария. Затем будет добавлена ​​поддержка естественной плавной прокрутки с помощью CSS, и в заключение мы подведем некоторые замечания, касающиеся истории навигации браузера.

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

Полный исходный код доступен на GitHub.

Jump.js

Jump.js написан на vanilla ES6 JavaScript, без каких-либо внешних зависимостей. Это небольшая утилита, занимающая всего около 42 SLOC , но размер предоставляемого минимизированного пакета составляет около 2,67 КБ, поскольку его необходимо транспортировать. Демо доступно на странице проекта GitHub.

Как следует из названия библиотеки, она обеспечивает только переход: анимированное изменение положения полосы прокрутки от ее текущего значения до места назначения, определяемое путем предоставления либо элемента DOM, селектора CSS, либо расстояния в форме положительного или значение отрицательного числа. Это означает, что при реализации шаблона плавной прокрутки мы должны выполнить перехват ссылки сами. Подробнее об этом в следующих разделах.

Обратите внимание, что в настоящее время поддерживается только прокрутка области просмотра и только по вертикали.

Мы можем настроить переход с некоторыми параметрами, такими как длительность (этот параметр является обязательным), функция замедления и обратный вызов, который должен быть запущен в конце анимации. Мы увидим их в действии позже в демоверсии. Смотрите документацию для получения полной информации.

Jump.js работает без проблем в «современных» браузерах, включая Internet Explorer версии 10 или выше. Опять же, обратитесь к документации для получения полного списка поддерживаемых браузеров. С подходящим polyfill для requestAnimationFrame он должен работать даже в старых браузерах.

Быстрый взгляд за экраном

Внутренне источник Jump.js использует метод requestAnimationFrame объекта window, чтобы запланировать обновление положения вертикальной позиции области просмотра в каждом кадре прокручиваемой анимации. Это обновление достигается передачей следующего значения позиции, вычисленного с помощью функции замедления, методу window.scrollTo . Смотрите источник для получения полной информации.

Немного настройки

Прежде чем погрузиться в демонстрацию, демонстрирующую использование Jump.js, мы собираемся внести небольшие изменения в исходный код, которые, тем не менее, оставят его внутреннюю работу неизменной.

Исходный код написан на ES6 и должен использоваться с инструментом сборки JavaScript для переноса и объединения модулей. Это может быть излишним для некоторых проектов, поэтому мы собираемся применить некоторый рефакторинг для преобразования кода в ES5, готовый для использования везде.

Перво-наперво, давайте удалим синтаксис и функции ES6. Скрипт определяет класс ES6 :

 import easeInOutQuad from './easing' export default class Jump { jump(target, options = {}) { this.start = window.pageYOffset this.options = { duration: options.duration, offset: options.offset || 0, callback: options.callback, easing: options.easing || easeInOutQuad } this.distance = typeof target === 'string' ? this.options.offset + document.querySelector(target).getBoundingClientRect().top : target this.duration = typeof this.options.duration === 'function' ? this.options.duration(this.distance) : this.options.duration requestAnimationFrame(time => this._loop(time)) } _loop(time) { if(!this.timeStart) { this.timeStart = time } this.timeElapsed = time - this.timeStart this.next = this.options.easing(this.timeElapsed, this.start, this.distance, this.duration) window.scrollTo(0, this.next) this.timeElapsed < this.duration ? requestAnimationFrame(time => this._loop(time)) : this._end() } _end() { window.scrollTo(0, this.start + this.distance) typeof this.options.callback === 'function' && this.options.callback() this.timeStart = false } } 

Мы могли бы преобразовать это в «ES5-класс» с помощью функции конструктора и нескольких методов-прототипов, но учтите, что нам никогда не понадобится несколько экземпляров этого класса, поэтому синглтон, реализованный с простым литералом объекта, сделает свое дело:

 var jump = (function() { var o = { jump: function(target, options) { this.start = window.pageYOffset this.options = { duration: options.duration, offset: options.offset || 0, callback: options.callback, easing: options.easing || easeInOutQuad } this.distance = typeof target === 'string' ? this.options.offset + document.querySelector(target).getBoundingClientRect().top : target this.duration = typeof this.options.duration === 'function' ? this.options.duration(this.distance) : this.options.duration requestAnimationFrame(_loop) }, _loop: function(time) { if(!this.timeStart) { this.timeStart = time } this.timeElapsed = time - this.timeStart this.next = this.options.easing(this.timeElapsed, this.start, this.distance, this.duration) window.scrollTo(0, this.next) this.timeElapsed < this.duration ? requestAnimationFrame(_loop) : this._end() }, _end: function() { window.scrollTo(0, this.start + this.distance) typeof this.options.callback === 'function' && this.options.callback() this.timeStart = false } }; var _loop = o._loop.bind(o); // Robert Penner's easeInOutQuad - http://robertpenner.com/easing/ function easeInOutQuad(t, b, c, d) { t /= d / 2 if(t < 1) return c / 2 * t * t + b t-- return -c / 2 * (t * (t - 2) - 1) + b } return o; })(); 

Помимо удаления класса, нам нужно было сделать пару других изменений. Обратный вызов для requestAnimationFrame , используемый для обновления положения полосы прокрутки в каждом кадре, который в исходном коде вызывается с помощью функции стрелки ES6, предварительно привязывается к синглтону перехода во время инициализации. Затем мы объединяем функцию замедления по умолчанию в том же исходном файле. Наконец, мы обернули код в IIFE ( выражения функций с немедленным вызовом), чтобы избежать загрязнения пространства имен.

Теперь мы можем применить еще один шаг рефакторинга, отметив, что с помощью вложенных функций и замыканий мы можем просто использовать функцию вместо объекта:

 function jump(target, options) { var start = window.pageYOffset; var opt = { duration: options.duration, offset: options.offset || 0, callback: options.callback, easing: options.easing || easeInOutQuad }; var distance = typeof target === 'string' ? opt.offset + document.querySelector(target).getBoundingClientRect().top : target ; var duration = typeof opt.duration === 'function' ? opt.duration(distance) : opt.duration ; var timeStart = null, timeElapsed ; requestAnimationFrame(loop); function loop(time) { if (timeStart === null) timeStart = time; timeElapsed = time - timeStart; window.scrollTo(0, opt.easing(timeElapsed, start, distance, duration)); if (timeElapsed < duration) requestAnimationFrame(loop) else end(); } function end() { window.scrollTo(0, start + distance); typeof opt.callback === 'function' && opt.callback(); timeStart = null; } // ... } 

Синглтон теперь становится функцией jump которая будет вызываться для анимации прокрутки, а обратные вызовы loop и end становятся вложенными функциями, а свойства объекта теперь становятся локальными переменными (замыканиями). Нам больше не нужен IIFE, потому что теперь весь код надежно упакован в одну функцию.

В качестве последнего шага рефакторинга, чтобы избежать повторения timeStart сброса timeStart при каждом вызове обратного вызова цикла, первый раз, когда вызывается requestAnimationFrame() мы передаем ему анонимную функцию, которая сбрасывает переменную timerStart перед вызовом функции цикла:

 requestAnimationFrame(function(time) { timeStart = time; loop(time); }); function loop(time) { timeElapsed = time - timeStart; window.scrollTo(0, opt.easing(timeElapsed, start, distance, duration)); if (timeElapsed < duration) requestAnimationFrame(loop) else end(); } 

Еще раз отметим, что в процессе рефакторинга код анимации прокрутки ядра не изменился.

Тестовая страница

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

Страница состоит из оглавления (TOC) с внутренними ссылками на следующие разделы документа и дополнительными ссылками на TOC. Мы добавим некоторые внешние ссылки на другие страницы. Вот основная структура этой страницы:

 <body> <h1>Title</h1> <nav id="toc"> <ul> <li><a href="#sect-1">Section 1</a></li> <li><a href="#sect-2">Section 2</a></li> ... </ul> </nav> <section id="sect-1"> <h2>Section 1</h2> <p>Pellentesque habitant morbi tristique senectus et netus et <a href="http://www.example.net/">a link to another page</a> ac turpis egestas. <a href="http://www.example.net/index.html#foo">A link to another page, with an anchor</a> quam, feugiat vitae, ...</p> <a href="#toc">Back to TOC</a> </section> <section id="sect-2"> <h2>Section 2</h2> ... </section> ... <script src="jump.js"></script> <script src="script.js"></script> </body> 

В главе мы включим несколько правил CSS для настройки базового минимального макета, в то время как в конце тега body мы включаем два файла JavaScript: первый — это наша версия Jump.js с рефакторингом, а второй — скрипт что мы сейчас обсудим.

Мастер сценарий

Это сценарий, который улучшит процесс прокрутки тестовой страницы с помощью анимированных переходов, предоставляемых нашей пользовательской версией библиотеки Jump.js. Конечно, этот код также будет написан на ES5 JavaScript.

Давайте кратко опишем задачи, которые он должен выполнить: он должен перехватывать щелчки по ссылкам на странице, отключая поведение браузера по умолчанию (резкий переход к целевому элементу, указанному во фрагменте хэша атрибута href по нажатой ссылке), и замените его вызовом нашей функции jump ().

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

Делегация мероприятия

При первом подходе мы добавляем прослушиватель кликов только к одному элементу document.body . Таким образом, каждое событие щелчка на любом элементе страницы будет пузыриться в дереве DOM вдоль ветви его предков, пока не достигнет document.body :

 document.body.addEventListener('click', onClick, false); 

Конечно, теперь в зарегистрированном прослушивателе событий ( onClick ) мы должны проверить цель объекта входящего события click, чтобы убедиться, что он связан с элементом ссылки на странице. Это можно сделать несколькими способами, поэтому мы абстрагируем его от вспомогательной функции isInPageLink() . Мы посмотрим на механику этой функции через минуту.

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

Вот обработчик события:

 function onClick(e) { if (!isInPageLink(e.target)) return; e.stopPropagation(); e.preventDefault(); jump(e.target.hash, { duration: duration }); } 

Индивидуальные обработчики

При втором подходе к мониторингу щелчков по ссылкам, слегка модифицированная версия обработчика событий, представленная выше, прикрепляется к каждому элементу ссылки на странице, поэтому никакого всплывающего события не происходит:

 [].slice.call(document.querySelectorAll('a')) .filter(isInPageLink) .forEach(function(a) { a.addEventListener('click', onClick, false); }); 

Мы запрашиваем все элементы <a> и конвертируем возвращенный DOM NodeList в массив JavaScript с помощью хака [] .slice () (если целевые браузеры поддерживают его, лучшей альтернативой будет использование ES6 Array.from () метод). Затем мы можем использовать методы массива для фильтрации внутристраничных ссылок, повторно используя ту же вспомогательную функцию, определенную выше, и, наконец, присоединить слушателя к оставшимся элементам ссылки.

Обработчик событий почти такой же, как и раньше, но, конечно, нам не нужно проверять цель клика:

 function onClick(e) { e.stopPropagation(); e.preventDefault(); jump(hash, { duration: duration }); } 

Какой подход лучше, зависит от контекста использования. Например, если новые элементы ссылок могут динамически добавляться после начальной загрузки страницы, то мы должны использовать делегирование событий.

Теперь мы переходим к реализации isInPageLink() , вспомогательной функции, которую мы использовали в предыдущих обработчиках событий для абстрагирования теста для ссылок на странице. Как мы уже видели, эта функция принимает узел DOM в качестве аргумента и возвращает логическое значение, чтобы указать, представляет ли узел элемент ссылки на странице. Недостаточно проверить, что переданный узел является тегом A и что у него установлен хеш-фрагмент, поскольку ссылка может быть на другую страницу, и в этом случае действие браузера по умолчанию не должно быть отключено. Поэтому мы проверяем, равно ли значение, хранимое в атрибуте href ‘минус’ хеш-фрагмента, URL страницы:

 function isInPageLink(n) { return n.tagName.toLowerCase() === 'a' && n.hash.length > 0 && stripHash(n.href) === pageUrl ; } 

stripHash() — это еще одна вспомогательная функция, которую мы также используем для установки значения переменной pageUrl при инициализации скрипта:

 var pageUrl = location.hash ? stripHash(location.href) : location.href ; function stripHash(url) { return url.slice(0, url.lastIndexOf('#')); } 

Это основанное на строках решение с обрезкой фрагмента хеша работает даже с URL-адресами со строками запроса, потому что часть хеша идет после них в общей структуре URL-адреса.

Как я уже говорил, это только один из возможных способов реализации этого теста. Например, в статье, процитированной в начале этого учебного пособия, используется другое решение, в котором проводится компонентное сравнение ссылки href с объектом location .

Следует отметить, что мы использовали эту функцию в обоих подходах к подписке на события, но во втором мы используем ее в качестве фильтра для элементов, которые, как мы уже знаем, являются тегами <a> поэтому первая проверка атрибута tagName является избыточной , Это оставлено в качестве упражнения для читателя.

Вопросы доступности

В настоящее время наш код уязвим к известной ошибке (на самом деле пара несвязанных ошибок, попадающих в Blink / WebKit / KHTML и одна, попадающая в IE), которая затрагивает пользователей клавиатуры. При переходе по ссылкам оглавления активация одной из них будет плавно прокручиваться вниз к выбранному разделу, но фокус останется на ссылке. Это означает, что при нажатии клавиши следующей вкладки пользователь будет отправлен обратно в оглавление, а не на первую ссылку в выбранном разделе.

Чтобы исправить это, мы собираемся добавить еще одну функцию в наш главный скрипт:

 // Adapted from: // https://www.nczonline.net/blog/2013/01/15/fixing-skip-to-content-links/ function setFocus(hash) { var element = document.getElementById(hash.substring(1)); if (element) { if (!/^(?:a|select|input|button|textarea)$/i.test(element.tagName)) { element.tabIndex = -1; } element.focus(); } } 

который будет выполняться в обратном вызове, который мы передадим в функцию jump , передавая значение хеша элемента, на который мы прокручиваем:

 jump(e.target.hash, { duration: duration, callback: function() { setFocus(e.target.hash); } }); 

Эта функция извлекает элемент DOM, которому соответствует значение хеш-функции, и проверяет, является ли он элементом, который может получить фокус (например, якорь или элемент кнопки). Если элемент не может получить фокус по умолчанию (например, наши контейнеры <section> ), он устанавливает свой атрибут tabIndex в значение -1 (что позволяет ему получать фокус программно, но не через клавиатуру). Затем фокус устанавливается на этот элемент, это означает, что нажатие следующей вкладки пользователя сместит фокус на следующую доступную ссылку.

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

Поддержка нативной плавной прокрутки с помощью CSS

Спецификация модуля CSS Object Model View представляет новое свойство для естественной реализации плавной прокрутки: scroll-behavior .

Может принимать два значения: auto для мгновенной прокрутки по умолчанию и smooth для анимированной прокрутки.
Спецификация не предоставляет никакого способа настройки анимации прокрутки, такой как ее продолжительность и функция синхронизации (замедление).

К сожалению, на момент написания статьи поддержка очень ограничена. В Chrome эта функция находится в стадии разработки, и частичная реализация доступна путем включения ее на экране chrome://flags . Свойство CSS еще не реализовано, поэтому плавная прокрутка по ссылкам не работает.

В любом случае, с небольшим изменением нашего основного сценария, мы можем определить, доступна ли эта функция в пользовательском агенте, и избежать запуска остальной части нашего кода. Чтобы использовать плавную прокрутку в области просмотра, мы применяем свойство CSS к корневому элементу HTML (но на нашей тестовой странице мы могли бы даже применить его к элементу body):

 html { scroll-behavior: smooth; } 

Затем мы добавляем простой тест для определения функций в начале скрипта:

 function initSmoothScrolling() { if (isCssSmoothSCrollSupported()) { document.getElementById('css-support-msg').className = 'supported'; return; } //... } function isCssSmoothSCrollSupported() { return 'scrollBehavior' in document.documentElement.style; } 

Следовательно, если браузер поддерживает собственную прокрутку, скрипт ничего не делает и завершает работу, в противном случае он продолжит выполнение, как и раньше, и неподдерживаемое свойство CSS будет игнорироваться браузером.

Вывод

Еще одно преимущество только что рассмотренного решения CSS, помимо простоты реализации и производительности, заключается в том, что поведение истории браузера соответствует тому, что вы испытываете при использовании прокрутки браузера по умолчанию. Каждый переход на страницу помещается в стек истории браузера, и мы можем перемещаться по ним с помощью соответствующих кнопок (но без плавной прокрутки, по крайней мере, в Firefox).

В написанном нами коде (который мы теперь можем рассматривать как запасной вариант, когда поддержка CSS недоступна) мы не принимали во внимание поведение скрипта по отношению к истории браузера. В зависимости от контекста и варианта использования, это может представлять интерес, а может и не представлять интерес, но если мы придерживаемся мнения, что сценарий должен улучшить возможности прокрутки по умолчанию, нам следует ожидать согласованного поведения, как это происходит с CSS.