对双向数据绑定原理的理解及代码模拟 | Vue

600 阅读10分钟

1. 一句话解释

vue双向数据绑定原理是通过 数据劫持 结合 发布订阅模式 的方式来实现的。

1.1 什么是数据劫持?

数据劫持是一种技术,它通过监听数据的变化来自动更新视图,同时也可以反过来,通过监听视图的变化来更新数据。

在不同版本的Vue中,数据劫持的使用方式略有不同。

Vue 2.x使用的是 ES5 中的 Object.defineProperty 方法, vue3.x使用的是Es6中的 Proxy 方法。

1.2 什么是发布订阅模式?

发布订阅模式是一种消息范式,包含一个主题/事件中心,通常会有多个订阅者,当主题对象发布事件时,订阅者对象会收到事件通知,然后进行相应的处理。

类似于一家报社发布报纸,读者可以订阅,当新的报纸发布时,读者就会收到通知。这种模式可以实现松散耦合,订阅者不需要知道谁发布了事件,发布者也不需要知道谁订阅了事件,从而使代码更灵活、可复用和易于维护。

1.3 发布订阅模式和观察者模式之间的区别:

发布订阅模式中,发布者和订阅者并没有直接的联系。发布者将消息发布到一个通道(channel)中,而订阅者通过指定并订阅这个通道来接收消息。发布者和订阅者之间的关系是通过这个通道建立起来的。

相比之下,观察者模式中,观察者和被观察者是直接耦合的。当被观察者状态发生改变时,它会直接通知观察者,而观察者也会相应地做出反应。

因此,发布订阅模式更加松散耦合,而观察者模式更加紧密耦合。

2. 双向数据绑定在vue框架中的作用

由三块部分构成:

  1. 数据层 (Model) :应用的数据及业务逻辑,为开发者编写的业务代码;
  2. 视图层 (View) :应用的展示效果,各类UI组件,由 template 和 css 组成的代码;
  3. 业务逻辑层 (ViewModel) :框架封装的核心,它负责将数据与视图关联起来;

上面这个分层的架构方案,有一个专业术语:MVVM,核心功能便是“双向数据绑定”

3. 理解双向数据绑定

3.1 主要职责:

它允许数据模型的变化自动更新视图,同时允许视图的变化自动更新数据模型。

Untitled.png

3.2 主要构成:

  1. 监听器 (Observer) : 观察数据,时刻关注数据的任何变化,通知视图更新;
  2. 解析器 (Compiler) :观察视图层,时刻关注视图发生的一切交互,更新数据;

3.3 双向数据绑定的过程

双向数据绑定的过程可以归纳为以下几个步骤:

  1. 绑定数据模型和视图元素;
  2. 监听数据模型的变化,并更新视图元素;
  3. 监听视图元素的变化,并更新数据模型;

3.4 双向数据绑定实现步骤

  1. 首先在 VUE 初始化的时候,对数据(data)进行劫持监听(响应化处理),需要设置一个监听器(Observer), 用来监听所有属性。

    监听器中使用到了ES5中的 Object.defineProperty() 方法,该方法能够劫持对象的 setter 和 getter方法,已达到监听数据的效果。

  2. 对模版执行编译操作,需要一个 指令解析器(Compile),对每个节点元素进行扫描和解析(元素节点/文本节点)。

    找到其中动态绑定的数据,从 data 中获取并初始化视图。

    同时,初始化一个 订阅者(Watcher)并添加更新函数,将来数据变化时 Watcher 会调用更新函数。

  3. 由于数据data中的key在一个视图中可能出现多次,订阅者会有多个,所以需要一个 消息订阅器(Dep)来专门收集这些订阅者,进行统一管理。

  4. 如果data中的属性发生了变化,会先找到对应的消息订阅器(Dep), 通知所有的 订阅者(Watcher)执行更新函数。

Untitled (1).png

实现双向数据绑定可以归纳为以下3个步骤:

  1. 实现一个监听器 Observer, 用来劫持并监听所有属性,如果有变动,就通知订阅者。

  2. 实现一个订阅者 Watcher, 可以收到属性的变化通知并执行相应的函数,从而更新视图。

    a. 订阅者可能有多个,需要一个订阅收集器来统一管理,在observer-get中,给每个属性赋值时,会初始化一个 Dep类,用来收集和发布订阅者

  3. 实现一个解析器 Compile, 可以扫描和解析每个节点的相关指令,并根据初始化模版数据以及初始化相应的订阅器。

4. 模拟响应式原理,实现一个简易的Vue

