普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月8日首页

虚拟列表:支持“向上加载”的历史消息(Vue 3 & React 双版本)

2026年2月8日 19:00

前言

在AI聊天产品中,向上滚动加载历史消息是一个经典场景。如果直接渲染万级聊天记录,页面必卡无疑。而使用虚拟列表时,向上插入数据导致的位置偏移是最大的技术痛点。本文将分享如何实现一个支持“滚动位置锁定”和“动态高度补偿”的虚拟列表方案。

react虚拟列表向上加载.gif

一、 核心困难点:为什么向上加载这么难?

  1. 滚动位置丢失:当你向数组头部插入 5 条新消息时,总高度会增加。如果不处理,浏览器会停留在原来的 scrollTop,导致用户看到的内容被“顶走”。
  2. 动态高度计算:聊天内容(图片、长文本)高度不一,必须在 DOM 渲染后通过 ResizeObserver 实时修正。
  3. 索引偏移:插入数据后,原来的索引全部失效,必须依赖“累计高度数组”和二分查找重新定位。

二、 实现思路

1、第一步:搭个“戏台子”(基础结构)

我们要搭一个三层嵌套的戏台,每一层都有它的“使命”:

  1. 外层大管家:固定好高度,别让列表把页面撑坏了。
  2. “虚胖”占位层:这是个空盒子,高度设为 totalHeight。它的唯一作用是欺骗浏览器,让滚动条以为这里有成千上万条数据,从而产生真实的滚动感。
  3. 舞台中心(可视区) :绝对定位。它会像电梯一样,跟着你的滚动距离通过 translateY 灵活位移,永远保证自己出现在观众视线内。

2、第二步:准备核心数据

为了让“戏”不演砸,我们需要掌握这些情报:

  • 预判值MIN_ITEM_HEIGHT(哪怕不知道多高,也得有个保底值)和 BUFFER_SIZE(多渲染几行,别让用户一滑就看到白屏)。
  • 雷达站LOAD_THRESHOLD(距离顶部还有多远时,赶紧去后台搬救兵/加载数据)。
  • 记账本:用一个 Map 记录每个消息的真实高度,再整一个 cumulativeHeights(累计高度数组),记录每一条消息距离顶部的距离。

3、第三步:索引计算

  • 找起点:用二分查找在“记账本”里搜一下,看现在的滚动位置对应哪一行的地盘。
  • 定终点:起点加上你能看到的行数,再算上“缓冲区”的几位,就是这一幕的结束。
  • 定位置:算出起点项对应的累计高度,把舞台一推(offsetY),搞定!

4、第四步:时间回溯(向上加载的核心!核心!)

这是实现向上加载最难的地方:往开头塞了新胶片,怎么保证观众看到的画面不跳动?

  1. 做标记:触发加载前,先死死记住现在的 scrollHeight(总高)和 scrollTop(进度)。
  2. 塞数据:把新消息“砰”地一下插到 listData 的最前面。
  3. 神操作(高度补偿) :数据塞进去后,总高度肯定变了。这时候赶紧算一下:新高度 - 旧高度 = 增加的高度
  4. 瞬间平移:把滚动条位置强制修改为 旧进度 + 增加的高度。这套动作要在浏览器刷新前完成,用户只会觉得加载了新内容,但眼前的画面纹丝不动。

5、第五步:实时监控(高度纠正)

万一某条消息里突然蹦出一张大图,高度变了怎么办?

  • 派出侦察兵:子组件自带 ResizeObserver,一旦发现自己长高了,立马报告给父组件。
  • 精准打击:父组件收到报告,更新账本。如果这个变高的项在观众视线上方,还得手动把滚动条再推一推,防止内容在眼皮子底下“乱跳”。

6、终章:开幕仪式(初始化)

  1. 一滚到底:聊天室嘛,进场肯定得看最下面(最新消息)。
  2. 双重保险:调用 scrollToBottom 时,先用 requestAnimationFrame 请浏览器配合,再加个 setTimeout 兜底,确保无论网络多慢,都能准确降落在列表底部。

三、 Vue 3 + TailwindCSS 实现

1. 虚拟列表组件:

<template>
  <div
    class="min-h-screen bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5"
  >
    <div
      class="bg-white mt-10 rounded-xl border shadow-lg relative"
      ref="containerRef"
    >
      <div
        ref="virtualListRef"
        class="h-full overflow-auto relative overflow-anchor-none"
        @scroll="handleScroll"
      >
        <!-- 顶部加载提示 -->
        <div
          v-if="isLoading"
          class="sticky top-0 z-10 py-2 flex justify-center items-center text-sm text-gray-500"
        >
          <div class="flex items-center space-x-2">
            <span>正在加载...</span>
          </div>
        </div>

        <div :style="{ height: `${totalHeight}px` }"></div>
        <div
          class="absolute top-0 left-0 right-0"
          :style="{ transform: `translateY(${offsetY}px)` }"
        >
          <VirtualListItem
            v-for="item in visibleList"
            :key="item.id"
            :item="item"
            @update-height="handleItemHeightUpdate"
          />
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed, nextTick, watch } from 'vue';
import VirtualListItem from './listItem.vue';

const MIN_ITEM_HEIGHT = 80; //预设虚拟列表项最小高度
const BUFFER_SIZE = 5; // 缓冲区大小,用于预加载项
const LOAD_THRESHOLD = 50; // 加载消息触发距离

const virtualListRef = ref<HTMLDivElement | null>(null); // 虚拟列表容器引用
const listData = ref<any[]>([]); // 列表数据
const itemHeights = ref<Map<number, number>>(new Map()); // 列表项高度数组:存储每个项的高度
const scrollTop = ref(0); // 滚动位置:当前滚动的垂直偏移量
const isLoading = ref(false); // 是否正在加载更多数据
const isInitialized = ref(false); // 是否已初始化:用于判断是否已加载初始数据
const hasMore = ref(true); // 是否有更多数据可加载
const containerRef = ref<HTMLDivElement | null>(null);

let minId = 10000; // 模拟生成消息ID

// 计算累计高度数组,对应了每个元素在列表中的垂直位置
const cumulativeHeights = computed(() => {
  const heights: number[] = [0];
  let currentSum = 0;
  for (const item of listData.value) {
    const h = itemHeights.value.get(item.id) || MIN_ITEM_HEIGHT;
    currentSum += h;
    heights.push(currentSum);
  }
  return heights;
});

// 列表总高度:列表所有项的累计高度
const totalHeight = computed(() => {
  const len = cumulativeHeights.value.length;
  return len > 0 ? cumulativeHeights.value[len - 1] : 0;
});

// 起始索引
const startIndex = computed(() => {
  let low = 0,
    high = cumulativeHeights.value.length - 1;
  // 核心:根据二分查找法出可视区内第一个可见项的索引!!!!!
  while (low <= high) {
    const mid = Math.floor((low + high) / 2);
    if (cumulativeHeights.value[mid] < scrollTop.value) {
      low = mid + 1;
    } else {
      high = mid - 1;
    }
  }
  return Math.max(0, low - 1 - BUFFER_SIZE);
});

// 结束索引
const endIndex = computed(() => {
  if (!virtualListRef.value) return 10;
  const t = scrollTop.value + virtualListRef.value.clientHeight; // 可视区底部在列表中的垂直位置`
  let low = 0,
    high = cumulativeHeights.value.length - 1;
  // 核心:根据二分查找法出可视区内最后一个可见项的索引!!!!!
  while (low <= high) {
    const mid = Math.floor((low + high) / 2);
    if (cumulativeHeights.value[mid] < t) {
      low = mid + 1;
    } else {
      high = mid - 1;
    }
  }
  return Math.min(listData.value.length, low + BUFFER_SIZE);
});

// 可见列表项:根据起始索引和结束索引截取列表数据
const visibleList = computed(() => {
  return listData.value.slice(startIndex.value, endIndex.value);
});

// 偏移量:根据起始索引计算列表项的垂直偏移量
const offsetY = computed(() => {
  if (startIndex.value === 0) return 0;
  return cumulativeHeights.value[startIndex.value];
});

// mock真实数据
const generateData = (count: number) => {
  const arr = [];
  for (let i = 0; i < count; i++) {
    minId--;
    arr.push({
      id: minId,
      content: `历史消息 ${minId}`,
      timestamp: new Date().toLocaleTimeString(),
    });
  }
  return arr;
};

// 初始化数据
const initData = async () => {
  const initialData = await new Promise<any[]>(
    (resolve) => setTimeout(() => resolve(generateData(20)), 100) // 模拟异步数据加载,初始加载时加载20条数据防止数据量过少撑不起容器
  );
  listData.value = initialData.reverse();
  await nextTick(); // 等待listData渲染到DOM中
  await nextTick(); // 再次等待子组件完全渲染并计算好实际高度

  isInitialized.value = true;
  scrollToBottom(); // 滚动到底部显示最新消息
};

// 滚动到底
const scrollToBottom = () => {
  if (!virtualListRef.value) return;
  const scroll = () => {
    nextTick(() => {
      if (virtualListRef.value) {
        const scrollHeight = virtualListRef.value.scrollHeight;
        const clientHeight = virtualListRef.value.clientHeight;
        virtualListRef.value.scrollTop = scrollHeight - clientHeight;
        scrollTop.value = virtualListRef.value.scrollTop;
      }
    });
  };

  // 双重保障:先使用requestAnimationFrame等待浏览器完成一次重绘,此时 scrollHeight 和 clientHeight 已正确计算,
  // 再用setTimeout兜底确保即使 requestAnimationFrame 失效也能执行
  requestAnimationFrame(() => {
    scroll();
    // 兜底方案,确保滚动执行
    setTimeout(() => {
      scroll();
    }, 100);
  });
};

// 监听totalHeight变化,初始化时确保滚动到底部
watch(
  totalHeight,
  (newVal, oldVal) => {
    if (isInitialized.value && oldVal === 0 && newVal > 0) {
      scrollToBottom();
    }
  },
  { immediate: true }
);

// 加载新消息
const loadNewMessages = async () => {
  if (isLoading.value || !hasMore.value || !isInitialized.value) return;
  isLoading.value = true;
  try {
    await new Promise((resolve) => setTimeout(resolve, 1000));    // 模拟1秒延迟
    const newData = generateData(5); // 每次加载5条新消息
    const currentScrollHeight = virtualListRef.value?.scrollHeight || 0;    // 记录当前滚动状态,为未加载前整个列表的高度(含不可见)!!!
    const currentScrollTop = scrollTop.value;
    listData.value = [...newData, ...listData.value];    // 在顶部添加新数据
    await nextTick();    // 等待DOM更新
    // 保持滚动位置,让用户停留在原来的地方
    if (virtualListRef.value) {
      const newScrollHeight = virtualListRef.value.scrollHeight;
      const heightAdded = newScrollHeight - currentScrollHeight;
      virtualListRef.value.scrollTop = currentScrollTop + heightAdded;
      scrollTop.value = virtualListRef.value.scrollTop;
    }
    // 模拟没有更多数据的情况
    if (minId <= 9000) {
      hasMore.value = false;
    }
  } catch (error) {
    console.error('加载消息失败:', error);
  } finally {
    isLoading.value = false;
  }
};

// 处理项目高度更新
const handleItemHeightUpdate = (id: number, realHeight: number) => {
  const oldHeight = itemHeights.value.get(id) || MIN_ITEM_HEIGHT;
  const diff = realHeight - oldHeight;
  if (Math.abs(diff) < 1) return;

  itemHeights.value.set(id, realHeight);
  // 如果项目在可视区域上方,调整滚动位置
  const index = listData.value.findIndex((item) => item.id === id);
  if (index < 0) return;

  const itemTop = cumulativeHeights.value[index];
  const viewportTop = scrollTop.value;

  if (itemTop < viewportTop && virtualListRef.value) {
    virtualListRef.value.scrollTop += diff;
    scrollTop.value = virtualListRef.value.scrollTop;
  }
};

// 处理滚动事件
const handleScroll = (e: Event) => {
  const target = e.target as HTMLDivElement;
  scrollTop.value = target.scrollTop;

  // 当滚动到距离顶部LOAD_THRESHOLD像素时,加载更多消息
  if (
    scrollTop.value <= LOAD_THRESHOLD &&
    !isLoading.value &&
    hasMore.value &&
    isInitialized.value
  ) {
    loadNewMessages();
  }
};


