js通过Object.defineProperty实现数据响应式

数据响应式

假设我们现在有这么一个页面

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        p {
            font-family: '幼圆';
            font-size: 20px;
        }
    </style>
</head>

<body>
    <p class="firstName">姓:<span></span></p>
    <p class="lastName">名:<span></span></p>
    <p class="sex">性别:<span></span></p>
    <script>
        const info = {
            name: "贝蒂小熊",
            sex: "男"
        }
        function renderFirstName() {
            const firstName = document.querySelector(".firstName>span")
            firstName.innerHTML = info.name.length > 3 ? info.name.slice(0, 2) : info.name[0]
        }
        function renderLastName() {
            const lastName = document.querySelector(".lastName>span")
            lastName.innerHTML = info.name.length > 3 ? info.name.slice(2) : info.name.slice(1)
        }
        function renderSex() {
            const sex = document.querySelector(".sex>span")
            sex.innerHTML = info.sex
        }
        renderFirstName()
        renderLastName()
        renderSex()
    </script>
</body>

</html>

它的页面显示如下
结果

我们可以发现,页面显示的内容实际上是由我们预先定义的数据决定的,页面本身也不会具有任何数据,此时的页面与数据是高度一致
如果我们将数据更改了会怎么样

info.name = "牢大"

界面却并没有及时的同步显示
结果

我们可以说解决这个问题十分简单,直接调用renderFirstNamerenderLastName函数就行了

info.name = "牢大"
renderFirstName()
renderLastName()

结果

可是为什么更改了name我们就需要调用renderFirstNamerenderLastName这两个函数?
我们可以从逻辑上说name的改变会让一个人的姓和名也跟着变更,而一个人的性别却并不和姓名相关,所以不用调用renderSex函数,那如果我们将renderSex的函数修改成以下这样呢

function renderSex() {
    const sex = document.querySelector(".sex>span")
    text = info.name === "贝蒂小熊" ? "赛马娘" : "肘击王"
    sex.innerHTML = info.sex + " - " + text
}

此时的sex依旧是,没有改变,sexname在逻辑上也没有强相关的联系,那么此时应该要调用renderSex函数吗
结果
似乎有哪里不对,可见除了从逻辑层面解释在哪些属性被修改时应该调用哪些函数之外还可以通过其他方面解释
我们再来看下面这个例子

const obj = {
    a: "value",
    b: 1,
    c: new Symbol(),
    d: {
        key: "key"
    }
}
function e() {
    //相关操作......
}
function f() {
    //相关操作......
}
function g() {
    //相关操作......
}
function h() {
    //相关操作......
}

此时无论是obj还是相关的四个函数全是无意义的脏数据,在逻辑上没有任何关联,但每个函数都调用了obj里的某一个属性,我们并不知道哪些函数调用了哪些属性,那么我们该怎么确定在obj里的属性被改变时该调用哪些函数

答案其实很简单,当某一个函数访问了某一个属性,那么这个属性被改变时这个函数就需要同步重新运行,无论这个属性与函数在逻辑上是否相关联,一个函数可以访问多个属性,一个属性可以被多个函数访问,函数在运行期间可能会修改多个属性,多个属性被修改会带动更多的函数运行…

这种解决方案我们通常称之为响应式编程,也被称之为数据响应式

那么新的问题又出来了,我们如何记录哪些属性被哪些函数访问了

属性描述符

我们在学习属性描述符的时候我们学过两个存取属性描述符,分别是setgetset会在属性被设置时调用get会在属性被读取时调用,我们能不能在这两个描述符上完成函数收集函数运行的操作呢?

propertyResponsive

我们定义一个函数用来重写属性的setget描述符

function propertyReponsive(obj, key) {

}

这个函数需要传递两个参数,obj为需要监控的对象,key为具体监控的属性
我们首先需要获得原属性的值

function propertyReponsive(obj, key) {
    let _value = obj[key]
}

然后我们需要拦截原本的getset操作

function propertyReponsive(obj, key) {
    let _value = obj[key]
    Object.defineProperty(obj, key, {
        get() {
            return _value
        },
        set(newValue) {
            _value = newValue
        }
    })
}

现在我们就需要在get收集函数,在set调用函数

依赖收集

get收集函数的这个环节,我们通常称之为依赖收集,即收集依赖该属性的函数
那么什么是依赖
依赖简单的来说就是函数在运行期间用到了哪些属性,就被称之为函数依赖于哪些属性
依赖收集对应的操作叫做派发更新,意思也能简单,就是将收集到的函数重新再运行一遍就是派发更新
那么现在我们就有了一个新问题,这些依赖收集到哪呢

依赖队列

我们可以定义一个依赖队列,专门用来维护各个属性的依赖函数,这个依赖队列可以简单的就定义为一个数组,但为了日后的可维护和可扩展,我们将其定义为一个,这个类的名字就命名为Dep

class Dep {
    constructor() {
        this.subs = new Set()
    }
    addSub(sub) {
        this.subs.add(sub)
    }
}

