《Vue.js 设计与实现》读书笔记 - 第 4 章、响应系统的作用与实现

第 4 章、响应系统的作用与实现

4.1 响应式数据与副作用

副作用函数就是会对外部造成影响的函数,比如修改了全局变量。

响应式:修改了某个值的时候,某个会读取该值的副作用函数能够自动重新执行。

4.2 响应系统的简单实现

如何实现响应式:

1、副作用读取值的时候,把函数放到值的某个桶里

2、重新给值赋值的时候,执行桶里的函数

在 Vue2 中通过 Object.defineProperty 实现,Vue3 通过 Proxy 实现。

点击查看代码
const data = { text: 'hello world' }
function effect() {
  document.body.innerText = obj.text
}

const bucket = new Set()
const obj = new Proxy(data, {
  get(target, key) {
    bucket.add(effect)
    return target[key]
  },
  set(target, key, newVal) {
    target[key] = newVal
    bucket.forEach(fn => fn())
    // 返回true代表设置操作成功
    return true
  }
})
// 触发读取
effect()
// 修改响应式数据
setTimeout(() => {
  obj.text = 'hello vue3'
}, 1000)

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

上面的实现硬编码了函数名,现在在全局维护一个变量来存储这个副作用函数。

点击查看代码
// 全局变量,用于存储被注册的副作用函数
let activeEffect;
// effect 函数用于注册副作用函数
function effect(fn) {
  activeEffect = fn;
  fn();
}

const data = { text: 'hello world' };

const bucket = new Set();
const obj = new Proxy(data, {
  get(target, key) {
    if (activeEffect) {
      bucket.add(activeEffect);
    }
    return target[key];
  },
  set(target, key, newVal) {
    target[key] = newVal;
    bucket.forEach((fn) => fn());
    // 返回true代表设置操作成功
    return true;
  },
});
// 触发读取
effect(
  // 一个匿名的副作用函数
  () => {
    document.body.innerText = obj.text;
  }
);
// 修改响应式数据
setTimeout(() => {
  obj.text = 'hello vue3';
}, 1000);

但是现在 bucket 并没有和 text 字段绑定,也就是说我修改任何值都会触发函数的执行。我们需要重新设计“桶”的数据结构。很简单,把 Set 改成 Map 来存储。

bucket 应该先绑定响应式对象,再绑定字段。

点击查看代码
// 全局变量,用于存储被注册的副作用函数
let activeEffect;
// effect 函数用于注册副作用函数
function effect(fn) {
  activeEffect = fn;
  fn();
  activeEffect = undefined; // 不过书上没有这一句
}
const bucket = new WeakMap();

const data = { text: 'hello world', text1: 'before' };
const obj = new Proxy(data, {
  get(target, key) {
    if (activeEffect) {
      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);
    }
    return target[key];
  },
  set(target, key, newVal) {
    target[key] = newVal;
    let depsMap = bucket.get(target);
    if (depsMap) {
      let effects = depsMap.get(key);
      effects && effects.forEach((fn) => fn());
    }
    // 返回true代表设置操作成功
    return true;
  },
});

// 触发读取
effect(
  // 一个匿名的副作用函数
  () => {
    document.body.innerText = obj.text;
  }
);
effect(() => {
  console.log(obj.text1);
});
// 修改响应式数据
setTimeout(() => {
  obj.text = 'hello vue3';
}, 1000);
setTimeout(() => {
  obj.text1 = 'after';
}, 2000);

这里使用了 WeakMap,其对 key 是弱引用,不影响垃圾回收。一旦对象被回收,对象的键和值就无法被访问到,所以再监听就没有意义了。否则一直引用会导致内存溢出。

getset 中的操作分别封装到 tracktrigger

点击查看代码
const data = { text: 'hello world', text1: 'before' };
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);
    // 返回true代表设置操作成功
    return true;
  },
});

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);
}

function trigger(target, key) {
  let depsMap = bucket.get(target);
  if (depsMap) {
    let effects = depsMap.get(key);
    effects && effects.forEach((fn) => fn());
  }
}

4.4 切换分支与 cleanup

如下代码

const data = {  ok: true,text: 'hello world' }
const obj = new proxy(data, {...})

effect(() => {
  document.body.innerText = obj.ok ? obj.text : 'not'
})

effectobj.ok 取不同值的时候,执行代码会发生变化,叫做分支切换。