// 初始化
onMounted(() => {
  // 计算容器高度:视口高度减去上下边距和标题区域
  if (containerRef.value) {
    const computedHeight = window.innerHeight - 200; // 等价于 calc(100vh - 200px)
    containerRef.value.style.height = `${Math.max(200, computedHeight)}px`; // 防止负数或太小
  }
  // 确保DOM完全挂载后再初始化数据
  nextTick(() => {
    initData();
  });
});
</script>

<style scoped>
.overflow-anchor-none {
  overflow-anchor: none;
}
</style>

2. 子组件:

<template>
  <div
    ref="itemRef"
    class="py-2 px-4 border-b border-gray-200"
    :class="{
      'bg-pink-200': item.id % 2 !== 0,
      'bg-green-200': item.id % 2 === 0,
    }"
    :style="{ height: item.id % 2 === 0 ? '150px' : '100px' }"
  >
    {{ item.content }}
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUpdated, onUnmounted, watch, nextTick } from 'vue';

// 定义props:接收父组件传递的item数据
const props = defineProps<{
  item: {
    id: number;
    content: string;
  };
}>();

// 定义emit:向父组件传递高度更新事件
const emit = defineEmits<{
  (e: 'update-height', id: number, height: number): void;
}>();

const itemRef = ref<HTMLDivElement | null>(null);
let resizeObserver: ResizeObserver | null = null;

// 计算并发送当前组件的高度
const sendItemHeight = () => {
  if (!itemRef.value) return;
  const realHeight = itemRef.value.offsetHeight;
  emit('update-height', props.item.id, realHeight);
};

// 监听组件挂载:首次发送高度 + 监听高度变化
onMounted(() => {
  // 首次渲染完成后发送高度
  nextTick(() => {
    sendItemHeight();
  });

  // 监听元素高度变化(适配动态内容导致的高度变化)
  if (window.ResizeObserver) {
    resizeObserver = new ResizeObserver(() => {
      sendItemHeight();
    });
    if (itemRef.value) {
      resizeObserver.observe(itemRef.value);
    }
  }
});

// 组件更新后重新发送高度(比如内容变化)
onUpdated(() => {
  nextTick(() => {
    sendItemHeight();
  });
});

// 组件卸载:清理监听
onUnmounted(() => {
  if (resizeObserver) {
    resizeObserver.disconnect();
    resizeObserver = null;
  }
});

// 监听item变化:如果item替换,重新计算高度
watch(
  () => props.item.id,
  () => {
    nextTick(() => {
      sendItemHeight();
    });
  }
);
</script>

3. 效果图:

vue虚拟列表向上加载.gif


四、 React + TailwindCSS实现

在React中我们需要利用 useMemo 优化索引计算,并利用 useLayoutEffect 处理滚动位置,避免视觉闪烁。

1. 虚拟列表组件:

import React, {
  useState,
  useRef,
  useEffect,
  useMemo,
  useCallback,
} from 'react';
import VirtualListItem from './VirtualListItem';

const MIN_ITEM_HEIGHT = 80; // 每个列表项的最小高度
const BUFFER_SIZE = 5; // 缓冲区大小,用于预加载
const LOAD_THRESHOLD = 30; // 触发加载的px值
const NEW_DATA_COUNT = 5; // 每次加载的新数据数量
const PRE_LOAD_OFFSET = 100; // 预加载偏移量,用于提前加载部分数据

// 列表项类型定义
interface ListItem {
  id: number; // 列表项的唯一标识符
  content: string; // 列表项的内容
  timestamp: string; // 列表项的时间戳
}

const VirtualList: React.FC = () => {
  const virtualListRef = useRef<HTMLDivElement>(null); // 虚拟列表容器引用
  const containerRef = useRef<HTMLDivElement>(null); // 列表容器引用
  const loadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); // 加载定时器引用
  const initScrollAttemptsRef = useRef(0); // 初始化滚动尝试次数引用,最多10次
  const [listData, setListData] = useState<ListItem[]>([]); // 列表数据状态,初始为空数组
  const [itemHeights, setItemHeights] = useState<Map<number, number>>(
    new Map()
  ); // 列表项高度映射Map
  const [scrollTop, setScrollTop] = useState<number>(0); // 滚动位置状态,初始为0
  const [isLoading, setIsLoading] = useState<boolean>(false); // 加载状态,初始为false
  const [isInitialized, setIsInitialized] = useState<boolean>(false); // 初始化状态,初始为false
  const [hasMore, setHasMore] = useState<boolean>(true); // 是否还有更多数据状态,初始为true

  const minIdRef = useRef(10000); // 最小ID引用,初始为10000
  const isLoadingRef = useRef(false); // 正在加载状态
  const hasMoreRef = useRef(true); // 是否还有更多数据
  const isFirstInitRef = useRef(true); // 是否第一次初始化
  const scrollStateRef = useRef<{
    isManualScroll: boolean;
    lastScrollTop: number;
  }>({
    isManualScroll: false,
    lastScrollTop: 0,
  }); // 滚动状态引用

  // 同步 ref 和 state
  useEffect(() => {
    isLoadingRef.current = isLoading;
    hasMoreRef.current = hasMore;
  }, [isLoading, hasMore]);

  // 计算累计高度
  const cumulativeHeights = useMemo(() => {
    const heights: number[] = [0];
    let currentSum = 0;
    for (const item of listData) {
      const h = itemHeights.get(item.id) || MIN_ITEM_HEIGHT;
      currentSum += h;
      heights.push(currentSum);
    }
    return heights;
  }, [listData, itemHeights]);

  // 列表总高度
  const totalHeight = useMemo(() => {
    return cumulativeHeights[cumulativeHeights.length - 1] || 0;
  }, [cumulativeHeights]);

  // 起始索引
  const startIndex = useMemo(() => {
    if (!virtualListRef.current || listData.length === 0) return 0;
    let low = 0,
      high = cumulativeHeights.length - 1;
    while (low <= high) {
      const mid = Math.floor((low + high) / 2);
      if (cumulativeHeights[mid] < scrollTop) {
        low = mid + 1;
      } else {
        high = mid - 1;
      }
    }

    const baseIndex = Math.max(0, low - 1);
    return Math.max(0, baseIndex - BUFFER_SIZE);
  }, [cumulativeHeights, scrollTop, listData.length]);

  // 结束索引
  const endIndex = useMemo(() => {
    if (!virtualListRef.current || listData.length === 0)
      return BUFFER_SIZE * 2;
    const clientHeight = virtualListRef.current.clientHeight;
    const t = scrollTop + clientHeight + PRE_LOAD_OFFSET;
    let low = 0,
      high = cumulativeHeights.length - 1;
    while (low <= high) {
      const mid = Math.floor((low + high) / 2);
      if (cumulativeHeights[mid] < t) {
        low = mid + 1;
      } else {
        high = mid - 1;
      }
    }

    return Math.min(listData.length, low + BUFFER_SIZE);
  }, [cumulativeHeights, scrollTop, listData.length]);

  // 可见列表项
  const visibleList = useMemo(() => {
    return listData.slice(startIndex, endIndex);
  }, [listData, startIndex, endIndex]);

  // 偏移量
  const offsetY = useMemo(() => {
    return startIndex === 0 ? 0 : cumulativeHeights[startIndex];
  }, [cumulativeHeights, startIndex]);

  // 生成模拟数据
  const generateData = useCallback(
    (count: number, isInitialLoad: boolean = false) => {
      const arr: ListItem[] = [];
      for (let i = 0; i < count; i++) {
        minIdRef.current--;
        arr.push({
          id: minIdRef.current,
          content: `历史消息 ${minIdRef.current}`,
          timestamp: new Date().toLocaleTimeString(),
        });
      }
      console.log('生成数据:', arr);
      if (!isInitialLoad) {
        arr.reverse();
      }
      return arr;
    },
    []
  );

  // 滚动到底部
  const scrollToBottom = useCallback(() => {
    if (!virtualListRef.current) return;

    const scrollEl = virtualListRef.current;

    // 使用多次尝试,直到成功滚动到底部
    const attemptScroll = () => {
      requestAnimationFrame(() => {
        const scrollHeight = scrollEl.scrollHeight;
        const clientHeight = scrollEl.clientHeight;

        if (scrollHeight > clientHeight) {
          const targetScrollTop = scrollHeight - clientHeight;
          const currentScrollTop = scrollEl.scrollTop;

          // 如果还没到底部,继续滚动
          if (Math.abs(currentScrollTop - targetScrollTop) > 1) {
            scrollEl.scrollTop = targetScrollTop;
            setScrollTop(targetScrollTop);

            // 增加尝试次数
            initScrollAttemptsRef.current++;

            // 最多尝试10次,每次间隔50ms
            if (initScrollAttemptsRef.current < 10) {
              setTimeout(attemptScroll, 50);
            } else {
              console.log('初始化滚动到底部完成');
              isFirstInitRef.current = false;
            }
          } else {
            console.log('已经滚动到底部');
            isFirstInitRef.current = false;
          }
        } else {
          isFirstInitRef.current = false; // 内容高度小于容器高度,不需要滚动
        }
      });
    };

    // 重置尝试次数并开始滚动
    initScrollAttemptsRef.current = 0;
    attemptScroll();
  }, []);

  // 初始化数据
  const initData = useCallback(async () => {
    try {
      const initialData = await new Promise<ListItem[]>((resolve) =>
        setTimeout(() => resolve(generateData(20, true)), 100)
      );
      setListData(initialData);
      setIsInitialized(true);
    } catch (error) {
      console.error('初始化数据失败:', error);
    }
  }, [generateData]);

  // 核心:加载新消息
  const loadNewMessages = useCallback(async () => {
    if (isLoadingRef.current || !hasMoreRef.current || !isInitialized) return;

    isLoadingRef.current = true;
    setIsLoading(true);

    try {
      await new Promise((resolve) => setTimeout(resolve, 1000));
      const newData = generateData(NEW_DATA_COUNT, false);

      const scrollEl = virtualListRef.current;
      if (!scrollEl) return;

      // 1. 记录加载前的滚动位置
      const beforeScrollTop = scrollEl.scrollTop;
      const beforeScrollHeight = scrollEl.scrollHeight;

      // 2. 更新数据
      setListData((prev) => [...newData, ...prev]);

      // 3. 等待DOM更新后调整滚动位置
      requestAnimationFrame(() => {
        if (scrollEl) {
          const afterScrollHeight = scrollEl.scrollHeight;
          const heightAdded = afterScrollHeight - beforeScrollHeight;

          // 关键修复:检查当前是否仍在顶部附近
          const isStillNearTop = scrollEl.scrollTop <= LOAD_THRESHOLD + 50;

          // 只有当用户没有手动滚动且仍在顶部时才调整
          if (!scrollStateRef.current.isManualScroll && isStillNearTop) {
            scrollEl.scrollTop = beforeScrollTop + heightAdded;
            setScrollTop(scrollEl.scrollTop);
          }
        }
      });

      // 模拟没有更多数据
      if (minIdRef.current <= 9000) {
        hasMoreRef.current = false;
        setHasMore(false);
      }
    } catch (error) {
      console.error('加载消息失败:', error);
    } finally {
      isLoadingRef.current = false;
      setIsLoading(false);
    }
  }, [generateData, isInitialized]);

  // 处理列表项高度更新
  const handleItemHeightUpdate = useCallback(
    (id: number, realHeight: number) => {
      setItemHeights((prev) => {
        const newHeights = new Map(prev);
        const oldHeight = newHeights.get(id) || MIN_ITEM_HEIGHT;
        const diff = realHeight - oldHeight;

        if (Math.abs(diff) < 1) return prev;

        newHeights.set(id, realHeight);

        // 自动调整滚动位置
        if (
          virtualListRef.current &&
          !isFirstInitRef.current &&
          !scrollStateRef.current.isManualScroll
        ) {
          const scrollEl = virtualListRef.current;
          const index = listData.findIndex((item) => item.id === id);

          if (index >= 0) {
            const itemTop = cumulativeHeights[index];
            const viewportTop = scrollEl.scrollTop;

            // 仅当元素在视口上方时调整
            if (itemTop < viewportTop) {
              scrollEl.scrollTop += diff;
              setScrollTop(scrollEl.scrollTop);
            }
          }
        }

        return newHeights;
      });
    },
    [listData, cumulativeHeights]
  );

  // 处理滚动事件
  const handleScroll = useCallback(
    (e: React.UIEvent<HTMLDivElement>) => {
      const target = e.target as HTMLDivElement;
      const currentScrollTop = target.scrollTop;
      setScrollTop(currentScrollTop);

      // 标记手动滚动
      scrollStateRef.current = {
        isManualScroll: true,
        lastScrollTop: currentScrollTop,
      };

      // 检查是否需要加载
      const shouldLoad = currentScrollTop <= LOAD_THRESHOLD;

      if (
        shouldLoad &&
        !isLoadingRef.current &&
        hasMoreRef.current &&
        isInitialized
      ) {
        // 清除之前的防抖计时器
        if (loadTimerRef.current) {
          clearTimeout(loadTimerRef.current);
        }

        // 防抖处理
        loadTimerRef.current = setTimeout(() => {
          if (target.scrollTop <= LOAD_THRESHOLD && !isLoadingRef.current) {
            loadNewMessages();
          }
        }, 100);
      }
    },
    [isInitialized, loadNewMessages]
  );

  // 初始化
  useEffect(() => {
    console.log('组件挂载,开始初始化');

    // 设置容器高度
    if (containerRef.current) {
      const computedHeight = window.innerHeight - 200;
      containerRef.current.style.height = `${Math.max(200, computedHeight)}px`;
    }

    initData();

    // 清理函数
    return () => {
      console.log('组件卸载,清理定时器');
      if (loadTimerRef.current) {
        clearTimeout(loadTimerRef.current);
      }
    };
  }, [initData]);

  // 监听总高度变化,在数据完全渲染后滚动到底部
  useEffect(() => {
    if (isInitialized && totalHeight > 0 && isFirstInitRef.current) {
      // 延迟一段时间确保DOM完全渲染
      const timer = setTimeout(() => {
        scrollToBottom();
      }, 300); // 增加延迟时间,确保所有列表项都已渲染并测量高度

      return () => clearTimeout(timer);
    }
  }, [isInitialized, totalHeight, scrollToBottom]);

  // 监听列表数据变化,确保在高度测量后滚动
  useEffect(() => {
    if (listData.length > 0 && isInitialized && isFirstInitRef.current) {
      console.log('列表数据更新,当前数据量:', listData.length);

      // 再给一些时间让所有列表项完成高度测量
      const timer = setTimeout(() => {
        if (isFirstInitRef.current) {
          console.log('高度测量后尝试滚动');
          scrollToBottom();
        }
      }, 500);

      return () => clearTimeout(timer);
    }
  }, [listData.length, isInitialized, scrollToBottom]);

  // 重置手动滚动标记
  useEffect(() => {
    const timer = setTimeout(() => {
      scrollStateRef.current.isManualScroll = false;
    }, 500);
    return () => clearTimeout(timer);
  }, [scrollTop]);

  return (
    <div className="h-full bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5">
      <div
        ref={containerRef}
        className="bg-white mt-10 rounded-xl border shadow-lg relative"
      >
        <div
          ref={virtualListRef}
          className="h-full overflow-auto relative"
          onScroll={handleScroll}
          style={{
            overflowAnchor: 'none',
            overscrollBehavior: 'contain',
            scrollBehavior: 'auto',
          }}
        >
          {/* 加载提示(绝对定位,不影响布局) */}
          {isLoading && (
            <div className="absolute top-0 left-0 right-0 z-10 py-2 flex justify-center items-center text-sm text-gray-500 ">
              <div className="flex items-center space-x-2">
                <div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
                <span>正在加载历史消息...</span>
              </div>
            </div>
          )}

          {/* 列表占位容器 */}
          <div
            style={{
              height: `${totalHeight}px`,
              pointerEvents: 'none',
              opacity: 0,
            }}
          ></div>

          {/* 可视区域内容 */}
          <div
            className="absolute top-0 left-0 right-0"
            style={{
              transform: `translateY(${offsetY}px)`,
              width: '100%',
            }}
          >
            {visibleList.length === 0 ? (
              <div className="py-4 text-center text-gray-400">
                {listData.length === 0
                  ? '正在初始化...'
                  : '加载更多历史消息...'}
              </div>
            ) : (
              visibleList.map((item) => (
                <VirtualListItem
                  key={item.id}
                  item={item}
                  onUpdateHeight={handleItemHeightUpdate}
                />
              ))
            )}
          </div>

          {/* 没有更多数据的提示 */}
          {!hasMore && (
            <div className="absolute bottom-0 left-0 right-0 py-2 text-center text-sm text-gray-400 bg-white border-t">
              没有更多历史消息了
            </div>
          )}
        </div>
      </div>
    </div>
  );
};

