VUE2 数据驱动的实现原理

209 阅读5分钟

源码层面分析 Vue2 数据驱动的实现原理


数据驱动的基本思想是:使用数据来描述应用的状态,将界面的修改与数据的修改绑定起来,实现数据的任何修改都能直接实时的反映到界面上。

一 v-model(双向绑定) 的实现原理

双向绑定的方向之一:dom 变化 => 更新数据,这个过程就是 input / select 等元素的 value 改变,结合 change / click 等事件实现的,过程较为简单,不在此讨论。

下面介绍双向绑定的另一个方向:数据变化 => 更新 dom。

要实现这个过程需要解决两个关键问题:

  • 如何知道数据更新。
  • 数据更新后,如何通知变化。

1. 如何知道数据更新

js 的原生方法 Object.defineProperty(),具有监听对象属性的存取器属性,即对象属性 setter 和 getter。当访问一个属性或修改一个属性的时候,该属性的 set 和 get 方法会被触发,通过这一特性可以劫持该属性。

let data ={ name:'tcy' };
Object.defineProperty(data,'name',{
  set: function(newValue) {
    console.log('更新了data的name:' + newValue);
  },
  get: function() {
    console.log('获取data数据name');
  }
})
const name = data.name; // 访问,会触发代码里的 get 方法
data.name = "fyn"; // 修改,会触发代码里的 set 方法

2. 数据更新后,如何通知变化

数据驱动流程.png

vue 采用了观察者模式(发布/订阅模式)来收集和通知数据的变化。

观察者模式.png

  • observe: 观察者,即监听数据,并为每个数据建立一个发布类(Dep)。
  • Dep:发布类,维护数据与订阅者之间的关系,当数据发生更新时,通知该数据的订阅者(Watcher)。
  • Watcher:订阅类,接受发布类的数据变更通知。

下面是一段从源码中拷出简化的代码,可在浏览前上直接运行调试:

/**
 * 发布器
 */
let uid = 0; // 发布器的uid
class Dep{
  constructor () {
    this.id = uid++; // 发布器的标识,每次加 1 以确保唯一
    this.subs = []; // 订阅者集合
  }
  // 添加订阅者实例对象
  addSub(watcher){
    this.subs.push(watcher);
  }
  //移除订阅者实例对象
  removeSub (watcher) {
    remove(this.subs, watcher)
  }
  // 依赖收集函数,在 getter 中执行,在 Dep.target 上找到当前 watcher,并添加依赖
  depend() { 
    Dep.target && Dep.target.addDep(this)
  }
  //通知所有订阅者
  notify () {
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update();//更新
    }
  }
}
//记录当前的watcher实例
Dep.target = null;
const targetStack = []
function pushTarget (_target) {
  if (Dep.target) targetStack.push(Dep.target)
  Dep.target = _target
}
function popTarget () {
  Dep.target = targetStack.pop()
}
 
/**
 * 订阅者 Watcher(core/observer/watcher.js)
 * 在 vue 实例初始化(initState)的时候,执行到 initComputed(core/instance/state.js) / initWatch 时会创建 Watcher 实例
 * 组件挂载时会创建 Watcher 实例(core/instance/lifecycle.js  mountComponent)
 */
class Watcher{
  constructor (
    vm,//vm数据对象
    expOrFn,//待监听的属性表达式
    cb//监听到变化后的回调函数
    ){
	  this.vm=vm;
	  this.expOrFn = expOrFn;
	  this.cb = cb;
	  this.value= this.get();
    }
  // 添加自身订阅者到发布器
  addDep (dep) {
    dep.addSub(this)
  }
  // 通知更新
  update(){
    this.run();
  }
  //实现视图的更新
  run(){
    let oldValue = this.value//更新前数据
    let value =  this.get();//获取最新值
    if(value != oldValue){
      this.cb.call(this.vm);
    }
  }
  //获取value值,并进行依赖收集
  get(){
    //将自身watcher订阅实例设置给Dep.target
    pushTarget(this);
    //这一步很重要,获取属性值,同时将订阅者实例添加到发布器中
    let value = this.expOrFn.call(this.vm);
    //将订阅实例从target栈中取出并设置给Dep.target
    popTarget();
    return value;
  }
}
/**
 * observe 方法位于源码中的 src/core/observe/index.js
 * 在 vue 实例初始化(initState)的时候,执行到 initProvide,initProps,initData等方法时,会调用 observe 方法
 * 在实际源码中 observe 方法经过一系列判断后,返回的是 Observer 类,
 * Observer 类中的 walk 方法,等同于此处的 observe 方法
 * 此处代码有所省略,但原理上等同。
 */
