这应该是最详细的响应式系统讲解了


Posted in Javascript onJuly 22, 2019

前言

本文从一个简单的双向绑定开始,逐步升级到由defineProperty和Proxy分别实现的响应式系统,注重入手思路,抓住关键细节,希望能对你有所帮助。

一、极简双向绑定

首先从最简单的双向绑定入手:

// html
<input type="text" id="input">
<span id="span"></span>
// js
let input = document.getElementById('input')
let span = document.getElementById('span')
input.addEventListener('keyup', function(e) {
 span.innerHTML = e.target.value
})

以上似乎运行起来也没毛病,但我们要的是数据驱动,而不是直接操作dom:

// 操作obj数据来驱动更新
let obj = {}
let input = document.getElementById('input')
let span = document.getElementById('span')
Object.defineProperty(obj, 'text', {
 configurable: true,
 enumerable: true,
 get() {
  console.log('获取数据了')
  return obj.text
 },
 set(newVal) {
  console.log('数据更新了')
  input.value = newVal
  span.innerHTML = newVal
 }
})
input.addEventListener('keyup', function(e) {
 obj.text = e.target.value
})

以上就是一个简单的双向数据绑定,但显然是不足的,下面继续升级。

二、以defineProperty实现响应系统

在Vue3版本来临前以defineProperty实现的数据响应,基于发布订阅模式,其主要包含三部分:Observer、Dep、Watcher。

1. 一个思路例子

// 需要劫持的数据
let data = {
 a: 1,
 b: {
  c: 3
 }
}

// 劫持数据data
observer(data)

// 监听订阅数据data的属性
new Watch('a', () => {
  alert(1)
})
new Watch('a', () => {
  alert(2)
})
new Watch('b.c', () => {
  alert(3)
})

以上就是一个简单的劫持和监听流程,那对应的observer和Watch该如何实现?

2. Observer

observer的作用就是劫持数据,将数据属性转换为访问器属性,理一下实现思路:

①Observer需要将数据转化为响应式的,那它就应该是一个函数(类),能接收参数。
②为了将数据变成响应式,那需要使用Object.defineProperty。
③数据不止一种类型,这就需要递归遍历来判断。

// 定义一个类供传入监听数据
class Observer {
 constructor(data) {
  let keys = Object.keys(data)
  for (let i = 0; i < keys.length; i++) {
   defineReactive(data, keys[i], data[keys[i]])
  }
 }
}
// 使用Object.defineProperty
function defineReactive (data, key, val) {
 // 每次设置访问器前都先验证值是否为对象,实现递归每个属性
 observer(val)
 // 劫持数据属性
 Object.defineProperty(data, key, {
  configurable: true,
  enumerable: true,
  get () {
   return val
  },
  set (newVal) {
   if (newVal === val) {
    return
   } else {
    data[key] = newVal
    // 新值也要劫持
    observer(newVal)
   }
  }
 })
}

// 递归判断
function observer (data) {
 if (Object.prototype.toString.call(data) === '[object, Object]') {
  new Observer(data)
 } else {
  return
 }
}

// 监听obj
observer(data)

3. Watcher

根据new Watch('a', () => {alert(1)})我们猜测Watch应该是这样的:

class Watch {
 // 第一个参数为表达式,第二个参数为回调函数
 constructor (exp, cb) {
  this.exp = exp
  this.cb = cb
 }
}

那Watch和observer该如何关联?想想它们之间有没有关联的点?似乎可以从exp下手,这是它们共有的点:

class Watch {
 // 第一个参数为表达式,第二个参数为回调函数
 constructor (exp, cb) {
  this.exp = exp
  this.cb = cb
  data[exp]  // 想想多了这句有什么作用
 }
}

data[exp]这句话是不是表示在取某个值,如果exp为a的话,那就表示data.a,在这之前data下的属性已经被我们劫持为访问器属性了,那这就表明我们能触发对应属性的get函数,那这就与observer产生了关联,那既然如此,那在触发get函数的时候能不能把触发者Watch给收集起来呢?此时就得需要一个桥梁Dep来协助了。

4. Dep

思路应该是data下的每一个属性都有一个唯一的Dep对象,在get中收集仅针对该属性的依赖,然后在set方法中触发所有收集的依赖,这样就搞定了,看如下代码:

