响应系统的实现

89 阅读6分钟

## 响应系统作为Vue中的重要组成部分,理解响应系统对于理解Vue的设计思路来说有着很大的帮助。

### 在学习了霍春阳工的《Vue.js设计与实现》一书后,深有感触,做了一些笔记和自己的一些思考

1. 在实现一个响应系统我们首先需要理解几个概念。

响应式数据:当值变化时,函数可以自动执行。
副作用函数:函数的执行会直接或间接影响其他函数的执行,例如修改全局变量。
Proxy:ES6新增,创建一个对象的代理,当外界对目标对象进行访问时,都必须先通过这层拦截;
let testProxy = new Proxy(target, handler);
其中target为目标对象;handler为代理的处理对象。
常用方法:get() 监视对象属性的访问;set() 监视对象设置属性的过程

2. 如何实现一个响应式数据:

// 存储副作用函数的桶
const bucket = new Set();
// 原始数据
const data = {text: "hello world"};
// 对原始数据进行代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数effect存入副作用函数的桶中
    bucket.add(effect);
    // 返回属性值
    return target[key];
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal;
    // 将副作用函数从桶中取出并执行
    bucket.forEach(fn => fn());
    // 设置操作成功返回true
    return true;
  } 
})

// 副作用函数
function effect() {
  document.body.innerText = obj.text;
}

// 执行副作用函数,触发读取
effect();

// 1秒后修改响应式数据
setTimeout(() => {
  obj.text = 'hello zyt'
})

至此便实现了一个微型的响应系统,从代码中可以总结出一个响应系统的工作流程:
1.读取操作时,将副作用函数存入“桶”中;
2.设置操作时,先进行复制,后将副作用函数从“桶”中取出并执行。

但是上面的响应系统还有很多缺陷,比如副作用函数通过名字“effect”来硬编码,一个完善的响应系统,副作用函数应该是可以随意命名,甚至是匿名函数。
那我们改如何解决这种硬编码的机制呢?

3. 处理硬编码问题

其实解决方法也很简单,我们可以创建一个用来注册副作用函数的函数和一个用来存储被注册的副作用函数的全局变量。 说明可能有点拗口,直接上代码:

// 用一个全局变量存储被注册的副作用函数
let activeEffect;

// effect函数用于注册副作用函数
function effect(fn) {
  // 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
  activeEffect = fn;
  // 执行副作用函数
  fn();
}

将副作用函数fn作为effect函数的参数,然后赋值给activeEffect,接着执行被注册的副作用函数fn,这样就会触发响应式数据obj.text的读取操作。

// 当副作用函数为匿名函数时
effect(
  // 匿名函数
  () => {
    document.body.innerText = obj.text
  }
)

对应的需要简单修改一下Proxy的get拦截函数

const obj = new Proxy(data, {
  get(target, key) {
    // 将activeEffect中存储的副作用函数收集到“桶”中
    if (activeEffect) {
      bucket.add(activeEffect);
    }
    return target[key]
  },
  set(target, key, newVal) {
    target[key] = newVal;
    bucket.forEach(fn => fn());
    return true
  }
})

此时副作用函数已经存入到activeEffect中了,get拦截器也是将activeEffect收集到“桶”中,从而实现了响应系统无须依赖副作用函数名字。

effect(
  // 匿名函数
  () => {
    console.log('函数执行次数')
    document.body.innerText = obj.text
  }
)

setTimeout(() => {
  // 副作用函数中只读取了text的值,现在添加一个新的属性
  obj.notExist = '不存在的属性';
}, 1000)
// "函数执行次数"被打印了两次

但是上面的响应系统仍不是特别完善,匿名副作用函数中,我们读取的是obj.text的值,从而使匿名副作用函数与字段obj.text之间建立起响应联系。但是如果我们后续添加一个定时器,设置obj中本不存在的属性时,理论上来说,匿名副作用函数中并没有调用这个不存在的属性,即这个新增的属性并没有与副作用函数建立联系,因此定时器中的设置操作不应该触发副作用函数的重新执行,但是实际上,副作用函数重新执行了。
根本原因是没有在副作用函数与被操作的目标字段之间建立起明确的联系。
因此,无论读取的是哪一个属性,都会把副作用函数存入“桶”中;无论设置的是哪一个属性,也都会将副作用函数从“桶”中取出执行。
解决方法:在副作用函数与被操作的字段之间建立联系,重新设计当前“桶”的数据结构。

4. 设计“桶”的数据结构

首先我们看一下关于副作用函数注册的这一段代码

effect(effectFn() {
document.body.innerText = obj.text
})

我们可以将这段代码分为三个部分:

  1. 使用effect函数注册的副作用函数effectFn
  2. 被读取的代理对象obj
  3. 被读取的字段名text
    用target来表示原始对象,key表示被操作的字段名,effectFn来表示被注册的副作用函数
    三者其实是一种树形结构
    target对于key是一对多,key对于effectFn也是一对多的关系,这样便建立起来这三者之间的联系,设置任何key都只会导致这个key对应的effectFn的执行。

5. 重新设计响应系统

// 存储副作用函数的桶
const bucket = new WeakMap();

const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数activeEffect添加到存储副作用函数的桶中
    track(target, key);
    // 返回属性值
    return target[key];
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal;
    // 将副作用函数取出并执行
    trigger(target, key);
  }
})

function track(target, key) {
  // 没有activeEffect,直接return
  if (!activeEffect) return;
  // 根据target从“桶”中取得depsMap,这是个Map类型:key --> effects
  let depsMap = bucket.get(target);
  // 如果不存在depsMap,新建一个Map与target关联起来
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }
  // 根据key从depsMap中获取deps,这是个Set类型,用来存储所有与当前key相关联的副作用函数
  let deps = depsMap.get(key);
  // 如果不存在deps,同样新建一个Set与key关联起来
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  // 最后将当前激活的副作用函数添加到“桶”里
  deps.add(activeEffect);
}

function trigger(target, key) {
  // 根据target从“桶”中取得depsMap
  const depsMap = bucket.get(target);
  // 如果不存在,直接return
  if (!depsMap) return;
  // 根据key获取所有的副作用函数effects
  const effects = depsMap.get(key);
  // 执行副作用函数
  effects && effects.forEach(fn => fn());
}