3天学写mvvm框架[一]:数据监听
栏目: JavaScript · 发布时间: 6年前
内容简介:首先我们将从数据监听开始讲起,对于这一部分的内容相信许多小伙伴都看过网上各种各样的源码解读了,不过当我自己尝试去实现的时候,还是发现自己动手对于巩固知识点非常重要。不过鉴于Vue3将使用当然这一部分的代码很可能将与Vue2以及Vue3都不尽相同,不过核心原理都是相同的。今天我们的目标是让以下代码如预期运行:
首先我们将从数据监听开始讲起,对于这一部分的内容相信许多小伙伴都看过网上各种各样的源码解读了,不过当我自己尝试去实现的时候,还是发现自己动手对于巩固知识点非常重要。不过鉴于Vue3将使用 Proxy
来实现数据监听,所以我这里是通过 Proxy
来实现了。如果你还不了解js中的这部分内容,请先通过MDN来学习一下哦。
当然这一部分的代码很可能将与Vue2以及Vue3都不尽相同,不过核心原理都是相同的。
目标
今天我们的目标是让以下代码如预期运行:
const data = proxy({ a: 1 }); const watcher = watch(() => { return data.a + data.b; }, (oldVal, value) => { console.log('watch callback', oldVal, value); }); data.b = 1; // console.log('watch callback', NaN, 2); data.a += 1; // console.log('watch callback', 2, 3); 复制代码
我们将实现 proxy
与 watch
两个函数。 proxy
接受一个数据对象,并返回其通过 Proxy
生成的代理对象; watch
方法接受两个参数,前者为求值函数,后者为回调函数。
因为这里的求值函数需要使用到 data.a
与 data.b
两个数据,因此当两者改变时,求值函数将重新求值,并触发回调函数。
原理介绍
为了实现以上目标,我们需要在求值函数运行时,记录下其所依赖的数据,从而在数据发生改变时,我们就能触发重新求值并触发回调了。
从另一个角度来说,每当我们从 data
中取它的 a
与 b
数据时,我们希望能记录下当前是谁在取这些数据。
这里有两个问题:
- 何时进行记录:如果你已经学习了
Proxy
的用法,那这里的答案应当很明显了,我们将通过Proxy
来设置getter
,每当数据被取出时,我们设置的getter
将被调用,这时我们就可以 - 记录的目标是谁:我们只需要在调用一个求值函数之前用一个变量将其记录下来,再调用这个求值函数,那么在调用结束之前,触发这些
getter
的应当都是这一求值函数。在求值完成后,我们再置空这一变量就行了
这里需要注意的是,我们将编写的微型mvvm框架不会包含计算属性。由于计算属性也是求值函数,因此可能会出现求值函数嵌套的情况(例如一个求值函数依赖了另一个计算属性),这样的话我们不能仅使用单一变量来记录当前的求值函数,而是需要使用栈的结构,在求值函数运行前后进行入栈与出栈操作。对于这部分内容,感兴趣的小伙伴不妨可以自己试试实现以下计算属性哦。
使用Proxy创建getter与setter
首先我们实现一组最简单的 getter
与 setter
,仅仅进行一个简单的代理:
const proxy = function (target) { const data = new Proxy(target, { get(target, key) { return target[key]; }, set(target, key, value) { target[key] = value; return true; } }); return data; }; 复制代码
对于最简单的数据例如 { a: 1, b: 1 }
上面的做法是行得通的。但对于复杂一些的数据呢?例如 { a: { b: 1 } }
,外层的数据 a
是通过 getter
取出的,但我们并没有为 a
即 { b: 1 }
设置 getter
,因此对于获取 a.b
我们将不得而知。因此,我们需要递归的遍历数据,对于类型为对象的值递归创建 getter
与 setter
。同时不仅在初始化时,每当数据被设置时,我们也需要检查新的值是否是对象:
const proxy = function (target) { for (let key in target) { const child = target[key]; if (child && typeof child === 'object') { target[key] = proxy(child); } } return _proxyObj(target); }; const _proxyObj = function (target) { const data = new Proxy(target, { get(target, key) {console.log(1); return target[key]; }, set(target, key, value) { if (value && typeof value === 'object') { value = proxy(value); } target[key] = value; return true; } }); return data; }; 复制代码
这里要注意一点, typeof null
也会返回 "object"
,但我们并不应该将其作为对象递归处理。
Dep和DepCollector
Dep类
对于如下的求值函数:
() => { return data.a + data.b.c; } 复制代码
将被记录为:这个求值函数依赖于 data
的 a
属性,依赖于 data
的 b
属性,以及 data.b
的 c
属性。对于这些依赖,我们将用Dep类来表示。
对于每个对象或者数组形式的数据,我们将为其创建一个 Dep
实例。 Dep
实例将会有一个 map
键值对属性,其键为属性的 key
,而值是一个数组,用来将相应的监听者不重复地 watcher
记录下来。
Dep
实例有两个方法: add
和 notify
。 add
在 getter
过程中通过键添加 watcher
; notify
在 setter
过程中触发对应的 watcher
让它们重新求值并触发回调:
class Dep { constructor() { this.map = {}; } add(key, watcher) { if (!watcher) return; if (!this.map[key]) this.map[key] = new DepCollector(); watcher.addDepId(this.map[key].id); if (this.map[key].includes(watcher)) return; this.map[key].push(watcher); } notify(key) { if (!this.map[key]) return; this.map[key].forEach(watcher => watcher.queue()); } } 复制代码
同时需要修改 proxy
方法,为数据创建 Dep
实例,并在 getter
( currentWatcher
指向当前在求值的 Watcher
实例)和 setter
过程中调用其 add
和 notify
方法:
const proxy = function (target) { const dep = target[KEY_DEP] || new Dep(); if (!target[KEY_DEP]) target[KEY_DEP] = dep; for (let key in target) { const child = target[key]; if (child && typeof child === 'object') { target[key] = proxy(child); } } return _proxyObj(target, dep, target instanceof Array); }; const _proxyObj = function (target, dep) { const data = new Proxy(target, { get(target, key) { if (key !== KEY_DEP) dep.add(key, currentWatcher); return target[key]; }, set(target, key, value) { if (key !== KEY_DEP) { if (value && typeof value === 'object') { value = proxy(value); } target[key] = value; dep.notify(key); return true; } } }); return data; }; 复制代码
这里我们用 const KEY_DEP = Symbol('KEY_DEP');
作为键将已经创建的 Dep
实例保存到数据对象上,使得一个数据被多次 proxy
时能重用先前的 Dep
实例。
DepCollector类
DepCollector
类仅仅是对数组进行了一层包装,这里的主要目的是为每个 DepCollector
实例添加一个用以唯一表示的 id
,在介绍 Watcher
类的时候就会知道这个 id
有什么用了:
let depCollectorId = 0; class DepCollector { constructor() { const id = ++depCollectorId; this.id = id; DepCollector.map[id] = this; this.list = []; } includes(watcher) { return this.list.includes(watcher); } push(watcher) { return this.list.push(watcher); } forEach(cb) { this.list.forEach(cb); } remove(watcher) { const index = this.list.indexOf(watcher); if (index !== -1) this.list.splice(index, 1); } } DepCollector.map = {}; 复制代码
数组的依赖
对于数组的变动,例如调用 push
、 pop
、 splice
等方法或直接通过下边设置数组中的元素时,将发生改变的数组对应的下标以及 length
都将作为 key
触发我们的 getter
,这是 Proxy
很强大的地方,但我们不需要这么细致的监听数组的变动,而是统一触发一个 数组发生了变化
的事件就可以了。
因此我们将创建一个特殊的 key
—— KEY_DEP_ARRAY
来表示这一事件:
const KEY_DEP_ARRAY = Symbol('KEY_DEP_ARRAY'); const proxy = function (target) { const dep = target[KEY_DEP] || new Dep(); if (!target[KEY_DEP]) target[KEY_DEP] = dep; for (let key in target) { const child = target[key]; if (child && typeof child === 'object') { target[key] = proxy(child); } } return _proxyObj(target, dep, target instanceof Array); }; const _proxyObj = function (target, dep, isArray) { const data = new Proxy(target, { get(target, key) { if (key !== KEY_DEP) dep.add(isArray ? KEY_DEP_ARRAY : key, currentWatcher); return target[key]; }, set(target, key, value) { if (key !== KEY_DEP) { if (value && typeof value === 'object') { value = proxy(value); } target[key] = value; dep.notify(isArray ? KEY_DEP_ARRAY : key); return true; } } }); return data; }; 复制代码
小结
这里我们用一张图进行一个小结:
只要能理清观察者、数据对象、以及 Dep
和 DepCollector
之间的关系,那这一部分就不会让你感到困惑了。
Watcher
接下来我们需要实现 Watcher
类,我们需要完成以下几个步骤:
-
Watcher
构造函数将接收一个求值函数以及一个回调函数 -
Watcher
实例将实现eval
方法,此方法将调用求值函数,同时我们需要维护当前的watcher
实例currentWatcher
。 -
queue
方法将调用queueWatcher
,使得Watcher
实例的eval
在nextTick
中被调用。 - 实现
addDepId
与clearDeps
方法,前者使Watcher
实例记录与DepCollector
的依赖关系,后者使得Watcher
可以在重新求值后或销毁时清理与DepCollector
的依赖关系。 - 最后我们实现
watch
方法,它将调用Watcher
构造函数。
为什么在重新求值后我们需要清理依赖关系呢?
想象这样的函数:
() => { return data.a ? data.b : data.c; } 复制代码
因为 a
的值改变,将改变这个求值函数依赖于 b
还是 c
。
又或者:
const data = proxy({ a: { b: 1 } }); const oldA = data.a; watch(() => { return data.a.b; }, () => {}); data.a = { b: 2 }; 复制代码
由于 data.a
已被整体替换,因此我们将为其生成新的 Dep
,以及为 data.a.b
生成新的 DepCollector
。此时我们再修改 oldA.b
,不应该再触发我们的 Watcher
实例,因此这里是要进行依赖的清理的。
最终代码如下:
let watcherId = 0; class Watcher { constructor(func, cb) { this.id = ++watcherId; this.func = func; this.cb = cb; } eval() { this.depIds = this.newDepIds; this.newDepIds = {}; pushWatcher(this); this.value = this.func(); // 缓存旧的值 popWatcher(); this.clearDeps(); } addDepId(depId) { this.newDepIds[depId] = true; } clearDeps() { // 移除已经无用的依赖 for (let depId in this.depIds) { if (!this.newDepIds[depId]) { DepCollector.map[depId].remove(this); } } } queue() { queueWatcher(this); } run() { const oldVal = this.value; this.eval(); // 重新计算并收集依赖 this.cb(oldVal, this.value); } } let currentWatcheres = []; // 栈,computed属性 let currentWatcher = null; const pushWatcher = function (watcher) { currentWatcheres.push(watcher); currentWatcher = watcher; }; const popWatcher = function (watcher) { currentWatcheres.pop(); currentWatcher = currentWatcheres.length > 0 ? currentWatcheres[currentWatcheres.length - 1] : null; }; const watch = function (func, cb) { const watcher = new Watcher(func, cb); watcher.eval(); return watcher; }; 复制代码
queueWatcher与nextTick
nextTick
会将回调加入一个数组中,如果当前没有还预定延时执行,则请求延时执行,在执行时依次执行数组中所有的回调。
延时执行的实现方式有很多,例如 requestAnimationFrame
、 setTimeout
或者是node.js的 process.nextTick
与 setImmediate
等等,这里不做纠结,使用 requestIdleCallback
:
const nextTickCbs = []; const nextTick = function (cb) { nextTickCbs.push(cb); if (nextTickCbs.length === 1) { requestIdleCallback(() => { nextTickCbs.forEach(cb => cb()); nextTickCbs.length = 0; }); } }; 复制代码
queueWatcher
方法会将 watcher
加入待处理列表中(如果它尚不在这个列表中)。
整个待处理列表将按照 watcher
的 id
进行排序。这点暂时是用不着的,但如果存在计算属性等用户创建的 watcher
或是组件概念,我们希望从父组件其向下更新组件,或是用户创建的 watcher
优先于组件渲染的 watcher
执行,那么我们就需要维护这样的顺序。
最后,如果 flushSchedulerQueue
尚未通过 nextTick
加入延时执行,则将其加入:
const queue = []; let has = {}; let waiting = false; let flushing = false; let index = 0; const queueWatcher = function (watcher) { const id = watcher.id; if (has[id]) return; has[id] = true; if (!flushing) { queue.push(watcher); } else { const i = queue.length - 1; while (i > index && queue[i].id > watcher.id) { i--; } queue.splice(i + 1, 0, watcher); } if (waiting) return; waiting = true; nextTick(flushSchedulerQueue); }; const flushSchedulerQueue = function () { flushing = true; let watcher, id; queue.sort((a, b) => a.id - b.id); for (index = 0; index < queue.length; index++) { watcher = queue[index]; id = watcher.id; has[id] = null; watcher.run(); } index = queue.length = 0; has = {}; waiting = flushing = false; }; 复制代码
你还可以尝试...
在我的简陋的代码的基础上,你可以尝试进一步实现计算属性,给 Watcher
类添加销毁方法,用不同的方式实现 nextTick
,或是添加一些容错性与提示。如果使用时不小心, queueWatch
可能会因为计算属性的互相依赖而陷入死循环,你可以尝试让你的代码发现并处理这一问题。
如果仍感到迷惑,不妨阅读Vue的源码,无论是整体的实现还是一些细节的处理都能让我们受益匪浅。
总结
今天我们实现了 Dep
、 DepCollectpr
以及 Watcher
类,并最终实现了 proxy
和 watch
两个方法,通过它们我们可以对数据添加监听,从而为响应式模板打下基础。
下一次,我们将自己动手完成模板的解析工作。
参考:
代码:TODO
以上所述就是小编给大家介绍的《3天学写mvvm框架[一]:数据监听》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Lighttpd
Andre Bogus / Packt Publishing / 2008-10 / 39.99
This is your fast guide to getting started and getting inside the Lighttpd web server. Written from a developer's perspective, this book helps you understand Lighttpd, and get it set up as securely an......一起来看看 《Lighttpd》 这本书的介绍吧!