class Dep {
 constructor () {
  // 定义一个收集对应属性依赖的容器
  this.subs = []
 }
 // 收集依赖的方法
 addSub () {
  // Dep.target是个全局变量,用于存储当前的一个watcher
  this.subs.push(Dep.target)
 }
 // set方法被触发时会通知依赖
 notify () {
  for (let i = 1; i < this.subs.length; i++) {
   this.subs[i].cb()
  }
 }
}

Dep.target = null

class Watch {
 constructor (exp, cb) {
  this.exp = exp
  this.cb = cb
  // 将Watch实例赋给全局变量Dep.target,这样get中就能拿到它了
  Dep.target = this
  data[exp]
 }
}

此时对应的defineReactive我们也要增加一些代码:

function defineReactive (data, key, val) {
 observer()
 let dep = new Dep() // 新增:这样每个属性就能对应一个Dep实例了
 Object.defineProperty(data, key, {
  configurable: true,
  enumerable: true,
  get () {
   dep.addSub() // 新增:get触发时会触发addSub来收集当前的Dep.target,即watcher
   return val
  },
  set (newVal) {
   if (newVal === val) {
    return
   } else {
    data[key] = newVal
    observer(newVal)
    dep.notify() // 新增:通知对应的依赖
   }
  }
 })
}

至此observer、Dep、Watch三者就形成了一个整体,分工明确。但还有一些地方需要处理,比如我们直接对被劫持过的对象添加新的属性是监测不到的,修改数组的元素值也是如此。这里就顺便提一下Vue源码中是如何解决这个问题的:

对于对象:Vue中提供了Vue.set和vm.$set这两个方法供我们添加新的属性,其原理就是先判断该属性是否为响应式的,如果不是,则通过defineReactive方法将其转为响应式。

对于数组:直接使用下标修改值还是无效的,Vue只hack了数组中的七个方法:pop','push','shift','unshift','splice','sort','reverse',使得我们用起来依旧是响应式的。其原理是:在我们调用数组的这七个方法时,Vue会改造这些方法,它内部同样也会执行这些方法原有的逻辑,只是增加了一些逻辑:取到所增加的值,然后将其变成响应式,然后再手动出发dep.notify()

三、以Proxy实现响应系统

Proxy是在目标前架设一层"拦截",外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写,我们可以这样认为,Proxy是Object.defineProperty的全方位加强版。

依旧是三大件:Observer、Dep、Watch,我们在之前的基础再完善这三大件。

1. Dep

let uid = 0 // 新增:定义一个id
class Dep {
 constructor () {
  this.id = uid++ // 新增:给dep添加id,避免Watch重复订阅
  this.subs = []
 }
 depend() { // 新增:源码中在触发get时是先触发depend方法再进行依赖收集的,这样能将dep传给Watch
  Dep.target.addDep(this);
 }
 addSub () {
  this.subs.push(Dep.target)
 }
 notify () {
  for (let i = 1; i < this.subs.length; i++) {
   this.subs[i].cb()
  }
 }
}

2. Watch

class Watch {
 constructor (exp, cb) {
  this.depIds = {} // 新增:储存订阅者的id,避免重复订阅
  this.exp = exp
  this.cb = cb
  Dep.target = this
  data[exp]
  // 新增:判断是否订阅过该dep,没有则存储该id并调用dep.addSub收集当前watcher
  addDep (dep) { 
   if (!this.depIds.hasOwnProperty(dep.id)) {
    dep.addSub(this)
    this.depIds[dep.id] = dep
   }
  }
  // 新增:将订阅者放入待更新队列等待批量更新
  update () {
   pushQueue(this)
  }
  // 新增:触发真正的更新操作
  run () {
   this.cb()
  }
 }
}

3. Observer

与Object.defineProperty监听属性不同,Proxy可以监听(实际是代理)整个对象,因此就不需要遍历对象的属性依次监听了,但是如果对象的属性依然是个对象,那么Proxy也无法监听,所以依旧使用递归套路即可。

