Vue源码系列(四):手写Vue2.X、Vue3.X数据响应式原理🔥🔥

13,368 阅读6分钟


  知其不可奈何而安之若命

                                                                          ——— <<庄子>>

前言

这是一个Vue源码系列文章,建议从第一篇文章 Vue源码系列(一):Vue源码解读的正确姿势 开始阅读。 文章是我个人学习源码的一个历程,这边分享出来希望对大家有所帮助。

本文是上一篇文章:Vue源码系列(三):数据响应式原理的补充。主要内容是Vue2.X、Vue3.X的数据响应式原理和区别,同时补充了发布订阅模式和观察者模式,最后手撸一个min-vue的数据响应式。

在上一篇文章中,对于响应式的源码解析咱们说到了一个方法:defineReactive(),它就是Vue对响应式处理的核心代码。不记得的小伙伴可以点击Vue源码系列(三):数据响应式原理回顾一下,对 defineReactive() 的解释在文章中的 代码块 7

接下来进入文章内容 👇 👇

VueJs的核心包括一套“响应式系统”。 “响应式”,是指当数据改变后,Vue会通知到使用该数据的代码。例如,视图渲染中使用了数据,数据改变后,视图也会自动更新。
接下来咱们将从浅到深,了解一下Vue2.XVue3.X 的响应式原理。

Vue2.X响应式原理

首先咱们先说一下 Vue2.X 的响应式原理吧。
人狠话不多,直接上代码 😂 😂

<!DOCTYPE html>
<html lang="cn">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title> Vue2.X 单属性的数据响应式 </title>
</head>
<body>
  <div id="app"></div>
  <script>
    // 模拟Vue中的data选项
    let data = {
      msg: 'hello world'
    }
    // 模拟Vue的实例
    let vm = {}
    // 数据劫持:当访问或者设置vm中的成员时,做一些劫持后操作
    Object.defineProperty(vm, 'msg', {
      // 当获取值的时候执行
      get () {
        console.log('get: ', data.msg)
        return data.msg
      },
      // 当设置值的时候执行
      set (newValue) {
        console.log('set: ', newValue)
        if (newValue === data.msg) {
          return
        }
        data.msg = newValue
        // 数据更改时更新DOM的值
        document.querySelector('#app').textContent = data.msg
      }
    })

    // 测试一下 o(* ̄︶ ̄*)o 
    vm.msg = 'Hello VueJs'
    console.log(vm.msg)
  </script>
</body>
</html>

运行代码咱们可以看到,改变vm.msg的值,触发了数据劫持

image.png

还可以试着改变看一下具体怎么触发数据劫持的

1111.gif

以上只支持一个属性的响应式,如果想支持多个属性又该怎么办呢?直接上代码。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title> Vue2.X 多属性的数据响应式 </title>
</head>
<body>
  <div id="app"></div>
  <script>
    // 模拟Vue中的data选项
    let data = {
      msg: 'hello vue',
      value: 7
    }

    // 模拟Vue的实例
    let vm = {}

    proxyData(data)

    function proxyData(data) {
      // 遍历data对象中的所有属性
      Object.keys(data).forEach(key => {
        // 把data中的属性,转换成vm的setter
        Object.defineProperty(vm, key, {
          enumerable: true,
          configurable: true,
          get () {
            console.log('get: ', key, data[key])
            return data[key]
          },
          set (newValue) {
            console.log('set: ', key, newValue)
            if (newValue === data[key]) {
              return
            }
            data[key] = newValue
            // 数据更改,使DOM的值更新
            document.querySelector('#app').textContent = data[key]
          }
        })
      })
    }

    // 测试一下 o(* ̄︶ ̄*)o 
    vm.msg = 'Hello Vue'
    console.log(vm.msg)
  </script>
</body>
</html>

试一下是否支持多个属性的响应式

2222.gif

Vue3.X响应式原理

Vue3.X和Vue2.X的响应式实现不同,Vue3.X是实现原理是用ES6中的Proxy方法来实现的。这里有的小伙伴可能对Proxy这个方法不是太熟悉,所以咱们先简单说一下:

Proxy对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)

语法

const p = new Proxy(target, handler)

参数

  • target: 要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。
  • handler: 一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理 p 的行为。

