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


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

Комментарии (11)


  1. argonavtt
    11.08.2018 13:31
    +1

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


    1. Rulexec Автор
      11.08.2018 13:39

      У меня всё-таки получилось в три раза больше 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
      -------------------------------------------------------------------------------
      


  1. PaulMaly
    11.08.2018 13:40
    +1

    Статья класс! Спасибо.

    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.”

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


    1. Rulexec Автор
      11.08.2018 13:51

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


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


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


      1. justboris
        12.08.2018 10:06
        +1

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


  1. staticlab
    11.08.2018 14:05

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


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


    1. Rulexec Автор
      11.08.2018 14:17

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


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


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


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


  1. exdeniz
    11.08.2018 16:18

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


  1. vasilii-kovalev
    12.08.2018 16:30

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


    1. Rulexec Автор
      12.08.2018 16:47

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


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


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


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

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


      1. vasilii-kovalev
        12.08.2018 17:47

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

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