目的
因为大家在写Vue的时候,会经常碰到,类似于,操作了数据但是视图没有更新的问题,所以写下此记录,来向大家展示Vue的响应式原理。

知识点

1.1. Vue是怎么样实现的变化侦测?

使用Object.defineProperty使数据变得可观测


let car = {
  brand: 'BMW',
  price: 3000
}
// 如何在我们修改car的时候 知道car 哪个属性被修改了
// 使用 js原生支持的 Object.defineProperty()
Object.defineProperty(car,'brand',{
  enumerable: true,
  configurable: true,
  get(){
    console.log('brand属性被读取了')
    return val
  },
  set(newVal){
    console.log('brand属性被修改了')
    val = newVal
  }
})
// 通过使用 Object.defineProperty 我们监听了car对象的brand属性的读写操作
// 当我们 显式的更改其值的时候
car.brand = 'MINI EV'
// 在控制台 可以看到我们写的 打印已经输出了
// 当我们 去读取其值的时候
car.brand
// 在控制台中也可以看到 打印出来了 ‘brand属性被读取了’

1.2. 那么 Vue的源码内是怎么处理的?

Vue内observer的源码

// 源码位置:src/core/observer/index.js
 
 
/**
 * Observer类会通过递归的方式把一个对象的所有属性都转化成可观测对象
 * 需要注意的是 如下的大多数代码都是伪代码 只是真实代码中的一部分
 */
export class Observer {
  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;
    }
  })
}

在上面代码中,可以看出Vue定义了一个observer类,它用来将一个正常的Object对象转换为可观测的Object.

通过判断值的类型,来将object类型的数据通过walk来转换为get/set形式来侦测变化。最后还在defineReactive中判断,val值的类型来递归子属性。所以,通过这段代码,可以实现,将一个 object传入到observer类中,我们就可以得到一个可观测的 Object。

1.3. Vue是怎么知道谁需要更新的?

当我们可以将数据变成可观测时候, 就引出了一个问题。我们为什么要将数据变为可观测数据?当然是为了 数据在变更时候 我们可以让视图去更新,那么我们怎么知道视图是谁呢? 总不能 我们一个数据更新了,将整个视图重新渲染一次吧。所以可以想到,视图中,谁用到了这条数据,就去更新谁。 换句话来说 “谁依赖这个数据就去更新谁”。

这时候我们就可以,把每个数据都建一个依赖数组,因为一个数据可能在多个地方使用,所以需要建立一个数组。当数据变化时候,我们去获取对应数据的依赖数组,挨个通知它们数据变了,自身该更新了。

那么依赖什么时候需要收集和更新? 可观测数据被获取的时候收集依赖,也就是 在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)
      }
    }
  }
}

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

修改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(); // 修改的地方
    }
  })
}

在上面代码中,我们收集的依赖是window.target,那么它到底是什么东西?

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

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

1.4. 那到底什么是watcher?

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

watcher基本的使用

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

可以想一下,我们该如何实现这个功能?是不是只需要将data.a.b.c属性添加到dep中就行了。当data.a.b.c的值发生变化的时候,通知watcher,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() {
    // 把实例自身赋给了全局的一个唯一对象window.target上
    window.target = this
    // 获取一下被依赖的数据,获取被依赖数据的目的是触发该数据上面的getter
    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
  }
}

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()方法中我们调用了数据变化的更新回调函数,从而更新视图。

总结来说,就是

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

1.5. 不足

虽然上面一系列的操作 通过Object.defineProperty方法实现了对object数据的可观测,但是这个方法目前只是能观测到取值和设置值的操作 ,当对object中添加一堆新的key/value时 或删除一堆已有的Key/value时,他是无法观测到的。导致我们新添加的key/value后,无法通知依赖,从而也就导致对应的视图无法进行响应式更新。解决方法也很简单Vue提供了Vue.set和Vue.delete方法。

1.6. Vue是如何将模板与数据绑定起来的?

关于这个问题, 我们需要预先知道,什么是模板编译?

在日常开发过程中,我们把写在template标签内的类原生html内容称之为模板。那么为什么是类原生呢?因为我们在template内可以利用js的变量,或是Vue指令 这些东西在html原生是不存在的。但它确实是能正常渲染出来了,这一切就要归功于Vue的模板编译了。

Vue将template内的内容进行编译,把属于Vue能力的东西进行处理。比如变量,指令等,生成渲染函数Render。Render会将模板内容生成为对应的VNode,而VNode经过一系列优化处理后,最后根据VNode创建真实的DOM节点并插入到视图中,最终完成视图的渲染。