subs是一个set集合,专门用来存放依赖,之所以定义成set而不是数组是因为考虑到了依赖可能会重复的情况
我们现在虽然解决了如何存放依赖,那我们怎么才能找到依赖

寻找依赖

我们不妨转变一下思路,我们为什么无法寻找到依赖,因为函数的运行位置我们无法掌握,函数会通过各种各样的方式被调用运行,我们能不能规定每次调用函数时必须在某个特定的地方调用,这个地方可以是一个全局变量,可以是全局对象上的一个属性,在每次调用函数前函数必须要存放到这个指定的地方来调用,调用完之后再将函数移除留待其他函数调用
使用以上方案的话我们在Dep中寻找依赖就只需要监听特定变量/属性就能获得依赖

class Dep {
    constructor() {
        this.subs = new Set()
    }
    addSub(sub) {
        this.subs.add(sub)
    }
    depend() {
        if (window.target)
            this.addSub(window.target)
    }
}

depend方法用来在每次属性get操作被调用时收集当前依赖并存放到subs
我们先不去考虑如何在每次函数调用前将函数存放到特定的地方,只考虑依赖队列的话这么写无疑能获取依赖
依赖收集后我们还需要在属性变更后及时派发更新

class Dep {
    constructor() {
        this.subs = new Set()
    }
    addSub(sub) {
        this.subs.add(sub)
    }
    depend() {
        if (window.target)
            this.addSub(window.target)
    }
    notify() {
        for (const sub of this.subs) {
            sub()
        }
    }
}

notify方法用于在属性set操作被调用时将sub里的依赖全部执行一遍
基于此我们就能实现依赖的收集了,最后我们再修改一下propertyResponse函数

function propertyReponsive(obj, key) {
    let _value = obj[key]
    let dep = new Dep()
    Object.defineProperty(obj, key, {
        get() {
            dep.depend()
            return _value
        },
        set(newValue) {
            _value = newValue
            dep.notify()
        }
    })
}

观察器

在之前的代码中我其实还遗留了一个问题,就是我们如何将函数放入window.target中,我们显然不能在每次函数调用前手动的将函数存放在window.target中,在函数运行结束后再将其移除
我们或许可以封装一个函数来协助我们做这件事

function watcher(fn) {
    window.target = fn
    fn()
    window.target = null
}

这么写虽然也能实现功能,但不利于日后的维护与扩展,我们还是将其写成一个

class Watcher {
    constructor(fn, vm, ...args) {
        this.fn = fn
        this.vm = vm
        this.args = args
        window.target = this
        fn.call(this.vm, this.args)
        window.target = null
    }
}

实例化一个Watcher对象需要传递三个参数,一个函数,一个当前函数对应的上下文,一个为函数运行时所需的参数
值得注意的是此时window.target存放的不再是函数,而是一个Watcher对象,为什么不直接存放函数呢,因为如果存放函数的话this参数都有可能会发生错误,所以综合考虑才传递一个Watcher对象
sub不再是一个函数时,这意味着在依赖队列里不能再通过简单粗暴的sub()派发更新了,那该怎么解决呢

派发更新

我们或许可以在Watcher中定义一个方法,由这个方法来负责此函数的更新操作,在依赖队列中我们只需要调用这个方法就能完成派发更新

class Watcher {
    constructor(fn, vm, ...args) {
        this.fn = fn
        this.vm = vm
        this.args = args
        window.target = this
        fn.call(this.vm, this.args)
        window.target = null
    }
    update() {
        this.fn.call(this.vm, this.args)
    }
}

update方法负责重新将函数执行一遍
Watcher改好了还需要修改Dep

class Dep {
    constructor() {
        this.subs = new Set()
    }
    addSub(sub) {
        this.subs.add(sub)
    }
    depend() {
        if (window.target)
            this.addSub(window.target)
    }
    notify() {
        for (const sub of this.subs) {
            sub.update()
        }
    }
}

Observer

现在,以上的代码已经能实现监测一个对象上的一个属性数据响应式功能了,但如果我们需要监听一个对象的全部属性,乃至全部的子属性,我们就需要继续封装一个函数来解决
这里我们还是通过的方式实现

class Observer {
    constructor(obj) {
        this.data = obj
        if (!Array.isArray(this.data))
            this.walk()
    }
    walk() {
        for (const key in this.data) {
            propertyReponsive(this.data, key)
        }
    }
}

Observer中因为Object.defineProperty只能监测对象,对于数组并不能监测,所以我们在执行walk之前需要对类型进行判断
我们接下来修改propertyResponse函数以支持递归监测

function propertyReponsive(obj, key) {
    let _value = obj[key]
    if (typeof _value === "object") new Observer(_value)
    let dep = new Dep()
    Object.defineProperty(obj, key, {
        get() {
            dep.depend()
            return _value
        },
        set(newValue) {
            _value = newValue
            dep.notify()
        }
    })
}

完整代码

