Skip to content

第四章:响应系统的作用与实现

响应式数据与副作用函数

副作用函数:当一个函数直接或间接影响了其他函数(比如修改了全局变量)的执行就称其为副作用函数

javascript
const obj = { text: 'hello world' };
function effect() {
  document.body.innerText = obj.text;
}

我们希望对对象修改后 obj.text = 'abc',副作用函数会自动执行,如果能实现这个目标,就成其为响应式。

响应式数据的基本实现

Object.definePropertyProxy 来劫持对象的 getset 方法

javascript
const bucket = new Set();
const data = { text: 'hello' };
const obj = new Proxy(data, {
  get(target, key) {
    // 访问时将副作用函数加入到 桶 中
    bucket.add(effect);
    return target[key];
  },
  set(target, key, val) {
    target[key] = val;
    // 设置时 触发 effect 函数
    bucket.forEach(fn => fn());
    return true;
  }
});

设计一个完善的响应式系统

在上面的例子里,副作用函数的名称被硬编码(只能是 effect),为了解决这个问题,将 effect 改为一个注册副作用函数的函数。

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

// 用于注册副作用函数
function effect(fn) {
  activeEffect = fn;
  // 执行副作用函数 (触发收集)
  fn();
}
javascript
const obj = new Proxy(data, {
  get(target, key) {
    // 将 activeEffect 存储的副作用函数收集到桶里
    if (activeEffect) {
      bucket.add(activeEffect);
    }
    return target[key];
  },
  set(target, key, val) {
    target[key] = val;
    // 设置时 触发 effect 函数
    bucket.forEach(fn => fn());
    return true;
  }
});

现在可以这样使用

javascript
effect(() => {
  console.log('effect run');
  document.body.innerText = obj.text;
});

如果我们执行如下操作,仍然会触发副作用函数执行。

javascript
// 副作用函数里根本没有访问这个字段
obj.notExist = '123';

其原因就是副作用函数和目标对象没有建立明确的关系。为了解决这个问题我们修改下 bucket 的数据结构

javascript
const bucket = new WeakMap();

const obj = new Proxy(data, {
  get(target, key) {
    // 将 activeEffect 存储的副作用函数收集到桶里
    track(target, key);
    return target[key];
  },
  set(target, key, val) {
    target[key] = val;
    // 将副作用函数从 桶 中取出来并调用
    trigger(target, key);
  }
});

// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
  if (!activeEffect) return;
  let depsMap = bucket.get(target);
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  deps.add(activeEffect);
}

// 在 set 拦截器函数内调用 trigger 函数触发副作用函数
function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  effects && effects.forEach(fn => fn());
}

分支切换与 cleanup

javascript
const obj = { ok: true, text: 'hello world' };
effect(() => {
  document.body.innerText = obj.ok ? obj.text : 'not';
});

根据上面的代码可知,副作用函数会被会 obj.okobj.text 收集。 我们修改 obj.ok = false。现在的副作用函数与 obj.text 是无关的,但是修改仍然会触发副作用函数。

解决这个问题的思路很简单:每次副作用函数执行时,我们可以先把它从所有与之关联的依赖集合中删除

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

// 用于注册副作用函数
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    fn();
  };
  // activeEffect.deps 用来存储所有该副作用函数相关的依赖合集
  effectFn.deps = [];
  // 执行副作用函数
  effectFn();
}
javascript
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
  if (!activeEffect) return;
  let depsMap = bucket.get(target);
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  /**
   * 这里就是完成了一个双向的绑定。
   * - 每个集合里收集了副作用函数
   * - 每个副作用函数收集了自己在哪些个集合
   */
  deps.add(activeEffect);
  activeEffect.deps.push(deps);
}
javascript
function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i];
    // 将副作用函数在 deps(依赖集合)中移除
    deps.delete(effectFn);
  }
  effectFn.deps.length = 0;
}

WARNING

根据 Set.prototype.forEach 规范,我们这样实现会出现死循环,解决方案就是创建一个新的 Set 数组

javascript
const set = new Set([1]);

// 有问题
set.forEach(() => {
  set.delete(1);
  set.add(1);
  console.log('set forEach');
});

const set1 = new Set(set);
// 无问题
set1.forEach(() => {
  // 注意 此处处理的是原集合而非新集合
  set.delete(1);
  set.add(1);
  console.log('set1 forEach');
});

