普通视图

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

千问称春节免单活动热度远超预期,延长免单卡有效期

2026年2月7日 12:27
记者今日获悉,千问将春节免单卡的有效使用期限从2月23日延长至28日。千问官方表示,春节免单活动上线后,参与热度远超预期,为给用户预留更充裕的使用时间,将免单卡有效期延长至2月28日。同时,25元免单卡不仅支持购买奶茶,还可支持早中晚餐、鸡蛋、青菜等生鲜百货零食,以及天猫超市和线下商超的年货。据悉,全国盒马门店目前已接入千问APP。(财联社)

天涯社区将重启,天涯社区1999元服务包开售

2026年2月7日 11:58
关停近3年后,天涯社区重启迎来新进展。 2月6日晚间,“新天涯”联合工作组、成都天涯客网络科技有限公司、天涯好东西(海南)电子商务有限公司联合公告,正推进天涯社区于2026年6月1日恢复访问。(证券时报)

虚拟列表:从定高到动态高度的 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 做异步批处理。

春节期间“不打烊” 多家快递企业将继续提供收派服务

2026年2月7日 11:45
近期,多家快递企业相继发布春节期间服务安排,宣布将继续提供收派服务,全力满足节日期间的寄递需求。 中国邮政、顺丰速运、京东物流、德邦快递等多家快递企业陆续发布公告,今年春节期间将全力保障快件收发需求,积极调配运力、人力资源,服务“不打烊”。 同时,快递企业提醒:因节日期间资源调配受限、极端天气变化等多重因素,快件时效或将受到不同程度影响。(央视新闻)

Anthropic最快下周完成超200亿美元融资

2026年2月7日 11:31
据报道,人工智能公司Anthropic正着手敲定新一轮融资的最终细节,预计融资额将超过200亿美元,有望最快下周完成。最新一轮融资将使Anthropic的估值几乎翻番,达到近3500亿美元。(界面)

国家税务总局:2月25日起可预约办理2025年度个税汇算

2026年2月7日 11:14
国家税务总局近日发布《关于2025年度个人所得税综合所得汇算清缴预约办理时间的通告》。通告明确,2025年度个人所得税综合所得汇算清缴办理时间为2026年3月1日至6月30日,税务部门将为纳税人提供预约办理服务。纳税人需在3月1日至3月20日期间办理的,可以自2月25日起通过个人所得税App提前预约;3月21日至6月30日期间,纳税人无需预约,可以随时办理。(央视新闻)

这 7 个免费 Lottie 动画网站,帮你省下一个设计师的工资

2026年2月7日 11:03

大家好,我是大华!

有时候写前端,会觉得:同样是写页面,为什么有些产品一看就很舒服,而自己写的界面,怎么看都觉得很笨重。

是他们用了什么高深的技术吗?

其实那些看起来很好看,很丝滑的动画,大多数项目只是用了 Lottie。

比如下面这种动画效果:

什么是Lottie动画?

Lottie 并不是某种前端框架,而是一种动画文件格式和播放方案。 设计师在 After Effects 里把动画做好,导出成 JSON 文件,前端或客户端只需要通过 Lottie 播放器加载,就能把动画渲染出来。

和 GIF 或视频相比,Lottie 的体积更小、更轻量。 它是矢量动画,体积小、放大不会失真,还可以通过代码控制播放、暂停、循环,甚至动态改颜色和速度。

怎么使用?

一、在 HTML / 原生页面中使用 Lottie

这是最简单、也是最通用的一种方式,适合官网、活动页、Demo 页面。

现在官方更推荐用 lottie-web + <lottie-player> 这种方式,基本零学习成本。

1️⃣ 引入 Lottie 播放器

直接在 HTML 里引入官方 CDN:

<script src="https://unpkg.com/@lottiefiles/lottie-player@latest/dist/lottie-player.js"></script>

2️⃣ 在页面中使用

<lottie-player
  src="https://assets.lottiefiles.com/packages/lf20_x62chJ.json"
  background="transparent"
  speed="1"
  style="width: 300px; height: 300px;"
  loop
  autoplay>
</lottie-player>

这样,一个简单的动画就已经跑起来了,效果如下:

你可以简单理解为: Lottie = 一个自定义的 HTML 标签,src 指向 JSON 文件即可。

常用属性也很好记:

  • loop:是否循环
  • autoplay:是否自动播放
  • speed:播放速度
  • style:控制大小

如果你只是想加个加载动画、空状态动画,这种方式已经完全够用了。


二、在 Vue 项目中使用 Lottie

在 Vue 里,一般会直接使用 lottie-web,控制力更强,适合业务场景。

1️⃣ 安装依赖

npm install lottie-web

2️⃣ 在组件中使用

<template>
  <div ref="lottieContainer" style="width: 300px; height: 300px;"></div>
</template>

<script>
import lottie from 'lottie-web'

export default {
  mounted() {
    lottie.loadAnimation({
      container: this.$refs.lottieContainer,
      renderer: 'svg',
      loop: true,
      autoplay: true,
      path: '/lottie/loading.json' // 本地或远程 JSON
    })
  }
}
</script>

这样动画会在组件挂载完成后自动播放。

这里有几个关键点,理解了基本就不怕用了:

  • container:动画挂载的 DOM
  • renderer:一般用 svg
  • path:Lottie JSON 文件路径
  • loop / autoplay:控制播放行为

三、在 Vue 里怎么控制动画?

这也是 Lottie 相比 GIF 最大的优势之一。

你可以拿到动画实例,然后随意控制:

const animation = lottie.loadAnimation({
  container: this.$refs.lottieContainer,
  renderer: 'svg',
  loop: false,
  autoplay: false,
  path: '/lottie/success.json'
})

// 手动播放
animation.play()

// 暂停
animation.pause()

// 停止
animation.stop()

比如:

  • 提交成功后再播放动画
  • 请求完成才显示动效
  • 根据状态切换不同动画

简而言之:Lottie 可以让你在应用或网站中添加更流畅的动画效果,并且不会降低性能或占用所存储的空间。


免费 Lottie 动画网站

说实话,动画制作成本很高。没人会为了做一个弹跳的购物车图标就去组件整个特效团队。 幸运的是,网上有很多免费的 Lottie 动画,你可以直接把它们添加到你的应用或者网站里面。

下面这些网站,基本可以覆盖你 80% 的日常需求,而且不花钱。

1. LottieFiles(官方首选)

