普通视图

发现新文章,点击刷新页面。
今天 — 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 的计算可能变慢。此时可考虑分段计算维护高度。


国投白银LOF:提示交易价格溢价风险并公布停复牌安排

2026年2月8日 18:08
2月8日,国投白银LOF公告称,近期其二级市场交易价格明显高于基金份额净值,溢价幅度较大。为保护投资者利益,基金将于2026年2月9日开市起至10:30停牌,10:30复牌。若当日溢价幅度未有效回落,基金有权采取进一步措施。自2026年1月28日起,基金已暂停��购(含定期定额投资)业务,恢复时间另行公告。目前基金运作正常,无应披露未披露重大信息。(每日经济新闻)

杉杉股份:若重整成功,公司实际控制人将变更为安徽省国资委

2026年2月8日 18:07
2月8日,杉杉股份公告称,公司控股股东杉杉集团及其全资子公司宁波朋泽贸易有限公司、杉杉集团管理人与重整投资人安徽皖维集团有限责任公司和宁波金融资产管理股份有限公司签署了《重整投资协议》。若本次重整成功,公司���控制权将发生变更,公司控股股东将变更为皖维集团,公司实际控制人将变更为安徽省国资委。(财联社)

马斯克:是时候大规模重返月球了

2026年2月8日 18:06
2月8日,马斯克发帖表示,是时候大规模重返月球了。此外,有消息称SpaceX正在奥斯汀和西雅图招聘工程师,以开发人工智能卫星和太空数据中心。马斯克转发相关帖子并回复称:是真的。(财联社)

“破案”笔记:iframe动态加载内容后,打印功能为何失灵?

2026年2月8日 17:14

“破案”笔记:iframe动态加载内容后,打印功能为何失灵?

案件概述

异常现象:当我用 iframe.srcdoc动态生成一个报告页面,并想自动调起打印时,打印窗口死活不弹出来,打印完成的回调函数也永远不会执行。代码看起来没问题,但就是无效。

初步怀疑:是不是 srcdoc把我刚绑定的事件监听器给“冲走了”?


第一现场:重现“案发”过程

这是当时“案发”的代码片段:

// 1. 给 iframe 灌入新内容
let frame = document.getElementById('myFrame');
frame.srcdoc = `<h1>我的报告</h1><p>请打印我</p>`;

// 2. 立刻绑定打印完成后的回调
frame.contentWindow.addEventListener('afterprint', function() {
  console.log('打印完成!'); // 🚨 这条日志从未出现!
});

// 3. 立刻下令打印
frame.contentWindow.print(); // 🚨 打印窗口毫无反应!

直观感受:代码执行了,但像石沉大海,没有任何效果和报错。


侦查实验:逐一排除嫌疑

我们做了几个关键实验来排查。

实验一:事件监听器真的被“冲走了”吗?

我们在设置新内容前后,绑定一个自己能控制的“信号弹”(自定义事件)。

frame.addEventListener('信号弹', () => console.log('监听器A在'));
frame.srcdoc = `<h1>新内容</h1>`;
frame.addEventListener('信号弹', () => console.log('监听器B也在'));

// 发射信号弹
frame.dispatchEvent(new Event('信号弹'));
// 控制台输出:监听器A在 | 监听器B也在

✅ 结论:监听器没有消失。两个都还在正常工作。所以“冲走监听器”的嫌疑被排除了。

实验二:如果等一会儿再打印呢?

我们怀疑是不是命令下得太急了。

frame.srcdoc = `<h1>新内容</h1>`;
setTimeout(() => {
  frame.contentWindow.print(); // 🕐 延迟1秒后:打印窗口弹出了!
  console.log('打印调用成功,但 afterprint 仍不触发');
}, 1000);

⚠️ 新发现等待足够时间后,打印命令能执行了,但 afterprint事件依然不触发。 这说明事件绑定的时机可能也有问题。

实验三:找到那个“正确时机”

我们尝试在 iframe 自己宣布“我准备好了”的时候再行动。

frame.srcdoc = `<h1>新内容</h1>`;

// 监听 iframe 的“准备好”信号
frame.onload = function() {
  // 等它喊“准备好”了,我们再绑定和打印
  frame.contentWindow.addEventListener('afterprint', function() {
    console.log('✅✅✅ 打印完成!'); // 这次成功了!
  });
  frame.contentWindow.print(); // 打印窗口正常弹出
};

✅ 决定性证据:在 onload事件里操作,一切完全正常


案情复盘:到底发生了什么?

我们可以把 iframe.srcdoc = ‘...’这个过程,想象成给一个房间(iframe)进行彻底的重装修

  1. 拆旧:浏览器先把房间里(iframe 内)所有旧的家具、管道(旧的文档、窗口)全清空。

  2. 异步装修:然后开始根据你给的新图纸(HTML字符串)异步施工。这需要时间,水电、墙面、家具都在同步安排。

  3. 施工中:在装修队喊“完工啦!”(触发 load事件)之前,这个房间处于施工状态

    • 你对着一个还在铺水泥的墙面(不稳定的内部窗口)喊“打印!”(print()),工人会无视你。
    • 你告诉一面还没砌好的墙“打印完喊我一声”(绑 afterprint),这个请求可能会丢失。
  4. 竣工:只有等 onload事件触发,才代表房间完全装修好,水电全通,可以正式投入使用。这时你的所有指令都能被正确接收和执行。

所以,核心不是监听器被“删除”,而是你对着一个“半成品”发出了指令。


解决方案:两个可靠的行动指南

方案一:等待“竣工典礼”(最推荐)

做法:用 srcdoc设置内容,但所有操作都放到 iframe.onload回调函数里。

优点:逻辑清晰,是现代 API 的标准用法。

iframe.srcdoc = ‘你的HTML内容’;
iframe.onload = function() {
  // 在这里进行所有“室内操作”
  iframe.contentWindow.addEventListener(‘afterprint’, 你的回调);
  iframe.contentWindow.print();
};

方案二:使用“魔法瞬间重建”

做法:不用 srcdoc,改用传统的 document.write()来同步写入内容。

原理document.write()会在你写下内容的同一时刻,同步、立即地重建整个文档,没有“施工中”的等待期。写完后立即可用。

优点:无需等待 onload,立即生效。

let doc = iframe.contentWindow.document;
doc.open();
doc.write(‘你的完整HTML内容’); // 魔法发生,内容瞬间被替换
doc.close();
// 紧接着就可以操作,因为文档已经就绪
iframe.contentWindow.print();

构建无障碍组件之Alert Dialog Pattern

作者 anOnion
2026年2月8日 16:43

Alert Dialog Pattern 详解:构建无障碍中断式对话框