我们回到出问题的地方进行修正

javascript
function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);

  const effectsToRun = new Set(effects);
  effectsToRun.forEach(fn => fn());
  // effects && effects.forEach(fn => fn());
}

嵌套的 effect 与 effect 栈

javascript
const data = { foo: true, bar: true };
const obj = new Proxy(data, {
  /** */
});
let temp1, temp2;

effect(function fn1() {
  console.log('fn1 exec');
  effect(function fn2() {
    console.log('fn2 exec');
    temp2 = obj.bar;
  });
  temp1 = obj.foo;
});
/**
 * 修改 foo 输出:
 *  fn1 exec
 *  fn2 exec
 *  fn2 exec
 */
obj.foo = false;

可以看到这是不符合预期的,我们期望修改 foo 时执行 fn1, 修改 bar 时执行 fn2,但是事实上 修改 foo 后执行的 fn2。

原因: 我们用全局的 activeEffect 来存储副作用函数,意味着同一时刻只会存在一个,新来的会将旧的值覆盖掉,并且永远不会恢复到原来的值。 在执行嵌套的逻辑时,收集的永远都是最内层的,所以会出现这个问题

解决: 用 effectStack 来存储所有的副作用函数,当副作用函数执行时入栈,执行结束后将其弹出,并始终让 activeEffect 指向栈顶。

javascript
// 用一个全局变量临时存储被注册的副作用函数
let activeEffect;
const effectStack = [];

// 用于注册副作用函数
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  };
  // activeEffect.deps 用来存储所有该副作用函数相关的依赖合集
  effectFn.deps = [];
  // 执行副作用函数
  effectFn();
}

避免无限递归循环

javascript
effect(function fn1() {
  console.log('fn1 exec', obj.foo);
  temp1 = obj.foo++;
});

在副作用函数中执行了自增 obj.foo++, 我们模拟下执行逻辑:

  1. 执行 effect 里的副作用函数 effectFn()
  2. 副作用函数中读取 obj.foo,触发 track,将副作用函数添加到 bucket 中
  3. 副作用函数中执行 ++,触发 trigger,将副作用函数取出执行
  4. 此时副作用函数还未执行完就要开始下一轮执行,因此出现了死递归

解决: 如果 trigger 触发执行的副作用函数与当前执行的副作用函数相同,则不触发执行。

javascript
function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);

  const effectsToRun = new Set();
  effects &&
    effects.forEach(fn => {
      if (fn !== activeEffect) {
        effectsToRun.add(fn);
      }
    });

  effectsToRun.forEach(fn => fn());
}

调度执行

可调度性:当 trigger 函数触发副作用函数执行时,有能力决定其执行时机、执行次数。

如何控制副作用函数的调用?

effect 函数支持传递 options,并将 options 挂载到副作用函数,在 trigger 调用时,将副作用函数传递给 options.scheduler

javascript
function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  };
  // activeEffect.deps 用来存储所有该副作用函数相关的依赖合集
  effectFn.deps = [];
  effectFn.options = options;
  // 执行副作用函数
  effectFn();
}

function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);

  const effectsToRun = new Set();
  effects &&
    effects.forEach(fn => {
      if (fn !== activeEffect) {
        effectsToRun.add(fn);
      }
    });

  effectsToRun.forEach(fn => {
    if (fn.options.scheduler) {
      fn.options.scheduler(fn);
    } else {
      fn();
    }
  });
}
javascript
let temp1;
const data = { foo: 1, bar: true };

const obj = new Proxy(data, {
  /** */
});

effect(
  function fn1() {
    console.log('fn1 exec', obj.foo);
    temp1 = obj.foo;
  },
  {
    scheduler: effectFn => {
      setTimeout(effectFn, 1000);
    }
  }
);

obj.foo = 0;
console.log(`[a] `, obj);

// ------------ 输出 ------------
// fn1 exec 1
// [a]  { foo: 0, bar: true }
// fn1 exec 0  => 1s 后输出

当我们执行了多次 obj.foo++,会触发多次副作用函数,通过调度器还可以实现控制调用次数,使其只在最后一次执行。

javascript
let temp1, temp2;
const jobs = new Set();
const p = Promise.resolve();

