Розробка

Як зробити пошук користувачів з Github використовуючи VanillaJS

Доброго дня. Мене звуть Олександр і я Vanilla ES5.1 розробник у 2018 році.

 

Дана стаття є відповіддю на статтю-відповідь «Як зробити пошук користувачів з GitHub без React + RxJS 6 + Recompose», яка показала нам, як можна використовувати SvelteJS.

 

Пропоную подивитися на один з варіантів, як це можна реалізувати не використовуючи ніяких залежностей, крім браузера. Тим більше, що сам GitHub заявив, що вони розробляють фронтенд без фреймворків.

 

Робитимемо все той же инпут, що відображає плашку GitHub-користувача:

 

Disclaimer

Дана стаття ігнорує абсолютно всі можливі практики сучасного джаваскрипта і веб-розробки.

 

Підготовка

 

Що-небудь налаштовувати і писати конфіги нам не потрібно, створимо index.html з усієї необхідної версткою:

index.html

<!doctype html>
<html>
<head>
 <meta charset='utf-8'>
 <title>GitHub users</title>

 <link rel='stylesheet' type='text/css' href='index.css'>
</head>
<body>

<div id='root'></div>

<div id='templates' style='display:none;'>
 <div data-template-id='username_input'>
 <input type='text' data-onedit='onNameEdit' placeholder='GitHub username'>
</div>

 <div data-template-id='usercard' class='x-user-card'>
 <div class='background'></div>
 <div class='avatar-container'>
 <a class='avatar' data href='userUrl'>
 <img data-src='avatarImageUrl'>
</a>
</div>
 <div class='name' data-text='userName'></div>
 <div class='content'>
 <a class='block' data href='reposUrl'>
 <b data-text='reposCount'></b>
<span>Repos</span>
</a>
 <a class='block' data href='gistsUrl'>
 <b data-text='gistsCount'></b>
<span>Gists</span>
</a>
 <a class='block' data href='followersUrl'>
 <b data-text='followersCount'></b>
<span>Followers</span>
</a>
</div>
</div>

 <div data-template-id='error'><b data-text='status'></b>: <span data-text='text'></span></div>
 <div data-template-id='loading'>Loading...</div>
</div>

</body>
</html>

 

Якщо кому-небудь цікавий CSS, його можна подивитися в репозиторії.

 

Стилі у нас самі звичайні, ніяких css-modules та іншого scope’інга. Ми просто помічаємо компоненти класами починаються з x- і гарантуємо, що більше в проекті таких не буде. Будь-які селектори пишемо щодо них.

 

Поле введення

 

Все, що ми хочемо від нашого поля вводу — debounced-подій його зміни, а так само подія початку введення, щоб за нього відразу показувати індикацію завантаження. Виходить ось так:

 

in_package('GitHubUsers', function() {

this.provide('UserNameInput', UserNameInput);
function UserNameInput(options) {
 var onNameInput = options.onNameInput,
 onNameChange = options.onNameChange;

 var element = GitHubUsers.Dom.instantiateTemplate('username_input');

 var debouncedChange = GitHubUsers.Util.delay(1000, function() {
onNameChange(this.value);
});

 GitHubUsers.Dom.binding(element, {
 onNameEdit: function() {
onNameInput(this.value);

 debouncedChange.apply(this, arguments);
}
});

 this.getElement = function() { return element; };
}

});

 

Тут ми заюзали трохи утилітарних функцій, пройдемося по них:

 

Так як у нас немає webpack, немає CommonJS, немає RequireJS, ми все складаємо в об’єкти за допомогою наступної функції:

packages.js

window.in_package = function(path, fun) {
 path = path.split('.');

 var obj = path.reduce(function(acc, p) {
 var o = acc[p];

 if (!o) {
 o = {};
 acc[p] = o;
}

 return o;
 }, window);

fun.call({
 provide: function(name, value) {
 obj[name] = value;
}
});
};

 

Функція instantiateTemplate() видає нам глибоку копію DOM-елемента, які будуть отримані функцією consumeTemplates() з елемента #templates в нашому index.html.

templates.js