Alert Dialog 是 Web 无障碍交互的重要组件。本文详解其 WAI-ARIA 实现要点,涵盖角色声明、键盘交互、最佳实践,助你打造中断式对话框,让关键信息触达每位用户。

一、Alert Dialog 的定义与核心功能

Alert Dialog(警告对话框)是一种模态对话框,它会中断用户的工作流程以传达重要信息并获取响应。与普通的 Alert 通知不同,Alert Dialog 需要用户明确与之交互后才能继续其他操作。这种设计适用于需要用户立即关注和做出决定的场景。

在实际应用中,Alert Dialog 广泛应用于各种需要用户确认或紧急通知的场景。例如,删除操作前的确认提示、表单提交失败的错误确认、离开页面时的未保存更改提醒等。这些场景都需要用户明确响应才能继续操作,因此 Alert Dialog 成为最佳选择。

二、Alert Dialog 的特性与注意事项

Alert Dialog 组件具有几个重要的特性,这些特性决定了它的适用场景和实现方式。首先,Alert Dialog 会获取键盘焦点,确保用户的注意力集中在对话框上。其次,Alert Dialog 通常会阻止用户与页面的其他部分交互,直到用户关闭对话框。这种模态特性确保了用户必须处理重要信息才能继续操作。

Alert Dialog 组件的设计还需要考虑几个关键因素。首先,Alert Dialog 应该始终包含一个明确的关闭方式,如确认按钮或取消按钮。其次,对话框应该有一个清晰的标题,通过 aria-labelledbyaria-label 关联。另外,对话框的内容应该通过 aria-describedby 关联,以便屏幕阅读器能够正确读取完整信息。这些属性的正确使用对于无障碍体验至关重要。

三、WAI-ARIA 角色、状态和属性

正确使用 WAI-ARIA 属性是构建无障碍 Alert Dialog 组件的技术基础。Alert Dialog 组件的 ARIA 要求包含多个属性的配合使用。

role="alertdialog" 是 Alert Dialog 组件的必需属性,它向辅助技术表明这个元素是一个警告对话框。这个属性使浏览器和辅助技术能够将 Alert Dialog 与其他类型的对话框区分开来,从而提供特殊的处理方式,如播放系统提示音。

aria-labelledbyaria-label 用于标识对话框的标题。如果对话框有可见的标题标签,应该使用 aria-labelledby 引用该标题元素;如果没有可见标题,则使用 aria-label 提供标签。

aria-describedby 用于引用包含警告消息的元素。这确保屏幕阅读器能够朗读完整的对话框内容,包括详细的说明和操作提示。

<!-- Alert Dialog 基本结构 -->
<dialog
  id="confirm-dialog"
  role="alertdialog"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-desc">
  <form method="dialog">
    <h2 id="dialog-title">确认删除</h2>
    <p id="dialog-desc">您确定要删除这个文件吗?此操作无法撤销。</p>
    <div class="actions">
      <button value="confirm">确认删除</button>
      <button value="cancel">取消</button>
    </div>
  </form>
</dialog>

值得注意的是,Alert Dialog 与普通 Dialog 的主要区别在于 Alert Dialog 用于紧急或重要信息,并且通常包含确认/取消按钮。用户无法忽略 Alert Dialog,必须做出响应才能继续操作。

四、键盘交互规范

Alert Dialog 的键盘交互遵循模态对话框的交互模式。用户可以通过多种方式与 Alert Dialog 进行交互。

  • EnterSpace 用于激活默认按钮,通常是对话框中的主要操作按钮。
  • Tab 键用于在对话框内的焦点元素之间切换,焦点会循环停留 在对话框内部。
  • Escape 键通常用于关闭对话框,相当于点击取消按钮。
// ESC 键关闭对话框示例
document.addEventListener('keydown', function (e) {
  if (e.key === 'Escape' && dialog.open) {
    dialog.close();
  }
});

焦点管理是 Alert Dialog 的关键部分。当对话框打开时,焦点应该立即移动到对话框内部或默认按钮上。当对话框关闭时,焦点应该返回到打开对话框的元素。这种焦点管理确保了键盘用户能够保持其工作上下文。

五、完整示例

以下是一个完整的 Alert Dialog 实现示例,展示了正确的 HTML 结构、ARIA 属性和焦点管理。

<dialog
  id="confirm-dialog"
  role="alertdialog"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-desc">
  <form method="dialog">
    <h2 id="dialog-title">确认删除</h2>
    <p id="dialog-desc">您确定要删除这个文件吗?此操作无法撤销。</p>
    <div class="dialog-actions">
      <button
        class="btn btn-ghost"
        value="cancel">
        取消
      </button>
      <button
        class="btn btn-error"
        value="confirm">
        删除
      </button>
    </div>
  </form>
</dialog>

<button
  id="delete-btn"
  class="btn btn-error">
  删除文件
</button>

<script>
  const dialog = document.getElementById('confirm-dialog');
  const deleteBtn = document.getElementById('delete-btn');
  let previousActiveElement;

  deleteBtn.addEventListener('click', function () {
    previousActiveElement = document.activeElement;
    dialog.showModal();
  });

  dialog.addEventListener('close', function () {
    if (dialog.returnValue === 'confirm') {
      console.log('文件已删除');
    }
    previousActiveElement.focus();
  });
</script>

六、最佳实践

6.1 实现方式对比

Alert Dialog 可以通过两种方式实现:使用 div 配合 ARIA 属性,或使用原生 <dialog> 元素。

传统方式(div + ARIA)
<div
  role="alertdialog"
  aria-modal="true"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-desc">
  <h2 id="dialog-title">确认删除</h2>
  <p id="dialog-desc">您确定要删除这个文件吗?</p>
  <button>确认</button>
  <button>取消</button>
</div>

这种方式需要开发者手动处理焦点管理、ESC 键关闭、背景锁定等逻辑。

推荐方式(原生 dialog)
<dialog
  role="alertdialog"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-desc">
  <form method="dialog">
    <h2 id="dialog-title">确认删除</h2>
    <p id="dialog-desc">您确定要删除这个文件吗?</p>
    <button value="confirm">确认</button>
    <button value="cancel">取消</button>
  </form>
</dialog>

HTML 原生 <dialog> 元素简化了实现,它提供了:

  • 自动焦点管理
  • 内置 ESC 键支持
  • 自动模态背景
  • 内置 ARIA 属性

<dialog> 元素的默认 roledialog,表示普通对话框。对于 Alert Dialog,需要显式设置 role="alertdialog" 来告诉辅助技术这是一个需要紧急处理的对话框,从而获得系统提示音等特殊处理。