Lottie 的官方平台,也是大多数人找动画的第一站。 提供大量免费动画资源,支持在线预览和修改颜色,下载的就是标准 JSON 文件,用起来非常省心。

网站:lottiefiles.com/


2. IconScout

IconScout 本身就是一个大型设计素材站,其中的免费 Lottie 动画覆盖了大量 UI 场景,从加载动画到角色动效都有,风格也比较多样。

网站:iconscout.com/


3. Storyset(Freepik 出品)

Storyset 提供可在浏览器中直接编辑的插画和 Lottie 动画,你可以自己改颜色、搭配元素,然后导出。即使完全不懂设计,也能做出“像定制过”的效果。

网站:storyset.com/


4. LottieFlow

由 Webflow 社区维护的免费 Lottie 库,主打无水印、无会员限制。 动画以 Web 场景为主,但 JSON 文件在任何项目中都能用。

网站:finsweet.com/lottieflow


其他可选资源

如果你想多囤一些风格不同的动画,这些也可以顺手收藏:


Lottie 真正解决的,其实是页面体验的问题。 它让动画变得轻量、可控,也大幅降低了开发和设计之间的协作成本。

你不需要是 After Effects 高手,也不用写复杂的动画逻辑。 很多时候,只是下载一个 JSON 文件,放进项目里,页面立刻就会多一点质感。

本文首发于公众号:程序员大华,专注前端、Java开发,AI应用和工具的分享。关注我,少走弯路,一起进步!

黄仁勋:人工智能领域的资本支出是合理且可持续的

2026年2月7日 11:01
据报道,英伟达CEO黄仁勋表示,尽管存在客户在数据中心方面过度消费的担忧,但其支出水平“是合理且可持续的”。黄仁勋称,人工智能基础设施的建设工作将持续七到八年,他还表示对AI的需求“简直太高了”,“AI已变得非常有用且功能强大,其应用的普及程度也变得极高”。(第一财经)

OpenAI与G42洽谈,拟为阿联酋打造专属ChatGPT

2026年2月7日 10:50
据报道,知情人士透露,OpenAI正与总部位于阿联酋阿布扎比的G42公司合作,打造针对阿联酋定制的ChatGPT新版本。OpenAI员工透露,该定制版专为阿联酋政府设计。据悉,具体细节仍在商讨中,但最终成果预计会是经过精细调整的 ChatGPT版本,它能流利地使用当地阿拉伯语,并可能设有内容限制。(界面)

苹果拟允许第三方语音控制AI应用接入CarPlay

2026年2月7日 10:34
据报道,知情人士透露,苹果准备允许其他公司的语音控制人工智能应用接入CarPlay系统。此举将使用户首次能够通过其车载界面与人工智能聊天机器人交流。上述人士表示,苹果将在未来几个月内努力为CarPlay系统中的应用程序提供支持。此前,苹果在其车载信息娱乐系统软件中仅允许自家Siri助手作为语音控制选项。知情人士称,苹果不会允许用户更换CarPlay上的Siri按钮或唤醒该服务的唤醒词。用户需要打开相关应用程序才能激活第三方语音控制功能。(界面)

为什么有的函数要用 call,有的却完全不用?

作者 DoraBigHead
2026年2月7日 10:32

——从 this 设计本质理解 JavaScript 方法分类

在学习 JavaScript 的过程中,很多人都会卡在一个问题上:

为什么 Object.getPrototypeOf(obj) 不需要 call
Object.prototype.toString 却必须用 call

更进一步的问题是:

我怎么提前知道一个函数到底是“参数型函数”还是“this 型函数”?

本文将从设计层面而不是“记规则”的角度,彻底解释这个问题。


一、困惑的根源:我们混淆了两种“函数设计方式”

在 JS 里,函数只有一种语法形式,但实际上有两种完全不同的设计思路

1️⃣ 参数型函数(Parameter-based)

Object.getPrototypeOf(obj)
Object.keys(obj)
Math.max(1, 2, 3)

特点:

  • 操作对象 通过参数传入
  • 函数内部 不依赖 this
  • this 是谁 无关紧要

2️⃣ this 型函数(This-based)

obj.toString()
arr.push(1)
Object.prototype.toString.call(value)

特点:

  • 操作对象 来自 this
  • 函数内部 强依赖 this
  • 必须明确 this 指向谁

👉 是否需要使用 call,只取决于这一点


二、为什么 Object.getPrototypeOf 不需要 call?

先看调用方式:

Object.getPrototypeOf(left)

它的“设计意图”非常明确:

  • 要操作的对象是 left
  • left 已经作为参数传入
  • 函数内部只关心参数,不关心 this

可以把它理解为伪代码:

function getPrototypeOf(obj) {
  return obj.__proto__
}

👉 这是一个纯工具函数(utility function)

所以:

  • 不需要 call
  • call 反而多余

三、为什么 Object.prototype.toString 必须用 call?

再看这个经典写法:

Object.prototype.toString.call(value)

为什么不能直接这样?

Object.prototype.toString(value) // ❌

因为这个方法的设计是:

  • 没有参数
  • 要检查的对象只能来自 this

伪代码理解:

Object.prototype.toString = function () {
  return "[object " + 内部类型(this) + "]"
}

👉 如果你不告诉它 this 是谁,它根本不知道要检查什么。

这就是 必须使用 call 的根本原因


四、一个极其重要的判断标准(80% 准确)

看方法“挂在哪里”

✅ 挂在构造函数本身上的(参数型)

Object.keys
Object.getPrototypeOf
Array.isArray
Math.max

特点:

  • Object.xxx
  • Array.xxx
  • Math.xxx

👉 几乎一定是参数型函数


✅ 挂在 prototype 上的(this 型)

Object.prototype.toString
Array.prototype.push
Array.prototype.slice
Function.prototype.call

特点:

  • xxx.prototype.xxx
  • 操作“当前对象”

👉 几乎一定依赖 this


口诀总结(非常重要)

静态方法用参数,原型方法靠 this


五、最可靠的方法:一行代码验证

如果你真的不确定,直接用这一招。

验证是否依赖 this

const fn = Object.prototype.toString
fn() // ❌ 报错或结果异常

👉 没有 this 就不能工作 → this 型函数


验证是否依赖参数

const fn = Object.getPrototypeOf
fn({}) // ✅ 正常执行

👉 this 不重要 → 参数型函数


六、为什么不能“所有函数都用 call”?

技术上可以,但语义上是错误的

Object.getPrototypeOf.call(null, obj)