in_package('GitHubUsers.Dom', function() {

var templatesMap = new Map();

this.provide('consumeTemplates', function(containerEl) {
 var templates = containerEl.querySelectorAll('[data-template-id]');

 for (var i = 0; i < templates.length; i++) {
 var templateEl = templates[i],
 templateId = templateEl.getAttribute('data-template-id');

 templatesMap.set(templateId, templateEl);

templateEl.parentNode.removeChild(templateEl);
}

 if (containerEl.parentNode) containerEl.parentNode.removeChild(containerEl);
});

this.provide('instantiateTemplate', function(templateId) {
 var templateEl = templatesMap.get(templateId);

 return templateEl.cloneNode(true);
});

});

 

Функція Dom.binding() приймає елемент, опції, шукає певні data-атрибут і вчиняє з елементами потрібні нам дії. Наприклад, для атрибуту data element вона додає поле до результату з посиланням на позначений елемент, для атрибуту data-onedit навішує на елемент обробники keyup та change з хендлером опцій.

binding.js

in_package('GitHubUsers.Dom', function() {

this.provide('binding', function(element, options) {
 options = options || {};

 var binding = {};

 handleAttribute('data element', function(el, name) {
 binding[name] = el;
});

 handleAttribute('data-text', function(el, key) {
 var text = options[key];
 if (typeof text !== 'string' && typeof text !== 'number') return;

 el.innerText = text;
});

 handleAttribute('data-src', function(el, key) {
 var src = options[key];
 if (typeof src !== 'string') return;

 el.src = src;
});

 handleAttribute('data href', function(el, key) {
 var href = options[key];
 if (typeof href !== 'string') return;

 el.href = href;
});

 handleAttribute('data-onedit', function(el, key) {
 var handler = options[key];
 if (typeof handler !== 'function') return;

 el.addEventListener. ('keyup', handler);
 el.addEventListener. ('change', handler);
});

 function handleAttribute(attribute, fun) {
 var elements = element.querySelectorAll('[' + attribute + ']');
 for (var i = 0; i < elements.length; i++) {
 var el = elements[i],
 attributeValue = el.getAttribute(attribute);

 fun(el, attributeValue);
}
}

 return binding;
});

});

 

Ну і delay займається потрібною нам видом debounce’а:

debounce.js

in_package('GitHubUsers.Util', function() {

this.provide('delay', function(timeout, fun) {
 var timeoutId = 0;

 return function() {
 var that = this,
 args = arguments;

 if (timeoutId) clearTimeout(timeoutId);

 timeoutId = setTimeout(function() {
 timeoutId = 0;

 fun.apply(that, args);
 }, timeout);
};
});

});

 

Картка користувача

 

У неї немає логіки, лише шаблон, який наповнюється даними:

 

in_package('GitHubUsers', function() {

this.provide('UserCard', UserCard);
function UserCard() {
 var element = GitHubUsers.Dom.instantiateTemplate('usercard');

 this.getElement = function() { return element; };

 this.setData = function(data) {
 GitHubUsers.Dom.binding(element, data);
};
}

});

 

Звичайно, робити стільки querySelectorAll кожен раз, коли ми міняємо дані не дуже добре, але воно працює і ми миримося з цим. Якщо раптом з’ясується, що з-за цього у нас все гальмує — будемо писати дані в збережені data element. Або зробимо іншу функцію биндинга, яка сама зберігає елементи і може підчитати нові дані. Або зробимо підтримку передачі в об’єкт опцій не просто статичних значень, потік їх змін, щоб биндинг міг за ними стежити.

 

Індикація завантаження / помилки запиту

 

Ми припускаємо, що ці вистави будуть статичні, будуть використовуватися тільки в одному місці і шанси, що у них з’явиться своя логіка вкрай малі (на відміну від картки користувача), тому для них ми не будемо робити окремі компоненти. Вони будуть просто шаблонами для компонента.

 

Запит даних

 

Зробимо клас з методом запиту користувача, в разі чого ми зможемо легко підмінити його примірник ншою реалізацією:

 

in_package('GitHubUsers', function() {

this.provide('GitHubApi', GitHubApi);
function GitHubApi() {
 this.getUser = function(options, callback) {
 var url = 'https://api.github.com/users/' + options.userName;

 return GitHubUsers.Http.doRequest(url, function(error, data) {
 if (error) {
 if (error.type === 'not200') {
 if (error.status === 404) callback(null, null);
 else callback({ status: error.status, message: data && data.message });
 } else {
callback(error);
}
return;
}

 // TODO: validate `data` against schema
 callback(null, data);
});
};
}

});

 