6.2 焦点管理

正确的焦点管理对于键盘用户和无障碍体验至关重要。打开对话框时,焦点应该移动到对话框内部或默认按钮。关闭对话框时,焦点应该返回到触发对话框的元素。

// 焦点管理最佳实践
function openDialog(dialog) {
  const previousFocus = document.activeElement;
  dialog.showModal();

  // 移动焦点到对话框内
  const focusableElements = dialog.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
  );
  if (focusableElements.length > 0) {
    focusableElements[0].focus();
  }

  // 保存关闭时的焦点元素
  dialog.dataset.previousFocus = previousFocus;
}

function closeDialog(dialog) {
  dialog.close();
  const previousFocus = document.querySelector(
    `[data-focus-id="${dialog.dataset.focusId}"]`,
  );
  if (previousFocus) {
    previousFocus.focus();
  }
  dialog.remove();
}

6.3 避免过度使用

Alert Dialog 会中断用户的工作流程,因此应该谨慎使用。只有在真正需要用户立即响应的情况下才使用 Alert Dialog。对于非紧急信息,应该考虑使用普通的 Alert 或 Toast 通知。

<!-- 不推荐:过度使用 Alert Dialog -->
<dialog
  open
  role="alertdialog">
  <h2>提示</h2>
  <p>您的设置已保存。</p>
  <button onclick="this.closest('dialog').close()">确定</button>
</dialog>

<!-- 推荐:使用普通 Alert -->
<div role="alert">您的设置已保存。</div>

6.4 屏幕阅读器兼容性

确保 <dialog> 对屏幕阅读器用户友好。<dialog> 元素内置了无障碍支持,但仍然建议对 Alert Dialog 设置 role="alertdialog" 来区分紧急对话框。

<!-- 屏幕阅读器友好的 dialog -->
<dialog
  id="session-dialog"
  role="alertdialog">
  <form method="dialog">
    <h2>重要提醒</h2>
    <p>您的会话将在 5 分钟后过期。请尽快保存您的工作。</p>
    <div class="actions">
      <button value="continue">继续使用</button>
      <button value="exit">退出</button>
    </div>
  </form>
</dialog>

七、Alert 与 Alert Dialog 的区别

理解 AlertAlert Dialog 的区别对于正确选择通知组件至关重要。虽然两者都是用于传达重要信息,但它们服务于不同的目的和使用场景。

Alert 是一种被动通知组件,它不需要用户进行任何交互操作。Alert 会在不被中断用户工作流程的前提下自动通知用户重要信息。用户可以继续当前的工作,Alert 只是在视觉和听觉上提供通知。这种设计适用于不紧急、不需要用户立即响应的信息,例如操作成功确认、后台处理完成通知等。

Alert Dialog 则是一种需要用户主动响应的对话框组件。当用户需要做出决定或者提供确认时,应该使用 Alert Dialog。Alert Dialog 会中断用户的工作流程,获取键盘焦点,要求用户必须与之交互才能继续其他操作。这种设计适用于紧急警告、确认删除操作、放弃更改确认等需要用户明确响应的场景。

选择建议:如果信息需要用户立即响应并做出决定,使用 Alert Dialog;如果只是被动通知信息,使用 Alert。

八、总结

构建无障碍的对话框组件需要关注元素选择、焦点管理、键盘交互三个层面的细节。从元素选择角度,推荐优先使用原生 <dialog> 元素,它内置了无障碍支持和焦点管理。从焦点管理角度,需要确保打开和关闭时焦点的正确移动。从用户体验角度,应该避免过度使用对话框,只在真正需要用户响应时使用。

WAI-ARIA Alert Dialog Pattern 为我们提供了清晰的指导方针,遵循这些规范能够帮助我们创建更加包容和易用的 Web 应用。每一个正确实现的对话框,都是提升用户体验和确保重要信息有效传达的重要一步。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

明冠新材:终止太阳能背板及功能性膜生产基地项目投资协议

2026年2月8日 16:34
2月8日,明冠新材公告,公司决定终止与肥东县人民政府签订的太阳能背板及功能性膜生产基地项目投资合作协议。该项目总投资预计50亿元,目前公司正积极配合肥东县政府办理定制物业的竣工验收等后续工作。自2023年第四季度起,光伏行业已出现产能过剩苗头,产业链产品价格竞争随之加剧,行业处于产业调整周期,2024年和2025年度出现光伏行业普遍亏损。公司管理层在对市场变化认真分析及谨慎研判的基础上,考虑行业内卷导致光伏封装材料盈利能力逐步下降,加之2025年行业竞争白热化且周期性调整尚未呈现明显好转,若继续推进明冠合肥项目,将不可避免地推高公司整体运营成本。(界面新闻)

国联民生:拟向民生证券增资2亿元

2026年2月8日 16:33
2月8日,国联民生公告,公司拟向民生证券增资2亿元,增资后公司对民生证券持股比例不变。本次增资的资金来源为公司向特定对象发行股票募集的配套资金,用于民生证券财富管理业务发展和信息技术投入。(第一财经)

Koa.js 教程 | 一份不可多得的 Node.js 的 Web 框架 Koa.js 教程

作者 千寻girling
2026年2月8日 15:49

第一章 安装和配置 koa

Koa 是一个轻量级、现代化的框架, 由 Express 原班人马开发

初始化配置文件 package.json

npm init -y

配置 package.json (ESM规范)

{
     "type": "module",
     "name": "demo",
     "version": "1.0.0",
     "main": "index.js",
     "scripts": {
          "dev":"nodemon index.js",
           "test": "echo \"Error: no test specified\" && exit 1"
     },
     "keywords": [],
     "author": "",
     "license": "ISC",
     "description": ""
}

npm 官网

     www.npmjs.com

安装koa      

npm i koa

     全局安装 nodemon

  .  npm i nodemon -g

     当 nodemon 检测到监视的文件发生更改时, 会自动重新启动应用

第二章 创建并启动 http 服务器

中间件

中间件是处理 HTTP 请求和响应的函数,它们可以做以下操作:

  • 处理请求(例如解析请求体、验证用户身份等)
  • 修改响应(例如设置响应头、发送响应体等)
  • 执行后续中间件

中间件 - 很重要的概念 !!!!!!!

注意 : app.use() 方法用于注册 中间件

中间件 是处理 http 请求和响应的函数 , 当一个请求到达服务器时, 会从第一个中间件开始执行, 直到最后一个中间件

上下文对象 ctx

在 Koa 中,ctx(上下文)对象是每个中间件函数的核心,它包含了请求和响应的所有信息。所有的 HTTP 请求和响应都通过 ctx 进行处理。

