Vue的data、computed、watch源码浅谈


Posted in Javascript onApril 04, 2020

导读

记得初学Vue源码的时候,在defineReactive、Observer、Dep、Watcher等等内部设计源码之间跳来跳去,发现再也绕不出来了。Vue发展了很久,很多fix和feature的增加让内部源码越来越庞大,太多的边界情况和优化设计掩盖了原本精简的代码设计,让新手阅读源码变得越来越困难,但是面试的时候,Vue的响应式原理几乎成了Vue技术栈的公司面试中高级前端必问的点之一。

这篇文章通过自己实现一个响应式系统,尽量还原和Vue内部源码同样结构,但是剔除掉和渲染、优化等等相关的代码,来最低成本的学习Vue的响应式原理。

预览

源码地址(ts):
https://github.com/sl1673495/vue-reactive

源码地址(js)
https://github.com/sl1673495/vue-reactive/tree/js-version

预览地址:
https://sl1673495.github.io/vue-reactive/

reactive

Vue最常用的就是响应式的data了,通过在vue中定义

new Vue({
  data() {
    return {
      msg: 'Hello World'
    }
  }
})

在data发生改变的时候,视图也会更新,在这篇文章里我把对data部分的处理单独提取成一个api:reactive,下面来一起实现这个api。

要实现的效果:

const data = reactive({
 msg: 'Hello World',
})

new Watcher(() => {
 document.getElementById('app').innerHTML = `msg is ${data.msg}`
})

在data.msg发生改变的时候,我们需要这个app节点的innerHTML同步更新,这里新增加了一个概念Watcher,这也是Vue源码内部的一个设计,想要实现响应式的系统,这个Watcher是必不可缺的。

在实现这两个api之前,我们先来理清他们之间的关系,reactive这个api定义了一个响应式的数据,其实大家都知道响应式的数据就是在它的某个属性(比如例中的data.msg)被读取的时候,记录下来这时候是谁在读取他,读取他的这个函数肯定依赖它。
在本例中,下面这段函数,因为读取了data.msg并且展示在页面上,所以可以说这段渲染函数依赖了data.msg。

// 渲染函数
document.getElementById('app').innerHTML = `msg is ${data.msg}`

这也就解释清了,为什么我们需要用new Watcher来传入这段渲染函数,我们已经可以分析出来Watcher是帮我们记录下来这段渲染函数依赖的关键。

在js引擎执行渲染函数的途中,突然读到了data.msg,data已经被定义成了响应式数据,读取data.msg时所触发的get函数已经被我们劫持,这个get函数中我们去记录下data.msg被这个渲染函数所依赖,然后再返回data.msg的值。

这样下次data.msg发生变化的时候,Watcher内部所做的一些逻辑就会通知到渲染函数去重新执行。这不就是响应式的原理嘛。

下面开始实现代码

import Dep from './dep'
import { isObject } from '../utils'

// 将对象定义为响应式
export default function reactive(data) {
 if (isObject(data)) {
  Object.keys(data).forEach(key => {
   defineReactive(data, key)
  })
 }
 return data
}

function defineReactive(data, key) {
 let val = data[key]
 // 收集依赖
 const dep = new Dep()

 Object.defineProperty(data, key, {
  get() {
   dep.depend()
   return val
  },
  set(newVal) {
   val = newVal
   dep.notify()
  }
 })

 if (isObject(val)) {
  reactive(val)
 }
}

代码很简单,就是去遍历data的key,在defineReactive函数中对每个key进行get和set的劫持,Dep是一个新的概念,它主要用来做上面所说的dep.depend()去收集当前正在运行的渲染函数和dep.notify() 触发渲染函数重新执行。

可以把dep看成一个收集依赖的小筐,每当运行渲染函数读取到data的某个key的时候,就把这个渲染函数丢到这个key自己的小筐中,在这个key的值发生改变的时候,去key的筐中找到所有的渲染函数再执行一遍。

Dep

export default class Dep {
 constructor() {
  this.deps = new Set()
 }

 depend() {
  if (Dep.target) {
   this.deps.add(Dep.target)
  }
 }

 notify() {
  this.deps.forEach(watcher => watcher.update())
 }
}

// 正在运行的watcher
Dep.target = null

这个类很简单,利用Set去做存储,在depend的时候把Dep.target加入到deps集合里,在notify的时候遍历deps,触发每个watcher的update。

没错Dep.target这个概念也是Vue中所引入的,它是一个挂在Dep类上的全局变量,js是单线程运行的,所以在渲染函数如:

document.getElementById('app').innerHTML = `msg is ${data.msg}`