4.1 效果展示及项目结构:

            Vue2-mini
            ├─ Compiler.js
            ├─ Dep.js
            ├─ index.html
            ├─ Observer.js
            ├─ vue.js
            └─ Watcher.js

git地址: github.com/wuyanfeiyin…

4.1 初始化Vue

index.html

<div id="app">
    <h1>学习响应式原理,Vue2 简易版</h1>

    <hr/>
    <h4>双向数据绑定:</h4>    
    <input type="text" v-model="msg">
    <h6>{{msg}}</h6>
    <hr/>
    <h4>v-html:</h4>    
    <div v-html="htmlStr"></div>
    <hr/>
    <h4>v-text</h4>    
    <div v-text="msg"></div>
    <hr/>
    <h4>v-on:click</h4>
    <span>{{number}}</span>
    <button v-on:click="changeCount">点击+1</button>
  </div>
<script>
    new Vue({
      el: '#app',
      data: {
        msg: 'hi~ wuyanfeiying',
        htmlStr: `<span style='color:green;'>你好哇~</span>`,
        number: 0
      },
      methods: {
        changeCount() {
          this.number ++
        }
      }
    })
  </script>

4.2 vue.js

  1. 将传入的配置项赋值到当前vue实例上;

  2. 代理data到vm上( _proxyData 方法 );

    1. 作用:方便取值,例如:vm.msg 相当于 vm.$data.msg
  3. 初始化 监听器 ( Observer方法 );

    1. 作用:对data的属性进行响应化处理
  4. 初始化 模板编译器 (Compiler 方法);

    1. 作用:解析指令和差值表达表达式
class Vue {
  constructor (options){
    this.$options = options || {}
    this.$data = options.data
    this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el

    debugger

    // 代理data到vm上,例如:vm.msg = vm.$data.msg
    this._proxyData(this.$data)

    // 初始化 监听器:对data的属性进行响应化处理
    new Observer(this.$data)

    // 初始化 模板编译器:解析指令和差值表达式
    new Compiler(this)
  }

  // 代理data到vm上
  _proxyData (data) {
    Object.keys(data).forEach(key => {
      Object.defineProperty(this,key,{
        enumberable: true, // 是否可枚举,比如能不能遍历这个属性
        configurable: true, // 是否可配置,比如能不能删除这个属性
        // 访问属性时
        get(){
          return data[key]
        },

        set(newValue) {
          if (newValue === data[key]) {
            return
          }
          data[key] = newValue
        }
      })
    })
  }
}

4.3 Observer.js (数据劫持)

  1. 校验传入的data的格式

  2. 遍历data的所有属性,进行响应化处理

    1. 每个属性在响应化处理前,都会先注册一个Dep(订阅收集器)

    2. get方法中(访问属性时)

      1. 作用:收集依赖(订阅者 Watcher 的实例),存储到dep.subs数组中
      2. 注意:
      3. 此时 Dep.target 是订阅者Watcher的实例
      4. 怎么触发的呢?
      5. 在 Watcher 实例化的时候(这个发生在模版解析器里),将 Dep.target = new Watcher(),之后,访问了属性,触发了属性对应的getter,就到这里来了
      6. 在这里(指属性对应的getter方法中)进行依赖收集
      7. 最后(在Wathcer中),Dep.target = null (进行销毁处理)
    3. set方法中(属性被赋值时)

      1. 发送通知
      2. 作用:告诉当前 Dep(订阅收集器)的所有订阅者(Watcher实例),更新数据
class Observer {
  constructor (data) {
    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) {
    let that = this

    // 初始化 订阅收集器:负责收集订阅者(Watcher), 并发送通知
    let dep = new Dep()

    // val 如果也是对象,需要继续将内部属性响应化
    that.walk(val)

    Object.defineProperty(obj, key, {
      enumerable: true, // 是否可枚举,比如 是否可以循环
      configurable: true, // 是否可配置,比如 是否可以删除属性
      // 访问属性时
      get(){
        // 收集依赖(收集订阅器 Watcher 的实例)
        // 注意:这里 Dep.target 是 订阅器Watcher的实例,
        // 在初始化Watcher的时候,被缓存到Dep的target上面
        // 怎么触发的呢?
        // 在Watcher初始化的时候(这个发生在模版解析器中),访问了属性,触发这个属性的getter,就到这里来了
        // 触发完属性之后,又会被销毁掉(Dep.target = null)
        Dep.target && dep.addSub(Dep.target)
        return val
      },
      // 属性被赋值时
      set(newValue) {
        if (newValue === val) {
          return
        }
        val = newValue
        that.walk(newValue)
        // 发送通知
        // 告诉当前Dep(订阅收集器)关联的所有 订阅者(Watcher 实例),更新数据
        dep.notify()
      }
    })
  }
}

