以前面试官问我:请说一下vue的响应式是怎么实现的? 我答:用
Object.defineProperty
的原理实现的。 答案到此结束,我知道面试官再继续往下问,我就蒙了。
在vue源码学习10: 将虚拟dom创建成真实dom一文中,实现了如何将虚拟Dom生成真实Dom。接下来核心问题来了,在script
中改变数据,视图是如何发生变化的?
先来看一下vue的代码和实现的结果:
<div id="app">
hello({{name}})word
</div>
<script src="dist/vue.js"></script>
<script>
let vm = new Vue({
el: '#app',
data() {
return { name: '阿飞', age: 11 }
}
});
setTimeout(() => {
vm.name = '1s后变成程序员'
}, 1000)
</script>
在这里,data中定义了一个name阿飞
,在视图中引用了name,渲染到页面。
今天要学习的,就是当我1s之后,通过vm.name修改成1s后变化才能程序员
。页面要同步变化。
回顾:页面如何渲染真实Dom的
在之前的vue源码学习(8-10)中,实现了从ast到render字符串,然后从render字符串生成虚拟Dom,再由虚拟Dom生成真实的Dom。
而生成真实Dom,在lifecycle.js中有这样一段代码
export function mountComponent(vm, el) {
// 数据变化后,会再次调用更新函数
let updateComponent = () => {
vm._update(vm._render())
}
updateComponent()
}
vm._update(vm._render())
就是把虚拟Dom渲染到页面上的方法,也就是说,我们在1s之后,调用这个方法就可以了。
setTimeout(() => {
// 注意:重新调用render方法,并没有diff算法
vm.name = '1s后变成程序员'
vm._update(vm._render())
}, 1000)
然而,我们并不应该在每次发生更新的时候,都让用户来调用vm._update(vm._render())
。这件事情,需要在数据发生变化的时候,自动去执行。
在源码之前先谈两个概念
1. 观察者模式
当对象间存在一对多关系时,则使用观察者模式(Observer Pattern)。比如,当一个对象被修改时,则会自动通知依赖它的对象。观察者模式属于行为型模式。
2. 依赖收集
Vue能够实现当一个数据变更时,视图就进行刷新,而且用到这个数据的其他地方也会同步变更;
而且,这个数据必须是在有被依赖的情况下,视图和其他用到数据的地方才会变更。
所以,Vue要能够知道一个数据是否被使用,实现这种机制的技术叫做依赖收集
。
解释一下这张图:
- Component生成虚拟Dom,每一个组件都有一个watcher,页面渲染时,会把属性记录为依赖。
- 当script中,操作属性是,触发setter,此时则通知watcher,触发组件的重新渲染。
那么问题来了:
- 在Vue的依赖收集中,谁是观察者?
- 谁是被观察者?
- 每个组件是如何做到观察数据变化的?
带着这几个问题,我继续深入的学习源码。
响应式源码实现
在vue中,实现了三个类。
-
Observer类:它负责将数组、对象转换成可观测的类。(这个在之前的# 探讨vue2.x的数据劫持是怎么实现的?一文中有详细学习)
-
Dep (dependent的缩写):它扮演被观察者,每一个数据都有一个dep,dep里面有一个subs队列,存放
watcher
,当这一个数据发生变化时,就是通过dep.notify()通知对应的watcher重新渲染 -
Watcher:它扮演的是观测者,每一个组件都有一个watcher,每一个watcher内部都有一个deps,保存着这个watcher观测的所有数据。
由此可见:可以看做deps和watcher是多对多的关系。deps中有多个watcher(一个属性会被多个组件使用),watcher中会有多个deps(一个组件使用多个数据,都需要监听)
Watcher类
// 一个组件对应一个watcher
let id = 0;
import { popTarget, pushTarget } from './dep';
class Watcher {
constructor(vm, exprOrFn, cb, options) {
this.vm = vm
this.exprOrFn = exprOrFn
this.cb = cb
this.options = options
this.id = id++ // 给watcher添加标识
// 默认应该执行exprOrFn
// exprOrFn 做了渲染和更新
// 方法被调用的时候,会取值
this.getter = exprOrFn
this.deps = []
this.depsId = new Set()
// 默认初始化执行get
this.get()
}
get() {
pushTarget(this) // Dep的target就是一个watcher
/* 创建关联
* 每个属性都可以收集自己的watcher
* 希望一个属性可以对应多个watcher
* 一个watcher可以对应多个属性
*/
// 稍后用户更新的时候可以重新调用get方法
this.getter()
popTarget() // 这里去除Dep.target,是防止用户在js中取值产生依赖收集
}
update() {
this.get()
}
addDep(dep) {
let id = dep.id
if (!this.depsId.has(id)) {
this.depsId.add(id)
this.deps.push(dep)
dep.addSub(this)
}
}
}
export default Watcher
简单的对上述的类的功能进行一下整理:
-
每一个组件对应一个watcher,所以
需要一个id来记录
watcher的唯一性。每次new watcher实例的时候,生成一个新的id -
popTarget, pushTarget:这两个方法是Dep类中暴露出来的方法,在下面
Dep类
内容中可以看到,他们的作用分别是:把Dep上挂载的target赋值成当前的watcher 以及 把Dep上挂载的target赋值为null。
get() {
pushTarget(this)
this.getter()
popTarget()
}
javascript是一个单线程的语言,这里的做法是,当get方法被调用的时候,会执行pushTarget,然后渲染页面,然后在去除这个target上的watcher对象。
之所以要去除这个对象,是因为要防止用户在js中取值产生依赖收集。
再看这张图片
render的时候,只有getter才会产生依赖收集,不会在setter的时候收集。
- addDep:这个方法被Dep类使用,每new一个Dep的时候,都要存放到对应的watcher中。depsId是一个set,用来去重,因为一个相同的变量在视图中使用多次,只需要做一个依赖收集。
addDep(dep) {
let id = dep.id
if (!this.depsId.has(id)) {
this.depsId.add(id)
this.deps.push(dep)
dep.addSub(this)
}
}
Dep类
// 一个属性对应一个dep,做属性收集
let id = 0
class Dep {
// 每一个属性都分配一个Dep,每一个Dep可以存放watcher,watch中要存放Dep
constructor() {
this.id = id++
this.subs = [] // 用来存放watcher
}
depend() {
// Dep.target dep里面要存放这个watcher watcher一样要存放dep
if (Dep.target) {
// 把dep给watcher,让watcher存放dep
Dep.target.addDep(this)
}
}
addSub(watcher) {
this.subs.push(watcher)
}
notify() {
this.subs.forEach(watcher => {
watcher.update()
})
}
}
Dep.target = null
export function pushTarget(watcher) {
Dep.target = watcher
}
export function popTarget() {
Dep.target = null
}
export default Dep
-
id:和watcher类一样,id也是为了dep的唯一性。
-
depend:在Observer类中,get方法被触发的时候,会调用depend方法。这个方法,把dep实例给watcher,让watcher存放当前这个dep实例。
-
addSub:向subs队列存放watcher
-
notify:发送变更的消息。当这个变量发生变化的时候,subs队列中的每一个watcher都要接受到变更消息,重新渲染页面
-
Dep.target 作为一个静态变量,所有的dep实例公用,pushTarget和popTarget的作用在Watcher类中已经介绍了。
什么时候对watcher进行实例化?
在前文中说过,每一个组件都有一个watcher
所有,组件挂载的时候就会有一个new watcher的过程。
lifecyle.js中的代码做如下修改:
export function mountComponent(vm, el) {
let updateComponent = () => {
// 1. 通过render生成虚拟dom
vm._update(vm._render()) // 后续更新可以调动updateComponent方法
// 2. 虚拟Dom生成真实Dom
}
// 观察者模式:属性是被观察者 刷新页面:观察者
// 如果属性发生变化,就调用updateComponent方法
// 每一个组件都有一个watcher
new Watcher(vm, updateComponent, () => {
// true 告诉他是一个渲染过程
// 后续还有其他的watcher
}, true)
}
何时收集依赖,何时同时重新渲染页面?
在observer类中,我们对对象进行遍历,将每个属性用defineProperty重新定义,对数据进行了全量劫持
因此,每一个属性发生变化和被使用的时候,都会触发get和set方法。核心代码如下
function defineReactive(data, key, value) {
// value 有可能是对象(对象套对象),递归劫持
observe(value)
let dep = new Dep()
Object.defineProperty(data, key, {
get() {
console.log('key', key)
// 取值时候我希望将watcher和Dep关联起来
// 但是这里没有watcher
if (Dep.target) {
// 说明这个get是在模板中使用的
// 让dep记住watcher,依赖收集,它是一个依赖收集器
dep.depend()
}
return value
},
set(newV) {
if (newV !== value) {
observe(newV)
// 如果用户赋值的是一个新对象,需要将这个对象进行劫持
value = newV
dep.notify() // 通知当前的属性存放的watcher执行
}
}
})
}
依赖收集:
// 当数据被页面渲染调用的时候,进行依赖收集
get() {
if (Dep.target) {
dep.depend()
}
return value
}
通知变化:
set(newV) {
// 如果新的值和旧的值不一致的话,则重新渲染页面
if (newV !== value) {
...
dep.notify() // 通知当前的属性存放的watcher执行,重新渲染页面
}
}
好了,今天的学习就到此结束了,很期待下一次的学习。