运行之前,先把全局的Dep.target设置为存储了这个渲染函数的watcher,也就是:

new Watcher(() => {
 document.getElementById('app').innerHTML = `msg is ${data.msg}`
})

这样在运行途中data.msg就可以通过Dep.target找到当前是哪个渲染函数的watcher正在运行,这样也就可以把自身对应的依赖所收集起来了。

这里划重点:Dep.target一定是一个Watcher的实例。

又因为渲染函数可以是嵌套运行的,比如在Vue中每个组件都会有自己用来存放渲染函数的一个watcher,那么在下面这种组件嵌套组件的情况下:

// Parent组件
<template>
 <div>
  <Son组件 />
 </div>
</template>

watcher的运行路径就是: 开始 -> ParentWatcher -> SonWatcher -> ParentWatcher -> 结束。

是不是特别像函数运行中的入栈出栈,没错,Vue内部就是用了栈的数据结构来记录watcher的运行轨迹。

// watcher栈
const targetStack = []

// 将上一个watcher推到栈里,更新Dep.target为传入的_target变量。
export function pushTarget(_target) {
 if (Dep.target) targetStack.push(Dep.target)
 Dep.target = _target
}

// 取回上一个watcher作为Dep.target,并且栈里要弹出上一个watcher。
export function popTarget() {
 Dep.target = targetStack.pop()
}

有了这些辅助的工具,就可以来看看Watcher的具体实现了

import Dep, { pushTarget, popTarget } from './dep'

export default class Watcher {
 constructor(getter) {
  this.getter = getter
  this.get()
 }

 get() {
  pushTarget(this)
  this.value = this.getter()
  popTarget()
  return this.value
 }

 update() {
   this.get()
 }
}

回顾一下开头示例中Watcher的使用。

const data = reactive({
 msg: 'Hello World',
})

new Watcher(() => {
 document.getElementById('app').innerHTML = `msg is ${data.msg}`
})

传入的getter函数就是

() => {
 document.getElementById('app').innerHTML = `msg is ${data.msg}`
}

在构造函数中,记录下getter函数,并且执行了一遍get

get() {
  pushTarget(this)
  this.value = this.getter()
  popTarget()
  return this.value
 }

在这个函数中,this就是这个watcher实例,在执行get的开头先把这个存储了渲染函数的watcher设置为当前的Dep.target,然后执行this.getter()也就是渲染函数

在执行渲染函数的途中读取到了data.msg,就触发了defineReactive函数中劫持的get:

Object.defineProperty(data, key, {
  get() {
   dep.depend()
   return val
  }
 })

这时候的dep.depend函数:

depend() {
  if (Dep.target) {
   this.deps.add(Dep.target)
  }
 }

所收集到的Dep.target,就是在get函数开头中pushTarget(this)所收集的

new Watcher(() => {
 document.getElementById('app').innerHTML = `msg is ${data.msg}`
})

这个watcher实例了。

此时我们假如执行了这样一段赋值代码:

data.msg = 'ssh'

就会运行到劫持的set函数里:

Object.defineProperty(data, key, {
  set(newVal) {
   val = newVal
   dep.notify()
  }
 })

此时在控制台中打印出dep这个变量,它内部的deps属性果然存储了一个Watcher的实例。

Vue的data、computed、watch源码浅谈

运行了dep.notify以后,就会触发这个watcher的update方法,也就会再去重新执行一遍渲染函数了,这个时候视图就刷新了。

computed

在实现了reactive这个基础api以后,就要开始实现computed这个api了,这个api的用法是这样:

const data = reactive({
 number: 1
})

const numberPlusOne = computed(() => data.number + 1)

// 渲染函数watcher
new Watcher(() => {
 document.getElementById('app2').innerHTML = `
  computed: 1 + number 是 ${numberPlusOne.value}
 `
})

vue内部是把computed属性定义在vm实例上的,这里我们没有实例,所以就用一个对象来存储computed的返回值,用.value来拿computed的真实值。

这里computed传入的其实还是一个函数,这里我们回想一下Watcher的本质,其实就是存储了一个需要在特定时机触发的函数,在Vue内部,每个computed属性也有自己的一个对应的watcher实例,下文中叫它computedWatcher

先看渲染函数:

// 渲染函数watcher
new Watcher(() => {
 document.getElementById('app2').innerHTML = `
  computed: 1 + number 是 ${numberPlusOne.value}
 `
})

这段渲染函数执行过程中,读取到numberPlusOne的值的时候

首先会把Dep.target设置为numberPlusOne所对应的computedWatcher

