-
Notifications
You must be signed in to change notification settings - Fork 14
Description
本文将把vue 2.x的响应式原理及其依赖收集进行分析讲解,并简单用代码模拟一遍vue这一部分的源码。
(此处及之后的vue泛指2.x版本的vue)
绝大部分人都知道vue2.x的响应式依赖一个APIObject.defineProperty,通过该API可以劫持属性的获取(get)/赋值(set),在回调中进行更新视图,从而达到由数据驱动视图去更新。
但要实现响应式,细节上还需要更多,譬如:
-
属性被set之后,具备什么样的条件才能更新视图。
-
上述第一点满足后,框架如何知道具体要更新哪一个视图组件。
-
如何解决多个属性触发同一个组件更新的情况。
-
...
抛出上述问题之后,vue的做法是这样的:
- 引入依赖收集机制:递归遍历组件状态
data()之后,每个属性key作为一个依赖,实例化一个名为Dep的依赖对象const dep = new Dep(),并用Object.defineProperty劫持get/set,- 视图渲染过程中触发属性
getter,在getter的回调中收集其对应的依赖dep - 主动
set属性时,在属性的setter回调中,其对应dep通知所有收集到它的对象。
- 视图渲染过程中触发属性
- 设立一个对象Watcher,进行上述依赖的收集和管理。该
watcher对应到每一个组件,Vue把其称为render watcher。属性被set之后,依赖对象dep就会通知将它收集的watcher,由watcher进行更新视图。
引用vue官方的一张图展示这一个过程:
首先组件render的时候,渲染在视图中的状态都会触发其getter,然后组件对应的Watcher在getter回调中将其作为依赖进行收集。当状态发生变化后,通知notify收集其依赖的Watcher,然后Watcher进行更新,触发组件的重新render。
接下来由代码来讲述这整个流程,基本就是vue源代码的简化版,省略了大部分的变量校验和与本文主题无关的代码。想仔细研究完整版本请自行查阅源码。
状态属性的getter/setter
首先我们要拿到组件中声明的状态,默认规定是一个工厂函数,返回一个object,这样就可以保证每次根据配置实例化的状态,不会指向同一份状态。拿到状态data后,我们就进行观察劫持。
// 获取data 并保留一份指引在实例上,即this._data
const data = vm._data = vm.$option.data.call(vm)
// 将状态代理到实例上,就可以通过this.xxx获取
// 源码中是将 this.xxx 代理到 this._data.xxx
const keys = Object.keys(data)
let i = keys.length
while (i--) {
const key = keys[i]
proxy(this, '_data', key)
}
// 然后进行递归的observe
observe(data)下面则是进行代理的proxy方法实现
// 将状态代理到目标上
function proxy (source, sourceKey, k) {
Object.defineProperty(source, k, {
enumerable: true,
configurable: true,
get: function () {
return this[sourceKey][k]
},
set: function (val) {
this[sourceKey][k] = val
}
})
}接着讲解的是observe(data),这里要完成的就是递归进行劫持。源码中整个流程:observe(data)->new Observer(data)->walk(data)->defineReative(data, key)。当中涉及到数组和普通对象的处理,以及是否需要劫持的判断,故在此不作展开。简化版如下,并不影响我们的逻辑分析:
function observe (obj) {
const keys = Object.keys(obj)
for (const key of keys) {
const dep = new Dep()
// 保存对象-Key的取值
let val = obj[key]
// 递归劫持
if (Object.prototype.toString.call(val) === 'object') {
observe(val)
}
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function () {
// 进行依赖收集
if (Dep.target) {
dep.depend()
}
return val
},
set: function (newVal) {
// vue此处做了优化,如果值没变化,则不会通知watcher
if (newVal === val) return
// 变化之后需要再次赋值
val = newVal
// 由依赖进行通知
dep.notify()
}
})
}
}依赖 Dep
这是递归遍历data时,为每一个key值实例化的类,一个key对应一个dep。首先看下Dep类的简单实现:
let depId = 0
class Dep {
// 静态属性,类型是Watcher
static target;
constructor () {
this.id = depId++
this.subs = []
}
// 添加订阅者,当属性发生变化,可以透过属性去查找其对应的watcher
addSub (sub) {
this.subs.push(sub)
}
// 移除订阅
removeSub (sub) {
const index = this.subs.findIndex(sub)
if (index !== -1) {
this.subs.splice(index, 1)
}
}
// 依赖收集
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
// 广播更新
notify () {
this.subs.forEach(sub => {
sub.update()
})
}
}
Dep.target = null有两个设计点:
- 内部维护了一个订阅数组,一个属性不仅可以被
render watcher收集,也可以被user watcher收集,即用户自己编写的watch选项,等等。 - 维护了一个静态属性
target,存放当前进行render的watcher。虽然说vue的更新是异步的,但是这个异步只是相对于改变状态的操作而言,对于模版/render方法渲染单个组件的过程依然是js同步进行的。所以全局同一时候只会有一个watcher进行更新,更新完当前的watcher,再将新的watcher重新赋值到Dep.target。
vue使用了一个栈来维护当前的Dep.target,因为考虑到当前watcher更新时,可能会触发另一个watcher的更新渲染,需要对上一个watcher进行保留。
const targetStack = []
function pushTarget (target) {
targetStack.push(target)
Dep.target = target
}
function popTarget () {
targetStack.pop()
Dep.target = targetStack[targetStack.length - 1]
}而什么时候需要赋值当前的Dep.target,就在于Watcher的设计了。
Watcher
vue里面的Watcher,负责了三个功能:computed、用户自定义watcher、data状态的依赖收集,而前两者都有依赖于第三个依赖收集的机制。源码里的Watcher考虑到了更多的情景,这里只针对依赖收集的那一部分代码进行简单实现:
let watchId = 0
class Watcher {
constructor (vm, expOrFn) {
this.id = watchId++
this.vm = vm
// 这里是获取值的回调,也可以穿入render/update方法
this.getter = expOrFn
// 用于处理依赖
this.deps = []
this.newDeps = []
this.depIds = new Set()
this.newDepIds = new Set()
// 实例化时,会执行一遍“获取”的get函数
this.value = this.get()
}
// 收集依赖
addDep (dep) {
const id = dep.id
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
dep.addSub(this)
}
}
}
// 清理依赖
cleanupDeps () {
let i = this.deps.length
while (i--) {
const dep = this.deps[i]
if (!this.newDepIds.has(dep.id)) {
dep.removeSub(this)
}
}
let tmp = this.depIds
this.depIds = this.newDepIds
this.newDepIds = tmp
this.newDepIds.clear()
tmp = this.deps
this.deps = this.newDeps
this.newDeps = tmp
this.newDeps.length = 0
}
// 获取watcher渲染的值,如果是render watcher,其返回的值不必用到,只会执行逻辑上的渲染
get () {
pushTarget(this)
const value = this.getter.call(this.vm)
popTarget()
return value
}
// 这里源码里是会启动一个异步队列,进行更新
update () {
Promise.resolve().then(() => {
this.get()
})
}
}watcher的实例化时机就在所有状态、事件、注入都初始化完了之后,DOM进行mount之前。那一刻data中的状态均已完成了响应式劫持的声明。
// 源码中大概的调用:
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)对照回往上watcher的声明,可以发现其实render watcher里,expOrFn函数就会被传递成一个render的函数。代表着,实例化watcher代码的最后执行this.get(),相当于都会执行一次传递过来的render。
紧接着我们来看get函数
get () {
pushTarget(this)
const value = this.getter.call(this.vm)
popTarget()
return value
}在这里,就把当前正在渲染watcher变成Dep.target,然后执行参数中传递过来的render,在render过程中就会触发data状态的get属性回调,并执行依赖收集。
addDep (dep) {
const id = dep.id
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
dep.addSub(this)
}
}
}依赖收集这里用了两对数组,一对是new开头的,分别存放id和实例的。另一对则不带new,意味着是原来的。
这样设计的原因是**每一次的render,模板中用到的状态可能会不一样。**e.g:v-if的状态由true改变成了false,并且v-if下的代码块中包含了声明的状态date。那么对比前后,第一次渲染的时候watcher收集到了date的依赖,但是状态改变之后,date的状态被v-if="false"包裹了,对于视图来说我们不需要收集这个依赖去更新了。
所以每一次更新我们都要重新清除cleanupDeps上一次收集过的依赖,赋值给新的依赖。就可以避免改变视图中没用到的状态,也会触发更新这个场景,可以说是一种优化。
实践 && 测试
结合了上述编写的简单Dep和Watcher,简单写一个Vue类试验一下。
class Vue {
constructor (option) {
this._option = option
this._el = document.querySelector(option.el)
this._template = this._el.innerHTML
this._initState(this)
new Watcher(this, function update () {
this._mount()
})
}
// 递归遍历data 初始化响应式
_initState () {
const data = this._data = this._option.data
? this._option.data() : {}
const keys = Object.keys(data)
let i = keys.length
while (i--) {
const key = keys[i]
proxy(this, '_data', key)
}
observe(data)
}
_mount () {
const _this = this
let template = _this._template
// 替换差值表达式
let matchText
while ((matchText = /\{\{((\w)+?)\}\}/.exec(template))) {
template = template.replace(matchText[0], _this._data[matchText[1]])
}
_this._el.innerHTML = template
}
}这里没有模拟virtual-dom,只模拟vue中响应式和依赖收集的场景。然后html中编写以下代码:
<body>
<div id="app">
<div>计数器:{{counter}}</div>
<div>当前时间戳:{{currentDate}}</div>
</div>
<button id="counter">增加</button>
<button id="timer">打印时间</button>
</body>
<script>
const app = new Vue({
el: '#app',
data () {
return {
counter: 1,
currentDate: Date.now()
}
}
})
const $ = sel => document.querySelector(sel)
$('#counter').onclick = () => {
app.counter++
}
$('#timer').onclick = () => {
app.currentDate = Date.now()
}
</script>试验也能成功

