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);
复制代码

我们将实现 proxywatch 两个函数。 proxy 接受一个数据对象,并返回其通过 Proxy 生成的代理对象; watch 方法接受两个参数,前者为求值函数,后者为回调函数。

因为这里的求值函数需要使用到 data.adata.b 两个数据,因此当两者改变时,求值函数将重新求值,并触发回调函数。

原理介绍

为了实现以上目标,我们需要在求值函数运行时,记录下其所依赖的数据,从而在数据发生改变时,我们就能触发重新求值并触发回调了。

从另一个角度来说,每当我们从 data 中取它的 ab 数据时,我们希望能记录下当前是谁在取这些数据。

这里有两个问题:

  • 何时进行记录:如果你已经学习了 Proxy 的用法,那这里的答案应当很明显了,我们将通过 Proxy 来设置 getter ,每当数据被取出时,我们设置的 getter 将被调用,这时我们就可以
  • 记录的目标是谁:我们只需要在调用一个求值函数之前用一个变量将其记录下来,再调用这个求值函数,那么在调用结束之前,触发这些 getter 的应当都是这一求值函数。在求值完成后,我们再置空这一变量就行了

这里需要注意的是,我们将编写的微型mvvm框架不会包含计算属性。由于计算属性也是求值函数,因此可能会出现求值函数嵌套的情况(例如一个求值函数依赖了另一个计算属性),这样的话我们不能仅使用单一变量来记录当前的求值函数,而是需要使用栈的结构,在求值函数运行前后进行入栈与出栈操作。对于这部分内容,感兴趣的小伙伴不妨可以自己试试实现以下计算属性哦。

使用Proxy创建getter与setter

首先我们实现一组最简单的 gettersetter ,仅仅进行一个简单的代理:

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 我们将不得而知。因此,我们需要递归的遍历数据,对于类型为对象的值递归创建 gettersetter 。同时不仅在初始化时,每当数据被设置时,我们也需要检查新的值是否是对象:

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;
}
复制代码

将被记录为:这个求值函数依赖于 dataa 属性,依赖于 datab 属性,以及 data.bc 属性。对于这些依赖,我们将用Dep类来表示。

对于每个对象或者数组形式的数据,我们将为其创建一个 Dep 实例。 Dep 实例将会有一个 map 键值对属性,其键为属性的 key ,而值是一个数组,用来将相应的监听者不重复地 watcher 记录下来。

Dep 实例有两个方法: addnotifyaddgetter 过程中通过键添加 watchernotifysetter 过程中触发对应的 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 实例,并在 gettercurrentWatcher 指向当前在求值的 Watcher 实例)和 setter 过程中调用其 addnotify 方法:

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 = {};
复制代码

数组的依赖

对于数组的变动,例如调用 pushpopsplice 等方法或直接通过下边设置数组中的元素时,将发生改变的数组对应的下标以及 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;
};
复制代码

小结

这里我们用一张图进行一个小结:

3天学写mvvm框架[一]:数据监听

只要能理清观察者、数据对象、以及 DepDepCollector 之间的关系,那这一部分就不会让你感到困惑了。

Watcher

接下来我们需要实现 Watcher 类,我们需要完成以下几个步骤:

  • Watcher 构造函数将接收一个求值函数以及一个回调函数
  • Watcher 实例将实现 eval 方法,此方法将调用求值函数,同时我们需要维护当前的 watcher 实例 currentWatcher
  • queue 方法将调用 queueWatcher ,使得 Watcher 实例的 evalnextTick 中被调用。
  • 实现 addDepIdclearDeps 方法,前者使 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 会将回调加入一个数组中,如果当前没有还预定延时执行,则请求延时执行,在执行时依次执行数组中所有的回调。

延时执行的实现方式有很多,例如 requestAnimationFramesetTimeout 或者是node.js的 process.nextTicksetImmediate 等等,这里不做纠结,使用 requestIdleCallback

const nextTickCbs = [];
const nextTick = function (cb) {
  nextTickCbs.push(cb);
  if (nextTickCbs.length === 1) {
    requestIdleCallback(() => {
      nextTickCbs.forEach(cb => cb());
      nextTickCbs.length = 0;
    });
  }
};
复制代码

queueWatcher 方法会将 watcher 加入待处理列表中(如果它尚不在这个列表中)。

整个待处理列表将按照 watcherid 进行排序。这点暂时是用不着的,但如果存在计算属性等用户创建的 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的源码,无论是整体的实现还是一些细节的处理都能让我们受益匪浅。

总结

今天我们实现了 DepDepCollectpr 以及 Watcher 类,并最终实现了 proxywatch 两个方法,通过它们我们可以对数据添加监听,从而为响应式模板打下基础。

下一次,我们将自己动手完成模板的解析工作。

参考:

代码:TODO


以上所述就是小编给大家介绍的《3天学写mvvm框架[一]:数据监听》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Lighttpd

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》 这本书的介绍吧!

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

Markdown 在线编辑器
Markdown 在线编辑器

Markdown 在线编辑器

RGB HSV 转换
RGB HSV 转换

RGB HSV 互转工具