Звичайно, нам буде потрібно обгортка над XMLHttpRequest. Ми не використовуємо fetch тому що він не підтримує запитів переривання, а так само не хочемо зв’язуватися з промисами з тієї ж причини.

ajax.js

in_package('GitHubUsers.Http', function() {

this.provide('doRequest', function(options, callback) {
 var url;

 if (typeof options === "string") {
 url = options;
 options = {};
 } else {
 if (!options) options = {};
 url = options.url;
}

 var method = options.method || "GET",
 headers = options.headers || [],
 body = options.body,
 dataType = options.dataType || "json",
 timeout = options.timeout || 10000;

 var old_callback = callback;
 callback = function() {
 callback = function(){}; // ignore all non-first calls
 old_callback.apply(this, arguments);
};

 var isAborted = false;

 var request = new XMLHttpRequest();

 // force timeout
 var timeoutId = setTimeout(function() {
 timeoutId = 0;
 if (!isAborted) { request.abort(); isAborted = true; }
 callback({msg: "fetch_timeout", request: request, opts: options});
 }, timeout);

 request.addEventListener. ("load", function() {
 var error = null;

 if (request.status !== 200) {
 error = { type: 'not200', status: request.status };
}

 if (typeof request.responseText === "string") {
 if (dataType !== "json") {
 callback(error, request.responseText);
return;
}

 var parsed;

 try {
 parsed = JSON.parse(request.responseText);
 } catch (e) {
callback(e);
return;
}

 if (parsed) {
 callback(error, parsed);
 } else {
 callback({msg: "bad response", request: request});
}
 } else {
 callback({msg: "no response text", request: request});
}
});
 request.addEventListener. ("error", function() {
 callback({msg: "request_error", request: request});
});

 request.open(method, url, true /*async*/);

 request.timeout = timeout;
 request.responseType = "";

 headers.forEach(function(header) {
 try {
 request.setRequestHeader(header[0], header[1]);
 } catch (e) {}
});

 try {
 if (body) request.send(body);
 else request.send();
 } catch (e) {
 callback({exception: e, type: 'send'});
}

 return {
 cancel: function() {
 if (!isAborted) { request.abort(); isAborted = true; }

 if (timeoutId) { clearTimeout(timeoutId); timeoutId = 0; }
}
};
});

});

 

Підсумкове додаток

app.js

in_package('GitHubUsers', function() {

this.provide('App', App);
function App(options) {
 var api = options.api;

 var element = document.createElement('div');

 // Create needed components
 var userNameInput = new GitHubUsers.UserNameInput({
 onNameInput: onNameInput,
 onNameChange: onNameChange
});

 var userCard = new GitHubUsers.UserCard();

 var errorElement = GitHubUsers.Dom.instantiateTemplate('error');

 var displayElements = [
 { type: 'loading', element: GitHubUsers.Dom.instantiateTemplate('loading') },
 { type: 'error', element: errorElement },
 { type: 'userCard', element: userCard.getElement() }
];

 // Append elements to DOM
element.appendChild(userNameInput.getElement());
 userNameInput.getElement().style.marginBottom = '1em'; // HACK

 displayElements.forEach(function(x) {
 var el = x.element;
 el.style.display = 'none';
element.appendChild(el);
});

 var contentElements = new GitHubUsers.DomUtil.DisplayOneOf({ items: displayElements });

 // User name processing
 var activeRequest = null;

 function onNameInput(name) {
 name = name.trim();

 // Instant display of `loading` or current request result
 if (activeRequest && activeRequest.name === name) {
activeRequest.activateState();
 } else if (name) {
contentElements.showByType('loading');
 } else {
contentElements.showByType(null);
}
}

 function onNameChange(name) {
 name = name.trim();

 // Cancel old request
 if (activeRequest && activeRequest.name !== name) {
activeRequest.request.cancel();
 activeRequest = null;
 } else if (activeRequest) { // same name
return;
}

 if (!name) return;

 // Do request new
 activeRequest = {
 name: name,
 request: api.getUser({ userName: name }, onUserData),

 // method for `onNameInput`
 activateState: function() {
contentElements.showByType('loading');
}
};

activeRequest.activateState();

 function onUserData(error, data) {
 if (error) {
 activeRequest = null;
contentElements.showByType('error');
 GitHubUsers.Dom.binding(errorElement, {
 status: error.status,
 text: error.message
});
return;
}

 if (!data) {
 activeRequest.activateState = function() {
 GitHubUsers.Dom.binding(errorElement, {
 status: 404,
 text: 'Not found'
});
contentElements.showByType('error');
};
activeRequest.activateState();
return;
}

 activeRequest.activateState = function() {
userCard.setData({
 userName: data.name || data.login, // `data.name` can be `null`
 userUrl: data.html_url,
 avatarImageUrl: data.avatar_url + '&s=80',

 reposCount: data.public_repos,
 reposUrl: 'https://github.com/' + data.login + '?tab=repositories',

 gistsCount: data.public_gists,
 gistsUrl: 'https://gist.github.com/' + data.login,

 followersCount: data.followers,
 followersUrl: 'https://github.com/' + data.login + '/followers'
});

contentElements.showByType('userCard');
};

activeRequest.activateState();
}
}

 this.getElement = function() { return element; };
}

});

 

