普通视图

发现新文章,点击刷新页面。
昨天以前首页

深入剖析Vue框架:实现精简的computed

作者 Slice_cy
2025年9月12日 17:51

一、前言

在上一篇中,我们已经实现了一个基础的响应式系统。如果不知道怎么构建一个基础的响应式系统,深入剖析 Vue 响应式系统:从零实现一个精简版 。现在,我们将在此基础上,手动实现 Vue 的 computed 函数。

源码地址:github.com/chuyuan132/…

二、了解 computed 的核心特性

要实现 computed,首先要理解它的两个核心特性:

  1. 懒执行 (Lazy Evaluation) :只有当你真正访问 computed 属性的值时,它才会执行计算。
  2. 缓存 (Caching) :如果依赖的响应式数据没有发生变化,computed 会返回上一次缓存的值,而不是重新计算。这大大提升了性能。

computed 的实现会借用我们之前实现的 effect 函数。如果不了解effect如何实现的,请浏览深入剖析 Vue 响应式系统:从零实现一个精简版。为了满足懒执行的特性,我们需要改造 effect 函数,增加一个 lazy 选项。

三、改造 effect 函数以支持懒执行

在我们之前的 effect 函数中,只要调用 effect(fn),副作用函数 fn 就会立即执行。为了实现懒执行,我们可以添加一个 lazy 选项。如果 lazytrueeffect 函数将不再立即执行 fn,而是直接返回 effectFn 本身。

// ...(省略其他代码)

const defaultOptions = {
  scheduler: null,
  lazy: false,
};

function effect(fn, options = defaultOptions) {
  function effectFn() {
    // ...(省略原有逻辑)
    const res = fn(); // 副作用函数执行,返回其返回值
    // ...(省略原有逻辑)
    return res;
  }
  effectFn.deps = [];
  effectFn.options = options;

  if (!options.lazy) {
    // 如果不是懒执行,则立即执行
    effectFn();
  }
  // 无论是否立即执行,都返回 effectFn
  return effectFn;
}

四、实现 computed 函数

有了支持懒执行的 effect 函数,我们就可以着手实现 computed 了。computed 函数接受一个 getter 函数作为参数,并返回一个对象,该对象有一个 value 属性。

1. 基础结构

首先,我们利用 effectlazy 选项来创建 computed 的基本结构。

function computed(getter) {
  const effectFn = effect(getter, { lazy: true });

  const obj = {
    get value() {
      // 访问 value 时,才执行 effectFn
      return effectFn();
    },
  };

  return obj;
}

这段代码已经实现了懒执行,但还缺少缓存功能。

2. 添加缓存机制

为了实现缓存,我们需要一个变量来追踪 computed 是否需要重新计算。我们称这个变量为 dirty

  • dirtytrue 时,表示值是“脏”的,需要重新计算。
  • dirtyfalse 时,表示值是干净的,可以返回缓存值。

同时,我们还需要一个 value 变量来保存缓存的结果。

function computed(getter) {
  let dirty = true;
  let value = undefined;

  const effectFn = effect(getter, { lazy: true });

  const obj = {
    get value() {
      // 只有当 dirty 为 true 时才重新计算
      if (dirty) {
        // 执行 effectFn 并将结果缓存到 value
        value = effectFn();
        // 重新计算后,将 dirty 设为 false
        dirty = false;
      }
      return value;
    },
  };

  return obj;
}

这段代码实现了缓存,但 dirty 一旦变为 false 就再也没有机会变回 true 了。我们如何让它在依赖变化时重新变为 true 呢?

3. 依赖变化时重新计算

getter 函数内部的响应式依赖发生变化时,dirty 应该被重置为 true。这里,scheduler 调度器就派上用场了。

我们可以给 effect 函数传入一个 scheduler 选项。当 getter 的依赖变化时,scheduler 函数会被调用。我们可以在这个函数里将 dirty 设为 true

function computed(getter) {
  let dirty = true;
  let value = undefined;

  const effectFn = effect(getter, {
    lazy: true,
    scheduler: () => {
      // 依赖变化时,将 dirty 设为 true
      dirty = true;
    },
  });

  const obj = {
    get value() {
      if (dirty) {
        value = effectFn();
        dirty = false;
      }
      return value;
    },
  };

  return obj;
}

