Główną siłą biblioteki jQuery, było to, że poprawiała błędy przeglądarek i różnice w ich API. Ale to już w większości przypadków nie jest potrzebne, ponieważ nowoczesne przeglądarki udostępniają prawie taki samo API. W innych przypadkach jQuery nie poprawia wszystkich błędów, które znajdują się w implementacjach DOM i tak trzeba pisać kod, który łata te bugi. Inną super cechą jQuery jest prostota API, którym można się inspirować, aby pisać własne biblioteki JavaScript.
W tym wpisie przedstawię, jak można zacząć pisać prostą bibliotekę JavaScript do obsługi DOM, która może zastąpić jQuery. Oczywiście tylko wtedy, gdy chcemy pisać aplikację w czystym JS (tzw. Vanilla), a nie w jakimś framework-u architektonicznym jak React, Angular czy Vue.
Konstruktor
Przejdźmy od razu do kodu naszej biblioteki JavaScript.
Pierwsza rzecz to konstruktor, który będzie znajdował i opakowywał elementy na stronie.
function DOM(arg) {
if (!(this instanceof DOM)) { // 1
return new DOM(arg);
}
if (arg instanceof Array) { // 2
this._nodes = arg;
} else if (typeof arg === 'string') { // 3
return this.find(arg);
} else if (arg instanceof Element) { // 4
this._nodes = [arg];
} else {
this._nodes = [];
}
}
W (1) sprawdzamy czy DOM został wywołany jako konstruktor czy jako funkcja, upraszcza to
kod ponieważ nie trzeba pisać new, a zawsze dostaniemy nowy obiekt. W (2) sprawdzamy czy
przekazano tablicę, zakładamy w takim przypadku, że jest to lista elementów DOM. Jeśli
argumentem jest ciąg znaków (3) wywołujemy funkcje find
, która znajdzie element w
drzewie DOM (napiszemy ja za chwilę). Funkcja obsługuje też przypadek, gdy przekażemy do
niej obiekt DOM Node (4).
Prototyp
Następnie zdefiniujemy prototyp naszego konstruktora, jeśli wolisz możesz zastąpić ten kod klasą ES6, ale ja użyje prototypu.
DOM.fn = DOM.prototype = {
find: function(selector) {
if (this._nodes && this._nodes.length) { // 1
var nodes = [];
this._nodes.forEach(function(node) {
nodes = nodes.concat([].slice.call(node.querySelectorAll(selector))); // 2
});
return new DOM(nodes);
}
return new DOM([].slice.call(document.querySelectorAll(selector))); // 3
}
};
Użyłem przypisania DOM.fn
, tak jak to jest w jQuery, aby umożliwić prostsze dodawanie
pluginów. W jQuery są to po prostu funkcje, dodawane do prototypu.
W funkcji find
sprawdzane jest, czy istnieją elementy node, ponieważ można ją wywołać z
konstruktora, zanim zostanie przypisana do nich wartość. Jeśli istnieją to dla każdej
elementu wyszukiwany jest nowy element i dodawany do listy (2). querySelectorAll
zwraca
obiekt typu NodeList
, a my potrzebujemy tablicy, dlatego używamy tricku z
[].slice.call
. Można go zastąpić:
nodes = nodes.concat(...node.querySelectorAll(selector));
ale wtedy kod nie będzie działał w IE i będziemy musieli użyć narzędzia Babel, w celu
konwersji do ES5. Można także użyć funkcji Array.from
.
Jeśli nie ma żadnych elementów czyli używamy np. DOM('body ul');
, to wywoływany jest
querySelectorAll
na obiekcie document
(3).
Obsługa Zdarzeń
Przydała by nam się obsługa zdarzeń (ang. events). Oto prosta funkcja:
DOM.fn = DOM.prototype = {
find: function(selector) { /* kod funkcji */ },
on: function(event_name, fn) {
this._nodes.forEach(function(node) {
node.addEventListener(event_name, fn);
});
return this;
}
};
W funkcji zwracamy this
i tak będzie także z innymi funkcjami. Dzięki temu będziemy
mogli łączyć wywołania funkcji w łańcuchy, tak jak to jest realizowane w przypadku
biblioteki jQuery.
Jak mamy dodawanie zdarzeń, to musimy dodać ich usuwanie:
DOM.fn = DOM.prototype = {
find: function(selector) { /* kod funkcji */ },
on: function(event_name, fn) { /* kod funkcji */ },
off: function(event_name, fn) {
this._nodes.forEach(function(node) {
node.removeEventListener(event_name, fn);
});
return this;
}
};
Funkcje addEventListener
oraz removeEventListener
są dostępne w każdej przeglądarce,
nawet IE9, więc nie trzeba pisać już kodu z attachEvent
dla IE, szczegóły na
MDN.
Dodawanie nowych elementów do drzewa DOM
Teraz fajnie by było móc dodawać nowe elementy do HTML.
DOM.fn = DOM.prototype = {
find: function(selector) { /* kod funkcji */ },
on: function(event_name, fn) { /* kod funkcji */ },
off: function(event_name, fn) { /* kod funkcji */ },
create: function(arg) {
if (typeof arg === 'string') {
if (arg.match(/^\s*<.*>\s*$/)) { // 1
var temp = document.createElement('template');
temp.innerHTML = arg; // 2
var nodes = temp.content ? temp.content.children : temp.childNodes; // 3
return new DOM([].slice.call(nodes)); // 4
} else {
var element = document.createElement(arg); // 5
return new DOM([element]);
}
}
}
};
Najpierw sprawdzamy czy argument wygląda jak html za pomocą wyrażenia regularnego (1). W
przypadku gdy argument to html, używamy innerHTML
, dzięki temu przeglądarka skonwertuje
za nas, dowolny html do elementów DOM (1). temp.content
jest to specjalny obiekt o
nazwie document fragment. W przeglądarce IE nie jest on jednak dostępny, dlatego
sprawdzamy czy istnieje (2). Taki kod temp.childNodes || temp.content.children
nie
zadziała, ponieważ element posiada to pole z obiektu Node
, po którym dziedziczy
(jest w łańcuchu prototypów). Mając utworzone elementy, możemy je skonwertować na tablicę
(3). Jeśli wartością nie jest html, to zakładamy, że to jest nazwa taga html, dlatego
tworzymy nowy element za pomocą funkcji createElement
(3). W obu przypadkach funkcja
zwraca nową instancje obiektu DOM.
Tworzenie elementów DOM
Jedyny problem z tym rozwiązaniem to tworzenie html <td>foo</td>
, w przeglądarce IE, bez
rodzica table
. W tej przeglądarce zostanie utworzony sam tekst bez elementu, tak jest z
każdym elementem który powinien być w tabeli. Aby to rozwiązać biblioteka jQuery tworzy
wrapper czyli <table><tbody><tr>
dla td
. Rozwiązanie tego problemu wygląda tak:
DOM.fn = DOM.prototype = {
find: function(selector) { /* kod funkcji */ },
on: function(event_name, fn) { /* kod funkcji */ },
off: function(event_name, fn) { /* kod funkcji */ },
create: function(arg) {
if (typeof arg === 'string') {
var elements;
if (arg.match(/^\s*<.*>\s*$/)) {
var temp = document.createElement('template');
if (temp.content == void 0) { // ie11
if (/^[^\S]*?<(t(?:head|body|foot|r|d|h))/i.test(arg)) {
temp.innerHTML = '<table>'+arg;
elements = temp.querySelector(RegExp.$1).parentNode.childNodes;
} else {
temp.innerHTML = arg;
elements = temp.childNodes;
}
} else {
temp.innerHTML = arg;
elements = temp.content.children;
}
elements = [].slice.call(elements);
} else {
elements = [document.createElement(arg)];
}
return new DOM(elements);
}
}
};
Rozwiązanie bazuje na odpowiedzi ze StackOverflow.
Mając taką funkcje create
można sprawdzać w konstruktorze, czy ciąg znaków wygląda jak
html i oddelegowywać robotę do create
, tak jak to robi jQuery.
function DOM(arg) {
if (!(this instanceof DOM)) {
return new DOM(arg);
}
if (arg instanceof Array) {
this._nodes = arg;
} else if (typeof arg === 'string') {
if (arg.match(/^\s*<.*>\s*$/)) {
return this.create(arg);
}
return this.find(arg);
} else if (arg instanceof Element) {
this._nodes = [arg];
} else {
this._nodes = [];
}
}
Metody statyczne
Funkcje find oraz create zwracają nowy obiekt DOM i można je używać bez obiektu,
zakładając że nie używamy strict mode lub gdy nie mamy w obiekcie window
zmiennej
_nodes
. Dlatego można je dodać do funkcji DOM, będą mogły być używane, jak metody
statyczne.
['find', 'create'].forEach(function(fn) {
DOM[fn] = DOM.fn[fn];
});
Jeśli boli cię, że funkcja find zawiera odwołanie do this
, które nie zadziała w strict
mode, to możesz poprawić ją w sposób, w jaki utworzyliśmy konstruktor biblioteki.
DOM.fn = DOM.prototype = {
find: function(selector) {
// dodane this instanceof DOM
if (this instanceof DOM && this._nodes && this._nodes.length) { // 1
var nodes = [];
this._nodes.forEach(function(node) {
nodes = nodes.concat([].slice.call(node.querySelectorAll(selector))); // 2
});
return new DOM(nodes);
}
return new DOM([].slice.call(document.querySelectorAll(selector))); // 3
},
on: function(event_name, fn) { /* kod funkcji */ },
off: function(event_name, fn) { /* kod funkcji */ },
create: function(arg) { /* kod funkcji */ }
};
Funkcja html
Kiedy mamy funkcje create
i chcemy utworzyć tag html za pomocą nazwy taga, to może nam się
przydać funkcja html
.
DOM.fn = DOM.prototype = {
find: function(selector) { /* kod funkcji */ },
on: function(event_name, fn) { /* kod funkcji */ },
off: function(event_name, fn) { /* kod funkcji */ },
create: function(arg) { /* kod funkcji */ },
html: function(html) {
if (typeof html === 'undefined') {
return this._nodes[0].innerHTML;
}
this._nodes.forEach(function(node) {
node.innerHTML = html;
});
return this;
}
};
Teraz możemy użyć np.:
DOM.create('li').html('Hello')
Dodawanie do drzewa
Następnym krokiem, jest dodanie utworzonego elementu do drzewa, a oto dwie funkcje znane z jQuery:
DOM.fn = DOM.prototype = {
find: function(selector) { /* kod funkcji */ },
on: function(event_name, fn) { /* kod funkcji */ },
off: function(event_name, fn) { /* kod funkcji */ },
create: function(arg) { /* kod funkcji */ },
html: function(html) { /* kod funkcji */ },
append: function(arg) {
if (arg instanceof DOM) {
this._nodes.forEach(function(node) {
arg._nodes.forEach(function(arg) {
node.appendChild(arg);
});
});
}
return this;
},
appendTo: function(arg) {
DOM(arg).append(this);
return this;
}
};
Teraz możemy użyć:
DOM('body').find('ul').on('click', function(e) {
var color = e.target.style.color == 'red' ? 'black' : 'red';
e.target.style.color = color;
}).append(DOM.create('li').html('Hello'));
Delegacja zdarzeń
Nasz mechanizm zdarzeń, niestety nie działa dla nowych elementów, ponieważ zdarzenia są dodawane bezpośrednio do elementu. Aby rozwiązać ten problem, musimy zmodyfikować naszą obsługę zdarzeń, aby uzyskać ich delegacje.
DOM.fn = DOM.prototype = {
find: function(selector) { /* kod funkcji */ },
on: function(event_name, fn) {
if (typeof arguments[1] === 'string') {
var selector = arguments[1]; // 1
fn = arguments[2];
}
this._nodes.forEach(function(node) {
if (selector) {
if (!node._delegate) {
node._delegate = {
handler: function(event) { // 2
node._delegate.callbacks.forEach(function(callback) {
// msMatchesSelector dostępne od IE9
var element = event.target;
var matches = (element.msMatchesSelector || element.matches).bind(element);
if (matches(callback.selector)) { // 3
callback.fn.call(null, event);
}
});
},
callbacks: [{fn: fn, selector: selector}] // 4
};
node.addEventListener(event_name, node._delegate.handler); // 5
} else {
node._delegate.callback.push({fn: fn, selector: selector}); // 6
}
} else {
node.addEventListener(event_name, fn); // 7
}
});
return this;
},
off: function(event_name, fn) { /* kod funkcji */ },
create: function(arg) { /* kod funkcji */ },
html: function(html) { /* kod funkcji */ },
append: function(arg) { /* kod funkcji */ },
appendTo: function(arg) { /* kod funkcji */ },
};
W funkcji mamy podobny mechanizm jak w jQuery, gdy drugi argument to ciąg znaków,
zakładamy, że jest to selektor CSS (1). Gdy mamy selektor dla każdego elementu w tablicy
_nodes
tworzymy nowe pole _delegate
, które zawiera handler
, czyli naszą funkcję
obsługi zdarzenia (2), którą dodajemy tylko raz (5). Do pola _delegate
, dodajemy także
tablicę obiektów callbacks (4), która zawiera selektor oraz funkcję. Wywołujemy ją gdy
selektor pasuje do elementu, dla którego odpaliło się zdarzenie (3). Jeśli _delegate
jest zdefiniowane, czyli mamy już podpiętego obserwatora zdarzenia, możemy po prostu dodać
nową funkcję do tablicy callbacks, wraz z selektorem (6). Jeśli nie ma selektora tzn. że
to zwykły przypadek zdarzenia do elementu (7).
Musimy także zmodyfikować naszą funkcje do usuwania zdarzeń.
DOM.fn = DOM.prototype = {
find: function(selector) { /* kod funkcji */ },
on: function(event_name, fn) { /* kod funkcji */ },
off: function(event_name, fn) {
if (typeof arguments[1] === 'string') {
var selector = arguments[1];
fn = arguments[2];
}
this._nodes.forEach(function(node) {
if (selector && node._delegate) {
if (fn) {
node._delegate.callbacks = node._delegate.filter(function(callback) {
return callback.fn !== fn; // 1
});
} else {
node._delegate.callbacks = node._delegate.filter(function(callback) {
return callback.selector !== selector; // 2
});
}
if (!node._delegate.callbacks.length) {
node.removeEventListener(event_name, node._delegate.handler); // 3
delete node._delegate;
}
} else {
node.removeEventListener(event_name, fn); // 4
}
});
return this;
},
create: function(arg) { /* kod funkcji */ },
html: function(html) { /* kod funkcji */ },
append: function(arg) { /* kod funkcji */ },
appendTo: function(arg) { /* kod funkcji */ },
};
Usuwając zdarzenie musimy sprawdzić, czy ustawiono selektor, funkcje oraz pole
_delegate
. Jeśli ten warunek jest spełniony, usuwamy obiekt z tablicy callbacks
, za
pomocą metody filter
dla tablic, sprawdzając czy nie jest to funkcja, którą chcemy usunąć
(1). Jeśli nie mamy funkcji sprawdzamy selektor (2). Jeśli nie ma już nic w tablicy
callbacks
, usuwamy zdarzenie oraz zmienną (3). W przypadku gdy nie mamy selektora tzn. że
mamy zwykłe zdarzenie, więc usuwamy zdarzenie w zwykły sposób (4).
Nasze użycie biblioteki, czyli zdarzenie click
, będzie działać z nowymi elementami:
DOM('body').find('ul').on('click', function(e) {
var color = e.target.style.color == 'red' ? 'black' : 'red';
e.target.style.color = color;
}).append(DOM.create('li').html('Hello'));
Obsługa stylów
Użycie style trochę źle wygląda, napiszmy funkcje do zmieniania css.
DOM.fn = DOM.prototype = {
find: function(selector) { /* kod funkcji */ },
on: function(event_name, fn) { /* kod funkcji */ },
off: function(event_name, fn) { /* kod funkcji */ },
create: function(arg) { /* kod funkcji */ },
html: function(html) { /* kod funkcji */ },
append: function(arg) { /* kod funkcji */ },
appendTo: function(arg) { /* kod funkcji */ },
css: function(arg, value) {
var self = this;
if (typeof arg === 'string') {
if (typeof value !== 'undefined') {
this._nodes.forEach(function(node) {
node.style[arg] = value;
});
} else if (this._nodes.length) {
return this._nodes[0].style[arg];
}
} else if (typeof arg === 'object' && arg !== null) {
Object.keys(arg).forEach(function(key) {
self.css(key, arg[key]);
});
} else
return this;
}
};
Funkcja używa tylko właściwości style, jeśli chcemy aby pobierała też wartości z arkuszy stylów, należy użyć funkcji getComputedStyle.
Funkcja nie obsługuje też zmiennych css (ang. Custom Properties), aby je pobierać i zapisywać najlepiej użyć:
if (typeof value !== 'undefined') {
this._nodes.forEach(function(node) {
node.style.setProperty(arg, value);
});
} else if (this._nodes.length) {
var style = getComputedStyle(this._nodes[0]);
return style.getPropertyValue(arg);
}
Będzie to uniwersalne i działało z normalnymi właściwościami jak i ze zmiennymi css. (dla
porównania funkcja css()
w bibliotece jQuery nie działała, ze zmiennymi CSS
aż do wersji bodajże 3.4). To rozwiązanie spowoduje inny problem, a mianowicie kolory,
będą konwertowane do znormalizowanej postaci (może być problematyczne porównywanie z przypisaną
wartością jak w naszym przykładzie). W przypadku Google Chrome będzie to ciąg znaków
rgb(255, 0, 0)
dla koloru czerwonego. Rozwiązanie tego problemu zostawiam czytelnikowi.
Warto też obsłużyć mapowanie stylów, aby można było pobierać i przypisywać
np. ’padding-left’, który ma swój odpowiednik w style jako paddingLeft
. Aby zrobić
poprawkę dla funkcji css
, warto sprawdzić jak jQuery to obsługuje, a używa modułu
core/camelCase.
Drugą rzecz, jaką można poprawić, to sprawdzać, czy jest przypisywana liczba. Wtedy trzeba
zamienić ją na ciąg znaków i dodać px
na końcu (node.style.paddingLeft = 10;
nie
zadziała). W bibliotece jQuery, są też wyjątki takie jak np. zIndex
. Lista znajduje się
kodzie źródłowym jQuery
w pliku css.js linia 190.
Pluginy
Teraz czas na prosty plugin:
DOM.fn.color = function(arg) {
return this.css('color', arg);
};
dzięki temu pluginowi możemy zmienić nasz kod obsługi click
:
DOM('body').find('ul').on('click', 'li', function(e) {
var self = DOM(e.target);
self.color(self.color() == 'red' ? 'black' : 'red');
});
Właściwie już z funkcją css
, mogliśmy mieć praktycznie to samo, ale to jest tylko
przykład użycia pluginu.
Usuwanie elementów z drzewa DOM
Ostatnia funkcja, jaka została do dodania, to remove
:
DOM.fn = DOM.prototype = {
find: function(selector) { /* kod funkcji */ },
on: function(event_name, fn) { /* kod funkcji */ },
off: function(event_name, fn) { /* kod funkcji */ },
create: function(arg) { /* kod funkcji */ },
html: function(html) { /* kod funkcji */ },
append: function(arg) { /* kod funkcji */ },
appendTo: function(arg) { /* kod funkcji */ },
css: function(arg, value) { /* kod funkcji */ },
remove: function() {
this._nodes.forEach(function(node) {
node.parentNode.removeChild(node);
});
return this;
}
};
Zamiast usuwania za pomocą removeChild
dla rodzica, można użyć
ChildNode.remove().
Niestety funkcja jest niedostępna w IE, dlatego użyłem removeChild
.
Można teraz wywołać taki kod:
DOM('body').find('ul').on('click', 'li', function(e) {
var self = DOM(e.target);
self.color(self.color() == 'red' ? 'black' : 'red');
}).append(DOM.create('li').html('Hello')).find('li:nth-child(1)').remove();
DOM('<div><p>Hello</p></div><div><p>Hello</p></div>').appendTo('body').css('color', 'red');
Jeśli mamy taki html:
<body>
<ul>
<li>Foo</li>
<li>Bar</li>
</ul>
</body>
To po odpaleniu tego kodu, będziemy mieli taką strukturę (a dokładnie jej odpowiednik w drzewie DOM):
<body>
<ul>
<li>Bar</li>
<li>Hello</li>
</ul>
<div style="color: red;">
<p>Hello</p>
</div>
<div style="color: red;">
<p>Hello</p>
</div>
</body>
Jeśli klikniemy na którymś z elementów li, dostanie on atrybut style="color: red"
, jeśli
nie użyto funkcji getComputedStyle
, lub style="color: rgb(255,0,0)"
w przypadku gdy
jej użyto.
Podsumowanie
Można powiedzieć, że podstawa biblioteki jest gotowa, ale na pewno brakuje wielu
funkcji. Najlepiej użyć tej biblioteki w jakimś projekcie i dodawać nowe funkcje jak będą
potrzebne. Ale nie powinno się przesadzać z dodawaniem nowych ficzerów do
biblioteki. Najlepsze są takie, które mają proste API. Można dodać nowy plik z pluginami i
jak jakiś będzie używany więcej niż 4 razy, to dodać go do biblioteki. Tak postępowali z
jQuery. Można się także sugerować funkcjami, które są w jQuery, których nie jest tak
dużo. Niektórych API z jQuery nie potrzebujmy, np. ajax można zastąpić funkcją fetch
,
która jest dostępna w 87% przeglądarek, a gdy
potrzebujemy 100% można użyć polyfill np.
bibliteka unfetch, której autorem jest Jason
Miller autor biblioteki Preact, uproszczonej wersji React.js.
Komentarze
Hasło, które podasz umożliwi ponowne zalogowanie się i np. usunięcie komentarza, jest dobrowolne. Email jest szyfrowany i używany do wysyłania powiadomień o odpowiedziach do komentarzy oraz do pobierania awatara dzięki usłudze Gravatar.com.