想详细了解的可以戳 MDN详解说明 详细了解一下。

具体怎么用Proxy实现Vue3.X响应式原理的,咱们还是人狠话不多,直接上代码 😂 😂

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Vue3.X 数据响应式</title>
</head>
<body>
  <div id="app"> </div>
  <script>
    // 模拟Vue中的data选项
    let data = {
      msg: 'hello vue',
      value: 7
    }
    // 模拟Vue实例
    let vm = new Proxy(data, {
      // 执行代理行为的函数 当访问vm的成员会执行
      get (target, key) {
        console.log('get, key: ', key, target[key])
        return target[key]
      },
      // 当设置vm的成员会执行
      set (target, key, newValue) {
        console.log('set, key: ', key, newValue)
        if (target[key] === newValue) {
          return
        }
        target[key] = newValue
        document.querySelector('#app').textContent = target[key]
      }
    })
    // 测试一下 o(* ̄︶ ̄*)o 
    vm.msg = 'Hello Vue'
    console.log(vm.msg)
  </script>
</body>
</html>

试验一下,看看效果如何。

3333.gif

👌🏻 完美! 看一下区别吧,虽然已经很明了了,但是还总结一下。

Vue2.X 和 Vue3.X 的响应式原理

首先是Vue 2.x

  • 底层原理:Object.defineProperty
  • 直接监听属性
  • 浏览器兼容IE8以上(不兼容IE8)

其次是Vue3.X

  • 底层原理:Proxy
  • 直接监听对象,而非属性
  • ES6中新增方法,不支持IE浏览器

发布订阅模式和观察者模式

接下来我们继续深入Vue的响应式原理,那么就不得不说一下发布订阅模式和观察者模式了。

发布订阅模式

什么是发布订阅模式呢?
基于一个事件中心,接收通知的对象是订阅者,需要先订阅某个事件,触发事件的对象是发布者,发布者通过触发事件,通知各个订阅者。
举个例子🌰 :大家都订阅过公众号吧?比如:微信开发者啊、奇舞精选啊之类的。这里就涉及到两个角色:公众号(事件中心) 和 订阅了公众号的大家(订阅者)。然后当公众号的作者发布了文章以后,订阅了公众号的大家就会收到消息,这里又涉及到一个角色:公众号的作者(发布者)。

vue中的事件总线就是使用的发布订阅模式。

接下来就模拟实现Vue中的自定义事件,依旧人狠话不多,直接上代码

<!DOCTYPE html>
<html lang="cn">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>发布订阅模式</title>
</head>
<body>
  <script>
    // 事件触发器
    class EventEmitter {
      // 事件中心
      constructor () {
        // 创建的对象原型属性为null
        this.subs = Object.create(null) 
      }
      // 注册事件
      $on (eventType, handler) {
        this.subs[eventType] = this.subs[eventType] || []
        this.subs[eventType].push(handler)
      }
      // 触发事件
      $emit (eventType) {
        if (this.subs[eventType]) {
          this.subs[eventType].forEach(handler => {
            handler()
          })
        }
      }
    }
    
    // 测试一下 o(* ̄︶ ̄*)o 
    let em = new EventEmitter()
    // 注册事件(订阅消息)
    em.$on('click', () => {
      console.log('click1')
    })
    em.$on('click', () => {
      console.log('click2')
    })
    // 触发事件(发布消息)
    em.$emit('click')
  </script>
</body>
</html>

校验一下,大家也可以复制代码体验一下:

image.png

是不是感觉通俗易懂 😆 😆 😆


观察者模式

目标者对象和观察者对象有相互依赖的关系,观察者对某个对象的状态进行观察,如果对象的状态发生改变,就会通知所有依赖这个对象的观察者。

观察者模式相比发布订阅模式少了个事件中心,订阅者和发布者不是直接关联的。

  • 目标者对象 [Subject] : 拥有方法:[ 添加 / 删除 / 通知 ] Observer;
  • 观察者对象 [Observer] : 拥有方法:接收 Subject 状态变更通知并处理;
    目标对象 [Subject] 状态变更时,通知所有观察对象[Observer]。

