Создаем свою первую библиотеку JavaScript
() translation by (you can also view the original English article)
Вы когда-нибудь удивлялись магии Mootools? Вы когда-нибудь задумывались над тем, как Dojo это делает? Вы когда-нибудь интересовались гимнастикой jQuery? В этом уроке мы собираемся скрыться за кулисами и попробовать свои силы в создании суперпростой версии вашей любимой библиотеки.
Мы используем библиотеки JavaScript почти каждый день. Когда вы только начинаете, что-то вроде jQuery кажется просто фантастическим, главным образом из-за DOM. Во-первых, DOM может быть довольно грубым для новичка; это довольно плохое оправдание API. Во-вторых, он даже не согласован во всех браузерах.
Мы обертываем элементы в объекте, потому что хотим иметь возможность создавать методы для объекта.
В этом уроке мы собираемся взять (решительно мелкий) удар при создании одной из таких библиотек с нуля. Да, это будет весело, но прежде чем вы будете взволнованы, позвольте мне прояснить несколько моментов:
- Это не будет полнофункциональная библиотека. О, у нас есть полный набор методов для написания, но это не jQuery. Мы сделаем достаточно, чтобы дать вам хорошее представление о тех проблемах, с которыми вы бы столкнулись при создании библиотеки.
- Здесь мы не собираемся использовать полную совместимость с браузером. Сегодня мы должны работать с Internet Explorer 8+, Firefox 5+, Opera 10+, Chrome и Safari.
- Мы не собираемся рассказывать о каждом возможном использовании нашей библиотеки. Например, наши методы
append
иprepend
будут работать только в том случае, если вы передадите им экземпляр нашей библиотеки; они не будут работать с необработанными узлами DOM или нодлистами.
Еще одна вещь: пока мы не будем писать тесты для этой библиотеки, я сделал это, когда впервые ее разработал. Вы можете получить библиотеку и тесты на Github.
Шаг 1: Создание каркса библиотеки
Мы начнем с кода оболочки, который будет содержать всю нашу библиотеку. Это ваше типичное выражение, которое сразу же вызывается (IIFE).
1 |
window.dome = (function () { |
2 |
function Dome (els) { |
3 |
|
4 |
}
|
5 |
|
6 |
var dome = { |
7 |
get: function (selector) { |
8 |
|
9 |
}
|
10 |
};
|
11 |
|
12 |
return dome; |
13 |
}());
|
Как вы можете видеть, мы называем в нашу библиотеку Dome, потому что это прежде всего библиотека DOM. Да, это хромает.
У нас здесь есть несколько вещей. Во-первых, мы имеем функцию; в конечном итоге это будет функция-конструктор для экземпляров нашей библиотеки; эти объекты обернут наши выбранные или созданные элементы.
Тогда у нас есть наш объект dome
, который является нашим фактическим объектом библиотеки; как вы можете видеть, он возвращается в конце. У него есть пустая функция get
, которую мы будем использовать для выбора элементов со страницы. Итак, давайте наполним это.
Шаг 2: Получение элементов
Функция dome.get
будет принимать один параметр, но это может быть несколько вещей. Если это строка, мы предположим, что это селектор CSS; но мы также можем взять один узел DOM или NodeList.
1 |
get: function (selector) { |
2 |
var els; |
3 |
if (typeof selector === "string") { |
4 |
els = document.querySelectorAll(selector); |
5 |
} else if (selector.length) { |
6 |
els = selector; |
7 |
} else { |
8 |
els = [selector]; |
9 |
}
|
10 |
return new Dome(els); |
11 |
}
|
Мы используем document.querySelectorAll
для упрощения поиска элементов: конечно, это ограничивает нашу поддержку браузерами, но для этого случая все в порядке. Если selector
не является строкой, мы проверим свойство length
. Если оно существует, мы узнаем, что у нас есть NodeList
; в противном случае у нас есть один элемент, и мы поместим его в массив. Это потому, что нам нужен массив, чтобы перейти к нашему вызову в Dome
внизу; как вы можете видеть, мы возвращаем новый объект Dome
. Итак, давайте вернемся к этой пустой функции Dome
и заполним ее.
Шаг 3: Создание экземпляров Dome
Вот функция Dome
:
1 |
function Dome (els) { |
2 |
for(var i = 0; i < els.length; i++ ) { |
3 |
this[i] = els[i]; |
4 |
}
|
5 |
this.length = els.length; |
6 |
}
|
Я действительно рекомендую вам покопаться внутри нескольких ваших любимых библиотек.
Это очень просто: мы просто перебираем элементы, которые мы выбрали, и привязываем их к новому объекту с числовыми индексами. Затем добавим свойство length
.
Но в чем тут смысл? Почему бы просто не вернуть элементы? Мы обертываем элементы в объекте, потому что хотим иметь возможность создавать методы для объекта; это методы, которые позволят нам взаимодействовать с этими элементами. На самом деле это своя версия jQuery.
Итак, теперь, когда мы возвращаем объект Dome
, добавим некоторые методы к его прототипу. Я собираюсь применить эти методы прямо под функцией Dome
.
Шаг 4: добавление нескольких утилит
Первые функции, которые мы собираемся написать, будут простые служебные функции. Поскольку наши объекты Dome
могут обернуть более одного элемента DOM, нам нужно будет перебирать каждый элемент почти в каждом методе; поэтому эти утилиты будут удобны.
Начнем с функции map
:
1 |
Dome.prototype.map = function (callback) { |
2 |
var results = [], i = 0; |
3 |
for ( ; i < this.length; i++) { |
4 |
results.push(callback.call(this, this[i], i)); |
5 |
}
|
6 |
return results; |
7 |
};
|
Конечно, функция map
принимает один параметр, функцию обратного вызова. Мы будем перебирать элементы в массиве, собирая все, что возвращается из обратного вызова в массиве results
. Обратите внимание, как мы вызываем эту функцию обратного вызова:
1 |
callback.call(this, this[i], i)); |
Таким образом, функция будет вызываться в контексте нашего экземпляра Dome
, и она получит два параметра: текущий элемент и номер индекса.
Нам также нужна функция forEach
. Это на самом деле очень просто:
1 |
Dome.prototype.forEach(callback) { |
2 |
this.map(callback); |
3 |
return this; |
4 |
};
|
Поскольку единственная разница между map
и forEach
заключается в том, что в map
нужно что-то возвращать, мы можем просто передать наш обратный вызов this.map
и проигнорировать возвращенный массив; вместо этого мы вернем this
, чтобы сделать нашу библиотеку цепочкой. Мы будем использовать forEach
совсем немного. Поэтому обратите внимание, что когда мы возвращаем наш вызов this.forEach
из функции, мы фактически возвращаем this
. Например, эти методы фактически возвращают одно и то же:
1 |
Dome.prototype.someMethod1 = function (callback) { |
2 |
this.forEach(callback); |
3 |
return this; |
4 |
};
|
5 |
|
6 |
Dome.prototype.someMethod2 = function (callback) { |
7 |
return this.forEach(callback); |
8 |
};
|
Еще одна функция: mapOne
. Легко понять, что делает эта функция, но реальный вопрос: зачем нам это нужно? Это требует немного того, что вы могли бы назвать «философией библиотеки».
Короткий «Философский» тур
Во-первых, DOM может быть довольно сложным для новичка; это довольно плохое оправдание API.
Если бы создание библиотеки предназначалось для написания кода, это не было бы слишком сложной задачей. Но когда я работал над этим проектом, я обнаружил, что более сложная часть - это решение того, как определенные методы должны работать.
Вскоре мы создадим метод text
, который возвращает текст выбранных нами элементов. Если наш объект Dome
обертывает несколько узлов DOM (dome.get («li»
) например), что должно это вернуть? Если вы делаете что-то подобное в jQuery ($("li").text()
), вы получите одну строку с текстом всех элементов, объединенных вместе. Это полезно? Я так не думаю, но я не уверен, что будет лучшим возвратным значением.
Для этого проекта я верну текст нескольких элементов в виде массива, если только в массиве нет только одного элемента; то мы просто вернем текстовую строку, а не массив с одним элементом. Я думаю, что вы чаще всего будете получать текст одного элемента, поэтому мы оптимизируем для этого случая. Однако, если вы получаете текст из нескольких элементов, мы вернем вам то, с чем вы можете работать.
Назад к написанию кода
Таким образом, метод mapOne
будет просто запускать map
, а затем либо вернет массив, либо единственный элемент, который был в массиве. Если вы все еще не знаете, где это будет полезно, читайте дальше: вы увидите!
1 |
Dome.prototype.mapOne = function (callback) { |
2 |
var m = this.map(callback); |
3 |
return m.length > 1 ? m : m[0]; |
4 |
};
|
Шаг 5: Работа с текстом и HTML
Затем добавим этот метод text
. Как и jQuery, мы можем передать ему строку и задать текст элемента или не использовать параметры, чтобы вернуть текст.
1 |
Dome.prototype.text = function (text) { |
2 |
if (typeof text !== "undefined") { |
3 |
return this.forEach(function (el) { |
4 |
el.innerText = text; |
5 |
});
|
6 |
} else { |
7 |
return this.mapOne(function (el) { |
8 |
return el.innerText; |
9 |
});
|
10 |
}
|
11 |
};
|
Как и следовало ожидать, нам нужно проверить значение в text
, чтобы узнать, устанавливаем ли мы его или получаем. Обратите внимание, что просто if (text)
не будет работать, потому что пустая строка является ложным значением.
Если мы устанавливаем, мы сделаем forEach
над элементами и установим их свойство innerText
в text
. Если мы получаем, мы вернем свойство innerText
элементов. Обратите внимание на наше использование метода mapOne
: если мы работаем с несколькими элементами, он вернет массив; в противном случае это будет только строка.
Метод html
будет делать почти точно так же, как и text
, за исключением того, что он будет использовать свойство innerHTML
, а не innerText
.
1 |
Dome.prototype.html = function (html) { |
2 |
if (typeof html !== "undefined") { |
3 |
this.forEach(function (el) { |
4 |
el.innerHTML = html; |
5 |
});
|
6 |
return this; |
7 |
} else { |
8 |
return this.mapOne(function (el) { |
9 |
return el.innerHTML; |
10 |
});
|
11 |
}
|
12 |
};
|
Как я сказал: почти то же самое.
Шаг 6: Классы
Затем мы хотим иметь возможность добавлять и удалять классы; поэтому давайте напишем методы addClass
и removeClass
.
Наш метод addClass
примет либо строку, либо массив имен классов. Чтобы выполнить эту работу, нам нужно проверить тип этого параметра. Если это массив, мы перейдем к нему и создадим строку имен классов. В противном случае мы просто добавим одно пространство в начало имени класса, чтобы оно не связывалось с существующими классами в элементе. Затем мы просто перебираем элементы и добавляем новые классы к свойству className
.
1 |
Dome.prototype.addClass = function (classes) { |
2 |
var className = ""; |
3 |
if (typeof classes !== "string") { |
4 |
for (var i = 0; i < classes.length; i++) { |
5 |
className += " " + classes[i]; |
6 |
}
|
7 |
} else { |
8 |
className = " " + classes; |
9 |
}
|
10 |
return this.forEach(function (el) { |
11 |
el.className += className; |
12 |
});
|
13 |
};
|
Довольно просто, да?
Теперь, как насчет удаления классов? Для простоты мы разрешим удаление только одного класса за раз.
1 |
Dome.prototype.removeClass = function (clazz) { |
2 |
return this.forEach(function (el) { |
3 |
var cs = el.className.split(" "), i; |
4 |
|
5 |
while ( (i = cs.indexOf(clazz)) > -1) { |
6 |
cs = cs.slice(0, i).concat(cs.slice(++i)); |
7 |
}
|
8 |
el.className = cs.join(" "); |
9 |
});
|
10 |
};
|
На каждом элементе мы разделим имя el.className
на массив. Затем мы используем цикл while, чтобы разрезать класс до тех пор, пока cs.indexOf(clazz)
не вернет -1. Мы делаем это, чтобы покрыть тот редкий случай, когда одни и те же классы были добавлены к элементу более одного раза: мы должны убедиться, что он действительно ушел. Как только мы уверены, что мы вырезали каждый экземпляр класса, мы присоединяем массив к пробелам и устанавливаем его на el.className
.
Шаг 7: Исправление ошибки IE
Худший браузер, с которым мы будем иметь дело, - IE8. В нашей маленькой библиотеке есть только одна ошибка IE, с которой нам нужно иметь дело; к счастью, это довольно просто. IE8 не поддерживает метод Array
indexOf
; мы используем его в removeClass
, так что давайте поправим его:
1 |
if (typeof Array.prototype.indexOf !== "function") { |
2 |
Array.prototype.indexOf = function (item) { |
3 |
for(var i = 0; i < this.length; i++) { |
4 |
if (this[i] === item) { |
5 |
return i; |
6 |
}
|
7 |
}
|
8 |
return -1; |
9 |
};
|
10 |
}
|
Это довольно просто, и это не полная реализация (не поддерживает второй параметр), но она будет работать для наших целей.
Шаг 8: Настройка атрибутов
Теперь нам нужна функция attr
. Это будет легко, потому что это практически идентично нашим методам text
и html
. Подобно этим методам, мы сможем как получить, так и установить атрибуты: мы возьмем имя и значение атрибута для установки и просто имя атрибута, которое нужно получить.
1 |
Dome.prototype.attr = function (attr, val) { |
2 |
if (typeof val !== "undefined") { |
3 |
return this.forEach(function(el) { |
4 |
el.setAttribute(attr, val); |
5 |
});
|
6 |
} else { |
7 |
return this.mapOne(function (el) { |
8 |
return el.getAttribute(attr); |
9 |
});
|
10 |
}
|
11 |
};
|
Если val
имеет значение, мы будем прокручивать элементы и устанавливать выбранный атрибут с этим значением, используя метод setAttribute
у каждого элемента. В противном случае мы будем использовать mapOne
для возврата этого атрибута с помощью метода getAttribute
.
Шаг 9: Создание элементов
Мы должны иметь возможность создавать новые элементы, как любая хорошая библиотека. Конечно, это было бы неплохо как метод на экземпляре Dome
, поэтому давайте поместим его прямо на наш объект dome
.
1 |
var dome = { |
2 |
// get method here
|
3 |
create: function (tagName, attrs) { |
4 |
|
5 |
}
|
6 |
};
|
Как вы можете видеть, мы возьмем два параметра: имя элемента и объект атрибутов. Большинство атрибутов применяют с помощью нашего метода attr
, но два - исключение. Мы будем использовать метод addClass
для свойства className
и метод text
для свойства text
. Конечно, нам нужно сначала создать элемент и объект Dome
. Вот все это в действии:
1 |
create: function (tagName, attrs) { |
2 |
var el = new Dome([document.createElement(tagName)]); |
3 |
if (attrs) { |
4 |
if (attrs.className) { |
5 |
el.addClass(attrs.className); |
6 |
delete attrs.className; |
7 |
}
|
8 |
if (attrs.text) { |
9 |
el.text(attrs.text); |
10 |
delete attrs.text; |
11 |
}
|
12 |
for (var key in attrs) { |
13 |
if (attrs.hasOwnProperty(key)) { |
14 |
el.attr(key, attrs[key]); |
15 |
}
|
16 |
}
|
17 |
}
|
18 |
return el; |
19 |
}
|
Как вы можете видеть, мы создаем элемент и отправляем его прямо в новый объект Dome
. Затем мы имеем дело с атрибутами. Обратите внимание, что после работы с ними мы должны удалить атрибуты className
и text
. Это не позволяет применять их в качестве атрибутов, когда мы перебираем остальные ключи в attrs
. Конечно, мы заканчиваем тем, что возвращаем новый объект Dome
.
Но теперь, когда мы создаем новые элементы, мы захотим вставить их в DOM, верно?
Шаг 10: Добавление элементов
Затем мы напишем методы append
и prepend
. Теперь это на самом деле немного сложные функции, в основном из-за различных вариантов использования. Вот что мы хотим сделать:
1 |
dome1.append(dome2); |
2 |
dome1.prepend(dome2); |
Худший браузер, с которым мы имеем дело, - IE8.
Варианты использования таковы: мы можем захотеть append или prepend
- один новый элемент для одного или нескольких существующих элементов.
- несколько новых элементов для одного или нескольких существующих элементов.
- один существующий элемент к одному или нескольким существующим элементам.
- несколько существующих элементов для одного или нескольких существующих элементов.
Примечание. Я использую «новый» для обозначения элементов, еще не находящихся в DOM; существующие элементы уже находятся в DOM.
И так приступим:
1 |
Dome.prototype.append = function (els) { |
2 |
this.forEach(function (parEl, i) { |
3 |
els.forEach(function (childEl) { |
4 |
|
5 |
});
|
6 |
});
|
7 |
};
|
Мы ожидаем, что параметр els
будет объектом Dome
. Полноценная библиотека DOM приняла бы это как узел или нодлист, но мы этого не будем делать. Мы должны пройтись в цикле по каждому из наших элементов, а затем внутри этого цикла мы перебираем каждый из элементов, которые хотим добавить.
Если мы добавим els
к нескольким элементам, нам нужно клонировать их. Однако мы не хотим клонировать узлы при первом добавлении, только в последующие моменты времени. Поэтому мы сделаем следующее:
1 |
if (i > 0) { |
2 |
childEl = childEl.cloneNode(true); |
3 |
}
|
Это i
исходит из внешнего цикла forEach
: это индекс текущего родительского элемента. Если мы не добавляем первый родительский элемент, мы клонируем узел. Таким образом, фактический узел войдет в первый родительский узел, и каждый другой родитель получит копию. Это хорошо работает, поскольку объект Dome
, переданный в качестве аргумента, будет иметь только исходные (неклонированные) узлы. Итак, если мы добавляем только один элемент в один элемент, все задействованные узлы будут частью их соответствующих объектов Dome
.
Наконец, мы фактически добавим элемент:
1 |
parEl.appendChild(childEl); |
Итак, в целом вот что у нас получилось:
1 |
Dome.prototype.append = function (els) { |
2 |
return this.forEach(function (parEl, i) { |
3 |
els.forEach(function (childEl) { |
4 |
if (i > 0) { |
5 |
childEl = childEl.cloneNode(true); |
6 |
}
|
7 |
parEl.appendChild(childEl); |
8 |
});
|
9 |
});
|
10 |
};
|
Метод prepend
Мы хотим охватить те же случаи для метода prepend
, поэтому этот метод очень похож:
1 |
Dome.prototype.prepend = function (els) { |
2 |
return this.forEach(function (parEl, i) { |
3 |
for (var j = els.length -1; j > -1; j--) { |
4 |
childEl = (i > 0) ? els[j].cloneNode(true) : els[j]; |
5 |
parEl.insertBefore(childEl, parEl.firstChild); |
6 |
}
|
7 |
});
|
8 |
};
|
Разные, когда добавление состоит в том, что если вы последовательно добавляете список элементов в другой элемент, они будут в конечном итоге в обратном порядке. Поскольку мы не выполнить forEach
назад, поэтому я использую цикл for
. Опять же, мы будем клонировать узел, если это не первый родитель, к которому мы добавляем.
Шаг 11: Удаление узлов
Для нашего последнего метода манипуляции с узлами мы хотим иметь возможность удалять узлы из DOM. Легко, действительно:
1 |
Dome.prototype.remove = function () { |
2 |
return this.forEach(function (el) { |
3 |
return el.parentNode.removeChild(el); |
4 |
});
|
5 |
};
|
Просто перебирайте узлы и вызывайте метод removeChild
для parentNode
каждого элемента. Красота здесь (все благодаря DOM) заключается в том, что этот объект Dome
все равно будет работать нормально; мы можем использовать любой метод, который захотим, включая append или prepend его обратно в DOM. Приятно, не так ли?
Шаг 12: Работа с событиями
Наконец, но не в последнюю очередь, мы собираемся написать несколько функций для обработчиков событий.
Как вы, наверное, знаете, IE8 использует старые события IE, поэтому нам нужно будет это проверить. Кроме того, мы будем бросать события DOM 0, просто потому, что можем.
Ознакомьтесь с методом, а затем мы обсудим его:
1 |
Dome.prototype.on = (function () { |
2 |
if (document.addEventListener) { |
3 |
return function (evt, fn) { |
4 |
return this.forEach(function (el) { |
5 |
el.addEventListener(evt, fn, false); |
6 |
});
|
7 |
};
|
8 |
} else if (document.attachEvent) { |
9 |
return function (evt, fn) { |
10 |
return this.forEach(function (el) { |
11 |
el.attachEvent("on" + evt, fn); |
12 |
});
|
13 |
};
|
14 |
} else { |
15 |
return function (evt, fn) { |
16 |
return this.forEach(function (el) { |
17 |
el["on" + evt] = fn; |
18 |
});
|
19 |
};
|
20 |
}
|
21 |
}());
|
Здесь у нас есть IIFE, и внутри мы делаем проверку функций. Если document.addEventListener
существует, мы будем использовать его; в противном случае мы проверим document.attachEvent
или вернемся к событиям DOM 0. Обратите внимание, как мы возвращаем окончательную функцию из IIFE: вот что будет присвоено к Dome.prototype.on
. При выполнении обнаружения функции очень удобно назначать соответствующую функцию, подобную этой, вместо проверки функций при каждом запуске.
Функция off
, которая отцепляет обработчики событий, в значительной степени идентична:
1 |
Dome.prototype.off = (function () { |
2 |
if (document.removeEventListener) { |
3 |
return function (evt, fn) { |
4 |
return this.forEach(function (el) { |
5 |
el.removeEventListener(evt, fn, false); |
6 |
});
|
7 |
};
|
8 |
} else if (document.detachEvent) { |
9 |
return function (evt, fn) { |
10 |
return this.forEach(function (el) { |
11 |
el.detachEvent("on" + evt, fn); |
12 |
});
|
13 |
};
|
14 |
} else { |
15 |
return function (evt, fn) { |
16 |
return this.forEach(function (el) { |
17 |
el["on" + evt] = null; |
18 |
});
|
19 |
};
|
20 |
}
|
21 |
}());
|
Вот оно!
Надеюсь, вы попробуете нашу маленькую библиотеку и, возможно, даже немного ее расширите. Как я уже упоминал ранее, она есть на Github вместе с набором тестов Jasmine для кода, который мы написали выше. Не стесняйтесь форкать ее, поиграть и отправить пул-реквест.
Позвольте мне еще раз пояснить: в этом уроке не имелось в виду, что вы всегда должны писать свои собственные библиотеки.
Есть специальные команды людей, которые работают вместе, чтобы сделать большие библиотеки максимально хорошими. Суть здесь заключалась в том, чтобы заглянуть в то, что может происходить внутри библиотеки; Надеюсь, вы подчерпнули здесь несколько советов.
Я действительно рекомендую вам копаться внутри нескольких ваших любимых библиотек. Вы обнаружите, что они не настолько загадочны, как вы могли подумать, и вы, вероятно, узнаете много всего интересного. Вот несколько отличных мест для начала:
- 10 вещей, которые я узнал из исходников jQuery (автор Paul Irish)
- 11 Больше вещей, которые я узнал из исходников jQuery (также Paul Irish)
- Под капотом jQuery (Джеймс Падольси)
- Backbone.js: Руководство для хакеров, часть 1, часть 2, часть 3, часть 4
- Знать какие-либо другие хорошие разборки библиотек? Давайте посмотрим их в комментариях!