vaakian / vaakian.github.io

some notes
https://vaakian.github.io
3 stars 0 forks source link

“隐式”依赖收集 #39

Open vaakian opened 2 years ago

vaakian commented 2 years ago

隐式依赖收集,即不需要像react hooks一样,需要明确写明回调函数的依赖收集方式。 通过一次初始化调用,造成watcher内的回调函数访问data,然后直接记录收集到该函数所有的访问记录,即依赖。

也就是Vue所使用的依赖性收集方式。

代码示例:

const data = { price: 5, quantity: 2 }
let target, total, salePrice

class Dep {
    constructor() {
        this.subscribers = []
    }
    depend() {
        if (target && !this.subscribers.includes(target)) {
            this.subscribers.push(target)
        }
    }
    notify() {
        this.subscribers.forEach(sub => sub())
    }

}

Object.keys(data).forEach(key => {
    let internalValue = data[key]
    // dep属于这个key
    const dep = new Dep()

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

function watcher(cb) {
    target = cb
    // 马上call一遍,导致date.price * data.quantity触发get,就能够收集到依赖了,这才是核心。
    // call -> 第一次 get 收集依赖,后面再get的时候,target已经没有了,所以dep.depend()不会放入新的依赖
    // 最后触set方法,触发notify,然后subscriber即target,就被触发
    target()
    target = null
}

watcher(() => {
    total = data.price * data.quantity
    salePrice = total * 0.8
})

console.log(total) //  10
console.log(salePrice) // 8

date.price = 20

console.log(total) //  40
console.log(salePrice) // 32

例子中totalsalePrice也就是相当于Vue实例中的计算属性

可以看到,要实现隐式依赖收集,那么访问的函数必须要被先调用一次。 那么Vue中,watch方法,并没有被先调用一次,那么是如何实现的依赖收集?

当然是跟Vue的写法有关,如果要watch一个状态,那么函数名一定是和该变量名一样,所以相当于是一种显式依赖收集,不需要先调用一次

vaakian commented 2 years ago

以下例子当price被修改时,会输出信息。

watcher(() => {
    console.log('detected price change: %s', data.price)
})
vaakian commented 2 years ago
let app = ''

function appReRender() {
    app = `
    <div>${data.price}</div>
    <div>${total}</div>
    <div>${salePrice}</div>
    `
    // 然后渲染到页面上
}
watcher(appReRender)

data.price = 5
console.log(app)

这样实现了一个简易的,当状态改变,则页面也会改变。 当然这种简单的字符串拼接方式粒度太粗,任何数据更新就是整个页面的重构过程。Vue比这个复杂的多。

vaakian commented 2 years ago

粗粒度更新版mini vue


class Dep {
    constructor() {
        this.subscribers = []
    }
    depend(vm) {
        if (vm.notifer && !this.subscribers.includes(vm.notifer)) {
            this.subscribers.push(vm.notifer)
        }
    }
    notify() {
        this.subscribers.forEach(sub => sub())
    }
}
function Vue(options) {
    const data = options.data
    const vm = this
    Object.keys(data).forEach(key => {
        let internalValue = data[key]
        vm[key] = internalValue
        // dep属于这个key
        const dep = new Dep()
        // 把this所对应的所有data属性代理
        Object.defineProperty(vm, key, {
            get() {
                dep.depend(vm)
                return internalValue
            },
            set(newVal) {
                internalValue = newVal
                dep.notify()
            }
        })
    })
    function watcher(notifer) {
        // 存
        vm.notifer = notifer
        // 记录依赖
        vm.notifer()
        // 清
        vm.notifer = null
    }
    const computed = options.computed
    for (let key of Object.keys(computed)) {
        watcher(() => {
            // computed[key] 访问了哪个属性,就自动收集到了依赖
            vm[key] = computed[key].apply(vm)
        })
    }
    // 收集模板上的依赖
    watcher(options.render.bind(vm))
}

Vue.prototype.$mount = function (el) {
    // 把模板渲染到页面上
    console.log(this)
    this.$el = document.querySelector(el)
    this.$el.innerHTML = this.template
}
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>miniVue</title>
</head>

<body>
    <div id="app">

    </div>
</body>
<script src="vue.js"></script>
<script>
    window.onload = function () {
        window.vm = new Vue({
            render() {
                this.template = `
                <div>price: ${this.price}</div>
                <div>quantity: ${this.quantity}</div>
                <div>total: ${this.total}</div>
        `
                if (this.$el) {
                    this.$el.innerHTML = this.template
                }
                // 当data改变,就会触发render
                return this.template
            },
            data: {
                price: 25,
                quantity: 9
            },
            computed: {
                total() {
                    return this.price * this.quantity
                }
            }
        })
        vm.$mount('#app')
    }

</script>

</html>
vaakian commented 2 years ago

通过以上的例子,computed属性不是reactive的,所以直接更改computed属性不会有任何效果。仅仅computed对应的是依赖变化了,才会产生新的vdom->diff->patch->render?

vaakian commented 2 years ago

12-22日 周三 15:30

通过proxy重写miniVue


// 全局的target函数,用于暂存依赖函数
let update = null
function watcher(hander) {
    // 存:供depend访问
    update = hander
    // Record:执行依赖函数
    update()
    // 清
    update = null
}
class Dep {
    constructor() {
        this.subs = []
    }
    depend(sub) {
        if (sub && !this.subs.includes(sub)) this.subs.push(sub)
    }
    notify() {
        this.subs.forEach(sub => sub())
    }
}
function Vue(options) {
    const instance = this
    // 初始化数据
    for (let key in options.data) {
        instance[key] = options.data[key]
    }
    // 初始化data/computed对应的dep依赖收集器
    const _deps = {}
    for (let key in options.data) {
        _deps[key] = new Dep()
    }
    for (let key in options.computed) {
        _deps[key] = new Dep()
    }
    // 代理实例的所有数据
    const proxiedInstance = new Proxy(instance, {
        get(target, key) {
            // 保存依赖对应的更新器
            _deps[key]?.depend(update)
            return target[key]
        },
        set(target, key, value) {
            let oldValue = target[key]
            target[key] = value
            // 通知key对应的依赖去更新:先修改,再通知。
            _deps[key]?.notify()
            // 通知watch
            options.watch[key]?.call(instance, value, oldValue)

            // 返回
            return target[key]
        }
    })

    // 初始化所有computed属性,且computed属性应该也要有dep
    for (let key in options.computed) {
        // watcher将函数执行一遍,触发set,然后进行依赖收集
        watcher(() => {
            proxiedInstance[key] = options.computed[key].call(proxiedInstance)
        })

    }
    return proxiedInstance
}

const vm = new Vue({
    data: {
        price: 15,
        quantity: 3,
        discount: 0.74
    },
    computed: {
        totalPrice() {
            return this.price * this.quantity
        },
        salePrice() {
            //totalPrice同样依赖与上一个计算属性。
            // 那么totalPrice改变(price或者quantity改变)则会导致salePrice改变
            return this.totalPrice * this.discount
        }
    },
    watch: {
        price(newVal, oldVal) {
            console.log('price change detected: ', { newVal, oldVal, vm: this })
        }
    }
})
console.log(vm)
vm.price = 20
// console.log(vm.totalPrice)

修改price,导致两个计算属性totalPrice/salePrice发生改变。

修改discount,仅导致salePrice发生改变。

vaakian commented 2 years ago

通过手写计算属性computedwatch方法,理解了两者的本质区别,computed计算属性是惰性的、被缓存的,仅当依赖发生改变才会重新执行更新函数。而watch方法每当该属性发生改变就触发执行函数。完全不可混为一谈。

vaakian commented 2 years ago

TODO: 以上代码存在一个问题,仅仅是浅层代理,更深层次的代理需要进行递归处理。

vaakian commented 2 years ago

收集依赖->监视getters 通知更新->监视setters

TODO: 实现vdomdiff比对,对真实页面进行patch操作。