问题在于:

  • this 被完全忽略
  • 代码可读性变差
  • 违背 JS API 的设计初衷

👉 call 的存在是为了解决 this,而不是统一写法


七、总结一句话(博客结尾版)

JS 中是否使用 call
不取决于“函数高级不高级”,
只取决于“这个函数是否依赖 this”。


八、你现在卡住,其实非常正常

你现在遇到的不是“语法问题”,而是:

从“会用 JS” → “理解 JS 设计” 的过渡阶段

这是一个所有中高级 JS 开发者都必经的坎。

Vue3中非响应式的全局状态管理

作者 EchoEcho
2026年2月7日 08:14

vue项目中,一般说到状态管理,我们会想到pinia,它可以帮助我们管理需要在多个页面、组件间共享的数据,并且根据数据的更新触发相关的渲染更新。但如果是数据变化不会引起页面刷新的全局数据呢?

 比如我当前开发的项目中,需要在项目初始化之后获取对应的引擎实例,该实例提供相关api用于处理页面逻辑,但该实例并不会触发页面的更新,此时就需要一个 非响应式的全局状态管理 -- globalState

适用场景:
  • 全局配置、缓存、临时数据
  • 跨页面/组件的事件通知
  • 不需要响应式的数据共享
不适用场景
  • 需要数据变化时自动刷新页面的场景【用PiniaVuex
逻辑梳理:
  1.  定义一个globalState类,全局只有一个实例
  2. 维护一份state数据,提供setgethasdeleteclear
  3. 提供事件总线功能,用于在不同组件间“广播消息”: on【监听事件】、emit【触发事件】、off【移除监听】

具体实现代码:

/stores/globalState.ts

// 全局状态管理(非响应式)
/**
 * 跨页面/组件共享数据,但又不需要响应式(不需要自动刷新UI)。
 * 存储全局配置、缓存、临时数据等。
 * 避免污染 window,比直接用 window.xxx 更安全、可控、易维护。
 * 比 Pinia/Vuex 更轻量,适合存储不需要响应式的数据。
 */

class GlobalState {
    private static instance: GlobalState
    private state: Map<string, any> = new Map()
    private eventListeners: Map<string, Function[]> = new Map()

    static getInstance(): GlobalState {
        if(!GlobalState.instance) {
            GlobalState.instance = new GlobalState()
        }
        return GlobalState.instance
    }
    
    set(key: string, value: any): void {
        this.state.set(key, value)
    }

    get(key: string): any {
        return this.state.get(key)
    }

    has(key: string): boolean {
        return this.state.has(key)
    }

    delete(key: string): boolean {
        return this.state.delete(key)
    }

    clear(): void {
        this.state.clear()
    }

    // 新增事件总线功能
    on(eventName: string, callback: Function): void {
        if(!this.eventListeners.has(eventName)) {
            this.eventListeners.set(eventName, [])
        }
        this.eventListeners.get(eventName)?.push(callback)
    }
    
    emit(eventName: string, data?: any): void {
        const listeners = this.eventListeners.get(eventName)
        if(listeners) {
            listeners.forEach(callback => {
                try {
                    callback(data)
                } catch (error) {
                    console.error('事件回调执行错误:', error)
                }
            })
        }
    }

    off(eventName: string, callback?: Function): void {
        if (!callback) {
            this.eventListeners.delete(eventName)
        } else {
            const listeners = this.eventListeners.get(eventName)
            if (listeners) {
                const index = listeners.indexOf(callback)
                if (index > -1) {
                    listeners.splice(index, 1)
                }
            }
        }
    }

}

export const globalState = GlobalState.getInstance()
使用
  1. 组件A -- a.vue  【当引擎实例化成功后,设置引擎数据,并触发广播,在适合的时机销毁相关数据】
// a.vue 当引擎实例化成功后,设置引擎数据,并触发广播
import { globalState } from "@/stores/globalState";
const InstanceHasInited = (data) => {
      if (engine) {
            globalState.set("engineInstance", data);
            // 触发事件,通知其他组件
            globalState.emit("engineInstance:created", data);
      }
});
// 销毁相关数据
onUnmounted(() => {
  globalState.delete("engineInstance");
  globalState.off("engineInstance:created");
});
  1. 组件B -- b.vue  【在需要使用引擎api获取数据的位置添加监听】

import { globalState } from "@/stores/globalState";
const handleInstanceCreated = (engine: Engine) => {
    if(engine) {
        // 调用相关api
    }
}
onMounted(() => {
  // 监听引擎实例创建事件
  globalState.on("engineInstance:created", handleInstanceCreated);
})

挑战全栈框架的极限:仅 7kb 的 Lupine.js 发布了

作者 uuware
2026年2月7日 06:55

Lupine.js:一款"极其"高效的 Web 框架

在一个被庞大的元框架 (Meta-frameworks) 和复杂构建链主导的世界里,Lupine.js 提出了一个简单的问题:如果我们能拥有现代全栈框架的威力,却不需要那些臃肿的负担,会怎样?

Lupine.js 是一个 轻量级 (7kb gzipped)全栈 Web 框架,它结合了类 React 的前端体验和类 Express 的后端架构。它是完全从零开始设计,旨在实现极致的速度、简洁和高效。

og-image.png

为什么选择 Lupine.js?

1. 🪶 极其轻量的前端

lupine.web 前端包极其小巧——仅 7kb gzipped。然而,它保留了你熟悉和喜爱的开发体验:TSX 语法 (React JSX)、组件和 Hooks。没有沉重的运行时需要下载,这意味着即使在慢速网络下,你的页面也能瞬间加载。

2. ⚡ 内置服务端渲染 (SSR)

大多数框架将 SSR 视为附加功能。在 Lupine 中,SSR 是 一等公民lupine.api 后端经过优化,能够自动在服务器上渲染你的前端页面。

  • 无样式闪烁 (No FOUC): 关键 CSS 由服务端注入。
  • 零配置 SEO: Meta 标签 (og:image, description) 在页面离开服务器前就已经计算完毕。
  • 社交分享就绪: 分享到 Twitter/微信/Facebook 的链接开箱即用,效果完美。

3. 🎨 原生 CSS-in-JS 引擎

告别配置 PostCSS、Tailwind 或 styled-components 的烦恼。Lupine 内置了一个强大的 CSS-in-JS 引擎。

  • 样式隔离: 样式自动隔离到你的组件。
  • 嵌套支持: 支持 .parent & 语法。
  • 高性能: 样式在 SSR 期间被高效提取和注入。
