前言

watch 是由用户定义的数据监听,当监听的属性发生改变就会触发回调,这项配置在业务中是很常用。在面试时,也是必问知识点,一般会用作和 computed 进行比较。

那么本文就来带大家从源码理解 watch 的工作流程,以及依赖收集和深度监听的实现。在此之前,希望你能对响应式原理流程、依赖收集流程有一些了解,这样理解起来会更加轻松。

往期文章:

手摸手带你理解Vue响应式原理

手摸手带你理解Vue的Computed原理

watch 用法

“知己知彼,才能百战百胜”,分析源码之前,先要知道它如何使用。这对于后面理解有一定的辅助作用。

第一种,字符串声明:

var vm = new Vue({
el: '#example',
data: {
message: 'Hello'
},
watch: {
message: 'handler'
},
methods: {
handler (newVal, oldVal) { /* ... */ }
}
})

第二种,函数声明:

var vm = new Vue({
el: '#example',
data: {
message: 'Hello'
},
watch: {
message: function (newVal, oldVal) { /* ... */ }
}
})

第三种,对象声明:

var vm = new Vue({
el: '#example',
data: {
peopel: {
name: 'jojo',
age: 15
}
},
watch: {
// 字段可使用点操作符 监听对象的某个属性
'people.name': {
handler: function (newVal, oldVal) { /* ... */ }
}
}
})
watch: {
people: {
handler: function (newVal, oldVal) { /* ... */ },
// 回调会在监听开始之后被立即调用
immediate: true,
// 对象深度监听 对象内任意一个属性改变都会触发回调
deep: true
}
}

第四种,数组声明:

var vm = new Vue({
el: '#example',
data: {
peopel: {
name: 'jojo',
age: 15
}
},
// 传入回调数组,它们会被逐一调用
watch: {
'people.name': [
'handle',
function handle2 (newVal, oldVal) { /* ... */ },
{
handler: function handle3 (newVal, oldVal) { /* ... */ },
}
],
},
methods: {
handler (newVal, oldVal) { /* ... */ }
}
})

工作流程

入口文件:

// 源码位置:/src/core/instance/index.js
import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
import { warn } from '../util/index' function Vue (options) {
this._init(options)
} initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue) export default Vue

_init:

