Как сделать поиск пользователей по Github используя VanillaJS

в 9:42, , рубрики: javascript, vanillajs, Разработка веб-сайтов

Здравствуйте. Меня зовут Александр и я Vanilla ES5.1 разработчик в 2018 году.

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

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

Делать будем всё тот же инпут, отображающий плашку GitHub-пользователя:

Как сделать поиск пользователей по Github используя VanillaJS - 1

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 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 в принципе. Я считаю, что писать на ванилле имеет смысл только если ваш проект планирует жить намного дольше, чем любой из фреймворков и у вас не будет ресурсов, чтобы переписать его целиком.

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

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

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

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

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

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

P.S.

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

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

Автор: Александр Рулёв

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js