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


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入门知识简介
Mar 04 Javascript
jQuery图片播放8款精美插件分享
Feb 17 Javascript
ichart.js绘制虚线、平均分虚线效果的实现代码
May 05 Javascript
浅谈javascript alert和confirm的美化
Dec 15 Javascript
利用JS实现简单的日期选择插件
Jan 23 Javascript
原生JS实现不断变化的标签
May 22 Javascript
jquery ajaxfileupload异步上传插件
Nov 21 jQuery
实例讲解JavaScript截取字符串
Nov 30 Javascript
基于 jQuery 实现键盘事件监听控件
Apr 04 jQuery
Node 搭建一个静态资源服务器的实现
May 20 Javascript
Node.js+ELK日志规范的实现
May 23 Javascript
JS继承最简单的理解方式
Mar 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内置的Math函数效率测试
2014/12/01 PHP
PHP简单遍历对象示例
2016/09/28 PHP
php实现产品加入购物车功能(1)
2020/07/23 PHP
javascript 拖动表格行实现代码
2011/05/05 Javascript
10款新鲜出炉的 jQuery 插件(Ajax 插件,有幻灯片、图片画廊、菜单等)
2011/06/08 Javascript
js解决弹窗问题实现班级跳转DIV示例
2014/01/06 Javascript
jQery使网页在显示器上居中显示适用于任何分辨率
2014/06/09 Javascript
JS使用eval()动态创建变量的方法
2016/06/03 Javascript
浅谈JS中的bind方法与函数柯里化
2016/08/10 Javascript
Bootstrap和Java分页实例第一篇
2016/12/23 Javascript
微信小程序学习(4)-系统配置app.json详解
2017/01/12 Javascript
详解基于Node.js的微信JS-SDK后端接口实现代码
2017/07/15 Javascript
js仿微信抢红包功能
2020/09/25 Javascript
使用jquery DataTable和ajax向页面显示数据列表的方法
2018/08/09 jQuery
js实现前面自动补全位数的方法
2018/10/10 Javascript
React事件处理的机制及原理
2018/12/03 Javascript
Vue项目从webpack3.x升级webpack4不完全指南
2019/04/28 Javascript
微信小程序云开发修改云数据库中的数据方法
2019/05/18 Javascript
webpack的tree shaking的实现方法
2019/09/18 Javascript
详解一些适用于Node.js的命名约定
2019/12/08 Javascript
[03:11]2014DOTA2国际邀请赛-VG掉入败者组 独家专访357
2014/07/19 DOTA
python学习之第三方包安装方法(两种方法)
2015/07/30 Python
利用Python读取文件的四种不同方法比对
2017/05/18 Python
Python常见排序操作示例【字典、列表、指定元素等】
2018/08/15 Python
Python中的取模运算方法
2018/11/10 Python
对python:threading.Thread类的使用方法详解
2019/01/31 Python
python多进程读图提取特征存npy
2019/05/21 Python
python列表生成器迭代器实例解析
2019/12/19 Python
python读取excel数据绘制简单曲线图的完整步骤记录
2020/10/30 Python
大一学生假期实习的自我评价
2013/10/12 职场文书
高中生学习生活的自我评价
2013/11/27 职场文书
党员创先争优活动总结
2014/05/04 职场文书
办公室岗位职责范本
2015/04/11 职场文书
婚宴来宾致辞
2015/07/28 职场文书
中学图书馆工作总结
2015/08/11 职场文书
nginx处理http请求实现过程解析
2021/03/31 Servers