Pull to refresh

О создании улучшенной JavaScript-библиотеки для работы с DOM

Reading time12 min
Views13K
В настоящее время jQuery является де-факто библиотекой для работы с DOM. Она может использоваться вместе с популярными MV* фрэймворками (такими как Backbone), имеет множество плагинов и очень большое сообщество. С другой стороны JavaScript становится все популярнее и многие разработчики начинают интересоваться как работают стандартные API и когда можно просто их использовать, не добавляя дополнительную библиотеку.

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

Затем Daniel Buchner создал SelectorListener, и родилась идея live расширений. Я начал подумывать о создании набора функций, который позволит создавать ненавязчивые и независимые компоненты DOM, используя лучший подход. Задача была сделать обзор существующих решений и создать более понятную, тестируемую, маленькую, но в то же время самодостаточную библиотеку.

Добавление полезных функций в библиотеку


Идея live расширений способствовала разработке проекта better-dom, хотя кроме него имеются другие интересные особенности, которые делают библиотеку уникальной. Давайте сделаем их беглый обзор:
  • live расширения
  • нативные анимации
  • встроенный шаблонизатор
  • поддержка интернационализации

Live расширения

В jQuery существует понятие live событий. За кулисами они используют event delegation чтобы обрабатывать существующие и будущие элементы. Однако во многих случаях требуется большая гибкость. Например, если виджет должен при инициализации добавить дополнительные элементы в дерево документа, которые должны взаимодействовать или замещать существующие, live события не работают. Чтобы решить проблему я представляю live расширения.

Цель — объявить расширение однажды, и после этого оно должно работать для будущего контента независимо от сложности виджета. Это важная особенность, поскольку позволяет создавать веб-страницы декларативно, поэтому хорошо подходит для AJAX приложений.

Рассмотрим простой пример. Допустим, наша задача реализовать полностью кастомизируемую всплывающую подсказку. Псевдоселектор :hover не подходит, потому что позиция тултипа меняется в зависимости от курсора мыши. Event delegation так же не подходит — слишком затратно слушать mouseover и mouseleave для всех элементов на странице. Здесь на сцену выходят live расширения.
DOM.extend("[title]", {
  constructor: function() {
    var tooltip = DOM.create("span.custom-title");

    // устанавливаем textContent всплывающей подсказки в значение 
    // title исходного элемента и скрываем ее изначально
    tooltip.set("textContent", this.get("title")).hide();

    this
      // удаляем нативный tooltip
      .set("title", null)
      // сохраняем ссылку для более быстрого доступа
      .data("tooltip", tooltip)
      // регистрируем обработчики событий
      .on("mouseenter", this.onMouseEnter, ["clientX", "clientY"])
      .on("mouseleave", this.onMouseLeave)
      // вставляем всплывающую подсказку в DOM
      .append(tooltip);
  },
  onMouseEnter: function(x, y) {
    this.data("tooltip").style({left: x, top: y}).show();
  },
  onMouseLeave: function() {
    this.data("tooltip").hide();
  }
});

Наш тултип теперь можно стилизировать с помощью селектора .custom-title в CSS:
.custom-title {
  position: fixed;
  border: 1px solid #faebcc;
  background: #faf8f0;
}

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

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

В заключение несколько слов о Web components. Один и разделов спецификации, под названием «Декораторы», предназначен для решения схожей проблемы. В настоящее время он использует разметку и специальный синтаксис для навешивания слушателей на дочерние элементы. Но это пока очень ранний черновик:
Декораторы, в отличие от других разделов Web Components не имеют пока спецификации.

Нативные анимации

Благодаря Apple в CSS сейчас есть хорошая поддержка анимаций. В прошлом анимации реализовывались на JavaScript с помощью setInterval и setTimeout. Это была классная фишка но теперь… что-то вроде плохой практики. Нативные анимации всегда будут более плавными: они обычно быстрее, требуют меньше энергии и просто не показываются в браузерах, которые их не поддерживают.

В better-dom нет метода animate: только show, hide и toggle. Чтобы захватить состояние скрытого элемента в CSS библиотека использует стандартизированный атрибут aria-hidden.