export default VirtualList;

2. 子组件:

import React, {
  useEffect,
  useRef,
  forwardRef,
  useImperativeHandle,
} from 'react';

export interface ListItemProps {
  item: {
    id: number;
    content: string;
    timestamp: string;
  };
  onUpdateHeight: (id: number, height: number) => void;
}

const VirtualListItem = forwardRef<HTMLDivElement, ListItemProps>(
  ({ item, onUpdateHeight }, ref) => {
    const itemRef = useRef<HTMLDivElement>(null);
    const resizeObserverRef = useRef<ResizeObserver | null>(null);

    useImperativeHandle(ref, () => {
      if (itemRef.current) {
        return itemRef.current;
      }
      // 提供一个安全的默认值
      const emptyDiv = document.createElement('div');
      return emptyDiv;
    });

    // 使用 ResizeObserver 监听尺寸变化
    useEffect(() => {
      const updateHeight = () => {
        if (itemRef.current) {
          const height = itemRef.current.offsetHeight;
          onUpdateHeight(item.id, height);
        }
      };

      // 立即执行一次初始测量
      updateHeight();

      if (!resizeObserverRef.current) {
        resizeObserverRef.current = new ResizeObserver(() => {
          // 防抖处理,避免频繁触发
          if (itemRef.current) {
            requestAnimationFrame(updateHeight);
          }
        });
      }

      if (itemRef.current && resizeObserverRef.current) {
        resizeObserverRef.current.observe(itemRef.current);
      }

      // 额外的初始延迟测量,确保样式已应用
      const timer = setTimeout(() => {
        updateHeight();
      }, 10);

      return () => {
        if (resizeObserverRef.current && itemRef.current) {
          resizeObserverRef.current.unobserve(itemRef.current);
        }
        clearTimeout(timer);
      };
    }, [item.id, onUpdateHeight]);

    // 模拟不同的内容高度
    const itemStyle: React.CSSProperties = {
      height: item.id % 2 === 0 ? '150px' : '100px',
    };

    const itemClass = `${item.id % 2 !== 0 ? 'bg-pink-200' : 'bg-green-200'}`;

    return (
      <div ref={itemRef} className={itemClass} style={itemStyle}>
        {item.id}
      </div>
    );
  }
);

VirtualListItem.displayName = 'VirtualListItem';
export default VirtualListItem;

3. 效果图:

react虚拟列表向上加载.gif


五、 注意事项

  • 浏览器干扰:必须设置 overflow-anchor: none。现代浏览器尝试自动调整滚动位置,这会与我们的手动补偿冲突。

  • 索引边界检查:对切片索引执行 Math.max(0, ...)Math.min(total, ...) 的区间收敛,防止因 startIndexendIndex 越界导致的渲染异常。

  • 初始化时机:首次加载数据后,应调用 scrollToBottom()。为了确保渲染完成,建议采用 requestAnimationFrame + setTimeout 的双重保险。

  • 无感加载策略:执行头部数据插入前,需快照记录当前的 scrollHeight。数据推送至渲染引擎后,通过 newScrollHeight - oldScrollHeight 算得 空间增量,并将其累加至当前滚动偏移量上。该补偿逻辑需在渲染刷新前完成,以实现“无感加载”

  • 性能瓶颈:随着 listData 增加到数万条,cumulativeHeights 的计算可能变慢。此时可考虑分段计算维护高度。


昨天 — 2026年2月7日首页

JS-AbortController:优雅中止请求操作

2026年2月6日 11:32

前言

在前端开发中,我们经常遇到需要中途撤回请求的情况(例如:搜索框快速输入、大型文件上传取消、或是 AI 聊天流的即时中断)。传统的 Promise 一旦启动就无法在外部“叫停”,而 AbortController 的出现,完美填补了这一空白。

一、 核心概念与原理

AbortController 是 JavaScript 内置的信号控制对象,它是实现异步操作可控制、可中止的核心。

1. 关键组成部分

  • controller.signal:一个 AbortSignal 对象实例。它充当“监听器”,将其传递给异步操作后,该操作会持续观察信号状态。

  • controller.abort() :触发中止的方法。调用后,signal 上的 abort 事件会被触发,同时将 signal.aborted 设为 true


二、 基础使用模式

1. 实现步骤

  1. 使用 new AbortController() 生成实例。
  2. 将实例中的 signal 属性传递给需要支持中止的异步 API(如 fetch)。
  3. 在合适的时机调用 controller.abort() 即可主动终止。

2. 代码示例

// 1. 创建 AbortController 实例
const controller = new AbortController();
const { signal } = controller;

// 2. 发起请求并绑定信号
fetch("/api/data", { signal })
  .then((response) => response.json())
  .then((data) => console.log("请求成功:", data))
  .catch((err) => {
    // 3. 捕获中止错误
    if (err.name === "AbortError") {
      console.log("主动取消:请求被成功截断");
    } else {
      console.error("请求失败:", err);
    }
  });

// 2 秒后主动取消请求
setTimeout(() => {
  controller.abort(); 
}, 2000);

三、 进阶技巧与场景

1. 批量取消请求

如果想同时取消多个相关的请求,可以给这些请求共享同一个 signal。当调用 abort() 时,所有关联的任务都会收到中止信号。

2. 示例

// 使用同一个 AbortController 取消多个请求
const controller = new AbortController();

// 请求1
const request1 = fetch('url1', {
  signal: controller.signal
});

// 请求2
const request2 = fetch('url2', {
  signal: controller.signal
});

// 请求3
const request3 = fetch('url3', {
  signal: controller.signal
});

// 同时取消所有请求
document.getElementById('cancelBtn').addEventListener('click', () => {
  controller.abort();
  console.log('所有请求已取消');
});

// 等待所有请求
Promise.all([request1, request2, request3])
  .then(responses => Promise.all(responses.map(r => r.json())))
  .then(data => console.log('所有数据:', data))
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('请求被取消');
    }
  });

3. 注意事项

  • 兼容性:并非所有 API 都原生支持。目前 fetchAxios (v0.22+) 模块已提供支持。
  • 幂等性abort() 方法只能生效一次。多次调用虽然不会报错,但只有第一次调用会触发中止逻辑。

四、 总结对比

特性 传统 Promise 带有 AbortController 的 Promise
可控性 开启后无法干预 可随时通过 abort() 中止
异常处理 只有成功/失败 增加 AbortError 类型,方便区分主动取消与网络异常
应用场景 简单的数据获取 复杂交互、流式输出、性能调优

虚拟列表:从定高到动态高度的 Vue 3 & React 满分实现

2026年2月7日 11:49

前言

在处理海量数据渲染(如万级甚至十万级列表)时,直接操作 DOM 会导致严重的页面卡顿甚至崩溃。虚拟列表(Virtual List) 作为前端性能优化的“核武器”,通过“只渲染可视区”的策略,能将渲染性能提升数个量级。本文将带你从零实现一个支持动态高度的通用虚拟列表。

定高虚拟列表滚动.gif

一、 核心原理解析

虚拟列表本质上是一个“障眼法”,其结构通常分为三层:

  1. 外层容器(Container) :固定高度,设置 overflow: auto,负责监听滚动事件。
  2. 占位背景(Placeholder) :高度等于“总数据量 × 列表项高度”,用于撑开滚动条,模拟真实滚动的视觉效果。
  3. 渲染内容区(Content Area) :绝对定位,根据滚动距离动态计算起始索引,并通过 translateY 偏移到当前可视区域。

image.png


二、 定高虚拟列表

1. 设计思路

  • 可视项数计算Math.ceil(容器高度 / 固定高度) ± 缓冲区 (BUFFER)
  • 起始索引Math.floor(滚动距离 / 固定高度)
  • 偏移量起始索引 * 固定高度

2. Vue 3 + TailwindCSS实现

