watch的实现原理是什么?
在template里的插值表达式,如果太长,会让模板代码变得难于维护;如果有多处用到了同样的插值表达式,也不便于复用和修改。例如,这样的一个插值表达式:
<div>{{ message.split('').reverse().join('') }}</div>
基于这个需求,vue提供了计算属性。
示例:
<div>{{ reversedMessage }}</div>
...
data: ()=>({
message:'hello vue'
}),
computed:{
reversedMessage:function(){
return this.message.split('').reverse().join('')
}
}
这段代码运行之后出现了一段警告:
[Vue warn]: Computed property "reversedMessage" was assigned to but it has no setter.
报错原因:你没有给计算属性设置set函数,却修改了计算属性的值。解决方法:给计算属性加上set函数。修改示例:
computed:{
// reversedMessage:function(){
// return this.message.split('').reverse().join('')
// }
reversedMessage:{
get(){
return this.message.split('').reverse().join('')
},
set(val){}
}
},
运行效果:
计算属性在computed中定义,以Function的形式定义,不保存状态,持有对data变量的引用,当相关的data变量变化时,计算属性亦随之变化。
注意,计算属性的Function不能使用箭头函数,因为箭头函数没有this。使用箭头函数定义计算属性,就不能引用data变量了。
有两个问题:
第2个问题,get语法将对象属性绑定到查询该属性时将被调用的函数,所以本质上get属性是一个函数,只是它在调用时,不必加()
,并且还有以下两点优势:
这两个优势是js es6本身带来的福利。
计算属性还有一个优点,就是它是基于它们的响应式依赖进行缓存的。例如reversedMessage依赖于message,只要message不变化,reversedMessage会一直缓存,多次访问 reversedMessage 计算属性会立即返回之前的计算结果。
计算属性reversedMessage也可以使用计算方法取代,示例:
methods: {
reversedMessage: function () {
return this.message.split('').reverse().join('')
}
}
运行效果是一样的。
计算方法的特点是没有缓存,每次视图渲染时方法总会被调用。如果数据量大,计算开销很大,性能是一个问题。但如果计算开销不大,没有缓存反而是一个优点。
有一个问题:
计算方法依赖的数据变化了,视图会自动渲染吗?
答案竟然是会。
测试示例:
<div>{{ reversedMessage2() }}</div>
...
methods: {
reversedMessage2: function () {
return this.message.split('').reverse().join('')
}
},
created(){
setInterval(()=>{
this.message += new Date().getTime()%100
},1000)
}
运行效果:
为什么计算方法也是响应式的?
可能的解释是:在第一次模板渲染时,即使插值是js表达式,抑或是函数,当data变量的set属性被访问时,插值的依赖已经被收集了,这样如果依赖项更新了,插值自然也会更新。
Object.defineProperty(obj, key, {
get: function () {
// 添加订阅者 watcher 到主题对象 Dep
if (Dep.target) dep.addSub(Dep.target);
return val
},
这或许也是第1小节中第一个问题的答案,为什么data变量变化时,计算属性也会随之变化,因为插值的依赖在第一次编译时就计算好了。
在编译时,可以将计算方法、计算属性都看作是一个特殊的js表达式。
回到刚才的问题,计算属性如何与属性建立依赖关系?属性发生变化又如何通知到计算属性重新计算?
在src/core/instance/state.js文件内有一个函数initComputed,是处理计算属性的:
// 初始化计算属性
function initComputed (vm: Component, computed: Object) {
...
for (const key in computed) {
...
defineComputed(vm, key, userDef)
...
}
}
辗转调用数次后,执行指针到了:
function createComputedGetter (key) {
return function computedGetter () {
const watcher = this._computedWatchers && this._computedWatchers[key]
if (watcher) {
if (watcher.dirty) {
watcher.evaluate()
}
if (Dep.target) {
watcher.depend()
}
return watcher.value
}
}
}
其中if (Dep.target) { watcher.depend()}
是关键,当Dep.target有值,且处于收集时,为当前的节点收集这个依赖。
而Dep.target此时是什么呢?
关键在于src/core/observer/watcher.js文件中的get:
get () {
pushTarget(this)
let value
const vm = this.vm
try {
// 这里的getter是指向计算属性,而计算属性又会指向data变量的getter
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(this)推入,此时Dep.target有值了,最后是popTarget(),Dep.target为空了。在此期间很关键的关于this.getter.call的调用,会指向开发者定义的计算属性(reversedMessage),而计算属性又会指向data变量的getter:
// src/observer/index.js
Object.defineProperty(obj, key, {
get: function reactiveGetter () {
if (Dep.target) {
// 建立依赖关系
dep.depend()
...
}
return value
},
set: function reactiveSetter (newVal) {
...
// 依赖发生变化,通知到计算属性重新计算
dep.notify()
}
})
当全局变量Dep.target非空的,建立依赖关系;当值发生变化时,广播通知视图节点更新。
关于计算属性reversedMessage,建立响应式的流程可以概括为:
1. 首先,data 属性初始化,建立getter/setter
2. computed 计算属性初始化,提供的函数将用作属性 vm.reversedMessage 的 getter
3. 当首次获取 reversedMessage 计算属性的值时,Dep 开始依赖收集
4. 在 message getter 被调用时,如果 Dep 处于依赖收集状态,则判定 message 为 reversedMessage 的依赖,并建立依赖关系
5. 当 message 发生变化时,根据依赖关系,触发 reverseMessage 的重新计算,这是通过dep.notify()达成的
以上就是计算属性响应式机制的实现原理。vue在处理插值中的js表达式与计算方法时,响应式的实现原理与之是类似的。
由于计算属性与data对象的属性一样,都要被defineProperty重定义在vm上,所以计算属性对象computed中的名称与data对象中的不能重复。
methods中的函数名称同理,亦不能与计算属性有重名。
侦听属性是有一些数据需要随着其它数据变动而变动时使用的。示例:
<div id="demo">{{ fullName }}</div>
watch: {
message: function (val) {
this.fullName = val + ' ' + new Date().getTime()
},
},
当message变化时,主动改变fullName的值。
事实上侦听属性在开发中并不被经常使用。类似于钩子逻辑的做法会让业务逻辑变得头绪繁乱而复杂。如果需要计算的值完全是同步的,侦听属性完全可以由计算属性或计算方法代替;如果包括异步代码,可以考虑使用事件机制代替。
以下示例改自于官方文档,当数据变化时触发一个函数的执行:
<p>
Ask a yes/no question:
<input v-model="question" />
</p>
<p>{{ answer }}</p>
watch: {
question: function (newQuestion, oldQuestion){
this.getAnswer()
}
},
getAnswer: function () {
if (!/[??]/.test(this.question)) {
this.answer = 'Questions usually contain a question mark. ;-)'
return
}
if (this.answer == 'Thinking...') return
this.answer = 'Thinking...'
var vm = this
axios.get('https://yesno.wtf/api')
.then(function (response) {
vm.answer = response.data.answer
})
.catch(function (error) {
vm.answer = 'Error! Could not reach the API. ' + error
})
}
当question变化时,自动调用getAnswer函数,请求一个接口。由于接口请求是异步的,这里要适当做一个滤重。
运行效果:
在运行时如果提示缺少axios:
[Vue warn]: Error in callback for watcher "question": "ReferenceError: axios is not defined"
此时需要安装网络类库:
yarn add axios
然后在script添加引用:
import axios from 'axios'
watch 有一个特点是,最初绑定的时候是不会执行的,要等到被监视的属性改变时才执行方法。如果想要一开始就执行,应该怎么做?
修改示例:
question: {
handler (newQuestion, oldQuestion) {
this.getAnswer()
},
// 代表了立即执行handler方法
immediate: true
}
如果被监听的数据属性是obj,而obj又有一个子属性a,正常情况下当a发生变化时,obj是不被视为有变化的,watch机制不会被触发。除非将watch的默认选项deep,由false改为true:
<div>
<p>obj.a: {{obj.a}}</p>
<p>obj.a: <input type="text" v-model="obj.a"></p>
</div>
new Vue({
data: {
obj: {
a: 123
}
},
watch: {
obj: {
handler(newName, oldName) {
console.log('obj.a changed');
},
deep:true,
immediate: true
}
}
})
通过声明创建的watch,不用手动注销,它会随着组件的销毁而自动注销。
但是通过vm.$watch手动创建的监听,却需要手动注销:
const unWatch = vm.$watch('text', (newVal, oldVal) => {
console.log(`${newVal} : ${oldVal}`);
})
...
unWatch(); // 手动注销watch
vm.$watch返回一个unWatch方法,在destroyed周期函数中调用即可。
createWatcher (vm, key, handler) {
let options
...
if (typeof handler === 'string') {
handler = vm[handler]
}
vm.$watch(key, handler, options)
}
如上所示,通过代码声明创建的watch,最终调用的还是vm.$watch方法。面在vue源码中,关于这个watch方法的实现,代码大概是这样的:
Vue.prototype.$watch=function(expOrFn,cb,options){
const vm = this
options = options || {}
options.user = true
const watcher = new Watcher(vm, expOrFn, cb, options)
//immediate=true时 马上执行一次
if (options.immediate) {
cb.call(vm, watcher.value)
}
//返回取消监听的匿名函数
return function unwatchFn () {
watcher.teardown()
}
}
基本思想就是创建一个Watcher对象,注册好依赖,等data属性更新的时候,会触发Watcher对象的update方法。这与data属性的响应式实现的原理是类似的。
https://git.code.tencent.com/shiqiaomarong/vue-go-rapiddev-example/tags/v20200116