上下文对象 ctx ( context ) 包含了与当前 http 请求相关的所有信息

如: http方法、url、请求头、请求体、查询参数等

import Koa from 'koa'

const hostname = "127.0.0.1" //服务器监听的ip地址
const port = 8008 //服务器监听的端口号

/*
    实例化一个 Koa 对象
    实例化是指根据一个类创建具体对象的过程
*/
const app = new Koa()

app.use(async ctx => {
    ctx.body = "juejin.cn" // 使用 ctx.body 设置响应体的内容
})

//启动 http 服务器, 并在指定的ip地址(127.0.0.1)和端口(8008)上监听连接请求
app.listen(port, hostname, () => {
    console.log(`服务器已启动: http://${hostname}:${port}`)
})

第三章 洋葱模型

洋葱模型

当你处理一个请求时,

可以想象成是在 "剥洋葱" ,从外向内一层一层地往里剥,直到剥到中心部分

这个过程涉及对 请求 的多个层面进行解析、验证、处理

在处理完洋葱(请求)后,

构建 响应 的过程就像是从精心准备的食材 ( 处理请求 后得到的数据) 开始,

从内向外逐层添加调料(格式化、封装等),最终形成一道色香味俱佳的菜肴(响应)

image.png

import Koa from 'koa'

const hostname = "127.0.0.1" //服务器监听的ip地址
const port = 8008 //服务器监听的端口号

/*
    实例化一个 Koa 对象
    实例化是指根据一个类创建具体对象的过程
*/
const app = new Koa()

/*
    app.use() 方法用于注册中间件
    中间件是处理 http 请求和响应的函数
    当一个请求到达服务器时, 会从第一个中间件开始执行, 直到最后一个中间件
    
    上下文对象 ctx(context) 包含了与当前 http 请求相关的所有信息
    如: http方法、url、请求头、请求体、查询参数等
*/
app.use(async (ctx,next) => {
    console.log(1)
    await next() //若中间件调用了next(),会暂停当前中间件的执行,将控制权传递给下一个中间件
    console.log(2)
})

app.use(async (ctx,next) => { 
    console.log(3)
    await next()
    console.log(4)
})

//当中间件没有再调用next(),则不需要再将控制权传递给下一个中间件,控制权会按照相反的顺序执行
app.use(async (ctx,next) => {
    console.log(5)
    ctx.body = "dengruicode.com" // 使用 ctx.body 设置响应体的内容
})

//启动 http 服务器, 并在指定的ip地址(127.0.0.1)和端口(8008)上监听连接请求
app.listen(port, hostname, () => {
    console.log(`服务器已启动: http://${hostname}:${port}`)
})

第四章 安装和配置路由 - get请求

在 Koa 中,koa-router 是一个轻量级的路由中间件,它可以帮助你定义路由、处理 HTTP 请求并解析请求参数。通过使用 koa-router,你可以创建一个灵活的路由系统,轻松地组织和管理 Koa 应用的各个部分。

安装 koa-router

首先,你需要安装 koa-router

npm install @koa/router       # 注意:新版 koa-router 包名是 @koa/router
import Koa from 'koa'
import Router from '@koa/router'

const hostname = "127.0.0.1"
const port = 8008

const app = new Koa()
const router = new Router() //实例化一个 Router 对象

//------ get请求
//路由是根据客户端发送的请求(包括请求的路径、方法等)调用与之匹配的处理函数
//根路由 http://127.0.0.1:8008/
router.get('/', async ctx => { //get请求
    ctx.body = "dengruicode.com"
})

//查询参数 http://127.0.0.1:8008/test?id=001&web=dengruicode.com
router.get('/test', async ctx => { //get请求
    let id = ctx.query.id
    let web = ctx.query.web
    ctx.body = id + " : " + web
})

//路径参数 http://127.0.0.1:8008/test2/id/002/web/www.dengruicode.com
router.get('/test2/id/:id/web/:web', async ctx => {
    let id = ctx.params.id
    let web = ctx.params.web
    ctx.body = id + " : " + web
})

//重定向路由 http://127.0.0.1:8008/test3
router.redirect('/test3', 'https://www.baidu.com')

app.use(router.routes()) //将定义在 router 对象中的路由规则添加到 app 实例中

//------ 路由分组
//http://127.0.0.1:8008/user/add
//http://127.0.0.1:8008/user/del

const userRouter = new Router({ prefix: '/user' })
userRouter.get('/add', async ctx => {
    ctx.body = "添加用户"
})
userRouter.get('/del', async ctx => {
    ctx.body = "删除用户"
})
app.use(userRouter.routes())

// 在所有路由之后添加404处理函数
app.use(async ctx => {
    if (!ctx.body) { //若没有设置 ctx.body, 则说明没有到匹配任何路由
        ctx.status = 404
        ctx.body = '404 Not Found'
    }
})

app.listen(port, hostname, () => {
    console.log(`服务器已启动: http://${hostname}:${port}`)
})

第五章 post请求

安装 koa-body

Koa 原生不支持解析 POST 请求体,需安装 koa-body 中间件:

npm install koa-body

POST 请求处理示例

修改 src/index.js,新增 POST 路由:

import Koa from 'koa';
import Router from '@koa/router';
import { koaBody } from 'koa-body';

const app = new Koa();
const router = new Router();
const port = 8008;

// 注册 koa-body 中间件:解析 JSON、表单、文件类型的 POST 数据
app.use(koaBody({
  multipart: true, // 支持文件上传(后续第八章用)
  json: true, // 解析 JSON 格式
  urlencoded: true // 解析表单格式(application/x-www-form-urlencoded)
}));

// 1. 处理 JSON 格式 POST 请求
router.post('/api/json', async (ctx) => {
  const { name, age } = ctx.request.body;
  ctx.body = {       // ctx.request.body 是 koa-body 解析后的 POST 数据
    code: 200,
    msg: "JSON 数据接收成功",
    data: { name, age }
  };
});

// 2. 处理表单格式 POST 请求
router.post('/api/form', async (ctx) => {
  const { username, password } = ctx.request.body;
  ctx.body = {
    code: 200,
    msg: "表单数据接收成功",
    data: { username, password }
  };
});

app.use(router.routes());

// 404 处理
app.use(async (ctx) => {
  ctx.status = 404;
  ctx.body = '404 Not Found';
});

app.listen(port, () => {
  console.log(`POST 服务器启动:http://localhost:${port}`);
});

测试 POST 请求(两种方式)

方式 1:Postman 测试

  • 请求地址:http://localhost:8008/api/json

  • 请求方法:POST

  • 请求体:选择 raw > JSON,输入:

    { "name": "张三", "age": 20 }
    
  • 响应:{"code":200,"msg":"JSON 数据接收成功","data":{"name":"张三","age":20}}