<template>
  <div
    class="min-h-screen bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5"
  >
    <div class="bg-white mt-20 h-[calc(100vh-200px)] rounded-xl">
      <!-- 滚动容器 -->
      <div
        ref="virtualListRef"
        class="h-full overflow-auto relative"
        @scroll="handleScroll"
      >
        <!-- 占位容器:用于撑开滚动条,高度 = 总数据量 * 每项高度 -->
        <div :style="{ height: `${totalHeight}px` }"></div>

        <!-- 可视区域列表:通过 transform 定位到滚动位置 -->
        <div
          class="absolute top-0 left-0 right-0"
          :style="{ transform: `translateY(${offsetY}px)` }"
        >
          <div
            v-for="item in visibleList"
            :key="item.id"
            class="py-2 px-4 border-b border-gray-200"
            :class="{
              'bg-pink-200 h-[100px]': item.id % 2 !== 0,
              'bg-green-200 h-[100px]': item.id % 2 === 0,
            }"
          >
            {{ item.name }}
          </div>
        </div>
      </div>
    </div>
    <div
      class="fixed top-2 left-24 -translate-x-1/2 px-8 py-3 bg-white text-indigo-600 rounded-full text-base font-semibold cursor-pointer shadow-lg transition-all duration-300 hover:-translate-x-1/2 hover:-translate-y-0.5 hover:shadow-2xl"
      @click="goBack"
    >
      ← 返回首页
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';

const router = useRouter();

const ITEM_HEIGHT = 100; // 列表项固定高度(与样式中的 h-[100px] 一致)
const BUFFER = 5; // 缓冲区数量,避免滚动时出现空白

const virtualListRef = ref<HTMLDivElement | null>(null);

const ListData = ref<any[]>([]); // 完整列表数据
const scrollTop = ref(0); // 滚动容器的滚动距离

// 总列表高度(撑开滚动条用)
const totalHeight = computed(() => ListData.value.length * ITEM_HEIGHT);

// 可视区域高度(滚动容器的高度)
const viewportHeight = computed(() => {
  return virtualListRef.value?.clientHeight || 0;
});

// 可视区域可显示的列表项数量(向上取整 + 缓冲区)
const visibleCount = computed(() => {
  return Math.ceil(viewportHeight.value / ITEM_HEIGHT) + BUFFER;
});

// 当前显示的起始索引
const startIndex = computed(() => {
  // 滚动距离 / 每项高度 = 跳过的项数(向下取整)
  const index = Math.floor(scrollTop.value / ITEM_HEIGHT);
  // 防止索引为负数
  return Math.max(0, index);
});

// 当前显示的结束索引
const endIndex = computed(() => {
  const end = startIndex.value + visibleCount.value;
  // 防止超出总数据长度
  return Math.min(end, ListData.value.length);
});

// 可视区域需要渲染的列表数据
const visibleList = computed(() => {
  return ListData.value.slice(startIndex.value, endIndex.value);
});

// 可视区域的偏移量(让列表项定位到正确位置)
const offsetY = computed(() => {
  return startIndex.value * ITEM_HEIGHT;
});

// 处理滚动事件
const handleScroll = () => {
  if (virtualListRef.value) {
    scrollTop.value = virtualListRef.value.scrollTop;
  }
};

// 返回首页
const goBack = () => {
  router.push('/home');
};

// 初始化
onMounted(() => {
  // 生成模拟数据
  ListData.value = Array.from({ length: 1000 }, (_, index) => ({
    id: index,
    name: `Item ${index}`,
  }));
});
</script>

3. 实现效果图

定高虚拟列表滚动.gif


三、 进阶:不定高(动态高度)虚拟列表

在实际业务(如社交动态、聊天记录)中,每个 Item 的高度往往是不固定的。

1. 核心改进思路

  • 高度映射表(Map) :记录每一个 Item 渲染后的真实高度。
  • 累计高度数组(Cumulative Heights) :存储每一项相对于顶部的偏移位置。
  • ResizeObserver:利用该 API 监听子组件高度变化,实时更新映射表,解决图片加载或文本折行导致的位移。

2. Vue 3 + tailwindCSS 实现(子组件抽离)

子组件: 负责上报真实高度:

<template>
  <div
    ref="itemRef"
    class="py-2 px-4 border-b border-gray-200"
    :class="{
      'bg-pink-200': item.id % 2 !== 0,
      'bg-green-200': item.id % 2 === 0,
    }"
    :style="{ height: item.id % 2 === 0 ? '150px' : '100px' }"
  >
    {{ item.name }}
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUpdated, onUnmounted, watch, nextTick } from 'vue';

// 定义props:接收父组件传递的item数据
const props = defineProps<{
  item: {
    id: number;
    name: string;
  };
}>();

// 定义emit:向父组件传递高度更新事件
const emit = defineEmits<{
  (e: 'update-height', id: number, height: number): void;
}>();

const itemRef = ref<HTMLDivElement | null>(null);
let resizeObserver: ResizeObserver | null = null;

// 计算并发送当前组件的高度
const sendItemHeight = () => {
  if (!itemRef.value) return;
  const realHeight = itemRef.value.offsetHeight;
  emit('update-height', props.item.id, realHeight);
};

// 监听组件挂载:首次发送高度 + 监听高度变化
onMounted(() => {
  // 首次渲染完成后发送高度
  nextTick(() => {
    sendItemHeight();
  });

  // 监听元素高度变化(适配动态内容导致的高度变化)
  if (window.ResizeObserver) {
    resizeObserver = new ResizeObserver(() => {
      sendItemHeight();
    });
    if (itemRef.value) {
      resizeObserver.observe(itemRef.value);
    }
  }
});

// 组件更新后重新发送高度(比如内容变化)
onUpdated(() => {
  nextTick(() => {
    sendItemHeight();
  });
});

// 组件卸载:清理监听
onUnmounted(() => {
  if (resizeObserver) {
    resizeObserver.disconnect();
    resizeObserver = null;
  }
});

// 监听item变化:如果item替换,重新计算高度
watch(
  () => props.item.id,
  () => {
    nextTick(() => {
      sendItemHeight();
    });
  }
);
</script>

父组件:核心逻辑

<template>
  <div
    class="min-h-screen bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5"
  >
    <div class="bg-white mt-20 h-[calc(100vh-200px)] rounded-xl">
      <!-- 滚动容器 -->
      <div
        ref="virtualListRef"
        class="h-full overflow-auto relative"
        @scroll="handleScroll"
      >
        <!-- 占位容器:撑开滚动条 -->
        <div :style="{ height: `${totalHeight}px` }"></div>

        <!-- 可视区域列表 -->
        <div
          class="absolute top-0 left-0 right-0"
          :style="{ transform: `translateY(${offsetY}px)` }"
        >
          <!-- 渲染子组件,监听高度更新事件 -->
          <VirtualListItem
            v-for="item in visibleList"
            :key="item.id"
            :item="item"
            @update-height="handleItemHeightUpdate"
          />
        </div>
      </div>
    </div>
    <div
      class="fixed top-2 left-24 -translate-x-1/2 px-8 py-3 bg-white text-indigo-600 rounded-full text-base font-semibold cursor-pointer shadow-lg transition-all duration-300 hover:-translate-x-1/2 hover:-translate-y-0.5 hover:shadow-2xl"
      @click="goBack"
    >
      ← 返回首页
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed, onUnmounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import VirtualListItem from './listItem.vue'; // 引入子组件

const router = useRouter();

const MIN_ITEM_HEIGHT = 100; // 子项预设的最小高度
const BUFFER = 5; //上下缓冲区数目
const virtualListRef = ref<HTMLDivElement | null>(null); // 滚动容器引用

const ListData = ref<any[]>([]); // 完整列表数据
const scrollTop = ref(0); // 滚动距离
const itemHeights = ref<Map<number, number>>(new Map()); // 子组件高度映射表
const cumulativeHeights = ref<number[]>([0]); // 累计高度数组
const scrollTimer = ref<number | null>(null); // 滚动节流定时器
const isUpdatingCumulative = ref(false); // 累计高度更新防抖

// 初始化位置数据
const initPositionData = () => {
  // 初始化高度映射表(默认最小高度)
  const heightMap = new Map<number, number>();
  ListData.value.forEach((item) => {
    heightMap.set(item.id, MIN_ITEM_HEIGHT);
  });
  // 初始化累计高度
  updateCumulativeHeights();
};

// 更新累计高度(核心)
const updateCumulativeHeights = () => {
  if (isUpdatingCumulative.value) return;
  isUpdatingCumulative.value = true;

  const itemCount = ListData.value.length;
  const cumulative = [0];
  let sum = 0;

  for (let i = 0; i < itemCount; i++) {
    const itemId = ListData.value[i].id;
    sum += itemHeights.value.get(itemId) || MIN_ITEM_HEIGHT;
    cumulative.push(sum);
  }

  cumulativeHeights.value = cumulative;
  isUpdatingCumulative.value = false;
};

// 处理子组件的高度更新事件
const handleItemHeightUpdate = (id: number, height: number) => {
  // 高度未变化则跳过
  if (itemHeights.value.get(id) === height) return;

  // 更新高度映射表
  itemHeights.value.set(id, height);

  // 异步更新累计高度(避免同步更新导致的性能问题)
  nextTick(() => {
    updateCumulativeHeights();
  });
};

// 总高度,根据统计高度数组最后一个值计算得出
const totalHeight = computed(() => {
  return cumulativeHeights.value[cumulativeHeights.value.length - 1] || 0;
});

// 列表可视区域高度
const viewportHeight = computed(() => {
  return virtualListRef.value?.clientHeight || MIN_ITEM_HEIGHT * 5;
});

// 计算起始索引
const startIndex = computed(() => {
  const totalItemCount = ListData.value.length;
  if (totalItemCount === 0) return 0;
  if (scrollTop.value <= 0) return 0;

  let baseStartIndex = 0;
  // 反向遍历找起始索引
  for (let i = cumulativeHeights.value.length - 1; i >= 0; i--) {
    if (cumulativeHeights.value[i] <= scrollTop.value) {
      baseStartIndex = i;
      break;
    }
  }
  const finalIndex = Math.max(0, baseStartIndex - BUFFER); // 确保不小于0
  return Math.min(finalIndex, totalItemCount - 1);
});

// 计算结束索引
const endIndex = computed(() => {
  const totalItemCount = ListData.value.length;
  const viewportHeightVal = viewportHeight.value;
  if (totalItemCount === 0) return 0;

  const targetScrollBottom = scrollTop.value + viewportHeightVal; // 目标滚动到底部位置
  let baseEndIndex = totalItemCount - 1;
  for (let i = 0; i < cumulativeHeights.value.length; i++) {
    if (cumulativeHeights.value[i] > targetScrollBottom) {
      baseEndIndex = i - 1;
      break;
    }
  }
  const finalEndIndex = Math.min(baseEndIndex + BUFFER, totalItemCount - 1); // 确保不大于总项数-1
  return finalEndIndex;
});

// 可见列表
const visibleList = computed(() => {
  const start = startIndex.value;
  const end = endIndex.value;
  return start <= end ? ListData.value.slice(start, end + 1) : [];
});

const offsetY = computed(() => {
  return cumulativeHeights.value[startIndex.value] || 0;
});

// 滚动节流处理
const handleScroll = () => {
  if (!virtualListRef.value) return;

  if (scrollTimer.value) clearTimeout(scrollTimer.value);
  scrollTimer.value = window.setTimeout(() => {
    scrollTop.value = virtualListRef.value!.scrollTop;
  }, 20);
};

const handleResize = () => {
  if (virtualListRef.value) {
    scrollTop.value = virtualListRef.value.scrollTop;
  }
};

const goBack = () => {
  router.push('/home');
};

// 生命周期
onMounted(() => {
  // 生成模拟数据
  ListData.value = Array.from({ length: 1000 }, (_, index) => ({
    id: index,
    name: `Item ${index}`,
  }));
  initPositionData();
  window.addEventListener('resize', handleResize); // 监听窗口大小变化
});

onUnmounted(() => {
  window.removeEventListener('resize', handleResize);
  if (scrollTimer.value) clearTimeout(scrollTimer.value);
  isUpdatingCumulative.value = false;
  itemHeights.value.clear();
});
</script>

3. React + tailwindCSS 实现(子组件抽离)

子组件:

import React, { useEffect, useRef, useState, useCallback } from 'react';

interface VirtualListItemProps {
  item: {
    id: number;
    name: string;
  };
  onUpdateHeight: (id: number, height: number) => void; // 替代 Vue 的 emit
}