由于这部分技术十分复杂,所以我只会进行简单的描述,告诉你Vue是如何将模板与数据绑定的,是怎么做到的数据驱动视图,而不会讲述他是如何生成render渲染函数,render渲染函数如何生成Vnode,Vnode内的Diff优化 全部略过。

在这个阶段具体流程如下

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

在模板解析阶段中,会去解析模板中是否存在变量,也就是被{{}}符号包围的文本,当解析到变量时。Vue内部会获取该变量的值 并replace替换掉其{{}}变量文本。因为此步骤中,获取了变量 就触发了依赖收集所以这里的依赖就会被收集到。在以后的变量更新时,Vue内部将自动执行依赖更新,将其内部的文本重新赋值为最新值。

如下是个伪实现 根据上面说到的过程 

Compile

export default class Compile {
  constructor(el, vm) {
    this.$el = document.querySelector(el)
    this.$vm = vm
    if (this.$el) {
      console.log('el',this.$el);
      this.$fragment = this.getNodeChirdren(this.$el)
      this.compile(this.$fragment)
      this.$el.appendChild(this.$fragment)
    }
  }
  getNodeChirdren(el) {
    const frag = document.createDocumentFragment()
    let child
    while ((child = el.firstChild)) {
      frag.appendChild(child)
    }
    return frag
  }
  compile(el) {
    const childNodes = el.childNodes
    Array.from(childNodes).forEach((node) => {
      if (node.nodeType == 1) {
      } else if (node.nodeType == 2) {
      } else if (node.nodeType == 3) {
        //3为文本节点
        this.compileText(node)
      }
 
 
      // 递归子节点
      if (node.childNodes && node.childNodes.length > 0) {
        this.compile(node)
      }
    })
  }
 
  compileText(node) {
    console.log('compileText')
    /**
     * . 表示任意单个字符,不包含换行符
     * + 表示匹配前面多个相同的字符
     * ?表示非贪婪模式,尽可能早的结束查找
     * */
    const reg = /\{\{(.+?)\}\}/
    const arrayReg = /\S*\[\d\]/
    const reg2 = /(.*?)\[(.*?)\]/
    var param = node.textContent
    if (reg.test(param)) {
      //  $1表示匹配的第一个
      const key = RegExp.$1.trim()
      // const reg = /({{(.*)}})/;
      // const key = String(param.match(reg)).match(reg2)[0]//获取监听的key
      // 编译模板的时候,创建一个watcher实例,并在内部挂载到Dep上
      console.log(this.$vm)
      console.log(key)
      console.log(this.$vm[key])
      if(arrayReg.test(key)){
        const aKey = key.match(reg2)[1]
        const aVal = key.match(reg2)[2]
        // console.log(this.$vm.aKey[aVal])
        node.textContent = param.replace(reg, this.$vm[aKey][aVal])
 
        new Watcher(this.$vm[aKey], aVal, (newValue) => {
          // 通过回调函数,更新视图
          console.log('通过回调函数,更新视图', newValue)
          node.textContent = newValue
        })
      }else{
        node.textContent = param.replace(reg, this.$vm._data[key])
        new Watcher(this.$vm, key, (newValue) => {
          // 通过回调函数,更新视图
          console.log('通过回调函数,更新视图', newValue)
          node.textContent = newValue
        })
      }
    }
  }
}

这样 就实现了一个简简单单的Vue数据驱动视图。当然,真正的Vue并没有看起来的那么简单,其内部做了非常多的边界处理以及性能上的优化,有效的避免了很多极端场景。

整理与测试

https://github.com/littleCareless/mini-vue

克隆项目→ 安装依赖 → 在template文件夹的index.html内编辑调试

执行 npm run build 将做到更改index.html 调试

使用 VsCode的live server插件 打开 index.html

在浏览器控制台中 找到Sources 在左侧的page项中 找到index.html 给你想要调试的地方打上断点 刷新浏览器 就可以调试了

在这个页面 可以看到提供了四个按钮和一个input 每个按钮调用了不同的方法

展示了$set的使用 和 双向绑定 具体实现其实可以参考我上面说的和源代码。

https://github.com/vuejs/vue

克隆项目→ 安装依赖 → 在examples文件夹内新起一个文件夹 文件夹内新建一个index.html

index.html 展开源码

执行 npm run dev 可以在src文件夹内随意做更改,立即生效。

调试 index.html与上诉方法并无差别.

实际应用

通过上面一系列讲解,我们可以知道Vue是如何的将数据变成响应式的。

那么我们在写Vue的时候,就需要按照他的原理来操作数据,以触发视图的更新。

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

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();
   }
 };
最后修改:2023 年 03 月 06 日
如果觉得我的文章对你有用,请随意赞赏