[Перевод] Разработка собственного фреймворка и профессиональный рост JS-программиста

Вы когда-нибудь задавались вопросом о том, как работают фреймворки? Автор материала, перевод которого мы сегодня публикуем, говорит, что когда он, много лет назад, после изучения jQuery, наткнулся на Angular.js, то, что он увидел, показалось ему очень сложным и непонятным. Потом появился Vue.js, и разбираясь с этим фреймворком, он вдохновился на написание собственной системы двусторонней привязки данных. Подобные эксперименты способствуют профессиональному росту программиста. Эта статья предназначена для тех, кто хочет расширить собственные знания в сфере технологий, на которых основаны современные JS-фреймворки. В частности, речь здесь пойдёт о том, как написать ядро собственного фреймворка, поддерживающего пользовательские атрибуты HTML-элементов, реактивность и двустороннюю привязку данных.

image

О системе реактивности


Хорошо будет, если мы, в самом начале, разберёмся с тем, как работают системы реактивности современных фреймворков. На самом деле, всё тут довольно просто. Например, Vue.js, при объявлении нового компонента, проксирует каждое свойство (геттеры и сеттеры), используя паттерн проектирования «прокси».

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

Паттерн «прокси»


В основе паттерна «прокси» лежит перегрузка механизмов доступа к объекту. Это похоже на то, как люди работают со своими банковскими счетами.

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

var account = {
    balance: 5000
}

var bank = new Proxy(account, {
    get: function (target, prop) {
        return 9000000;
    }
});
console.log(account.balance); // 5,000 (реальный баланс)
console.log(bank.balance);    // 9,000,000 (банк сообщает ложные сведения)
console.log(bank.currency);   // 9,000,000 (некие действия банка)


В этом примере, при использовании объекта bank для доступа к балансу счёта, представленного объектом account, осуществляется перегрузка функции-геттера, что приводит к тому, что в результате подобного запроса всегда возвращается значение 9,000,000, вместо реального значения свойства, даже в том случае, если это свойство не существует.

А вот пример перегрузки функции-сеттера.

var bank = new Proxy(account, {
    set: function (target, prop, value) {
        // Всегда устанавливать значение свойства в 0
        return Reflect.set(target, prop, 0); 
    }
});

account.balance = 5800;
console.log(account.balance); // 5,800

bank.balance = 5400;
console.log(account.balance); // 0 (некие действия банка)


Здесь, перегрузив функцию set, можно управлять её поведением. Например, можно изменить значение, которое требуется записать в свойство, записать данные в какое-нибудь другое свойство, или даже попросту ничего не делать.

Пример системы реактивности


Теперь, когда мы разобрались с паттерном «прокси», приступим к разработке нашего собственного JS-фреймворка.

Для того чтобы не усложнять его, мы воспользуемся синтаксисом, очень напоминающим тот, который применяется в Angular.js. В результате объявление контроллера и привязка элементов шаблона к свойствам контроллера будет выглядеть просто и понятно.

Вот код шаблона.


Для начала надо объявить контроллер со свойствами. Далее — использовать этот контроллер в шаблоне, и, наконец — применить атрибут ng-bind для того, чтобы наладить двустороннюю привязку данных для значения элемента.

Разбор шаблона и создание экземпляра контроллера


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

В процессе работы с контроллерами фреймворк будет искать элементы, имеющие атрибуты ng-controller. Если то, что удалось найти, будет соответствовать одному из объявленных контроллеров, фреймворк создаст новый экземпляр этого контроллера. Данный экземпляр контроллера ответственен лишь за этот конкретный фрагмент шаблона.

var controllers = {};
var addController = function (name, constructor) {
    // Конструктор контроллера
    controllers[name] = {
        factory: constructor,
        instances: []
    };
    
    //Ищем элементы, использующие контроллер
    var element = document.querySelector('[ng-controller=' + name + ']');
    if (!element){
       return; // Нет элементов, использующих этот контроллер
    }
    
    // Создаём новый экземпляр и сохраняем его
    var ctrl = new controllers[name].factory;
    controllers[name].instances.push(ctrl);
    
    // Ищем привязки данных.....
};

addController('InputController', InputController);


