手动实现MVVM双向绑定(v-model原理)

1,257 阅读6分钟

目标

  • 掌握Object.defineProperty的使用
  • 掌握js的观察者模式
  • 手动实现v-model

1- 前置知识

1. Object.defineProperty

  • 作用就是直接在一个对象上定义一个新属性,或者修改一个已经存在的属性 Object.defineProperty(对象, 属性, 描述符对象)

  • 为对象的属性直接赋值的情况下,对象的属性也可以修改和删除,但是通过Object.defineProperty()定义属性,通过描述符的设置可以进行更精准的控制对象属性

  <script>
  var Person={}
  var reactiveName = "zhangsan"
  
  Object.defineProperty(Person, 'name', {
      // 可配置
      configurable: true, 
      // 可枚举
      enumerable: true,
      // 访问器属性`get`
      // 读取`Person.name`的时候触发的方法
      get () {
     
        console.log('执行get')
        return reactiveName
      },
      // 访问器属性`set`
      // 修改`Person.name`的时候触发的方法
      set (newValue) {
        console.log('执行set')
        reactiveName = newValue
       
      }
  })
  
  
  console.log(Person.name); // 'zhangsan'
  Person.name = 'lisi';
  console.log(Person.name); // 'lisi'
  </script>

2. 观察者模式

<script>
    // 订阅者
    class Dep {
      constructor() {
        this.watchers = []
      }
      // 添加观察者
      addWatcher (watcher) {
        this.watchers.push(watcher)
      }

      // 通知
      notify  () {
        this.watchers.forEach(watcher => {
          watcher.update()
        })
      }
      
    }
    

    // 观察者
    class Watcher {
      constructor(callback) {
        this.callback = callback
      }
      update () {
        console.log('update');
        this.callback();
      }
    }
    
    
    // 创建订阅者
    const dep = new Dep();
    // 创建观察者
    const watcher1 = new Watcher(() => console.log('watcher1'));
    const watcher2 = new Watcher(() => console.log('watcher2'));
    // 添加观察者
    dep.addWatcher(watcher1)
    dep.addWatcher(watcher2)
    // 触发通知
    dep.notify();
</script>

3. 小结

  • Object.defineProperty用来定义对象的属性,可以配置getter和setter访问器
  • 观察者模式就是有两个订阅者和观察者对象,订阅者可以收集观察者,并且在合适的时机触发观察者执行更新操作
  • 实现v-model时,就是在get方法里面收集观察者,set方法里面触发更新操作

2- 实现v-model

1. 采用类似Vue的调用方式

  <div id="app">
    <input type="text" v-model="msg" />
  </div>

  <script>
  const vm = new MyVue({
    el: '#app',
    data: {
      msg: 'abc'
    }
  })
  </script>

所以我们要创建MyVue对象

<script>
  class MyVue {
    constructor({el, data}) {
      this.container = document.querySelector(el);
      this.data = data;
    }
  }
</script>

2. 初始化data数据

  • 使用Object.defineProperty定义data中的每个属性
<script>
  class MyVue {
    constructor({el, data}) {
      this.container = document.querySelector(el);
      this.data = data;
      
      // 初始化data
      this.initData(this, this.data)
    }
    
    initData(vm, data) {
      for (let key in data) {
        let initVal = data[key];

        Object.defineProperty(vm, key, {
          get() {
            return initVal
          },
          set(val) {
            initVal = val;
          }
        })
        // 判断是否是对象,递归调用initData
        if (Object.prototype.toString.call(data[key]) === "[object Object]") {
          this.initData(vm[key], data[key])
        }
      }
    }
  }
</script>
<script>
  const vm = new MyVue({
    el: '#app',
    data: {
      msg: 'abc'
    }
  });
  
  console.log(vm.msg) // abc
</script>

3. 初始化v-model表单

<script>
	class MyVue {
      constructor({ el, data }) {
        this.container = document.querySelector(el);
        this.data = data;

        // 初始化data
        this.initData(this, this.data)

        // 初始化v-model表单
        this.initVModel();
      }
      initVModel () {
        // 获取所有v-model元素
        const nodes = this.container.querySelectorAll('[v-model]');

        nodes.forEach(node => {
          const key = node.getAttribute('v-model');
          
          // 初始化赋值
          node.value = this[key];

          // 监听输入事件
          node.addEventListener('input', ev => {
            this[key] = ev.target.value;
          }, false)

          
        });
      }
    }
</script>  

上面的代码只能拿到data中第一层的数据,如果是这样绑定:

<input type="text" v-model="obj.a" />

那其实获取this['obj.a']是获取不到数据的,所以还需要重写一个获取data数据的方法:

