Vue响应式原理及简单实现

916 阅读7分钟

准备工作

先了解一下这些:

  • 数据驱动
  • 响应式核心原理
  • 发布订阅模式和观察者模式

数据驱动

  • 数据响应式:数据模型是普通的js对象,但是当修改数据时,视图也会随之更新,避免开发者手动进行DOM操作
  • 双向数据绑定:当数据发生变化时,视图也会发生变化;视图发生变化时,数据也会发生变化。体现就是对于表单元素,可以使用v-model指令创建双向数据绑定
  • 数据驱动:开发时只需要关注数据本身业务本身,而不需要考虑数据该如何渲染视图

响应式核心原理

在vue2中,响应式是基于Object.defineProperty实现的:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div id="app">msg</div>
<script>
  let data = {
    msg: 'hello world'
  }

  let vm = {}

  Object.defineProperty(vm, 'msg', {
    // 可枚举
    enumerable: true,
    // 可配置
    configurable: true,
    // 读vm.msg时会触发这个,读取到的是data.msg
    get() {
      return data.msg
    },
    // 设置vm.msg的值时会触发这个,同时可以在这里进行dom操作来修改视图
    set(v) {
      if (v === data.msg) return
      data.msg = v
      document.getElementById('app').textContent = data.msg
    }
  })
  // 给vm.msg重新赋值时,视图也会重新渲染出新值
</script>
</body>
</html>

这种方法需要遍历对象中的属性来设置好gettersetter

在vue3中,使用的是proxy,这个方法是监听整个对象而不是分别给每个属性添加gettersetter

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div id="app">msg</div>
<script>
    const data = {
      msg: 'hello world',
      count: 0
    }

    // proxy是个构造函数
    // 参数第一项是被代理的对象,第二项是handler
    // new完返回的即是代理对象
    const vm = new Proxy(data, {
      get(target, key) {
        return target[key]
      },
      set(target, key, value) {
        if (target[key] === value) return
        target[key] = value
        document.getElementById('app').textContent = target[key]
      }
    })
</script>
</body>
</html>

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

  1. 发布订阅模式:这个模式有三个对象:发布者、订阅者和消息中心(事件中心)。当一个事件触发时,发布者会向消息中心传递一个信号,所有在消息中心订阅该消息任务会在接收到该信号后开始执行。

在vue中,可以通过自定义事件来体验发布订阅模式:

// 创建一个空的vue实例
// 消息中心
const vm = new Vue()

// 订阅者
vm.$on('change', () => {
    console.log('Event triggerred')
})

//发布者
vm.$emit('change')

vue中的兄弟组件可以通过此类方法进行通信

可以简单模拟一下自定义事件的机制:

class EventEmitter {
    // 该类内部有$emit和$on两个方法,还有一个能保存发布的事件的对象
    constructor() {
      // 创建这个保存事件的对象
      this.subs = {}
    }

    // $on-订阅者
    // Type为事件名称,handler为事件处理函数
    $on(type, handler) {
      // 判断subs中有没有该事件,如果没有就将它赋值为一个空数组用于存储多个事件
      this.subs[type] = this.subs[type] || []
      this.subs[type].push(handler)
    }

    // $emit-发布者
    $emit(type, ...args) {
      // 如果有该事件,就执行
      if (this.subs[type]) {
      this.subs[type].forEach(handler => {
        handler(...args)
      })
    }
  }
}

再来测试一下:

const event = new EventEmitter()

event.$on('change', (arg1, arg2) => {
  console.log(arg1, arg2)
})

event.$emit('change', 1, 2)

1624368553316.png

控制台也会有打印

  1. 观察者模式:与发布订阅模式相比,观察者模式少了消息中心。发布者(目标)内有subs数组来存储所有的订阅者(观察者)、addSub函数添加观察者、notify函数来在事件触发时调用订阅者(观察者)中的update函数来执行相应的任务。也就是说,不同于发布订阅模式下发布者和订阅者的“零耦合”,观察者模式中目标和观察者是有依赖关系的。

简单实现(不考虑传参问题)

// 目标-发布者
class Dep {
  constructor() {
    // 初始化一个subs数组以存储观察者
    this.subs = []
  }
  // addSub方法-将观察者添加进subs中
  addSub(sub) {
    if (sub && sub.update) {
      this.subs.push(sub)
    }
  }
  // notify方法-调用所有观察者的update方法
  notify() {
    this.subs.forEach(sub => {
      sub.update()
    })
  }
}
// 观察者-订阅者
class Watcher {
  update() {
    // 观察者要执行的相应的任务
    console.log('updated')
  }
}

测试一下:

const dep = new Dep()
const watcher = new Watcher()

dep.addSub(watcher)
dep.notify()

1624371125499.png

总结一下就是:

观察者模式是由目标调度,观察者和目标之间是有依赖关系的;发布订阅模式是由消息中心统一调度,发布者和订阅者之间没有联系。