Vue中响应式数据变化是观察者模式,在上一篇源码解析文章中咱们已经了解了,每个响应式属性都有dep,dep存放了依赖这个属性的watcher(watcher是观测数据变化的函数),如果数据发生变化,dep就会通知所有的观察者watcher去调用更新方法。因此, 观察者需要被目标对象收集,目的是通知依赖它的所有观察者。这里可能有的小伙伴会问:为什么watcher中也要存放dep呢?原因是因为当前正在执行的watcher需要知道此时是哪个dep通知了自己。

观察者(订阅者)- Watcher

  • update(): 当事件发生时,具体要做的事情

目标者(发布者) - Dep

  • subs数组:存储所有的观察者
  • addSub(): 添加观察者
  • notify(): 当事件发生后调用所有观察者的update()

没有事件中心

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>观察者模式</title>
</head>
<body>
  <script>
    // 目标者(发布者)
    class Dep {
      constructor () {
        // 记录所有的订阅者
        this.subs = []
      }
      // 添加订阅者
      addSub (sub) {
        if (sub && sub.update) {
          this.subs.push(sub)
        }
      }
      // 发布通知
      notify () {
        this.subs.forEach(sub => {
          sub.update()
        })
      }
    }
    // 观察者(订阅者)
    class Watcher {
      update () {
        console.log('update')
      }
    }

    // 测试一下 o(* ̄︶ ̄*)o 
    let dep = new Dep()
    let watcher = new Watcher()
    let watcher1 = new Watcher()
    // 添加订阅
    dep.addSub(watcher)
    dep.addSub(watcher1)
    // 开启通知
    dep.notify()
  </script>
</body>
</html>

虽然已经很明显了,但还是验证一下吧。

image.png

兄弟们 看到这里是不是恍然大悟的感觉?「哈哈。。。 是不是有点 🌬 🐂 🍺 的感觉?」

发布订阅模式和观察者模式的区别

从三个角度总结一下吧:

从结构上分析

  • 观察者模式里,只有两个角色:观察者 和 目标者(也可以叫被观察者)
  • 发布订阅模式里,不仅仅只有发布者和订阅者,还有一个事件中心(也可以叫控制中心)

从关系上分析

  • 观察者和目标者,是松耦合的关系
  • 发布者和订阅者,则完全不存在耦合

从使用角度分析

  • 观察者模式,多用于单个应用内部(上面说过Vue中响应式数据变化就是观察者模式)
  • 发布订阅模式,则更多应用于跨应用的模式,比如我们常用的 消息中间件

来个图片说明一下吧

image.png

简易的vue响应式

一切都准备好,那么就开始进入咱们今天的主题吧。依旧人狠话不多,直接上代码。

运用代码(index.html)

首先上运用代码。然后根据功能代码再上各个部分的代码。

<body>
  <div id="app">
    <div>
      <input v-model="msg" />
      <span> {{ msg }} </span>
      <p v-text="msg"></p>
    </div>
    <br>
    <div>
      <input v-model="value" />
      <span> {{ value }} </span>
      <p v-text="value"></p>
    </div>
  </div>
  <script src="./dep.js"></script>
  <script src="./watcher.js"></script>
  <script src="./compiler.js"></script>
  <script src="./observer.js"></script>
  <script src="./vue.js"></script>
  <script>
    let vm = new Vue({
      el: '#app',
      data: {
        msg: 'hello vue',
        value: 7,
      },
    })
  </script>
</body>

vue类 「vue.js」

/**
 * vue.js
 *
 * 属性
 * - $el:挂载的dom对象
 * - $data: 数据
 * - $options: 传入的属性
 *
 * 方法:
 * - _proxyData 将数据转换成getter/setter形式
 *
 */

class Vue {
  constructor(options) {
    // 获取传入的对象 默认为空对象
    this.$options = options || {}
    // 获取 el (#app)
    this.$el =
      typeof options.el === 'string'
        ? document.querySelector(options.el)
        : options.el
    // 获取data 默认为空对象
    this.$data = options.data || {}
    // 调用_proxyData处理data中的属性
    this._proxyData(this.$data)
    // 使用Obsever把data中的数据转为响应式 并监测数据的变化,渲染视图
    new Observer(this.$data)
    // 编译模板 渲染视图
    new Compiler(this)
  }
  // 把data中的属性注册到Vue
  _proxyData(data) {
    // 遍历data对象的所有属性 进行数据劫持
    Object.keys(data).forEach((key) => {
      // 把data中的属性,转换成vm的getter/setter
      Object.defineProperty(this, key, {
        // 可枚举(可遍历)
        enumerable: true,
        // 可配置(可以使用delete删除,可以通过defineProperty重新定义)
        configurable: true,
        // 获取值的时候执行
        get() {
          return data[key]
        },
        // 设置值的时候执行
        set(newValue) {
          // 若新值等于旧值则返回
          if (newValue === data[key]) {
            return
          }
          // 如新值不等于旧值则赋值
          data[key] = newValue
        },
      })
    })
  }
}

