在制作业务过程中,经常有vue开发会遇到。当前数据更新了但是视图并没有更新的问题。

这个涉及到了Vue自身对于变化侦测方面的处理,所以给大家讲一下Vue是如何实现的变化侦测。

极简的双向绑定示例

首先从最简单的双向绑定入手:

<!DOCTYPE html>
<html>
  <body>
    // html
    <p>请修改输入字段中的文本,然后在字段外点击以触发 onchange。</p>
    <span id="span">我是默认内容</span>
    <br />
    <input id="input" type="text" name="txt" value="Hello" />
    // javascript
    <script>
      let span = document.getElementById('span')
      let input = document.getElementById('input')
      input.addEventListener('change', (event) => {
        span.innerHTML = event.target.value
      })
    </script>
  </body>
</html>

从上面代码中可以看到,我们是操作DOM来实现的响应式。

但是这样看来,相比现在的Vue响应式 存在着很多很多不方便。比如我们要手动为对应的输入框来绑定change。所以这一步操作能不能自动运行呢?让我们可以操作数据而不去操作DOM就可以更新DOM。

我们在这个阶段情况下,利用数据模型Model在视图中显示是个很简单的事情,而且View对Model的修改也是非常容易的。但反过来,Model修改时,View也对应刷新。

这就不简单了,我们就需要知道Model的哪个数据它修改了,它对应使用的View是谁 在这方面 React和vue提供了两个不同的解决方案,具体可以参考这篇文章

vue数据响应式的中心思想

我们Vue使用的响应式数据的实现思路,就是在Vue初始化时,将传入的data选项进行遍历,并且对这个data选项内的对象所有的属性使用Object.defineProperty将这些属性转为getter/setter

而且我们这个Object.defineProperty是es5的属性,还没法使用babel将其转化为更低版本的实现。因为在ES5之前并没有相关的实现。所以无法进行降级处理,也就无法支持IE8以及以下版本的浏览器。

贴一张官方的图 通过这张图可以明显地看到我们这个响应式系统有watcher render data(dep,obseorver)

这时就要引出我们的Object.defineProperty了,它是构成我们Vue变化侦测的重要方法。

Object.defineProperty是什么东西?

Object.defineProperty方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。

一般的用法是使用get和set方法来监听读写操作如下。

|

let car = {
  brand: 'BMW',
  price: 3000
}
let val = 'BMW'
// 使用 Object.defineProperty 监听了car对象的brand属性的读写操作 
Object.defineProperty(car,'brand',{
  enumerable: true, // 是否可枚举 Object.keys(car)
  configurable: true,// 是否可删除 delete car.brand
  get(){
    console.log('brand属性被读取了')
    return val;
  },
  set(newVal){
    console.log('brand属性被修改了')
    val = newVal;
  }
})

将Vue示例上的data变为响应式

 title=

|

// 源码位置:src/core/observer/index.js
/**
 * Observer类会通过递归的方式把一个对象的所有属性都转化成可观测对象
 * 需要注意的是 如下的大多数代码都是伪代码 只是真实代码中的一部分
 */
export class Obseorver {
  constructor (value) {
    this.value = value
    if (Array.isArray(value)) {
      // 当value为数组时的逻辑
      // ...
    } else {
      this.walk(value)
    }
  }

  walk (obj) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }
}
/**
 * 使一个对象转化成可观测对象
 * @param { Object } obj 对象
 * @param { String } key 对象的key
 * @param { Any } val 对象的某个key的值
 */
function defineReactive (obj,key,val) {
  // 如果只传了obj和key,那么val = obj[key]
  if (arguments.length === 2) {
    val = obj[key]
  }
  if(typeof val === 'object'){
      new Observer(val)
  }
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get(){
      console.log(`${key}属性被读取了`);
      return val;
    },
    set(newVal){
      if(val === newVal){
          return
      }
      console.log(`${key}属性被修改了`);
      val = newVal;
    }
  })
}

 title=

在Observer中判断值的类型,将object类型的数据通过walk来转换为get/set形式来侦测变化。最后还在defineReactive中判断,val值的类型来递归子属性。

defineReactive 是真正为数据添加 get 和 set 属性方法的方法,它将 data 中的数据定义一个响应式对象。

并给该对象设置 get 和 set 属性方法,其中 get 方法是对依赖进行收集, set 方法是当数据改变时通知 Watcher 派发更新。

vue是怎么知道谁利用了响应式数据?依赖收集

 title=

依赖收集的原理是:当视图被渲染时,会触发渲染中所使用到的数据的 get 属性方法,通过 get 方法进行依赖收集。

思考一下,我们之所以要将数据变为可观测数据,其目的是在当数据发生变化时,可以通知那些使用数据的地方。

举个例子:

|

<template>
    <h1>{{ name }}</h1>
</tempalte>

