Vue中的各种响应式原理

简介

使用Vue开发应用,当我们修改Vue组件的data的属性后,视图会自动更新,这种特性我们可以称为“响应式”。那么Vue是如何实现响应式的呢?即Vue使如何实现我们修改data属性的值后,视图能够自动更新的呢?

简单地说,Vue在组件初始化时候,通过发布-订阅模式将data的属性设置为响应式,data的属性作为发布者,Vue会在渲染时候创建订阅者,订阅者会订阅data的属性值变化,进行更新视图的操作。

除了渲染需要订阅data的属性的变化,computed和watch也需要订阅data属性变化,它们都是通过一个名为“Watcher”的类来实现订阅的。

Vue通过数据代理技术来实现发布-订阅模式。

下面我们介绍Vue中使用到的数据代理技术,并介绍Vue组件初始化时候是如何把data设置为响应式的,然后介绍一下computed和watch的实现原理,最后简单介绍一下Watcher这个类。

数据代理

我们知道,Vue中我们修改data的属性的值时候,会触发视图更新,因此很容易想到,Vue修改了data的属性的行为。让用户设置data属性时候可以做相应地操作。

我们可以修改数据的属性的行为,当我们在访问或者修改对象的某个属性时,访问或者修改的行为实际是我们修改后的,这样我们就可以进行额外的操作或者修改返回的结果。这种让我们指定的行为代替数据的默认行为的技术叫“数据代理”。

前端面试刷题网站灵题库,收集大厂面试真题,相关知识点详细解析。】

在Vue2.0中,使用Object.defineProperty()方法来进行数据代理,但是这种方法无法代理数组类型的数据属性,Vue2.0中通过改写数组方法的方式来监听数组的改变。在Vue3.0时候改用ES6的新特性Proxy来进行数据代理,就可以方便地监听数组变化了。这两种数据代理方法的详细用法请参考文章【1】。

把data设置为响应式

在Vue实例化时候,Vue会把data设置为响应式,即让用户修改data属性时候,依赖这个属性的地方能够被通知到,从而做出响应

其中有两个比较重要的类,DepWatcher,后面会介绍到。

下面看如何将data设置为响应式,实例代码会将Vue源码精简和简单修改,省略与本节无关的细节。

首先看Vue组件实例化时候对data的处理,Vue会将组件的data的每个属性定义get和set方法,在get中收集依赖(即将订阅者保存),在set中通知订阅者。

// 调用 walk 方法,遍历 data 中的每一个属性,监听数据的变化。
function walk(obj) {
  const keys = Object.keys(obj);
  for (let i = 0; i < keys.length; i++) {
    defineReactive(obj, keys[i]);
  }
}

// 执行 defineProperty 监听数据读取和设置。
function defineReactive(obj, key, val) {
  // 为每个属性创建 Dep(依赖搜集的容器,后文会讲)
  const dep = new Dep();
  // 绑定 get、set
  Object.defineProperty(obj, key, {
    get() {
      const value = val;
      // 如果有 target 标识,则进行依赖收集
      if (Dep.target) {
        dep.depend();
      }
      return value;
    },
    set(newVal) {
      val = newVal;
      // 修改数据时,通知页面重新渲染
      dep.notify();
    },
  });
}

代码中的Dep是一个发布-订阅的实现,我们看到在data的属性的get方法中使用dep.depend()收集依赖,在set方法中使用dep.notify()通知订阅者。

下面看Dep的代码

class Dep {
  // 根据 ts 类型提示,我们可以得出 Dep.target 是一个 Watcher 类型。
  static target: ?Watcher;
// subs 存放搜集到的 Watcher 对象集合
subs: Array<Watcher>;
constructor() {
  this.subs = [];
}
addSub(sub: Watcher) {
  // 搜集所有使用到这个 data 的 Watcher 对象。
  this.subs.push(sub);
}
depend() {
  if (Dep.target) {
    // 搜集依赖,最终会调用上面的 addSub 方法
    Dep.target.addDep(this);
  }
}
notify() {
  const subs = this.subs.slice();
  for (let i = 0, l = subs.length; i < l; i++) {
    // 调用对应的 Watcher,更新视图
    subs[i].update();
  }
}
}

这里的Watcher是订阅者用来订阅dep的类,通过实例化Watcher并传入订阅的值和回调来订阅,dep会在订阅的值改变后发布给订阅者。