const Button = () => {
  const css = {
    backgroundColor: '#0ac92a',
    '&:hover': {
      backgroundColor: '#08a823',
    },
  };
  return <button css={css}>点击我</button>;
};

4. 🚀 全栈合一

Lupine 不仅仅是一个前端库;它是完整的应用解决方案。

  • 后端 (lupine.api): 一个高效、极简的 Node.js 框架,类似于 Express。
  • 前端 (lupine.web): 一个响应式的 UI 库。
  • 开发体验: 运行 npm run dev,即可在同一个 VS Code 会话中同时调试前端和后端。

快速开始

准备好尝试了吗?几秒钟就能搭建一个新的项目。

第一步:创建项目

使用我们的 CLI 工具创建一个新应用。

npx create-lupine@latest my-awesome-app

第二步:运行项目

进入目录并启动开发服务器。

cd my-awesome-app
npm install
npm run dev

访问 http://localhost:11080,你将看到你的第一个 Lupine 应用正在运行!

代码活跃度

Lupine 正在积极开发中。你可以直接在 GitHub 上查看我们的代码频率和贡献: 👉 github.com/uuware/lupi…

总结

Lupine.js 非常适合这样的开发者:

  • 掌控力: 想要了解技术栈的每一个部分。
  • 速度: 想为用户提供最快的体验。
  • 简洁: 没有隐藏的魔法,只有干净的代码。

Lupine.js 在 GitHub 上点个 Star,并在你的下一个项目中尝试一下吧!

TypeScript 泛型从轻松入门到看懂源码

作者 SuperEugene
2026年2月7日 02:02

从「完全不懂泛型」一路走到「看懂下面这段代码到底在干嘛」:

//此代为为VxeTable组件库Grid配置式表格数据分页示例代码部分片段
<script lang="ts" setup>
import { reactive } from 'vue'
import type { VxeGridProps, VxeGridListeners } from 'vxe-table'

interface RowVO {  
  id: number  
  name: string  
  role: string  
  sex: string  
  age: number  
  address: string
}

const gridOptions = reactive<VxeGridProps<RowVO>>({  
  showOverflow: true,  
  border: true,  
  loading: false,  
  height: 500,  
  pagerConfig: pagerVO,  
  columns: [    
    { type: 'seq', width: 70, fixed: 'left' },    
    { field: 'name', title: 'Name', minWidth: 160 },    
    { field: 'email', title: 'Email', minWidth: 160 },    
    { field: 'nickname', title: 'Nickname', minWidth: 160 },    
    { field: 'age', title: 'Age', width: 100 },    
    { field: 'role', title: 'Role', minWidth: 160 },    
    { field: 'amount', title: 'Amount', width: 140 },    
    { field: 'updateDate', title: 'Update Date', visible: false },    
    { field: 'createDate', title: 'Create Date', visible: false },  
  ],  
  data: [],
})
</script>

VxeTable组件库简介:

  • 由于这篇文章引用到了VxeTable组件库的代码,所以在这里给没接触过的小伙伴做一个简单的介绍,老司机可自行跳过。
  • VxeTable是一个基于 Vue 的表格组件库,提供表格、表单、工具栏、分页等组件,适合中后台场景。性能与功能都较强,但学习成本和按需引入的配置需要投入时间。如果你的项目以表格为核心,且需要虚拟滚动、复杂交互等功能,VxeTable 是合适的选择。感兴趣的小伙伴可以通过下方贴上的官网链接学习了解。

(PS·即使没用过VxeTable也不影响你看懂这篇文章)

官网链接:VxeTable官网

一、什么是泛型?一句话版本

泛型 = 给 “类型” 加参数。

  • 函数可以有参数:function fn(x: number) {}
  • 类型也可以有 “参数”:Array<string>

这里 Array 就是一个「带类型参数」的类型,<string> 就是「传给它的类型参数」。

用人话说:

泛型就是:我写一份通用的类型 / 函数,真正用的时候再告诉它具体用什么类型

二、最普通的一层泛型

1. 最熟悉的例子:数组

// 这俩是完全等价的
const list1: string[] = []
const list2: Array<string> = []
  • Array<T> 是一个泛型类型
  • T 是它的类型参数
  • Array<string> 表示「元素类型是 string 的数组」

2. 自己写一个泛型函数

function identity<T>(value: T): T {  
  return value
}
identity<number>(1)      // T 被替换成 number
identity<string>('hi')   // T 被替换成 string

你可以理解为:

  • 定义:identity<T>T 是一个「占位的类型」
  • 使用:identity<number> → 这次调用里「把 T 换成 number

或许有同学不理解为什么要在函数名称后面写<T>。不用纠结,这是固定的写法,就像你要使用变量,就要先声明一样,如:

let data = []
data.push(123)

如果此处没有声明data,便用不了data。同理如果不在函数名称后面写<T>声明一下这是泛型参数,TypeScript 无法识别 T 是什么,如下:

//  错误:找不到名称 'T'
function identity(value: T): T {
  return value
}
// 报错:Cannot find name 'T'

TypeScript 会把 T 当作一个未声明的类型,因此报错。

三、类型也可以是泛型:接口 /type

1. 泛型接口

// 使用时传入不同的 T
interface ApiResponse<T> {  
  code: number  
  msg: string  
  data: T
}

interface User {  
  id: number  
  name: string
}

const res1: ApiResponse<User> = {  
  code: 0,  
  msg: 'ok',  
  data: { id: 1, name: '张三' },
}

const res2: ApiResponse<string[]> = {  
  code: 0,  
  msg: 'ok',  
  data: ['a', 'b'],
}

观察:

  • ApiResponse<T> 自己并不知道 T 是啥
  • 真正用的时候写 ApiResponse<User> / ApiResponse<string[]>
  • TypeScript 在这一刻才把 T 替换掉

四、嵌套泛型:泛型里面再套泛型

其实很简单,就是「类型参数本身也是一个泛型类型」。

// 一层:数组里放字符串
Array<string>

// 两层:Promise 里放数组,数组里放字符串
Promise<Array<string>>

// 换个写法更直观
type StringArray = Array<string>
type StringArrayPromise = Promise<StringArray>

你可以这么想:

  • 第一层:Array<T>
  • 第二层:Promise<第一层>

五、回到文章最开始的例子:VxeGridProps<RowVO>

先看定义的行数据类型:

interface RowVO {  
  id: number  
  name: string  
  role: string  
  sex: string  
  age: number  
  address: string
}