方式 2:curl 命令测试

# 测试 JSON 格式
curl -X POST -H "Content-Type: application/json" -d '{"name":"张三","age":20}' http://localhost:8008/api/json

# 测试表单格式
curl -X POST -d "username=admin&password=123456" http://localhost:8008/api/form

第六章 错误处理

import Koa from 'koa'
import Router from '@koa/router'

const hostname = "127.0.0.1"
const port = 8008

const app = new Koa()
const router = new Router()

//http://127.0.0.1:8008/
router.get('/', async ctx => {
    throw new Error("测试")
})

/*
    将 '错误处理中间件' 放在 '路由处理中间件' 之前, 当一个请求到达时,
    会先经过 '错误处理中间件', 然后才会进入 '路由处理中间件',
    是为了确保可以捕获错误
*/
app.use(async (ctx, next) => {  // 错误处理中间件
    try {
        await next()
    } catch (err) {
        //console.log('err:', err)
        ctx.status = 500
        ctx.body = 'err: ' + err.message
    }
})

app.use(router.routes())   // 路由处理中间件

app.listen(port, hostname, () => {
    console.log(`服务器已启动: http://${hostname}:${port}`)
})

第七章 允许跨域请求

安装跨域中间件

npm install @koa/cors

跨域配置示例

import Koa from 'koa';
import Router from '@koa/router';
import Cors from '@koa/cors';

const app = new Koa();
const router = new Router();
const port = 8008;

app.use(Cors()) //允许跨域请求

// 测试跨域路由
router.get('/api/cors', async (ctx) => {
  ctx.body = {
    code: 200,
    msg: "跨域请求成功"
  };
});

app.use(router.routes());

app.listen(port, () => {
  console.log(`跨域服务器启动:http://localhost:${port}`);
});

测试跨域

在任意前端项目(如 Vue / React / HTML 文件)中发送请求:

// 前端代码示例
fetch('http://localhost:8008/api/cors')
  .then(res => res.json())
  .then(data => console.log(data)) // 输出 {code:200, msg:"跨域请求成功"}
  .catch(err => console.error(err));

无跨域报错即配置成功。

第八章 上传图片

依赖准备(复用 koa-body)

koa-body 已支持文件上传,无需额外安装依赖,只需确保配置 multipart: true

图片上传示例

import Koa from 'koa';
import Router from '@koa/router';
import { koaBody } from 'koa-body';
import fs from 'fs';
import path from 'path';

const app = new Koa();
const router = new Router();
const port = 8008;

// 1. 创建上传目录(不存在则创建)
const uploadDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadDir)) {
  fs.mkdirSync(uploadDir, { recursive: true });
}

// 2. 配置 koa-body 支持文件上传
app.use(koaBody({
  multipart: true, // 开启文件上传
  formidable: {
    uploadDir: uploadDir, // 临时存储目录
    keepExtensions: true, // 保留文件扩展名(如 .png/.jpg)
    maxFieldsSize: 2 * 1024 * 1024, // 限制文件大小 2MB
    filename: (name, ext, part, form) => {
      // 自定义文件名:时间戳 + 原扩展名,避免重复
      return Date.now() + ext;
    }
  }
}));

// 3. 图片上传接口
router.post('/api/upload', async (ctx) => {
  // ctx.request.files 是上传的文件对象
  const file = ctx.request.files.file; // 前端上传的文件字段名需为 file
  if (!file) {
    ctx.status = 400;
    ctx.body = { code: 400, msg: "请选择上传的图片" };
    return;
  }

  // 返回文件信息
  ctx.body = {
    code: 200,
    msg: "图片上传成功",
    data: {
      filename: file.newFilename, // 自定义后的文件名
      path: `/uploads/${file.newFilename}`, // 访问路径
      size: file.size // 文件大小(字节)
    }
  };
});

// 4. 静态文件访问:让上传的图片可通过 URL 访问
app.use(async (ctx, next) => {
  if (ctx.path.startsWith('/uploads/')) {
    const filePath = path.join(uploadDir, ctx.path.replace('/uploads/', ''));
    if (fs.existsSync(filePath)) {
      ctx.type = path.extname(filePath).slice(1); // 设置响应类型(如 png/jpg)
      ctx.body = fs.createReadStream(filePath); // 读取文件并返回
      return;
    }
    ctx.status = 404;
    ctx.body = "文件不存在";
    return;
  }
  await next();
});

app.use(router.routes());

app.listen(port, () => {
  console.log(`图片上传服务器启动:http://localhost:${port}`);
});

测试图片上传

方式 1:Postman 测试

  • 请求地址:http://localhost:8008/api/upload
  • 请求方法:POST
  • 请求体:选择 form-data,Key 为 file,Type 选 File,上传一张图片。
  • 响应:返回文件路径,如 http://localhost:8008/uploads/1738987654321.png,访问该 URL 可查看图片。

方式 2:curl 命令测试

终端输入 bash 命令

curl -X POST -F "file=@/你的图片路径/xxx.png" http://localhost:8008/api/upload

第九章 cookie

Cookie 是存储在客户端浏览器的小型文本数据,Koa 内置 ctx.cookies API 可以操作 Cookie。

Cookie 操作示例

import Koa from 'koa'
import Router from '@koa/router'
 
const app = new Koa();
const router = new Router();
const port = 8008;

// 1. 设置 Cookie
router.get('/cookie/set', async (ctx) => {
  // ctx.cookies.set(名称, 值, 配置)
  ctx.cookies.set(
    'username', 
    encodeURIComponent('张三'), 
    {
      maxAge: 24 * 60 * 60 * 1000, // 过期时间 1 天(毫秒)
      httpOnly: true, // 仅允许服务端访问,防止 XSS 攻击
      secure: false, // 开发环境设为 false(HTTPS 环境设为 true)
      path: '/', // 生效路径(/ 表示全站)
      sameSite: 'lax' // 防止 CSRF 攻击
    }
  );
  ctx.body = { code: 200, msg: "Cookie 设置成功" };
});

// 2. 获取 Cookie
router.get('/cookie/get', async (ctx) => {
  const username = ctx.cookies.get('username');
  ctx.body = {
    code: 200,
    msg: "Cookie 获取成功",
    data: { username }
  };
});

// 3. 删除 Cookie
router.get('/cookie/delete', async (ctx) => {
  ctx.cookies.set('username', '', { maxAge: 0 }); // 设置 maxAge 为 0 即删除
  ctx.body = { code: 200, msg: "Cookie 删除成功" };
});

app.use(router.routes());