4. 解决嵌套副作用函数的依赖问题

现在,如果我们在另一个 effect 函数中访问 computedvalue,会发生什么?

const name = createProxy({ firstName: 'Jack', lastName: 'Chen' });

const fullName = computed(() => `${name.firstName} ${name.lastName}`);

effect(() => {
  // 当 fullName.value 变化时,这个副作用函数应该重新执行
  console.log(fullName.value);
});

// 依赖改变,fullName.value 应该更新
name.firstName = 'Mike';

name.firstName 改变时,computedscheduler 会被调用,将 dirty 设为 true。但 effect 函数并不知道 fullName.value 的值变了,所以它不会重新执行。

为了解决这个问题,我们需要让 computed 能够像普通响应式数据一样被追踪。也就是说,当 computed 的值发生变化时,它应该能够触发依赖它的副作用函数。

我们可以在 computedget value() 内部进行 依赖收集(track ,并在 scheduler触发依赖(trigger

// ... (省略其他代码)

// 优化后的 computed 函数
function computed(getter) {
  let dirty = true;
  let value = undefined;

  // 使用 effect 的 scheduler,当依赖变化时,触发 computed 的更新
  const runner = effect(getter, {
    lazy: true,
    scheduler() {
      if (!dirty) {
        dirty = true;
        // 触发 computed 值的依赖
        trigger(proxy, 'value');
      }
    },
  });

  const obj = {
    get value() {
      if (dirty) {
        value = runner();
        dirty = false;
      }
      // 在访问 computed.value 时,进行依赖收集
      track(proxy, 'value');
      return value;
    },
  };
  
  return obj
}

经过以上优化,我们成功地实现了一个完整的 computed 函数,它同时具备了懒执行缓存响应式的特性。

五、演示

现在,你可以用新的 computed 函数来测试一下,看看它是否能正常工作。

 const obj = {
    count: 0,
 };

const proxyObj = createProxy(obj);

const result = computed(() => proxyObj.count + 1);

effect(() => {
console.log("访问计算属性的effect函数", result.value);
});

setTimeout(() => {
proxyObj.count = 2;
}, 1000);

不定高虚拟列表

作者 Slice_cy
2025年9月11日 17:37

在前端开发中,处理海量数据列表是一个常见的性能挑战。当列表项成千上万时,直接渲染所有 DOM 节点会导致页面卡顿甚至崩溃。虚拟列表(Virtual List) 是解决这一问题的最佳实践,它只渲染可视区域内的列表项,大大减少了 DOM 节点数量,从而实现丝滑般的滚动体验。

本文将手把手教你如何从零开始,实现一个功能完备、支持可变高度的高性能虚拟列表组件。

核心思想:只渲染“看得见”的部分

虚拟列表的核心思想非常直观: “用空间换时间”

它通过计算和只渲染当前用户可见区域内的列表项,同时利用一个大高度的空白占位元素(empty-block)来模拟完整列表的滚动条,从而欺骗浏览器,让用户以为整个列表都在页面上。

我们的实现将包含以下几个关键步骤:

  1. 确定可视区域:通过监听滚动事件,动态计算当前可视区域的起始和结束索引。
  2. 数据裁剪与渲染:根据可视区域的索引,从完整数据中截取出一部分,并将其渲染到页面上。
  3. 动态定位:利用 transform: translateY() 属性,将渲染的列表块精确地定位到正确的位置。
  4. 可变高度处理:这是难点,我们需要一个数据结构来动态存储和更新每个列表项的实际高度和位置。

代码实现与核心逻辑剖析

以下是虚拟列表组件的完整代码。我将通过注释和分段讲解,带你深入理解每一个细节。

📜 组件模板 (template)

组件的 DOM 结构非常简洁,主要由三个部分组成:

  • .virtual-content:承载所有内容的容器,负责监听滚动事件。
  • .empty-block:一个巨大的空白占位元素,它的高度等于所有列表项的总高度,用于撑起滚动条。
  • .virtual-list:实际渲染列表项的容器,通过 transform: translateY() 实现精准定位。

HTML

<template>
  <div class="virtual-content" ref="screenRef" @scroll="scrollEvent">
    <div
      class="empty-block"
      :style="{ height: virtualTotalHeight + 'px' }"
    ></div>

    <div
      class="virtual-list"
      ref="listRef"
      :style="{ transform: `translateY(${offsetY}px)` }"
    >
      <div v-for="item in visibleList" :key="item._index" :id="item._index">
        <slot :item="item.item"></slot>
      </div>
    </div>
  </div>
</template>

🧩 组件逻辑 (script)

核心数据管理

我们使用几个 refcomputed 变量来管理组件的状态。

  • virtualList:完整的列表数据,我们为每个列表项添加一个 _index 属性,用于唯一标识。
  • positions这是实现可变高度的关键。它是一个数组,记录了每个列表项的预估/实际高度 (height)、顶部距离 (top) 和底部距离 (bottom)。
  • visibleCount:可视区域内可以容纳的列表项数量。
  • startIndex / endIndex:当前可视区域的起始和结束索引。
  • offsetY:列表容器的 transform: translateY 偏移量。

JavaScript

// ... 省略部分代码

interface VirtualListItem {
  _index: number;
  item: any;
}
interface PositionItem {
  height: number;
  top: number;
  bottom: number;
}

const virtualList = ref<VirtualListItem[]>([]);
const screenHeight = ref(0);
const screenRef = ref();
const listRef = ref();
const positions = ref<PositionItem[]>([]);
const visibleCount = ref(0);
const startIndex = ref(0);
const endIndex = ref(0);
const offsetY = ref(0);

// 监听父组件传入的列表,初始化虚拟列表和位置信息
watch(
  () => props.list,
  (value: any[]) => {
    virtualList.value = value.map((item, index) => {
      return {
        _index: index,
        item,
      };
    });
    // 初始化 positions 数组,使用预估高度
    positions.value = initPositions(virtualList.value);
  },
  { immediate: true, deep: true }
);

// 计算所有列表项的总高度
const virtualTotalHeight = ref(0);

// 计算当前应渲染的可见列表,并加上缓冲区域
const visibleList = computed(() => {
  return virtualList.value.slice(
    startIndex.value - aboveCount.value,
    endIndex.value + belowCount.value
  );
});

// 计算底部缓冲区的数量
const belowCount = computed(() => {
  return Math.min(
    virtualList.value.length - endIndex.value,
    Math.floor(props.bufferScale * visibleCount.value)
  );
});

// 计算顶部缓冲区的数量
const aboveCount = computed(() => {
  return Math.min(
    startIndex.value,
    Math.floor(props.bufferScale * visibleCount.value)
  );
});

核心函数解析

  1. initPositions:初始化预估位置

    • 在初次渲染时,我们不知道每个列表项的实际高度。
    • 这个函数根据 estimatedItemSize(预估高度)来初始化 positions 数组,为每个列表项计算一个预估的 topbottom 值。
  2. binarySearch:二分查找优化

    • 这是一个非常重要的优化点。
    • 当滚动时,我们需要快速找到当前可视区域的第一个列表项的索引。
    • binarySearch 函数通过二分查找,根据当前的 scrollTop 值,在 positions 数组中高效地找到对应的 startIndex。这比线性遍历快得多。
  3. updateItemsSize:动态更新实际高度

    • visibleList 渲染到 DOM 后,我们可以获取每个列表项的实际高度
    • 这个函数在 onUpdated 生命周期钩子中被调用。
    • 它会遍历所有可见的 DOM 节点,获取它们的 clientHeight
    • 然后,它会比较实际高度和 positions 中记录的高度,如果存在差异,就会更新该列表项的 heighttopbottom,并级联更新它之后的所有列表项的位置信息。
  4. scrollEvent:滚动事件处理

    • 当用户滚动时,这个函数被触发。
    • 它获取 scrollTop,并使用 binarySearch 找到 startIndex
    • 然后,计算 endIndex,最后调用 setOffsetY 来定位列表。
  5. setOffsetY:设置列表偏移量

    • 这个函数根据 startIndex 和缓冲区的数量,计算出 offsetY 的值。
    • offsetY 决定了 .virtual-list 容器的 transform: translateY 偏移量,从而确保列表项能够准确地显示在可视区域。
  6. 生命周期钩子 (onMounted, onUpdated)

    • onMounted:组件挂载后,获取容器的实际高度,并计算 visibleCount 和初始的 endIndex
    • onUpdated:在数据更新后(例如 visibleList 变化导致 DOM 重新渲染),nextTick 确保 DOM 更新完毕,然后调用 updateItemsSize 来更新列表项的实际高度和位置信息,并重新计算总高度和偏移量。

完整代码

import { ref, watch, onMounted, computed, onUpdated, nextTick } from "vue";
import { cloneDeep } from "lodash-es";
const props = defineProps({
  list: {
    type: Array,
    default: () => [],
  },
  estimatedItemSize: {
    type: Number,
    default: 100,
  },
  bufferScale: {
    type: Number,
    default: 0.5,
  },
});

const emits = defineEmits(["loadmore"]);

interface VirtualListItem {
  _index: number;
  item: any;
}
interface PositionItem {
  height: number;
  top: number;
  bottom: number;
}

const virtualList = ref<VirtualListItem[]>([]);
const screenHeight = ref(0);
const screenRef = ref();
const listRef = ref();
const positions = ref<PositionItem[]>([]);
const visibleCount = ref(0);
const startIndex = ref(0);
const endIndex = ref(0);
const offsetY = ref(0);
watch(
  () => props.list,
  (value: any[]) => {
    virtualList.value = value.map((item, index) => {
      return {
        _index: index,
        item,
      };
    });
    positions.value = initPositions(virtualList.value);
  },
  { immediate: true, deep: true }
);

const virtualTotalHeight = ref(0);

const visibleList = computed(() => {
  return virtualList.value.slice(
    startIndex.value - aboveCount.value,
    endIndex.value + belowCount.value
  );
});

const belowCount = computed(() => {
  return Math.min(
    virtualList.value.length - endIndex.value,
    Math.floor(props.bufferScale * visibleCount.value)
  );
});

const aboveCount = computed(() => {
  return Math.min(
    startIndex.value,
    Math.floor(props.bufferScale * visibleCount.value)
  );
});

function initPositions(list: any[]) {
  return list.map((item: VirtualListItem) => {
    return {
      height: props.estimatedItemSize,
      top: item._index * props.estimatedItemSize,
      bottom: (item._index + 1) * props.estimatedItemSize,
    };
  });
}

const binarySearch = (list: PositionItem[], target: number) => {
  let left = 0;
  let right = list.length - 1;
  let tempIndex = null;

  while (left <= right) {
    let midIndex = Math.floor((left + right) / 2);
    let midValue = list[midIndex].bottom;
    if (midValue === target) {
      return midIndex + 1;
    } else if (midValue < target) {
      left = midIndex + 1;
    } else {
      if (tempIndex === null || tempIndex > midIndex) {
        tempIndex = midIndex;
      }
      right = midIndex - 1;
    }
  }
  return tempIndex as number;
};

function updateItemsSize() {
  const nodes = Array.from(listRef.value.children);
  if (!nodes || !nodes.length) return;
  const clonePosition = cloneDeep(positions.value);
  (nodes as HTMLElement[]).forEach((node) => {
    const height = node.clientHeight;
    const index = Number(node.id);
    const oldHeight = clonePosition[index].height;
    const diff = oldHeight - height;
    if (Math.abs(diff)) {
      clonePosition[index].bottom -= diff;
      clonePosition[index].height = height;
      for (let k = index + 1; k < clonePosition.length; k++) {
        clonePosition[k].top = clonePosition[k - 1].bottom;
        clonePosition[k].bottom -= diff;
      }
    }
  });
  positions.value = clonePosition;
}

async function scrollEvent(e: Event) {
  const scrollTop = (e.target as HTMLElement).scrollTop;
  startIndex.value = binarySearch(positions.value, scrollTop);
  endIndex.value = startIndex.value + visibleCount.value;
  setOffsetY();
}

function setOffsetY() {
  if (startIndex.value >= 1) {
    const size =
      positions.value[startIndex.value - 1].bottom -
      positions.value[startIndex.value - aboveCount.value].top;
    offsetY.value = positions.value[startIndex.value].top - size;
  } else {
    offsetY.value = 0;
  }
}

onUpdated(() => {
  nextTick(() => {
    if (!positions.value.length) return;
    updateItemsSize();
    virtualTotalHeight.value =
      positions.value[positions.value.length - 1].bottom;
    setOffsetY();
  });
});
onMounted(() => {
  screenHeight.value = screenRef.value.clientHeight;
  visibleCount.value = Math.ceil(screenHeight.value / props.estimatedItemSize);
  startIndex.value = 0;
  endIndex.value = startIndex.value + visibleCount.value;
});

🎨 组件样式 (style)

为了保证组件的正确渲染和性能,CSS 样式也至关重要。

  • .virtual-content 容器设置为相对定位,overflow: auto 以便创建滚动条。
  • .empty-block.virtual-list 均采用绝对定位,确保它们可以精准地覆盖在 virtual-content 容器内。
  • z-index 的设置保证了 virtual-listempty-block 之上,同时 empty-block 负责撑开滚动条。

CSS

.virtual-content {
  height: 100%;
  overflow: auto;
  position: relative;
}
.virtual-list {
  position: absolute;
  left: 0;
  right: 0;
  z-index: 1;
}
.empty-block {
  left: 0;
  right: 0;
  top: 0;
  position: absolute;
  z-index: -1;
}

使用组件

<template>
  <div class="demo">
    <VirtualList :list="list" :loadMore="loadmore">
      <template #default="{ item }">
        <div class="item">{{ item.key }} - {{ item.value }}</div>
      </template>
    </VirtualList>
  </div>
</template>

<script setup lang="ts">
import VirtualList from "./components/virtual-list/index.vue";
import faker from "faker";

import { onMounted, ref } from "vue";
const list = ref<any[]>([]);

const mockData = () => {
  const data = [];
  for (let i = 0; i < 100; i++) {
    data.push({
      value: faker.lorem.sentences(),
      key: i,
    });
  }
  list.value = data;
};

onMounted(() => {
  mockData();
});
</script>

<style scoped>
.demo {
  height: 100vh;
}
.item {
  width: 100%;
  background-color: #fff;
  border-bottom: 1px solid red;
  padding: 20px 0;
}
</style>

演示:

结语

通过以上实现,我们成功构建了一个支持可变高度、性能优异的虚拟列表组件。它巧妙地利用了 Proxy 的能力,通过动态计算和定位,解决了长列表的渲染性能瓶颈。希望这份详细的文档能帮助你更好地理解虚拟列表的实现原理,并在你的项目中发挥作用!

深入剖析 Vue 响应式系统:从零实现一个精简版

作者 Slice_cy
2025年9月11日 17:01

Vue 的响应式系统是其核心魅力之一,它能够在你改变数据时,自动更新依赖这些数据的视图。本文将从零开始,带你深入剖析 Vue 响应式系统的核心机制,逐步构建一个精简版的响应式系统。

一、什么是副作用函数(Effect Function)?

在编程世界中,副作用函数(effect function)是指那些会间接或直接改变外部状态的函数。在 Vue 的响应式系统中,它扮演着至关重要的角色,特指那些“依赖于响应式数据,并在数据变化时会自动重新执行”的函数。

举个例子,下面的 effect 函数就是一个典型的副作用函数。它通过读取响应式对象 obj.title 的值,从而改变了外部的 document.body.innerHTML

JavaScript

const obj = {
  title: "hello world",
};

// 这是一个副作用函数,因为它依赖于 obj.title 并改变了外部的 DOM
function effect() {
  document.body.innerHTML = obj.title;
}

// 另一个函数,它的执行结果间接依赖于 effect 的执行
function showResult() {
  console.log(document.body.innerHTML);
}

effect(); // 执行 effect,此时 document.body.innerHTML 被设置为 "hello world"
showResult(); // 输出 "hello world"

二、初步实现响应式系统雏形

为了实现“当数据变化时,依赖于该数据的副作用函数能自动重新执行”的神奇效果,Vue 的响应式系统遵循两大核心步骤:依赖收集依赖触发

  • 依赖收集(Track) :当副作用函数执行时,它会访问响应式数据。此时,系统就像一个“侦探”,默默追踪这个访问行为,并将该副作用函数“记住”下来,作为该数据的依赖
  • 依赖触发(Trigger) :当响应式数据发生变化时,系统会“通知”所有之前收集到的依赖(也就是那些副作用函数),让它们重新执行,从而更新视图或执行其他操作。

下面,我们使用 Proxy 来实现一个简化的响应式系统,模拟这个过程。

const obj = {
  title: "hello world",
};

// 存储所有副作用函数的桶(依赖集合)
const bucket = new Set();

function effect() {
  document.body.innerHTML = obj.title;
}

// 使用 Proxy 创建响应式对象
const proxyObj = new Proxy(obj, {
  // get 拦截器:进行依赖收集
  get(target, key) {
    // 将 effect 函数添加到依赖桶中
    bucket.add(effect);
    return target[key];
  },

  // set 拦截器:进行依赖触发
  set(target, key, value) {
    // 设置新值
    target[key] = value;
    // 遍历依赖桶,执行所有副作用函数
    bucket.forEach((fn) => fn());
  },
});

// 在 1 秒后修改数据,这会触发依赖更新
setTimeout(() => {
  proxyObj.title = "hello vue";
  // 此时,document.body.innerHTML 将自动更新为 "hello vue"
}, 1000);

三、硬编码与不精准触发的优化

在上面的初步实现中,我们遇到了两个明显的问题:

  1. 副作用函数硬编码bucket.add(effect) 这种写法将副作用函数 effect 的名称写死了,这无法灵活处理多个副作用函数。
  2. 不精准的依赖触发set 拦截器会无差别地执行 bucket 中的所有副作用函数,即使修改的属性与它们无关,这会造成不必要的性能开销。

优化后的数据结构

为了解决这些问题,我们需要对存储依赖的数据结构进行升级。我们将用一个多层嵌套的数据结构来存储依赖关系,就像一个精心组织的档案库:

  • WeakMap ( bucket ) :最顶层的结构,它的键是响应式对象target)。使用 WeakMap 是一个聪明的选择,因为它的键是弱引用,当对象没有其他引用时,垃圾回收器会自动清理它,有效防止内存泄漏。
  • Map ( depsMap ) :中层结构,它的键是属性名key),值是一个 Set
  • Set ( deps ) :最底层结构,它存储了所有依赖于该属性的副作用函数。使用 Set 可以确保每个副作用函数只被存储一次,避免重复。