然后:

const gridOptions = reactive<VxeGridProps<RowVO>>({...})

拆开理解:

  • VxeGridProps<D = any> 是 vxe-table 提供的泛型接口
  • 你写的是 VxeGridProps<RowVO>
  • 这一刻,D 就被替换成了 RowVO

也就是在这一整次使用里,可以把它脑补成:

// 伪代码,仅用于理解
interface VxeGridProps_RowVO extends VxeTableProps<RowVO> {  
  columns?: VxeGridPropTypes.Columns<RowVO>  
  proxyConfig?: VxeGridPropTypes.ProxyConfig<RowVO>  
  // ...
}

可能很多同学看到这里会感到些疑惑,怎么一会儿T一会儿D的。其实不管是T还是D都是类型变量的自定义名称,叫什么都无所谓,语法上没有任何固定含义,就像你写 JS 时给变量起名num/name/age一样,只是前端社区形成了「约定俗成的命名习惯」,用不同字母对应不同语义,让代码更易读。

字母 全称 含义/使用场景 例子
T Type 通用类型(最常用,无特殊语义时都用 T) first<T>(arr: T[])
D Default/Date 通常指 “默认类型” 或 “日期类型”(小众) 泛型接口里的默认类型:interface Config<D = string>
K KeyKey 表示对象的「键」类型 getKey<K extends string>(obj: { [k: K]: any }, key: K)
V Value 表示对象的「值」类型 Map<K, V>(TS 内置的 Map 泛型)
E Element 表示数组 / 集合的「元素」类型 Array<E>(TS 内置的数组泛型)
P Parameter 表示函数的「参数」类型 function wrap<P>(fn: (arg: P) => void, arg: P)

六、类型参数是怎么一层一层 “传下去” 的?

到这一步为了更好的理解泛型,我将带着同学们追溯源码。一起来追踪一下源码看看吧。

1. 第一层:VxeGridProps<D>

源码里(简化):

export interface VxeGridProps<D = any> extends VxeTableProps<D> {  
  columns?: VxeGridPropTypes.Columns<D>  
  proxyConfig?: VxeGridPropTypes.ProxyConfig<D>  
  // ...
}

当你用 VxeGridProps<RowVO>

  • extends VxeTableProps<D> → 变成 extends VxeTableProps<RowVO>
  • columns?: Columns<D> → 变成 columns?: Columns<RowVO>
  • proxyConfig?: ProxyConfig<D> → 变成 proxyConfig?: ProxyConfig<RowVO>

记忆:哪里写了 <D>,就会被替换成 <RowVO>

2. 第二层:Columns<D> = Column<D>[]

export namespace VxeGridPropTypes {  
  export type Column<D = any> = VxeTableDefines.ColumnOptions<D>  
  export type Columns<D = any> = Column<D>[]
}

当你用的是 Columns<RowVO> 时:

  • Columns<D>Columns<RowVO>
  • = Column<D>[] 这一行里的 D 同样被替换成 RowVO,变成:
  • Columns<RowVO> = Column<RowVO>[]

接着:

  • Column<D> = VxeTableDefines.ColumnOptions<D>
  • 也会变成:Column<RowVO> = VxeTableDefines.ColumnOptions<RowVO>

所以:

columns 的每一项类型就是 ColumnOptions<RowVO>

七、第三层:ColumnOptions<D>D 真正用在哪里?

export interface ColumnOptions<D = any> extends VxeColumnProps<D> {  
  children?: ColumnOptions<D>[]  
  slots?: VxeColumnPropTypes.Slots<D>
}

继续替换:

  • ColumnOptions<D>ColumnOptions<RowVO>
  • extends VxeColumnProps<D> → 变成 extends VxeColumnProps<RowVO>
  • children?: ColumnOptions<D>[] → 变成 children?: ColumnOptions<RowVO>[]
  • slots?: Slots<D> → 变成 slots?: Slots<RowVO>

关键点:ColumnOptions<RowVO> 本身定义了「列配置」的结构它继承的 VxeColumnProps<RowVO> + Slots<RowVO> 等地方,会在「需要行数据的回调」里用到 RowVO,比如:

formatter(params: { row: RowVO; ... })
className(params: { row: RowVO; ... })

八、我在学习时候的疑惑?

我当时并不理解TypeScript 做的是统一替换

// 把 D 换成 RowVO:
type Columns<RowVO> = Column<RowVO>[]

// 再把 Column 展开:
type Column<RowVO> = VxeTableDefines.ColumnOptions<RowVO>

// 合起来就是:
type Columns<RowVO> = VxeTableDefines.ColumnOptions<RowVO>[]

就拿文章示例的代码来看,TypeScript 会把函数体中所有的<T>都 替换成你制定的类型。

不理解的代码:

export type Column<D = any> = VxeTableDefines.ColumnOptions<D>
export type Columns<D = any> = Column<D>[]

我当特别不能理解 Column<D>[]<D>是怎么变成<RowVO>的。直到我明白了TypeScript会做统一替换,根本不是按数据传参的逻辑去做的。

九、把整个链路串起来(从外到内)

你写了:

reactive<VxeGridProps<RowVO>>({...})

于是:

VxeGridProps<D> → VxeGridProps<RowVO>
extends VxeTableProps<D> → extends VxeTableProps<RowVO>
columns?: Columns<D> → columns?: Columns<RowVO>

然后:

Columns<D> = Column<D>[] → Columns<RowVO> = Column<RowVO>[]
Column<D> = ColumnOptions<D> → Column<RowVO> = ColumnOptions<RowVO>

再往下:

ColumnOptions<D> extends VxeColumnProps<D> → ColumnOptions<RowVO> extends VxeColumnProps<RowVO>

最终效果:

  • data 的类型是:RowVO[]
  • 所有回调里涉及「行数据」的地方,类型参数是 RowVO

十、总结这次案例

  • RowVO:描述 “一行数据长什么样”
  • VxeGridProps<RowVO>:告诉表格「我的每一行数据都是 RowVO
  • 泛型参数 <RowVO> 会一层层往下传,凡是类型里写了 <D> 的地方,就会变成 <RowVO>

你现在已经不是 “不懂泛型的小白” 了,你已经能:

  • 看懂「类型参数是怎么一层一层传下去的」
  • 顺着 VxeGridProps<RowVO> → Columns<RowVO> → ColumnOptions<RowVO> 这一整条链路往下追

这就已经是非常扎实的泛型理解了。

