实现一个简单的Vue响应式

实现一个简单的Vue响应式(以Vue2.x为例),整个过程的代码会很浅显(例如不涉及子节点的处理,数组的处理等),以突出 数据拦截依赖收集 两个重点,并且本篇假设你已经懂得Object.defineProperty的用法。

大体思路

什么是响应性?

响应性:Vue 会自动跟踪 JavaScript 状态并在其发生变化时响应式地更新 DOM。

按照定义,我们需要考虑的问题是DOM上的节点如何知道JS中数据的变化,以及如何改变。不同的节点访问了不同的属性,对于这些节点而言,它们如何知道自己该关心哪一个属性的变化呢?

很简单,由于每个节点渲染时已经使用了对应的属性,那么监测该属性就好了,举个例子,<div>{{ name }}</div>访问了name属性,节点就监测name的变动。

整个实现过程都遵循这一思路。

准备

先用vite生成一个空的项目npm create vite@latest

html 复制代码
<!--index.html-->

<body>
<div id="app">
  <div>{{ name }}</div>
  <span>{{ age }}</span>
</div>
<script type="module" src="/src/main.js"></script>
</body>
js 复制代码
// main.js

import { MyVue } from "./myVue"

const vm = new MyVue({
  el: "#app",
  data: {
    name: 'xiaohua',
    age: 18
  }
})

// 用于在控制台查看和调试
window.vm = vm
js 复制代码
// myVue/index.js

export class MyVue {
    constructor(options) {
        this.el = document.querySelector("#app")
        this.data = options.data
    }
}

模板编译------解析大胡子语法

请出第一个角色------Compiler。

js 复制代码
// myVue/Compiler.js
export class Compiler {
    constructor(vm) {
        this.el = vm.el
        this.data = vm.data

        // 遍历元素
        this.walk(this.el)
    }

    walk(rootNode) {
        for (let i = 0; i < rootNode.children.length; i++) {
            console.log(rootNode.children[i], rootNode.children[i].textContent)
        }
    }
}
js 复制代码
// myVue/index.js
import { Compiler } from "./Compiler"

export class MyVue {
    constructor(options) {
        this.el = document.querySelector("#app")
        this.data = options.data

        new Compiler(this)
    }
}

接下来用正则把节点的内容替换掉:

js 复制代码
// myVue/Compiler.js
export class Compiler {
    constructor(vm) {
        this.el = vm.el
        this.data = vm.data

        // 遍历元素
        this.walk(this.el)
    }

    walk(rootNode) {
        for (let i = 0; i < rootNode.children.length; i++) {
            const reg = /\{\{\s*(\w+)\s*\}\}/
            const prop = rootNode.children[i].textContent.match(reg)[1]
            rootNode.children[i].textContent = rootNode.children[i].textContent.replace(reg, this.data[prop])
        }
    }
}

这样我们就完成了对节点的最简单的编译,并且就止步于此,不再深入了。

建立监听

此时虽然完成了节点的渲染,但是数据发生变化后,节点是不会做出改变的。

怎样做才能让节点发生改变呢?

------只需要在数据发生变化那一刻通知节点改变就可以了------setter,给属性增加修改时的拦截,告诉节点进行更新。

很好,但问题是一个属性发生改变时哪一些节点需要被告知呢?

------对于属性来说,需要事先将那些依赖它的节点都收集起来。

怎么做?

------谁访问了我,我就收集谁 ,也就是说需要一个getter,在getter中完成收集,考虑到可能不止一个节点访问同一属性,那么用一个数组来作为收集容器。

js 复制代码
// myVue/index.js
import { Compiler } from "./Compiler"

export class MyVue {
    constructor(options) {
        this.el = document.querySelector("#app")
        this.data = options.data

        // 由于初始化Compiler会访问数据,因此将定义响应式的逻辑置于Compiler之前。
        // 当执行Compiler时就会触发getter完成收集
        this.walk(this.data)
        new Compiler(this)
    }

