计算属性
计算属性会根据依赖项进行计算,返回一个新的值,有两个特点:
- 缓存:依赖性不变,不会进行计算,直接返回结果
- 惰性求值:依赖项改变时,如果不进行读取,也不会进行计算
实现
实现思路:
- 依赖收集:由于计算属性是根据多个依赖项得出,任何一个依赖项改变都要进行重新求值,所以每一个依赖项都需要收集它的 watcher。具体实现可以在实例化 watcher时调用计算属性的函数,函数内会读取所有的依赖项。
- 缓存:调用函数的时候保存结果,下次读取如果没有改变依赖项直接返回结果。
- 惰性求值:依赖项改变时,不直接调用函数,而是修改 watcher的属性,表示需要重新计算。
Watcher
新增 options参数默认为空对象,新增 lazy、dirty、value属性。如果 lazy为true,表示惰性求值,读取的时候再求值。如果 dirty为 true,表示依赖项改变,需要重新计算。value用来保存计算的结果。
class Watcher {
constructor(vm, key, cb, options = {}) {
// 略 ...
this.lazy = !!options.lazy
this.dirty = this.lazy
this.value = undefined
// lazy为true,读取时再进行求值
if (!this.lazy) {
this.get()
}
}
get() {
Dep.target = this
if (typeof this.key === 'function') {
// 如果是函数,说明是计算属性,对函数进行调用,函数内部再对依赖项进行读取
this.value = this.key.call(this.vm)
} else {
this.value = this.vm[this.key]
}
Dep.target = null
}
update() {
if (this.lazy) {
// 依赖性更新时,将 dirty设置为 true,下次读取时重新求值
this.dirty = true
} else {
this.run()
}
}
// ...
}
class Dep {
// ...
notify() {
for (const watcher of this.subs) {
// 派发任务时,调用 update函数
watcher.update()
}
}
}
initComputed
初始化计算顺序,并将其挂载到 Vue实例上。
class Vue {
constructor(options) {
const data = options.data
this.$options = options
this._data = typeof data === 'function' ? data() : data
this.initData()
this.initComputed() // 在 initWatch之前调用,因为 watch可以监听 computed
this.initWatch()
}
// 略 ...
initComputed() {
const computed = this.$options.computed
if (!computed) return
const keys = Object.keys(computed)
for (const key of keys) {
// 为计算属性创建 watcher
const watcher = new Watcher(this, computed[key], () => {}, { lazy: true })
// 将计算属性挂载到 Vue实例上
Object.defineProperty(this, key, {
// dirty为 true,则重新计算结果,否则直接返回缓存的值
get() {
if (watcher.dirty) {
watcher.get()
watcher.dirty = false
}
return watcher.value
}
})
}
}
}
监听计算属性
此时,计算属性的功能基本完成,但还有一个问题就是侦听器还不能监听计算属性。计算属性并不像普通属性有自己的 Dep实例来收集 watcher,它依赖其他属性计算得出,任何一个依赖项改变都需要触发它的回调。
实现思路:记录计算属性的watcher被那些属性收集,再让这些属性对 watch也收集一次。
Watcher、Dep相互收集
class Watcher {
deps = [] // 记录收集了该实例的 dep
// ...
// 记录 dep,并调用 addSub收集该 Watcher实例
addDep(dep) {
if (this.deps.includes(dep)) return
this.deps.push(dep)
dep.addSub(this)
}
}
class Dep {
subs = []
// 收集 watcher时先通知 watcher记录被哪个 dep收集
depend() {
if (Dep.target) {
Dep.target.addDep(this)
}
}
// 收集watcher
addSub(watcher) {
this.subs.push(watcher)
}
// ...
}
一个bug
class Watcher {
// 略 ...
get() {
Dep.target = this
if (typeof this.key === 'function') {
this.value = this.key.call(this.vm)
} else {
this.value = this.vm[this.key]
}
Dep.target = null
}
}
此处代码存在一个bug,当侦听器监听计算属性时,此时 Dep.target指向侦听器的 watcher,当读取属性时,会触发侦听器的 getter函数,watcher.dirty为 true,又会调用 watcher.get函数,Dep.target被重新赋值为计算属性的 watcher,导致侦听器的 watcher丢失。
解决办法:定义一个数组来收集 watcher,收集完成后删除 watcher,如果数组中含有数据,则将其取出,赋值给 Dep.target。
const targetStack = []
class Watcher {
// 略 ...
get() {
// 收集 watcher
targetStack.push(this)
Dep.target = this
if (typeof this.key === 'function') {
this.value = this.key.call(this.vm)
} else {
this.value = this.vm[this.key]
}
// 收集完后删除最后添加的 watcher
targetStack.pop()
// 如果数组中还有 watcher,则取出最后一个赋值给 Dep.target
Dep.target = targetStack.length ? targetStack.at(-1) : null
}
}
代码修改后,侦听器的watcher会在计算属性的watcher.get完成后到侦听器的watcher.get完成前的期间内挂载到 Dep.target上,可以在计算属性的getter函数中对其进行收集。
initComputed() {
const computed = this.$options.computed
if (!computed) return
const keys = Object.keys(computed)
for (const key of keys) {
const watcher = new Watcher(this, computed[key], () => {}, { lazy: true })
Object.defineProperty(this, key, {
get() {
if (watcher.dirty) {
watcher.get()
watcher.dirty = false
}
// 如果 Dep.target存在,则让计算属性的依赖项的dep对侦听器的watcher进行收集
if (Dep.target) {
watcher.deps.forEach((dep) => dep.depend(Dep.target))
}
return watcher.value
}
})
}
手动读取计算属性
此时侦听器已经可以监听计算属性,但触发侦听器回调时,计算属性需要重新求值,可以在侦听器运行时进行读取,让计算属性重新计算。
class Watcher {
// 略 ...
run() {
if (watcherQueue.includes(this.id)) return
watcherQueue.push(this.id)
Promise.resolve().then(() => {
this.get() // 调用 get函数读取计算属性,让其重新求值
this.cb.call(this.vm)
watcherQueue.splice(watcherQueue.indexOf(this.id), 1)
})
}
}
完整代码
class Vue {
constructor(options) {
const data = options.data
this.$options = options
this._data = typeof data === 'function' ? data() : data
this.initData()
this.initComputed()
this.initWatch()
}
initData() {
const data = this._data
observer(data)
const keys = Object.keys(data)
for (const key of keys) {
Object.defineProperty(this, key, {
get() {
return data[key]
},
set(value) {
data[key] = value
}
})
}
}
initComputed() {
const computed = this.$options.computed
if (!computed) return
const keys = Object.keys(computed)
for (const key of keys) {
const watcher = new Watcher(this, computed[key], () => {}, { lazy: true })
Object.defineProperty(this, key, {
get() {
if (watcher.dirty) {
watcher.get()
watcher.dirty = false
}
if (Dep.target) {
watcher.deps.forEach((dep) => dep.depend(Dep.target))
}
return watcher.value
}
})
}
}
initWatch() {
const watch = this.$options.watch
if (!watch) return
const keys = Object.keys(watch)
for (const key of keys) {
this.$watch(key, watch[key])
}
}
$watch(key, cb) {
new Watcher(this, key, cb)
}
$set(target, key, value) {
if (Array.isArray(target)) {
target[key] = value
observer(value)
} else {
defindReactive(target, key, value)
}
target.__ob__.dep.notify()
}
}
function observer(data) {
const dataType = Object.prototype.toString.call(data)
if (dataType !== '[object Object]' && dataType !== '[object Array]') {
return
}
if (data.__ob__) return data.__ob__
return new Observer(data)
}
function defindReactive(target, key, value) {
const ob = observer(value)
const dep = new Dep()
Object.defineProperty(target, key, {
get() {
dep.depend()
ob?.dep.depend()
return value
},
set(val) {
if (val === value) return
dep.notify()
value = val
}
})
}
class Observer {
dep = new Dep()
constructor(data) {
if (Array.isArray(data)) {
data.__proto__ = arrayMethods
this.observeArray(data)
} else {
this.walk(data)
}
Object.defineProperty(data, '__ob__', {
value: this,
enumerable: false
})
}
walk(data) {
const kyes = Object.keys(data)
for (const key of kyes) {
defindReactive(data, key, data[key])
}
}
observeArray(arr) {
arr.forEach(observer)
}
}
class Dep {
subs = []
depend() {
if (Dep.target) {
Dep.target.addDep(this)
}
}
notify() {
for (const watcher of this.subs) {
watcher.update()
}
}
addSub(watcher) {
this.subs.push(watcher)
}
}
let watcherId = 0
const watcherQueue = []
const targetStack = []
class Watcher {
deps = []
constructor(vm, key, cb, options = {}) {
this.vm = vm
this.key = key
this.cb = cb
this.id = watcherId++
this.lazy = !!options.lazy
this.dirty = this.lazy
this.value = undefined
if (!this.lazy) {
this.get()
}
}
get() {
targetStack.push(this)
Dep.target = this
if (typeof this.key === 'function') {
this.value = this.key.call(this.vm)
} else {
this.value = this.vm[this.key]
}
targetStack.pop()
Dep.target = targetStack.length ? targetStack.at(-1) : null
}
update() {
if (this.lazy) {
this.dirty = true
} else {
this.run()
}
}
run() {
if (watcherQueue.includes(this.id)) return
watcherQueue.push(this.id)
Promise.resolve().then(() => {
this.get()
this.cb.call(this.vm)
watcherQueue.splice(watcherQueue.indexOf(this.id), 1)
})
}
addDep(dep) {
if (this.deps.includes(dep)) return
this.deps.push(dep)
dep.addSub(this)
}
}
const methods = ['push', 'pop', 'shift', 'unshift', 'reverse', 'sotr', 'splice']
const arrayMethods = methods.reduce((obj, method) => {
obj[method] = function (...args) {
const res = Array.prototype[method].apply(this, args)
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
default:
break
}
inserted?.forEach(observer)
this.__ob__.dep.notify()
return res
}
return obj
}, Object.create(Array.prototype))