深入了解Vue响应式系统

1,829 阅读6分钟

前言

前面几篇文章一直都以源码分析为主,其实枯燥无味,对于新手玩家来说很不友好。这篇文章主要讲讲 Vue 的响应式系统,形式与前边的稍显 不同吧,分析为主,源码为辅,如果能达到深入浅出的效果那就更好了。

什么是响应式系统

「响应式系统」一直以来都是我认为 Vue 里最核心的几个概念之一。想深入理解 Vue ,首先要掌握「响应式系统」的原理。

从一个官方的例子开始

由于 Vue 不允许动态添加根级响应式属性,所以你必须在初始化实例前声明所有根级响应式属性,哪怕只是一个空值:


var vm = new Vue({
  data: {
    // 声明 message 为一个空值字符串
    message: ''
  },
  template: '<div>{{ message }}</div>'
})
// 之后设置 `message`
vm.message = 'Hello!'

如果你未在 data 选项中声明 message,Vue 将警告你渲染函数正在试图访问不存在的属性。

当然,仅仅从上面这个例子我们也只能知道,Vue不允许动态添加根级响应式属性。这意味我们需要将使用到的变量先在data函数中声明。

抛砖🧱引玉

新建一个空白工程,加入以下代码

export default {
    name: 'JustForTest',
    data () {
        return {}
    },
    created () {
        this.b = 555
        console.log(this.observeB)
        this.b = 666
        console.log(this.observeB)
    },
    computed: {
    	observeB () {
            return this.b
    	}
    }
}

运行上述代码,结果如下👇

555
555

😅显然,变量 b 并不是响应式的,这也就造成了二次赋值的时候,对应的 computed 并没有发生变化。接下来我们来深究下这其中的原因。

computed 与依赖收集

咱们简单看下 computed 属性定义与触发的流程👇

属性定义:

  1. initComputed
  2. 创建一个对应 computed 属性 key 值的 watcher 对象
  3. defineComputed(挂载代理函数,并把对应的属性代理到当前组件的对象上)

触发:

  1. 访问 computed 属性
  2. 执行对应的 computed 属性绑定下的函数

在这两个过程中,只有触发 computed 属性时,才会访问到 computed 中依赖的变量,比如👇

computed: {
    test () {
        return this.a + this.b
    }
}

第一次访问 this.test 时,其实也就是访问了 this.athis.b

真实的执行流程👇

  1. 触发 computed 属性的 getter
  2. 执行 evaluate
  3. 执行 get

前面说到,第一次访问 this.test 时也就是访问了 this.athis.b,其实也就是访问了它们的 getter👇

这里会对 Dep.target 进行判断后才接着走下一步,那么它的取值到底是什么呢?
我们看到在 Watcher.prototype.get 执行了 pushTarget(this)

function pushTarget (target) {
  targetStack.push(target);
  Dep.target = target;
}

这里是直接将 this(也就是 watcher 对象)存在了 Dep.target 上,于是我们在收集依赖的时候就能将变量对应的 watcher 存进去,大致的流程如下👇

相关代码:

Dep.prototype.depend = function depend () {
  if (Dep.target) {
    Dep.target.addDep(this);
  }
};
Watcher.prototype.addDep = function addDep (dep) {
  var id = dep.id;
  if (!this.newDepIds.has(id)) {
    this.newDepIds.add(id);
    this.newDeps.push(dep);
    if (!this.depIds.has(id)) {
      dep.addSub(this);
    }
  }
};
Dep.prototype.addSub = function addSub (sub) {
  this.subs.push(sub);
};

响应式更新

当变量的值变化时,就会触发 setter,这个时候变量依赖的 watcher 当然也要做出相应的更新👇

set: function reactiveSetter (newVal) {
      var value = getter ? getter.call(obj) : val;
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter();
      }
      // #7981: for accessor properties without setter
      if (getter && !setter) { return }
      if (setter) {
        setter.call(obj, newVal);
      } else {
        val = newVal;
      }
      childOb = !shallow && observe(newVal);
      dep.notify();
    }

对应的 notify 函数,这里的 subs 其实就是一个 watcher 数组👇