    walk(data) {
        Object.keys(data).forEach(key => {
            this.defineReactive(data, key, data[key])
        })
    }
    defineReactive(obj, key, value) {
        const subs = []
        Object.defineProperty(obj, key, {
            configurable: true,
            enumerable: true,
            get() {
                console.log('触发了getter')
                subs.push(正在访问属性的节点)
                return value
            },
            set(newValue) {
                if (newValue === value) return
                console.log('触发了setter')
                value = newValue
            }
        })
    }
}

可以看到getter中有subs.push(正在访问属性的节点),但遗憾的是这个"正在访问属性的节点"似乎并没有和属性之间有直接的联系,以至于在getter运行过程中只知道有节点正在访问,却不知道是哪一个。

巧妙的方法出现了,由于js是单线程的缘故,意味着同一时刻它只做一件事,那么当一个getter在执行时有且只有一个getter在执行------该时刻也只有一个节点在访问属性

只需要在进入getter的执行前找到一个类似于全局变量的地方(其实最直接的思路就是存放在全局变量里),把该节点保存下来,等到getter执行时就能从"全局变量"里拿到那个访问它的节点。

这里我直接选择保存在Myvue这个类中:

js 复制代码
export class MyVue {
    static target = null
    constructor(options) {
        ...
    }
    ...
}

在compiler中,this.data[prop]的出现使得getter启动。

js 复制代码
// myVue/Compiler.js
import { MyVue } from "."
export class Compiler {
    constructor(vm) {
        this.el = vm.el
        this.data = vm.data

        // 遍历元素
        this.walk(this.el)
    }

    walk(rootNode) {
        for (let i = 0; i < rootNode.children.length; i++) {
            const reg = /\{\{\s*(\w+)\s*\}\}/
            const prop = rootNode.children[i].textContent.match(reg)[1]
            
            // 在执行getter前先在"全局变量"中保存下即将访问的节点
            MyVue.target = rootNode.children[i]
            // 这个地方触发getter时,MyVue.target正好就是访问属性的那个节点
            rootNode.children[i].textContent = rootNode.children[i].textContent.replace(reg, this.data[prop])
            MyVue.target = null // getter中收集完节点以后让"全局变量"为null
        }
    }
}
js 复制代码
// myVue/index.js
import { Compiler } from "./Compiler"

export class MyVue {
    static target = null
    constructor(options) {
        ...
    }
    ...
    defineReactive(obj, key, value) {
        const subs = []
        Object.defineProperty(obj, key, {
            configurable: true,
            enumerable: true,
            get() {
                console.log('触发了getter')
                MyVue.target && subs.push(MyVue.target)
                console.log(subs)
                return value
            },
            ...
        })
    }
}

可以看到我们已经把节点收集起来了,甚至我们只需要简单地在setter中更新节点,就可以完成"数据改变后,节点的响应式的更新"。

js 复制代码
// myVue/index.js
export class MyVue {
    ...
    defineReactive(obj, key, value) {
        const subs = []
        Object.defineProperty(obj, key, {
            ...
            set(newValue) {
                if (newValue === value) return
                console.log('触发了setter')
                subs.forEach(node => {
                    node.textContent = newValue
                })
                value = newValue
            }
        })
    }
}

Watcher

虽然刚才已经在setter中简单地实现了节点的更新,但这种方式太过死板了。数据更新的逻辑完全可以收回到节点自身中:

js 复制代码
// myVue/Compiler.js
import { MyVue } from "."
export class Compiler {
    constructor(vm) {
        ...
    }

    walk(rootNode) {
        for (let i = 0; i < rootNode.children.length; i++) {
            const reg = /\{\{\s*(\w+)\s*\}\}/
            const prop = rootNode.children[i].textContent.match(reg)[1]
            
            rootNode.children[i].watcher = {
                update(newValue) {
                    rootNode.children[i].textContent = newValue
                }
            }
            MyVue.target = rootNode.children[i]
            rootNode.children[i].textContent = rootNode.children[i].textContent.replace(reg, this.data[prop])
            MyVue.target = null
        }
    }
}
js 复制代码
// myVue/index.js
    ...
    defineReactive(obj, key, value) {
        const subs = []
        Object.defineProperty(obj, key, {
            ...
            set(newValue) {
                if (newValue === value) return
                console.log('触发了setter')
                subs.forEach(node => {
                    node.watcher.update(newValue)
                })
                value = newValue
            }
        })
    }
    ...