// 源码位置:/src/core/instance/init.js
export function initMixin (Vue: Class<Component>) {
Vue.prototype._init = function (options?: Object) {
const vm: Component = this
// a uid
vm._uid = uid++ // merge options
if (options && options._isComponent) {
// optimize internal component instantiation
// since dynamic options merging is pretty slow, and none of the
// internal component options needs special treatment.
initInternalComponent(vm, options)
} else {
// mergeOptions 对 mixin 选项和 new Vue 传入的 options 选项进行合并
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
} // expose real self
vm._self = vm
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props
// 初始化数据
initState(vm)
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created') if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
}

initState:

// 源码位置:/src/core/instance/state.js
export function initState (vm: Component) {
vm._watchers = []
const opts = vm.$options
if (opts.props) initProps(vm, opts.props)
if (opts.methods) initMethods(vm, opts.methods)
if (opts.data) {
initData(vm)
} else {
observe(vm._data = {}, true /* asRootData */)
}
if (opts.computed) initComputed(vm, opts.computed)
// 这里会初始化 watch
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}

initWatch:

// 源码位置:/src/core/instance/state.js
function initWatch (vm: Component, watch: Object) {
for (const key in watch) {
const handler = watch[key]
if (Array.isArray(handler)) {
// 1
for (let i = 0; i < handler.length; i++) {
createWatcher(vm, key, handler[i])
}
} else {
// 2
createWatcher(vm, key, handler)
}
}
}
  1. 数组声明的 watch 有多个回调,需要循环创建监听
  2. 其他声明方式直接创建

createWatcher:

// 源码位置:/src/core/instance/state.js
function createWatcher (
vm: Component,
expOrFn: string | Function,
handler: any,
options?: Object
) {
// 1
if (isPlainObject(handler)) {
options = handler
handler = handler.handler
}
// 2
if (typeof handler === 'string') {
handler = vm[handler]
}
// 3
return vm.$watch(expOrFn, handler, options)
}
  1. 对象声明的 watch,从对象中取出对应回调
  2. 字符串声明的 watch,直接取实例上的方法(注:methods 中声明的方法,可以在实例上直接获取)
  3. expOrFnwatchkey 值,$watch 用于创建一个“用户Watcher

所以在创建数据监听时,除了 watch 配置外,也可以调用实例的 $watch 方法实现同样的效果。

$watch:

// 源码位置:/src/core/instance/state.js
export function stateMixin (Vue: Class<Component>) {
Vue.prototype.$watch = function (
expOrFn: string | Function,
cb: any,
options?: Object
): Function {
const vm: Component = this
if (isPlainObject(cb)) {
return createWatcher(vm, expOrFn, cb, options)
}
// 1
options = options || {}
options.user = true
// 2
const watcher = new Watcher(vm, expOrFn, cb, options)
// 3
if (options.immediate) {
try {
cb.call(vm, watcher.value)
} catch (error) {
handleError(error, vm, `callback for immediate watcher "${watcher.expression}"`)
}
}
// 4
return function unwatchFn () {
watcher.teardown()
}
}
}

stateMixin 在入口文件就已经调用了,为 Vue 的原型添加 $watch 方法。

  1. 所有“用户Watcher”的 options,都会带有 user 标识
  2. 创建 watcher,进行依赖收集
  3. immediate 为 true 时,立即调用回调
  4. 返回的函数可以用于取消 watch 监听

依赖收集及更新流程

经过上面的流程后,最终会进入 new Watcher 的逻辑,这里面也是依赖收集和更新的触发点。接下来看看这里面会有哪些操作。

依赖收集

// 源码位置:/src/core/observer/watcher.js
export default class Watcher {
constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: ?Object,
isRenderWatcher?: boolean
) {
this.vm = vm
// options
if (options) {
this.deep = !!options.deep
this.user = !!options.user
this.lazy = !!options.lazy
this.sync = !!options.sync
this.before = options.before
} else {
this.deep = this.user = this.lazy = this.sync = false
}
this.cb = cb
this.id = ++uid // uid for batching
this.active = true
this.dirty = this.lazy // for lazy watchers
this.deps = []
this.newDeps = []
this.depIds = new Set()
this.newDepIds = new Set()
// parse expression for getter
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
}
this.value = this.lazy
? undefined
: this.get()
}
}

Watcher 构造函数内,对传入的回调和 options 都进行保存,这不是重点。让我们来关注下这段代码:

if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
}

传进来的 expOrFnwatch 的键值,因为键值可能是 obj.a.b,需要调用 parsePath 对键值解析,这一步也是依赖收集的关键点。它执行后返回的是一个函数,先不着急 parsePath 做的是什么,先接着流程继续走。

下一步就是调用 get:

get () {
pushTarget(this)
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm)
} catch (e) {
if (this.user) {
handleError(e, vm, `getter for watcher "${this.expression}"`)
} else {
throw e
}
} finally {
// "touch" every property so they are all tracked as
// dependencies for deep watching
if (this.deep) {
traverse(value)
}
popTarget()
this.cleanupDeps()
}
return value
}

pushTarget 将当前的“用户Watcher”(即当前实例this) 挂到 Dep.target 上,在收集依赖时,找的就是 Dep.target。然后调用 getter 函数,这里就进入 parsePath 的逻辑。

// 源码位置:/src/core/util/lang.js
const bailRE = new RegExp(`[^${unicodeRegExp.source}.$_\\d]`)
export function parsePath (path: string): any {
if (bailRE.test(path)) {
return
}
const segments = path.split('.')
return function (obj) {
for (let i = 0; i < segments.length; i++) {
if (!obj) return
obj = obj[segments[i]]
}
return obj
}
}

参数 objvm 实例,segments 是解析后的键值数组,循环去获取每项键值的值,触发它们的“数据劫持get”。接着触发 dep.depend 收集依赖(依赖就是挂在 Dep.targetWatcher)。

