Я фронтенд-инженер, и я прошел слишком много кейсов о «привязке данных». В первые дни я использовал jQuery для этого. Однако каждый раз, когда данные меняются, я могу связать их с dom только вручную, если количество данных огромно, это было бы очень болезненно - вся боль заканчивается, пока я не встретил VueJS.

Одним из аргументов в пользу VueJS является «привязка данных». Пользователям не нужно заботиться о том, как данные связываются с домом, а просто сосредоточиться на данных, потому что VueJS сделает это автоматически.

Удивительно, не так ли? Я быстро влюбился в VueJS и использовал его в своих проектах. Через несколько дней я был знаком с его использованием, и я хотел бы знать его глубину.

Как доза VueJS выполняет привязку данных?

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

Когда вы передаете простой объект JavaScript экземпляру Vue в качестве параметра данных, Vue просматривает все его свойства и преобразует их в геттеры/сеттеры с помощью Object.defineProperty.

Ключевое слово Object.definProperty. В документе MDN говорится

Метод Object.defineProperty() определяет новое свойство непосредственно в объекте или изменяет существующее свойство объекта и возвращает объект.

Давайте сделаем пример, чтобы проверить это.

Во-первых, создайте Железного человека с несколькими свойствами:

let ironman = {
  name: 'Tony Stark',
  sex: 'male',
  age: '35'
}

Теперь давайте используем Object.defineProperty() для изменения его свойства и покажем изменения из консоли.

Object.defineProperty(ironman, 'age', {
  set (val) {
    console.log(`Set age to ${val}`)
    return val
  }
})

Когда я изменю его возраст, я увижу журнал:

ironman.age = '48'
// --> Set age to 48

Кажется идеальным, и если вы измените console.log(val) на element.innerHTML = val, привязка данных будет выполнена напрямую, верно?

Давайте немного изменим свойства Железного человека:

let ironman = {
  name: 'Tony Stark',
  sex: 'male',
  age: '35',
  hobbies: ['girl', 'money', 'game']
}

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

Object.defineProperty(ironman.hobbies, 'push', {
  value () {
    console.log(`Push ${arguments[0]} to ${this}`)
    this[this.length] = arguments[0]
  }
})
ironman.hobbies.push('wine')
console.log(ironman.hobbies)
// --> Push wine to girl,money,game
// --> [ 'girl', 'money', 'game', 'wine' ]

В предыдущий момент я использовал get() для наблюдения за изменениями свойств объекта, но для массива мы не можем использовать get() для наблюдения за его свойствами, а вместо этого используем value(). Хоть и работает, но не лучше. Есть ли идея упростить способ отслеживания изменений объекта или массива?

В ECMA2015 Proxy — хорошая идея

Что такое Proxy? В документе MDN сказано:

Объект Proxy используется для определения пользовательского поведения основных операций (например, поиска свойств, назначения, перечисления, вызова функций и т. д.).

Proxy — это новая функция в ECMA2015, мощная и полезная. Сегодня я не буду много говорить об этом, а только об одном полезном его использовании. Теперь давайте создадим прокси:

let ironmanProxy = new Proxy(ironman, {
  set (target, property, value) {
    target[property] = value
    console.log('change....')
    return true
  }
})
ironmanProxy.age = '48'
console.log(ironman.age)
// --> change....
// --> 48

Он работает как ожидание. А как насчет Array?

let ironmanProxy = new Proxy(ironman.hobbies, {
  set (target, property, value) {
    target[property] = value
    console.log('change....')
    return true
  }
})
ironmanProxy.push('wine')
console.log(ironman.hobbies)
// --> change...
// --> change...
// --> [ 'girl', 'money', 'game', 'wine' ]

Оно работает! Но почему он выводит change... дважды? Причина в том, что как только я запускаю функцию push(), оба length и body этого массива будут изменены.

Привязка данных в реальном времени

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

Рассмотрим шаблон и объект данных:

<!-- html template -->
<p>Hello, my name is {{name}}, I enjoy eatting {{hobbies.food}}</p>
<!-- javascript -->
let ironman = {
  name: 'Tony Stark',
  sex: 'male',
  age: '35',
  hobbies: {
    food: 'banana',
    drink: 'wine'
  }
}

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

$setData (dataObj, fn) {
    let self = this
    let once = false
    let $d = new Proxy(dataObj, {
      set (target, property, value) {
        if (!once) {
          target[property] = value
          once = true
          /* Do something here */
        }
        return true
      }
    })
    fn($d)
  }

И используйте его, как показано ниже:

$setData(dataObj, ($d) => {
  /* 
   * dataObj.someProps = something
   */
})
// or
$setData(dataObj.arrayProps, ($d) => {
  /* 
   * dataObj.push(something)
   */
})