通过这种结构,我们实现了精准的依赖触发。当你只修改 proxyObj.age 时,trigger 函数会因为 depsMap.get('age') 返回 undefined 而直接返回,effect 函数将不会被触发。只有当你修改了 proxyObj.title 时,才会精准地执行与它关联的副作用函数,这解决了之前不精准触发的问题,实现了更高效、健壮的响应式系统。

const bucket = new WeakMap();
const obj = {
  title: "hello world",
};

let activeEffect = null;

function effect(fn) {
  activeEffect = fn;
  fn();
}

const proxyObj = new Proxy(obj, {
  get(target, key) {
    track(target, key);
    return target[key];
  },
  set(target, key, value) {
    target[key] = value;
    trigger(target, key);
  },
});

// 依赖收集
function track(target, key) {
  // 如果没有正在执行的副作用函数,则直接返回
  if (!activeEffect) return;
  // 获取 depsMap
  let depsMap = bucket.get(target);
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }
  // 获取 deps 集合
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  // 将当前 activeEffect 添加到 deps 集合中
  deps.add(activeEffect);
}

// 触发依赖
function trigger(target, key) {
  // 获取 depsMap
  let depsMap = bucket.get(target);
  if (!depsMap) return;
  // 获取 deps 集合
  let deps = depsMap.get(key);
  // 遍历并执行所有副作用函数
  deps && deps.forEach((fn) => fn());
}

