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