const VirtualListItem: React.FC<VirtualListItemProps> = ({
  item,
  onUpdateHeight,
}) => {
  const itemRef = useRef<HTMLDivElement>(null);
  // 存储 ResizeObserver 实例(避免重复创建)
  const resizeObserverRef = useRef<ResizeObserver | null>(null);

  // 计算并上报高度
  const sendItemHeight = useCallback(() => {
    if (!itemRef.current) return;
    const realHeight = itemRef.current.offsetHeight;
    onUpdateHeight(item.id, realHeight);
  }, [item.id, onUpdateHeight]);

  useEffect(() => {
    const timer = setTimeout(() => {
      sendItemHeight();
    }, 0);

    // 初始化 ResizeObserver 监听高度变化
    if (window.ResizeObserver) {
      resizeObserverRef.current = new ResizeObserver(() => {
        sendItemHeight();
      });
      if (itemRef.current) {
        resizeObserverRef.current.observe(itemRef.current);
      }
    }

    // 清理定时器(对应 Vue 的 onUnmounted 部分)
    return () => {
      clearTimeout(timer);
      if (resizeObserverRef.current) {
        resizeObserverRef.current.disconnect();
        resizeObserverRef.current = null;
      }
    };
  }, [sendItemHeight]); // 仅首次挂载执行

  //监听 item 变化重新计算高度
  useEffect(() => {
    const timer = setTimeout(() => {
      sendItemHeight();
    }, 0);
    return () => clearTimeout(timer);
  }, [item.id, sendItemHeight]); // item.id 变化时执行

  const itemClass = `py-2 px-4 border-b border-gray-200 ${
    item.id % 2 !== 0 ? 'bg-pink-200' : 'bg-green-200'
  }`;

  const itemStyle: React.CSSProperties = {
    height: item.id % 2 === 0 ? '150px' : '100px',
  };

  return (
    <div ref={itemRef} className={itemClass} style={itemStyle}>
      {item.name}
    </div>
  );
};

export default VirtualListItem;


父组件:

import React, {
  useEffect,
  useRef,
  useState,
  useCallback,
  useMemo,
} from 'react';
import VirtualListItem from './listItem';

const VirtualList: React.FC = () => {
  const MIN_ITEM_HEIGHT = 100; // 最小项高度
  const BUFFER = 5; // 缓冲区项数

  const virtualListRef = useRef<HTMLDivElement>(null); // 虚拟列表容器引用

  const [listData, setListData] = useState<Array<{ id: number; name: string }>>(
    []
  ); // 列表数据
  const [scrollTop, setScrollTop] = useState(0); // 滚动位置
  const [itemHeights, setItemHeights] = useState<Map<number, number>>(
    new Map()
  ); // 高度映射表(Map 结构)
  const [cumulativeHeights, setCumulativeHeights] = useState<number[]>([0]); // 累计高度数组
  const scrollTimerRef = useRef<number | null>(null); // 滚动节流定时器

  // 初始化模拟数据
  const initData = () => {
    const mockData = Array.from({ length: 1000 }, (_, index) => ({
      id: index,
      name: `Item ${index}`,
    }));
    setListData(mockData);
    // 初始化高度映射表(默认最小高度)
    const initHeightMap = new Map<number, number>();
    mockData.forEach((item) => {
      initHeightMap.set(item.id, MIN_ITEM_HEIGHT);
    });
    setItemHeights(initHeightMap);
    // 初始化累计高度
    updateCumulativeHeights(initHeightMap, mockData);
  };

  useEffect(() => {
    initData();
    // 监听窗口大小变化
    const handleResize = () => {
      if (virtualListRef.current) {
        setScrollTop(virtualListRef.current.scrollTop);
      }
    };
    window.addEventListener('resize', handleResize);

    // 清理监听
    return () => {
      window.removeEventListener('resize', handleResize);
      if (scrollTimerRef.current) {
        clearTimeout(scrollTimerRef.current);
      }
      itemHeights.clear(); // 清空 Map 释放内存
    };
  }, []);

  // 更新累计高度(核心函数)
  const updateCumulativeHeights = useCallback(
    (heightMap: Map<number, number>, data: typeof listData) => {
      const cumulative = [0];
      let sum = 0;
      for (let i = 0; i < data.length; i++) {
        const itemId = data[i].id;
        sum += heightMap.get(itemId) || MIN_ITEM_HEIGHT;
        cumulative.push(sum);
      }
      setCumulativeHeights(cumulative);
    },
    [MIN_ITEM_HEIGHT]
  );

  // 处理子组件的高度更新事件(对应 Vue 的 handleItemHeightUpdate)
  const handleItemHeightUpdate = useCallback(
    (id: number, height: number) => {
      // 高度未变化则跳过
      if (itemHeights.get(id) === height) return;

      // 更新高度映射表
      const newHeightMap = new Map(itemHeights);
      newHeightMap.set(id, height);
      setItemHeights(newHeightMap);

      // 异步更新累计高度
      setTimeout(() => {
        updateCumulativeHeights(newHeightMap, listData);
      }, 0);
    },
    [itemHeights, listData, updateCumulativeHeights]
  );

  // 滚动节流处理
  const handleScroll = useCallback(() => {
    if (!virtualListRef.current) return;

    // 节流:20ms 内只更新一次 scrollTop
    if (scrollTimerRef.current) {
      clearTimeout(scrollTimerRef.current);
    }
    scrollTimerRef.current = setTimeout(() => {
      setScrollTop(virtualListRef.current!.scrollTop);
    }, 20);
  }, []);

  // 可视区域高度
  const viewportHeight = useMemo(() => {
    return virtualListRef.current?.clientHeight || MIN_ITEM_HEIGHT * 5;
  }, []);

  //  总列表高度
  const totalHeight = useMemo(() => {
    return cumulativeHeights[cumulativeHeights.length - 1] || 0;
  }, [cumulativeHeights]);

  // 起始索引
  const startIndex = useMemo(() => {
    const totalItemCount = listData.length;
    if (totalItemCount === 0) return 0;
    if (scrollTop <= 0) return 0;

    // 反向遍历找起始索引
    let baseStartIndex = 0;
    for (let i = cumulativeHeights.length - 1; i >= 0; i--) {
      if (cumulativeHeights[i] <= scrollTop) {
        baseStartIndex = i;
        break;
      }
    }

    const finalIndex = Math.max(0, baseStartIndex - BUFFER);
    return Math.min(finalIndex, totalItemCount - 1);
  }, [
    scrollTop,
    viewportHeight,
    totalHeight,
    cumulativeHeights,
    listData.length,
  ]);

  // 结束索引
  const endIndex = useMemo(() => {
    const totalItemCount = listData.length;
    if (totalItemCount === 0) return 0;

    const targetScrollBottom = scrollTop + viewportHeight;
    let baseEndIndex = totalItemCount - 1;

    for (let i = 0; i < cumulativeHeights.length; i++) {
      if (cumulativeHeights[i] > targetScrollBottom) {
        baseEndIndex = i - 1;
        break;
      }
    }

    let finalEndIndex = baseEndIndex + BUFFER;
    finalEndIndex = Math.min(finalEndIndex, totalItemCount - 1);
    return finalEndIndex;
  }, [scrollTop, viewportHeight, cumulativeHeights, listData.length]);

  // 可视区列表
  const visibleList = useMemo(() => {
    return startIndex <= endIndex
      ? listData.slice(startIndex, endIndex + 1)
      : [];
  }, [startIndex, endIndex, listData]);

  // 偏移量
  const offsetY = useMemo(() => {
    return cumulativeHeights[startIndex] || 0;
  }, [startIndex, cumulativeHeights]);

  return (
    <div className="h-full bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5">
      <div className="bg-white mt-10 h-[calc(100vh-200px)] rounded-xl">
        {/* 滚动容器 */}
        <div
          ref={virtualListRef}
          className="h-full overflow-auto relative"
          onScroll={handleScroll}
        >
          {/* 占位容器:撑开滚动条 */}
          <div style={{ height: `${totalHeight}px` }}></div>

          {/* 可视区域列表:transform 偏移 */}
          <div
            className="absolute top-0 left-0 right-0"
            style={{ transform: `translateY(${offsetY}px)` }}
          >
            {visibleList.map((item) => (
              <VirtualListItem
                key={item.id}
                item={item}
                onUpdateHeight={handleItemHeightUpdate}
              />
            ))}
          </div>
        </div>
      </div>
    </div>
  );
};

export default VirtualList;

4. 实现效果图

动高虚拟列表滚动.gif


四、 总结与避坑指南

1. 为什么需要缓冲区(BUFFER)?

如果只渲染可见部分,用户快速滚动时,异步渲染可能会导致瞬间的“白屏”。设置上下缓冲区可以预加载部分 DOM,让滑动更顺滑。

2. 性能进一步优化

  • 滚动节流(Throttle) :虽然滚动监听很快,但在 handleScroll 中加入 requestAnimationFrame 或 20ms 的节流,能有效减轻主线程压力。
  • Key 的选择:在虚拟列表中,key 必须是唯一的 id,绝对不能使用 index,否则在滚动重用 DOM 时会出现状态错乱。

3. 注意事项

  • 定高:逻辑简单,性能极高。
  • 不定高:依赖 ResizeObserver,需注意频繁重排对性能的影响,建议对 updateCumulativeHeights 做异步批处理。
昨天以前首页

JS-手写系列:防抖与节流

2026年2月5日 15:35

前言

在前端开发中,某些事件(如 resizescrollinputmousemove)会在短时间内频繁触发。如果处理函数涉及 DOM 操作或网络请求,频繁执行会导致页面卡顿或服务器压力过大。防抖节流正是解决这一问题的两把“手术刀”。


一、 防抖(Debounce)

1. 核心概念

触发事件后 nn 秒内函数只会执行一次。如果 nn 秒内事件再次被触发,则重新计算时间。“等最后一个人说完再行动。”

2. 使用场景

  • 搜索框输入:用户连续输入文字,只在停止输入后的 nn 毫秒发送搜索请求。
  • 窗口调整window.resize 时,只在用户停止拖拽后重新计算布局。

3. 实现

  function debounce(fn, delay) {
    let timer = null;

    return function (...args) {
      // 如果定时器存在,则清除,重新计时
      if (timer) clearTimeout(timer);

      // 正常的防抖逻辑
      timer = setTimeout(() => {
        fn.apply(this, args);
      }, delay);
    };
  }

  // 测试用例
  let count = 0;
  function handleInput() {
    count++;
    console.log('执行次数:', count);
  }

  const debouncedInput = debounce(handleInput, 1000);

  // 模拟快速调用5次
  debouncedInput();
  debouncedInput();
  debouncedInput();
  debouncedInput();
  debouncedInput();

  // 1秒后只会执行一次
  setTimeout(() => {
    console.log('最终执行次数应该是 1');
  }, 1100);

二、 节流(Throttle)

1. 核心概念

连续触发事件,但在 nn 秒内只允许执行一次。节流会显著稀释函数的执行频率。“技能冷却中。”

2. 使用场景

  • 鼠标点击:抢购按钮不断点击,规定时间内只发一次请求。
  • 滚动监听:页面无限加载时,每隔一段时间请求一次数据,而不是停下才请求。

3. 实现方案对比

方案 A:时间戳版

  • 特点:第一次触发立即执行。
 function throttleTimestamp(fn, delay) {
    let previous = 0;
    return function (...args) {
      const now = Date.now();
      if (now - previous > delay) {
        fn.apply(this, args);
        previous = now;
      }
    };
  }

  // 测试用例
  let count = 0;
  function handleClick() {
    count++;
    console.log('执行次数:', count, '时间:', Date.now());
  }

  const throttledClick = throttleTimestamp(handleClick, 1000);

  // 快速调用5次
  throttledClick();
  throttledClick();
  throttledClick();
  throttledClick();
  throttledClick();

  console.log('立即执行次数应该是 1');

  // 1.1秒后再调用,应该执行第二次
  setTimeout(() => {
    throttledClick();
    console.log('1.1秒后执行次数应该是 2');
  }, 1100);