Для иллюстрации подхода давайте добавим простую анимацию тултипу, который мы написали ранее:
.custom-title {
  position: fixed;
  border: 1px solid #faebcc;
  background: #faf8f0;
  /* анимация */
  opacity: 1;
  -webkit-transition: opacity 0.5s;
  transition: opacity 0.5s;
}

.custom-title[aria-hidden=true] {
  opacity: 0;
}

Внутри show и hide атрибут aria-hidden меняет свое значение на false или true. Этого достаточно чтобы показывать анимации средствами CSS.

Больше примеров анимаций с помощью better-dom.

Встроенный шаблонизатор

HTML-строки громоздкие. Когда я начал искать замену, то нашел отличный проект Emmet. В настоящее время он достаточно популярный в качестве плагина для текстовых редакторов и имеет чистый и компактный синтаксис. Сравните:
body.append("<ul><li class='list-item'></li><li class='list-item'></li><li class='list-item'></li></ul>");

что эквивалентно
body.append("ul>li.list-item*3");

В better-dom методы, которые принимают HTML-строки в качестве аргументов, так же поддерживают emmet-аббревиатуры. Парсер аббревиатур быстрый, поэтому можно не думать о потерях в производительности. Так же имеется функция для прекомпиляции шаблонов, которая может быть использована по мере необходимости.

Поддержка интернационализации

Разработка UI-виджета часто требует локализацию, что не всегда простая задача. Многие решали эту проблему по-своему. С better-dom я надеюсь, что смена языка будет не сложнее изменения состояния CSS селектора.

С идеологической точки зрения переключение языка — это наподобие изменения «представления» контента. В CSS2 есть несколько псевдоселекторов, которые помогают описать такую модель: :lang и :before. Взгляните на код ниже:
[data-i18n="hello"]:before {
  content: "Hello Maksim!";
}

[data-i18n="hello"]:lang(ru):before {
  content: "Привет Максим!";
}

Хитрость в том, что свойство content меняется в соответствии с текущим языком, который определяется значением атрибута lang для элемента html. С помощью атрибута data-i18n мы можем использовать более общую запись:
[data-i18n]:before {
  content: attr(data-i18n);
}

[data-i18n="Hello Maksim!"]:lang(ru):before {
  content: "Привет Максим!";
}

Разумеется, такой CSS код не выглядит привлекательным, поэтому в better-dom два хелпера: i18n и DOM.importStrings. Первый используется для обновления атрибута data-i18n с соответствующим значением, а второй локализует строки для определенного языка.
label.i18n("Hello Maksim!");
// label отображает "Hello Maksim!"
DOM.importStrings("ru",  "Hello Maksim!", "Привет Максим!");
// теперь если язык страницы "ru", то label будет показывать "Привет Максим!"
label.set("lang", "ru");
// теперь label показывает "Привет Максим!" независимо от языка страницы

Параметризованные строки так же поддерживаются: достаточно добавить ${param} переменные в ключевую строку:
label.i18n("Hello ${user}!", {user: "Maksim"});
// label показывает "Hello Maksim!"

Улучшение нативных API


Обычно мы хотим придерживаться стандартов. Но иногда стандарты не совсем дружелюбные. DOM очень запутанный и, чтобы сделать его приятным, нужно обернуть его в удобный API. Несмотря на многочисленные улучшения, которые сделаны разными библиотеками, кое-какие вещи можно сделать лучше:
  • getter и setter
  • улучшенная обработка событий
  • поддержка функциональных методов

Getter и setter

Нативный DOM имеет понятия атрибутов и свойств у элемента, которые могут вести себя по-разному. Предположим на странице имеется разметка:
<a href="/chemerisuk/better-dom" id="foo" data-test="test">better-dom</a>

Чтобы объяснить недружелюбие нативного DOM, давайте поработаем с ним немного:
var link = document.getElementById("foo");

link.href; // => "https://github.com/chemerisuk/better-dom"
link.getAttribute("href"); // => "/chemerisuk/better-dom"
link["data-test"]; // => undefined
link.getAttribute("data-test"); // => "test"

link.href = "abc";
link.href; // => "https://github.com/abc"
link.getAttribute("href"); // => "abc"

Итак, значение атрибута равно соответствующей строке в HTML, в то время как свойство элемента с таким же именем может иметь специальное поведение, например, генерация полного URL в примере выше. Эта разница может иногда запутывать.

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

