Vue中的双向绑定
这篇文章用简化的Vue模型和双向绑定基本原理进行讲解,最后会放上Demo代码供大家调试,
要理解双向绑定并不难,看过双向绑定基本原理的同学都可以说出个一二,但很多人都处于:"哇,Object.defineProperty这么神奇,学到了学到了!",但是具体Vue是怎么把它和组件渲染关联的,就有些懵逼。笔者之前也处于这种状态,深有体会,什么?你说你没有这种感觉?!那没事了……
所以这篇文章不会深入教你双向绑定基本原理到底是什么,而是默认你对双向绑定有一定的了解,想了解它和Vue渲染过程是如何关联,希望在这里可以让你学习到一点东西
如果一点都没有了解过双向绑定的,可以戳这里 Vue双向绑定基本原理
第一步
让我们先定义一个超级简化的VueClass类来模拟Vue,为了专注双向绑定这个实现逻辑,这个类的结构非常简单
- 拥有一个
render函数(外部传入) - 拥有一个更新组件的函数
updateComponent
这里为什么有一个render函数?首先我们要知道render函数做了什么和双向绑定息息相关的事情。
如果你不熟悉render,那你只要写过Vue就一定知道<template></template>,这是Vue中定义的,你的浏览器并不能识别template标签,Vue提供了compiler去编译<template></template>,最终会生成render函数,所以最后在浏览器执行的,就是这个render函数
class VueClass {
constructor(options) {
// 只关心data
this.data = options.data
// Vue的编译器会把模板template编译成render函数
this.render = options.render
}
// 更新组件,可以理解为是VueClass的渲染函数
updateComponent() {
// 构建VNode树(不理解VNode树没关系,不影响双向绑定理解)
this.render()
// ...
// 省略挂载Dom的操作
}
}
大家在使用模板编写代码时,会绑定一些数据上去,比如下面这样:
<template>{{msg}}</template>
要想展示msg,就得获取它的值,所以render函数被调用时会获取到被绑定到模板中的变量!!
现在不理解没关系,现在大家先记住这一点,render函数被调用时会获取到被绑定到模板中的变量!!,这对理解双向绑定很重要。这也是我要在简化模型中再定义一个render的原因,之后我们会模拟render获取模板变量的过程。
第二步:双向绑定基本原理
好了,上面的代码先放在一边,下面上关键性代码observe的实现,这里我们只考虑target是对象的情况,对于数组,Vue做了额外的一些处理,这里暂不提及。
代码逻辑也不复杂,递归对target对象的每一个属性进行配置,拦截它的getter和setter,这样我们就可以在属性访问或属性赋值的时候对它为所欲为进行一些其他操作了(比如渲染)
是的,这一切是不是都这么熟悉,当你访问一些属性进行修改时,会触发该属性的setter函数,如果这时候可以插入一个渲染逻辑重新渲染页面,那这不就是双向绑定吗!
function observe(target) {
if(target && typeof target !== 'object') return
Object.keys(target).forEach(key => {
defineReactive(target, key, target[key])
})
}
function defineReactive(obj, key, val) {
// 对子属性进行递归, 这里不需要判断参数是否合法,判断在observe内部进行,如果val不是对象,则不会进行任何操作
observe(val)
Object.defineProperty(obj, key, {
// 可枚举
enumerable: true,
// 可配置
configurable: true,
get() {
// 当属性被访问时触发
return val
},
set(value) {
// 当属性被赋值时触发
val = value
}
})
}
但是,具体要怎么实现呢,Vue采用了经典的观察者模式,设计模式这一块推荐一发修言大大的掘金小册,请大家支持正版,知识是无价的,修大观察者模式举的例子真的太生动了!!i了i了。
我们需要借助两个类, Dep和Watcher,如果你要问为啥要两个类,那就来个套公式大法:既然是观察者模式了,那肯定有订阅者和发布者啊,先不管Dep和Watcher对应哪个,总之要两个就对了!
观察者模式
不理解观察者模式,别急,借一下修大的例子,Dep是产品经理,Watcher是苦逼程序员。
现在公司开了一个新项目,这个项目就是要"渲染页面",但是具体的需求还没出(还没有人改变数据),于是产品经理(Dep)预先创了一个“xx2077迭代”群,既然Dep是群主,他当然拥有拉人进群的权限,把干活的人(Watcher)拉进群之后,可能群里就没人说话了,因为需求还没定下来,大家可以去干自己的事,互不打扰。
突然某一天,需求文档出了,要按照文档(客户修改了数据)重新渲染页面,产品经理(Dep)立马通知大家,“2077需求文档终于出了!大家来干活吧,加油打工人!”于是大家(Watcher)开始了手头的工作……
这里拉人进群的操作就是添加订阅者,在群里发布需求更新文档,就是发布消息,订阅者们就开始干活。这就是观察者模式
这里为了简化逻辑,Watcher类只举例渲染用的Watcher,啥意思呢,就是这个Watcher只会干一件事,那就是渲染页面,所以我们也可以叫它渲染Watcher,实际Vue源码中是传入了一个回调函数(可以干任何事),不理解的我之后会有一小段代码来说明
// 产品经理Dep类
class Dep {
constructor() {
// 创建一个群
this.deps = []
}
// 拥有拉人进群的权限, sub是一个Watcher
addSub(sub) {
this.deps.push(sub)
}
// 通知大家干活啦
notify() {
for(let i=0,j=this.deps.length; i<j; i++) {
// 这个群的每个打工仔都得干活
this.deps[i].update()
}
}
}
// 苦逼程序员Watcher类
class Watcher {
// 初始化,传入一个VueClass实例,为啥要传?打工人肯定得知道老板是谁啊,不然谁发工资?
constractor(vm) {
this.vm = vm
}
// 打工人开始干活
update() {
// 我是渲染Watcher,要干活了!(渲染)
}
}
打工仔Watcher
现在我们有三个类VueClass、Dep、Watcher,以及一个黑科技函数observe
setter
上面的例子中,产品经理Dep是有感知需求文档变化(客户修改数据)的能力的,于是,我们把它放到对应属性的setter中,只要修改了数据,就会调用setter,产品经理Dep就可以感知变化,然后发送通知,大家就开始干活
属性数据修改 ->> setter被触发!! ->> 调用deps.notify 通知所有Watcher
现在我们要改造一下defineReactive,我们之前定义了每个属性的getter和setter,我说过是为了做拦截,现在让我们为所欲为一下
function observe() {
//... 没有改变
}
function defineReactive(obj, key, val) {
// 把每个属性都当一个项目组,每个项目组都安排一个产品经理
let deps = new Dep()
observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
// Dep.target是全局性的,值是当前正在等待入群的Watcher
if(Dep.target) {
deps.addSub(Dep.target)
}
return val
},
set(value) {
// 稍微做个简单的判断,如果值没改变,不做任何操作
if(val === value) return
val = value
// **当该属性值被更新时,通知所有打工仔干活**
deps.notify()
}
})
}
class Watcher {
constructor(vm) {
this.vm = vm
Dep.target = this
// 初始化Watcher的时候先调用一次渲染函数
this.vm.updateComponent()
// 添加完成,划掉这个名字
Dep.target = null
}
update() {
// 这个打工仔专门干渲染的事
this.vm.updateComponent()
}
}
getter
这里为什么要在Watcher初始化的时候调用渲染函数,还记得我之前说过,render函数内部会获取到被绑定到模板中的变量!!,Watcher的初始化步骤就是这样的:
初始化Watcher对象 ->> vm.updateComponent ->> vm.render [拿到被绑定的值] ->> 触发getter!!! ->> 把Watcher加入项目组
这就是Vue巧妙的地方,通过触发对象属性的getter来进行一系列操作,至于在Watcher初始化的时候调用渲染函数是否合理,当然不能每个Watcher都这样做,不然浏览器疯狂渲染还得了,我上面也说了,我们这个例子里的Watcher只干一件事,那就是渲染,而Vue的渲染Watcher是在组件初始化的时候被new出来的,也就是说,Vue定义了渲染的函数,但是不会自己去调用,而是全权交给干渲染活的打工仔渲染Watcher来调用,第一次渲染是这样,之后触发的渲染也是这样。
这么说不太直观,我举个其他Watcher的例子,我们例子里的Watcher把工作内容定死了,但实际上,在初始化Watcher的时候会传递一个exp来描述他需要关注的工作对象是谁,回调函数cb来说明他的工作内容是什么
// exp来说明绑定值的对象
// cb说明工作内容(触发watcher后要干什么)
class WatcherOther {
constructor(vm, exp, cb) {
this.vm = vm
this.exp = exp
this.cb = cb
this.val = this.get()
}
get() {
Dep.target = this
// 和渲染watcher一样 获取exp这个属性对应的值,触发它的getter,把watcher添加进对应的Dep
let value = this.vm.data.exp
Dep.target = null
return value
}
update() {
this.cb()
}
}
我希望大家能理解我把Watcher类直接简化为渲染Watcher类的意义,我不想在Watcher中添加一些回调逻辑,这样对理解双向绑定的核心没有任何意义,大家只要知道我们现在使用的Watcher是专门用来渲染的就好
其实到这里,整个流程已经基本完成了,但如果要把demo跑起来让他可以允许,还剩最后一件事: 伪造render函数内部获取模板参数的操作
拿之前的代码做🌰,假设有这样的模板函数,把它放到编译器里,它会生成一个render函数
<template>{{msg}}</template>
<script>
let vm = new VueClass({
data: {
msg: 'hello 2077'
}
})
</script>
模板会被编译成一个render函数,就是这么简单,最后会这样创建一个VueClass实例:
let vm = new VueClass({
data: {
msg: 'hello 2077'
},
render() {
let msg = this.data.msg
console.log(`开始生成虚拟节点,发现用户使用了${msg}!`)
}
})
上面的代码都是直接手写的,没有测试,只理了逻辑,下面上完整代码,大家可以copy下来自己跑一下:
class VueClass {
constructor(options) {
this.data = options.data;
// Vue的编译器会把模板template编译成render函数
this.render = options.render;
}
// 更新组件,可以理解为是VueClass的渲染函数
updateComponent() {
// 构建VNode树(不理解VNode树没关系,不影响双向绑定理解)
this.render();
// ...
// 省略挂载Dom的操作
}
}
function observe(target) {
if (target && typeof target !== "object") return;
Object.keys(target).forEach((key) => {
defineReactive(target, key, target[key]);
});
}
function defineReactive(obj, key, val) {
// 把每个属性都当一个项目组,每个项目组都安排一个产品经理
let deps = new Dep();
observe(val);
Object.defineProperty(obj, key, {
// 可枚举
enumerable: true,
// 可配置
configurable: true,
get() {
// Dep.target是全局性的,值是当前正在等待入群的Watcher
if (Dep.target) {
deps.addSub(Dep.target);
}
return val;
},
set(value) {
// 稍微做个简单的判断,如果值没改变,不做任何操作
if (val === value) return;
val = value;
// **当该属性值被更新时,通知所有打工仔干活**
deps.notify();
},
});
}
// 产品经理Dep类
class Dep {
constructor() {
// 创建一个群
this.deps = [];
}
// 拥有拉人进群的权限, sub是一个Watcher
addSub(sub) {
this.deps.push(sub);
}
// 通知大家干活啦
notify() {
for (let i = 0, j = this.deps.length; i < j; i++) {
// 这个群的每个打工仔都得干活
this.deps[i].update();
}
}
}
// 苦逼程序员Watcher类
class Watcher {
// 初始化,传入一个VueClass实例,为啥要传?打工人肯定得知道老板是谁啊,不然谁发工资?
constructor(vm) {
this.vm = vm;
Dep.target = this;
// 初始化Watcher的时候先调用一次渲染函数
this.vm.updateComponent();
// 添加完成,划掉这个名字
Dep.target = null;
}
// 打工人开始干活
update() {
// 这个打工仔专门干渲染的事
this.vm.updateComponent();
}
}
// 开始测试
let vm = new VueClass({
data: {
msg: "hello 2077",
other: "other text"
},
render() {
console.log(`我拿到了this.data.msg,值为${this.data.msg},开始生成VNode树`);
},
});
// 监听vm.data
observe(vm.data);
// 创建vm实例的渲染Watcher,这个过程会自动开始第一次渲染
new Watcher(vm);
// 修改data的属性值 看看会发生什么吧!
vm.data.msg = "2077又跳票了";
vm.data.other = "other text changed"
Amazing!! vm.data.msg改变的时候,触发了render函数,但是vm.data.other改变却没有触发,为什么呢,秘密就在于vm.data.other并没有被绑定到模板上,也就是说 render函数中并没有出现过vm.data.other,那么它的getter不会被触发,自然就不会为它添加Watcher
如果你看完了这段代码,稍微思考一下可能会有几个疑问
-
为什么要给每个属性值new一个
Dep,在例子里每个Dep里最多也只有一个Watcher,也完全没必要用数组去储存Watcher答:因为例子里是只有一个渲染Watcher,但是实际情况下用户还会对一些属性手写watch监听(你肯定干过),这时候就要为这个属性新增一个Watcher了,当值发生改变时,会调用Deps.notify依次触发多个Watcher
-
如果再往模版上绑定一个数据,对它进行修改,渲染
Watcher不就会多次触发渲染吗答:如果你能想到这个问题,恭喜你基本已经完全理解Vue双向绑定原理了。在我们的例子里,绑定多个数据,确实是多次触发render,并且同一个数据在一个tick内多次改变值,也会触发多次render。这个很好解决,Vue也针对于这一块做了很多优化,在一个tick内,你可以使用map来保存触发过的Watcher id,如果发现有重复的id出现,不再重复触发。
-
渲染Watcher和普通Watcher除了干的活不一样,还有什么其他区别吗?
答: 有! 渲染Watcher会自动添加到每一个被render函数调用过的属性所对应的deps里,而普通Watcher只会被添加到指定的绑定属性上
其实双向绑定这一块,Vue还做了其他优化,这里不一一举例,如果感兴趣,强烈推荐自己去阅读源码,或许你在阅读时你会很痛苦,但是痛苦过后,你会发现自己有质地的提升。
最后,希望这片文章能帮到你,哪怕一点点,本人文笔有限,已经尽最大的力来表达了,如果有写错或逻辑写的不清晰的地方,欢迎大家指出,共同进步!
愿大家心中有火,眼里有光!