function Observer (data) {
 let dep = new Dep()
 return new Proxy(data, {
  get () {
   // 如果订阅者存在,进去depend方法
   if (Dep.target) {
    dep.depend()
   }
   // Reflect.get了解一下
   return Reflect.get(data, key)
  },
  set (data, key, newVal) {
   // 如果值未变,则直接返回,不触发后续操作
   if (Reflect.get(data, key) === newVal) {
    return
   } else {
    // 设置新值的同时对新值判断是否要递归监听
    Reflect.set(target, key, observer(newVal))
    // 当值被触发更改的时候,触发Dep的通知方法
    dep.notify(key)
   }
  }
 })
}

// 递归监听
function observer (data) {
 // 如果不是对象则直接返回
 if (Object.prototype.toString.call(data) !== '[object, Object]') {
  return data
 }
 // 为对象时则递归判断属性值
 Object.keys(data).forEach(key => {
  data[key] = observer(data[key])
 })
 return Observer(data)
}

// 监听obj
Observer(data)

至此就基本完成了三大件了,同时其不需要hack也能对数组进行监听。

四、触发依赖收集与批量异步更新

完成了响应式系统,也顺便提一下Vue源码中是如何触发依赖收集与批量异步更新的。

1. 触发依赖收集

在Vue源码中的$mount方法调用时会间接触发了一段代码:

vm._watcher = new Watcher(vm, () => {
 vm._update(vm._render(), hydrating)
}, noop)

这使得new Watcher()会先对其传入的参数进行求值,也就间接触发了vm._render(),这其实就会触发了对数据的访问,进而触发属性的get方法而达到依赖的收集。

2. 批量异步更新

Vue在更新DOM时是异步执行的。只要侦听到数据变化,Vue将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个watcher被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和DOM操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue刷新队列并执行实际 (已去重的) 工作。Vue在内部对异步队列尝试使用原生的Promise.then、MutationObserver和setImmediate,如果执行环境不支持,则会采用setTimeout(fn, 0)代替。

根据以上这段官方文档,这个队列主要是异步和去重,首先我们来整理一下思路:

  1. 需要有一个队列来存储一个事件循环中的数据变更,且要对它去重。
  2. 将当前事件循环中的数据变更添加到队列。
  3. 异步的去执行这个队列中的所有数据变更。
// 使用Set数据结构创建一个队列,这样可自动去重
let queue = new Set()

// 在属性出发set方法时会触发watcher.update,继而执行以下方法
function pushQueue (watcher) {
 // 将数据变更添加到队列
 queue.add(watcher)
 // 下一个tick执行该数据变更,所以nextTick接受的应该是一个能执行queue队列的函数
 nextTick('一个能遍历执行queue的函数')
}

// 用Promise模拟nextTick
function nextTick('一个能遍历执行queue的函数') {
 Promise.resolve().then('一个能遍历执行queue的函数')
}

以上已经有个大体的思路了,那接下来完成'一个能遍历执行queue的函数':

// queue是一个数组,所以直接遍历执行即可
function flushQueue () {
 queue.forEach(watcher => {
  // 触发watcher中的run方法进行真正的更新操作
  watcher.run()
 })
 // 执行后清空队列
 queue = new Set()
}

还有一个问题,那就是同一个事件循环中应该只要触发一次nextTick即可,而不是每次添加队列时都触发:

// 设置一个是否触发了nextTick的标识
let waiting = false
function pushQueue (watcher) {
 queue.add(watcher)
 if (!waiting) {
  // 保证nextTick只触发一次
  waiting = true
  nextTick('一个能遍历执行queue的函数')
 }
}

完整代码如下:

// 定义队列
let queue = new Set()

// 供传入nextTick中的执行队列的函数
function flushQueue () {
 queue.forEach(watcher => {
  watcher.run()
 })
 queue = new Set()
}

// nextTick
function nextTick(flushQueue) {
 Promise.resolve().then(flushQueue)
}

// 添加到队列并调用nextTick
let waiting = false
function pushQueue (watcher) {
 queue.add(watcher)
 if (!waiting) {
  waiting = true
  nextTick(flushQueue)
 }
}

最后

以上就是响应式的一个大概原理,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

相关参考:

Vue源码学习

实现双向绑定Proxy比defineproperty优劣如何?

