Двусторонний binding данных с ECMAScript-2015 без Proxy

в 11:32, , рубрики: bindings, ES-6, getter setter, html, javascript, object.defineproperty, promise, Разработка веб-сайтов, метки:

Привет, уважаемые читатели Хабра. Эта статья некое противопоставление недавно прочитанной мной статье «Односторонний 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. Это ни в коем случае не готовое для использования решение. Это просто реализация некоторых размышлений.

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

Автор: IPri

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js