Как сделать поиск пользователей по 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 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. В комментариях было бы интересно услышать о других вариантах использования.


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

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 11

      +1
      Мне кажется, или кода не меньше чем React + RxJS 6 + Recompose? А если учитывать код этих 3 библиотек так и совсем смешно становится.
        0

        У меня всё-таки получилось в три раза больше javascript'а. Если не считать папку lib с «моим фреймворком», получается тот же порядок (разницы 20 строк).


        recompose-github-ui
        recompose-github-ui$ cloc public src
               8 text files.
               8 unique files.                              
               0 files ignored.
        
        -------------------------------------------------------------------------------
        Language                     files          blank        comment           code
        -------------------------------------------------------------------------------
        CSS                              2             21              0            123
        JavaScript                       5             12              0            120
        HTML                             1              6             20             17
        -------------------------------------------------------------------------------
        SUM:                             8             39             20            260
        -------------------------------------------------------------------------------
        

        vanilla-github-habr
        vanilla-github-habr$ cloc .
              14 text files.
              14 unique files.                              
               1 file ignored.
        
        -------------------------------------------------------------------------------
        Language                     files          blank        comment           code
        -------------------------------------------------------------------------------
        JavaScript                      12            115              9            358
        CSS                              1             25              0             67
        HTML                             1             10              0             53
        -------------------------------------------------------------------------------
        SUM:                            14            150              9            478
        -------------------------------------------------------------------------------
        
        +2
        Статья класс! Спасибо.

        Btw, со Svelte вы также не используете никаких зависимостей, кроме браузера)))) В том то и фишка, никакого специфического рантайма, только ещё и код за вас компилятор пишет)))

        Так что поддерживаю ваше стремление использовать ванилу, однако позволю себе процитировать Рича Харриса, автора Svelte:

        “You can't write serious applications in vanilla JavaScript without hitting a complexity wall. But a compiler can do it for you.”

        Пожалуй я с ним согласен в этом.
          0

          Спасибо за отзыв.


          Мне нравится концепция исчезающих фреймворков, возможно, через какое-то время изучу один из них, возможно, Svelte.


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

            +3
            Уж лучше легаси на angular, чем на самодельном фреймворке in_package/this.provide
          0

          А почему же вы используете объект Map из ES6? Для IE<11 добавляете полифилл или поддерживать планируется минимум IE11? Тогда уже можно смело использовать хотя бы let и const :)


          Что же касается боязни использовать сборщики из-за опасений по обратной совместимости, то ведь никто не заставляет использовать их самые последние версии. Можно просто их зафиксировать.

            0

            Полифилл, да. Для IE11, к сожалению, тоже нужен полифилл, он в конструктор массив не принимает, можно забыть.


            Версии фиксируются, а документацию/примеры по старым версиям потом сложно искать.


            Я больше боюсь за то, что сборщик может вдруг перестать быть просто сборщиком и начнёт очень сильно влиять на сам код. Вроде влиять на import'ы.


            Если мы начинаем писать код, который не может самостоятельно работать в браузере — это уже не javascript, а какой-то собственный язык. Чем мне заниматься не хотелось бы по религиозным причинам. Хотя тут на вкус и цвет.

            0
            Спасибо, статья хорошая. На досуге покапаюсь в коде, особенно в хелперах и сборщике.
              0

              Спасибо за статью. Замечательный цикл получается :)
              Тоже раньше делал небольшую страничку для подтягивания данных о пользователях GitHub с помощью VanillaJS/SCSS. Правда, там много циклов, поэтому работает медленно.
              Если кому интересно, пример здесь.

                0

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


                innerHTML += за гранью добра и зла. Вообще, лучше никогда не генерировать HTML руками, ибо можно легко что-нибудь непроэкранировать.


                Вот тут, например:


                '<p class="repository__description">' + repository.description + '</p>'

                В description можно положить <script>, например.

                  0
                  Спасибо за замечание про topics!

                  Насчёт innerHTML: я правильно понимаю, что вместо `${something}` корректнее было бы с помощью repositoryElement.appendChild добавлять все элементы, предварительно проверяя каждый из них (например, тот же repository.description)? Если так, как могла бы выполняться проверка/экранирование таких данных?

              Only users with full accounts can post comments. Log in, please.