1624372204635.png

模拟Vue响应式原理

整体结构如下

1624451855953.png

现在一步一步实现这五个方面(迷你版)

Vue

Vue的主要功能:

  1. 接收初始化的参数
  2. 把data中的数据注入vue实例中并转换成getter和setter
  3. 调用observer监听data中数据的变化
  4. 调用compiler解析差值表达式和指令

看一下类图:

1624452141629.png

按照功能实现一下:

class Vue {
  constructor(options) {
    // 一、将options中的数据保存起来
    // 1.将options保存到$options上
    this.$options = options || {}
    // 2.将options.data保存到$data上
    this.$data = options.data || {}
    // 3.将options.el保存到$el上
    this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el

    // 二、将data中的数据转换成getter和setter,注入vue实例中
    this._proxyData(this.$data)
    // 三、调用observer监听数据变化
    // 四、调用compiler解析指令和差值表达式
  }
  _proxyData(data) {
    // 遍历data
    Object.keys(data).forEach(key => {
      // 将data中的属性注入vue实例中
      Object.defineProperty(this, key, {
        enumerable: true,
        configurable: true,
        get() {
          return data[key]
        },
        set(newVal) {
          if (newVal === data[key]) return
          data[key] = newVal
        }
      })
    })
  }
}

同时在模板HTML文件里面引用一下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div id="app">
    <h1>差值表达式</h1>
    <h3>{{ msg }}</h3>
    <h3>{{ count }}</h3>
    <h1>v-text</h1>
    <div v-text="msg"></div>
    <h1>v-model</h1>
    <input type="text" v-model="msg">
    <input type="text" v-model="count">
</div>
<script src="./js/vue.js"></script>
<script>
    const vm = new Vue({
      el: '#app',
      data: {
        msg: 'hello',
        count: 1
      }
    })
    console.log(vm)
</script>
</body>
</html>

打印结果为

1624454888680.png

看到$data$el$optionsgettersetter都已经在vue实例中了。

Observer

Observer的主要功能:

  1. 把data中的属性转换成响应式数据(如果某个属性也是对象,将其转换成响应式对象)
  2. 在数据发生变化时发送通知(需要配合观察者模式)

observer的类图:

1624455234173.png

实现:

class Observer {
  constructor(data) {
    // 调用walk转换getter setter
    this.walk(data)
  }
  walk(data) {
    // 先判断一下data是不是对象
    if (!data || typeof data !== 'object') return
    // 遍历data中的属性
    Object.keys(data).forEach(key => {
      this.defineReactive(data, key, data[key])
    })
  }
  defineReactive(obj, key, val) {
    // 对值调用walk方法,以解决当val为对象时不能将其内部属性转换成getter setter的bug
    this.walk(val)
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get() {
        return val
      },
      set(newVal) {
        if (newVal === val) return
        val = newVal
        // 当给data中属性进行赋值时,如果值为对象,将其内部属性转换成getter setter
        that.walk(newVal)
        // 发送通知
      }
    })
  }
}

defineReactive方法中转换getter时直接return val是因为如果返回obj[key],这个时候就会产生递归调用产生死循环:

1624458621644.png

同时在vue.js里面创建一个Observer的实例,传入$data

  constructor(options) {
    // 一、将options中的数据保存起来
    // 1.将options保存到$options上
    this.$options = options || {}
    // 2.将options.data保存到$data上
    this.$data = options.data || {}
    // 3.将options.el保存到$el上
    this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el

    // 二、将data中的数据转换成getter和setter,注入vue实例中
    this._proxyData(this.$data)
    // 三、调用observer监听数据变化
    new Observer(this.$data)
    // 四、调用compiler解析指令和差值表达式
  }

html文件中,引入vue.js之前引入observer.js,打开浏览器打印一下vm可以看到$data中的属性已经被转换成了gettersetter

1624458181728.png

Compiler

Compiler的主要功能:

  1. 编译模板、解析指令与差值表达式
  2. 渲染页面并在数据更新后再次渲染数据

类图:

1624538400532.png

实现:

class Compiler {
  // 构造器接收vue实例
  constructor(vm) {
    this.el = vm.$el
    this.vm = vm
    this.compiler(this.el)
  }

  // 编译模板,处理文本节点以及元素节点
  compiler(el) {
    const childNodes = el.childNodes
    Array.from(childNodes).forEach(node => {
      // 判断节点类型并对相应的节点进行操作
      if (this.isTextNode(node)) {
        this.compilerText(node)
      } else if (this.isElementNode(node)) {
        this.compilerElement(node)
      }
      // 判断是否有子节点,若有则递归
      if (node.childNodes && node.childNodes.length) {
        this.compiler(node)
      }
    })
  }