computedWatcher的特殊之处在于

  1. 渲染watcher只能作为依赖被收集到其他的dep筐子里,而computedWatcher实例上有属于自己的dep,它可以收集别的watcher作为自己的依赖。
  2. 惰性求值,初始化的时候先不去运行getter。
export default class Watcher {
 constructor(getter, options = {}) {
  const { computed } = options
  this.getter = getter
  this.computed = computed

  if (computed) {
   this.dep = new Dep()
  } else {
   this.get()
  }
 }
}

其实computed实现的本质就是,computed在读取value之前,Dep.target肯定此时是正在运行的渲染函数的watcher。

先把当前正在运行的渲染函数的watcher作为依赖收集到computedWatcher内部的dep筐子里。

把自身computedWatcher设置为 全局Dep.target,然后开始求值:

求值函数会在运行() => data.number + 1的途中遇到data.number的读取,这时又会触发'number'这个key的劫持get函数,这时全局的Dep.target是computedWatcher,data.number的dep依赖筐子里丢进去了computedWatcher。
此时的依赖关系是 data.number的dep筐子里装着computedWatcher,computedWatcher的dep筐子里装着渲染watcher。
此时如果更新data.number的话,会一级一级往上触发更新。会触发computedWatcher的update,我们肯定会对被设置为computed特性的watcher做特殊的处理,这个watcher的筐子里装着渲染watcher,所以只需要触发 this.dep.notify(),就会触发渲染watcher的update方法,从而更新视图。
下面来改造代码:

// Watcher
import Dep, { pushTarget, popTarget } from './dep'

export default class Watcher {
 constructor(getter, options = {}) {
  const { computed } = options
  this.getter = getter
  this.computed = computed

  if (computed) {
   this.dep = new Dep()
  } else {
   this.get()
  }
 }

 get() {
  pushTarget(this)
  this.value = this.getter()
  popTarget()
  return this.value
 }

 // 仅为computed使用
 depend() {
  this.dep.depend()
 }

 update() {
  if (this.computed) {
   this.get()
   this.dep.notify()
  } else {
   this.get()
  }
 }
}

computed初始化:

// computed
import Watcher from './watcher'

export default function computed(getter) {
 let def = {}
 const computedWatcher = new Watcher(getter, { computed: true })
 Object.defineProperty(def, 'value', {
  get() {
   // 先让computedWatcher收集渲染watcher作为自己的依赖。
   computedWatcher.depend()
   return computedWatcher.get()
  }
 })
 return def
}

这里的逻辑比较绕,如果没理清楚的话可以把代码下载下来一步步断点调试,data.number被劫持的set触发以后,可以看一下number的dep到底存了什么。

Vue的data、computed、watch源码浅谈

watch

watch的使用方式是这样的:

watch(
 () => data.msg,
 (newVal, oldVal) => {
  console.log('newVal: ', newVal)
  console.log('old: ', oldVal)
 }
)

传入的第一个参数是个函数,里面需要读取到响应式的属性,确保依赖能被收集到,这样下次这个响应式的属性发生改变后,就会打印出对饮的新值和旧值。

分析一下watch的实现原理,这里依然是利用Watcher类去实现,我们把用于watch的watcher叫做watchWatcher,传入的getter函数也就是() => data.msg,Watcher在执行它之前还是一样会把自身(也就是watchWatcher)设为Dep.target,这时读到data.msg,就会把watchWatcher丢进data.msg的依赖筐子里。

如果data.msg更新了,则就会触发watchWatcher的update方法

直接上代码:

// watch
import Watcher from './watcher'

export default function watch(getter, callback) {
 new Watcher(getter, { watch: true, callback })
}

没错又是直接用了getter,只是这次传入的选项是{ watch: true, callback },接下来看看Watcher内部进行了什么处理:

export default class Watcher {
 constructor(getter, options = {}) {
  const { computed, watch, callback } = options
  this.getter = getter
  this.computed = computed
  this.watch = watch
  this.callback = callback
  this.value = undefined

  if (computed) {
   this.dep = new Dep()
  } else {
   this.get()
  }
 }
}

首先是构造函数中,对watch选项和callback进行了保存,其他没变。

然后在update方法中。

update() {
  if (this.computed) {
   ...
  } else if (this.watch) {
   const oldValue = this.value
   this.get()
   this.callback(oldValue, this.value)
  } else {
   ...
  }
 }

在调用this.get去更新值之前,先把旧值保存起来,然后把新值和旧值一起通过调用callback函数交给外部,就这么简单。
我们仅仅是改动寥寥几行代码,就轻松实现了非常重要的api:watch。

总结。

有了精妙的Watcher和Dep的设计,Vue内部的响应式api实现的非常简单,不得不再次感叹一下尤大真是厉害啊!