Dep.prototype.notify = function notify () {
  // stabilize the subscriber list first
  var subs = this.subs.slice();
  if (process.env.NODE_ENV !== 'production' && !config.async) {
    // subs aren't sorted in scheduler if not running async
    // we need to sort them now to make sure they fire in correct
    // order
    subs.sort(function (a, b) { return a.id - b.id; });
  }
  for (var i = 0, l = subs.length; i < l; i++) {
    subs[i].update();
  }
}

更加方便的定义响应式属性

文档中提到,Vue 建议在根级声明变量。通过上面的分析我们也知道,在 data 函数中 声明变量则使得变量变成「响应式」的,那么是不是所有的情况下,变量都只能在 data 函数中 事先声明呢?

$set

Vue 其实提供了一个 $set 的全局函数,通过 $set 就可以动态添加响应式属性了。

export default {
	data () {
        return {}
    },
    created () {
        this.$set(this, 'b', 666)
    },
}

然而,执行上面这段代码后控制台却报错了
[Vue warn]: Avoid adding reactive properties to a Vue instance or its root $data at runtime - declare it upfront in the data option.

其实,对于已经创建的实例,Vue 不允许动态添加根级别的响应式属性。 $set 函数的执行逻辑:

  • 判断实例是否是数组,如果是则将属性插入
  • 判断属性是否已定义,是则赋值后返回
  • 判断实例是否是 Vue 的实例或者是已经存在 ob 属性(其实也是判断了添加的属性是否属于根级别的属性),是则结束函数并返回
  • 执行 defineReactive?1,使得属性成为响应式属性
  • 执行 ob.dep.notify(),通知视图更新

相关代码:

function set (target, key, val) {
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(("Cannot set reactive property on undefined, null, or primitive value: " + ((target))));
  }
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key);
    target.splice(key, 1, val);
    return val
  }
  if (key in target && !(key in Object.prototype)) {
    target[key] = val;
    return val
  }
  var ob = (target).__ob__;
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    );
    return val
  }
  if (!ob) {
    target[key] = val;
    return val
  }
  c(ob.value, key, val);
  ob.dep.notify();
  return val
}

数组操作

为什么要重写数组方法?

Vue 无法监听到数组的 length 的变化,也就意味着使用方法操作数组时,setter 无法捕获到赋值操作

为什么要使用继承而不是直接在 prototype 上重写

避免覆盖其他第三方库/框架上的数组方法

重写的方法有这些:

  • push
  • pop
  • shift
  • unshift
  • splice
  • sort
  • reverse

那么这些方法是怎么重写的呢?
首先,定义一个 arrayMethods 继承 Array

var arrayProto = Array.prototype;
var arrayMethods = Object.create(arrayProto);

然后,利用 object.defineProperty,将 mutator 函数绑定在数组操作上:

def(arrayMethods, method, function mutator () { ... })

最后在调用数组方法的时候,会直接执行 mutator函数。源码中,对这三种方法做了特别 处理:

  • push
  • unshift
  • splice

因为这三种方法都会增加原数组的长度。当然如果调用了这三种方法,会再调用一次 observeArray 方法(这里的逻辑就跟前面提到的一样了)
最后的最后,调用 notify 函数

核心代码:

methodsToPatch.forEach(function (method) {
 // cache original method
 var original = arrayProto[method];
 def(arrayMethods, method, function mutator () {
   var args = [], len = arguments.length;
   while ( len-- ) args[ len ] = arguments[ len ];

   var result = original.apply(this, args);
   var ob = this.__ob__;
   var inserted;
   switch (method) {
     case 'push':
     case 'unshift':
       inserted = args;
       break
     case 'splice':
       inserted = args.slice(2);
   }
   if (inserted) { ob.observeArray(inserted); }
   // notify change
   ob.dep.notify();
   return result
 });
});

总结

「响应式原理」借助了这三个类来实现,分别是:

  • Watcher
  • Observer
  • Dep

初始化阶段,利用 getter 的特点,监听到变量被访问 ObserverDep 实现对变量的「依赖收集」, 赋值阶段利用 setter 的特点,监听到变量赋值,利用 Dep 通知 Watcher,从而进行视图更新。

avatar

参考资料

深入响应式原理

扫描下方的二维码或搜索「tony老师的前端补习班」关注我的微信公众号,那么就可以第一时间收到我的最新文章。