function observe(data){
  //获取所有的属性进行遍历
  const keys = Object.keys(data)
  for (let i = 0; i < keys.length; i++) {
    let val = data[keys[i]];
    defineReactive(data, keys[i],val); // 创建监听
  }
}

function defineReactive(obj, key, val){
  // 为每个 key 都创建一个 dep
  let dep = new Dep();
  Object.defineProperty(obj, key, {
    enumerable: true,//可枚举
    configurable: true,//可配置
    get: function reactiveGetter () {
      // 判断是否有Watcher正在进行依赖收集
      // 如果有的话,调用dep.depend(),表示“我被调用了,它依赖我,请记录”
      if (Dep.target) {
      	dep.depend();
      }
      return val;
    },
    set: function reactiveSetter (newVal) {
        val = newVal;
        dep.notify(); // 通知所有订阅者
    }
  })
}

/**
 * TEST
 * 第一步,为每个属性创建一个发布器,并设置set,get劫持
 * 实际中,执行到 initProvide,initProps,initData 等方法时,会调用 observe 方法
 */
//对象数据
let data ={name:'tcy',age:'20',sex:'male'};
observe(data);
/**
 * TEST
 * 第二步,创建监听并实现依赖收集
 * 实际源码,new Watcher 接收的第一个参数一般是当前的 vue 实例,第二个参数是要监听的数据待或监听的属性表达式
 * 从而将数据与当前vue实例绑定,Dep 发布类从而能收集数据与订阅者
 */ 
var watcher = new Watcher(data,() => {
  "name"+data.name + "age"+data.age
},() => {
  console.log("实现视图更新");
});
 
// 第三步,数据变化,触发视图更新
data.name = "fyn"; // 实现视图更新
data.sex = "female"; // 没有在watcher中,所以不会创建监听和收集依赖

二 props/data/computed等中的属性 为什么能用 this.xxx 直接访问?

vue 源码定义了一个 proxy 的代理方法。

src/core/instance/state.js:

const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
}

/**
 * target:  目标对象,一般就是 vm(vue 实例)
 * sourceKey:数据来源 key,例如_props
 * key: 被代理的 key, 就是实际的数据属性
 */
export function proxy (target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  // 将 key 挂到 target 上
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

以 props 为例如:

function initProps (vm: Component, propsOptions: Object) {
  const propsData = vm.$options.propsData || {}
  const props = vm._props = {}
  // cache prop keys so that future props updates can iterate using Array
  // instead of dynamic object key enumeration.
  const keys = vm.$options._propKeys = []
  const isRoot = !vm.$parent
  // root instance props should be converted
  if (!isRoot) {
    toggleObserving(false)
  }
  for (const key in propsOptions) {
    keys.push(key)
    const value = validateProp(key, propsOptions, propsData, vm)
    defineReactive(props, key, value)
    // static props are already proxied on the component's prototype
    // during Vue.extend(). We only need to proxy props defined at
    // instantiation here.
    if (!(key in vm)) {
      proxy(vm, `_props`, key)
    }
  }
  toggleObserving(true)
}

第二个参数为 vm.options.props定义。第一行读取了vm.options.props 定义。第一行读取了 vm.options.propsData,它包含了父组件传递进来的属性值, defineReactive(props, key, value)方法运行,便将 vm.$options.props 中的属性值通过 Object.defineProperty() 方法处理后赋给了 props 变量(第3行:const props = vm._props = {}), props 和 vm._props 指向同一个引用,因此父组件传递进来的属性值实际上都赋给了vm._props。

后续执行 proxy(vm, _props, key),调用上述的代理方法,实现了把 vm._props 中的属性赋给了 vm 本身。


参考:恰恰虎