Skip to content

计算属性 computed 与 lazy

在深入讲解计算属性之前,我们需要先来聊聊关于懒执行的 effect,即 lazy 的effect。 目前实现的 effect 函数会立即执行传递给它的副作用函数

js
effect(
    // 这个函数会立即执行
    () => {
        console.log(obj.foo)
    }
)

但在有些场景下,我们并不希望它立即执行,而是希望它在需要的时候才执行,例如计算属性。这时我们可以通过在 options 中添加 lazy 属性来达到目的,如下面的代码所示:

js
effect(
  // 指定了 lazy 选项,这个函数不会立即执行
  () => {
    console.log(obj.foo)
  },
  // options
  {
    lazy: true
  }
)

lazy 选项和之前介绍的 scheduler 一样,它通过 options 选项对象指定。有了它,我们就可以修改 effect 函数的实现逻辑了,当 options.lazy 为 true 时,则不立即执行副作用函数:

js
// 存储副作用函数的桶  Set 改为 WeakMap
const bucket = new WeakMap();
// 原始数据 变为两个属性
const data = { foo: 1, bar: 1 };

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

// 对原始数据的代理
export 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);
    return true;
  },
});

// 在 get 拦截函数内调用 track 函数追踪变化
export 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 相关联的副作用函数:effects
  let deps = depsMap.get(key);
  // 如果 deps 不存在,同样新建一个 Set 并与 key 关联
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  // 最后将当前激活的副作用函数添加到“桶”里
  deps.add(activeEffect);
  // deps 就是一个与当前副作用函数存在联系的依赖集合
  // 将其添加到 activeEffect.deps 数组中
  activeEffect.deps.push(deps); // 新增
}

// 在 set 拦截函数内调用 trigger 函数触发变化
export function trigger(target, key) {
  // 根据 target 从桶中取得 depsMap,它是 key --> effects
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  // 根据 key 取得所有副作用函数 effects
  const effects = depsMap.get(key);
  // 执行副作用函数
  // 使用一个新 set 避免死循环
  const effectsToRun = new Set(effects);
  effectsToRun.forEach((effectFn) => {
    // effectFn !== activeEffect &&
    //   effectStack.every((effectFn) => effectFn !== activeEffect) &&
    //   effectFn();
    if (
      effectFn !== activeEffect &&
      effectStack.every((effectFn) => effectFn !== activeEffect)
    ) {
      // 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
      if (effectFn.options.scheduler) {
        // 新增
        effectFn.options.scheduler(effectFn); // 新增
      } else {
        // 否则直接执行副作用函数(之前的默认行为)
        effectFn(); // 新增
      }
    }
  });
}

function cleanup(effectFn) {
  // 遍历 effectFn.deps 数组
  for (let i = 0; i < effectFn.deps.length; i++) {
    // deps 是依赖集合
    const deps = effectFn.deps[i];
    // 将 effectFn 从依赖集合中移除
    deps.delete(effectFn);
  }
  // 最后需要重置 effectFn.deps 数组
  effectFn.deps.length = 0;
}

// 添加配置参数 options
export function effect(fn, options = {}) {
  const effectFn = () => {
    // 调用 cleanup 函数完成清除工作
    cleanup(effectFn);
    // 当 effectFn 执行时,将其设置为当前激活的副作用函数
    activeEffect = effectFn;
    // 用一个全局变量存储当前激活的 effect 函数
    // 在调用副作用函数之前将当前副作用函数压入栈中
    effectStack.push(effectFn);
    const res = fn(); 
    // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];

    return res; 
  };

  // 将 options 挂载到 effectFn 上
  effectFn.options = options;
  // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = [];
  // 只有非 lazy 的时候,才执行
  if (!options.lazy) {
    // 执行副作用函数
    effectFn();
  }
  // 将副作用函数作为返回值返回
  return effectFn; // 新增
}

const effectFn = effect(
  // getter 返回 obj.foo 与 obj.bar 的和
  () => obj.foo + obj.bar,
  { lazy: true } 
);

// 手动执行副作用函数
// effectFn();
const value = effectFn();
html
<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8">
    <title>Demo</title>
</head>

<body>
</body>
<script src="./8_计算属性_computed.js" type="module"></script>

</html>

通过这个判断,我们就实现了让副作用函数不立即执行的功能。但问题是,副作用函数应该什么时候执行呢?通过上面的代码可以看到,我们将副作用函数 effectFn 作为effect 函数的返回值,这就意味着当调用 effect 函数时,通过其返回值能够拿到对应的副作用函数,这样我们就能手动执行该副作用函数了

