How to search for users on Github using VanillaJS
Hello. My name is Alexander and I am Vanilla ES5.1 developer in 2018.
This article is a response to the article-response “How to search for users on GitHub without React + RxJS 6 + Recompose” , which showed us how to use SvelteJS.
I suggest looking at one of the options for how this can be implemented without using any dependencies other than the browser. Moreover, GitHub itself said that they are developing a frontend without frameworks .
We will do the same input displaying the GitHub user dashboard:
This article ignores absolutely all possible practices of modern javascript and web development.
Training
We don’t need to configure and write configs, create an index.html with all the necessary layout:
<!doctype html><html><head><metacharset='utf-8'><title>GitHub users</title><linkrel='stylesheet'type='text/css'href='index.css'></head><body><divid='root'></div><divid='templates'style='display:none;'><divdata-template-id='username_input'><inputtype='text'data-onedit='onNameEdit'placeholder='GitHub username'></div><divdata-template-id='usercard'class='x-user-card'><divclass='background'></div><divclass='avatar-container'><aclass='avatar'data-href='userUrl'><imgdata-src='avatarImageUrl'></a></div><divclass='name'data-text='userName'></div><divclass='content'><aclass='block'data-href='reposUrl'><bdata-text='reposCount'></b><span>Repos</span></a><aclass='block'data-href='gistsUrl'><bdata-text='gistsCount'></b><span>Gists</span></a><aclass='block'data-href='followersUrl'><bdata-text='followersCount'></b><span>Followers</span></a></div></div><divdata-template-id='error'><bdata-text='status'></b>: <spandata-text='text'></span></div><divdata-template-id='loading'>Loading...</div></div></body></html>
If anyone is interested in CSS , it can be viewed in the repository .
We have the most common styles, no css-modules and other scope'ing. We simply mark the components with classes starting with x- and guarantee that there will be no more such in the project. Any selectors write about them.
Entry field
All that we want from our input field is the debounce-events of its change, as well as the event of the beginning of the input, so that it immediately shows the indication of the load. It turns out like this:
in_package('GitHubUsers', function() {
this.provide('UserNameInput', UserNameInput);
functionUserNameInput(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; };
}
});
Here we have used a few utilitarian functions, let's go over them:
Since we do not have webpack
, no CommonJS
, no RequireJS
, we add everything to objects using the following function:
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;
}
});
};
The function instantiateTemplate()
gives us a deep copy of the DOM element that will be obtained by the function consumeTemplates()
from the element #templates
in ours index.html
.
in_package('GitHubUsers.Dom', function() {
var templatesMap = newMap();
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);
});
});
The function Dom.binding()
takes an element, an option, searches for certain data-attributes and performs the actions we need with the elements. For example, for an attribute, data-element
it adds a field to the result with reference to the marked element, for an attribute, it hooks data-onedit
handlers to the element keyup
and change
with a handle from the options.
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);
});
functionhandleAttribute(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;
});
});
Well, delay
we deal with the kind of debounce we need:
in_package('GitHubUsers.Util', function() {
this.provide('delay', function(timeout, fun) {
var timeoutId = 0;
returnfunction() {
var that = this,
args = arguments;
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(function() {
timeoutId = 0;
fun.apply(that, args);
}, timeout);
};
});
});
User Card
It has no logic, only a template that is filled with data:
in_package('GitHubUsers', function() {
this.provide('UserCard', UserCard);
functionUserCard() {
var element = GitHubUsers.Dom.instantiateTemplate('usercard');
this.getElement = function() { return element; };
this.setData = function(data) {
GitHubUsers.Dom.binding(element, data);
};
}
});
Of course, doing so much querySelectorAll
each time we change data is not very good, but it works and we put up with it. If suddenly it turns out that because of this, everything is slowing down, we will write data to the saved ones data-element
. Or we will make another binding function, which itself stores the elements and can read the new data. Or we will support the transfer to the object of options not just static values, the flow of their changes so that the binding can follow them.
Load Indication / Request Error
We assume that these representations will also be static, will be used only in one place, and the chances that they will have their own logic are extremely small (unlike the user's card), therefore we will not make separate components for them. They will be just templates for the application component.
Request data
Let's make a class with the user request method, in which case we can easily replace its instance with a mock / other implementation:
in_package('GitHubUsers', function() {
this.provide('GitHubApi', GitHubApi);
functionGitHubApi() {
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);
});
};
}
});
Of course, we need a wrapper over XMLHttpRequest . We do not use it fetch
because it does not support interruption of requests, and we also don’t want to contact promises for the same reason.
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 timeoutvar 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; }
}
};
});
});
Final application
in_package('GitHubUsers', function() {
this.provide('App', App);
functionApp(options) {
var api = options.api;
var element = document.createElement('div');
// Create needed componentsvar 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 processingvar activeRequest = null;
functiononNameInput(name) {
name = name.trim();
// Instant display of `loading` or current request resultif (activeRequest && activeRequest.name === name) {
activeRequest.activateState();
} elseif (name) {
contentElements.showByType('loading');
} else {
contentElements.showByType(null);
}
}
functiononNameChange(name) {
name = name.trim();
// Cancel old requestif (activeRequest && activeRequest.name !== name) {
activeRequest.request.cancel();
activeRequest = null;
} elseif (activeRequest) { // same namereturn;
}
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();
functiononUserData(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; };
}
});
We got quite a lot of code, half of which is occupied by the initialization of all the components we need, half - the logic of sending requests and displaying the load / error / result. But everything is absolutely transparent, it is obvious, and we can change the logic in any place, if necessary.
We used an auxiliary utility DisplayOneOf
, which shows one element from the given, the rest hides:
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 = '';
};
});
});
To make it all work, we need to initialize the templates and throw a copy App
on the page:
functiononReady() {
GitHubUsers.Dom.consumeTemplates(document.getElementById('templates'));
var rootEl = document.getElementById('root');
var app = new GitHubUsers.App({
api: new GitHubUsers.GitHubApi()
});
rootEl.appendChild(app.getElement());
}
Result?
As you can see, we wrote a lot of code for such a small example. Nobody does all the magic for us, we achieve everything ourselves. We ourselves create the magic that we need if we want it.
Write a stupid and eternal code. Write without frameworks, which means you sleep tight and don't be afraid that tomorrow all your code will be deprecated or not fashionable enough.
What's next?
This example is too small to write on VanillaJS in principle. I believe that it makes sense to write on vanilla only if your project plans to live much longer than any of the frameworks and you will not have the resources to rewrite it in its entirety.
But if he was still more, this is what we would do again:
HTML templates we would do for modules / components. They would lie in the folders with components and instantiateTemplate
accept the name of the module plus the name of the template, and not just the global name.
At the moment, we have all the CSS in index.css
, it obviously also needs to be placed next to the components.
There are not enough builds of bundles, we connect all the files with our hands index.html
, this is not good.
There is no problem to write a script that, according to the lists of modules that should be included in bundles, will assemble all the js, html, css of these modules and make us one js'nik for each bundle. It will be an order of magnitude dumber and easier than setting up a webpack , and after a year find out that there is already a completely different version and you need to rewrite the config and use other bootloaders.
It is advisable to have any flag that would support the js / html / css connection scheme with a huge list in index.html
. Then there will be no assembly delays, and in Sources in chrome you will have each file in a separate tab and no sourcemaps are needed.
PS
This is just one of the options, how it can all be using VanillaJS . In the comments it would be interesting to hear about other uses.
Thanks for attention.