下面看Watcher的代码

class Watcher {
  constructor(vm: Component, expOrFn: string | Function) {
    // 将 vm._render 方法赋值给 getter。
    // 这里的 expOrFn 其实就是 vm._render,后文会讲到。
    this.getter = expOrFn;
    this.value = this.get();
  }
  get() {
    // 给 Dep.target 赋值为当前 Watcher 对象
    Dep.target = this;
    // this.getter 其实就是 vm._render
    // vm._render 用来生成虚拟 dom、执行 dom-diff、更新真实 dom。
    const value = this.getter.call(this.vm, this.vm);
    return value;
  }
  addDep(dep: Dep) {
    // 将当前的 Watcher 添加到 Dep 收集池中
    dep.addSub(this);
  }
  update() {
    // 开启异步队列,批量更新 Watcher
    queueWatcher(this);
  }
  run() {
    // 和初始化一样,会调用 get 方法,更新视图
    const value = this.get();
  }
}

渲染界面时候会实例化Watcher,从而订阅渲染用到的data的属性。

渲染的代码如下

const updateComponent = () => {
  vm._update(vm._render());
};
// 结合上文,我们就能得出,updateComponent 就是传入 Watcher 内部的 getter 方法。
new Watcher(vm, updateComponent);

// new Watcher 会执行 Watcher.get 方法
// Watcher.get 会执行 this.getter.call(vm, vm) ,也就是执行 updateComponent 方法
// updateComponent 会执行 vm._update(vm._render())

// 调用 vm._render 生成虚拟 dom
// 调用 vm._update(vnode) 渲染虚拟 dom

渲染视图时候实例化Watcher并传递参数getter为updateComponent。

实例化时候,调用Watcher的get方法,这个方法首先执行Dep.target = this(注意,这是精简后的代码,还有其他与当前无关的逻辑后面会提及),将自身绑定到调用getter,即updateComponent。

在执行updateComponent的过程中,会用到data的某些属性,这样就会触发属性的get方法,在上面设置data响应式代码中我们看到get方法判断如果存在Dep.target,就将这个依赖收集到dep的依赖池(subs)中。

当data属性改变,会触发set方法,从而调用dep.notify(),在dep.notify方法中调用每个watcher的update方法,然后将watcher加入到异步队列中。

在下个tic清空异步队列时候(flushSchedulerQueue)会调用watcher.runwatcher.run调用getter方法,即updateComponent,从而更新视图。

简单总结为,在组件初始化时候遍历data的属性,为每个属性设置get方法和set方法,在get方法中收集依赖,在set方法里通知订阅者,更新视图时候创建订阅者,更新视图时候如果依赖了data的某个属性,就会触发这个属性的get方法时候,该订阅者(更新视图的方法)就会被data的属性收集,在更新属性时候触发set方法,从而触发界面更新。

computed原理

在Vue组件模板中,如果一个表达式有复杂计算,可以使用computed(计算属性)。

computed依赖某些data属性,并计算得到一个新的值。

{
    name: 'myComponent',
    data() {
        return {
            message: 'hello'
        };
    },
    computed: {
        info() {
            // 字符串翻转
            return this.message.split('').reverse().join('');
        }
    }
}

当data相关属性变化时候,并不会重新计算computed的值,只会标记数据已经发生改变,当前的是脏数据(dirty),后面如果其他地方(比如渲染)用到computed,发现是dirty就会重新计算,如果不是dirty,直接使用当前的值,不需要重新计算,这样可以避免不必要的复杂计算。

这里有两个关键逻辑

  1. 在computed依赖的data属性更新后,需要对computed标记dirty
  2. 在访问computed时候,会判断是否是dirty,dirty ? 重新计算 : 返回当前的值

computed的原理其实就是如何实现这两个关键逻辑。

第一个逻辑的实现思路是,订阅data属性的变化,在data属性变化时候标记dirty。

第二个逻辑的实现思路是,设定computed的get方法,在访问时候处理相关逻辑。

下面看关键的代码,注意代码是简化的。