getData(str) {
  const arr = str.split('.'); // ["obj", "a"]

  const res = arr.reduce((target, item) => {
    // 第一次遍历 target等于data
    return target[item] // return之后,target就等于target[item] ===> data.obj
    // 第二次遍历  target等于data.obj
    // return之后,target就等于data.obj.a
  }, this)

  return res;
},	
initVModel () {
  // 获取所有v-model元素
  const nodes = this.container.querySelectorAll('[v-model]');

  nodes.forEach(node => {
    const key = node.getAttribute('v-model');

    // 初始化赋值
    // node.value = this[key]; // 如果key是"obj.a"这种字符串,获取不到数据
    node.value = this.getData(key); 

    // 监听输入事件
    node.addEventListener('input', ev => {
      // this[key] = ev.target.value; // 也要处理key是"obj.a"格式的字符串
      const arr = key.split("."); // ["obj", "a"]

      // 如果arr只有一个元素,直接赋值
      if (arr.length === 1) {
        this[key] = node.value;
        return;
      }
	
      // 获取倒数第二级的对象(对于`obj.a`,那就获取this.obj)
      const res = this.getData(key.substring(0, key.lastIndexOf("."))) // this.obj
	
      // 赋值最后一级(this.obj.a = node.value)
      res[arr[arr.length - 1]] = node.value;
    }, false)


  });
},
    

此时已经实现了视图变化触发模型的变化,接下来实现模型的变化触发视图的变化

4. 模型的变化触发视图更新

1. 引入观察者模式

	// 订阅者
    class Dep {
      constructor() {
        this.watchers = []
      }
      // 添加观察者
      addWatcher (watcher) {
        this.watchers.push(watcher)
      }

      // 通知
      notify  () {
        this.watchers.forEach(watcher => {
          watcher.update()
        })
      }
      
    }
    

    // 观察者
    class Watcher {
      constructor(callback) {
        this.callback = callback
      }
      update () {
        console.log('update');
        this.callback();
      }
    }

2. 通知触发的时机

模型驱动视图更新,就是当修改vm.msg时(比如vm.msg="123"),需要触发dom更新。所以我们需要在set方法里面调用dep.notify()

// 初始化data
initData(vm, data) {
  for (let key in data) {
    let initVal = data[key];
    //++++++++++++++++++++++++++++++
    const dep = new Dep(); 
    //++++++++++++++++++++++++++++++
    Object.defineProperty(vm, key, {
      get() {
        return initVal
      },
      set(val) {
        initVal = val;
        //++++++++++++++++++++++++++++++
        // 通知视图更新
        dep.notify();
        //++++++++++++++++++++++++++++++
      }
    })
    // 判断是否是对象,递归调用initData
    if (Object.prototype.toString.call(data[key]) === "[object Object]") {
      this.initData(vm[key], data[key])
    }
  }
}

3. 创建观察者

观察者需要更新dom,所以需要在初始化v-model表单的时候创建观察者

initVModel () {
  // 获取所有v-model元素
  const nodes = this.container.querySelectorAll('[v-model]');

  nodes.forEach(node => {
    const key = node.getAttribute('v-model');

    // 初始化赋值
    // node.value = this[key]; // 如果key是"obj.a"这种字符串,获取不到数据
    node.value = this.getData(key); 
    
    //++++++++++++++++++++++++++++++
    // 创建观察者
    const watcher = new Watcher(() => {
      node.value = this.getData(key)
    })
    //++++++++++++++++++++++++++++++
    
    // 监听输入事件
    node.addEventListener('input', ev => {
      // this[key] = ev.target.value; // 也要处理key是"obj.a"格式的字符串
      const arr = key.split("."); // ["obj", "a"]

      // 如果arr只有一个元素,直接赋值
      if (arr.length === 1) {
        this[key] = node.value;
        return;
      }
	
      // 获取倒数第二级的对象(对于`obj.a`,那就获取this.obj)
      const res = this.getData(key.substring(0, key.lastIndexOf("."))) // this.obj
	
      // 赋值最后一级(this.obj.a = node.value)
      res[arr[arr.length - 1]] = node.value;
    }, false)


  });
},

4. 添加观察者

当创建了观察者后,应该马上添加到订阅者中,但是订阅者怎么知道已经创建了观察者呢?

  • 这时我们在观察者的构造函数里面获取一下data数据,就会执行Object.defineProperty定义的的get方法。所以我们在get方法里面添加观察者。
// 创建观察者时传入this和key
const watcher = new Watcher(this, key, () => {
  node.value = this.getData(key)
})

// 观察者
class Watcher {
  constructor(vm, key, callback) {
    this.callback = callback;
    // 获取data数据,触发get方法
    vm.getData(key);
  }
  update () {
    console.log('update');
    this.callback();
  }
}


// get方法中添加观察者
get() {
	dep.addWatcher(watcher)
	return initVal
},

5. 将观察者实例赋值给Dep的某个属性

上面dep.addWatcher(watcher)中的watcher并不存在,我们需要在观察者的构造函数中先将观察者实例赋值给Dep的某个属性,再获取data数据