很明显在 obj.ok = trueeffect 依赖 obj.text,但是当 obj.ok = false 的时候,就和 obj.text 无关,obj.text 再修改时也不应该触发函数重新执行了。

为了实现这个效果,上面的代码需要修改,每次副作用函数重新执行的时候,我们要先把它从所有与之关联的依赖集合中删除。执行后会建立新的关联。

点击查看代码
let activeEffect;
function effect(fn) {
  const effectFn = () => {
    // 执行前先清除依赖
    cleanup(effectFn);
    activeEffect = effectFn;
    fn();
    activeEffect = undefined
  };
  // 用来存储与该副作用函数相关联的依赖集合
  effectFn.deps = [];
  effectFn();
}
function cleanup(effectFn) {
  // 很简单 就是在每个依赖集合中把该函数删除
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i];
    deps.delete(effectFn);
  }
  effectFn.deps.length = 0;
}
const bucket = new WeakMap();
// 在 track 中记录 deps
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);
}

function trigger(target, key) {
  let depsMap = bucket.get(target);
  if (depsMap) {
    let effects = depsMap.get(key);
    // 不能直接执行effects 因为执行 effects 会先 cleanup 清除 bucket 中的依赖集合
    // 但是再次执行后会再在集合中添加副作用函数
    // 这样会导致死循环
    const effectsToRun = new Set(effects);
    effectsToRun.forEach((fn) => fn());
  }
}

const data = { ok: true, text: 'hello world' };
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);
    // 返回true代表设置操作成功
    return true;
  },
});
effect(() => {
  document.body.innerText = obj.ok ? obj.text : 'not';
  console.log('run!');
});
setTimeout(() => {
  obj.ok = false;
  obj.text = 'changed';
}, 1000);

注意其中的 trigger 函数

function trigger(target, key) {
  let depsMap = bucket.get(target);
  if (depsMap) {
    let effects = depsMap.get(key);
    // 不能直接执行effects 因为执行 effects 会先 cleanup 清除 bucket 中的依赖集合
    // 但是再次执行后会再在集合中添加副作用函数
    // 这样会导致死循环
    const effectsToRun = new Set(effects);
    effectsToRun.forEach((fn) => fn());
  }
}

4.3 嵌套的 effect 与 effect 栈

在 Vue 中如果我们使用组件嵌套组件,就会有 effect 嵌套执行。

如果有嵌套的 effect 执行,我们就需要在保存当前 effect 函数的同时,记录之前的 effect 函数,并在当前的函数之前完之后,把上一层的 effect 赋值为 activeEffect。很简单的会想到用栈来实现这个功能。

let activeEffect;
const effectStack = [];
function effect(fn) {
  const effectFn = () => {
    // 执行前先清除依赖
    cleanup(effectFn);
    // 执行前先压入栈中
    activeEffect = effectFn;
    effectStack.push(effect);
    fn();
    // 执行后弹出
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  };
  // 用来存储与该副作用函数相关联的依赖集合
  effectFn.deps = [];
  effectFn();
}

4.6 避免无限递归循环

const data = { foo: 1 };
const obj = new Proxy(data, {
  // ......
});
effect(() => {
  obj.foo++;
});

上面的代码会导致死循环,因为 obj.foo++; 既有取值又有赋值操作。读取的时候会把该函数添到依赖集合,赋值的时候会导致副作用函数再执行。

所以我们在 trigger 中执行副作用函数的时候,不执行当前正在处理的副作用函数,即 activeEffect

function trigger(target, key) {
  let depsMap = bucket.get(target);
  if (depsMap) {
    let effects = depsMap.get(key);
    // 不能直接执行effects 因为执行 effects 会先 cleanup 清除 bucket 中的依赖集合
    // 但是再次执行后会再在集合中添加副作用函数
    // 这样会导致死循环
    const effectsToRun = new Set();
    effects &&
      effects.forEach((effectFn) => {
        if (effectFn !== activeEffect) {
          effectsToRun.add(effectFn);
        }
      });
    effectsToRun.forEach((fn) => fn());
  }
}

4.7 调度执行

可调度,指的是当 trigger 动作触发副作用函数重新执行时,又能力决定副作用函数的执行时机、次数以及方式。

effect 函数增加选项,可以指定执行副作用函数的调度器。