// 组件实例化时候初始化computed
function initComputed(vm: Component, computed: Object) {
  for (const key in computed) {
    const getter = computed[key];
    const watcher = new Watcher(
      vm,
      getter || noop,
      noop,
      {lazy: true}
    );
    Object.defineProperty(vm, key, {
      get() {
        if (watcher) {
          if (watcher.dirty) {
            watcher.evaluate();
          }
          // 把watcher绑定的所有dep,都绑定到当前的Dep.target上
          if (Dep.target) {
            watcher.depend();
          }
          return watcher.value;
        }
      }
    });
  }
}

初始化computed,对每个computed的key,都实例化一个watcher。另外每个computed的key都绑定到vm实例上,并设置get方法。

我们看下Watcher的关键方法,同上一节“把data设置为响应式”的Watcher代码相比,下面的Watcher代码突出了computed使用的场景。

实际的Watcher代码更综合更复杂,请参考Vue源码。

class Watcher {
  constructor(vm: Component, expOrFn: string | Function, cb, options) {
    this.getter = expOrFn;
    this.lazy = !!options.lazy;
    this.value = this.get();
  }
  get() {
    // 实际执行了Dep.target = this
    pushTarget(this);
    const value = this.getter.call(this.vm, this.vm);
    return value;
  }
  addDep(dep: Dep) {
    dep.addSub(this);
  }
  update() {
    if (this.lazy) {
      this.dirty = true;
    }
    else {
      queueWatcher(this);
    }
  }
  evalute() {
    this.value = this.get();
    this.dirty = false;
  }
}

下面我们分析上面两段代码是如何实现两个关键的逻辑的。

在initComputed时候,对每个computed的key,实例化一个Watcher,实例的getter参数是computed的方法。

Watcher构造函数中会调用get方法,先将watcher绑定到Dep.target,然后调用getter方法(即computed的方法),调用computed方法时候会访问该computed key所依赖的data属性,从而触发data属性的get方法,我们在上一节“把data设置为响应式”中已经说明过,在data属性的get方法中会收集依赖,因此该watcher会被data属性所收集,即该watcher订阅了所依赖的data属性。

这样在data属性变化时候,会触发dep.notify,从而调用watcher的upadte方法,我们看到watcher的update方法中会判断this.lazy,因为实例化watcher时候传入的options.lazy为 为true,所以这里标记this.dirty为true。这样就实现了第一个逻辑

另外我们看到初始化computed时候,设定了computed的get方法,当用户访问这个computed属性时候,首先判断如果dirty为true,则执行watcher.get()方法,并赋值给watcher,如果dirty为false则不处理。最后返回watcher.value这样就实现了第二个逻辑

总结一下,computed的原理是:

  1. 在初始化时候实例化watcher,实例化watcher时候对依赖的data属性取一次值,从而触发data属性收集依赖。当改变data属性时,会通知订阅者watcher,由于watcher设置了lazy选项,因此会将watcher置为dirty(即数据更新),但不会重新计算。
  2. 设置computed的get方法,在访问computed的时候,判断如果是dirty,就重新计算,否则直接返回当前的值。

通俗地说,computed是data属性的一个订阅者,它在初始化时候被data属性收集依赖,当computed依赖的data属性改变后,标记该computed为dirty,即数据更改过,当渲染使用到computed时候,再计算出computed的值从而得到最新的正确的值。

还有一个面试中不常问的问题:Vue是如何让computed和渲染都能够监听到data属性的变更的呢?

这个问题相当于:computed的watcher和渲染的watcher都是如何绑定到data属性的依赖池中的?

computed的watcher我们已经分析过,是在初始化时候就已经绑定,那么渲染时候如果用到了computed,而不是直接访问data属性,那么渲染的watcher是如何绑定到data属性的dep上的呢?

我们知道依赖收集的关键是watcher先将自己挂到Dep.target上,然后访问data属性,data属性的get方法就能将Dep.target对应的watcher收集了。

实际上,在watcher.get()方法中,是通过调用pushTarget()来设置watcher到Dep.target的。pushTarget()是将watcher推入watcher栈中,watcher栈用来管理Dep.target上面挂载的watcher,它解决了再一个订阅者的执行中遇到另一个订阅者的问题(在渲染过程中遇到computed)。

// /vue/src/core/observer/dep.js

// The current target watcher being evaluated.
// This is globally unique because only one watcher
// can be evaluated at a time.
Dep.target = null
const targetStack = []

export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

