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


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 相关文章推荐
mouse_on_title.js
Aug 25 Javascript
jquery UI 1.72 之datepicker
Dec 29 Javascript
javascript:文字不间断向左移动的实例代码
Aug 08 Javascript
JS+CSS实现带有碰撞缓冲效果的竖向导航条代码
Sep 15 Javascript
Jquery promise实现一张一张加载图片
Nov 13 Javascript
使用jquery提交form表单并自定义action的方法
May 25 Javascript
JS实现HTML表格排序功能
Aug 05 Javascript
jQuery实现滚动条滚动到子元素位置(方便定位)
Jan 08 Javascript
JS对象的深度克隆方法示例
Mar 16 Javascript
判断div滑动到底部的scroll实例代码
Nov 15 Javascript
基于模板引擎Jade的应用(详解)
Dec 12 Javascript
vue实现购物车案例
May 30 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
mac下安装nginx和php
2013/11/04 PHP
php生成扇形比例图实例
2013/11/06 PHP
PHP中使用memcache存储session的三种配置方法
2014/04/05 PHP
DOM基础及php读取xml内容操作的方法
2015/01/23 PHP
laravel 框架配置404等异常页面
2019/01/07 PHP
PHP超级全局变量【$GLOBALS,$_SERVER,$_REQUEST等】用法实例分析
2019/12/11 PHP
javascript 面向对象编程  function是方法(函数)
2009/09/17 Javascript
web的各种前端打印方法之jquery打印插件PrintArea实现网页打印
2013/01/09 Javascript
js控制不同的时间段显示不同的css样式的实例代码
2013/11/04 Javascript
node.js中的fs.write方法使用说明
2014/12/15 Javascript
Javascript简单改变表单元素背景的方法
2015/07/15 Javascript
jquery实现简单文字提示效果
2015/12/02 Javascript
JavaScript File API文件上传预览
2016/02/02 Javascript
jQuery ajax全局函数处理session过期后的ajax跳转问题
2016/06/03 Javascript
jQuery UI制作选项卡(tabs)
2016/12/13 Javascript
Vue中的循环及修改差值表达式的方法
2019/08/29 Javascript
python中的全局变量用法分析
2015/06/09 Python
python根据京东商品url获取产品价格
2015/08/09 Python
python实现随机梯度下降法
2020/03/24 Python
python3实现逐字输出的方法
2019/01/23 Python
python求最大值,不使用内置函数的实现方法
2019/07/09 Python
python导入不同目录下的自定义模块过程解析
2019/11/18 Python
Window版下在Jupyter中编写TensorFlow的环境搭建
2020/04/10 Python
python中numpy数组与list相互转换实例方法
2021/01/29 Python
Python自动化测试基础必备知识点总结
2021/02/07 Python
AE美国鹰美国官方网站:American Eagle Outfitters
2016/08/22 全球购物
倩碧英国官网:Clinique英国
2018/08/10 全球购物
十佳班主任事迹材料
2014/01/18 职场文书
向领导表决心的话
2014/03/11 职场文书
企业负责人任命书
2014/06/05 职场文书
电气工程及其自动化专业毕业生自荐信
2014/06/21 职场文书
自我介绍演讲稿范文
2014/08/21 职场文书
夏洛特的网观后感
2015/06/15 职场文书
基于Python实现一个春节倒计时脚本
2022/01/22 Python
mysql使用 not int 子查询隐含陷阱
2022/04/12 MySQL
java版 简单三子棋游戏
2022/05/04 Java/Android