effect(() => {
  document.body.innerHTML = proxyObj.title;
  console.log("trigger effect");
});

setTimeout(() => {
  // 修改一个不相关的属性,不会触发 effect
  proxyObj.age = 18;
}, 1000);

四、解决“分支”导致的依赖遗留问题

想象一下这样的场景:

  • 第一次执行proxyObj.checktrueeffect 函数会读取 proxyObj.checkproxyObj.title。此时,proxyObj.checkproxyObj.title 都收集了该 effect 函数作为依赖。
  • 修改数据:当 proxyObj.check 被修改为 false 时,trigger 函数会执行 effect
  • 第二次执行effect 函数再次执行,由于 proxyObj.checkfalse,它现在只访问 proxyObj.check,而不再访问 proxyObj.title。然而,proxyObj.title 的依赖集合中仍然残留着这个 effect 函数。
effect(() => {
  document.body.innerHTML = proxyObj.check ? proxyObj.title : "hahaha";
});

这会导致一个“幽灵依赖”:当 title 改变时,这个本不应再执行的函数却被错误地触发了。

解决方案:先清理,再收集

为了解决这个问题,我们引入一个核心思想:在每次执行副作用函数之前,先将它从所有旧的依赖集合中移除,然后再重新收集新的依赖

为此,我们引入了两个关键机制:

  • effectFn.deps 数组:在 effect 函数内部,我们为每一个副作用函数实例 effectFn 创建一个 deps 数组,用来存储它所关联的所有依赖集合(Set)。这样,我们就能反向追踪该函数都存在于哪些依赖集合中。
  • clean 函数:在副作用函数重新执行前,clean 函数会遍历 effectFn.deps 数组,将该副作用函数从所有它关联的依赖集合中移除,并清空 deps 数组。