到这里依赖收集就完成了,从上面我们也得知,每一项键值都会被触发依赖收集,也就是说上面的任何一项键值的值发生改变都会触发 watch 回调。例如:

watch: {
'obj.a.b.c': function(){}
}

不仅修改 c 会触发回调,修改 ba 以及 obj 同样触发回调。这个设计也是很妙,通过简单的循环去为每一项都收集到了依赖。

更新

在更新时首先触发的是“数据劫持set”,调用 dep.notify 通知每一个 watcherupdate 方法。

update () {
if (this.lazy) { dirty置为true
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}

接着就走 queueWatcher 进行异步更新,这里先不讲异步更新。只需要知道它最后会调用的是 run 方法。

run () {
if (this.active) {
const value = this.get()
if (
value !== this.value ||
isObject(value) ||
this.deep
) {
// set new value
const oldValue = this.value
this.value = value
if (this.user) {
try {
this.cb.call(this.vm, value, oldValue)
} catch (e) {
handleError(e, this.vm, `callback for watcher "${this.expression}"`)
}
} else {
this.cb.call(this.vm, value, oldValue)
}
}
}
}

this.get 获取新值,调用 this.cb,将新值旧值传入。

深度监听

深度监听是 watch 监听中一项很重要的配置,它能为我们观察对象中任何一个属性的变化。

目光再拉回到 get 函数,其中有一段代码是这样的:

if (this.deep) {
traverse(value)
}

判断是否需要深度监听,调用 traverse 并将值传入

// 源码位置:/src/core/observer/traverse.js
const seenObjects = new Set() export function traverse (val: any) {
_traverse(val, seenObjects)
seenObjects.clear()
} function _traverse (val: any, seen: SimpleSet) {
let i, keys
const isA = Array.isArray(val)
if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) {
return
}
if (val.__ob__) {
// 1
const depId = val.__ob__.dep.id
// 2
if (seen.has(depId)) {
return
}
seen.add(depId)
}
// 3
if (isA) {
i = val.length
while (i--) _traverse(val[i], seen)
} else {
keys = Object.keys(val)
i = keys.length
while (i--) _traverse(val[keys[i]], seen)
}
}
  1. depId 是每一个被观察属性都会有的唯一标识
  2. 去重,防止相同属性重复执行逻辑
  3. 根据数组和对象使用不同的策略,最终目的是递归获取每一项属性,触发它们的“数据劫持get”收集依赖,和 parsePath 的效果是异曲同工

从这里能得出,深度监听利用递归进行监听,肯定会有性能损耗。因为每一项属性都要走一遍依赖收集流程,所以在业务中尽量避免这类操作。

卸载监听

这种手段在业务中基本很少用,也不算是重点,属于那种少用但很有用的方法。它作为 watch 的一部分,这里也讲下它的原理。

使用

先来看看它的用法:

data(){
return {
name: 'jojo'
}
}
mounted() {
let unwatchFn = this.$watch('name', () => {})
setTimeout(()=>{
unwatchFn()
}, 10000)
}

使用 $watch 监听数据后,会返回一个对应的卸载监听函数。顾名思义,调用它当然就是不会再监听数据。

原理

Vue.prototype.$watch = function (
expOrFn: string | Function,
cb: any,
options?: Object
): Function {
const vm: Component = this
if (isPlainObject(cb)) {
return createWatcher(vm, expOrFn, cb, options)
}
options = options || {}
options.user = true
const watcher = new Watcher(vm, expOrFn, cb, options)
if (options.immediate) {
try {
// 立即调用 watch
cb.call(vm, watcher.value)
} catch (error) {
handleError(error, vm, `callback for immediate watcher "${watcher.expression}"`)
}
}
return function unwatchFn () {
watcher.teardown()
}
}

可以看到返回的 unwatchFn 里实际执行的是 teardown

teardown () {
if (this.active) {
if (!this.vm._isBeingDestroyed) {
remove(this.vm._watchers, this)
}
let i = this.deps.length
while (i--) {
this.deps[i].removeSub(this)
}
this.active = false
}
}