У нас вийшло досить багато коду, половина з якого займають ініціалізації всіх потрібних нам компонентів, половина — логіка відправки запитів і відображення завантаження/помилки/результату. Але все абсолютно прозоро, очевидно і ми можемо змінити логіку в будь-якому місці, якщо це буде потрібно.

 

Ми використовували допоміжну утилітку DisplayOneOf, яка показує один елемент із заданих, решту ховає:

dom-util.js

in_package('GitHubUsers.DomUtil', function() {

this.provide('DisplayOneOf', function(options) {
 var items = options.items;

 var obj = {};

 items.forEach(function(item) { obj[item.type] = item; });

 var lastDisplayed = null;

 this.showByType = function(type) {
 if (lastDisplayed) {
 lastDisplayed.element.style.display = 'none';
}

 if (!type) {
 lastDisplayed = null;
return;
}

 lastDisplayed = obj[type];

 lastDisplayed.element.style.display = ";
};
});

});

 

Щоб у підсумку це все запрацювало, нам потрібно ініціалізувати шаблони і кинути примірник App на сторінку:

 

function onReady() {
GitHubUsers.Dom.consumeTemplates(document.getElementById('templates'));

 var rootEl = document.getElementById('root');

 var app = new GitHubUsers.App({
 api: new GitHubUsers.GitHubApi()
});

rootEl.appendChild(app.getElement());
}

 

Результат?

 

Як ви самі бачите, ми написали дуже багато коду для такого невеликого прикладу. Ніхто не робить за нас всю магію, ми добиваємося самі. Ми самі творимо магію, яка нам потрібна, якщо ми хочемо її.

 

→ Демо → Код

 

Пишіть тупий і вічний код. Пишіть без фреймворків, а значить міцніше спите і не бійтеся, що завтра весь ваш код виявиться deprecated або недостатньо модним.

 

Що далі?

 

Цей приклад занадто малий, щоб писати його на VanillaJS в принципі. Я вважаю, що писати на ванилле має сенс тільки якщо ваш проект планує жити набагато довше, ніж будь-який з фреймворків і у вас не буде ресурсів, щоб переписати його цілком.

 

Але якщо б він все-таки був більше, ось що ми б зробили ще:

 

HTML-шаблони ми б робили щодо модулів/компонентів. Вони б лежали в папках з компонентами і instantiateTemplate брав би ім’я модуля плюс ім’я шаблону, а не тільки глобальне ім’я.

 

У даний момент весь CSS у нас лежить в index.css, його, очевидно, теж потрібно класти поруч з компонентами.

 

Не вистачає складання бандлів, ми підключаємо всі файли руками index.htmlце недобре.

 

Немає проблем написати скрипт, який за списками модулів, які повинні входити в бандли збере весь php, html, css цих модулів і зробить нам по одному js’ніку для кожного пакету. Це буде на порядок тупіше і простіше, ніж налаштовувати webpack, а через рік дізнатися, що там вже зовсім інша версія і вам потрібно переписувати конфіг і використовувати інші завантажувачі.

 

Бажано мати якийсь прапор, який би підтримував схему підключення php/html/css величезним списком index.html. Тоді не буде жодних затримок на збірку, а в Sources в хромі у вас кожен файл буде в окремій вкладці і ніякі sourcemap’и не потрібні.

 

P. S.

 

Це лише один з варіантів, як воно все може бути використовуючи VanillaJS. У коментарях було б цікаво почути про інших варіантах використання.

 

Дякую за увагу.

Related Articles

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *

Close