4.4 Compiler.js (模板解析)

Untitled (3).png 遍历节点,根据节点类型进行模版解析

  1. 元素节点,

    1. 根据不同指令类型进行处理
    2. 如果存在子节点,递归编译
  2. 文本节点

    1. 正则匹配差值表达式
  3. 元素节点/文本节点 取值后,会创建 订阅者(Watcher)以及更新函数,当数据改变时,通过更新函数更新视图

class Compiler {
  constructor (vm) {
    this.el = vm.$el
    this.vm = vm
    this.compile(this.el)
  }

  // 编译模板:处理 元素节点 和 文本节点
  compile(el) {
    let childNodes = el.childNodes
    Array.from(childNodes).forEach(node => {
      // 元素节点
      if (this.isElementNode(node)) {
        this.compileElement(node)
      } else if (this.isTextNode(node)) {
        // 文本节点
        this.compileText(node)
      }

      // 判断是否有子节点
      if (node.childNodes && node.childNodes.length > 0) {
        // 对子节点进行递归调用
        this.compile(node)
      }
    })
  }

  // 元素节点 解析
  compileElement(node) {
    // 遍历所有的属性节点
    Array.from(node.attributes).forEach(attr => {
      let attrName = attr.name

      // 判断是否为指令 v-
      if (this.isDirective(attrName)) {
        // v-text ---> text
        attrName = attrName.substr(2)
        let key = attr.value
        this.update(node, key, attrName)
      }

      // 判断是否为事件指令 v-on:事件名
      if (this.isEvent(attrName)) {
        let key = attr.value // 解析后是:changeCount

        // 注意区分 substr 和 substring 用法
        // attrName 此时已经在上面经过处理了(attrName.substr(2))
        // 'v-on:click="changeName"'.substr(2) --> 'on:click'
        // 'on:click'.substring(3) --> 'click'

        const dir = attrName.substring(3) // 解析后是:click

        this.eventHandler(node, this.vm, key, dir)
      }
    })
  }

  // 更新获取到的值
  update(node, key, attrName) {
    let updateFn = this[attrName + 'Updater']
    updateFn && updateFn.call(this, node, this.vm[key], key)
  }

  // v-text 指令
  textUpdater(node, value, key) {
    node.textContent = value
    // 初始化 订阅者, 传入更新函数
    new Watcher(this.vm, key, newValue => {
      node.textContent = newValue
    })
  }

  // v-model 指令
  modelUpdater(node, value, key) {
    node.value = value
    new Watcher(this.vm, key, newValue => {
      node.value = newValue
    })

    // 双向数据绑定
    node.addEventListener('input', ()=> {
      this.vm[key] = node.value
    })
  }

  // v-html 指令
  htmlUpdater(node, value, key) {
    node.innerHTML = value
    new Watcher(this.vm, key, newValue => {
      node.innerHTML = newValue
    })
  }

  // 添加事件
  eventHandler(node, vm, exp, dir) {
    const fn = vm.$options.methods && vm.$options.methods[exp]
    if (dir && fn) {
      node.addEventListener(dir, fn.bind(vm))
    }
  }

  // 文本节点 解析
  compileText(node) {
    // 正则匹配 差值表达式
    let reg = /\{\{(.+?)\}\}/
    let value = node.textContent

    if (reg.test(value)) {
      let key = reg.exec(value)[1]
      node.textContent = value.replace(reg, this.vm[key])

      // 创建 订阅者(Watcher), 当数据改变时 更新视图
      new Watcher(this.vm, key, newValue => {
        node.textContent = newValue
      })
    }
  }

  // 判断元素属性是否是指令
  isDirective(attrName) {
    return attrName.startsWith('v-')
  }

  // 判断是否为 事件指令 v-on:事件名
  isEvent(attrName) {
    return attrName.indexOf("on:") === 0
  }

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

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

}

4.5 Dep.js (订阅收集器)

  1. subs 数组:

    1. 存储所有订阅者(Watcher实例)
  2. addSub 方法

    1. 添加订阅者
    2. 此方法会在Observer(数据劫持)中属性对应的get方法中调用
  3. notify 方法

    1. 发布通知
    2. Dep(订阅收集器)下的所有订阅者都会触发更新函数
    3. 此方法会在Observer(数据劫持)中属性对应的set方法中调用
class Dep {
  constructor() {
    // 存储所有订阅者(Watcher实例)
    this.subs = []
  }

  // 添加订阅者
  // 此方法,会在 Observer(数据劫持) 中属性对应的 get方法中进行调用
  addSub(watcher) {
    if (watcher && watcher.update) {
      this.subs.push(watcher)
    }
  }