总结

  1. 泛型的核心是「给类型加参数」,使用时再指定具体类型,如 Array<string>VxeGridProps<RowVO>
  2. 嵌套泛型的本质是「类型参数本身也是泛型」,参数会逐层传递替换(DRowVO);

以上便是对泛型的分享,欢迎大家指正讨论,与大家共勉。

从Vue到Bevy与Nestjs:我为 Vue 构建了一套“无头”业务引擎

作者 ArisuYuki
2026年2月6日 23:21

不知道大家是否见过那种动辄几千行、逻辑像乱麻一样缠绕的 .vue 文件?

笔者在许多开源项目和企业级项目里都见过类似的现象:各种 watch 互相套娃、生命周期里塞满异步逻辑、父子组件传值传到怀疑人生。当项目进入中后期,Vue 的响应式系统仿佛从‘利器’变成了‘诅咒’,每一行代码的改动都像是在玩扫雷。

这种“面条代码”的泛滥让我开始反思:当下的前端开发范式,真的能支撑起当今逻辑爆炸的复杂应用吗?


起初,我以为这种混乱只是人为因素——觉得只要通过规范的 Code Review、靠着开发者的自觉,就能压制住代码的腐烂。但随着项目规模的膨胀,我推翻了自己的想法。我发现 Vue 的 API 仿佛自带一种传染性

只要你的业务代码中还直接调用着 refwatchonMounted这些Vue最核心的功能,业务逻辑就不可避免地会向 UI 框架低头成为UI的附庸。今天为了省事顺手写下的每一个 watchcomputed,都是为未来的“谁改了我的变量”埋下伏笔。Vue的这种‘响应式链路’在项目初期极度丝滑,但在项目后期就是噩梦的开始。

直到最后,我发现一个几乎无法避开的实事:只要 UI 框架还掌握着状态的‘修改权’,业务代码就几乎注定会退化成面条。 于是我开始意识到,我必须从物理层面给 Vue 的权力‘断供’。这便是我设计 Virid 的初衷:我要的不是更优雅地写 Vue 代码的方法,而是一套根本不属于 Vue 的全新世界。


在这样的理念的推动下,我产生了一个极其激进的想法:**让逻辑彻底从 UI 中剥离,构建一套完全"无头"(Headless)的业务引擎。**当我将目光投向 Rust 的 Bevy ECS 架构NestJS 的 IoC 依赖注入时,我发现了我自己的答案。

Bevy 是 Rust 圈子里最硬核的开源项目之一,它的 ECS 系统美得像艺术品。但可惜它为游戏而生,天然自带高频 Tick,直接挪到前端开发中会显得格格不入。NestJS 是 JS 领域里依赖注入最成熟的实践。我一直在思考,如果能用 NestJS 的手感去写一套 Bevy 式的解耦逻辑,会发生什么?@Virid/core 就是这个思考的答案。它剔除了多余的资源损耗,保留了最核心的架构美感。


站在巨人的肩膀上,我为前端量身定制了一套“带帧双缓冲与优先级调度的消息中心”。

它绝非简单的 Event Bus 或 Pub/Sub 模式所能比拟。它本质上是一个融合了 NestJS 依赖注入Bevy调度核心的精密系统。通过帧双缓冲机制,它彻底消除了前端逻辑中常见的"竞态条件"与"状态踩踏";配合优先级调度,它确保了每一条业务指令都能在最合适的时间节拍里执行。

要使用@Virid/core,只需要简单的三步走。首先派生一个自己的消息。他可以携带任何你想要发送的数据。

// 初始化核心引擎
const app = createVirid();
// 派生一个自己的消息
class IncrementMessage extends SingleMessage {
  constructor(public amount: number) {
    super();
  }
}

接着,定义自己的Component并注册他,这是“数据中心”,他只负责存储数据,除此之外没有任何逻辑。

@Component()
class CounterComponent {
  public count = 0;
}
// 注册这个数据组件
app.bindComponent(CounterComponent);

最后,编写一个自己的system。他是纯静态的、不需要任何注册与调用,只需要编写他需要的参数。@Virid/core将会自己发现并在合适的时候调用它。

//定义系统
class CounterSystem {
  //默认优先级
  //无需任何操作,只要定义好后@Virid/core将会自动将system与对应的消息类型挂钩
  //当接收到对应的消息之后,@Virid/core将会注入所有需要的参数,自动执行整个system
  @System()
  static onIncrement(
    @Message(IncrementMessage) message: IncrementMessage,
    count: CounterComponent,
  ) {
    console.log("---------------------onIncrement----------------------");
    console.log("message :>> ", message);
    count.count += message.amount;
  }
   //设置一个很高的优先级
  @System(100)
  static onIncrementPriority(
    @Message(IncrementMessage) message: IncrementMessage,
    count: CounterComponent,
  ) {
    console.log(
      "---------------------onIncrementPriority----------------------",
    );
    console.log("message :>> ", message);
    count.count += message.amount;
  }
}

在任何地方,只要发送消息,onIncrement将会被自动调用。而且由于帧双缓冲机制,其天然自带防抖功能。

IncrementMessage.send(1);//这个消息将会被合并(如果使用EventMessage派生则不会被合并)
IncrementMessage.send(5);
//只需要发送上面的消息,CounterComponent将会被自动注入onIncrementPriority与onIncrement的调用中
//因为优先级的存在,控制台会先后显示onIncrementPriority与onIncrement的执行流程
//---------------------onIncrementPriority----------------------
//message :>>  IncrementMessage {
//  amount: 5
//}
//---------------------onIncrement----------------------
//message :>>  IncrementMessage {
//  amount: 5
//}

通过这种方式,业务逻辑、UI、数据三者能够彻底解耦,我们将不会再需要Vue做任何事情来介入业务,只要触发一个合适的信号,所有的系统将会自动合适的调用,并且调度系统将会严格保证执行的先后顺序。通过这样的设计,配合几个生命周期钩子。可以轻而易举的实现undo/redo与消息跟踪功能,这是在普通的Vue中难以做到的事。

由于 System 和 Component 都是纯粹的逻辑和数据,你可以在完全不启动浏览器、不渲染 Vue 组件的情况下,对业务逻辑进行 100% 的单元测试


解决了业务逻辑放和数据在哪儿的问题,剩下的就是解决与Vue之间的黏合问题。如何利用Vue的响应式和各种API,优雅的让我们的核心数据投影到UI上?在这个过程中,我创造了@virid/vue和大量的核心概念。

