实现一个简单的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,因为把这些东西加上的话,又涉及一些递归,整个过程会复杂很多,篇幅会膨胀很多,那样就会掩盖掉真正的重点,想想还是放弃了,有兴趣的朋友可以自行尝试。文中若有不足之处,还望指正,多谢。

相关推荐
文火冰糖的硅基工坊3 小时前
[嵌入式系统-146]:五次工业革命对应的机器人形态的演进、主要功能的演进以及操作系统的演进
前端·网络·人工智能·嵌入式硬件·机器人
2401_837088503 小时前
ResponseEntity - Spring框架的“标准回复模板“
java·前端·spring
yaoganjili3 小时前
用 Tinymce 打造智能写作
前端
angelQ3 小时前
Vue 3 中 ref 获取 scrollHeight 属性为 undefined 问题定位
前端·javascript
Dontla4 小时前
(临时解决)Chrome调试避免跳入第三方源码(设置Blackbox Scripts、将目录添加到忽略列表、向忽略列表添加脚本)
前端·chrome
我的div丢了肿么办4 小时前
js函数声明和函数表达式的理解
前端·javascript·vue.js
云中雾丽4 小时前
React.forwardRef 实战代码示例
前端
朝歌青年说4 小时前
一个在多年的技术债项目中写出来的miniHMR热更新工具
前端
武天4 小时前
一个项目有多个后端地址,每个后端地址的请求拦截器和响应拦截器都不一样,该怎么封装
vue.js
Moonbit4 小时前
倒计时 2 天|Meetup 议题已公开,Copilot 月卡等你来拿!
前端·后端