Observer 「observer.js」


/**
 * observer.js
 *
 * 功能
 * - 把$data中的属性,转换成响应式数据
 * - 如果$data中的某个属性也是对象,把该属性转换成响应式数据
 * - 数据变化的时候,发送通知
 *
 * 方法:
 * - walk(data)    - 遍历data属性,调用defineReactive将数据转换成getter/setter
 * - defineReactive(data, key, value)    - 将数据转换成getter/setter
 *
 */
class Observer {
  constructor(data) {
    this.walk(data)
  }
  // 遍历data转为响应式
  walk(data) {
     // 如果data为空或者或者data不是对象
     if (!data || typeof data !== "object") {
      return;
    }
    // 遍历data转为响应式
    Object.keys(data).forEach((key) => {
      this.defineReactive(data, key, data[key])
    })
  }
  // 将data中的属性转为getter/setter
  defineReactive(data, key, value) {
    // 检测属性值是否是对象,是对象的话,继续将对象转换为响应式的
    this.walk(value)
    // 保存一下 this
    const that = this;
    // 创建Dep对象 给每个data添加一个观察者
    let dep = new Dep();

    Object.defineProperty(data, key, {
      // 可枚举(可遍历)
      enumerable: true,
      // 可配置(可以使用delete删除,可以通过defineProperty重新定义)
      configurable: true,
      // 获取值的时候执行
      get() {
        // 在这里添加观察者对象 Dep.target 表示观察者
        Dep.target && dep.addSub(Dep.target)
        return value
      },
       // 设置值的时候执行
      set(newValue) {
        // 若新值等于旧值则返回
        if (newValue == value) {
          return;
        }
        // 如新值不等于旧值则赋值 此处形成了闭包,延长了value的作用域
        value = newValue;
        // 赋值以后检查属性是否是对象,是对象则将属性转换为响应式的
        that.walk(newValue);
        // 数据变化后发送通知,触发watcher的pudate方法
        dep.notify();

      },
    })
  }
}

Compiler 「compiler.js」

/**
 * compiler.js
 *
 * 功能
 * - 编译模板,解析指令/插值表达式
 * - 负责页面的首次渲染
 * - 数据变化后,重新渲染视图
 *
 * 属性
 * - el -app元素
 * - vm -vue实例
 *
 * 方法:
 * - compile(el) -编译入口
 * - compileElement(node) -编译元素(指令)
 * - compileText(node) 编译文本(插值)
 * - isDirective(attrName) -(判断是否为指令)
 * - isTextNode(node) -(判断是否为文本节点)
 * - isElementNode(node) - (判断是否问元素节点)
 */