export function popTarget () {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

在渲染视图时候,首先render会创建一个watcher,在watcher中将自身推入targetStack,然后在updateComponent时候遇到了computed,触发computed的getter,如果是computed的watcher是dirty,那么执行watcher.evalute(),evalute方法调用watcher.get()方法,注意watcher.get()方法首先pushTarget,在最后会popTarget,这样在执行完watcher.evalute(),当前的Dep.target指向targetStack的上一个元素,即渲染的watcher。

然后执行watcher.depend(),就是把computed的watcher绑定的所有dep,都绑定到Dep.target,即渲染的watcher上(这样做是因为上一个watcher依赖computed,也一定依赖computed所依赖的data属性)。这样渲染的watcher就绑定到相应的data属性的dep上面了。

在data属性变化后,首先会执行computed的watcher的update方法,置为dirty,然后执行渲染的watcher,渲染过程中用到computed又会进行计算,从而得到更新后的界面。

watch原理

watch实现的功能是监听data属性变化,当属性变化时候触发用户定义的方法。

{
    name: 'myComponent',
    data() {
        return {
            message: 'hello'
        };
    },
    watch: {
      message(value) {
        console.log('message change: ', value);
      }
    },
    mounted() {
      this.message = 'world';
    }
}

下面看初始化watch的代码(动态watch的原理类似),注意代码简化过。

function initWatch (vm: Component, watch: Object) {
  for (const key in watch) {
    const handler = watch[key];
    const watcher = new Watcher(vm, key, handler);
  }
}

Vue在初始化时候调用initWatch初始化,订阅相应的key,实例化一个watcher,watcher实例化时候会调用get,对监听的key进行取值,从而触发监听的key的getter方法,进而将watcher自身加入到监听的data属性的dep的依赖池中,如果监听的是computed,则取值时候也会触发data属性的getter,从而进行watcher绑定。

当data属性改变后,会触发watcher的update,然后放入update的队列中,在清空watcher队列(flushSchedulerQueue)时候,会调用watcher.run()方法,调用回调方法。

通俗地讲,在组件初始化时候,遍历所有的watch,对每个watch创建订阅者,绑定依赖的data属性,当data属性改变后发布给订阅者,然后会执行相应地回调。

Watcher

watcher是一个订阅者,它可以和相应的dep绑定,从而订阅data属性变化。

它的getter参数很关键,getter参数是订阅者根据依赖的属性获取值的一个方法。在Watcher实例化的时候就会取一次值,在这个取值操作中会访问watcher依赖的属性,从而触发属性的dep的收集。因此在Watcher实例化的时候,就已经绑定了发布者了。

当data的属性更新后,会重新执行watcher的getter,取得最新的值来做后面的处理。

渲染有一个watcher、computed有一个watcher、watch也有一个watcher。

渲染的watcher的getter是updateComponent,实际它不关心取值,当属性值改变后再次执行updateComponent即可。

computed的watcher的getter是用户定义计算方法,computed就是根据这个计算方法返回结果的。当属性值改变后会更新dirty而不会调用getter进行取值,然后取computed值时候再重新计算,这就是惰性求值。

watch的watcher的getter是watch所监听的属性,属性值改变后会触发重新求值,并用新的值调用watch的回调。

Watcher还有一个cb参数,是callback回调,对于渲染和computed,它们的watcher的cb都是noop,这是因为在data属性值改变后,调用getter重新渲染就行了,而computed只用标记dirty,也不需要其他操作。

watch的cb是用户定义的方法,当属性改变后,不但要用getter重新求值,还要用新的值调用回调。

双向绑定

你可以用 v-model 指令在表单 <input>、<textarea><select> 元素上创建双向数据绑定。它会根据控件类型自动选取正确的方法来更新元素。尽管有些神奇,但 v-model 本质上不过是语法糖。它负责监听用户的输入事件以更新数据,并对一些极端场景进行一些特殊处理。
—— 表单输入绑定

简单地说,Vue在编译模板时候会将v-model指令特殊处理:

  1. 创建订阅者,当组件的data属性改变时候,修改表单元素的value。
  2. 给表单元素创建事件(change事件或者input事件),事件的回调中,修改组件的数据。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,923评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,154评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,775评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,960评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,976评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,972评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,893评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,709评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,159评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,400评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,552评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,265评论 5 341
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,876评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,528评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,701评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,552评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,451评论 2 352

推荐阅读更多精彩内容