该模板中使用了name这条数据,所以当name发生变化时候,要向这个使用的地方发送通知。

那么依赖什么时候需要收集和更新? 可观测数据被获取的时候收集依赖,也就是 在getter中收集依赖。可观测数据被更改的时候更新依赖,也就是在setter中更新依赖

那么我们知道了何时去收集依赖和更新依赖,但是我们要将依赖收集到哪里去呢?

最好的做法是为每个数据创建一个依赖管理器,把这个数据的所有依赖都管理起来。

假设 ,每个key都有一个数组 用来存储当前key的依赖 ,并且假设依赖是个函数,并保存在window.target上。

|

let uid = 0
export default class Dep {
  constructor() {
    this.id = uid++
    this.subs = []
  }
  // 添加
  addSub(sub) {
    this.subs.push(sub)
  }
  // 删除依赖
  removeSub(sub) {
    this.remove(this.subs, sub)
  }
  // 添加一个依赖
  depend() {
    console.log('收集依赖');
    if(window.target) {
      console.log('depend',window.target);
      this.addSub(window.target)
      // dep会记录 数据发生变化时需要通知哪些watcher watcher也记录了自己会被哪些dep通知
    }
  }
  // 通知更新所有依赖
  notify() {
    console.log('notify','更新依赖');
    const subs = this.subs.slice()
    for(let i = 0,len= subs.length;i<len;i++) {
      subs[i].update()
    }
  }
  // 删除
  remove(arr, item) {
    // 把watcher从sub中删除调 当数据发生变化时 就不会通知这个已被删除的watcher unwatch原理
    if (arr.length) {
      const index = arr.indexOf(item)
      if(index > -1) {
        return arr.splice(index,1)
      }
    }
  }
}

这里我们新增加了一个类叫做Dep,用来管理依赖。

然后我们将defineReactive稍稍改造一下。

|

function defineReactive (obj,key,val) {
  // 如果只传了obj和key,那么val = obj[key]
  if (arguments.length === 2) {
    val = obj[key]
  }
  if(typeof val === 'object'){
      new Observer(val)
  }
  let dep = new Dep(); // 修改的地方
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get(){
      console.log(`${key}属性被读取了`);
      // 添加依赖
      dep.depend(); // 修改的地方
      return val;
    },
    set(newVal){
      if(val === newVal){
          return
      }
      console.log(`${key}属性被修改了`);
      val = newVal;
      dep.notify(); // 修改的地方
    }
  })
}

在set被触发的时候,我们调用dep中的notify方法来更新依赖。

在get被触发的时候,我们调用dep中的depend方法来收集依赖。

那到底什么是依赖?

在上面说了这么多依赖(window.target),那么这个依赖到底是什么呢?

收集谁 换句话来说就是 当数据发生改变的时候 通知谁。

但是因为,我们用到这个数据的地方有很多,而且类型不一致,比如我可能是在template中使用,也可能在watch中使用。所以需要抽象出一个能集中处理这些情况的类,当数据变化的时候,我们只需要通知它,让他内部再通知其他的地方。所以vue 给他起了个好听的名字 “watcher”。 所以我们要收集watcher!

什么是watcher呢?

对于watcher的定义:watcher是一个中间角色,用来数据变化时候通知它,它再去通知其他。

关于watcher有一个经典使用方式

|

vm.$watch(data.a.b.c,function (newVal,oldVal) {
    // 做点什么
})

可以想一下,我们该如何实现这个功能?是不是只需要将data.a.b.c属性添加到dep中就行了。

当data.a.b.c的值发生变化的时候,通知watcher,watcher执行参数中的回调函数。

下面来看一下 watcher的实现

|

import { parsePath } from '../../../util/lang'
export default class Watcher {
  constructor(vm, expOrFn, cb, options) {
    this.vm = vm
    this.getter = parsePath(expOrFn) // 执行this.getter() 就可以读取data.a.b.c的内容
    this.cb = cb
    this.value = this.get()
  }
  get() {
    // 把watcher实例自身赋给了全局的一个唯一对象window.target上
    window.target = this
    // 读取data.a.b.c的值 触发getter 获取一下被依赖的数据,获取被依赖数据的目的是触发该数据上面的getter 把this 指向到vm
    let value = this.getter.call(this.vm, this.vm)
    window.target = undefined
    return value
  }
  update() {
    console.log('update', '更新依赖')
    const oldValue = this.value
    this.value = this.get()
    this.cb.call(this.vm, this.value, oldValue)
  }
}


//  parsePath的代码
/**
 * 解析简单路径
 */
export const unicodeRegExp = /a-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD/
/**
 * 解析简单路径
 * 把一个形如'data.a.b.c'的字符串路径所表示的值,从真实的data对象中取出来
 * 例如:
 * data = {a:{b:{c:2}}}
 * parsePath('a.b.c')(data)  // 2
 */
