ICode9

精准搜索请尝试: 精确搜索
首页 > 其他分享> 文章详细

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

2019-10-30 09:04:01  阅读:216  来源: 互联网

标签:const target mvvm watcher key return data 监听 天学


本文转载于:猿2048网站➬https://www.mk2048.com/blog/blog.php?id=kjhkkii1j

此前为了学习Vue的源码,我决定自己动手写一遍简化版的Vue。现在我将我所了解到的分享出来。如果你正在使用Vue但还不了解它的原理,或者正打算阅读Vue的源码,希望这些分享能对你了解Vue的运行原理有所帮助。

Proxy

首先我们将从数据监听开始讲起,对于这一部分的内容相信许多小伙伴都看过网上各种各样的源码解读了,不过当我自己尝试去实现的时候,还是发现自己动手对于巩固知识点非常重要。不过鉴于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;
};

小结

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

只要能理清观察者、数据对象、以及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;
};

Proxy和defineProperty的比较

Vue2使用了defineProperty,而Vue3将使用Proxy

Proxy作为新的特性,有其强大之处,例如对于数组也可以直接代理,而此前需要拦截数组方法例如push等,而对于arr[2] = 3或者obj.newProp = 3这样数组元素和对象新属性的直接设置都无法处理,需要提供Vue.set这样的帮助函数。

不过需要注意,defineProperty是原地替换的,而Proxy并不是,例如:

const target = { a: 1 };
const data = new Proxy(target, { ... });

target.a = 2; // 不会触发setter
data.a = 3; // 修改data才会触发setter

你还可以尝试...

在我的简陋的代码的基础上,你可以尝试进一步实现计算属性,给Watcher类添加销毁方法,用不同的方式实现nextTick,或是添加一些容错性与提示。如果使用时不小心,queueWatch可能会因为计算属性的互相依赖而陷入死循环,你可以尝试让你的代码发现并处理这一问题。

如果仍感到迷惑,不妨阅读Vue的源码,无论是整体的实现还是一些细节的处理都能让我们受益匪浅。

总结

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

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


参考:

代码:TODO


更多专业前端知识,请上【猿2048】www.mk2048.com

标签:const,target,mvvm,watcher,key,return,data,监听,天学
来源: https://www.cnblogs.com/htmlandcss/p/11762793.html

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有