function effect(fn) {
  function effectFn() {
    clean(effectFn); // 对所有依赖集合中抹除该副作用函数
    activeEffect = effectFn; // 依赖收集需要通过 activeEffect 拿到副作用函数
    fn(); // 执行函数体
  }
  effectFn.deps = []; // 初始化 deps
  effectFn();
}

function clean(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i];
    deps.delete(effectFn);
  }
  effectFn.deps.length = 0; // delete 不会改变 length,需要手动处理
}

// 依赖收集
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); // 将属性值的依赖集合添加到 activeEffect 中,用于反向追踪
}

// 触发依赖
function trigger(target, key) {
  let depsMap = bucket.get(target);
  if (!depsMap) return;
  let deps = depsMap.get(key);
  // 新建一个 Set 是为了避免在遍历时因副作用函数执行而导致 Set 改变
  const newSet = new Set(deps);
  newSet && newSet.forEach((fn) => fn());
}

五、处理“嵌套”的 Effect 与 Effect 栈问题

在之前的实现中,我们使用一个全局变量 activeEffect 来存储当前正在执行的副作用函数。当存在 Effect 嵌套(例如,一个组件内部渲染另一个子组件)时,这会导致一个严重的问题:内部的 Effect 可能会覆盖 activeEffect,导致外部的 Effect 无法正确收集到依赖。