写到这里可以发现,收集过程中其实也没必要去收集整个节点,只需要收集watcher就可以了:

js 复制代码
// myVue/Compiler.js
import { MyVue } from "."
export class Compiler {
    constructor(vm) {
        ...
    }

    walk(rootNode) {
        for (let i = 0; i < rootNode.children.length; i++) {
            ...
            rootNode.children[i].watcher = {
                update(newValue) {
                    rootNode.children[i].textContent = newValue
                }
            }
            MyVue.target = rootNode.children[i].watcher // 只收集watcher
            rootNode.children[i].textContent = rootNode.children[i].textContent.replace(reg, this.data[prop])
            MyVue.target = null
        }
    }
}
js 复制代码
// myVue/index.js
    ...
    defineReactive(obj, key, value) {
        const subs = []
        Object.defineProperty(obj, key, {
            ...
            set(newValue) {
                if (newValue === value) return
                console.log('触发了setter')
                subs.forEach(watcher => {
                    watcher.update(newValue) // 此时已不再需要通过节点来访问到watcher了
                })
                value = newValue
            }
        })
    }
    ...

还可以更进一步,watcher没有必要绑定到节点身上,同时触发getter收集机制的逻辑也可以从节点上分离,收纳到watcher里:

js 复制代码
// myVue/Watcher.js
import { MyVue } from "."

export class Watcher {
    constructor(data, prop, cb) {
        this.data = data
        this.cb = cb

        MyVue.target = this
        // 访问属性触发getter,收集依赖
        this.data[prop]
        MyVue.target = null
    }

    update(newValue) {
        this.cb(newValue)
    }
}
js 复制代码
// myVue/Compiler.js
import { Watcher } from "./Watcher"
export class Compiler {
    ...

    walk(rootNode) {
        for (let i = 0; i < rootNode.children.length; i++) {
            const reg = /\{\{\s*(\w+)\s*\}\}/
            const prop = rootNode.children[i].textContent.match(reg)[1]

            new Watcher(this.data, prop, (newValue) => {
                rootNode.children[i].textContent = newValue
            })
            rootNode.children[i].textContent = rootNode.children[i].textContent.replace(reg, this.data[prop])
        }
    }
}

这样看起来又舒服了很多。

结语

旅途到这里就结束了,过程非常短暂,内容不算多,不过算是把想要表达的东西表达出来了,一个是数据拦截,另一个是依赖收集。从规范的角度来说,子节点的响应式逻辑没做,数组的响应式逻辑也没做,也没有什么Observer,因为把这些东西加上的话,又涉及一些递归,整个过程会复杂很多,篇幅会膨胀很多,那样就会掩盖掉真正的重点,想想还是放弃了,有兴趣的朋友可以自行尝试。文中若有不足之处,还望指正,多谢。

相关推荐
鱼樱前端2 分钟前
今天介绍下最新更新的Vite7
前端·vue.js
coder_pig44 分钟前
跟🤡杰哥一起学Flutter (三十四、玩转Flutter手势✋)
前端·flutter·harmonyos
万少1 小时前
01-自然壁纸实战教程-免费开放啦
前端
独立开阀者_FwtCoder1 小时前
【Augment】 Augment技巧之 Rewrite Prompt(重写提示) 有神奇的魔法
前端·javascript·github
yuki_uix1 小时前
AI辅助网页设计:从图片到代码的实践探索
前端
我想说一句1 小时前
事件机制与委托:从冒泡捕获到高效编程的奇妙之旅
前端·javascript
陈随易1 小时前
MoonBit助力前端开发,加密&性能两不误,斐波那契测试提高3-4倍
前端·后端·程序员
小飞悟1 小时前
你以为 React 的事件很简单?错了,它暗藏玄机!
前端·javascript·面试
中微子1 小时前
JavaScript 事件机制:捕获、冒泡与事件委托详解
前端·javascript
Whoisshutiao2 小时前
网安-XSS-pikachu
前端·安全·网络安全