В better-dom дела обстоят проще: каждый элемент имеет только умный getter и setter.
var link = DOM.find("#foo");

link.get("href"); // => "https://github.com/chemerisuk/better-dom"
link.set("href", "abc");
link.get("href"); // => "https://github.com/abc"
link.get("data-attr"); // => "test"

На первом шаге методы делают поиск свойства элемента и, если оно определено, используют его для операций. В противном случае работают с соответствующим атрибутом. Для буленовских атрибутов (checked, selected и т.д.) можно просто использовать true или false. Изменение этих свойств у элемента обновляет соответствующий атрибут (нативное поведение).

Улучшенная обработка событий

Обработка событий — это значимая часть кодирования для DOM. Одна фундаментальная проблема, которую я обнаружил, состоит в том, что наличие event object в слушателях элемента заставляет разработчиков, которые любят тестируемый код, мОчить первый агрумент или создавать дополнительную функцию, которая принимает используемые в этом обработчике свойства события.
var button = document.getElementById("foo");

button.addEventListener("click", function(e) {
  handleButtonClick(e.button);
}, false);

Это на самом деле надоедает и добавляет вызов дополнительной функции. Что если выделить меняющуюся часть в качестве аргумента: это позволит избавиться от замыкания:
var button = DOM.find("#foo");

button.on("click", handleButtonClick, ["button"]);

По умолчанию обработчик событий принимает массив ["target", "defaultPrevented"], поэтому нет необходимости добавлять последний аргумент, чтобы прочитать эти свойства:
button.on("click", function(target, canceled) {
  // обрабатываем клик
});

Позднее связывание так же поддерживается (рекомендую прочитать статью от Peter Michaux по теме). Это более гибкая альтернатива обычным обработчикам событий, которая, кстати, присутствует в стандарте. Может быть полезна в случаях, когда нужно делать частые вызовы методов on и off.
button._handleButtonClick = function() { alert("click!"); };

button.on("click", "_handleButtonClick");
button.fire("click"); // показывается сообщение "clicked"
button._handleButtonClick = null;
button.fire("click"); // ничего не показывается

В завершении стоит упомянуть что в better-dom нету методов наподобие и click(), focus(), submit() т.п., которые присутствуют в стандарте и имеют различное поведение в браузерах. Единственные способ их вызвать — это использовать метод fire, который выполняет поведение по умолчанию, когда ни один из обработчиков не вернул false:
link.fire("click"); // кликает по ссылке
link.on("click", function() { return false; });
link.fire("click"); // вызывает обработчик выше но не кликает по ссылке

Поддержка функциональных методов

ES5 стандартизировал несколько полезных методов для массивов, такие как map, filter, some и т.д. Они позволяют проводить операции над коллекциями в стандартизированном виде. В результате сегодня имеются проекты наподобие Underscore или Lo-Dash, которые позволяют пользоваться этими методами в старых браузерах.

Каждый элемент (или коллекция) в better-dom имеет методы ниже из коробки:
  • each (отличается от forEach тем, что возвращает this вместо undefined)
  • some
  • every
  • map
  • filter
  • reduce[Right]

var urls, activeLi, linkText; 

urls = menu.findAll("a").map(function(el) {
  return el.get("href");
});
activeLi = menu.children().filter(function(el) {
  return el.hasClass("active");
});
linkText = menu.children().reduce(function(memo, el) {
  return memo || el.hasClass("active") && el.find("a").get()
}, false);

Решение некоторых проблем jQuery


Большинство проблем ниже не могут быть исправлены в jQuery без потери обратной совместимости. Еще одна причина, по которой было решено создать новую библиотеку.
  • «магическая» функция $
  • значение оператора квадратных скобок
  • проблемы с return false
  • find и findAll

«Магическая» функция $

Все слышали когда-нибудь, что функция $ (доллар) — это «магия». Имя, состоящее всего из одного символа, не очень понятное, функция выглядит как встроенный в язык оператор. Именно поэтому неопытные разработчики просто вызывают ее везде, где это необходимо.

За кулисами $ — это довольно сложная функция. Частое ее выполнение, особенно внутри таких событий как mousemove или scroll, может быть причиной плохой отзывчивости UI.