// 观察者
class Watcher {
  constructor(vm, key, callback) {
    this.callback = callback;
    
    //将观察者实例赋值给Dep的target属性
    Dep.target = this;
    // 获取data数据,触发get方法
    vm.getData(key);
  }
  update () {
    console.log('update');
    this.callback();
  }
}



// get方法
get() {
	dep.addWatcher(Dep.target)
	return initVal
},

6. 避免反复添加观察者

上面已经实现了模型数据驱动视图更新,但是我们发现update方法会执行多次,那是因为dep.addWatcher(Dep.target)执行了多次,所以dep的watchers数组中有多个观察者,所以update方法会执行多次。解决方法就是获取data数据后重置Dep.target。

// 观察者
class Watcher {
  constructor(vm, key, callback) {
    this.callback = callback;
    
    //将观察者实例赋值给Dep的target属性
    Dep.target = this;
    // 获取data数据,触发get方法
    vm.getData(key);
    // 获取data数据后重置Dep.target,避免重复添加观察者
    Dep.target = null;
  }
  update () {
    console.log('update');
    this.callback();
  }
}


// get方法
get() {
	Dep.target && dep.addWatcher(Dep.target)
	return initVal
},

7. data多次设置为同样的值时不需要触发更新

set(val) {

  // 如果前后两次设置的值相等,就不触发更新
  if (initVal === val) {
    return;
  }

  initVal = val;

  dep.notify(); // 通知视图更新
}

完整代码

// 订阅者
class Dep {
  constructor() {
    this.watchers = []
  }
  // 添加观察者
  addWatcher(watcher) {
    this.watchers.push(watcher)
  }

  // 通知
  notify() {
    this.watchers.forEach(watcher => {
      watcher.update()
    })
  }

}


// 观察者
class Watcher {
  constructor(vm, key, callback) {
    this.callback = callback

    // 第二件事,把当前实例传给get方法
    Dep.target = this;

    // 第一件事,获取当前的data
    const val = vm.getData(key)

    // 为了防止重复添加观察者,添加完之后马上清空
    Dep.target = null;
  }
  update() {
    console.log('update');
    this.callback();
  }
}


class MyVue {
  constructor({
    el,
    data
  }) {
    this.container = document.querySelector(el);
    this.data = data;

    // 初始化数据
    this.initData(this, this.data)

    // 初始化v-model表单
    this.initVModel()
  }

  initData(vm, data) {
    for (let key in data) {
      let initVal = data[key];
      const dep = new Dep();
      Object.defineProperty(vm, key, {
        get() {
          // 只要获取data数据,就会进入get方法,然后添加观察者
          Dep.target && dep.addWatcher(Dep.target)
          return initVal
        },
        set(val) {

          // 如果前后两次设置的值相等,就不触发更新
          if (initVal === val) {
            return;
          }


          // console.log(key + '被修改数据')
          initVal = val;

          dep.notify(); // 通知视图更新
        }
      })
      // 判断是否是对象,递归调用initData
      if (Object.prototype.toString.call(data[key]) === "[object Object]") {
        this.initData(vm[key], data[key])
      }
    }

  }

  initVModel() {
    // 获取所有的v-model表单
    const nodes = this.container.querySelectorAll('[v-model]')
    // console.log(nodes)

    nodes.forEach(node => {
      // 获取v-model属性的值
      const key = node.getAttribute('v-model')


      // 把表单创建成观察者
      new Watcher(this, key, () => {
        node.value = this.getData(key)
      })



      // console.log(123, key, this[key])
      const val = this.getData(key)
      // console.log(234234, val)

      // 给表单赋值
      node.value = val

      // 视图驱动数据更新:监听表单事件,修改data。
      node.addEventListener('input', () => {
        // this[key] = node.value   // key === "obj.b"

        const arr = key.split("."); // ["obj", "b"]

        // 如果arr只有一个元素,直接赋值
        if (arr.length === 1) {
          this[key] = node.value;
          return;
        }

        // let res = this.getData(key) // this.obj.b ===> 2
        const res = this.getData(key.substring(0, key.lastIndexOf("."))) // this.obj

        // res = node.value;
        res[arr[arr.length - 1]] = node.value;
      })

    })
  }

  // 传入'a.b.c'这种格式的字符串,获取this.a.b.c的值
  getData(str) {
    const arr = str.split('.'); // ["obj", "a"]

    const res = arr.reduce((target, item) => {
      // 第一次遍历 target等于data
      return target[item] // return之后,target就等于target[item] ===> data.obj
      // 第二次遍历  target等于data.obj
      // return之后,target就等于data.obj.a
    }, this)

    return res;
  }
}

总结

  • Object.defineProperty
  • 观察者模式
  • v-model手动实现
    • 通过Object.defineProperty定义data的所有属性,在get方法中收集观察者,在set方法中触发观察者更新DOM