Ниже показано объявление «самодельной» переменной controllers. Обратите внимание на то, что объект controllers содержит все контроллеры, объявленные во фреймворке путём вызова addController.

var controllers = {
    InputController: {
        facrory: function InputController(){
            this.message = "Hello World!";
        },
        instances: [
            {message: "Hello World"}
        ]
    }
};


У каждого контроллера предусмотрена функция factory, делается это для того, чтобы, при необходимости, можно было создать экземпляр нового контроллера. Кроме того, фреймворк хранит, в свойстве instances, все экземпляры контроллера одного типа, использованные в шаблоне.

Поиск элементов, участвующих в привязке данных


В данный момент у нас есть экземпляр контроллера и фрагмент шаблона, использующий этот контроллер. Следующим нашим шагом будет поиск элементов с данными, которые нужно привязать к свойствам контроллера.

var bindings = {};

// Обратите внимание: element это элемент DOM, использующий контроллер
Array.prototype.slice.call(element.querySelectorAll('[ng-bind]'))
    .map(function (element) {
        var boundValue = element.getAttribute('ng-bind');

        if(!bindings[boundValue]) {
            bindings[boundValue] = {
                boundValue: boundValue,
                elements: []
            }
        }

        bindings[boundValue].elements.push(element);
    });


Здесь показана организация хранения всех привязок объекта с использованием хэш-таблицы. Рассматриваемая переменная содержит все свойства для привязки, с их текущими значениями, и все DOM-элементы, привязанные к конкретному свойству.

Вот как выглядит наш вариант переменной bindings:

    var bindings = {
        message: {
            // Переменная ссылается на:
            // controllers.InputController.instances[0].message
            boundValue: 'Hello World',

            // HTML-элементы (элементы управления с ng-bind="message")
            elements: [
                Object { ... },
                Object { ... }
            ]
        }
    };


Двусторонняя привязка свойств контроллера


После того, как фреймворк выполнил предварительную подготовку, приходит время одного интересного дела: двусторонней привязки данных. Смысл этого заключается в следующем. Во-первых, свойства контроллера нужно привязать к элементам DOM, что позволит обновлять их тогда, когда значения свойств меняются из кода, и, во-вторых, элементы DOM также должны быть привязаны к свойствам контроллера. Благодаря этому, когда пользователь воздействует на такие элементы, это приводит к изменению свойств контроллера. А если к свойству привязано несколько HTML-элементов, то это приводит и к тому, что их состояние также обновляется.

Обнаружение изменений, выполняемых из кода, с помощью прокси


Как уже было сказано выше, Vue.js оборачивает компоненты в прокси для того, чтобы выявлять изменения свойств. Сделаем то же самое, проксировав лишь сеттер для свойств контроллера, участвующих в привязке данных:

// Обратите внимание: ctrl - это экземпляр контроллера
var proxy = new Proxy(ctrl, {
    set: function (target, prop, value) {
        var bind = bindings[prop];
        if(bind) {
            // Обновим каждый элемент DOM, привязанный к свойству  
            bind.elements.forEach(function (element) {
                element.value = value;
                element.setAttribute('value', value);
            });
        }
        return Reflect.set(target, prop, value);
    }
});


В результате получается, что при записи значения в привязанное свойство прокси обнаружит все элементы, привязанные к этому свойству, и передаст им новое значение.

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

Реакция на события элементов


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

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

Object.keys(bindings).forEach(function (boundValue) {
    var bind = bindings[boundValue];
    
    // Прослушивание событий элементов и обновление свойства прокси
    bind.elements.forEach(function (element) {
      element.addEventListener('input', function (event) {
        proxy[bind.boundValue] = event.target.value; // Кроме того, это вызывает срабатывание сеттера прокси
      });
    })  
  });


Итоги


В результате, собрав воедино всё то, о чём мы тут рассуждали, вы получите собственную систему, реализующую базовые механизмы современных JS-фреймворков. Вот работающий пример реализации такой системы. Надеемся, этот материал поможет всем желающим лучше разобраться в том, как работают современные средства веб-разработки.

Уважаемые читатели! Если вы профессионально используете современные JS-фреймворки, просим рассказать о том, как вы начинали их изучение, и о том, что помогло вам в них разобраться.

1ba550d25e8846ce8805de564da6aa63.png

© Habrahabr.ru