方案 B:定时器版

  • 特点:第一次触发不会立即执行(需等待延迟)
  function throttleTimer(fn, delay) {
    let timer = null;
    return function (...args) {
      if (!timer) {
        timer = setTimeout(() => {
          timer = null;
          fn.apply(this, args);
        }, delay);
      }
    };
  }

  // 测试用例
  let count = 0;
  function handleClick() {
    count++;
    console.log('执行次数:', count, '时间:', Date.now());
  }

  const throttledClick = throttleTimer(handleClick, 1000);

  // 快速调用5次
  throttledClick();
  throttledClick();
  throttledClick();
  throttledClick();
  throttledClick();

  console.log('立即执行次数应该是 1');

  // 1.1秒后再调用,应该执行第二次
  setTimeout(() => {
    throttledClick();
    console.log('1.1秒后执行次数应该是 2');
  }, 1100);

三、 防抖与节流的本质区别

为了方便记忆,我们可以通过下表进行对比:

特性 防抖 (Debounce) 节流 (Throttle)
核心逻辑 重置计时器,只认最后一次 锁定计时器,在冷却期内忽略触发
执行频率 连续触发时,可能永远不执行(直到停止) 连续触发时,按固定频率执行
比喻 坐电梯:有人进来门就重新开,直到没人进来才走 坐地铁:每隔 10 分钟发一班车,准点出发

四、 进阶:如何选择?

  • 如果你的需求是 “只需要最终结果” (如输入框验证),选 防抖
  • 如果你的需求是 “过程中的平滑反馈” (如滚动加载、地图缩放),选 节流

JS-一文带你彻底搞懂 Promise 并发控制:all, race, any, allSettled

2026年2月4日 15:40

前言

在处理异步任务时,我们经常需要同时发起多个请求。Promise 提供的静态方法能让我们优雅地控制多个并发异步任务。本文将深度对比 allraceanyallSettled 的区别与应用场景。

一、 方法详解与对比

1. Promise.all() —— “全员通过制”

  • 概念:将多个 Promise 实例包装成一个。

  • 状态决定

    • Fulfilled:所有实例都成功。
    • Rejected:只要有一个失败,整体立即失败。
  • 应用场景:多个接口联动,必须全部拿到数据才能渲染页面。

  const p1 = Promise.resolve(1);
  const p2 = Promise.resolve(2);
  const p3 = Promise.resolve(3);

  Promise.all([p1, p2, p3])
    .then((results) => {
      console.log(results); // [1, 2, 3] 顺序与传入一致
    })
    .catch((err) => {
      console.error('其中一个失败了', err);
    });

2. Promise.race() —— “竞速制”

  • 概念:谁跑得快就听谁的。
  • 状态决定:状态取决于第一个改变状态的实例。
  • 应用场景:请求超时控制。
  const p1 = new Promise((resolve) =>
    setTimeout(() => resolve('1秒后成功'), 1000)
  );
  const p2 = new Promise((resolve) =>
    setTimeout(() => resolve('500毫秒后成功'), 500)
  );
  Promise.race([p1, p2]).then(
    (res) => console.log(`测试1结果:${res} (竞速赢家)`, 'success'),
    (err) => console.log(`测试1失败:${err}`, 'error')
  );

3. Promise.any() —— “择优录取制”

  • 概念:只要有一个成功就算成功。

  • 状态决定

    • Fulfilled:只要有一个成功。
    • Rejected全部都失败时才失败(返回 AggregateError)。
  • 应用场景:从多个备用服务器获取相同资源。

  const p1 = Promise.resolve(1);
  const p2 = Promise.reject(2);
  const p3 = Promise.reject(3);
  Promise.any([p1, p2, p3])
    .then((res) => console.log(`有一个成功了:${res}`, 'success'))
    .catch((err) => console.log(`所有都失败了:${err}`, 'error'));

4. Promise.allSettled() —— “结果导向制”

  • 概念:无论成功失败,我全都要。
  • 状态决定:永远是 fulfilled(在所有实例都结束后)。
  • 应用场景:执行多个互不影响的操作,最后统一统计结果。

二、 核心差异对比表

为了 scannability(易读性),我们通过表格直观对比:

方法 成功条件 (Fulfilled) 失败条件 (Rejected) 结果返回值
.all() 全部成功 任意一个失败 成功结果数组(按序)
.race() 任意一个最先成功 任意一个最先失败 第一个改变状态的值
.any() 任意一个成功 全部失败 第一个成功的值
.allSettled() 所有任务结束 从不(状态永远成功) 包含状态和值的对象数组

三、 总结

1. Promise.all 与 Promise.race 的直观区别

  • Promise.all:照顾“跑得最慢”的。必须等最慢的一个完成,且全员合格,才给最终结果。
  • Promise.race:关注“跑得最快”的。最快的那个一旦过线,无论输赢,比赛立刻结束。

2. 进阶使用

在项目中,配合 async/await 使用更加优雅:

const fetchData = async () => {
  try {
    // 强类型约束结果数组
    const [user, orders] = await Promise.all<[UserType, OrderType[]]>([
      getUserInfo(),
      getOrderList()
    ]);
    console.log(user, orders);
  } catch (error) {
    // 处理第一个捕获到的错误
  }
};

JS-手写系列:从零手写 Promise

2026年2月4日 15:17

前言

Promise 是 JavaScript 处理异步编程的基石。虽然我们在日常开发中频繁使用 async/await,但手动实现一个符合 Promise规范的类,不仅能让你在面试中脱颖而出,更能让你深刻理解微任务与链式调用的本质。


一、 Promise 核心设计方案

实现一个标准的 Promise,必须紧扣以下四个核心点:

  1. 状态机机制:存在 PENDING(等待)、FULFILLED(成功)、REJECTED(失败)三种状态,状态转换不可逆。
  2. 立即执行:构造函数中的执行器 executor(resolve, reject) 是同步立即执行的。
  3. 微任务队列:回调函数的执行必须是异步的,通常使用 queueMicrotask 来实现。
  4. 链式调用then 方法必须返回一个新的 Promise,并将前一个 Promise 的输出作为后一个 Promise 的输入。

二、 代码实现

      // 定义Promise的三种状态
      const PENDING = 'pending';
      const FULFILLED = 'fulfilled';
      const REJECTED = 'rejected';

      /**
       * 自定义Promise实现
       * @param {Function} executor 执行器函数,接收resolve和reject参数
       */
      function MyPromise(executor) {
        const self = this; // 保存this指向,避免回调中丢失
        self.status = PENDING; // 初始状态为pending
        self.value = undefined; // 成功的结果值
        self.reason = undefined; // 失败的原因
        self.onFulfilledCallbacks = []; // 存储成功回调
        self.onRejectedCallbacks = []; // 存储失败回调

        // 成功回调函数
        function resolve(value) {
          // 只有pending状态才能改变
          if (self.status === PENDING) {
            self.status = FULFILLED;
            self.value = value;
            // 异步执行所有成功回调,因为then方法是异步的,所以要等executor执行完再执行
            queueMicrotask(() => {
              self.onFulfilledCallbacks.forEach((callback) => {
                callback(self.value);
              });
            });
          }
        }

        // 失败回调函数
        function reject(reason) {
          // 只有pending状态才能改变
          if (self.status === PENDING) {
            self.status = REJECTED;
            self.reason = reason;
            // 异步执行所有失败回调
            setTimeout(() => {
              self.onRejectedCallbacks.forEach((callback) => {
                callback(self.reason);
              });
            });
          }
        }

        try {
          // 立即执行执行器函数
          executor(resolve, reject);
        } catch (error) {
          // 执行器抛出异常时,直接调用reject
          reject(error);
        }
      }

      /** then方法实现
       * @param {Function} onFulfilled 成功回调
       * @param {Function} onRejected 失败回调
       * @returns {MyPromise} 返回新的Promise实现链式调用
       */
      MyPromise.prototype.then = function (onFulfilled, onRejected) {
        const self = this;
        // 处理默认回调(兼容不传回调的情况)
        onFulfilled =
          typeof onFulfilled === 'function' ? onFulfilled : (value) => value;
        onRejected =
          typeof onRejected === 'function'
            ? onRejected
            : (reason) => {
                throw reason;
              };

        // 返回新的Promise实现链式调用,因为后续函数也可以通过then方法来处理
        return new MyPromise((resolve, reject) => {
          // 处理成功状态
          if (self.status === FULFILLED) {
            setTimeout(() => {
              try {
                // 执行成功回调并获取返回值
                const result = onFulfilled(self.value);
                // 根据返回值处理新Promise的状态
                resolvePromise(result, resolve, reject);
              } catch (error) {
                reject(error);
              }
            });
          }


          // 处理失败状态
          if (self.status === REJECTED) {
            setTimeout(() => {
              try {
                // 执行失败回调并获取返回值
                const result = onRejected(self.reason);
                // 根据返回值处理新Promise的状态
                resolvePromise(result, resolve, reject);
              } catch (error) {
                reject(error);
              }
            });
          }

          // 处理等待状态(暂存回调)
          if (self.status === PENDING) {
            self.onFulfilledCallbacks.push(() => {
              try {
                const result = onFulfilled(self.value);
                resolvePromise(result, resolve, reject);
              } catch (error) {
                reject(error);
              }
            });

            self.onRejectedCallbacks.push(() => {
              try {
                const result = onRejected(self.reason);
                resolvePromise(result, resolve, reject);
              } catch (error) {
                reject(error);
              }
            });
          }
        });
      };

      /** catch方法实现(语法糖,等价于then(null, onRejected))
       * @param {Function} onRejected 失败回调
       * @returns {MyPromise}
       */
      MyPromise.prototype.catch = function (onRejected) {
        return this.then(null, onRejected);
      };

      //静态方法resolve - 返回一个已完成的Promise
      MyPromise.resolve = function (value) {
        if (value instanceof MyPromise) {
          return value;
        }
        return new MyPromise((resolve) => {
          resolve(value);
        });
      };

      //静态方法reject - 返回一个已失败的Promise
      MyPromise.reject = function (reason) {
        return new MyPromise((_, reject) => {
          reject(reason);
        });
      };

      // 处理then回调返回值的工具函数
      function resolvePromise(result, resolve, reject) {
        // 如果返回值是当前Promise,抛出循环引用错误
        if (result instanceof MyPromise) {
          result.then(resolve, reject);
        } else {
          // 普通值直接resolve
          resolve(result);
        }
      }
    
      // 测试用例
      console.log('--- 测试用例开始 ---');

      // 1. 基本 resolve
      const p1 = new MyPromise((resolve) => {
        setTimeout(() => resolve('成功1'), 100);
      })
      p1.then(res => {
        console.log('Test 1: 成功1', res); // 成功1
      })
      p1.catch(err => {
        console.log('Test 1: 失败1', err);
      })

      // 2. 基本 reject + catch
      // const p2 = new MyPromise((_, reject) => {
      //   setTimeout(() => reject('失败1'), 100);
      // })
      // p2.then(success => {
      //   console.log('Test 2: 成功1', success); // 失败1
      // }, (err) => {
      //   console.log('Test 2: 失败1', err); // 失败1
      // })
      // p2.catch(err => {
      //   console.log('Test 2: 失败2', err); // 失败1
      // })

      // 3. executor 抛出异常
      // new MyPromise(() => {
      //   throw new Error('executor error');
      // }).catch(err => {
      //   console.log('Test 4: 异常', err.message); // executor error
      // });

      // 4. Promise 链式嵌套
      // const p4 = new MyPromise((resolve) => resolve(1))
      //   .then((v) => v + 1)
      //   .then((v) => MyPromise.resolve(v * 2))
      //   .then((v) => {
      //     console.log('Test 5:', v); // 4
      //   });

三、 深度细节解析

1. 为什么使用 queueMicrotask

根据 Promise规范,onFulfilledonRejected 必须在执行上下文栈仅包含平台代码时执行。这意味着回调必须是异步的。使用微任务而不是宏任务(如 setTimeout),是为了保证在当前任务循环结束前尽可能快地执行回调。

2. 状态不可逆性

resolvereject 函数中,我们首先判断 state === 'pending'。一旦状态变为 fulfilledrejected,后续任何调用都将被忽略,这保证了 Promise 的稳定性。


四、 总结

  1. 同步执行 executor
  2. 异步收集 回调函数。
  3. 递归解析 返回值,实现链式调用。

JS-手写系列:树与数组相互转换

2026年2月5日 11:48

前言

在前端业务中,后端返回的扁平化数组(Array)往往需要转换为树形结构(Tree)来适配 UI 组件(如 Element UI 的 Tree 或 Cascader)。掌握多种转换思路及性能差异,是进阶高级前端的必备技能。