const bailRE = new RegExp(`[^${unicodeRegExp.source}.$_\\d]`)
export function parsePath (path){
  if (bailRE.test(path)) {
    return
  }
  const segments = path.split('.')
  return function (obj) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return
      obj = obj[segments[i]]
    }
    return obj
  }
}

 title=

watcher逻辑如下。

  1. 当实例化Watcher类的时候,会先执行构造函数。
  2. 在构造函数中调用了this.get()实例方法。
  3. 在get()方法中,通过window.target = this 把实例赋值给了全局的一个唯一对象 window.target上, 然后通过

     let value = this.getter.call(this.vm, this.vm) 

    获取一下被依赖的数据 此步目的是为了触发数据的getter,所以我们触发了收集依赖。

  4. 在dep.depend() 中 取到挂载到window.target的值 也就是 watcher 实例 并存入依赖数组中,最后在get方法中将window.target释放掉
  5. 当数据变化的时候 会触发数据的setter,因为在setter中调用了dep.notify(),我们在dep.notify()中 遍历了所有依赖,并执行依赖的update()方法,也就是Watcher类中的update()实例方法,在update()方法中我们调用了数据变化的更新回调函数,从而更新视图。

vue是如何将模板与响应式数据绑定起来的

 title=

在日常开发过程中,我们把写在template标签内的类原生html内容称之为模板。

Vue将template内的内容进行编译,把属于Vue能力的东西进行处理。比如变量,指令等,生成渲染函数Render。

Render会将模板内容生成为对应的VNode,而VNode经过一系列优化处理后,最后根据VNode创建真实的DOM节点并插入到视图中,最终完成视图的渲染。

在这个阶段具体流程如下

  1. 模板解析阶段:将一堆模板字符串用正则表达式解析成抽象语法树AST
  2. 优化阶段: 遍历AST.找出其中的静态节点并打上标记
  3. 代码生成阶段:将AST转为渲染函数。

vue在当前情况下存在的问题

虽然上面一系列的操作 通过Object.defineProperty方法实现了对object数据的可观测,但是这个方法目前只是能观测到取值和设置值的操作 ,当对object中添加一堆新的key/value时 或删除一堆已有的Key/value时,他是无法观测到的。

导致我们新添加的key/value后,无法通知依赖,从而也就导致对应的视图无法进行响应式更新。

总结

所谓的变化侦测就是侦测数据的变化。当数据变化时候能够侦测并通知。

1_-2-mYOB_y7K2o5Tcqz7uYw.jpeg

请输入图片描述data通过observer转换成了getter/setter的形式来追踪其变化

  1. 当外界通过Watcher读取数据时,会触发getter将Watcher添加到依赖中
  2. 当数据发生了变化时,会触发setter,从而向Dep中的依赖(即Watcher)发送通知
  3. Watcher接收到通知后 会向外界发送通知 变化通知到外界后可能会触发视图更新,也可能会触发用户的某个回调函数.

Vue正确操作数据的方法。

更改数据触发视图更新需要满足条件:

1、数据是被vue劫持的; 例如 vm.name

2、视图中有用到对应的数据;

{{name}}

|

// 引用数据类型新增加属性 操作方法 注:vm是vue实例
// 方法一. 此方法是替换了obj的内存指向,所以更改了obj的内存指向整个会重新劫持。
vm.obj = {
    ...vm.obj,
    b:1000
}
// 方法二.此方法是官方提供的一个API 用以将新增的属性变为响应式数据
vm.$set(obj,b,1000)

|

// 数组 增删改查
// 对于数组的增删改查 应当避免使用索引和长度来操作数组 因为Vue内部只对数组的七个方法进行了劫持
// 方法一. 此方法同样是替换了array的内存指向,索引更改了array的内存指向整个会重新劫持。
vm.array = [...vm.array,[1,2,3]]
// 方法二. 利用Vue劫持的数组操作方法来进行操作 能触发视图更新的方法有:push pop unshift shift reverse splice sort
// 关于数组劫持 源码位置在 https://github.com/vuejs/vue/blob/dev/src/core/observer/array.js 在这里就可以看到vue所劫持的方法
vm.array.push(100);
vm.array.splice(2,1,30); 

|

// 其内部还有一个强制更新视图的方法 迫使Vue.js实例重新渲染。注意它仅仅影响实例本身以及插入插槽内容的子组件,而不是所有子组件。
vm.$forceUpdate()
// 源码内部实现如下 其实内部只是执行实例watcher的update方法,就可以使Vue.js实例重新渲染。
 Vue.prototype.$forceUpdate = function () {
   var vm = this;
   if (vm._watcher) {
     vm._watcher.update();
   }
 };

需要注意的是,如果你目前场景需要使用$forceUpdate()了,那么毫无疑问你的代码绝对有问题。

最后修改:2023 年 03 月 24 日
如果觉得我的文章对你有用,请随意赞赏