app.listen(port, () => {
  console.log(`Cookie 服务器启动:http://localhost:${port}`);
});

测试 Cookie

  1. 访问 http://localhost:8008/cookie/set → 设置 Cookie;
  2. 访问 http://localhost:8008/cookie/get → 获取 Cookie,输出 {username: "张三"}
  3. 访问 http://localhost:8008/cookie/delete → 删除 Cookie,再次获取则为 undefined

第十章 session

安装 Session 中间件

Koa 原生不支持 Session,需安装 koa-session

npm install koa-session

Session 配置示例

import Koa from 'koa'
import Router from '@koa/router'
import session  from 'koa-session'

const app = new Koa();
const router = new Router();
const port = 8008;

// 1. 配置 Session 密钥(生产环境需改为随机字符串)
app.keys = ['dengruicode_secret_key'];

// 2. Session 配置
const CONFIG = {
  key: 'koa:sess', // Session Cookie 名称
  maxAge: 24 * 60 * 60 * 1000, // 过期时间 1 天
  autoCommit: true,
  overwrite: true,
  httpOnly: true, // 仅服务端访问
  signed: true, // 签名 Cookie,防止篡改
  rolling: false, // 不刷新过期时间
  renew: false, // 快过期时自动续期
  secure: false, // 开发环境 false
  sameSite: 'lax'
};

// 3. 注册 Session 中间件
app.use(session(CONFIG, app));

// 4. Session 操作
// 设置 Session
router.get('/session/set', async (ctx) => {
  ctx.session.user = {
    id: 1,
    name: "张三",
    age: 20
  };
  ctx.body = { code: 200, msg: "Session 设置成功" };
});

// 获取 Session
router.get('/session/get', async (ctx) => {
  const user = ctx.session.user;
  ctx.body = {
    code: 200,
    msg: "Session 获取成功",
    data: { user }
  };
});

// 删除 Session
router.get('/session/delete', async (ctx) => {
  ctx.session = null; // 清空 Session
  ctx.body = { code: 200, msg: "Session 删除成功" };
});

app.use(router.routes());

app.listen(port, () => {
  console.log(`Session 服务器启动:http://localhost:${port}`);
});

测试 Session

  1. 访问 http://localhost:8008/session/set → 设置 Session;
  2. 访问 http://localhost:8008/session/get → 获取 Session,输出用户信息;
  3. 访问 http://localhost:8008/session/delete → 清空 Session,再次获取则为 undefined

注意:koa-session 是基于 Cookie 的内存 Session,生产环境建议使用 koa-redis 将 Session 存储到 Redis,避免服务重启丢失数据。

第十一章 jwt

安装 JWT 依赖

npm install jsonwebtoken koa-jwt
  • jsonwebtoken:生成 / 解析 JWT 令牌;
  • koa-jwt:验证 JWT 令牌的中间件。

JWT 完整示例

import Koa from 'koa'
import Router from '@koa/router'
import jwt  from 'jsonwebtoken'
import koaJwt  from 'koa-jwt'

const app = new Koa();
const router = new Router();
const port = 8008;

// 1. JWT 密钥(生产环境需加密存储)
const JWT_SECRET = 'dengruicode_jwt_secret';
// JWT 过期时间:1 小时(秒)
const JWT_EXPIRES_IN = 3600;

// 2. 登录接口:生成 JWT 令牌
router.post('/api/login', async (ctx) => {
  // 模拟验证用户名密码(生产环境需查数据库)
  const { username, password } = ctx.request.body;
  if (username === 'admin' && password === '123456') {
    // 生成 JWT 令牌
    const token = jwt.sign(
      { id: 1, username }, // 载荷:存储用户信息(不要存敏感数据)
      JWT_SECRET,
      { expiresIn: JWT_EXPIRES_IN }
    );
    ctx.body = {
      code: 200,
      msg: "登录成功",
      data: { token }
    };
  } else {
    ctx.status = 401;
    ctx.body = { code: 401, msg: "用户名或密码错误" };
  }
});

// 3. 受保护的接口:需要 JWT 验证
// koa-jwt 中间件会自动解析 Authorization 头中的 token
app.use(koaJwt({ secret: JWT_SECRET }).unless({
  path: [/^/api/login/] // 排除登录接口,无需验证
}));

// 4. 获取用户信息接口(需验证 JWT)
router.get('/api/user/info', async (ctx) => {
  // ctx.state.user 是 koa-jwt 解析后的 JWT 载荷
  const { id, username } = ctx.state.user;
  ctx.body = {
    code: 200,
    msg: "获取用户信息成功",
    data: { id, username }
  };
});

app.use(router.routes());

// 5. JWT 错误处理
app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    if (err.status === 401) {
      ctx.status = 401;
      ctx.body = { code: 401, msg: "token 无效或过期" };
    } else {
      throw err;
    }
  }
});

app.listen(port, () => {
  console.log(`JWT 服务器启动:http://localhost:${port}`);
});

测试 JWT

步骤 1:登录获取 token

curl -X POST -d "username=admin&password=123456" http://localhost:8008/api/login
# 响应:{"code":200,"msg":"登录成功","data":{"token":"xxx.xxx.xxx"}}

步骤 2:携带 token 访问受保护接口

curl -H "Authorization: Bearer 你的token" http://localhost:8008/api/user/info
# 响应:{"code":200,"msg":"获取用户信息成功","data":{"id":1,"username":"admin"}}

步骤 3:token 无效 / 过期测试

携带错误 token 或过期 token 访问,会返回 {"code":401,"msg":"token 无效或过期"}

总结

  1. 核心流程:Koa 开发的核心是「中间件 + 路由」,所有功能(跨域、上传、JWT)都通过中间件扩展;

  2. 关键依赖@koa/router(路由)、koa-body(POST / 上传)、@koa/cors(跨域)、koa-session(Session)、jsonwebtoken/koa-jwt(JWT);

  3. 生产建议

    • Session/JWT 密钥需随机生成并加密存储;

    • 文件上传需限制大小和类型,防止恶意上传;

    • 跨域需指定具体域名,而非 *

    • JWT 载荷不要存敏感数据,过期时间不宜过长。

热血渐凉:被耗尽的小米SU7 Ultra

2026年2月8日 15:45

出品|虎嗅汽车组

作者|王亚骏

头图|视觉中国


面对转岗的机会,小米员工林露(化名)并未考虑太多,选择了离开。


他此前所在的团队有一个亮眼的名称,叫“Ultra Master”。这个团队的职责是销售小米旗下最为昂贵的产品:售价52.99万至62.99万元的小米SU7 Ultra。

 