let isFlushing = false;
function flushJob() {
  if (isFlushing) return;
  isFlushing = true;
  p.then(() => {
    jobs.forEach(job => job());
  }).finally(() => {
    isFlushing = false;
  });
}

effect(
  function fn1() {
    console.log('fn1 exec', obj.foo);
    temp1 = obj.foo;
  },
  {
    scheduler: effectFn => {
      jobs.add(effectFn);
      flushJob();
    }
  }
);

obj.foo++;
obj.foo++;
obj.foo++;
console.log(`[a] `, obj);
// --------------- 输出 --------------
// fn1 exec 1
// [a]  { foo: 4, bar: true }
// fn1 exec 4

计算属性 computed 与 lazy

  1. 不会立即执行副作用函数
  2. 能够返回计算的结果
javascript
// 用于注册副作用函数
function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    // 将计算的值返回
    const res = fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
    return res;
  };
  // activeEffect.deps 用来存储所有该副作用函数相关的依赖合集
  effectFn.deps = [];
  effectFn.options = options;
  // 用于计算属性,不会立即执行副作用函数
  if (!options.lazy) {
    // 执行副作用函数
    effectFn();
  } else {
    return effectFn;
  }
}
javascript
function computed(fn) {
  let value;
  // 为计算属性提供缓存,每次访问计算属性取的都是缓存的值。 如果依赖的值有更新,会先刷新缓存 然后在返回值
  let dirty = true;
  const effectFn = effect(fn, {
    lazy: true,
    scheduler() {
      if (!dirty) {
        // 当依赖更新时,不需要执行计算 而是将 dirty=true,这样当计算属性访问时就可
        dirty = true;
        // 当计算属性依赖的响应式数据有变化时 手动调用 trigger 触发响应
        trigger(obj, 'value');
      }
    }
  });
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn();
        dirty = false;
      }
      // 当读取 value 时,手动调用 track 函数进行跟踪
      track(obj, 'value');
      return value;
    }
  };
  return obj;
}

watch 的实现原理

利用调度器可以实现一个最基础版本的 watch,但是 source.foo 是硬编码的 不通用,而且回调只有新的值

javascript
function watch(source, cb) {
  effect(() => source.foo, {
    scheduler(effectFn) {
      cb(effectFn());
    }
  });
}

实现一个通用的 watch

javascript
/**
 * 遍历对象的所有属性,这样就能触发所有属性的 get,从而实现 track
 * @param {*} obj 原始对象
 * @param {*} seen 防止重复引用
 */
function traverse(obj, seen = new Set()) {
  if (typeof obj !== 'object' || obj === null || seen.has(obj)) return;
  seen.add(obj);
  // 假设只有对象,其他数据结构先不处理
  for (const key in obj) {
    traverse(obj[key], seen);
  }
  return obj;
}
/**
 * @param source
 * 如果传递的是函数 () => obj.foo,直接当作副作用函数
 * 如果是对象,就构建一个函数,通过 traverse 递归的取所有值
 *
 * @param cb watch 的回调
 */
function watch(source, cb) {
  const getter = typeof source === 'function' ? source : () => traverse(source);

  effect(getter, {
    scheduler(effectFn) {
      cb(effectFn());
    }
  });
}

实现回调给出新、老值

javascript
function watch(source, cb) {
  let oldVal, newVal;
  const getter = typeof source === 'function' ? source : () => traverse(source);

  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler(effectFn) {
      // 获取新值
      newVal = effectFn();
      cb(oldVal, newVal);
      // 更新旧值
      oldVal = newVal;
    }
  });
  // 使用 lazy,然后手动调用,此时拿到的就是老值
  oldVal = effectFn();
}

立即执行的 watch 与回调机制

通过第三个选项中的 immediate 来决定是否立即执行回调。

javascript
function watch(source, cb, option) {
  let oldVal, newVal;
  const getter = typeof source === 'function' ? source : () => traverse(source);

  /**
   * 立即执行和 scheduler 调用本质上是一样的
   */
  const job = () => {
    newVal = effectFn();
    cb(oldVal, newVal);
    oldVal = newVal;
  };

  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: job
  });
  if (option.immediate) {
    job();
  } else {
    oldVal = effectFn();
  }
}