一、 核心概念:结构对比

  • 数组结构:每一项通过 parentId 指向父级。

      const nodes = [
        { id: 3, name: '节点C', parentId: 1 },
        { id: 6, name: '节点F', parentId: 3 },
        { id: 0, name: 'root', parentId: null },
        { id: 1, name: '节点A', parentId: 0 },
        { id: 8, name: '节点H', parentId: 4 },
        { id: 4, name: '节点D', parentId: 1 },
        { id: 2, name: '节点B', parentId: 0 },
        { id: 5, name: '节点E', parentId: 2 },
        { id: 7, name: '节点G', parentId: 2 },
        { id: 9, name: '节点I', parentId: 5 },
      ];
    
  • 树形结构:父级通过 children 数组包裹子级。

      let tree = [
        {
          id: 1,
          name: 'text1',
          parentId: 1,
          children: [
            {
              id: 2,
              name: 'text2',
              parentId: 1,
              children: [
                {
                  id: 4,
                  name: 'text4',
                  parentId: 2,
                },
              ],
            },
            {
              id: 3,
              name: 'text3',
              parentId: 1,
            },
          ],
        },
      ];
    

二、 数组转树

1. 递归思路

原理

  1. 首先需要传递给函数两个参数:数组、当前的父节点id
  2. 设置一个结果数组res,遍历数组,先找到子元素的父节点id与父节点id一致的子项
  3. 将这个子项的id作为父节点id传入函数,继续遍历
  4. 将遍历的结果作为children返回,并给当前项添加children
  5. 将这个当前项,插入到res里面,并返回

注意:如果不想影响原数组,需要先深拷贝一下数组。const cloneArr = JSON.parse(JSON.stringify (arr))

  const nodes = [
    { id: 3, name: '节点C', parentId: 1 },
    { id: 6, name: '节点F', parentId: 3 },
    { id: 0, name: 'root', parentId: null },
    { id: 1, name: '节点A', parentId: 0 },
    { id: 8, name: '节点H', parentId: 4 },
    { id: 4, name: '节点D', parentId: 1 },
    { id: 2, name: '节点B', parentId: 0 },
    { id: 5, name: '节点E', parentId: 2 },
    { id: 7, name: '节点G', parentId: 2 },
    { id: 9, name: '节点I', parentId: 5 },
  ];
  //递归写法
  const arrToTree1 = (arr, id) => {
    const res = [];
    arr.forEach((item) => {
      if (item.parentId === id) {
        const children = arrToTree1(arr, item.id);
        //如果希望每个元素都有children属性,可以直接赋值
        if (children.length !== 0) {
          item.children = children;
        }
        res.push(item);
      }
    });
    return res;
  };
  console.log(arrToTree1(nodes, null));

2. 非递归思路

原理:利用 filter 进行二次筛选。虽然写法简洁,但在大数据量下性能较差(O(n2)O(n^2))。

  1. 函数只需要接受一个参数,也就是需要转换的数组arr
  2. 第一层过滤数组,直接返回一个parentId为根id的元素
  3. 但是在返回之间,需要再根据当前id过滤里面的每一项(过滤规则为如果子项的paentId为当前的id,则在当前项的children插入这个子项)
  const arrToTree2 = (arr) => {
    return arr.filter((father) => {
      const childrenArr = arr.filter((children) => {
        return children.parentId === father.id;
      });
      //如果希望每个元素都有children属性,可以直接赋值
      if (childrenArr.length !== 0) {
        father.children = childrenArr;
      }
      return father.parentId === null;
    });
  };
  console.log(arrToTree2(nodes));

3. Map 对象方案(O(n)O(n) 时间复杂度)

原理:利用对象的引用性质。先将数组转为 Map,再遍历一次即可完成。这是在大数据量下的首选方案。

  const arrToTree3 = (arr) => {
    const map = {};
    const res = [];

    // 1. 建立映射表
    arr.forEach((item) => {
      map[item.id] = { ...item, children: [] };
    });

    // 2. 组装树结构
    arr.forEach((item) => {
      const node = map[item.id];
      if (item.parentId === null) {
        res.push(node);
      } else {
        if (map[item.parentId]) {
          map[item.parentId].children.push(node);
        }
      }
    });
    return res;
  };
  console.log(arrToTree3(nodes));

三、 树转数组

1. 递归遍历思路

原理:定义一个结果数组,递归遍历树的每一层,将节点信息(排除 children)推入数组。

  1. 首先定义一个结果数组res,遍历传入的树
  2. 直接将当前项的id、name、parentId包装在一个新对象里插入
  3. 判断是否有children属性,如果有则遍历children属性每一项,继续执行2、3步骤
  let tree = [
    {
      id: 1,
      name: 'text1',
      parentId: 1,
      children: [
        {
          id: 2,
          name: 'text2',
          parentId: 1,
          children: [
            {
              id: 4,
              name: 'text4',
              parentId: 2,
            },
          ],
        },
        {
          id: 3,
          name: 'text3',
          parentId: 1,
        },
      ],
    },
  ];
  const treeToArr = (tree) => {
    const res = [];
    tree.forEach((item) => {
      const loop = (data) => {
        res.push({
          id: data.id,
          name: data.name,
          parseId: data.parentId,
        });
        if (data.children) {
          data.children.forEach((itemChild) => {
            loop(itemChild);
          });
        }
      };
      loop(item);
    });
    return res;
  };
  console.log(treeToArr(tree));

四、 注意事项:深拷贝的必要性

在处理这些转换时,由于 JS 的对象是引用类型,直接修改 item.children 会改变原始数组的内容。

  • 快捷方案const cloneArr = JSON.parse(JSON.stringify(arr))
  • 避坑点:如果数组项中包含 Date 对象、RegExpFunctionJSON.parse 会导致数据失真,此时应使用其他深拷贝方案。

JS-手写系列:call、apply、bind

2026年2月5日 11:11

前言

在 JavaScript 中,this 的指向总是让人捉摸不透。callapplybind 作为改变 this 指向的三大杀手锏,其底层实现原理是面试中的高频考点。本文将带你通过手写实现,彻底搞懂它们背后的逻辑。

一、 手写 call

1. 核心思路

利用“对象调用方法时,方法内部 this 指向该对象”这一隐式绑定规则。

  • 将函数设为目标对象的一个属性。
  • 执行该函数。
  • 删除该临时属性,返回结果。

2. 实现

 Function.prototype.myCall = function (target, ...args) {
    // 1. 处理 target 为空的情况,默认为 window
    if (target === undefined || target === null) {
      target = window;
    }
    // 2. 创建唯一键,避免覆盖目标对象原有属性
    const fnKey = Symbol('fn');
    // 3. 将当前函数(this)指向目标对象的属性
    target[fnKey] = this;
    // 4. 执行函数并展开参数
    const result = target[fnKey](...args);
    // 5. 善后处理:删除临时属性
    delete target[fnKey];

    return result;
  };
  const obj = {
    age: 18,
    name: 'a',
    getName: function (job, hobby) {
      console.log(this.name, job, hobby);
    },
  };
  obj.getName.call(); // undefined undefined
  obj.getName.call({ name: 'b' }, 1, 2, 3); // b 1,2

  obj.getName.myCall(); // undefined undefined
  obj.getName.myCall({ name: 'b' }, 1, 2, 3); // b,1,2
};

二、 手写 apply

思路与call一致,都是利用“对象调用方法时,方法内部 this 指向该对象”这一隐式绑定规则

1. 实现

  //唯一区别:参数处理方式,call需要使用...展开
  Function.prototype.myApply = function (target, args) {
    // 1. 处理 target 为空的情况,默认为 window
    if (target === undefined || target === null) {
      target = window;
    }
    // 2. 创建唯一键,避免覆盖目标对象原有属性
    const fnKey = Symbol('fn');
    // 3. 将当前函数(this)指向目标对象的属性
    target[fnKey] = this;
    // 4. 执行函数并展开参数
    const result = target[fnKey](...(args || []));
    // 5. 善后处理:删除临时属性
    delete target[fnKey];

    return result;
  };
  const obj = {
    age: 18,
    name: 'a',
    getName: function (job, hobby) {
      console.log(this.name, job, hobby);
    },
  };
  obj.getName.apply(); // undefined undefined
  obj.getName.apply({ name: 'b' }, [1, 2, 3]); // b 1,2

  obj.getName.myApply(); // undefined undefined
  obj.getName.myApply({ name: 'b' }, [1, 2, 3]); // b,1,2

二、 手写 bind

bind 的实现比前两者复杂,因为它涉及两个核心特性:闭包返回函数支持 new 实例化。当 bind 返回的函数被用作 new 构造函数时:

  • this 绑定失效:生成的实例 this 应该指向 new 创建的对象,而非 bind 绑定的对象。
  • 原型链继承:实例需要能够访问到原函数原型(prototype)上的属性和方法。

2. 实现

  Function.prototype.myBind = function (fn, ...args1) {
    const self = this; // 保存原函数
    const bound = function (...args2) {
      // 如果 this 是 bound 的实例,说明是 new 调用,此时 fn 应该失效
      return self.apply(this instanceof bound ? this : fn, [
        ...args1,
        ...args2,
      ]);
    };
    // 修改原型链,使实例能继承原函数原型, 使用 Object.create 避免直接修改导致相互影响
    bound.prototype = Object.create(self.prototype);
    bound.prototype.constructor = self;
    return bound;
  };

  const obj = {
    age: 18,
    name: 'a',
    getName: function (job, hobby) {
      console.log(this.name, job, hobby);
    },
  };

  const boundGetName1 = obj.getName.bind({ name: 'b' }, 7, 8);
  const boundGetName2 = obj.getName.myBind({ name: 'b' }, 7, 8);
  boundGetName1(); // b 7 8
  boundGetName2(); // b 7 8

  let newFunc1 = obj.getName.bind({ name: 'aa' }, 7, 8);
  let newFunc2 = obj.getName.myBind({ name: 'aa' }, 7, 8);
  newFunc1(); // aa 7 8
  newFunc2(); // aa 7 8

三、 总结与核心差异

方法 参数传递 返回值 核心原理
call 参数列表 (obj, a, b) 函数执行结果 临时属性挂载(隐式绑定)
apply 数组/类数组 (obj, [a, b]) 函数执行结果 临时属性挂载(隐式绑定)
bind 参数列表 (obj, a) 返回新函数 闭包 + apply

JS-手写系列:new操作符

2026年2月5日 10:02

前言

在 JavaScript 中,new 关键字就像是一个“工厂加工器”。虽然它看起来只是简单地创建了一个实例,但其背后涉及到了原型链接、上下文绑定以及返回值的特殊处理。掌握 new 的实现原理,是通往 JS 高级开发者的必经之路。

一、 new 操作符的 4 个核心步骤

当我们执行 new Constructor() 时,JavaScript 引擎在后台完成了以下四件事:

  1. 开辟空间:创建一个全新的空对象。
  2. 原型链接:将该对象的隐式原型(__proto__)指向构造函数的显式原型(prototype)。
  3. 绑定 this:执行构造函数,并将其内部的 this 绑定到这个新对象上。
  4. 返回结果:根据构造函数的返回值类型,决定最终返回的对象。

二、 代码实现

在实现中,我们不仅要处理常规逻辑,还要兼容构造函数可能返回引用类型的情况。

  function myNew(Constructor, ...args) {
    // 1. 创建一个空对象,并将其原型指向构造函数的 prototype
      const obj = {};
      obj.__proto__ = Constructor.prototype;
    // 2. 执行构造函数,并将 this 绑定到新创建的对象上
    const result = Constructor.apply(obj, args);

    // 3. 处理返回值逻辑:如果构造函数显式返回了一个对象或函数,则返回该结果; 否则,返回我们创建的新对象 obj
    const isObject = typeof result === 'object' && result !== null;
    const isFunction = typeof result === 'function';

    return (isObject || isFunction) ? result : obj;
  }

  // 测试用例
  function Person(name, age) {
    this.name = name;
    this.age = age;
  }

  const per1 = new (Person)('ouyange', 23);
  const per2 = myNew(Person, 'ouyange', 23);

  console.log('原生 new 结果:', per1);
  console.log('手写 myNew 结果:', per2);

三、 细节解析

1. 构造函数返回值的坑

  • 如果构造函数 return 123(原始类型),new 会忽略它,依然返回实例对象。

  • 如果构造函数 return { a: 1 }(对象类型),new 会丢弃原本生成的实例,转而返回这个对象。