Более того, мы хотели бы, чтобы строка шаблона указывала на объект данных, тогда строку типа {{name}} можно было бы заменить на Tony Stark.

replaceFun(str, data) {
    let self = this
    return str.replace(/{{([^{}]*)}}/g, (a, b) => {
      return data[b]
    })
  }
replaceFun('My name is {{name}}', { name: 'xxx' })
// --> My name is xxx

Эта функция хорошо работает с однослойными свойствами, такими как { name: 'xx', age: 18 }, но не может работать с несколькими свойствами, такими как { hobbies: { food: 'apple', drink: 'milk' } }.

Например, если строка шаблона {{hobbies.food}}, код внутри replaceFun()должен вернуть data['hobbies']['food'].

getObjProp (obj, propsName) {
    let propsArr = propsName.split('.')
    function rec(o, pName) {
      if (!o[pName] instanceof Array && o[pName] instanceof Object) {
        return rec(o[pName], propsArr.shift())
      }
      return o[pName]
    }
    return rec(obj, propsArr.shift())
  }
getObjProp({ data: { hobbies: { food: 'apple', drink: 'milk' } } }, 'hobbies.food')
// --> return  { food: 'apple', drink: 'milk' }

И окончательное replaceFun() должно быть таким:

replaceFun(str, data) {
    let self = this
    return str.replace(/{{([^{}]*)}}/g, (a, b) => {
      let r = self._getObjProp(data, b);
      console.log(a, b, r)
      if (typeof r === 'string' || typeof r === 'number') {
        return r
      } else {
        return self._getObjProp(r, b.split('.')[1])
      }
    })
  }

Экземпляр привязки данных с именем «Mog»

Нет, почему, просто назовите его «Мог».

class Mog {
  constructor (options) {
    this.$data = options.data
    this.$el = options.el
    this.$tpl = options.template
    this._render(this.$tpl, this.$data)
  }
  $setData (dataObj, fn) {
    let self = this
    let once = false
    let $d = new Proxy(dataObj, {
      set (target, property, value) {
        if (!once) {
          target[property] = value
          once = true
          self._render(self.$tpl, self.$data)
        }
        return true
      }
    })
    fn($d)
  }
  _render (tplString, data) {
    document.querySelector(this.$el).innerHTML = this._replaceFun(tplString, data)
  }
  _replaceFun(str, data) {
    let self = this
    return str.replace(/{{([^{}]*)}}/g, (a, b) => {
      let r = self._getObjProp(data, b);
      console.log(a, b, r)
      if (typeof r === 'string' || typeof r === 'number') {
        return r
      } else {
        return self._getObjProp(r, b.split('.')[1])
      }
    })
  }
  _getObjProp (obj, propsName) {
    let propsArr = propsName.split('.')
    function rec(o, pName) {
      if (!o[pName] instanceof Array && o[pName] instanceof Object) {
        return rec(o[pName], propsArr.shift())
      }
      return o[pName]
    }
    return rec(obj, propsArr.shift())
  }
}

Использование:

<!-- html -->
    <div id="app">
      <p>
        Hello everyone, my name is <span>{{name}}</span>, I am a mini <span>{{lang}}</span> framework for just <span>{{work}}</span>. I can bind data from <span>{{supports.0}}</span>, <span>{{supports.1}}</span> and <span>{{supports.2}}</span>. What's more, I was created by <span>{{info.author}}</span>, and was written in <span>{{info.jsVersion}}</span>. My motto is "<span>{{motto}}</span>".
      </p>
    </div>
    <div id="input-wrapper">
      Motto: <input type="text" id="set-motto" autofocus>
    </div>
<!-- javascript -->
let template = document.querySelector('#app').innerHTML
let mog = new Mog({
  template: template,
  el: '#app',
  data: {
    name: 'mog',
    lang: 'javascript',
    work: 'data binding',
    supports: ['String', 'Array', 'Object'],
    info: {
      author: 'Jrain',
      jsVersion: 'Ecma2015'
    },
    motto: 'Every dog has his day'
  }
})
document.querySelector('#set-motto').oninput = (e) => {
  mog.$setData(mog.$data, ($d) => {
    $d.motto = e.target.value
  })
}

Поиграть можно ЗДЕСЬ

Что еще…

Mog - это всего лишь экспериментальный проект для изучения привязки данных, он недостаточно изящный или функциональный. Но эта маленькая игрушка помогает мне многому научиться. Если вам это интересно, вы можете разветвить его ЗДЕСЬ и поиграть со своей идеей.

Спасибо за чтение!