不过在一月底,小米开放了这款车的销售权限,所有销售人员均可参与小米SU7 Ultra的售卖;而林露们,则可以转岗去销售小米其他车型。小米官方将其解释为“销售策略调整升级”。

 

林露认为,自己擅长的技能以及累积的基盘客户,与走量车型其实并不匹配,留下来也是“熬着”。在他认识的其他Ultra Master中,“也只有一两个人留下了。”


小米各款车型价位


在林露加入团队之初,他可能想不到,这段经历会以这种结果收场。

 

小米SU7 Ultra承担着拉升品牌调性,增加小米在高端市场话语权的重任。雷军曾表示,小米SU7 Ultra将全部重新定义豪车的新标准,性能比肩保时捷、科技紧追特斯拉、豪华媲美BBA。


在更具体的销量上,雷军当时希望这款车实现年销1万辆的目标。


为此,小米下了相当大的功夫与本钱来打造销售团队,很多Ultra Master不仅具备豪车品牌的销售经验,甚至他们自己就是圈子中的“玩家”。林露告诉虎嗅,他所在的城市,第一批Ultra Master必须持有赛道驾驶执照。

 

在薪资方面,他们的月薪最高可达3万元。供职于经销商的前Ultra Master崇义(化名)告诉虎嗅,他有时赚得甚至比直营的Ultra Master更多。

 

对于许多团队成员而言,销售小米SU7 Ultra也不仅仅是一份薪水颇高的工作。崇义还记得,“在入职之初,许多人都怀着给中国汽车产业出一份力的情怀,毕竟在车行干了这么多年,大家还是希望中国性能车能做起来。”

 

除了过硬的专业能力和一腔热血,他们手中的武器也颇为锋利。

 

小米SU7 Ultra,最大马力达1548匹、零百加速1.98秒,这两个数据均领先于特斯拉Model S Plaid和保时捷Taycan Turbo GT,是纽北史上最速量产电动车。央视曾将这款车称为“中国汽车工业的辉煌成就”。


小米SU7 Ultra;图源:视觉中国


此外,在去年初,小米汽车业务势头正旺,这也为小米SU7 Ultra带来了泼天的流量和品牌势能。

 

在一系列有利因素的帮助下,这款车的开局可谓梦幻。2025年2月27日,小米SU7 Ultra正式上市,从3月到了8月,这款车累计销量已超1.4万辆,超额完成全年销量目标,是同期保时捷Panamera销量的两倍多(当然,小米SU7 Ultra能取得这个成绩,也有一部分售价偏低的原因)。

 

从销量层面来看,Ultra Master们所销售的汽车,的确正在颠覆国内性能车市场被欧洲品牌所主导的格局。

 

当时的他们可能并不会想到,接下来会发生什么。


从2025年9月起,小米SU7 Ultra月销量暴跌,直至去年12月份,从最高峰月销三千多辆,下滑到月销只有45辆。


数据来源:易车网


小米SU7 Ultra为何折戟?

 

小米SU7 Ultra开卖后,崇义的心中有了一个疑问。在他看来,小米很用心地打造出了这款汽车,但是在对车主的服务上,却“不太用心”。

 

他与林露两人相隔千里,但他俩所接触的小米SU7 Ultra意向客户,在身份上颇为一致:青年才俊,年龄多为90后、通过创业或职场上的成功积累了一定财富。

 

这个群体颇为重视情绪价值。崇义以往服务的豪车品牌,会赠送印有品牌Logo的钢笔、打火机等礼品。这些礼品的重点不在于成本或价值,而在于定制、有考究感、在市面上买不到,“这能让客户感受到被重视。”

 

小米SU7 Ultra车主所收到的礼品,则是棒球帽、背包等。在崇义看来,这些礼品看上去并不如其他豪车品牌的礼物“那么考究”,他眼中“最不考究”的礼品是小米不锈钢直饮水杯,“在商城就能买到,售价29块9,我都不知道这个点子是怎么想出来的...”


图源:小米商城


礼品方面的问题,尚可用“小米第一次做这么贵的车,经验不足”来解释,并以这款车不俗的产品力来弥补。

 

但是“锁马力事件”就不一样了,这件事的的确确伤害到了客户的体验。

 

2025年5月,小米SU7 Ultra汽车更新了1.7.0车机版本,此次更新后,车主要在指定赛道达成官方建议圈速,方可解锁最大马力。崇义认为这个行为是正确的,因为超过1500匹的马力本身就不是在街道上用的。

 

“但是你要提前沟通啊,或者是在销售的时候就锁上,告知客户通过测试才能解锁。客户要是觉得行就买,不行就算,总比这样子强。”

 

一波未平,一波又起。

 

在锁马力事件发生当月,小米SU7 Ultra又陷入碳纤维双风道前舱盖争议,多位准车主质疑该车型选装的碳纤维双风道前舱盖存在宣传与实际功能不符的情况。到了10月,成都一辆SU7 Ultra发生严重事故,现场流出的画面显示,多名救援人员尝试破门未果,这引发了外界对电子门锁与隐藏式门把手设计的质疑。

 

性能车的销量,很大程度上依赖于它在圈子玩家中口口相传的声誉,而这一系列的舆情,势必会损害小米SU7 Ultra的口碑。

 

对比小米官方所公布的大定数和实际销量,便不难看出口碑下滑对小米SU7 Ultra订单的影响。

 

在小米SU7 Ultra上市后的第二天,雷军曾公布该车型大定超1.5万辆;2025年,这款车的实际销量为1.57万辆(除了新增订单减少外,大概率也有大定用户放弃的原因)。

 

“小米人红是非多,舆情会被放大,我能理解,可是小米为什么不能多照顾下客户的情绪,多采取一些措施去把这些事更好地解决。”

 

12月,小米SU7 Ultra的销量为45辆。这个成绩在一定程度上宣告,小米SU7 Ultra未能提升公司在高端市场话语权。


同时,这款车市场表现的高开低走也让崇义颇为惋惜,他还记得在这款车上市之初的盛景,在他的客户中,有超过一半都是选择了价格更高的版本(62.99万元),“不光是我们希望这款车卖得好,客户们玩了这么久的外国车,也很希望能支持下中国品牌,但是...”


人散曲未终

 

虽然销量和口碑下滑严重,不少Ultra Master也已经离开,但小米SU7 Ultra仍在公司未来的计划之中。从小米的动作来看,这款车并没有因为销量下滑而被完全边缘化。

 

目前,小米SU7 Ultra已经正式入驻全球顶级赛车游戏《跑车浪漫旅7》(简称GT7),这是首辆在此游戏里上线的中国品牌车型。雷军在2月份的微博中,多次提及此事。

 