到此这篇关于Vue的data、computed、watch源码浅谈的文章就介绍到这了,更多相关Vue data、computed、watch源码内容请搜索三水点靠木以前的文章或继续浏览下面的相关文章希望大家以后多多支持三水点靠木!

Javascript 相关文章推荐
关于JS控制代码暂停的实现方法分享
Oct 11 Javascript
使用JavaScript 实现各种跨域的方法
May 08 Javascript
GRID拖拽行的实例代码
Jul 18 Javascript
JS删除字符串中重复字符方法
Mar 09 Javascript
node.js中的events.emitter.listeners方法使用说明
Dec 10 Javascript
js停止冒泡和阻止浏览器默认行为的简单方法
May 15 Javascript
JavaScript实现九九乘法表的简单实例
Jun 07 Javascript
防止页面url缓存中ajax中post请求的处理方法
Oct 10 Javascript
vue组件jsx语法的具体使用
May 21 Javascript
微信小程序实现点击图片旋转180度并且弹出下拉列表
Nov 27 Javascript
JavaScript 装逼指南(js另类写法)
May 10 Javascript
jQuery实现移动端扭蛋机抽奖
Nov 08 jQuery
VUE table表格动态添加一列数据,新增的这些数据不可以编辑(v-model绑定的数据不能实时更新)
Apr 03 #Javascript
mpvue实现微信小程序快递单号查询代码
Apr 03 #Javascript
mpvue网易云短信接口实现小程序短信登录的示例代码
Apr 03 #Javascript
javascript用defineProperty实现简单的双向绑定方法
Apr 03 #Javascript
JavaScript检测浏览器是否支持CSS变量代码实例
Apr 03 #Javascript
JS内置对象和Math对象知识点详解
Apr 03 #Javascript
vue组件库的在线主题编辑器的实现思路
Apr 03 #Javascript
You might like
利用static实现表格的颜色隔行显示的代码
2007/09/02 PHP
php木马webshell扫描器代码
2012/01/25 PHP
php中convert_uuencode()与convert_uuencode函数用法实例
2014/11/22 PHP
php 策略模式原理与应用深入理解
2019/09/25 PHP
关于Javascript 的 prototype问题。
2007/01/03 Javascript
仅IE不支持setTimeout/setInterval函数的第三个以上参数
2011/05/25 Javascript
THREE.JS入门教程(5)你应当知道的十件事
2013/01/24 Javascript
JS输入用户名自动显示邮箱后缀列表的方法
2015/01/27 Javascript
深入分析下javascript中的[]()+!
2015/07/07 Javascript
jQuery的框架介绍
2016/05/11 Javascript
分享JS数组求和与求最大值的方法
2016/08/11 Javascript
Angularjs自定义指令实现分页插件(DEMO)
2017/09/16 Javascript
jQuery实现上下滚动公告栏详细代码
2018/11/21 jQuery
Vue2(三)实现子菜单展开收缩,带动画效果实现方法
2019/04/28 Javascript
Node.js API详解之 timer模块用法实例分析
2020/05/07 Javascript
vuecli3.x中轻松4步带你使用tinymce的步骤
2020/06/25 Javascript
vue实现标签云效果的示例
2020/11/09 Javascript
[01:12]DOTA2 2015年秋季互动指南
2015/11/10 DOTA
[43:33]EG vs Spirit Supermajor 败者组 BO3 第一场 6.4
2018/06/05 DOTA
python里对list中的整数求平均并排序
2014/09/12 Python
Python 实现数据库(SQL)更新脚本的生成方法
2017/07/09 Python
python后端接收前端回传的文件方法
2019/01/02 Python
python常用函数与用法示例
2019/07/02 Python
python Django 创建应用过程图示详解
2019/07/29 Python
关于ZeroMQ 三种模式python3实现方式
2019/12/23 Python
详解Django ORM引发的数据库N+1性能问题
2020/10/12 Python
python drf各类组件的用法和作用
2021/01/12 Python
利用HTML5绘制点线面组成的3D图形的示例
2015/05/12 HTML / CSS
Myprotein意大利官网:欧洲第一运动营养品牌
2018/11/22 全球购物
Sisley法国希思黎美国官方网站:享誉全球的奢华植物美容品牌
2020/06/27 全球购物
2014年健康教育工作总结
2014/11/20 职场文书
一般纳税人申请报告
2015/05/18 职场文书
公司周年庆典致辞
2015/07/30 职场文书
本地通过nginx配置反向代理的全过程记录
2021/03/31 Servers
Node.js实现断点续传
2021/06/23 Javascript
浅析JavaScript中的变量提升
2022/06/01 Javascript