teardown 里的操作也很简单,遍历 deps 调用 removeSub 方法,移除当前 watcher 实例。在下一次属性更新时,也不会通知 watcher 更新了。deps 存储的是属性的 dep(依赖收集器)。

奇怪的地方

在看源码时,我发现 watch 有个奇怪的地方,导致它的用法是可以这样的:

watch:{
name:{
handler: {
handler: {
handler: {
handler: {
handler: {
handler: {
handler: ()=>{console.log(123)},
immediate: true
}
}
}
}
}
}
}
}

一般 handler 是传递一个函数作为回调,但是对于对象类型,内部会进行递归去获取,直到值为函数。所以你可以无限套娃传对象。

递归的点在 $watch 中的这段代码:

if (isPlainObject(cb)) {
return createWatcher(vm, expOrFn, cb, options)
}

如果你知道这段代码的实际应用场景麻烦告诉我一下,嘿嘿~

总结

watch 监听实现利用遍历获取属性,触发“数据劫持get”逐个收集依赖,这样做的好处是其上级的属性发生修改也能执行回调。

datacomputed 不同,watch 收集依赖的流程是发生在页面渲染之前,而前两者是在页面渲染时进行取值才会收集依赖。

在面试时,如果被问到 computedwatch 的异同,我们可以从下面这些点进行回答:

  • 一是 computed 要依赖 data 上的属性变化返回一个值,watch 则是观察数据触发回调;
  • 二是 computedwatch 依赖收集的发生点不同;
  • 三是 computed 的更新需要“渲染Watcher”的辅助,watch 不需要,这点在我的上一篇文章有提到。

手摸手带你理解Vue的Watch原理的更多相关文章

  1. 手摸手带你理解Vue的Computed原理

    前言 computed 在 Vue 中是很常用的属性配置,它能够随着依赖属性的变化而变化,为我们带来很大便利.那么本文就来带大家全面理解 computed 的内部原理以及工作流程. 在这之前,希望你能 ...

  2. 手摸手带你理解Vue响应式原理

    前言 响应式原理作为 Vue 的核心,使用数据劫持实现数据驱动视图.在面试中是经常考查的知识点,也是面试加分项. 本文将会循序渐进的解析响应式原理的工作流程,主要以下面结构进行: 分析主要成员,了解它 ...

  3. 【转】手摸手,带你用vue撸后台 系列三(实战篇)

    前言 在前面两篇文章中已经把基础工作环境构建完成,也已经把后台核心的登录和权限完成了,现在手摸手,一起进入实操. Element 去年十月份开始用vue做管理后台的时候毫不犹豫的就选择了Elemen, ...

  4. 【转】手摸手,带你用vue撸后台 系列二(登录权限篇)

    前言 拖更有点严重,过了半个月才写了第二篇教程.无奈自己是一个业务猿,每天被我司的产品虐的死去活来,之前又病了一下休息了几天,大家见谅. 进入正题,做后台项目区别于做其它的项目,权限验证与安全性是非常 ...

  5. 【转】手摸手,带你用vue撸后台 系列四(vueAdmin 一个极简的后台基础模板)

    前言 做这个 vueAdmin-template 的主要原因是: vue-element-admin 这个项目的初衷是一个vue的管理后台集成方案,把平时用到的一些组件或者经验分享给大家,同时它也在不 ...

  6. 【转】手摸手,带你用vue撸后台 系列一

    前言 说好的教程终于来了,第一篇文章主要来说一说在开始写业务代码前的一些准备工作吧,但这里不会教你webpack的基础配置,热更新怎么做,webpack速度优化等等,有需求的请自行google. 目录 ...

  7. 【手摸手,带你搭建前后端分离商城系统】01 搭建基本代码框架、生成一个基本API

    [手摸手,带你搭建前后端分离商城系统]01 搭建基本代码框架.生成一个基本API 通过本教程的学习,将带你从零搭建一个商城系统. 当然,这个商城涵盖了很多流行的知识点和技术核心 我可以学习到什么? S ...

  8. 【手摸手,带你搭建前后端分离商城系统】03 整合Spring Security token 实现方案,完成主业务登录

    [手摸手,带你搭建前后端分离商城系统]03 整合Spring Security token 实现方案,完成主业务登录 上节里面,我们已经将基本的前端 VUE + Element UI 整合到了一起.并 ...

  9. 【手摸手,带你搭建前后端分离商城系统】02 VUE-CLI 脚手架生成基本项目,axios配置请求、解决跨域问题

    [手摸手,带你搭建前后端分离商城系统]02 VUE-CLI 脚手架生成基本项目,axios配置请求.解决跨域问题. 回顾一下上一节我们学习到的内容.已经将一个 usm_admin 后台用户 表的基本增 ...