小米的其他高管也没有忘记维护小米SU7 Ultra的形象。今年频繁出现在直播间的小米汽车副总裁李肖爽,也在1月转发了“打假小米SU7 Ultra二手车价格崩盘”的相关微博。

 

这么做的原因并不难理解。小米SU7 Ultra的存在,仍可以展示公司技术实力,进而对小米的走量车型起到一定拉动作用。


初代小米SU7已经停售,新款小米SU7要等到第二季度才会上市,小米目前的走量车仅有一款小米YU7;数据来源:易车网


那么在自身销量方面,小米SU7 Ultra还有望回暖吗?

 

崇义告诉虎嗅,小米SU7 Ultra是一款非常“有功夫”的汽车,就算是圈子内的玩家,都不一定能把这款车“开明白”,“罗开罗这样的国内顶尖车手,才能完全驾驭这款车”,所以他认为,小米量产车型的销售人员,可能会讲不清楚这款车的技术细节,同时,量产车型销售人员为了完成销量KPI,也不会将精力重点放在小米SU7 Ultra上面。由此来看,小米SU7 Ultra大概率难以复现往日的辉煌。


不过,崇义、林露们会记得,2025年,他们所销售的汽车,曾在中国车市掀起过一股巨浪。

下载虎嗅APP,第一时间获取深度独到的商业科技资讯,连接更多创新人群与线下活动

pnpm-workspace.yaml

作者 code_YuJun
2026年2月8日 15:32

pnpm-workspace.yamlpnpm 的“项目组织与调度中枢”,告诉 pnpm:哪些目录是同一个 workspace,以及这些包之间如何协同工作。

定义哪些包属于同一个仓库

packages:
  - packages/*
  - apps/*
  • packages/* 下面每个有 package.json 的目录,都是一个包
  • apps/* 下面每个 app 也是一个包

Workspace 内包本地互相引用

packages/
  utils/
  ui/
apps/
  admin/

apps/admin/package.json 里:

{
  "dependencies": {
    "@my/utils": "workspace:*"
  }
}

效果是:

  • 不去 npm 下载
  • 直接 软链接到本地 packages/utils
  • 改代码立刻生效

这是 monorepo 的灵魂能力。

依赖统一安装、统一锁定

在根目录执行pnpm install
pnpm 会:

  • 扫描 pnpm-workspace.yaml 里的所有包
  • 统一生成 一份 pnpm-lock.yaml
  • 所有包共享同一个依赖树

支持 catalog

pnpm-workspace.yaml 里可以这样写:

catalog:
  vite: ^5.1.0
  vue: ^3.4.0
  typescript: ^5.3.3

子包中:

"devDependencies": {
  "vite": "catalog:",
  "vue": "catalog:"
}

版本集中管理,企业级工程标配.

支持 workspace 协议(workspace:*)

"@my/ui": "workspace:*"     // 任意版本
"@my/ui": "workspace:^"     // 遵循 semver
"@my/ui": "workspace:~"

批量执行命令

pnpm -r build
pnpm -r test
pnpm -r lint
  • -r = recursive
  • 对 workspace 里的 所有包 执行

corepack 作用

作者 code_YuJun
2026年2月8日 15:06

corepack 可以把它理解成 Node.js 自带的“包管理器管理器”
corepack 用来管理和锁定项目使用的包管理器(比如 pnpm / yarn),而不是管理依赖本身。

为什么会有 corepack

以前的情况很乱:

  • 有的人用 npm
  • 有的人用 yarn
  • 有的人用 pnpm
  • 同一个项目里,不同人用的 包管理器版本还不一样

结果就是:

“我这能跑,你那为啥装不起来?”

corepack 的出现,就是为了解决 “到底用哪个包管理器、用哪个版本” 这个问题。

corepack 能干什么

1️⃣ 统一项目使用的包管理器

package.json 里可以写:

{
  "packageManager": "pnpm@8.15.4"
}

含义是:

这个项目 必须pnpm,而且版本是 8.15.4

这时候:

  • pnpm install
  • 同事 npm install
  • CI 里跑 pnpm install

👉 corepack 会自动帮你下载并使用正确版本的 pnpm

不用大家手动装。


2️⃣ 自动安装 & 切换 yarn / pnpm

你甚至不需要提前全局装 pnpm:

corepack enable
pnpm install

如果项目声明的是:

"packageManager": "yarn@3.6.1"

corepack 会:

  • 自动下载 yarn 3.6.1
  • 用它来执行命令

你本地有没有 yarn 👉 不重要


3️⃣ 防止“包管理器版本不一致”的坑

比如:

  • A 用 pnpm 7
  • B 用 pnpm 8
  • lock 文件结构都不一样

corepack 可以 强制版本一致,从源头避免:

  • lockfile 被反复改
  • CI 跑不过
  • “我这没问题啊”的玄学 bug

corepack 和 npm / yarn / pnpm 的关系

可以这么理解👇

corepack
  ├── 管理 pnpm
  ├── 管理 yarn
  └── 管理 npm(间接)
  • npm / yarn / pnpm:真正干活的
  • corepack:负责“发工具、管版本、做协调”

常用命令速览 🧠

# 启用 corepack(Node 16+ 自带)
corepack enable

# 查看当前 corepack 版本
corepack --version

# 指定并激活某个包管理器版本
corepack prepare pnpm@8.15.4 --activate

什么时候一定要用 corepack

非常推荐用在这些场景👇

  • 团队协作项目
  • monorepo(pnpm / yarn workspace)
  • CI / Docker / 线上构建
  • 你已经被 “lockfile 一直变” 折磨过 😅

一句话总结

corepack 不是用来装依赖的,是用来“管包管理器的版本和使用权”的。
它让“这个项目该用哪个包管理器、哪个版本”变成一件确定的事。

中信证券:短期利益和长期价值的矛盾在海外市场激化

2026年2月8日 16:29
中信证券研报认为,近期海外市场风险偏好和流动性出现了明显异动。无论是战略安全的投入还是代表未来的新兴基建和技术投入,都意味着欧美将面临更激烈的竞争,同时面临短期股东利益和长期基础设施投入战略价值的权衡,矛盾在资本市场会反复被激发。对于长期习惯于赚“容易的钱”的投资者而言,未来全球金融市场的不确定性将持续提高,过度基于远期现金流或是资金接力预期的风险资产更容易出现持续的估值修正。反观中国的资本市场,过去几年已经先行完成了“脱虚向实”的定价,正处于对“提质增效”的验证和定价过程中,无需焦虑短期市场波动。配置上,建议依旧维持“资源+传统制造”打底,低吸非银,增配消费链和地产链。(证券时报)
❌
❌