要控制Vue,我们需要一个“代理人”(Controller)来做这件事。让他负责充当ViridVue之间的沟通人。他将会全权接管Vue的所有操作,并统一转发给System。于是,Vue文件中将会只剩下一行script代码(以一个音乐列表的播放为例)。

<template>
  <div>
    <div>This is a playlist page with many songs</div>
    <div v-for="(item, index) in plct.playlist" :key="item.id">
      <Song :index="index"></Song>
    </div>
  </div>
</template>
<script setup lang="ts">
  import Song from "./Song.vue";
  import { useController } from "@virid/vue";
  import { PlaylistController } from "@/logic/controllers/playListController";
  const plct = useController(PlaylistController, { id: "playlist" });
</script>
<style lang="scss" scoped></style>

在普通的Vue中,业务逻辑与UI逻辑往往掺杂在一起,但是在Virid的核心调度之下我们拥有了一个全新的选择:让Vue永远只负责UI的显示与绘制,将业务逻辑转交给@Virid/core

为了兼容响应式,我引入了响应式装饰器@Responsive(),只要给任何变量打上这个装饰器,当我们访问的时候,其将会被Virid自动转换成Vue的响应式变量。这意味着我们可以直接告诉Virid,那些变量是需要响应式的。

@Component()
export class PlaylistComponent {
  // 当前正在播放的歌,第一次访问时将会被Virid转化为响应式变量
  @Responsive()
  public currentSong: Song = null!
  // 歌单列表,第一次访问时将会被Virid转化为响应式变量
  @Responsive()
  public playlist: Song[] = []
}

@Project()是一个非常强大的“桥梁”。使得Controller能够直接访问任何Component上的属性,同时将其转化为只读的。这意味着一个Controller能够任意观察Component中的数据,从而更新Vue组件,同时只读保证了Component数据的安全。

@Listener()装饰器用于“偷听”一个消息,但是与System不同的是,其只能偷听一种派生自ControllerMessage类型的消息,并且无法享受依赖注入的功能,这意味着一个Controller不能直接更改Component

@OnHook('onSetup')装饰器告诉Virid,需要在Vue的什么生命周期自动调用下面这个方法。Virid将会在合适的时机自动调用被修饰的方法。

@Watch()是一个在Vue原版上,融合了Virid特点的更强大的功能,其不仅能够检测Controller自身响应式变量的变化。还能够监测任意一个Component上的变量。但是,因为**@Watch()**中只能更改Controller自身的变量,因此其仍然无法修改任何Component

export class SongControllerMessage extends ControllerMessage {
  //到底是哪一首歌发来的消息?索引
  constructor(public readonly index: number) {
    super()
  }
}

@Controller()
export class PlaylistController {
   //告诉Virid自动将playlist变为响应式的
  @Responsive()
  public playlist!: Song[]
    
  //创建一个投影,从component中映射数据
  @Project(PlaylistComponent, (i) => i.currentSong)
  public currentSong!: Song

  @Listener(SongControllerMessage)
  onMessage(@Message(SongControllerMessage) message: SongControllerMessage) {
    console.log('song', this.playlist[message.index])
    //可以做一些操作统一拦截,或者直接调用播放器
    PlaySongMesage.send(this.playlist[message.index])
  }
    
  @OnHook('onSetup')
  async getPlaylist() {
    //在这里可以获取数据,例如从服务器获取数据,这里模拟一下
    await new Promise((resolve) => setTimeout(resolve, 1000))
    this.playlist = [
      new Song('歌曲1', '1'),
      new Song('歌曲2', '2'),
      new Song('歌曲3', '3'),
      new Song('歌曲4', '4'),
      new Song('歌曲5', '5'),
      new Song('歌曲6', '6'),
      new Song('歌曲7', '7')
    ]
  }
  //观测当前歌曲,如果变了就触发某些操作
  @Watch(PlaylistComponent, (i) => i.currentSong, {})
  watchCurrentSong() {
    console.log('监听到当前歌曲改变PlaylistComponent:', this.currentSong)
  }
}

对于每一首歌,我们同样需要创建一个对应的Controller来充当我们和Virid的代理人,但是与此同时,每一个Song组件也需要和父Playlist组件通讯。因此我创建了一些更强大工具。

在.Vue文件中,我们传递了这样的变量,但是!**我们只传递了Song组件的索引,并没有传递item本身。**因此,我们需要某种方式获得index,并且还要能够访问到父组件的属性。

<div v-for="(item, index) in plct.playlist" :key="item.id">
  <Song :index="index"></Song>
</div>

@Env()是一个用于标记的标记装饰器。当你在子组件的Controller中标记一个属性为 @Env()时,Virid将会负责将其安装到这个属性上,这意味着你不需要自己定义props,按需声明取用即可

@Inherit()是一个类似@Project()的工具,如果说@Project()ControllerComponent之间的只读桥梁。那么@Inherit()就是ControllerController之间的只读桥梁。@Inherit 彻底终结了前端组件通信中冗长的 Emit/Props 链路。它建立了一个虫洞,让子组件可以直接观察到远方父组件的状态的同时,无法对父组件产生任何副作用污染。

通过@Inherit()你可以从任意组件内“继承”任意Controller的状态,同时,他也是只读的,这保证了一个Controller永远无法偷偷修改另一个Controller中数据的权利,当另一个Controller因为组件卸载而销毁的时候,这样的连接将会自动断开,类似于一种WeakRef。

通过@Inherit()@Project(),我们可以实现非常强大的功能,不需要父组件给我们提供任何数据,Song将会自己知道哪个数据才是自己应该得到的。

@Controller()
export class SongController {
  @Env()
  public index!: number
  @OnHook('onSetup')
  public onSetup() {
    console.log('我的索引是:', this.index)
  }
  @Inherit(PlaylistController, 'playlist', (i) => i.playlist)
  public playlist!: Song[]

  @Project<SongController>((i) => i.playlist?.[i.index])
  public song!: Song

  playThisSong() {
    //其实直接播放也行,但是这里我们模拟一下需要发送给父组件让父组件处理的情况
    console.log('发送播放消息:', this.index)
    SongControllerMessage.send(this.index)
  }
}

最终,消息将在System中得到处理,从此整个Virid将得到完整的闭环。