// 假设这是外部组件的渲染函数
effect(() => {
  // 假设这是内部组件的渲染函数
  effect(() => {
    document.body.innerHTML = proxyObj.title; // 内部 effect 读取 title
  });
  // 此时,activeEffect 已经被内部 effect 覆盖
  // 如果这里有读取操作,比如 proxyObj.someOtherProp,它将错误地被收集到内部 effect 中
  console.log(proxyObj.check); // 外部 effect 读取 check
});

解决方案:引入 Effect 栈

为了解决这个问题,我们需要一个副作用函数栈 ( effectStack )

  • 执行时:将当前副作用函数压入栈中。
  • 执行后:将其从栈中弹出。
  • activeEffect:始终指向栈顶的副作用函数。

通过这种方式,我们可以保证每一个响应式数据只会收集直接读取它的副作用函数,避免了相互干扰,让依赖关系变得清晰而精准。

const bucket = new WeakMap();
const obj = {
  title: "hello world",
  check: true,
};

let activeEffect = null;
const effectStack = [];

function effect(fn) {
  function effectFn() {
    clean(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  }
  effectFn.deps = [];
  effectFn();
}

六、解决“读写”自身属性导致的栈溢出

如果在一个副作用函数内部,我们同时读取写入同一个响应式属性,就会陷入一个无限递归的死循环:

  1. 读取属性get 拦截器触发 track,将当前副作用函数收集到“桶”中。
  2. 写入属性set 拦截器触发 trigger,从“桶”中取出副作用函数并执行。

问题在于,这个副作用函数正在执行中,但又被 trigger 再次调用,这会导致它无限递归地调用自己,最终引发栈溢出

// 触发依赖
function trigger(target, key) {
  let depsMap = bucket.get(target);
  if (!depsMap) return;
  let deps = depsMap.get(key);
  const newSet = new Set(deps);
  newSet &&
    newSet.forEach((fn) => {
      // 避免无限递归调用(读写同一个属性)
      if (fn !== activeEffect) {
        fn();
      }
    });
}

effect(() => {
  proxyObj.title += "2222"; // 读和写发生在同一个副作用函数中
});

为了解决这个问题,在 trigger 函数中,我们增加一个判断:当要执行的副作用函数与当前正在执行的副作用函数是同一个时,就跳过本次执行。


七、可调度性(Scheduler)

可调度性是响应式系统非常重要的特性。它赋予我们决定副作用函数执行时机、次数以及方式的能力。

通过引入调度器scheduler),我们可以将副作用函数的执行权交给用户。例如,我们可以设置在数据变化时,不是立即执行副作用函数,而是将它放入一个任务队列中,等待下一个“tick”再执行,从而实现批量更新,提高性能。