  // 处理元素节点
  compilerElement(node) {
    // 遍历所有的属性
    Array.from(node.attributes).forEach(attr => {
      let attrName = attr.name
      // 判断该属性是否为指令
      if (this.isDirective(attrName)) {
        attrName = attrName.substr(2)
        let key = attr.value
        this.update(node, key, attrName)
      }
    })
  }

  update(node, key, attrName) {
    const updateFn = this[attrName + 'Updater']
    updateFn && updateFn(node, this.vm[key])
  }
  textUpdater(node, value) {
    node.textContent = value
  }
  modelUpdater(node, value) {
    node.value = value
  }

  // 处理文本节点
  compilerText(node) {
    const reg = /\{\{(.+?)\}\}/
    let value = node.textContent
    if (reg.test(value)) {
      let key = RegExp.$1.trim()
      // {{ xxx }} 替换成变量的值
      node.textContent = value.replace(reg, this.vm[key])
    }
  }

  // 判断是否为指令
  isDirective(attrName) {
    return attrName.startsWith('v-')
  }

  // 判断是否为文本节点
  isTextNode(node) {
    return node.nodeType === 3
  }

  // 判断是否为元素节点
  isElementNode(node) {
    return node.nodeType === 1
  }
}

Dep

功能:

  1. 收集依赖,添加观察者
  2. 通知所有观察者

类图

1624777291842.png

实现

class Dep {
  constructor() {
    // subs用于存储所有的观察者
    this.subs = []
  }

  // 添加观察者
  addSubs(sub) {
    if (sub && sub.update) {
      this.subs.push(sub)
    }
  }

  // 发送通知
  notify() {
    this.subs.forEach(sub => {
      sub.update()
    })
  }
}

在html文件引入js文件的最上方引入Dep.js,然后在Observer类中的defineReactive方法中创建一个dep对象,并在getset方法中收集依赖和发送通知

  defineReactive(obj, key, val) {
    // 对值调用walk方法,以解决当val为对象时不能将其内部属性转换成getter setter的bug
    this.walk(val)
    const that = this

    // 创建dep对象来收集依赖+发送通知
    const dep = new Dep()
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get() {
        // 收集依赖
        Dep.target && dep.addSub(Dep.target)
        return val
      },
      set(newVal) {
        if (newVal === val) return
        val = newVal
        // 当给data中属性进行赋值时,如果值为对象,将其内部属性转换成getter setter
        that.walk(newVal)
        // 发送通知
        dep.notify()
      }
    })
  }

watcher

watcherdep的关系图:

1624803580196.png

功能:

  1. 当数据变化触发依赖,dep通知所有的watcher实例更新视图
  2. 自身实例化的时候往dep对象中添加自己

类图

1624804269071.png

实现

class Watcher {
  constructor(vm, key, cb) {
    this.vm = vm
    // data中的属性名
    this.key = key
    // 更新视图的回调函数
    this.cb = cb

    // 把watcher实例记录到Dep类的静态属性target上
    Dep.target = this
    // 旧值--此时会触发get方法,调用addSubs
    this.oldValue = vm[key]
    // 清空target
    Dep.target = null
  }
  // 更新视图
  update() {
    let newValue = this.vm[this.key]
    if (newValue === this.oldValue) return
    this.cb(newValue)
  }
}

然后修改Compiler类中的文本节点处理方法以及指令处理方法:

  // 处理文本节点
  compilerText(node) {
    const reg = /\{\{(.+?)\}\}/
    let value = node.textContent
    if (reg.test(value)) {
      let key = RegExp.$1.trim()
      // {{ xxx }} 替换成变量的值
      node.textContent = value.replace(reg, this.vm[key])

      // 创建watcher实例
      new Watcher(this.vm, key, newValue => node.textContent = newValue)
    }
  }

  update(node, key, attrName) {
    const updateFn = this[attrName + 'Updater']
    // 使用call改变方法调用时其内部this的指向,将其指向当前compiler实例
    updateFn && updateFn.call(this, node, this.vm[key], key)
  }
  textUpdater(node, value, key) {
    node.textContent = value
    new Watcher(this.vm, key, newValue => node.textContent = newValue)
  }
  modelUpdater(node, value, key) {
    node.value = value
    new Watcher(this.vm, key, newValue => node.value = newValue)
  }

这样当数据发生变化时,视图也会随之更新:

1624808830393.png

更新后:

1624808880426.png

双向绑定

效果:当文本框内数据发生变化时,vm内的数据和视图也要更新

v-model的处理函数中,给node增加input事件:在触发input后,将vm中对应的属性的值更改为当前的值:

  modelUpdater(node, value, key) {
    node.value = value
    new Watcher(this.vm, key, newValue => node.value = newValue)
    // 实现双向绑定
    node.addEventListener('input', () => {
      this.vm[key] = node.value
    })
  }

重新赋值时会触发set方法,这时会再调用Dep实例的notify方法来更新相应的视图