js
const effectFn = effect(() => {
  console.log(obj.foo)
}, { lazy: true })

// 手动执行副作用函数
effectFn()

如果仅仅能够手动执行副作用函数,其意义并不大。但如果我们把传递给 effect 的函数看作一个 getter,那么这个 getter 函数可以返回任何值,例如:

js
const effectFn = effect(
  // getter 返回 obj.foo 与 obj.bar 的和
  () => obj.foo + obj.bar,
  { lazy: true }
)

const val = effectFn()

现在我们已经能够实现懒执行的副作用函数,并且能够拿到副作用函数的执行结果了,接下来就可以实现计算属性了,如下所示:

js
function computed(getter) {
  // 把 getter 作为副作用函数,创建一个 lazy 的 effect
  const effectFn = effect(getter, {
    lazy: true
  })

  const obj = {
    // 当读取 value 时才执行 effectFn
    get value() {
      return effectFn()
    }
  }

  return obj
}

watch 的实现原理

本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数

js
 watch(obj, () => {
   console.log('数据变了')
 })

 // 修改响应数据的值,会导致回调函数执行
 obj.foo++

最简单的 watch 函数的实现

js
// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {
  effect(
    // 触发读取操作,从而建立联系
    () => source.foo,
    {
      scheduler() {
        // 当数据变化时,调用回调函数 cb
        cb()
      }
    }
  )
}
js
import { effect, obj } from './0_effect.js';

// // watch 函数接收两个参数,source 是响应式数据,cb 是回调函数 (硬编码)
// function watch1(source, cb) {
//   effect(
//     // 触发读取操作,从而建立联系
//     () => source.foo,
//     {
//       scheduler() {
//         // 当数据变化时,调用回调函数 cb
//         cb();
//       },
//     }
//   );
// }

// watch1(obj, () => {
//   console.log('数据变化了');
// });

// obj.foo++;

function watch(source, cb) {
  let getter;
  if (typeof source === 'function') {
    getter = source;
  } else {
    getter = () => traverse(source);
  }
  // 定义旧值与新值
  let oldValue, newValue;
  // 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler() {
      // 在 scheduler 中重新执行副作用函数,得到的是新值
      newValue = effectFn();
      // 将旧值和新值作为回调函数的参数
      cb(newValue, oldValue);
      // 更新旧值,不然下一次会得到错误的旧值
      oldValue = newValue;
    },
  });
  // 手动调用副作用函数,拿到的值就是旧值
  oldValue = effectFn();
}

function traverse(value, seen = new Set()) {
  // 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
  if (typeof value !== 'object' || value === null || seen.has(value)) return;
  // 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
  seen.add(value);
  // 暂时不考虑数组等其他结构
  // 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
  for (const k in value) {
    traverse(value[k], seen);
  }

  return value;
}

// watch(
//   () => obj.foo,
//   // 回调函数
//   () => {
//     console.log('obj.foo 的值变了');
//   }
// );

// obj.foo++;

watch(
  () => obj.foo,
  (newValue, oldValue) => {
    console.log(newValue, oldValue); // 2, 1
  }
);

obj.foo++;
html
<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8">
    <title>Demo</title>
</head>

<body>
</body>
<script src="./9_watch.js" type="module"></script>

</html>

最核心的改动是使用 lazy 选项创建了一个懒执行的 effect。注意上面代码中最下面的部分,我们手动调用 effectFn 函数得到的返回值就是旧值,即第一次执行得到的值

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

watch 的两个特性:一个是立即执行的回调函数,另一个是回调函数的执行时机。

js
watch(obj, () => {
  console.log('变化了')
}, {
  // 回调函数会在 watch 创建时立即执行一次
  immediate: true
})

flush 本质上是在指定调度函数的执行时机。前文讲解过如何在微任务队列中执行调度函数 scheduler,这与 flush 的功能相同。当 flush 的值为 'post' 时,代表调度函数需要将副作用函数放到一个微任务队列中,并等待 DOM 更新结束后再执行

js
import { effect, obj } from './0_effect.js';

