深入亿点点之Vue:数据响应式__Vue.js
发布于 4 年前 作者 banyungong 1535 次浏览 来自 分享
粉丝福利 : 关注VUE中文社区公众号,回复视频领取粉丝福利

前言

数据响应式原理也是老生常谈了,什么是数据响应式呢?
数据响应式,从官方定义来说,将Model绑定到View,当用代码更新Model时,View会自动更新。
数据响应式强调数据驱动DOM生成,而不是直接操作DOM。
而常常和数据响应式混为一谈的数据双向绑定,则特指v-model,该指令实现了如果用户更新了View,Model也会随之更新。结合响应式原理,则形成了双向绑定,即双向绑定 = 单向绑定 + UI事件监听
本文分为四个部分:

  • 一图理解响应式原理
  • 手把手教你实现数据双向绑定
  • 来个极简实现方案
  • 数据响应式的思考

一图理解响应式原理

Vue通过订阅发布者模式来实现,通过三个类ObserverDepWatcher来实现,主要关注每个类的功能和类之间的关系。
订阅发布者模式示意图
首先明确的是三个类之间的对应关系:ObserverDep是一对一的关系,DepWatcher是多对多的关系
每个类的具体功能如下:
Observer:数据的观察者(个人理解上是一个代理发布者),当初始化数据时,遍历数据所有属性,为每一个属性设置一个调度中心(Dep实例对象),通过Object.defineProperty把属性转为getter/setter,注入相关的Dep调度方法。
Watcher:数据的订阅者,当接收到调度中心Dep的更新通知时,Watcher实例执行回调cb,更新视图。
Dep调度中心,作为发布者和订阅者之间的消息传递枢纽。当Observer类触发getter时,Dep收集依赖的Watcher对象。当Observer类触发**setter**时,Observer将数据更新信息发送给DepDep通知订阅者Watcher更新。
这三个类构成了主要的Model到View逻辑。至于View到Model的逻辑,只需要对输入控件进行事件监听,即可实现View到Model。这样就形成了闭环的MVVM模型。

手把手教你实现数据双向绑定

1. 实现订阅-发布者模式架构

这一步骤主要是确定好整个流程框架,参照流程图可以确定:

  • 确定初始化需要的数据
  • 劫持数据,下发更新
  • 编译模板,收集依赖
  • 视图更新
class Vue{
  constructor(options) {
    // 1. 初始化数据
    this.options = options
    this.$data = options.data
    this.$el = document.querySelector(options.el)
    this._directive = [] // 收集依赖的容器
    this.observer()  // 2. 数据监测
    this.compiler()  // 3. 编译模板,收集依赖
  }

  // TODO: 数据劫持,下发更新
  observer() {}

  // TODO: 判断指令,收集依赖
  compiler() {}
}

// 订阅者,主要更新视图
class Watcher {
  constructor() {
    this.update()
  }
  // TODO: 更新视图
  update() {}
}

var vm = new Vue({
  el: '#app',
  data: {
    myText: '一开始,只是平平无奇的text',
    myModel: '普普通通的model',
  }
})

2. 实现M->V,把模型里的数据绑定到视图

在框架上补充具体的逻辑:

  • 初始数据:除了记录传入的一些数据外,还需要一个容器来记录订阅者。由于订阅者是对data的属性监听的,也就是当data[prop]更新时,只有订阅prop属性的订阅者会有更新操作,其他订阅者不会收到更新。所以订阅者容器是一个属性对应一个列表。
  • 劫持数据,下发更新:通过Object.defineProperty重写get/set(Vue3通过ES6的Proxy实现),对数据属性进行劫持监听。当更改数据属性时,下发更新。

    为什么用Proxy 替代 Object.defineProperty ? Object.defineProperty只能劫持对象的属性,因此我们需要对每个对象的每个属性进行遍历。通过递归以及遍历data对象来实现对数据的监控的,如果属性值也是对象那么需要深度遍历。
    Proxy相比较与Object.defineProperty,优点如下:
    1. 可以劫持整个对象,并返回一个新对象。显然可以大幅度提升性能
    2. z多种劫持操作

  • 编译模板,收集依赖:解析html模板,通过检测设定的特定属性如v-model,进行依赖收集操作
  • 视图更新:订阅者收到更新信息后,对DOM进行操作,更新视图
<body>
<div id="app">
  <h2>响应式原理实现</h2>
  <div v-text="myText"></div>
  <div v-text="myModel"></div>
  <input v-model="myModel" />
</div>
</body>
<script>
class Vue{
  constructor(options) {
    // 1. 初始化数据
    this.options = options
    this.$data = options.data
    this.$el = document.querySelector(options.el)
    this._directive = {} // 收集依赖的容器

    // 2. 数据监测
    this.observer(this.$data)
    
    // 3. 编译模板
    this.compiler(this.$el)
  }

