前言
这是一个Vue源码学习系列。打算开个手写Vue的坑,希望能在写代码的同时能把其中的细节讲清楚,最终目的是实现一个简版的vue。不知道自己能写到哪一步,总之尽力而为。如果能完成的话,应该是自我超越了和无限的自信了。放个仓库的 传送门。
一、观察者模式
场景:最近因为快到暑假了,产品韩梅梅提前一个月在大象拉了一个需求群,把研发李雷、小明拉进了群。她跟两位研发说,“我们过几天要开发一个新需求,需要两位研发的支持,是关于暑假欢乐谷门票活动的需求,等产品逻辑梳理完,咱们就进入开发。”。一个礼拜之后,韩梅梅在群里发出了需求文档,两位研发开始加班加点干活,需求完美上线。
颇费特~ 好的,观察者模式讲完了。
纳尼?等等...等等...,您这是讲了个啥。
咳咳...不好意思,重新来。
我们看一下上面这个场景,它分了几步
- 产品韩梅梅通知两位研发过几天会有个需求让他们进行开发
- 几天后,韩梅梅通知研发要开始开发需求了
- 研发开始进行开发
总结下来,我们发现有两个角色,一个发布者(产品韩梅梅),一个观察者(李雷等研发),当发布者的状态更新后,会进行通知观察者,观察者开始执行对应的动作。
ok,让我们试着写一下
class Dep {
constructor(state) {
this.watchers = []
this.state = state
}
// 添加观察者(研发)
add(watcher) {
!this.watchers.includes(watcher) && this.watchers.push(watcher)
}
// 移除观察者(研发)
remove(watcher) {
let index = this.watcher.indexOf(watcher)
if (~index) this.watcher.splice(index, 1)
}
// 状态更新, 通知全部观察者
notify() {
for (let watcher of this.watchers) watcher.update(this)
}
}
class Watcher {
constructor(value) {
this.value = value
}
// 更新
update() {
console.log('开始开发!')
}
}
const HanMeiMei = new Dep()
const XiaoMing = new Watcher()
const LiLei = new Watcher()
// 拉群!
HanMeiMei.add(XiaoMing)
HanMeiMei.add(LiLei)
await new Promise(resolve => setTimeout(resolve, 7 * 24 * 60 * 60 * 1000, '一周过去了')))
// 过了一周开始通知研发开发
HanMeiMei.notify()
问:那如果换成Vue中的视图与数据之间的关系呢?哪个是个发布者,哪个是观察者。
答:显而易见,数据是发布者,视图是观察者。当数据改变时,会通知视图,视图重新进行渲染。
这里有几个问题
- What,数据都收集什么样的观察者
- How,数据怎么收集的观察者
- When,数据什么时候收集观察者
ok,带着这些问题咱们继续往下看
二、Vue中的观察者
1、What
首先,明确一点,Vue实例中的响应数据,几乎全部都来源于data,就是那个Option API中的data。不管是props,computed这些都是基于data的。
其次,Vue中的Watcher分为了三种,
- render Watcher,可以简单的理解为template;
- computed Watcher,在Vue文档中,说到过缓存这个概念,说白了其实就是计算属性的getter中用到的数据(data) 没有发生过变化,那么这个getter就不必重新计算,这个我认为是Vue响应式中最绕的,下面的源码重点讲一下
- watch Watcher,没错就是那个Option API中的watch,你想想你数据改了,watch是不是得再执行一遍,那不跟视图是一样的么
所以,what的答案就有了 数据收集了这三种观察者
2、How
说个面试的段子,面试官:vue怎么收集依赖的?
这个其实老生常谈,getter/setter的存储器嘛
诶,那你知道Array是怎么收集的吗?
知道知道,不就是hack的一些原生方法嘛
哦,那为什么要hack呢,咋hack的呢,hack了哪些呢,不同的方法之间又都是怎么处理的数据呢?
......
好了,回去等通知吧(一面挂)
这里信息量太大!关于为什么要hack方法,尤大是给出了回答的,主要原因是因为性能和使用方便之间的取舍,这篇文章有写道:segmentfault.com/a/119000001…
但是你要问我为啥数组附个值还能跟性能扯上关系,咱也不懂,咱也不敢问。
3、When
在vue实例化的时候,在beforeCreate和create之间,会有一个初始化数据的过程,这里会将data、computed全部初始化好,通过getter,哪里用到就在哪里收集观察者。
三、思路
1、先从getter/setter开始
先从转换数据开始,我们来简单实现一个,很简单就是迭代加递归,两个函数搞定。
// 我们先来实现第一个函数observe
function observe(data) {
if (typeof data !== 'object') return
for (let key of Object.keys(data)) {
defineReactive(data, key)
}
return data
}
// 然后是defineReactive
function defineReactive(data, key) {
let val = data[key]
const dep = new Dep()
observe(val)
Object.defineProperty(data, key, {
configurable: false,
enumerable: true,
get() {
dep.depend()
return val
},
set(newVal) {
if (val === newVal) return
val = newVal
observe(val)
dep.notify()
}
})
}
这里的逻辑很简单就是通过迭代+递归,将所有值都改为存取器。
这里注意defineReactive方法,我并没有直接把data[key] 的这个value通过参数传进去,而是在函数内部取值,之所以为什么做,这里先留一个悬念。
2、Dep
这里出现了一个class Dep,这里其实Dep就是来收集Watcher的。
好的,我们继续来实现Dep
class Dep {
constructor() {
this.watchers = new Set()
}
depend() {
if (Dep.Target) this.watchers.add(Dep.Target)
}
notify() {
let watchers = this.watchers
for (let watcher of watchers) {
watcher.update()
}
}
}
这里Dep的实现也很简单,就是收集watchers,使用Set确保watcher的唯一。
但是!这里又双叒出现了一个新的东西,Dep.Target。这东西是个啥,其实看代码也能差不多发现,Dep.Target肯定是个Watcher实例。
诶~,这么多Watcher实例它到底是哪个呢?
好问题!我们先想想一个场景,我们有个数据比如是data,我们还有个渲染函数,然后呢~这个渲染函数用到了这个data。
用到data了肯定就会触发data的getter,从而收集依赖,那我们要收集的依赖肯定就是这个渲染函数了。
相应的Dep.Target的值也就是这个渲染函数。
哒嘎! 你以为这样就结束了吗,No,No,No,嘛哒哒!
要是渲染函数里面还有个渲染函数咋整。
纳尼!还有这种操作吗!
有的,而且很多,当我们组件里面嵌入了组件的时候就会出现。
我去,那不是很常见吗!那可怎么办。
别慌,我们只要实现一个栈,有新的函数要执行,我们就push进来,当函数执行结束,给他pop出去就好了。
ok,那我们开始实现一下。
Dep.Target = null
const watcherStack = []
// 入栈
function pushTarget(watcher) {
Dep.Target = watcher
watcherStack.push(watcher)
}
// 出栈
function popTarget() {
watcherStack.pop()
Dep.Target = watcherStack[watcherStack.length - 1]
}
完美解决~ 那么最后剩下的的就是watcher的实现了。
3、Watcher
RenderWatcher
上面说过,watcher一共有三种,我们先实现最简单、最基础的renderWatcher。
class Watcher {
constructor(getter) {
this.getter = getter
this.value = undefined
this.value = this.get()
}
get() {
pushTarget(this)
this.getter()
popTarget()
}
update() {
this.value = this.get()
}
}
这里的逻辑很简单,参数getter就是要执行的函数。对于RenderWatcher来说getter就是渲染函数。
好的!万事具备,我们来试着跑个例子。
<body>
<div id="app"></div>
<script src="./reactive/reactive.js"></script>
<script>
const data = observe({
age: 12,
name: 'Sunyanzhe'
})
// 渲染函数
function renderFunction() {
document.querySelector('#app').innerHTML = `我叫${data.name}, 我${data.age}岁`
}
// renderWatcher
const renderWatcher = new Watcher(renderFunction)
setTimeout(() => {
data.age = 25
}, 2000)
</script>
</body>
来,我们捋一下流程:
- 第一步当然是数据的处理,先变成
getter/setter - 当
new Watcher的时候,这时候Dep.Target是renderWatcher - 开始执行
renderFunction,读取到name和age的属性时,触发getter收集Dep.Target,也就是renderWatcher。此时name和age中的Dep实例都存了renderWatcher - 过了2秒,data.age赋值,触发
setter,触发age的Dep中保存的watcher的update方法。此时更新视图。 - 这时,
renderFunction执行,读取到age时,值为25。
别问为啥两秒,一个人就从12变成25了,经历痛苦会让人瞬间成长😂
ComputedWatcher
总听文章里说,computed有什么懒加载,缓存。那是个什么玩意啊
好说,因为computed其实是一个getter,是函数就要执行嘛。懒加载的意思就是它什么时候被用到了,它什么时候执行这个函数。
那缓存又是什么呢?
也很简单,就是computed中用到的值如果没发生改变的话,它的getter函数不进行计算,而是直接用上一次得出的结果。
ok,先到这里,我们先捋一捋思路
首先,刚刚说到,computed可以被缓存,当它用到的值没有发生改变时,getter不需要执行。
也就是说computed本身也是要有Dep,用来收集数据。
其次,他是lazy的,所以即使数据发生了改变也不用立即执行函数,获取结果。而是可以等到,什么时候再次用到这个computed的值再去计算。比如在render函数中用到
最后,computed中的数据改变后不能只通知computed的值需要重新更新,还需要通知用到computed的地方也要进行一次更新
总结下来就是,如果一个render函数中有用到computed,那么computed中的数据更新,不仅要通知computed的值要改变,还要告诉render函数进行重新执行。而当render函数重新执行的时候,就会再次获取computed。这时computed才会执行他的getter函数
好了,思路捋清了,我们实现一下。为此我们要修改一下之前的Dep和Watcher,并且我们还要实现一个computed方法。
class Dep {
constructor() {
this.watchers = new Set()
}
// 这里发生了变化
addSub(watcher) {
this.watchers.add(watcher)
}
// 这里发生了变化
depend() {
if (Dep.Target) {
Dep.Target.addDep(this)
}
}
notify() {
let watchers = this.watchers
for (let watcher of watchers) {
watcher.update()
}
}
}
class Watcher {
constructor(
getter,
options
) {
this.getter = getter
this.deps = new Set()
this.value = undefined
this.lazy = undefined
this.dirty = undefined
if (options) {
this.lazy = !!options.lazy
}
this.dirty = this.lazy
this.value = this.lazy
? undefined
: this.get()
}
get() {
pushTarget(this)
let value = this.getter()
popTarget()
return value
}
addDep(dep) {
dep.addSub(this)
this.deps.add(dep)
}
depend() {
let deps = this.deps
for (let dep of deps) {
dep.depend()
}
}
evalute() {
this.value = this.get()
this.dirty = false
}
update() {
if (this.lazy) {
this.dirty = true
} else {
Promise.resolve().then(() => {
this.run()
})
}
}
run() {
this.value = this.get()
}
}
function computed(computedGetter) {
const options = { lazy: true }
const computedWathcer = new Watcher(computedGetter, options)
const result = {}
Object.defineProperty(result, 'value', {
get() {
if (computedWathcer.dirty) {
computedWathcer.evalute()
}
if (Dep.Target) {
computedWathcer.depend()
}
return computedWathcer.value
}
})
return result
}
看到这里肯定很晕,没关系,咱们再举一个🌰,结合🌰来看懂这块逻辑。大家目前只需要关注一点,就是update中我们用了微任务。
ok,先看例子
<body>
<div id="app"></div>
<script src="./reactive/reactive.js"></script>
<script>
const data = observe({
age: 12,
name: 'Sunyanzhe'
})
function renderFunction() {
document.querySelector('#app').innerHTML = `我叫${data.name}, 我${data.age}岁,明年${nextYear.value}`
}
const nextYear = computed(() => data.age + 1)
const renderWatcher = new Watcher(renderFunction)
setTimeout(() => {
data.age = 25
}, 2000)
</script>
</body>
我们还是捋一下执行顺序
- 首先依旧是observe Data
- 然后我们定义了nextYear这个computed。这时候注意computedWatcher已经生成了,但是由于它是lazy的我们并没有执行get,且这个watcher的
dirty为true。这个很重要! - 开始renderFunction,执行get,收集依赖...这些都没问题,但是大家还记不记得之前说的
栈。此时的栈中有RenderWatcher。 - 读取到nextYear,触发nextYear的getter。由于dirty是ture,所以开始计算!此时执行了get方法,
栈中推入了ComputedWatcher。重点来了,RenderWatcher还没执行完!所以目前栈中有两个watcher。computedWatcher的get执行完,出栈,将得到的值赋给value属性。修改dirty属性为false - 另一个关键,此时的Dep.Target依旧指向的是RenderWatcher,然后computedWatcher执行了depend方法。这个方法的意思,就是要让收集到computedWatcher的dep继续收集那个用到computedWatcher的RenderWatche。(有点绕,多看看代码仔细体会)
- 值改变,computed的dirty再次变为true,等待renderWatcher的更新,再次出发computedWatcher的计算。
这里为什么使用了微任务,是因为执行顺序的问题,我们的computed的计算必须要在renderWatcher的更新之后,这样才能收集到对应的依赖。在Vue源码中,有一个执行更新的队列,它会将所有的watcher进行排序,避免报错。
WatchWatcher
其实,watch也很简单,就是加了个callback。
watch比较迷惑的地方其实它的getter是什么,在renderWatcher中,getter是render函数;在computedWatcher中,getter是getter函数;那么watch是什么呢。
其实很简单就是个travers函数,想想我们是怎么写watch的
watch: {
prop1(val) {
console.log(val)
}
}
// 转换为
$watch(() => {vm._data.prop1}, console.log)
这里面第一个函数是getter,用来收集依赖,第二个就是callback了
那deep呢? deep其实就是深度遍历
废话少说,直接开始实现!
其实很简单,我们只需要加个callback,找个地方调用一下就好了。
所以我们就改一下constructor和run这两个
class Watcher {
constructor(
getter,
options,
cb
) {
//...
this.cb = cb
this.user = undefined
if (options) {
this.user = !!options.user
}
// ...
}
run() {
let newValue = this.get()
if (this.user && newValue !== this.value) {
// 调用回调
this.cb(newValue, this.value)
this.value = newValue
}
}
}
function watch(watcheGetter, callback) {
const options = { user: true }
new Watcher(watcheGetter, options, callback)
}
就是如此的简单,比computed简单多了~
最后看一下效果
<body>
<div id="app"></div>
<script src="./reactive/reactive.js"></script>
<script>
const data = observe({
age: 12,
name: 'Sunyanzhe'
})
function renderFunction() {
document.querySelector('#app').innerHTML = `我叫${data.name}, 我${data.age}岁,明年${nextYear.value}`
}
const nextYear = computed(() => data.age + 1)
const renderWatcher = new Watcher(renderFunction)
watch(
() => data.name,
(val, oldVal) => {
console.log('new---', val)
console.log('old---', oldVal)
})
setTimeout(() => {
data.name = 'yanzhe'
}, 1000)
setTimeout(() => {
data.age = 25
}, 2000)
</script>
</body>
四、源码以及拓展阅读
在上文中谈到的为什么DefineReactive不传value的原因,在这个issue中:github.com/vuejs/vue/p…,主要原因是,数据本身就可以是getter/setter
Vue中的源码思路与本文一致,主要多了边界问题的处理,以及数组的hack,有关数组的处理需要大家去看源码去理解
- array —— github.com/vuejs/vue/b…
- 观察者—— github.com/vuejs/vue/b…