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

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

Спасибі всім за увагу. Коментарі та критика вітаються.
Всі! Нарешті кінець :)
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.