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事件),事件的回调中,修改组件的数据。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
好的,我会尽力帮助您解答这个问题。为了实现用户登录组件,您可以使用Vue.js框架的各种内置指令和组件,如v-bind、v-model、v-on等等。同时,在Vue 3,您还可以利用Composition API来编写可重用且易于维护的组件。以下是一个简单的登录组件的示例代码: ``` <template> <div> <form @submit.prevent="login"> <label for="username">用户名:</label> <input v-model="username" type="text" required> <br> <label for="password">密码:</label> <input v-model="password" type="password" required> <br> <button type="submit">登录</button> </form> </div> </template> <script> import { ref } from 'vue'; export default { setup() { const username = ref(''); const password = ref(''); const login = () => { // 在这里调用后端API进行用户登录验证 console.log(`正在登录... 用户名:${username.value} 密码:${password.value}`); }; return { username, password, login, }; }, }; </script> ``` 在以上代码,我们使用了Vue的ref响应式属性来存储用户名和密码,并通过v-model指令将输入框和这两个属性进行绑定。在用户点击“登录”按钮时,我们将调用一个名为“login”的方法来验证用户身份,并在控制台输出相关信息。 当然,为了实现完整的登录流程,您还需要编写后端API来验证用户输入的信息,生成并返回用户的身份认证令牌等等。希望这个简单的示例能够帮助您入门Vue.js开发!

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值