function effect(fn, options = {}) {
  function effectFn() {
    clean(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  }
  effectFn.deps = [];
  effectFn.options = options; // 存储用户传入的 options
  effectFn();
}

// 触发依赖
function trigger(target, key) {
  let depsMap = bucket.get(target);
  if (!depsMap) return;
  let deps = depsMap.get(key);
  const newSet = new Set(deps);
  newSet &&
    newSet.forEach((fn) => {
      if (fn !== activeEffect) {
        // 如果有调度器,则调用调度器
        if (fn.options.scheduler) {
          fn.options.scheduler(fn);
        } else {
          // 否则直接执行副作用函数
          fn();
        }
      }
    });
}

总结

至此,我们已经构建了一个功能强大、健壮、且支持嵌套的响应式系统雏形。它能够处理复杂的依赖关系,解决循环引用和依赖遗留问题,并通过调度器提供了高度的可控性。以下是完整的代码实现,你可以直接复制运行。

const bucket = new WeakMap();

const obj = {
  title: "hello world",
  check: true,
};

let activeEffect = null;
const effectStack = [];

function effect(fn, options = {}) {
  function effectFn() {
    // 1. 在执行前,先清理旧的依赖
    clean(effectFn);
    // 2. 将当前 effectFn 设置为 activeEffect
    activeEffect = effectFn;
    // 3. 将当前 effectFn 压入栈
    fn();
    // 4. 执行完毕后,将当前 effectFn 弹出栈
    effectStack.pop();
    // 5. 恢复 activeEffect 为栈顶的 effectFn
    activeEffect = effectStack[effectStack.length - 1];
  }
  // 在 effectFn 上添加一个数组,用于反向存储它所在的 deps 集合
  effectFn.deps = [];
  // 存储用户传入的 options
  effectFn.options = options;
  // 首次执行
  effectFn();
}

/**
 * 清理副作用函数的所有依赖
 */
function clean(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i];
    // 从每个依赖集合中移除 effectFn
    deps.delete(effectFn);
  }
  // 清空 effectFn 的依赖数组
  effectFn.deps.length = 0;
}

const proxyObj = new Proxy(obj, {
  get(target, key) {
    track(target, key);
    return target[key];
  },
  set(target, key, value) {
    target[key] = value;
    trigger(target, key);
  },
});

/**
 * 依赖收集
 */
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) return;
  let deps = depsMap.get(key);
  const newSet = new Set(deps);
  newSet &&
    newSet.forEach((fn) => {
      if (fn !== activeEffect) {
        if (fn.options.scheduler) {
          fn.options.scheduler(fn);
        } else {
          fn();
        }
      }
    });
}

// 示例:触发响应式
effect(() => {
  proxyObj.title += "2222";
});

setTimeout(() => {
  console.log(bucket);
}, 1000);
❌
❌