// 添加 options
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
    () => getter(),
    {
      lazy: true,
      scheduler: () => {
        // 在调度函数中判断 flush 是否为 'post',如果是,将其放到微任务队列中执行
        if (options.flush === 'post') {
          const p = Promise.resolve();
          p.then(job);
        } else {
          job();
        }
      },
    }
  );

  if (options.immediate) {
    // 当 immediate 为 true 时立即执行 job,从而触发回调执行
    job();
  } else {
    oldValue = effectFn();
  }
}

function traverse(value, seen = new Set()) {
  // 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
  if (typeof value !== 'object' || value === null || seen.has(value)) return;
  // 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
  seen.add(value);
  // 暂时不考虑数组等其他结构
  // 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
  for (const k in value) {
    traverse(value[k], seen);
  }

  return value;
}

// watch(
//   () => obj.foo,
//   // 回调函数
//   () => {
//     console.log('obj.foo 的值变了');
//   }
// );

// obj.foo++;

watch(
  () => obj.foo,
  (newValue, oldValue) => {
    console.log(newValue, oldValue); // 2, 1
  },
  {
    immediate: true,

    // 回调函数会在 watch 创建时立即执行一次
    flush: 'pre', // 还可以指定为 'post' | 'sync'
  }
);
obj.foo++;
obj.foo++;
html
<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8">
    <title>Demo</title>
</head>

<body>
</body>
<script src="./10_执行时机_watch.js" type="module"></script>

</html>

过期的副作用

竞态问题

js
let finalData

watch(obj, async () => {
  // 发送并等待网络请求
  const res = await fetch('/path/to/request')
  // 将请求结果赋值给 data
  finalData = res
})

在 Vue.js 中,watch 函数的回调函数接收第三个参数 onInvalidate,它是一个函数,类似于事件监听器,我们可以使用 onInvalidate 函数注册一个回调,这个回调函数会在当前副作用函数过期时执行

js
 watch(obj, async (newValue, oldValue, onInvalidate) => {
   // 定义一个标志,代表当前副作用函数是否过期,默认为 false,代表没有过期
   let expired = false
   // 调用 onInvalidate() 函数注册一个过期回调
   onInvalidate(() => {
     // 当过期时,将 expired 设置为 true
     expired = true
   })

   // 发送网络请求
   const res = await fetch('/path/to/request')

   // 只有当该副作用函数的执行没有过期时,才会执行后续操作。
   if (!expired) {
     finalData = res
   }
 })
js
import { effect, obj } from './0_effect.js';

// 添加 options
function watch(source, cb, options = {}) {
  let getter;
  if (typeof source === 'function') {
    getter = source;
  } else {
    getter = () => traverse(source);
  }

  let oldValue, newValue;

  // cleanup 用来存储用户注册的过期回调
  let cleanup;
  // 定义 onInvalidate 函数
  function onInvalidate(fn) {
    // 将过期回调存储到 cleanup 中
    cleanup = fn;
  }

  const job = () => {
    newValue = effectFn();
    // 在调用回调函数 cb 之前,先调用过期回调
    if (cleanup) {
      cleanup();
    }
    // 将 onInvalidate 作为回调函数的第三个参数,以便用户使用
    cb(newValue, oldValue, onInvalidate);
    oldValue = newValue;
  };

  const effectFn = effect(
    // 执行 getter
    () => getter(),
    {
      lazy: true,
      scheduler: () => {
        if (options.flush === 'post') {
          const p = Promise.resolve();
          p.then(job);
        } else {
          job();
        }
      },
    }
  );

  if (options.immediate) {
    job();
  } else {
    oldValue = effectFn();
  }
}

function traverse(value, seen = new Set()) {
  // 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
  if (typeof value !== 'object' || value === null || seen.has(value)) return;
  // 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
  seen.add(value);
  // 暂时不考虑数组等其他结构
  // 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
  for (const k in value) {
    traverse(value[k], seen);
  }

  return value;
}
watch(obj, async (newValue, oldValue, onInvalidate) => {
  let expired = false;
  onInvalidate(() => {
    expired = true;
  });

  const time = Math.random() * 1000;

  await new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, newValue.foo * 1000);
  });
  // console.log('结果:' + expired + time + '--' + newValue.foo);
  if (!expired) {
    console.log('结果:' + time + '--' + newValue.foo);
  }
});

// 第一次修改
obj.foo++;
setTimeout(() => {
  obj.foo = 3;
}, 700);

setTimeout(() => {
  obj.foo = 1;
}, 990);
html
<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8">
    <title>Demo</title>
</head>

<body>
</body>
<script src="./11_过期_watch.js" type="module"></script>

</html>