Vue-深度解读代理技术:Object.defineProperty 与 Proxy

2026年2月3日 17:06

前言

在 Vue 的进化史中,从 Vue 2 到 Vue 3 的跨越,最核心的变革莫之过于响应式系统的重构。而这场重构的主角,正是 Object.definePropertyProxy。本文将带你从底层描述符到 Reflect 陷阱,深度拆解这两大对象代理技术。

一、 ES5 时代的功臣:Object.defineProperty

Object.defineProperty 用于在一个对象上定义或修改属性。Vue 2 的响应式基础正是建立在其“存取描述符”之上的。

1. 基础语法

Object.defineProperty(obj, prop, descriptor);

  • obj:目标对象
  • prop:要定义或修改的属性名(字符串或 Symbol)
  • descriptor:属性描述符,是一个配置对象(包含数据描述符与存取描述符)

2. descriptor描述符分类

它可分为两类,一类为数据描述符、一类为存取描述符

属性描述符不能同时包含 value/writable(数据描述符)和 get/set(存取描述符)。

  • 数据描述符

    字段 类型 默认值 说明
    value any undefined 属性的值
    writable boolean false 是否可写(能否被重新赋值)
    enumerable boolean false 是否可枚举(能否在 for...inObject.keys 中出现)
    configurable boolean false 是否可配置(能否被删除或修改描述符)
  • 存取描述符:

    字段 类型 说明
    get function 读取属性时调用的函数
    set function 设置属性时调用的函数

注意❗:一个描述符不能同时包含 value/writableget/set,否则会报错。

3. 局限性分析(Vue 2 的痛点)

  • 无法监听新增/删除:必须预先定义好属性,动态添加的属性(data.b = 2)无法响应。

  • 数组支持差:无法拦截索引修改(arr[0] = x)及 length 变更。

  • 性能开销:必须通过递归遍历对象的所有属性进行拦截。

4. 使用示例:

// 封装一个劫持对象所有属性的函数
function observe(obj) {
  // 遍历对象的自有属性
  Object.keys(obj).forEach((prop) => {
    let value = obj[prop]; // 存储原始值
    Object.defineProperty(obj, prop, {
      enumerable: true,
      configurable: true,
      get() {
        console.log(`读取 ${prop} 属性:${value}`);
        return value;
      },
      set(newValue) {
        console.log(`给 ${prop} 赋值:${newValue}`);
        value = newValue;
      },
    });
  });
}

// 测试
const person = { name: "张三", gender: "男" };
observe(person);

person.name = "李四"; // 输出:给 name 赋值:李四
console.log(person.gender); // 输出:读取 gender 属性:男 → 男

二、 ES6 时代的巅峰:Proxy

Proxy 是ES6引入的一个新对象,用于创建一个对象的代理,从而拦截并自定义这个对象的基本操作(比如属性读取、赋值、删除、遍历等)。它是 Vue 3 实现高效响应式的基石。

1. 基本语法

  • 语法:const proxy = new Proxy(target, handler);

    • target:要代理的目标对象(可以是普通对象、数组、函数,甚至是另一个 Proxy)。

    • handler:一个配置对象,包含多个陷阱函数(traps),每个陷阱函数对应一种对目标对象的操作(比如读取属性对应get陷阱,赋值对应set陷阱)

    • proxy:返回的代理对象,后续操作都通过这个代理对象进行,而非直接操作原对象。

1. 常见陷阱函数 (Traps)

Proxy 的强大在于它能拦截多种底层操作。

Trap 触发时机 示例
get(target, prop, receiver) 读取属性时 obj.foo
set(target, prop, value, receiver) 设置属性时 obj.foo = 'bar'
has(target, prop) 使用in 操作符时 'foo' in obj
deleteProperty(target, prop) 删除属性时 delete obj.foo
ownKeys(target) 获取自身属性名时 Object.keys(obj)
apply(target, thisArg, args) 调用函数时(仅当 target 是函数) fn()
construct(target, args) 使用new操作符时 new Obejct()

2. 使用示例

    // 1. 定义原始用户对象
    const user = {
      name: '张三',
      age: 20,
    };

    // 2. 创建 Proxy 代理对象
    const userProxy = new Proxy(user, {
      // 拦截属性读取操作(比如 userProxy.name)
      get(target, prop, receiver) {
        console.log(`读取属性${prop}`);
        // 核心逻辑:属性不存在时返回默认提示
        if (!Reflect.has(target, prop)) {
          return `属性${prop}不存在`;
        }
        return Reflect.get(target, prop, receiver); // 用 Reflect 保证 this 指向正确
      },

      // 拦截属性赋值操作(比如 userProxy.age = 25)
      set(target, prop, value, receiver) {
        console.log(`给属性${prop}赋值:${value}`);
        // 核心逻辑:属性合法性校验
        switch (prop) {
          case 'age':
            if (typeof value !== 'number' || value <= 0) {
              console.error(' 年龄必须是大于0的数字!');
              return false; // 返回 false 表示赋值失败
            }
            break;
          case 'name':
            if (typeof value !== 'string' || value.trim() === '') {
              console.error(' 姓名不能为空字符串!');
              return false;
            }
            break;
        }
        return Reflect.set(target, prop, value, receiver); // 合法则执行赋值,返回 true 表示成功
      },
    });

    // 3. 测试代理功能
    console.log('===== 测试属性读取 =====');
    console.log(userProxy.name); // 读取存在的属性
    console.log(userProxy.age); // 读取存在的属性
    console.log(userProxy.gender); // 读取不存在的属性

    console.log('\n===== 测试合法赋值 =====');
    userProxy.age = 25; // 合法的年龄赋值
    userProxy.name = '李四'; // 合法的姓名赋值
    console.log('赋值后 name:', userProxy.name);
    console.log('赋值后 age:', userProxy.age);

    console.log('\n===== 测试非法赋值 =====');
    userProxy.age = -5; // 非法的年龄(负数)
    userProxy.name = ''; // 非法的姓名(空字符串)
    console.log('非法赋值后 age:', userProxy.age); // 年龄仍为 25
   
// 打印结果:  
===== 测试属性读取 =====
 读取属性name
 张三
 读取属性age
 20
 读取属性gender
 属性gender不存在
===== 测试合法赋值 =====
 给属性age赋值:25
 给属性name赋值:李四
 读取属性name
 赋值后 name: 李四
 读取属性age
 赋值后 age: 25
 114 
===== 测试非法赋值 =====
 给属性age赋值:-5
 年龄必须是大于0的数字!
 给属性name赋值:
 姓名不能为空字符串!
 读取属性age
 非法赋值后 age: 25



三、 Reflect:Proxy 的最佳拍档

Reflect 是 ES6 引入的内置全局对象,不能通过 new 实例化(不是构造函数)。它的核心作用是把原本属于 Object 对象的底层操作(比如属性赋值、删除)提炼成独立的函数方法,同时能保证操作的 “正确性”—— 比如转发操作时保留正确的 this 指向。

1. 为什么一定要配合 Reflect?

核心原因:处理 this 指向问题。

当对象内部存在 getter 并依赖 this 时,如果直接使用 target[prop]this 将指向原始对象而非代理对象,导致后续的属性读取无法被 Proxy 拦截。

2. Reflect使用对比

const person = {
      _name: '张三',
      get name() {
        console.log('getter 被调用,this:', this === person ? 'person' : this);
        return this._name;
      },

      introduce() {
        console.log('this', this)
        return `我叫${this.name}`;
      },
    };

    // 错误代理
    const badProxy = new Proxy(person, {
      get(target, prop, receiver) {
        console.log(`拦截: ${prop}`);
        if (prop === 'introduce') {
          const original = target[prop]; // 错误:直接获取
          return function () {
            return original(); // this 指向 badProxy
          };
        }
        return target[prop];
      },
    });

    // 正确代理
    const goodProxy = new Proxy(person, {
      get(target, prop, receiver) {
        console.log(`拦截: ${prop}`);
        if (prop === 'introduce') {
          return function () {
            return Reflect.apply(target[prop], receiver, arguments); // 正确
          };
        }
        return Reflect.get(target, prop, receiver);
      },
    });

    console.log('=== 测试错误代理 ===');
    console.log(badProxy.introduce());

    console.log('\n=== 测试正确代理 ===');
    console.log(goodProxy.introduce()

3. 打印结果分析

  1. 首先执行console.log(badProxy.introduce())

    • 它会读取badProxy.introduce属性,触发badProxyget 陷阱,参数target = personprop = 'introduce'receiver = badProxy
  2. 接着进入badProxyget陷阱函数,此时返回的新函数被赋值给badProxy.introduce,然后执行这个新函数。

    console.log(`拦截: ${prop}`);  // 输出:拦截: introduce
    if (prop === 'introduce') {
      const original = target[prop]; // 拿到 person.introduce 函数
      return function () { // 返回一个新函数
        return original(); // 关键错误:裸调用 original
      };
    }
    
  3. 执行返回的新函数original()(即person.introduce()

    • original是裸调用(没有对象前缀),所以introduce方法里的this指向window(非严格模式);
    • 输出:this window
    • 执行this.namewindow.name,不会触发personnamegetter(因为this不是person/badProxy),所以window._name不存在,返回undefined
    • 最终返回我叫undefined,控制台输出:我叫

  4. 执行console.log(goodProxy.introduce())

    • 它会读取goodProxy.introduce属性,触发goodProxyget 陷阱,参数:
    • target = personprop = 'introduce'receiver = goodProxy
  5. 第一次触发get陷阱(拦截introduce),此时返回的新函数被赋值给goodProxy.introduce,然后执行这个新函数

    console.log(`拦截: ${prop}`); // 输出:拦截: introduce → 第一次拦截
    if (prop === 'introduce') {
      return function () { // 返回一个新函数
        return Reflect.apply(target[prop], receiver, arguments); // 正确绑定 this
      };
    }
    
  6. 执行返回的新函数,Reflect.apply(target[prop], receiver, arguments),其中

    • target[prop]=person.introduce 函数;
    • receiver=goodProxy(把introduce方法的this绑定到goodProxy);
    • 执行person.introduce方法,此时方法内的this = goodProxy
  7. 执行 introduce 方法内部代码

    console.log('this', this); // 输出:this Proxy(Object) { _name: '张三' }(即 goodProxy)
    return `我叫${this.name}`; // 关键:读取 this.name → goodProxy.name
    
  8. 第二次触发get陷阱(拦截name),因为this = goodProxy,所以this.name等价于goodProxy.name,需要读取goodProxy.name属性,再次触发goodProxyget 陷阱,参数:

    • target = personprop = 'name'receiver = goodProxy
    • 进入get陷进函数
console.log(`拦截: ${prop}`); // 输出:拦截: name → 第二次拦截
if (prop === 'introduce') { /* 不执行 */ }
return Reflect.get(target, prop, receiver); // 调用 Reflect.get 读取 person.name

9. 调用Reflect.get(target, prop, receiver),触发person.name的 getter,此时 getter 里的thisreceiver绑定为goodProxy

get name() {
  console.log('getter 被调用,this:', this === person ? 'person' : this); 
  // 输出:getter 被调用,this: Proxy(Object) { _name: '张三' }
  return this._name; // this = goodProxy → 读取 goodProxy._name
}

10. 返回this._name(不是name!),这时会第三次触发goodProxy的get陷阱(prop = '_name'

console.log(`拦截: ${prop}`); // 输出:拦截: _name
return Reflect.get(target, '_name', receiver); // 返回 person._name = '张三'

11. 最终返回结果 我叫张三

![](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/cb44628ee3904c759428efdadbba9e90~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5Y-R546w5LiA5Y-q5aSn5ZGG55Oc:q75.awebp?rk3s=f64ab15b&x-expires=1770714393&x-signature=VN5mF0OKtlLwwfknHvfBPYIqpVE%3D)

四、 总结:Proxy 的降维打击

  1. 全方位拦截:不仅能拦截读写,还能拦截删除、函数调用、new 操作等。
  2. 性能优势:无需遍历属性,直接代理整个对象。
  3. 原生支持数组:完美解决 Vue 2 中数组监听的各种奇技淫巧(如重写数组原型方法)。
  4. 配合 Reflect:通过 receiver 参数完美转发 this 绑定,保证了响应式系统的严密性。
❌
❌