到此为止我们就将整个数据响应式写完了,我们最后来看看效果

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        p {
            font-family: '幼圆';
            font-size: 20px;
        }
    </style>
</head>

<body>
    <p class="firstName">姓:<span></span></p>
    <p class="lastName">名:<span></span></p>
    <p class="sex">性别:<span></span></p>
    <input type="text" onchange="this.value===''? info.name='贝蒂小熊': info.name=this.value">
    <script>
        class Watcher {
            constructor(fn, vm, ...args) {
                this.fn = fn
                this.vm = vm
                this.args = args
                window.target = this
                fn.call(this.vm, this.args)
                window.target = null
            }
            update() {
                this.fn.call(this.vm, this.args)
            }
        }
        class Dep {
            constructor() {
                this.subs = new Set()
            }
            addSub(sub) {
                this.subs.add(sub)
            }
            depend() {
                if (window.target)
                    this.addSub(window.target)
            }
            notify() {
                for (const sub of this.subs) {
                    sub.update()
                }
            }
        }
        class Observer {
            constructor(obj) {
                this.data = obj
                if (!Array.isArray(this.data))
                    this.walk()
            }
            walk() {
                for (const key in this.data) {
                    propertyReponsive(this.data, key)
                }
            }
        }
        function propertyReponsive(obj, key) {
            let _value = obj[key]
            if (typeof _value === "object") new Observer(_value)
            let dep = new Dep()
            Object.defineProperty(obj, key, {
                get() {
                    dep.depend()
                    return _value
                },
                set(newValue) {
                    _value = newValue
                    dep.notify()
                }
            })
        }
    </script>
    <script>
        const info = {
            name: "贝蒂小熊",
            sex: "男"
        }
        function renderFirstName() {
            const firstName = document.querySelector(".firstName>span")
            firstName.innerHTML = info.name.length > 3 ? info.name.slice(0, 2) : info.name[0]
        }
        function renderLastName() {
            const lastName = document.querySelector(".lastName>span")
            lastName.innerHTML = info.name.length > 3 ? info.name.slice(2) : info.name.slice(1)
        }
        function renderSex() {
            const sex = document.querySelector(".sex>span")
            sex.innerHTML = info.sex
        }
        new Observer(info)
        new Watcher(renderFirstName, window)
        new Watcher(renderLastName, window)
        new Watcher(renderSex, window)
    </script>
</body>

</html>

结果

关于数据响应式

最后我们再来谈谈什么是数据响应式

粗犷的来说,当数据改变时页面会自动的根据数据的变化来变化,而这背后其实是当数据改变时,依赖此数据的函数会同步执行,数据响应式的本质就是依赖收集和派发更新,依赖收集即将数据与被监听的函数关联起来,派发更新即重运行依赖关系的函数,核心就是拦截getter和setter

关于Object.defineProperty的限制

因为Object.defintProperty只能监听单个属性的读取修改操作,当新增属性或者删除属性时无法监听

另外Object.defineProperty也无法监听数组的变化,所以以上两种情况都需要单独监听,而如果使用ES6中的Proxy和Reflect就能很好的处理以上的情况了

相关推荐

  1. Vue的watch功能:实现响应数据更新

    2024-04-11 15:14:01       38 阅读
  2. Vue3:ref和reactive实现响应数据

    2024-04-11 15:14:01       24 阅读

最近更新

  1. TCP协议是安全的吗?

    2024-04-11 15:14:01       16 阅读
  2. 阿里云服务器执行yum,一直下载docker-ce-stable失败

    2024-04-11 15:14:01       16 阅读
  3. 【Python教程】压缩PDF文件大小

    2024-04-11 15:14:01       15 阅读
  4. 通过文章id递归查询所有评论(xml)

    2024-04-11 15:14:01       18 阅读

热门阅读

  1. Bash 编程精粹:从新手到高手的全面指南之变量

    2024-04-11 15:14:01       14 阅读
  2. [Linux][shell][权限] shell原理简介 + 权限细节笔记

    2024-04-11 15:14:01       13 阅读
  3. 知识碎片随手记-1

    2024-04-11 15:14:01       14 阅读
  4. c# 实现Quartz任务调度

    2024-04-11 15:14:01       15 阅读
  5. MySQL:统计总条数时去重

    2024-04-11 15:14:01       14 阅读
  6. python时间&内存计算

    2024-04-11 15:14:01       12 阅读
  7. 自动驾驶涉及相关的技术

    2024-04-11 15:14:01       14 阅读
  8. 死锁以及如何避免死锁

    2024-04-11 15:14:01       15 阅读
  9. 如何理解JVM

    2024-04-11 15:14:01       14 阅读
  10. Spring之事务底层源码解析

    2024-04-11 15:14:01       13 阅读
  11. CSS 选择器 – 类、名称、子选择器

    2024-04-11 15:14:01       14 阅读
  12. 为什么俗套的电邮“钓鱼”攻击,频频得手

    2024-04-11 15:14:01       14 阅读