Хабрахабр

Как сделать поиск пользователей по 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, его можно посмотреть в репозитории.

Мы просто помечаем компоненты классами начинающимися с x- и гарантируем, что больше в проекте таких не будет. Стили у нас самые обычные, никаких css-modules и прочего scope'инга. Любые селекторы пишем относительно них.

Поле ввода

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

in_package('GitHubUsers', function() { this.provide('UserNameInput', UserNameInput);
function UserNameInput(options) ); 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);
}); });

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

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); };
} });

Если вдруг выяснится, что из-за этого у нас всё тормозит — будем писать данные в сохранённые data-element. Конечно, делать столько querySelectorAll каждый раз, когда мы меняем данные не очень хорошо, но оно работает и мы миримся с этим. Или сделаем поддержку передачи в объект опций не просто статичных значений, поток их изменений, чтобы биндинг мог за ними следить. Или сделаем другую функцию биндинга, которая сама сохраняет элементы и может подчитать новые данные.

Индикация загрузки / ошибки запроса

Они будут просто шаблонами для компонента приложения. Мы предполагаем, что эти представления так же будут статичны, использоваться будут только в одном месте и шансы, что у них появится своя логика крайне малы (в отличие от карточки пользователя), поэтому для них мы не будем делать отдельные компоненты.

Запрос данных

Сделаем класс с методом запроса пользователя, в случае чего мы сможем легко подменить его экземпляр моком/другой реализацией:

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); }); };
} });

Мы не используем fetch потому что он не поддерживает прерывания запросов, а так же не хотим связываться с промисами по той же причине. Конечно, нам потребуется обёртка над XMLHttpRequest.

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 new request 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 в принципе.

Но если бы он всё-таки был больше, вот что мы бы сделали ещё:

Они бы лежали в папках с компонентами и instantiateTemplate принимал бы имя модуля плюс имя шаблона, а не только глобальное имя. HTML-шаблоны мы бы делали относительно модулей/компонентов.

В данный момент весь CSS у нас лежит в index.css, его, очевидно, тоже нужно класть рядом с компонентами.

Не хватает сборки бандлов, мы подключаем все файлы руками в index.html, это нехорошо.

Это будет на порядок тупее и проще, чем настраивать webpack, а через год узнать, что там уже совершенно другая версия и вам нужно переписывать конфиг и использовать другие загрузчики. Нет проблем написать скрипт, который по спискам модулей, которые должны входить в бандлы соберёт весь js, html, css этих модулей и сделает нам по одному js'нику для каждого бандла.

Тогда не будет никаких задержек на сборку, а в Sources в хроме у вас каждый файл будет в отдельной вкладке и никакие sourcemap'ы не нужны. Желательно иметь какой-нибудь флаг, который бы поддерживал схему подключения js/html/css громадным списком в index.html.

P.S.

В комментариях было бы интересно услышать о других вариантах использования. Это лишь один из вариантов, как оно всё может быть используя VanillaJS.

Спасибо за внимание.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть