vue响应式原理

180 阅读11分钟

前言

Vue最独特的特性之一就是非侵入式的响应式系统。数据模型仅仅是普通的JS对象,而当你修改他们的时候,视图会进行更新。这使得状态管理非常简单直接,不过理解其工作原理同样重要,这样你可以避开一些常见的问题。----官方文档

什么是响应式

<div id="app">
    <div>Price :¥{{ price }}</div>
    <div>Total:¥{{ price * quantity }}</div>
    <div>Taxes: ¥{{ totalPriceWithTax }}</div>
    <button @click="changePrice">改变价格</button>
</div>
var app = new Vue({
  el: '#app',
  data() {
    return {
      price: 5.0,
      quantity: 2
    };
  },
  computed: {
    totalPriceWithTax() {
      return this.price * this.quantity * 1.03;
    }
  },
  methods: {
    changePrice() {
      this.price = 10;
    }
  }
})

上例当price发生改变的时候,Vue知道自己需要做三件事

  • 更新页面上price的值
  • 计算表达式 price*quantity 的值,更新页面
  • 调用totalPriceWithTax 函数,更新页面 数据发生改变后,会对页面进行渲染,这就是Vue响应式,那么这一切是怎么做到的呢? 想完成这个过程,我们需要
  • 侦测数据变化
  • 收集视图依赖了哪些数据
  • 数据变化时,自动‘通知’需要更新的视图部分,并进行更新 对应的专业俗语是:
  • 数据劫持/数据代理
  • 依赖收集
  • 发布订阅模式

如何检测数据变化

Object.defineProperty和ES6的Proxy,这就是进行数据劫持或数据代理

Object.defineProperty的实现

Vue通过设置对象属性的setter/getter方法来监听数据的变化,通过getter收集依赖,而每个setter方法就是一个观察者,在数据变更的时候通知订阅者更新视图

function render () {
  console.log('模拟视图渲染')
}
let data = {
  name: '浪里行舟',
  location: { x: 100, y: 100 }
}
observe(data)
function observe (obj) { // 我们来用它使对象变成可观察的
  // 判断类型
  if (!obj || typeof obj !== 'object') {
    return
  }
  Object.keys(obj).forEach(key => {
    defineReactive(obj, key, obj[key])
  })
  function defineReactive (obj, key, value) {
    // 递归子属性
    observe(value)
    Object.defineProperty(obj, key, {
      enumerable: true, //可枚举(可以遍历)
      configurable: true, //可配置(比如可以删除)
      get: function reactiveGetter () {
        console.log('get', value) // 监听
        return value
      },
      set: function reactiveSetter (newVal) {
        observe(newVal) //如果赋值是一个对象,也要递归子属性
        if (newVal !== value) {
          console.log('set', newVal) // 监听
          render()
          value = newVal
        }
      }
    })
  }
}
data.location = {
  x: 1000,
  y: 1000
} //set {x: 1000,y: 1000} 模拟视图渲染
data.name // get 浪里行舟

上面这段代码的主要作用在于:observe这个函数传入一个 obj(需要被追踪变化的对象),通过遍历所有属性的方式对该对象的每一个属性都通过 defineReactive 处理,以此来达到实现侦测对象变化。值得注意的是,observe 会进行递归调用。 那我们如何侦测Vue中data 中的数据,其实也很简单:

class Vue {
    /* Vue构造类 */
    constructor(options) {
        this._data = options;
        this.observe(this._data);
        // this.observe(options);
        // return options;
    }
    observe(obj) {
        // 我们来用它使对象变成可观察的
        // 判断类型
        if (!obj || typeof obj !== 'object') {
            return;
        }
        let _this = this;
        Object.keys(obj).forEach((key) => {
            defineReactive(obj, key, obj[key]);
        });
        function defineReactive(obj, key, value) {
            // 递归子属性
            _this.observe(value);
            Object.defineProperty(obj, key, {
                enumerable: true, //可枚举(可以遍历)
                configurable: true, //可配置(比如可以删除)
                get: function reactiveGetter() {
                    console.log('get', value); // 监听
                    return value;
                },
                set: function reactiveSetter(newVal) {
                    _this.observe(newVal); //如果赋值是一个对象,也要递归子属性
                    if (newVal !== value) {
                        console.log('set', newVal); // 监听
                        _this.render();
                        value = newVal;
                    }
                },
            });
        }
    }
    render() {
        console.log('模拟视图渲染');
    }
}

let data = {
    name: '浪里行舟',
    location: { x: 100, y: 100 },
};

let vue = new Vue(data);
// console.dir(vue);
// vue.location = {
//     x: 1000,
//     y: 1000,
// }; //set {x: 1000,y: 1000} 模拟视图渲染
// vue.name; // get 浪里行舟
vue._data.location = {
    x: 1000,
    y: 1000,
}; //set {x: 1000,y: 1000} 模拟视图渲染
vue._data.name; // get 浪里行舟

或者

class Vue {
    /* Vue构造类 */
    constructor(options) {
        // this._data = options;
        // this.observe(this._data);
        this.observe(options);
        return options;
    }
    observe(obj) {
        // 我们来用它使对象变成可观察的
        // 判断类型
        if (!obj || typeof obj !== 'object') {
            return;
        }
        let _this = this;
        Object.keys(obj).forEach((key) => {
            defineReactive(obj, key, obj[key]);
        });
        function defineReactive(obj, key, value) {
            // 递归子属性
            _this.observe(value);
            Object.defineProperty(obj, key, {
                enumerable: true, //可枚举(可以遍历)
                configurable: true, //可配置(比如可以删除)
                get: function reactiveGetter() {
                    console.log('get', value); // 监听
                    return value;
                },
                set: function reactiveSetter(newVal) {
                    _this.observe(newVal); //如果赋值是一个对象,也要递归子属性
                    if (newVal !== value) {
                        console.log('set', newVal); // 监听
                        _this.render();
                        value = newVal;
                    }
                },
            });
        }
    }
    render() {
        console.log('模拟视图渲染');
    }
}

let data = {
    name: '浪里行舟',
    location: { x: 100, y: 100 },
};

let vue = new Vue(data);
console.dir(vue);
vue.location = {
    x: 1000,
    y: 1000,
}; //set {x: 1000,y: 1000} 模拟视图渲染
vue.name; // get 浪里行舟
// vue._data.location = {
//     x: 1000,
//     y: 1000,
// }; //set {x: 1000,y: 1000} 模拟视图渲染
// vue._data.name; // get 浪里行舟

这样我们只需要new一个Vue对象,就会将data中的数据进行追踪变化

不过有几个点需要注意一下

无法检测到对象属性的添加或删除(如data.location.a=1)

这是因为Vue通过Object.defineProperty来将对象的key转换为getter/setter的形式来追踪变化,但是getter/setter只能追踪一个数据是否被修改,无法追踪新增属性和删除属性。

如果是删除属性,我们可以用vm.$delete

那如果是新增属性,该怎么办呢?

  • 可以使用Vue.set(location,a,1)方法向嵌套对象添加响应式属性
  • 也可以给这个对象重新赋值,比如data.location={...data.loaction, a:1}

Object.defineProperty不能监听数组的变化,需要进行数组方法的重写

具体代码如下:

function render() {
  console.log('模拟视图渲染')
}
let obj = [1, 2, 3]
let methods = ['pop', 'shift', 'unshift', 'sort', 'reverse', 'splice', 'push']
// 先获取到原来的原型上的方法
let arrayProto = Array.prototype
// 创建一个自己的原型 并且重写methods这些方法
let proto = Object.create(arrayProto)
methods.forEach(method => {
  proto[method] = function() {
    // AOP
    arrayProto[method].call(this, ...arguments)
    render()
  }
})
function observer(obj) {
  // 把所有的属性定义成set/get的方式
  if (Array.isArray(obj)) {
    obj.__proto__ = proto
    return
  }
  if (typeof obj == 'object') {
    for (let key in obj) {
      defineReactive(obj, key, obj[key])
    }
  }
}
function defineReactive(data, key, value) {
  observer(value)
  Object.defineProperty(data, key, {
    get() {
      return value
    },
    set(newValue) {
      observer(newValue)
      if (newValue !== value) {
        render()
        value = newValue
      }
    }
  })
}
observer(obj)
function $set(data, key, value) {
  defineReactive(data, key, value)
}
obj.push(123, 55)
console.log(obj) //[1, 2, 3, 123,  55]

这种方法将数组的常用方法进行重写,进而覆盖掉原生的数组方法,重写之后的数组方法需能够被拦截,但是有些数组操作Vue时拦截不到,当然也就没有办法响应,比如:

obj.length-- // 不支持数组的长度变化

obj[0]=1 // 修改数组中第一个元素,也无法侦测数组的变化

Proxy实现

Proxy是ES6的一个新特性,Proxy的代理是针对整个对象的,而不是对象的某个属性,因此不同于Object.defineProperty的必须遍历对象的每个属性,Proxy只需要做一层代理就可以监听同级结构下的所有属性变化,当然对于深层结构,递归还是需要进行的。此外Proxy支持代理数组的变化

为什么要收集依赖

我们之所以要观察数据,其目的在于当数据的属性发生变化时,可以通知那些曾经使用了该数据的地方。比如第一例子中,模板中使用了price 数据,当它发生变化时,要向使用了它的地方发送通知。那如果多个Vue实例中共用一个变量,如下面这个例子:

let globalData = {
    text: '浪里行舟'
};
let test1 = new Vue({
    template:
        `<div>
            <span>{{text}}</span> 
        <div>`,
    data: globalData
});
let test2 = new Vue({
    template:
        `<div>
            <span>{{text}}</span> 
        <div>`,
    data: globalData
});

如果我们执行

globalData.text = '前端工匠';

此时我们需要通知test1和test2这两个Vue实例进行视图的更新,我们只有通过收集依赖才知道哪些地方依赖我的数据,以及数据更新时派发更新。那收集依赖是如何实现的?其中的核心思想就是“事件发布订阅模式”,接下来我们先介绍这两个重要角色--订阅者Dep和观察者Watcher,然后阐述收集依赖是如何实现的

订阅者Dep

为什么引入Dep

收集依赖需要为依赖找一个存储依赖的地方,为此我们创建了Dep,它用来收集依赖,删除依赖和向依赖发送消息等。

于是我们先来实现一个订阅者Dep类,用于解耦属性的依赖收集和派发更新操作,说的具体点,它的主要作用是用来存放Watcher观察者对象,我们可以把Watcher理解成一个中介的角色,数据发生变化的时候通知它,然后它再通知其他地方

Dep的简单实现

class Dep {
    constructor () {
        /* 用来存放Watcher对象的数组 */
        this.subs = [];
    }
    /* 在subs中添加一个Watcher对象 */
    addSub (sub) {
        this.subs.push(sub);
    }
    /* 通知所有Watcher对象更新视图 */
    notify () {
        this.subs.forEach((sub) => {
            sub.update();
        })
    }
}

以上代码主要做两件事

  • 用addSub方法可以在目前的Dep对象中增加一个Watcher的订阅操作
  • 用notify方法通知目前Dep对象的subs重的所有Watcher对象触发更新操作。

所以当需要依赖收集的时候调用 addSub,当需要派发更新的时候调用 notify。调用也很简单:

let dp = new Dep()
dp.addSub(() => {
    console.log('emit here')
})
dp.notify()

观察者Watcher

为什么引入Watcher

当属性发生变化后,我们要通知用到数据的地方,而是用这个数据的地方有很多,而且类型还不一样,既有可能是模版,也有可能是用户写的一个watch,这时需要抽象出一个能集中处理这些情况的类,然后我们在收集依赖阶段只收集这个封装好的类的实例进来,通知也只通知它一个,再由它负责通知其它地方,

收集依赖的目的是将观察者Watcher对象存放到当前闭包重的订阅者Dep的subs 中形成如下所示的这样一个关系

Watcher的简单实现

class Watcher {
  constructor(obj, key, cb) {
    // 将 Dep.target 指向自己
    // 然后触发属性的 getter 添加监听
    // 最后将 Dep.target 置空
    Dep.target = this
    this.cb = cb
    this.obj = obj
    this.key = key
    this.value = obj[key]
    Dep.target = null
  }
  update() {
    // 获得新值
    this.value = this.obj[this.key]
   // 我们定义一个 cb 函数,这个函数用来模拟视图更新,调用它即代表更新视图
    this.cb(this.value)
  }
}

以上就是Watcher的简单实现,在执行构造函数的时候,将Dep.target指向自身,从而使得收集到了对应的Watcher,在派发更新的时候取出Watcher,然后执行update函数

收集依赖

所谓的依赖,其实就是Watcher。至于如何收集依赖,总结起来就一句话,在getter中收集依赖,在setter中触发依赖。先收集依赖,即把用到该数据的地方收集起来,然后等属性发生变化时,把之前收集好的依赖循环触发一遍就行了。

具体来说,当外界通过Watcher读取数据时,便会触发getter从而将Watcher添加到依赖中,哪个Watcher触发了getter,就把哪个Watcher收集到Dep中。当数据发生变化时,会循环依赖列表,把所有的Watcher都通知一遍。

最后我们对 defineReactive 函数进行改造,在自定义函数中添加依赖收集和派发更新相关的代码,实现了一个简易的数据响应式。

function observe (obj) {
  // 判断类型
  if (!obj || typeof obj !== 'object') {
    return
  }
  Object.keys(obj).forEach(key => {
    defineReactive(obj, key, obj[key])
  })
  function defineReactive (obj, key, value) {
    observe(value)  // 递归子属性
    let dp = new Dep() //新增
    Object.defineProperty(obj, key, {
      enumerable: true, //可枚举(可以遍历)
      configurable: true, //可配置(比如可以删除)
      get: function reactiveGetter () {
        console.log('get', value) // 监听
     // 将 Watcher 添加到订阅
       if (Dep.target) {
         dp.addSub(Dep.target) // 新增
       }
        return value
      },
      set: function reactiveSetter (newVal) {
        observe(newVal) //如果赋值是一个对象,也要递归子属性
        if (newVal !== value) {
          console.log('set', newVal) // 监听
          render()
          value = newVal
     // 执行 watcher 的 update 方法
          dp.notify() //新增
        }
      }
    })
  }
}

class Vue {
    constructor(options) {
        this._data = options.data;
        observer(this._data);
        /* 新建一个Watcher观察者对象,这时候Dep.target会指向这个Watcher对象 */
        new Watcher();
        console.log('模拟视图渲染');
    }
}

当render function被渲染的时候,读取所需对象的值,会触发reactiveGetter函数把当前Watcher对象(存放在Dep.target中)收集到Dep类中去,之后如果修改对象的值,则会触发 reactiveSetter 方法,通知 Dep 类调用 notify 来触发所有 Watcher 对象的 update 方法更新对应视图。

总结

  • 在new Vue()后,Vue会调用_init函数进行初始化,也就是init过程,在这个过程中,Data通过Observer转换成了setter/getter的形式,来对数据追踪变化,当被设置的对象被读取的时候回执行getter函数,而在当被赋值的时候会执行setter函数
  • 当render function执行的时候,因为会读取所需对象的值,所以会触发getter函数,从而将Watcher添加到依赖中进行依赖收集
  • 在修改对象的值的时候,会触发setter,setter通知之前收集依赖得到的Dep重的每一个Watcher,告诉它们自己的值改变了,需要重新渲染视图。这个时候,这些watcher就会开始调用update来更新视图

原文链接:segmentfault.com/a/119000001…

vue.js 是采用数据劫持结合发布者-订阅者模式的方式,通过Object.defineProperty()来劫持各个属性的setter,getter,在数据变动时发布消息给订阅者,触发相应的监听回调

第一步:需要observe的数据对象进行递归遍历,包括子属性对象的属性,都加上 setter和getter这样的话,给这个对象的某个值赋值,就会触发setter,那么就能监听到了数据变化

第二步:compile解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图

第三步:Watcher订阅者是Observer和Compile之间通信的桥梁,主要做的事情是: 1、在自身实例化时往属性订阅器(dep)里面添加自己 2、自身必须有一个update()方法 3、待属性变动dep.notice()通知时,能调用自身的update()方法,并触发Compile中绑定的回调,则功成身退