Привет, уважаемые читатели Хабра. Эта статья некое противопоставление недавно прочитанной мной статье «Односторонний binding данных с ECMAScript-2015 Proxy». Если вам интересно узнать, как же сделать двусторонний асинхронный биндинг без лишних структур в виде Proxy, то прошу под кат.

Тех, кому не интересно читать буквы, приглашаю сразу понажимать на эти самые буквы DEMO binding

Итак, что же меня смутило в той статье и мотивировало на написание своей:

  1. Автор говорит о связывании данных, но описывается реализация observer`а. Т.е. подписка на изменение свойств объектов. Конечно, с помощью колбэков, можно реализовать связывание, но хочется как-то попроще. Сам термин связывание предполагает простое указание соответствия значения одной единицы хранения значению другой.

  2. Не очень удачная реализация асинхронности —
    setTimeout(()=> listener(event), 0);
    При минимальном таймауте все нормально, функции подписчики вызываются одна за другой через постоянный минимальный интервал (вроде как 4mc). Но что если нам необходимо увеличить его, например, до 500 мс. Тогда просто произойдет задержка на заданный интервал и потом все функции будут вызваны, но также с минимальным интервалом. А хотелось бы указать интервал, именно, между вызовами подписчиков.

  3. И еще немножко — незащищенность полей общего хранилища от прямой перезаписи, нет реализации привязок DOM > JS, DOM > DOM.

Ну что ж, хватит умничать, самое время показать свое «творчество». Начнем с постановки задачи.

Дано:

  • «чистые» JS объекты и DOM элементы.

Задание:

  • Реализовать двустороннюю привязку данных между любыми типами объектов (DOM – JS, JS – DOM, JS – JS, DOM – DOM).
  • Реализовать асинхронность привязок с возможностью указания таймаута
  • Реализовать возможность подписок на изменение свойств (навешивание функций наблюдателей) для расширения функционала привязок.
  • Реализовать распределенное хранилище привязок с защитой о прямой записи.

Решение:

Основные идеи реализации:

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

  2. Необходим функционал перехвата доступа к свойствам объекта, но без создания лишних структур в виде proxy объектов. Для этого отлично подойдет функционал геттеров и сеттеров (Object.defineProperty) определенный еще в ECMAScript 5.1.

  3. Для последовательного асинхронного биндинга реализуем очереди. Попробуем использовать для этого setTimeout + Promises.

Реализация:

  1. Структура класса Binder:
    image
    Рис. 1 — схема класса Binder

    Static:

    • hash — вычисляет хэш функции, будет использоваться для индентификации функций наблюдателей
    • delay – реализация таймаута в асинхронной очереди
    • queue – создает асинхронную очередь
    • timeout – getter/setter для _timeout
    • _timeout – таймаут по умолчанию
    • prototypes – хранит прототипы для «расширенных» объектов
    • createProto – создает прототипы для «расширенных» объектов

    Instance:

    • «transformed properties» — свойства объектов преобразованные в getter/setter
    • _binder – служебная информация
    • emitter – указывает на объект который в текущий момент инициирует привязку
    • bindings – хранилище привязок
    • watchers – хранилище наблюдателей
    • properties – хранилище свойств – значений которые были преобразованы в getter/setter
    • _bind / _unbind – реализация привязки / отвязки
    • _watch / _unwatch – реализация подписки / оптиски

  2. Конструктор класса:

         constructor(obj){
            
            let instance;
            
            /*1*/
            if (obj) {
                instance = Binder.createProto(obj, this);
            } else {
                instance = this;
            }
            
            /*2*/
            Object.defineProperty(instance, '_binder', {                                  
                configurable: true,                                                     
                value: {}
            });
            
            /*3*/
            Object.defineProperties(instance._binder, {                                  
                'properties': {
                    configurable: true,                                                     
                    value: {}
                },
                'bindings':{
                    configurable: true,                                                     
                    value: {}
                },
                'watchers':{
                    configurable: true,                                                     
                    value: {}
                },
                'emitter':{
                    configurable: true,
                    writable: true,
                    value: {}
                }            
            });
            
            return instance;  
        }
    
    /*1*/ — Проверяем, если конструктор был вызван без аргументов, то создаем новый объект. Если был передан объект то модифицируем его. Статический метод `createProto` см. описание /*8*/
    
    /*2*/ — /*3*/ — Указываем объекту поле-обертку `_bunder`, и записываем в него хранилище привязок, хранилище наблюдателей и значения свойств, которые подверглись трансформации. Поле «emitter» будет указывать на инициатора биндинга, но об этом чуть позже. Все свойства указываются через дескрипторы, таким образом защищаемся от прямой перезаписи (`writable: false`).
                 

  3. Статические методы класса:

    /* Очередь и таймауты */
    
       /*4*/
       static get timeout(){
            return Binder._timeout || 0;
        }
        
       /*5*/
        static set timeout(ms){
            Object.defineProperty(this, '_timeout', {
                configurable: true,
                value: ms 
            });
        }
        
        /*6*/
        static delay(ms = Binder.timeout){
            return new Promise((res, rej) => {
                if(ms > 0){
                    setTimeout(res, ms);
                } else {
                    res();
                }
            });
        }
        
        /*7*/    
        static get queue(){
            return Promise.resolve();
        }
    
    
    
    /*4*/-/*5*/— Геттер и сеттер для статического свойтсва `_timeout` задающего таймуат по умолчанию для асинхронной очереди. В принципе, геттер и сеттер тут ни к чему, но синтаксис ES6 не позволяет в описании класса указать статические свойства-значения.
    
    /*6*/-/*7*/ метод queue задает начала асинхронных очередей, в которые будут добавляться задачи. Метод `delay` возвращает промис, который будет "зарезолвен" по истечении указанного таймаута или таймаута по умолчанию. При этом вся асинхронная очередь будет ждать.
                 


    /* Модицикация объектов */
    
        /*8*/
        static createProto(obj, instance){
            
            let className = obj.constructor.name;
            
            if(!this.prototypes){
                Object.defineProperty(this, 'prototypes', {
                    configurable: true,
                    value: new Map()
                });
            }
                
            if(!this.prototypes.has(className)){
                
                let descriptor = { 
                    'constructor': {
                        configurable: true,                                                     
                        value: obj.constructor
                    }
                };
                
                Object.getOwnPropertyNames(instance.__proto__).forEach(
                    ( prop ) => {
                        if(prop !== 'constructor'){
                            descriptor[prop] = {
                                configurable: true,
                                value: instance[prop]
                            };
                        }
                    }
                );
                
                this.prototypes.set(
                    className, 
                    Object.create(obj.__proto__, descriptor)
                );
            }
            
            obj.__proto__ = this.prototypes.get(className);
            
            return obj;
        }
    
        /*8*/— Используется в конструкторе класса. Встраивает в цепочку прототипов объект с необходимыми методами. Если необходимо создает новый объект прототипа, либо берет уже созданный из статического хранилища класса - `Binder.prototypes`
                

    
        /* Модицикация объектов */
    
        /*9*/
        static transform(obj, prop){
           
            let descriptor, nativeSet;
            let newGet = function(){ return this._binder.properties[prop];};
            let newSet = function(value){
                /*10*/
                let queues = [Binder.queue, Binder.queue];
                
                /*11*/
                if(this._binder.properties[prop] === value){ return; }
                            
                Object.defineProperty(this._binder.properties, prop, {
                    configurable: true,
                    value: value
                });
    
                if(this._binder.bindings[prop]){
                    
                    this._binder.bindings[prop].forEach(( [prop, ms], boundObj ) => { 
                        
                        /*12*/
                        if(boundObj === this._binder.emitter) {
                            this._binder.emitter = null;
                            return;
                        }
                        
                        if(boundObj[prop] === value) return;
    
                        /*13*/
                        queues[0] = queues[0]
                            .then(() => Binder.delay(ms) )
                            .then(() => { 
                                boundObj._binder.emitter = obj;
                                boundObj[prop] = value; 
                            });   
                    });
                    
                    queues[0] = queues[0].catch(err => console.log(err) );
                }
                 /*14*/
                if( this._binder.watchers[prop] ){
    
                    this._binder.watchers[prop].forEach( ( [cb, ms] ) => { 
                        queues[1] = queues[1]
                            .then(() => Binder.delay(ms) )
                            .then(() => { cb(value); });
                    });
                }
    
                if( this._binder.watchers['*'] ){
    
                    this._binder.watchers['*'].forEach( ( [cb, ms] ) => { 
                        queues[1] = queues[1]
                            .then(() => Binder.delay(ms) )
                            .then(() => { cb(value); });
                    });
                }
                
                queues[1] = queues[1].catch(err => console.log(err));
                
            };
             /*15*/
            if(obj.constructor.name.indexOf('HTML') === -1){
                
                descriptor = {
                    configurable: true,
                    enumerable: true,
                    get: newGet,
                    set: newSet
                };
                
            } else {
                 /*16*/
                if('value' in obj) {
                    descriptor = Object.getOwnPropertyDescriptor(
                        obj.constructor.prototype,
                        'value'
                    );
                    obj.addEventListener('keydown', function(evob){
                        if(evob.key.length === 1){
                            newSet.call(this, this.value + evob.key);
                        } else {
                            Binder.queue.then(() => {
                                newSet.call(this, this.value);
                            });
                        }
                    });
                    
                } else {
                    
                    descriptor = Object.getOwnPropertyDescriptor(
                        Node.prototype,
                        'textContent'
                    );
                }
                
                 /*17*/
                nativeSet = descriptor.set;
                
                descriptor.set = function(value){
                    nativeSet.call(this, value);
                    newSet.call(this, value);
                };
            }
    
            Object.defineProperty(obj._binder.properties, prop, {
                configurable: true,
                value: obj[prop]
            });
            
            Object.defineProperty(obj, prop, descriptor);
            
            return obj;
        }
    
    /*9*/ - функция `transform` трансформируется свойства объекта. Если это JS объект, то значение свойтства записывается в `obj._binder.properties`, само свойство преобразуется в геттер/сеттер. Если же это DOM объект, то делает обертки над нативными геттером/сеттером.
    /*10*/ - стартуем две асинхронные очереди для привязок и наблюдателей.
    /*11*/ - проверяем если значение переданное в сеттер не отличает от текущего значения свойства то ничего не делаем.
    /*12*/ - Защита от волны кросс привязок - проверка эмиттера и текущего значения свойства. Объект инициатор обновления привязки прописывает себя в свойство `obj._binder.emitter` привязанного объекта. Привязанный объект таким образом не будет обновлять значение привязки инициатора. Иначе был бы бесконечный цикл взаимных обновлений привязок. 
    /*13*/ - Добавление исполнения привязки в асинхронную очередь с заданными таймаутом.
    /*14*/ - Добавление исполнения функций наблюдателей в асинхронную очередь с заданными таймаутом.
    /*15*/ - Проверка на принадлежность объекта к DOM
    /*16*/ - Проверка на тип DOM элемента. В данном случае подразумеваются "активные" элементы`input`, `textarea` со свойством `value` и остальные с `textContent`. 
    У "активных" элементов геттер/сеттер `value` находится в прототипе (см. `рис. 2`). Например, для `input` это будет `HTMLInputElementPrototype`. `textContent` это тоже геттер/сеттер который находится в `Node.prototype`(см. `рис. 3`). Чтобы получить нативные геттер/сеттер используем метод `Object.getOwnPropertyDescriptor`. Ну и в случае "активного" элемента без обработчика события не обойтись.
    /*17*/ - Делаем обертку на нативным сеттером, что и позволяет реализовать механизм привязок.
    
    /*Примечание*/ - Объявление `newSet` и `newGet`, конечно, следовало бы вынести во вне. 
                  

    image

    Рис.2 — наследование свойства `value`

    image

    Рис.3 — наследование свойства `textContent`, на примере элемента `div`

    Для наглядности приведу еще одно изображение поясняющее трансформацию DOM элемента, на примере элемента «div» (рис. 4)

    image

    Рис.4 — схема трансформация DOM элемента.

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

    image

    Рис.5 сравнение общей очереди исполнения с раздельными.

  4. Методы инстанса класса Binder:

         /*18*/
         _bind(ownProp, obj, objProp, ms){                                                       
                            
            if(!this._binder.bindings[ownProp]) {
                this._binder.bindings[ownProp] = new Map();
                Binder.transform(this, ownProp);     
            }
    
            if(this._binder.bindings[ownProp].has(obj)){                                           
                return !!console.log('Binding for this object is already set');             
            }
            
            this._binder.bindings[ownProp].set(obj, [objProp, ms]); 
                                                          
            if( !obj._binder.bindings[objProp] ||
                !obj._binder.bindings[objProp].has(this)) {
                    obj._bind(objProp, this, ownProp, ms);                                      
            }       
            
            return this;                                                                             
        }
        
       /*19*/
        _unbind(ownProp, obj, objProp){                                                     
            try{
                this._binder.bindings[ownProp].delete(obj);                                 
                obj._binder.bindings[objProp].delete(this);
                return this;
            } catch(e) {
                return !!console.log(e);
            }    
        };
        
       /*20*/
        _watch(prop = '*', cb, ms){
            
            var cbHash = Binder.hash(cb.toString().replace(/\s/g,''));                        
    
            if(!this._binder.watchers[prop]) {                                                     
                this._binder.watchers[prop] = new Map();
    
                if(prop === '*'){
                    Object.keys(this).forEach( item => {                                     
                        Binder.transform(this, item);
                    });
                } else {
                    Binder.transform(this, prop);
                }
            }
    
            if(this._binder.watchers[prop].has(cbHash)) {                                        
                return !!console.log('Watchers is already set');
            }
    
            this._binder.watchers[prop].set(cbHash, [cb, ms]);                                          
    
            return cbHash;                                                                
        };
        
    
        /*21*/
        _unwatch(prop = '*', cbHash = 0){
            try{
                this._binder.watchers[prop].delete(cbHash);
                return this;
            } catch(e){
                return !!console.log(e);
            }
        };
    
    
    /*18*/ - /*19*/ - функции привязки/отвязки. Функции привязки получает в качестве аргументов имя собственного свойства объекта, ссылку на объект, к которому привязываемся, название свойства привязываемого объекта и таймаут привязки. После привязки вызывается аналогичный метод у привязываемого объекта для обратной (двусторонней) привязки. См. `рис. 6`
    
    /*20*/ - /*21*/ - функции подписки/отписки. Функция подписки получает в качестве параметров имя собственного свойства объекта (по умолчанию все - "*"). Функцию наблюдателя и таймаут вызова этой функции при изменении свойства. В качестве возвращаемого значения используется вычисленный хэш функции.
    
    

    image

    Рис.6 Знакомтесь, кот Биндер


Итоги :

Хорошо:

  1. Защита свойств от прямой перезаписи;
  2. Механизм подписок на изменение свойств объекта;
  3. Настраиваемые асинхронные очереди привязок и подписок;
  4. Двусторонний «честный» биндинг, т.е. мы просто указываем соответствие значения одного другому.

Плохо :

  1. Для DOM элементов привязка только к свойтсвам 'value' и 'textContent';
  2. Возможность указания только одной привязки между двумя объектами;

P.S. Это ни в коем случае не готовое для использования решение. Это просто реализация некоторых размышлений.

Спасибо всем за внимание. Комментарии и критика приветствуются.
Всё! Наконец-то конец :)
Поделиться с друзьями
-->

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


  1. lega
    16.11.2016 16:17
    +3

    Необходим функционал перехвата доступа к свойствам объекта, но без создания лишних структур в виде proxy объектов. Для этого отлично подойдет функционал геттеров и сеттеров (Object.defineProperty)
    Обложить все и вся пропертями (Object.defineProperty) вы называете «без создания лишних структур», лишнего как раз будет поболее, причем с порчей (трансформацией) исходного объекта.
    Такой подход уже давно применяется в vue.js, aurelia, Matreshka.js и других фреймворках, так что открытия америки тут нет.

    трансформация DOM элемента
    Непонятно зачем, во первых это будет тормозить*, во вторых, чтобы отслеживать изменения DOM есть готовое апи MutationObserver (и старые аналоги), в третих, в SPA это не используют, всегда идет изменение DOM из js, поэтому в js это и отслеживается.


    1. IPri
      16.11.2016 17:13
      -1

      Обложить все и вся пропертями...
      — Не понял, что вы имеете ввиду. Если количество кода, тогда конечно смотрится это менее аккуратно, чем простое присвоение. Если вы про дескрипторы, то лишних структур нет, т.к. дескрипторы есть у каждого свойства по умолчанию.
      … открытия америки тут нет.
      — А никто и не претендует.
      Непонятно зачем, во первых это будет тормозить*
      — Тоже были такие подозрения, но вроде как речь идет о модификации исходных прототипов. Или сама цепочка тоже влияет. Буду рад если дадите ссылки где почитать.
      … есть готовое апи MutationObserver
      был не в курсе, спасибо почитаю.


    1. IPri
      16.11.2016 18:38
      -1

      Самое забавное, что MutationObserver не отслеживает изменение данных на input`ах и textarea`ах. И это справедливо в обе стороны JS->DOM, DOM->JS.

      Кстати вопрос по '… всегда идет изменение DOM из js'. А как же вводимые пользователем данные?


      1. lega
        16.11.2016 20:08
        +1

        А как же вводимые пользователем данные?
        Как и везде — addEventListener.


        1. IPri
          17.11.2016 01:14
          -3

          Ну так «EventListener» это как раз наоборот — реакция на изменение данных DOM.
          Т.е. данные DOM изменяются непосредственно пользователем путем ввода символов и JS тут ни при чем.
          Или я вас неправильно понял?


  1. sinires
    16.11.2016 16:43

    Спасибо за статью, было интересно увидеть ваше виденье данной задачи.
    Сравнивать данные реализации не корректно, так как в комментариях я отвечал: > "Я не открывал Америку. Мне было интересно сделать то, что сделано через Proxy. Дань памяти почившему O.o". Мы реализуем разные подходы, используя разные технологии с разными целями.


    1. IPri
      16.11.2016 17:24

      Вам, спасибо за комментарий.
      Почему ж, некорректно? Мне кажется вполне себе. Да подходы различаются, но цель то одна — связывание данных.
      Я тоже надо сказать открытия не делал.


  1. vlreshet
    16.11.2016 18:07

    Возможно я что-то не понимаю, но зачем столько кода для оповещения одного объекта о изменении поля другого? Это ведь можно реализовать через геттер и сеттер буквально в 20 строк

    Как-то вот так имею ввиду
    const BindTo = (from, to, field) => {
    	from[`#bind_collector_${field}`] = to;
    	from[`#bind_field_${field}`] = from[field];
    
    	Object.defineProperty(from, field, {
    		get : function(){
    			return this[`#bind_field_${field}`];
    		}, 
    		set : function(value){
    			this[`#bind_field_${field}`] = value;
    
    			from[`#bind_collector_${field}`](value);
    		}
    	});
    };
    


    1. vlreshet
      16.11.2016 18:16

      Посыпаю голову пеплом. Не знал что сеттеры-геттеры не работают на value DOM объектов.


    1. IPri
      16.11.2016 18:18

      У вас описана односторонняя привязка. Если начнете расширять функционал — двусторонние привязки, список привязок, асинхронн, DOM, проверки и прочее, то ваш код несомненно разрастется. Но возможно вам удастся все это более лаконично расписать и кода будет поменьше.


      1. vlreshet
        16.11.2016 18:31
        -1

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


        1. IPri
          16.11.2016 18:45

          Что значит

          нельзя навесить геттер-сеттер на DOM елемент.

          А у меня разве ни это самое и делается? Если внимательно посмотрите, то в DOM всё в геттерах и сеттерах


          1. vlreshet
            16.11.2016 18:48

            Я о том что это работает не со всеми DOM элементами. Допустим, если навесить геттер-сеттер на DOM элемент input type=«text» то ничего не сработает, и поле value не будет обновляться после ввода пользователя.

            P.S. а, так вы же сами об этом и пишите


            1. IPri
              17.11.2016 01:27

              Да, я тоже поначалу думал, что при вводе символов на «инпутах» вызывается setter. Но оказалось все «хитрее», и единственный способ это отследить навесить обработчик на событие. Хотя вызов setter`а для обновления DOM был бы вполне логичен.