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 相关文章推荐
制作特殊字的脚本
Jun 26 Javascript
基于jquery的气泡提示效果
May 31 Javascript
Javascript call和apply区别及使用方法
Nov 14 Javascript
浅谈js中对象的使用
Aug 11 Javascript
Vuejs第一篇之入门教程详解(单向绑定、双向绑定、列表渲染、响应函数)
Sep 09 Javascript
js实现打地鼠小游戏
Feb 13 Javascript
js截取字符串功能的实现方法
Sep 27 Javascript
JavaScript 异步调用
Oct 25 Javascript
layer弹出层父子页面事件相互调用方法
Aug 17 Javascript
详解微信小程序自定义组件的实现及数据交互
Jul 22 Javascript
Vue+ElementUI项目使用webpack输出MPA的方法
Aug 27 Javascript
Js实现复选框的全选、全不选反选功能代码实例
Feb 28 Javascript
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
PHP中strnatcmp()函数“自然排序算法”进行字符串比较用法分析(对比strcmp函数)
2016/01/07 PHP
CodeIgniter配置之routes.php用法实例分析
2016/01/19 PHP
jquery validator 插件增加日期比较方法
2010/02/21 Javascript
基于jQuery的淡入淡出可自动切换的幻灯插件打包下载
2010/09/15 Javascript
基于jquery的下拉框改变动态添加和删除表格实现代码
2020/09/12 Javascript
简单实例处理url特殊符号&amp;处理(2种方法)
2013/04/02 Javascript
如何消除inline-block属性带来的标签间间隙
2016/03/31 Javascript
JS+CSS3实现超炫的散列画廊特效
2016/07/16 Javascript
解决AjaxFileupload 上传时会出现连接重置的问题
2017/07/07 Javascript
收藏AngularJS中最重要的核心功能
2017/07/09 Javascript
Vue中父组件向子组件通信的方法
2017/07/11 Javascript
从零开始搭建一个react项目开发
2018/02/09 Javascript
webpack之devtool详解
2018/02/10 Javascript
vue 根据数组中某一项的值进行排序的方法
2018/08/30 Javascript
Layui点击图片弹框预览的实现方法
2019/09/16 Javascript
归纳整理Python中的控制流语句的知识点
2015/04/14 Python
通过Python来使用七牛云存储的方法详解
2015/08/07 Python
Python中shutil模块的常用文件操作函数用法示例
2016/07/05 Python
详解 Python 与文件对象共事的实例
2017/09/11 Python
Win8.1下安装Python3.6提示0x80240017错误的解决方法
2018/07/31 Python
在pytorch中为Module和Tensor指定GPU的例子
2019/08/19 Python
详解Python time库的使用
2019/10/10 Python
Python优秀开源项目Rich源码解析的流程分析
2020/07/06 Python
python中return不返回值的问题解析
2020/07/22 Python
Jabra捷波朗美国官网:用于办公、车载和运动的无线蓝牙耳麦
2017/02/01 全球购物
欧洲领先的火车票和大巴票预订平台:Trainline
2018/12/26 全球购物
保安员岗位职责
2013/11/17 职场文书
房地产销售计划书
2014/01/10 职场文书
幼儿教师演讲稿
2014/05/06 职场文书
大学生入党推荐书范文
2014/05/17 职场文书
2014年重阳节老干部座谈会上的讲话稿
2014/09/25 职场文书
党员年终个人总结
2015/02/14 职场文书
2015年小学数学教研组工作总结
2015/05/21 职场文书
MySQL 聚合函数排序
2021/07/16 MySQL
Java日常练习题,每天进步一点点(38)
2021/07/26 Java/Android
实战 快速定位MySQL的慢SQL
2022/03/22 MySQL