点击查看代码
function effect(fn, options = {}) {
  const effectFn = () => {
    // 执行前先清除依赖
    cleanup(effectFn);
    // 执行前先压入栈中
    activeEffect = effectFn;
    effectStack.push(effectFn);
    fn();
    // 执行后弹出
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  };
  // 把 options 挂在 effectFn 上
  effectFn.options = options;
  // 用来存储与该副作用函数相关联的依赖集合
  effectFn.deps = [];
  effectFn();
}

function trigger(target, key) {
  let depsMap = bucket.get(target);
  if (depsMap) {
    let effects = depsMap.get(key);
    const effectsToRun = new Set();
    effects &&
      effects.forEach((effectFn) => {
        if (effectFn !== activeEffect) {
          effectsToRun.add(effectFn);
        }
      });
    effectsToRun.forEach((effectFn) => {
      // 如果一个副作用函数存在调度器 就用调度器执行副作用函数
      if (effectFn.options.scheduler) {
        effectFn.options.scheduler(effectFn);
      } else {
        // 否则就直接执行
        effectFn();
      }
    });
  }
}

具体的使用代码

effect(
  () => {
    console.log('obj.foo', obj.foo);
  },
  {
    scheduler(fn) {
      setTimeout(fn);
    },
  }
);
obj.foo++;
console.log('结束');

// obj.foo 1
// 结束
// obj.foo 2

我们也可以指定副作用函数的执行次数,比如我们对同一个变量连续操作了多次,我们只需要对最终的结果执行副作用函数,中间值可以被忽略。

基于调度器实现这个功能

const jobQueue = new Set();
const p = Promise.resolve();

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

effect(
  () => {
    console.log('obj.foo', obj.foo);
  },
  {
    scheduler(fn) {
      jobQueue.add(fn);
      flushJob();
    },
  }
);
obj.foo++;
obj.foo++;
obj.foo++;
console.log('结束');

// obj.foo 1
// 结束
// obj.foo 4

通过 Set 实现去重,防止函数执行多次,通过 isflushing 做标记,执行过程中不会再次执行。

4.8 计算属性 computed 和 lazy

如果我们有时希望副作用函数不要立即执行,则需要提供一个选项,lazy 来决定是否立即执行。

function effect(fn, options = {}) {
  const effectFn = () => {
    // 执行前先清除依赖
    cleanup(effectFn);
    // 执行前先压入栈中
    activeEffect = effectFn;
    effectStack.push(effectFn);
    const res = fn();
    // 执行后弹出
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
    // 存储fn的计算结果并返回
    return res;
  };
  // 把 options 挂在 effectFn 上
  effectFn.options = options;
  // 用来存储与该副作用函数相关联的依赖集合
  effectFn.deps = [];
  if (!options.lazy) {
    effectFn();
  }
  // 将副作用函数作为返回值返回
  return effectFn;
}

函数只有在不指定 lazy 的时候才立即执行,同时把函数返回,这样就可以把函数在外部获取并随时手动执行。同时 effectFn 函数返回了函数执行的结果。

现在我们如果把 effect 函数的返回值作为某个属性的 getter 那么我们每次读取这个值的时候,都会触发副作用函数的执行,也会同时收集依赖。

function computed(getter) {
  const effectFn = effect(getter, {
    lazy: true,
  });
  const obj = {
    get value() {
      return effectFn();
    },
  };
  return obj;
}
const sumRes = computed(() => obj.foo + obj.bar)
console.log(sumRes.value);//3
obj.foo = 2;
console.log(sumRes.value);//4

现在的问题时,每一次读取值,都会触发 getter 的执行,但其实只要依赖不改变,计算属性的值是不会变的。这时我们一方面可以设置一个 flag 标识是否需要重新计算,同时在依赖修改时,只需要更新这个 flag 即可,并不需要重新计算,只需要在读取时计算即可。

function computed(getter) {
  let value;
  // 当前的值是否需要重新计算
  let dirty = true;
  const effectFn = effect(getter, {
    lazy: true,
    // 神来之笔啊!如果依赖修改了 并不需要重新计算 getter 但是需要更新 dirty
    // 只需要在 scheduler 中指定调度方式即可
    scheduler() {
      dirty = true;
    }
  });
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn();
        dirty = false;
      }
      return value;
    },
  };
  return obj;
}
const sumRes = computed(() => {
  console.log('重新计算了getter');
  return obj.foo + obj.bar;
});
console.log(sumRes.value);
// 重新计算了getter
// 3
obj.foo = 2;
obj.foo = 3;
obj.foo = 4;
console.log(sumRes.value);
// 重新计算了getter
// 6
console.log(sumRes.value);
// 6
console.log(sumRes.value);
// 6