//当Playlist调用 PlaySongMesage.send(this.playlist[message.index])时
//整个系统将被激活,从而更新正确的数据
@System()
  static playThisSong(
    @Message(PlaySongMesage) message: PlaySongMesage,
    playlist: PlaylistComponent,
    player: PlayerComponent
  ) {
    //把这首歌添加到playlist里,如果没有的话
    playlist.playlist.push(message.song)

    //开始播放这首歌
    playlist.currentSong = message.song
    player.player.play(message.song)
    //自动发送新消息,记录
    return new IncreasePlayNumMessage()
  }

Virid 不是为了消灭 Vue,而是为了解决业务逻辑被耦合在UI中的问题。它可能不适合所有的 Todo-list,但它一定适合那些让你夜不能寐的复杂系统。

项目地址:github.com/ArisuYuki/v…

Flutter ——流式布局(Wrap)

作者 Haha_bj
2026年2月6日 23:06

Flutter 中 Wrap组件是解决 Row/Column 溢出问题的另一种重要方案,下面从核心作用、基础用法、核心属性、实战场景和对比 Row 这几个方面,给你做全面且易懂的讲解。

一、Wrap 核心作用

Wrap 是流式布局组件,和 Row/Column 最大的区别是:

  • Row/Column 子组件总尺寸超过父容器时会溢出(出现警告);
  • Wrap 子组件总尺寸超过父容器时会自动换行 / 换列,不会溢出。

简单说:Wrap 就是 “可以自动换行的 Row” 或 “可以自动换列的 Column”。

二、基础用法

先看一个最基础的示例,直观感受 Wrap 的效果:

lass MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text("哈哈"),
        ),
        body: Padding(padding: EdgeInsetsGeometry.all(10),
          child: Wrap(
            children: [
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('1')),
              ),
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('2')),
              ),
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('3')),
              ),
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('4')),
              ),
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('5')),
              ),
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('6')),
              ),
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('7')),
              ),
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('8')),
              )
            ],
          ),
        ),
      ),
    );
  }
}
图片.png

效果:8 个 80x80 的容器会先在第一行排列,当剩余宽度不够放下下一个容器时,自动换行到第二行,完全适配父容器宽度,无溢出。

三、核心属性详解

Wrap 的属性和 Row/Column 高度相似,但新增了换行相关的属性:

属性 作用 常用值
direction 排列方向(主轴) Axis.horizontal(默认,水平)/ Axis.vertical(垂直)
alignment 主轴方向的对齐方式(单行 / 列的对齐) WrapAlignment.start(默认)/ center/ end/ spaceBetween/ spaceAround/ spaceEvenly
crossAxisAlignment 交叉轴方向的对齐方式(行 / 列之间的对齐) WrapCrossAlignment.start(默认)/ center/ end
runAlignment 多行 / 多列整体的对齐方式 WrapAlignment.start(默认)/ center/ end/ 等
spacing 主轴方向子组件之间的间距 数值(如 8.0)
runSpacing 交叉轴方向(行 / 列之间)的间距 数值(如 8.0)
children 子组件列表 Widget 数组

关键属性实战示例

Wrap(
  direction: Axis.horizontal, // 水平排列
  alignment: WrapAlignment.spaceBetween, // 单行内两端对齐
  runAlignment: WrapAlignment.center, // 多行整体居中
  crossAxisAlignment: WrapCrossAlignment.center, // 行内垂直居中
  spacing: 10, // 水平子组件间距 10
  runSpacing: 15, // 行与行之间的间距 15
  children: [
    Container(width: 70, height: 70, color: Colors.red),
    Container(width: 70, height: 80, color: Colors.green),
    Container(width: 70, height: 70, color: Colors.blue),
    Container(width: 70, height: 70, color: Colors.yellow),
    Container(width: 70, height: 70, color: Colors.purple),
    Container(width: 70, height: 70, color: Colors.yellow),
    Container(width: 70, height: 70, color: Colors.purple),
  ],
)
图片.png

四、常见使用场景

Wrap 是 Flutter 中实现 “标签流、按钮流、网格标签” 的首选组件,以下是两个高频实战场景:

场景 1:标签列表(最经典用法)

dart

// 模拟动态标签列表
Wrap(
  spacing: 8, // 标签之间的水平间距
  runSpacing: 8, // 行之间的垂直间距
  children: [
    // 标签组件封装
    _buildTag('Flutter'),
    _buildTag('Dart'),
    _buildTag('Android'),
    _buildTag('iOS'),
    _buildTag('前端'),
    _buildTag('移动端'),
    _buildTag('跨平台'),
    _buildTag('布局'),
    _buildTag('组件'),
  ],
)

// 封装标签 Widget
Widget _buildTag(String text) {
  return Container(
    padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
    decoration: BoxDecoration(
      color: Colors.grey[200],
      borderRadius: BorderRadius.circular(20),
    ),
    child: Text(text),
  );
}

五、Wrap vs Row/Column 核心对比

特性 Wrap Row/Column
溢出处理 自动换行 / 换列,无溢出 直接溢出,出现警告
空间占用 仅包裹子组件(mainAxisSize 固定为 min) 可设置 max/min,默认 max
适用场景 动态数量的子组件(标签、按钮) 固定数量的子组件(导航栏、表单行)
性能 略优(无需计算溢出) 需计算主轴空间,溢出时性能无影响

六、常见问题与注意事项

  1. Wrap 中使用 Expanded 无效:Expanded 是配合 Flex(Row/Column)的弹性布局组件,Wrap 不支持弹性分配空间,因此在 Wrap 的 children 中用 Expanded 不会有任何效果。

  2. 控制 Wrap 整体的宽度 / 高度:如果想让 Wrap 占满父容器宽度(而非仅包裹子组件),可以给 Wrap 包裹一个 Container 并设置宽度:

    dart

    Container(
      width: double.infinity, // 占满父容器宽度
      child: Wrap(/* ... */),
    )
    

总结

  1. 核心定位:Wrap 是流式布局,解决 Row/Column 溢出问题,子组件超出父容器时自动换行 / 换列。

  2. 核心属性spacing(子组件间距)、runSpacing(行 / 列间距)、direction(排列方向)是最常用的三个属性。

  3. 使用技巧

    • 动态数量的标签、按钮优先用 Wrap;
    • Wrap 不支持 Expanded,无需尝试弹性分配空间;
    • 控制间距优先用 spacing/runSpacing,而非子组件的 margin(更统一)。

掌握 Wrap 布局,就能轻松实现 Flutter 中绝大多数 “流式排列” 的 UI 场景,是替代 Row/Column 解决溢出问题的最佳选择。

❌
❌