Несмотря на многочисленные статьи, которые продвигают кэширование объектов jQuery разработчики продолжают вставлять $. Это потому, что синтаксис библиотеки способствует такому стилю кодирования.

Другая проблема с этой функцией состоит в том, что она является ответственной за две совершенно разные задачи. Люди уже привыкли к такому синтаксису, но это нехорошая практика дизайна функции в общем случае:
$("a"); // => поиск всех элементов, которые соответствуют селектору “a”
$("<a>"); // => создает элемент <a> с jQuery врапером

В better-dom зоны ответственности $-функции покрывают несколько методов: find[All] и DOM.create. Методы find[All] используются для поиска элементов по CSS-селектору. DOM.create создает новые элементы в памяти. Имена функций ясно говорят что эти функции делают.

Значение оператора квадратных скобок

Еще одна причина проблемы с слишком частыми вызовами доллар-функции — это оператор квадратных скобок. Когда создается новый jQuery-объект все связанные элементы сохраняются в numeric-свойствах. Важно заметить, что значение такого свойства содержит экземпляр нативного элемента (не jQuery-врапера):
var links = $("a");

links[0].on("click", function() { ... }); // ошибка!
$(links[0]).on("click", function() { ... }); // теперь все хорошо

Из-за такой особенности каждый функциональный метод в jQuery или другой библиотеки (как Underscore) требует, чтобы текущий элемент оборачивался в $() внутри итерационной функции. Поэтому разработчик обязан всегда помнить с каким объектом он работает: нативным или врапером, несмотря на факт, что используется библиотека для работы с DOM.

В better-dom оператор квадратных скобок возвращает объект библиотеки, поэтому можно забыть о нативных элементах. Единственный легальный способ получить к ним доступ — это использовать специальный метод legacy.
var foo = DOM.find("#foo");

foo.legacy(function(node) {
  // используя библиотеку Hammer слушаем жест swipe
  Hammer(node).on("swipe", function(e) {
    // обрабатываем жест swipe
  }); 
});

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

Проблемы с return false

Одна вещь, которая действительно взорвала мне мозг это странная обработка return false в слушателях событий. В соответствии с стандартами W3C это значение должно в большинстве случаев отменять поведение по умолчанию. В jQuery return false дополнительно останавливает event delegation!

Здесь сразу несколько проблем:
  1. вызов stopPropagation() сам по себе может создавать проблемы с совместимостью, т.к. он ломает возможность других слушателей делать их работу по возникновению такого события
  2. большинство разработчиков (даже опытных) не ожидают такого поведения

Непонятно почему сообщество jQuery решило пойти против стандартов. И better-dom не собирается повторять эту ошибку: return false внутри обработчика событий вызывает только preventDefault(), как и ожидается.

find и findAll

Поиск элементов — это одна из самых дорогих операций в браузере. Две нативные функции могут использоваться чтобы реализовать его: querySelector и querySelectorAll. Разница между ними в том, что первая останавливает поиск после первого совпадения.

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

jQuery имеет find метод, который использует querySelectorAll в общем случае. На сегодняшний день здесь нет метода, который бы использовал querySelector, чтобы найти только первый подходящий элемент.

В better-dom есть два разных метода: find и findAll. Они позволяют использовать querySelector-оптимизацию выше. Чтобы оценить потенциальный выигрыш, я сделал выборку по количеству вхождений в исходном коде последнего коммерческого проекта:
  • find — 103 совпадений в 11 файлах
  • findAll — 14 совпадений в 4 файлах

Определенно, что метод find горазде более популярный. Это значит, что querySelector-оптимизация имеет место в большинстве случаев, поэтому может дать ощутимый выигрыш в производительности кода на клиенте.

Заключение


Разработка с использованием live расширений действительно упрощает жизнь на front-end. Разделение UI на множество маленьких частей помогает создавать более независимые (=надежные) решения. Но, как видно выше, better-dom не только о них (хотя это была изначальная главная цель).

Во время разработки я понял одну важную вещь: если текущие стандарты не совсем устраивают или есть идеи как можно сделать лучше — просто реализуй и докажи что они работают. И это очень весело!

Больше информации о библиотеке better-dom всегда можно найти на GitHub.
Tags:
Hubs:
+14
Comments15

Articles