现在仍然有一个问题,就是在另一个 effect 中读取计算属性的时候,没有办法让计算属性收集依赖。

点击查看代码
function computed(getter) {
  let value;
  // 当前的值是否需要重新计算
  let dirty = true;
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      if (!dirty) {
        dirty = true;
        // 因为当 computed 的依赖改变时 computed 的值应该被重新计算
        // 这个时候需要手动触发依赖
        // 如果有依赖的话 就会重新计算 computed 的值了
        trigger(obj, 'value');
      }
    },
  });
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn();
        dirty = false;
      }
      // 读取 value 时手动进行依赖收集
      track(obj, 'value');
      return value;
    },
  };
  return obj;
}
const sumRes = computed(() => {
  return obj.foo + obj.bar;
});
effect(() => {
  console.log('sumRes', sumRes.value); // sumRes 3
});
obj.foo = 2; // sumRes 4
obj.foo = 4; // sumRes 6

在读取的时候做了依赖收集,同时在 computed 的依赖改变时,对收集的依赖触发执行。

4.9 watch 的实现原理

点击查看代码
function watch(source, cb) {
  effect(
    // 调用 traverse 递归地读取
    () => traverse(source),
    {
      scheduler() {
        cb();
      },
    }
  );
}

function traverse(value, seen = new Set()) {
  if (typeof value !== 'object' || value === null || seen.has(value)) return;
  seen.add(value);
  // 暂时只考虑对象 忽略数组等
  for (const k in value) {
    traverse(value[k], seen);
  }
  return value;
}

watch(
  obj,
  () => {
    console.log('foo 的值变了');
  }
);

obj.foo = 1;
obj.foo = 2;

watch 通过 effect 实现,第一个参数遍历传入的对象,读取对象的每一个值,第二个参数是一个回调函数,在 scheduler 中执行,也就是依赖每次更新都会执行。

不过 watch 第一个参数不一定亚奥传入一个值,也会传一个 getter 函数。同时,在 watch 中我们还希望获取改变前后的值。

点击查看代码
function watch(source, cb) {
  let getter;
  if (typeof source === 'function') {
    getter = source;
  } else {
    getter = () => traverse(source);
  }
  let oldValue, newValue;
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler() {
      newValue = effectFn();
      cb(newValue, oldValue);
      oldValue = newValue;
    },
  });
  oldValue = effectFn();
}

watch(
  () => obj.foo,
  (newValue, oldValue) => {
    console.log('foo 的值变了', newValue, oldValue);
  }
);

改动1,判断用户传入的参数是否是函数

改动2,在 scheduler 手动调用副作用函数,获取最新的值并缓存,然后在回调时传入。这里使用了 lazy,是为了手动调用第一次副作用函数以获取 oldValue

4.10 立即执行的 watch 与回调执行时机

新增 immediate 选项,让回调函数可以立即执行。

点击查看代码
function watch(source, cb, options = {}) {
  let getter;
  if (typeof source === 'function') {
    getter = source;
  } else {
    getter = () => traverse(source);
  }
  let oldValue, newValue;
  // 把scheduler调度函数提取为job函数
  const job = () => {
    newValue = effectFn();
    cb(newValue, oldValue);
    oldValue = newValue;
  };

  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: job,
  });
  if (options.immediate) {
    // 当immediate=true回立即执行回调函数
    job()
  } else {
    oldValue = effectFn();
  }
}

watch(
  () => obj.foo,
  (newValue, oldValue) => {
    console.log('foo 的值变了', newValue, oldValue);
  },
  {
    immediate: true
  }
);

这样,在第一次执行获取初始值之后也会立即执行回调函数,不过第一次的 oldValueundefined

除了立即执行,我们也可以通过其他方式指定回调函数执行时机。