  observer(data) {
    for (var key in data) {
      this._directive[key] = [] // 初始化订阅者容器
      let val = data[key]
      let watchers = this._directive[key]
      Object.defineProperty(this.$data, key, {
        get: function() {
          return val
        },
        set: function(newVal) { // 更新数值,下发更新
          val = newVal
          watchers.forEach(watcher => watcher.update())
        },
      })
    }

    // es6
    // const handler = {
    // set: function(data, prop, val) {
    // },
    // }
    // data = new Proxy(data, handler)
  }

  compiler(el) {
    let nodes = el.children

    for(let i=0; i<nodes.length; i++) {
      let node = nodes[i]
      // 如果有子元素,递归调用
      if (node.children.length > 0) this.compiler(node)

      // 判断指令,收集依赖
      if(node.hasAttribute("v-model")) {
        let attrVal = node.getAttribute("v-model")
        this._directive[attrVal].push(new Watcher(node, this, attrVal, 'value'))
      }

      if(node.hasAttribute("v-text")) {
        let attrVal = node.getAttribute("v-text")
        this._directive[attrVal].push(new Watcher(node, this, attrVal, 'innerText'))
      }
    }
  }
}

// 订阅者,主要更新数据
class Watcher {
  // el: 订阅节点
  // vm: vue实例
  // exp: 订阅的data属性值
  // attr: 不同订阅者更新视图时,修改的属性不同
  constructor(el, vm, exp, attr) {
    this.el = el
    this.vm = vm
    this.exp = exp
    this.attr = attr
    this.update()
  }
  
  // 更新
  update() {
    this.el[this.attr] = this.vm.$data[this.exp]
  }
}

var vm = new Vue({
  el: '#app',
  data: {
    myText: '一开始,只是平平无奇的text',
    myModel: '普普通通的model',
  },
})

setTimeout(function(){
  console.log(vm.$data)
  vm.$data["myText"] = '3秒后,text更新了'
}, 3000)
</script>

3. 实现V->M

v-model指令,编译时加入事件监听。

// ...
if(node.hasAttribute("v-model")) {
  let attrVal = node.getAttribute("v-model")
  this._directive[attrVal].push(new Watcher(node, this, attrVal, 'value'))
  // V -> M: 加入事件监听
  node.addEventListener("input", (function () {
    return function() {
      this.$data[attrVal] = node.value
    }
  })().bind(this))
}

20行代码极简实现

const input = document.getElementById('input')
const span = document.getElementById('span')
const obj = {
  text: '文本文本文本'
}
const handler = {
  set: function(target, prop, val) {
    target[prop] = val
    span.innerText = val
    input.value = val
  }
}

const myText = new Proxy(obj, handler);

input.addEventListener('keyup', function(e){
  // 赋值触发了set,初始化视图
  // myText代理了text属性,当text改变触发set
  myText.text = e.target.value;
})

对数据响应式的一些思考

优点

  • 双向绑定把数据变更的操作隐藏在框架内部,调用者并不会直接感知。
  • 在表单交互多的情况下,可以简化大量代码。

注意点

  • 数据之间互相依赖,由于"黑盒"的存在,在复杂应用中难以追踪数据变化和管理,不如引入如vuex的状态管理来得便利。
  • 使用Object.defineProperty实现数据绑定时,直接添加对象属性obj[prop],该属性并非响应式,即无法通过数据驱动视图。对于数组对象也有类似的限制,直接通过索引修改数组也不会驱动视图更新。为了成为响应式属性,需要通过Vue.set来设置。(Vue3.0使用Proxy实现,属性的添加和删除、数组索引和长度的变更都可以被监听,并可以支持 Map、Set、WeakMap 和 WeakSet,该问题得到解决)
  • 创造订阅者本身有一定的消耗,且订阅者一直存在于内存中。

参考

[1] 深入响应式原理
[2] 深入理解Vue响应式原理
[3] VUE数据响应式原理

广告时间

飞书是字节跳动旗下办公套件产品,其将即时沟通、在线协作、音视频会议、日历、云盘、工作台等功能进行了深度整合,为用户提供一站式协作体验。目前,飞书服务的客户已经覆盖了科技互联网、信息技术、制造、建筑地产、企业服务、教育、媒体等多个领域。欢迎投递字节跳动飞书团队,有海量前端后端HC~扫描二维码或者点击链接投递,认准飞书团队👍~
【校招】内推码: HZNVPHS,投递链接: https://job.toutiao.com/s/JaeUCoc
【社招】投递链接: https://job.toutiao.com/s/JaevUNo
社招 校招

版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 作者: 吉玉 原文链接:https://juejin.im/post/6844904119291871239

回到顶部