Вы когда-нибудь задавались вопросом, как работают фреймворки?

Когда я впервые открыл для себя AnglurJS после изучения jQuery он показался мне черной магией.

Затем вышел Vue.js. В процессе анализа его работы под капотом я попытался написать свою собственную реализацию двусторонней привязки.

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



Как работает реактивность?


Идея, которая стоит за паттерном, крайне проста и заключается в перегрузке методов доступа к объекту.

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

var account = {
	balance: 5000
}

var bank = new Proxy(account, {
    get: function (target, prop) {
    	return 9000000;
    }
});

console.log(account.balance); // 5,000 (your real balance)
console.log(bank.balance);    // 9,000,000 (the bank is lying)
console.log(bank.currency);   // 9,000,000 (the bank is doing anything)


В примере выше при запросе поля account из объекта bank геттер перегружен, поэтому всегда возвращается 9,000,000 вместо значения поля. Даже, если поле не существует.

var bank = new Proxy(account, {
    set: function (target, prop, value) {
        // Always set property value to 0
        return Reflect.set(target, prop, 0); 
    }
});

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

bank.balance = 5400;
console.log(account.balance); // 0 (the bank is doing anything)

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

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


Теперь, когда вы уже знаете, как работает паттерн проектирования Proxy, давайте напишем свой собственный JavaScript фреймворк.

Для простоты наш синтаксис будет близок к AngularJS. Объявление контроллера и привязка элементов шаблона к свойствам контроллера довольно проста.

<div ng-controller="InputController">
    <!-- "Hello World!" -->
    <input ng-bind="message"/>   
    <input ng-bind="message"/>
</div>

<script type="javascript">
  function InputController () {
      this.message = 'Hello World!';
  }
  angular.controller('InputController', InputController);
</script>


В начале мы определили контроллер с его полями. Затем использовали этот контроллер в шаблоне. Наконец, использовали атрибут ng-bind для доступа к двусторонней связке со значениями элементов.

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


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

Во время объявления контроллера наш фреймворк будет искать элементы, которые содержат атрибут ng-controller.

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

var controllers = {};
var addController = function (name, constructor) {
    // Store controller constructor
    controllers[name] = {
        factory: constructor,
        instances: []
    };
    
    // Look for elements using the controller
    var element = document.querySelector('[ng-controller=' + name + ']');
    if (!element){
       return; // No element uses this controller
    }
    
    // Create a new instance and save it
    var ctrl = new controllers[name].factory;
    controllers[name].instances.push(ctrl);
    
    // Look for bindings.....
};

addController('InputController', InputController);


Вот так выглядит объявление переменной контроллера своими руками.Объект controllers содержит все контроллеры, объявленные в рамках структуры путем вызова addController.



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

Поиск привязок


На этот раз мы получаем экземпляр нашего контроллера и часть шаблона.

var bindings = {};

// Note: element is the dom element using the controller
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);
    });


Достаточно просто. Фреймворк сохраняет все связи как объект. Для этого он использует отображение на основе хешей (hash map). Эта переменная хранит все поля для связки с конкретным значением и всеми его DOM элементами.



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


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

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

Поиск изменений в коде с proxy


Как говорилось выше, Vue оборачивает компоненты в proxy для динамического изменения полей. Давайте повторим такой подход и обернем сеттер полей контроллера.

// Note: ctrl is the controller instance
var proxy = new Proxy(ctrl, {
    set: function (target, prop, value) {
        var bind = bindings[prop];
        if(bind) {
            // Update each DOM element bound to the property  
            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];
  
  // Listen elements event and update proxy property   
  bind.elements.forEach(function (element) {
    element.addEventListener('input', function (event) {
      proxy[bind.boundValue] = event.target.value; // Also triggers the proxy setter
    });
  })  
});


Соединив все вместе, вы получаете реализацию двусторонней привязки своими руками.

Спасибо за чтение. Надеюсь статья помогла вам понять, как работают JavaScript фреймворки.

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

Исходный код можно посмотреть здесь.

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