Опыт декларативного программирования на JavaScript на примере аудиопроигрывателя
Автор — Ростислав Чебыкин.
Вёрстка и размещение на Хабр — den_lesnov.
I feel something so wrong
By doing the right thing…
Ryan Tedder (OneRepublic). Counting Stars
Мы с Денисом Лесновым разработали аудиопроигрыватель для моего сайта. На сайте размещаются аудиозаписи песен, и я давно мечтал сделать, чтобы они проигрывались прямо с веб-страниц.
Проигрыватель выглядит примерно так:
Как он работает — можно посмотреть на демонстрационной странице.
Первый вопрос, который нам задают,— почему мы городили собственный плеер с нуля, а не использовали какое-нибудь из сотен готовых решений? Ответ простой: потому что нам было интересно заниматься этой задачей.
В этом проекте не было заказчиков, начальников, финансовой мотивации и определённых сроков. Мы собирались примерно раз в неделю у кого-нибудь из нас дома и программировали в своё удовольствие. Первая действующая версия проигрывателя была готова за два вечера и выложена на сайт, а потом мы ещё около года приводили код в божеский вид.
Здесь пойдёт речь об основных технических решениях, которые мы применяли.
Техническая кошерность
— Надо же, мы только вчера начали, а у нас уже дофига legacy-кода…
Проигрыватель работает на современных клиентских технологиях (объект Audio из HTML5), без Flash и другого антиквариата. Однако пользователи старых браузеров не страдают: для них проигрыватель ведёт себя как прямая ссылка на файл MP3. Это достигается без проверки конкретных моделей и версий браузеров, а только через feature detection.
Мы не пользовались jQuery и другими библиотеками, весь код написан на чистом JavaScript. Это тоже потому, что так интереснее.
В JavaScript’е мы придерживались подхода «минимум императивности, максимум декларативности», а также стремились не допускать дублирования кода, «всемогущих» функций, многоэтажных условий и циклов и прочих code smells.
Проигрыватель — «резиновый» по горизонтали: он автоматически растягивается по ширине контейнера, даже если эта ширина меняется во время звучания.
HTML: лучше меньше, да лучше
не может моисей народу
скрижали прочитать свои
они наверно в кодировке
кои
Код HTML, связанный с проигрывателем, в исходном состоянии представляет собой обычную прямую гиперссылку на аудиофайл:
<a href="/path/file.mp3" target="_blank" id="player" class="ready">Послушать песню</a>
Это удобно, если вы хотите не запустить музыку, а сохранить файл на диск, открыть его другим приложением, скопировать ссылку и так далее.
Если щёлкнуть по ссылке, JavaScript проверит, готова ли окружающая среда проиграть файл. Если не готова — ссылка срабатывает обычным образом. То же самое происходит, если JavaScript вообще не запустится.
Наконец, если проигрывание доступно, содержание ссылки становится таким:
<a target="_blank" id="player" class="">
<track></track>
<playhead draggable="true"></playhead>
</a>
В активном состоянии проигрывателя код HTML состоит всего из трёх элементов: ровно по одному для каждого компонента интерфейса, с которым можно взаимодействовать:
- a по-прежнему представляет весь проигрыватель. Пользователь может запустить или остановить воспроизведение звука, щёлкнув по значку / . (Для значка нет отдельного элемента HTML, он тоже относится к a.)
- track — звуковая дорожка. По ней можно щёлкнуть, чтобы перемотать проигрывание в определённое место.
- playhead — «головка звукоснимателя». Он показывает текущую позицию проигрывания, и его можно перетаскивать.
Проектирование без спешки
— Здесь надо мимимизировать код…
— Хорошо ещё, что не мудифицировать!
В основе проектирования лежала модель, представляющая проигрыватель как набор основных функций, не зависящих от реализации. Чтобы выявить наиболее универсальную модель, мы задумывались, что было бы, если бы система была не веб-интерфейсом, а состояла из бетонных блоков или вообще представляла собой живого менестреля с лютней.
На этом этапе мы зафиксировали, например, что запустить и остановить музыку — это фундаментальные возможности проигрывателя, а «изменить значение left для головки» — детали реализации. Это помогло не смешивать в коде абстракции разного уровня.
Уточняя модель, мы рисовали много схем и таблиц и случайно придумали оригинальную разновидность ER-моделирования, о которой я расскажу в другой раз.
Мы щепетильно относились к именам переменных, классов CSS и других сущностей. Мы могли потратить час или два на подбор удачного названия, за которое потом не будет стыдно. Например, поначалу мы называли ползунок то slider, то indicator, пока не обнаружили, что в английском языке есть старинный термин playhead, который обозначает ровно то, что нужно. Мы так обрадовались, что стали использовать шуточный перевод «Играй-голова» в качестве неофициального названия всего проекта.
CSS: геометрия без картинок
— Выглядит нативно, а смотреть противно…
Мы не использовали картинки; вся геометрия проигрывателя нарисована средствами CSS. Например, вот значок :
#player::before {
content: '';
float: left;
margin-right: 6px;
width: 0;
height: 1px;
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
border-left: 10px solid #999;
}
Собственно, значок / реализован как псевдоэлемент ::before элемента а. Это упростило JavaScript: для запуска и остановки музыки обрабатывается щелчок по самому проигрывателю, а не по какому-то специальному элементу.
Похожим образом, чтобы изобразить заполнение буфера, мы не вводили отдельный элемент, а применяли функцию linear-gradient в качестве background-image для track. В исходном состоянии буфер пуст, и дорожка залита белым цветом:
track {
background-image: linear-gradient(to right, #ddd 0%, #fff 0%);
}
Процентное значение после #ddd наращивается в соответствии с заполненностью буфера, и часть дорожки становится серой. Когда буфер полон, вся дорожка залита серым цветом.
Состояния системы
— Это не костыль, а адаптер!
Мы решили, что в каждый момент времени система может находиться в одном из трёх состояний:
- READY — исходное состояние;
- SET — после того, как система убедилась, что проигрыватель готов играть;
- GO — рабочее состояние после того, как пользователь активировал проигрыватель.
Различать состояния важно, чтобы система по-разному реагировала на одни и те же события. Например, щелчок по проигрывателю в состоянии SET приводит к тому, что он разворачивает всю свою инфраструктуру, а в состоянии GO — к тому, что он запускает или останавливает музыку.
Состояния помогли строить код более декларативно. В первой версии программы было не продохнуть от нанизанных друг на друга addEventListener / removeEventListener, но к финальному варианту эта тудасюдака полностью прекратилась.
Следить за состояниями помогает специальный объект stateCtrl. Например, stateCtrl.is('SET') возвращает true тогда и только тогда, когда система находится в состоянии SET.
Всё поведение в одном месте
— Что ты думаешь про юнит-тесты?
— Знаешь, у Козьмы Пруткова по этому поводу был афоризм… только я забыл какой. Это всё, что я думаю про юнит-тесты.
Возможно, с этого места начинается самое забористое. Всё поведение системы описывается одной структурой, которую я покажу целиком:
var behavior = new Behavior({
window: {
DOMContentLoaded: { READY: 'player.set' }
},
html: {
dragover: 'playhead.pull',
drop: ['audio.land', 'stateCtrl.toggle']
},
audio: {
timeupdate: { NO_DRAG: ['playhead.move', 'track.augment'] },
progress: 'track.augment',
ended: ['audio.pause', 'player.toggle'] // 1
},
player: {
click: {
SET: ['audio.start', 'player.go', 'stateCtrl.nextActivityState'],
GO: 'audio.toggle' // 2
}
},
playhead: {
dragstart: 'playhead.drag'
},
track: {
click: 'audio.rewind'
}
});
Здесь описано, каких событий мы ожидаем на каком объекте и в каком состоянии и что делать в ответ на каждое событие. Например, строчка 1 означает, что по наступлению события ended на объекте audio (в любом состоянии) вызываются функции audio.pause и player.toggle. Строчка 2 означает, что по событию click на объекте player (это сам проигрыватель — элемент a в HTML), если система находится в состоянии GO, вызывается функция audio.toggle.
Фактически, весь остальной код посвящён тому, чтобы «вдохнуть жизнь» в это чисто декларативное описание и заставить его работать.
Те же и preventDefault
— Ну вот, два обработчика начали таскать playhead и порвали его…
В ответ на некоторые события нужно не только выполнить определённые действия, но и предотвратить реакцию по умолчанию (например, переход по ссылке). Мы решили не смешивать это с behavior’ом, описанным раньше, потому что это относится не к «бизнес-логике» проигрывателя, а к особенностям обработки событий в браузере.
Отдельная структура описывает, куда навешивать preventDefault:
var modifiers = { preventDefault: ['html', 'track', 'html.dragenter', 'player.click.SET'] };
Это означает, что preventDefault будет добавлен к обработчикам:
- всех событий объектов html и track (перечисленных в behavior),
- события dragenter объекта html (этого события нет в behavior),
- события click объекта player, только если система находится в состоянии SET.
Вся реализация тоже в одном месте
— Скажи рифму к слову eval!
— setInterval… Ой, нет, это плохая рифма… То есть рифма хорошая, идея плохая.
Реализация всех функций также описана в отдельной структуре, построенной по такой схеме:
var impl = {
Number: {
toPercentString: function() { /* … */ }
},
Array: {
modify: function(handler) { /* … */ }
},
String: {
createListeners: function(behavior) { /* … */ },
getObject: function() { /* … */ },
modify: function(handler) { /* … */ },
},
HTMLElement: {
move: function() { /* … */ },
// … и т. д. …
},
// … и т. д. …
};
Ключи этой структуры — имена «классов» (точнее, прототипов), а значения — список функций, которые будут вызываться от экземпляров этих «классов». Например, функция move вызывается от объекта playhead, прототип которого — HTMLElement.
В нынешней системе функции приделываются прямо к соответствующим прототипам, что несколько портит кошерность кода. В дальнейшем мы наладим более аккуратное наследование.
Отдельная фишка: если имя функции в impl начинается с get, то она приделывается как полноценный геттер. Например, функция getObject превращается в свойство, которое можно вызывать как строка.object.
Как всё складывается
— План такой: сначала пишем нечистую функцию, потом делаем её чистой. Отмывание функций!
Когда скрипт запускается, срабатывает особая чёрная магия, которая переваривает всю эту декларативщину и в итоге заставляет музыку звучать:
- Отдельный цикл проходит по перечню реализаций impl и засовывает каждую функцию в соответствующий прототип. Это делается с помощью специально написанного метода addProperties, который, в свою очередь, опирается на стандартный Object.defineProperties:
for(var typeName in impl) { window[typeName].addProperties(impl[typeName]); }
- Ещё один специально написанный метод modify изменяет объект behavior (в котором хранится «бизнес-логика»), добавляя к нему preventDefault’ы из объекта modifiers:
behavior.modify(modifiers);
- Наконец, к window применяется третий специальный метод createListeners, который навешивает на window обработчики событий, описанные в модифицированном behavior’е:
'window'.createListeners(behavior);
От этого на window немедленно срабатывает DOMContentLoaded, и всё взлетает. Остальные обработчики навешиваются внутри отдельных функций в нужные моменты. Например, обработчики для объектов html, player и audio создаются внутри функции set, которая запускается после загрузки страницы, а обработчики для track и playhead — внутри функции go, которая запускается после активации проигрывателя:
['playhead', 'track'].createListeners(behavior);
В итоге слово «addEventListener» упоминается ровно один раз на весь код.
Ещё немного о чёрной магии
— We need to go thiser…
Я упомянул про чёрную магию, потому что попытки программировать на JavaScript в декларативном стиле неминуемо приводят к мистическим заклинаниям в коде. Например, метод createListeners рекурсивно обходит внутренности behavior’а с помощью полиморфного метода walk, который определяется так:
for(var typeName in walkers) { // Object, Array, String
window[typeName].addProperties({
walk: functools.partialLeft(
function(name, params) {
walkers[name].call(this, params);
},
typeName
)
});
}
Здесь мы притянули за уши кусок Python’а: чтобы этот код работал, мы наколдовали partialLeft и положили его в «модуль» functools.
В свою очередь, partialLeft использует вспомогательную функцию array, которая превращает всё в массив. Чем не чёрная магия?
Мораль сей басни
— Ты опять рефакторишь несуществующий код?
Как вы заметили, в этом повествовании нет почти ничего про работу с объектом Audio и про другие специфические особенности медиапроигрывателей. Это потому, что в этой области мы как раз не произвели ничего сногсшибательного. Наш плеер издаёт звук примерно так же, как сотни других аналогов.
Зато декларативная архитектура, в центре которой лежит объект behavior со всем поведением системы, кажется нам многообещающей для использования не только в этом проигрывателе, но и в других проектах. Может быть, со временем из этого вырастет библиотека, которая посрамит и вытеснит все остальные.
А пока мы с Денисом продолжаем шлифовать существующий код и собираемся добавить в него плейлисты, чтобы на сайте можно было слушать целые альбомы.
Автор: den_lesnov