点击查看代码
function watch(source, cb, options = {}) {
  let getter;
  if (typeof source === 'function') {
    getter = source;
  } else {
    getter = () => traverse(source);
  }
  let oldValue, newValue;
  // 把scheduler调度函数提取为job函数
  const job = () => {
    newValue = effectFn();
    cb(newValue, oldValue);
    oldValue = newValue;
  };

  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: () => {
      // 设置post就放入微任务队列中执行
      if (options.flush === 'post') {
        const p = Promise.resolve();
        p.then(job);
      } else {
        job(); // 同步执行
      }
    },
  });
  if (options.immediate) {
    // 当immediate=true回立即执行回调函数
    job();
  } else {
    oldValue = effectFn();
  }
}

watch(
  () => obj.foo,
  (newValue, oldValue) => {
    console.log('foo 的值变了', newValue, oldValue);
  },
  {
    flush: 'post', // 'pre'|'post'|'sync'
  }
);

这样的话会导致连续修改数据的时候,执行结果有点问题,毕竟相当是数据全部改变后统一执行回调函数了。

4.11 过期的副作用

竞态问题,连续发送两次请求,后面的先返回,导致先发送的返回结果覆盖了后面的请求。而一般的需求是,保留最后一次请求的结果。

在第二次发送请求的时候,第一次请求已经“过期”,我们应该将其设置无效。

假设在 watch 我们会发送一个异步请求

let finalData
watch(obj, async () => {
  const res = await fetch('/path/to/request')
  finalData = res
})

可以通过在 watch 的回调函数中新增一个 onInvalidate 参数解决。

点击查看代码
function watch(source, cb, options = {}) {
  let getter;
  if (typeof source === 'function') {
    getter = source;
  } else {
    getter = () => traverse(source);
  }
  let oldValue, newValue;
  // cleanup 用于存储用户注册的过期回调
  let cleanup;

  function onInvalidate(fn) {
    cleanup = fn;
  }

  // 把scheduler调度函数提取为job函数
  const job = () => {
    newValue = effectFn();
    // 在执行回调函数之前 先执行过期函数
    // 我们在回调函数中会调用失效函数 会把过期函数绑在cleanup上
    // 我们先调用的回调会先把失效函数绑定
    // 而如果在上一次回调函数执行之前 就触发了下一次的执行 就会调用失效函数
    // 也就是上一次的回调函数对应的失效函数 则上一次的结果会被取消
    if (cleanup) {
      cleanup();
    }
    cb(newValue, oldValue, onInvalidate);
    oldValue = newValue;
  };

  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: () => {
      // 设置post就放入微任务队列中执行
      if (options.flush === 'post') {
        const p = Promise.resolve();
        p.then(job);
      } else {
        job(); // 同步执行
      }
    },
  });
  if (options.immediate) {
    // 当immediate=true回立即执行回调函数
    job();
  } else {
    oldValue = effectFn();
  }
}

let t = 0;
const mock = () => {
  return new Promise((resolve) => {
    if (++t <= 2) {
      // 模拟一下 前两次需要1s返回 第3次立即返回
      t = 1000;
    }
    setTimeout(() => {
      resolve(1);
    }, t);
  });
};

watch(
  () => obj.foo,
  async (newValue, oldValue, onInvalidate) => {
    console.log('foo 的值变了', newValue, oldValue);
    let expired = false;
    onInvalidate(() => {
      expired = true;
    });
    const res = await mock(newValue);
    console.log(expired ? '过期了' : '未过期', 'newValue=' + newValue);
    if (!expired) {
      finalData = res;
    }
  }
);

原理有点复杂,就是说第一次每次执行回调的请求之前给 watch 传一个过期函数,然后 watch 把它保存起来,然后在这个过程中如果再次执行 watch 了,就会执行之前保存的过期函数,就会把上次的请求设置为不合法,还挺有趣的=。= 之前面试官还问过我这个问题emmm我都不会。

总结

就是通过 effect 执行副作用函数,把当前执行的函数保存到全局,因为有嵌套执行的情况所以需要用栈来保存。在对象的 get 收集依赖,set 时执行对应依赖,而且每一次执行之前还需要情况上一次执行的依赖。

还有注意事项就是在 get 中如果又 set 当前正在执行的副作用函数,不会触发执行。

computedwatcheffect 上面进行一层封装,加了 lazyschedular 等选项。

还有竞态问题,通过 onInvalidate 解决。

把整章代码全部敲了一遍,感觉确实学到了不少:D

posted @ 2023-01-18 18:01  我不吃饼干呀  阅读(200)  评论(0编辑  收藏  举报