  // 发送通知
  // Dep下的所有订阅者都会触发更新函数
  // 此方法,会在 Observer(数据劫持) 中属性对应的 set方法中 进行调用
  notify() {
    this.subs.forEach(watcher => {
      watcher.update()
    })
  }
}

4.6 Watcher.js (订阅者)

  1. 形参(vm,key,cb):

    1. key:data的属性
    2. cb: 回调函数,作用是更新视图。此回调函数,是在Compiler模版解析过程中,取值时,创建订阅者(Watcher实例)的时候创建的
  2. Dep.target:

    1. 初始化Vue时,先执行Observer进行数据的响应化处理,每个属性在响应化时,会初始化 订阅收集器(Dep)
      1. 将当前 Watcher的实例 存储到 Dep.target 上面
      1. 通过数据的取值操作,会触发一次属性的get方法,在Observer中,会进行依赖的收集操作,将Dep.target存储到dep.subs中
      1. 数据销毁(Dep.target = null)
  3. update 方法:

    1. 数据发生变化时,更新视图
    2. 当数据发生变动时,会触发Observer中监听的属性的set方法
    3. set方法中,会调用 Dep中的notify, 对 订阅收集器(Dep)下面的所有订阅者(Watcher 实例)进行统一调用watcher.update方法
    4. 从而触发回调函数(cb),更新视图数据
class Watcher {
  constructor (vm, key, cb) {
    this.vm = vm;
    // data的key
    this.key = key;
    // 回调函数:更新视图
    // 此回调函数,是在 Compiler 中,模板解析的过程中,取值时,创建Watcher实例时,创建的
    this.cb = cb;

    // 初始化Vue时,先执行Observer进行数据的响应化处理,每个属性在响应化时,会初始化 订阅收集器(Dep)
    // 此处将当前 Watcher的实例 存储到 Dep.target 上面
    Dep.target = this;
    // 此处,进行了属性的取值操作,会触发一次属性的get方法,在observer中,会进行依赖的收集操作,将Dep.target存储到dep.subs中
    this.oldValue = vm[key]
    // 数据销毁
    Dep.target = null
  }

  // 数据发生变化时,更新视图
  // 当数据发生变动时,会触发Observer中监听的属性的set方法,
  // set方法中,会调用 Dep中的notify, 对 订阅收集器(Dep)下面的所有订阅者(Watcher 实例)进行统一调用watcher.update方法
  // 从而触发回调函数,更新视图数据
  update() {
    let newValue = this.vm[this.key]
    if (this.oldValue === newValue) {
      return
    }
    this.cb(newValue)
  }
}

5. Vue2和Vue3的响应式原理有什么区别?

版本实现方式优点缺点
Vue2.XObject.defineProperty1. 基于 数据劫持/依赖收集 的双向绑定的优点;2. 兼容性好,兼容IE9等1. 不能监听数组新增和删除属性,因为数组长度不确定,太长性能负担大;2. 只能监听属性,而不是整个对象,需要遍历属性;3. get方法没有传入参数,如果需要返回原值,需要在外部缓存之前的值;4. 只能监听属性的变化,不能监听属性的删减
Vue3.XProxy1. 可以监听数组;2. 可以监听整个对象,不需要递归;3. get方法可以传入对象和属性,可以直接在函数内部操作,不需要外部变量;4. new Proxy()会返回一个新对象,不会污染原对象;兼容性差,不支持IE等

6.总结

本文介绍了Vue框架中双向数据绑定的实现原理。主要分为两个部分:数据劫持和发布订阅模式。Vue 2.x 中使用的是 Object.defineProperty 方法,Vue 3.x 中使用的是 Proxy 方法。

在Vue的MVVM架构中,数据层、视图层和业务逻辑层共同构成了框架的核心。

双向数据绑定的过程涉及到监听器、解析器和订阅器、订阅收集器等模块,其中监听器用于劫持数据并监听数据变化,解析器用于扫描和解析每个节点的指令或表达式,订阅收集器用于统一管理订阅者,当数据发生变化时通知所有订阅者执行更新函数。

最后,本文还简单介绍了如何模拟响应式原理,实现一个简易的Vue。

参考:

Vue数据双向绑定 - 掘金

vue的双向绑定原理及实现 - canfoo#! - 博客园

面试题:你能写一个Vue的双向数据绑定吗? - 掘金

模拟Vue响应式原理,实现一个简易版Vue - 掘金

Daily-earning/Vue-mini版实现 at master · endless-z/Daily-earning

Vue双向绑定原理_牛客博客