随机推荐

  1. Java实现 LeetCode 682 棒球比赛(暴力)

    682. 棒球比赛 你现在是棒球比赛记录员. 给定一个字符串列表,每个字符串可以是以下四种类型之一: 1.整数(一轮的得分):直接表示您在本轮中获得的积分数. 2. "+"(一轮的 ...

  2. Java实现 LeetCode 528 按权重随机选择(TreeMap)

    528. 按权重随机选择 给定一个正整数数组 w ,其中 w[i] 代表位置 i 的权重,请写一个函数 pickIndex ,它可以随机地获取位置 i,选取位置 i 的概率与 w[i] 成正比. 说明 ...

  3. Java实现 LeetCode 434 字符串中的单词数

    434. 字符串中的单词数 统计字符串中的单词个数,这里的单词指的是连续的不是空格的字符. 请注意,你可以假定字符串里不包括任何不可打印的字符. 示例: 输入: "Hello, my nam ...

  4. Java实现 LeetCode 419 甲板上的战舰

    419. 甲板上的战舰 给定一个二维的甲板, 请计算其中有多少艘战舰. 战舰用 'X'表示,空位用 '.'表示. 你需要遵守以下规则: 给你一个有效的甲板,仅由战舰或者空位组成. 战舰只能水平或者垂直 ...

  5. Java实现 蓝桥杯VIP 算法提高 前10名

    算法提高 前10名 时间限制:1.0s 内存限制:256.0MB 问题描述 数据很多,但我们经常只取前几名,比如奥运只取前3名.现在我们有n个数据,请按从大到小的顺序,输出前10个名数据. 输入格式 ...

  6. Java实现 蓝桥杯VIP 算法提高 数字黑洞

    算法提高 数字黑洞 时间限制:1.0s 内存限制:256.0MB 问题描述 任意一个四位数,只要它们各个位上的数字是不全相同的,就有这样的规律: 1)将组成该四位数的四个数字由大到小排列,形成由这四个 ...

  7. SQL Server账号密码(sa)登录失败 错误原因:233

    (其实以前经常用的时候,都很简单,最近一段时间不用了,再一看发现都忘记的差不多了,还是写一篇博客吧,防止下一次再在这种问题上面浪费时间) 右键此电脑,点击管理 如果没有此电脑打开文件夹 在这里右键也是 ...

  8. java实现第四届蓝桥杯买不到的数目

    买不到的数目 题目描述 小明开了一家糖果店.他别出心裁:把水果糖包成4颗一包和7颗一包的两种.糖果不能拆包卖. 小朋友来买糖的时候,他就用这两种包装来组合.当然有些糖果数目是无法组合出来的,比如要买 ...

  9. 转载:windows下安装mac虚拟机(Vmvare+mac)

    体验Mac的高效与思想,每个技术人都应该去了解和体验,本文转载自网络,使用Vmvare,虚拟Mac系统 https://blog.csdn.net/qq_31867709/article/detail ...

  10. sql server 连接种类

    一.连接种类 内连接 inner join 如果分步骤理解的话,内连接可以看做先对两个表进行了交叉连接后,再通过加上限制条件(SQL中通过关键字on)剔除不符合条件的行的子集,得到的结果就是内连接了. ...