Vue.js源码全方位深入解析

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
如何使用jquery控制CSS样式,并且取消Css样式(如背景色,有实例)
Jul 09 Javascript
Jquery获取和修改img的src值的方法
Feb 17 Javascript
Javascript 运动中Offset的bug解决方案
Dec 24 Javascript
jQuery实现精美的多级下拉菜单特效
Mar 14 Javascript
jquery动态导航插件dynamicNav用法实例分析
Sep 06 Javascript
JS简单编号生成器实现方法(附demo源码下载)
Apr 05 Javascript
AngularJS的依赖注入实例分析(使用module和injector)
Jan 19 Javascript
vue cli 3.0 使用全过程解析
Jun 14 Javascript
原生JS实现图片懒加载之页面性能优化
Apr 26 Javascript
tsconfig.json配置详解
May 17 Javascript
vue实现自定义多选按钮
Jul 16 Javascript
解决antd 下拉框 input [defaultValue] 的值的问题
Oct 31 Javascript
20道JS原理题助你面试一臂之力(必看)
Jul 22 #Javascript
微信小程序webview 脚手架使用详解
Jul 22 #Javascript
koa2 用户注册、登录校验与加盐加密的实现方法
Jul 22 #Javascript
koa2服务端使用jwt进行鉴权及路由权限分发的流程分析
Jul 22 #Javascript
在小程序中推送模板消息的实现方法
Jul 22 #Javascript
javascript自定义日期比较函数用法示例
Jul 22 #Javascript
详解微信小程序自定义组件的实现及数据交互
Jul 22 #Javascript
You might like
PHP+Mysql日期时间如何转换(UNIX时间戳和格式化日期)
2012/07/15 PHP
解析php防止form重复提交的方法
2013/07/01 PHP
Smarty高级应用之缓存操作技巧分析
2016/05/14 PHP
jquery each的几种常用的使用方法示例
2014/01/21 Javascript
IE、FF浏览器下修改标签透明度
2014/01/28 Javascript
JavaScript link方法入门实例(给字符串加上超链接)
2014/10/17 Javascript
JS动态修改iframe内嵌网页地址的方法
2015/04/01 Javascript
jQuery匹配文档链接并添加class的方法
2015/06/26 Javascript
jQuery实现彩带延伸效果的网页加载条loading动画
2015/10/29 Javascript
跟我学习javascript的隐式强制转换
2015/11/16 Javascript
jQuery中inArray方法注意事项分析
2016/01/25 Javascript
Bootstrop实现多级下拉菜单功能
2016/11/24 Javascript
input输入框内容实时监测(附代码)
2017/08/15 Javascript
js实现QQ面板拖拽效果(慕课网DOM事件探秘)(全)
2017/09/19 Javascript
JS中的事件委托实例浅析
2018/03/22 Javascript
vue项目中vue-i18n和element-ui国际化开发实现过程
2018/04/25 Javascript
解决vue打包css文件中背景图片的路径问题
2018/09/03 Javascript
微信小程序组件传值图示过程详解
2019/07/31 Javascript
浅析JavaScript 函数防抖和节流
2020/07/13 Javascript
vue路由的配置和页面切换详解
2020/09/09 Javascript
Antd的Table组件嵌套Table以及选择框联动操作
2020/10/24 Javascript
[00:15]TI9观赛名额抽取
2019/07/10 DOTA
python通过文件头判断文件类型
2015/10/30 Python
详解常用查找数据结构及算法(Python实现)
2016/12/09 Python
Python中的Django基本命令实例详解
2018/07/15 Python
python3 实现对图片进行局部切割的方法
2018/12/05 Python
python如何删除文件中重复的字段
2019/07/16 Python
Python在Matplotlib图中显示中文字体的操作方法
2019/07/29 Python
pycharm编写spark程序,导入pyspark包的3中实现方法
2019/08/02 Python
pycharm双击无响应(打不开问题解决办法)
2020/01/10 Python
北美三大旅游网站之一:Travelocity加拿大
2016/08/20 全球购物
Java中实现多态的机制是什么?
2014/12/07 面试题
合伙经营协议书范本
2014/04/18 职场文书
挂职锻炼工作总结2015
2015/05/28 职场文书
总结Python连接CS2000的详细步骤
2021/06/23 Python
Win10/Win11 任务栏替换成经典样式
2022/04/19 数码科技