class Compiler {
  constructor(vm) {
    // 获取vm
    this.vm = vm
    // 获取el
    this.el = vm.$el
    // 编译模板 渲染视图
    this.compile(this.el)
  }
  // 编译模板渲染视图
  compile(el) {
    // 不存在则返回
    if (!el) return;
    // 获取子节点
    const nodes = el.childNodes;
    //收集
    Array.from(nodes).forEach((node) => {
      // 文本类型节点的编译
      if (this.isTextNode(node)) {
        // 编译文本节点
        this.compileText(node)
      } else if (this.isElementNode(node)) {
        // 编译元素节点
        this.compileElement(node)
      }
      // 判断是否还存在子节点
      if (node.childNodes && node.childNodes.length) {
        this.compile(node);
      }
    });
  }
  // 添加指令方法 并且执行
  update(node, value, attrName, key) {
    // 定义相应的方法 举个例子:添加textUpdater就是用来处理v-text的
    const updateFn = this[`${attrName}Updater`];
    // 若存在 则调用
    updateFn && updateFn.call(this, node, value, key);
  }
  // 用来处理v-text
  textUpdater(node, value, key) {
    node.textContent = value;
  }
  // 用来处理v-model
  modelUpdater(node, value, key) {
    node.value = value;
    // 用来实现双向数据绑定
    node.addEventListener("input", (e) => {
      this.vm[key] = node.value;
    });
  }
  // 编译元素节点
  compileElement(node) {
    // 获取到元素节点上面的所有属性进行遍历
    Array.from(node.attributes).forEach((attr) => {
      // 获取属性名
      let _attrName = attr.name
      // 判断是否是 v- 开头
      if (this.isDirective(_attrName)) {
        // 删除 v-
        const attrName = _attrName.substr(2);
        // 获取属性值 并赋值给key
        const key = attr.value;
        const value = this.vm[key];
        // 添加指令方法
        this.update(node, value, attrName, key);
        // 数据更新之后,通过wather更新视图
        new Watcher(this.vm, key, (newValue) => {
          this.update(node, newValue, attrName, key);
        });
      }
    });
  }
  // 编译文本节点
  compileText(node) {
    // . 表示任意单个字符,不包含换行符、+ 表示匹配前面多个相同的字符、?表示非贪婪模式,尽可能早的结束查找
    const reg = /\{\{(.+?)\}\}/; 
    // 获取节点的文本内容
    var param = node.textContent;
    // 判断是否有 {{}}
    if (reg.test(param)) {
      //  $1 表示匹配的第一个,也就是{{}}里面的内容
      // 去除 {{}} 前后空格
      const key = RegExp.$1.trim();
      // 赋值给node
      node.textContent = param.replace(reg, this.vm[key]);
      // 编译模板的时候,创建一个watcher实例,并在内部挂载到Dep上
      new Watcher(this.vm, key, (newValue) => {
        // 通过回调函数,更新视图
        node.textContent = newValue;
      });
    }
  }
  // 判断元素的属性是否是vue指令
  isDirective(attrName) {
    return attrName && attrName.startsWith("v-");
  }
  // 判断是否是文本节点
  isTextNode(node) {
    return node && node.nodeType === 3;
  }
  // 判断是否是元素节点
  isElementNode(node) {
    return node && node.nodeType === 1;
  }
}

Dep 「dep.js」

/**
 * dep.js
 *
 * 功能
 * - 收集观察者
 * - 触发观察者
 *
 * 属性
 * - subs: Array
 * - target: Watcher
 *
 * 方法:
 * - addSub(sub): 添加观察者
 * - notify(): 触发观察者的update
 *
 */
 
 class Dep {
  constructor() {
    // 存储观察者
    this.subs = []
  }
  // 添加观察者
  addSub(sub) {
    // 判断观察者是否存在、是否拥有update且typeof为function
    if (sub && sub.update && typeof sub.update === "function") {
      this.subs.push(sub);
    }
  }
  // 发送通知
  notify() {
    // 触发每个观察者的更新方法
    this.subs.forEach((sub) => {
      sub.update()
    })
  }
}

Watcher 「watcher.js」

/**
 * watcher.js
 *
 * 功能
 * - 生成观察者更新视图
 * - 将观察者实例挂载到Dep类中
 * - 数据发生变化的时候,调用回调函数更新视图
 *
 * 属性
 * - vm: vue实例
 * - key: 观察的键
 * - cb: 回调函数
 *
 * 方法:
 * - update()
 *
 */

class Watcher {
  constructor(vm, key, cb) {
    // 获取vm
    this.vm = vm
    // 获取data中的属性
    this.key = key
    // 回调函数(更新视图的具体方法)
    this.cb = cb
    // 将watcher实例挂载到Dep
    Dep.target = this
     // 缓存旧值
    this.oldValue = vm[key]
    // get值之后,清除Dep中的实例
    Dep.target = null
  }
  // 观察者中的方法 用来更新视图
  update() {
    // 调用update的时候,获取新值
    let newValue = this.vm[this.key]
    // 新值和旧值相同则不更新
    if (newValue === this.oldValue) return
    // 调用具体的更新方法
    this.cb(newValue)
  }
}

验证一下吧。

444.gif

到此完毕。觉得可以的欢迎点赞、收藏加关注 🙏 🙏 。