普通视图
央行连续第15个月增持黄金
天涯社区将重启,天涯社区1999元服务包开售
虚拟列表:从定高到动态高度的 Vue 3 & React 满分实现
前言
在处理海量数据渲染(如万级甚至十万级列表)时,直接操作 DOM 会导致严重的页面卡顿甚至崩溃。虚拟列表(Virtual List) 作为前端性能优化的“核武器”,通过“只渲染可视区”的策略,能将渲染性能提升数个量级。本文将带你从零实现一个支持动态高度的通用虚拟列表。
![]()
一、 核心原理解析
虚拟列表本质上是一个“障眼法”,其结构通常分为三层:
-
外层容器(Container) :固定高度,设置
overflow: auto,负责监听滚动事件。 - 占位背景(Placeholder) :高度等于“总数据量 × 列表项高度”,用于撑开滚动条,模拟真实滚动的视觉效果。
-
渲染内容区(Content Area) :绝对定位,根据滚动距离动态计算起始索引,并通过
translateY偏移到当前可视区域。
![]()
二、 定高虚拟列表
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. 实现效果图
![]()
三、 进阶:不定高(动态高度)虚拟列表
在实际业务(如社交动态、聊天记录)中,每个 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. 实现效果图
![]()
四、 总结与避坑指南
1. 为什么需要缓冲区(BUFFER)?
如果只渲染可见部分,用户快速滚动时,异步渲染可能会导致瞬间的“白屏”。设置上下缓冲区可以预加载部分 DOM,让滑动更顺滑。
2. 性能进一步优化
-
滚动节流(Throttle) :虽然滚动监听很快,但在
handleScroll中加入requestAnimationFrame或 20ms 的节流,能有效减轻主线程压力。 -
Key 的选择:在虚拟列表中,
key必须是唯一的id,绝对不能使用index,否则在滚动重用 DOM 时会出现状态错乱。
3. 注意事项
- 定高:逻辑简单,性能极高。
-
不定高:依赖
ResizeObserver,需注意频繁重排对性能的影响,建议对updateCumulativeHeights做异步批处理。
春节期间“不打烊” 多家快递企业将继续提供收派服务
Anthropic最快下周完成超200亿美元融资
国家税务总局:2月25日起可预约办理2025年度个税汇算
这 7 个免费 Lottie 动画网站,帮你省下一个设计师的工资
大家好,我是大华!
有时候写前端,会觉得:同样是写页面,为什么有些产品一看就很舒服,而自己写的界面,怎么看都觉得很笨重。
是他们用了什么高深的技术吗?
其实那些看起来很好看,很丝滑的动画,大多数项目只是用了 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 文件,用起来非常省心。
![]()
2. IconScout
IconScout 本身就是一个大型设计素材站,其中的免费 Lottie 动画覆盖了大量 UI 场景,从加载动画到角色动效都有,风格也比较多样。
![]()
3. Storyset(Freepik 出品)
Storyset 提供可在浏览器中直接编辑的插画和 Lottie 动画,你可以自己改颜色、搭配元素,然后导出。即使完全不懂设计,也能做出“像定制过”的效果。
![]()
4. LottieFlow
由 Webflow 社区维护的免费 Lottie 库,主打无水印、无会员限制。 动画以 Web 场景为主,但 JSON 文件在任何项目中都能用。
![]()
其他可选资源
如果你想多囤一些风格不同的动画,这些也可以顺手收藏:
- Creattie:creattie.com/
- Lordicon:lordicon.com/
- Lottielab:lottielab.com/
Lottie 真正解决的,其实是页面体验的问题。 它让动画变得轻量、可控,也大幅降低了开发和设计之间的协作成本。
你不需要是 After Effects 高手,也不用写复杂的动画逻辑。 很多时候,只是下载一个 JSON 文件,放进项目里,页面立刻就会多一点质感。
本文首发于公众号:程序员大华,专注前端、Java开发,AI应用和工具的分享。关注我,少走弯路,一起进步!
黄仁勋:人工智能领域的资本支出是合理且可持续的
OpenAI与G42洽谈,拟为阿联酋打造专属ChatGPT
苹果拟允许第三方语音控制AI应用接入CarPlay
为什么有的函数要用 call,有的却完全不用?
——从 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.xxxArray.xxxMath.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中非响应式的全局状态管理
在vue项目中,一般说到状态管理,我们会想到pinia,它可以帮助我们管理需要在多个页面、组件间共享的数据,并且根据数据的更新触发相关的渲染更新。但如果是数据变化不会引起页面刷新的全局数据呢?
比如我当前开发的项目中,需要在项目初始化之后获取对应的引擎实例,该实例提供相关
api用于处理页面逻辑,但该实例并不会触发页面的更新,此时就需要一个 非响应式的全局状态管理 --globalState
适用场景:
- 全局配置、缓存、临时数据
- 跨页面/组件的事件通知
- 不需要响应式的数据共享
不适用场景
- 需要数据变化时自动刷新页面的场景【用
Pinia或Vuex】
逻辑梳理:
- 定义一个
globalState类,全局只有一个实例 - 维护一份
state数据,提供set、get、has、delete、clear - 提供事件总线功能,用于在不同组件间“广播消息”:
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()
使用
- 组件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");
});
- 组件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 发布了
Lupine.js:一款"极其"高效的 Web 框架
在一个被庞大的元框架 (Meta-frameworks) 和复杂构建链主导的世界里,Lupine.js 提出了一个简单的问题:如果我们能拥有现代全栈框架的威力,却不需要那些臃肿的负担,会怎样?
Lupine.js 是一个 轻量级 (7kb gzipped) 的 全栈 Web 框架,它结合了类 React 的前端体验和类 Express 的后端架构。它是完全从零开始设计,旨在实现极致的速度、简洁和高效。
![]()
为什么选择 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 泛型从轻松入门到看懂源码
从「完全不懂泛型」一路走到「看懂下面这段代码到底在干嘛」:
//此代为为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>这一整条链路往下追
这就已经是非常扎实的泛型理解了。
总结
- 泛型的核心是「给类型加参数」,使用时再指定具体类型,如
Array<string>、VxeGridProps<RowVO>; - 嵌套泛型的本质是「类型参数本身也是泛型」,参数会逐层传递替换(
D→RowVO);
以上便是对泛型的分享,欢迎大家指正讨论,与大家共勉。
从Vue到Bevy与Nestjs:我为 Vue 构建了一套“无头”业务引擎
不知道大家是否见过那种动辄几千行、逻辑像乱麻一样缠绕的 .vue 文件?
笔者在许多开源项目和企业级项目里都见过类似的现象:各种 watch 互相套娃、生命周期里塞满异步逻辑、父子组件传值传到怀疑人生。当项目进入中后期,Vue 的响应式系统仿佛从‘利器’变成了‘诅咒’,每一行代码的改动都像是在玩扫雷。
这种“面条代码”的泛滥让我开始反思:当下的前端开发范式,真的能支撑起当今逻辑爆炸的复杂应用吗?
起初,我以为这种混乱只是人为因素——觉得只要通过规范的 Code Review、靠着开发者的自觉,就能压制住代码的腐烂。但随着项目规模的膨胀,我推翻了自己的想法。我发现 Vue 的 API 仿佛自带一种传染性。
只要你的业务代码中还直接调用着 ref、watch、onMounted这些Vue最核心的功能,业务逻辑就不可避免地会向 UI 框架低头成为UI的附庸。今天为了省事顺手写下的每一个 watch和computed,都是为未来的“谁改了我的变量”埋下伏笔。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)来做这件事。让他负责充当Virid与Vue之间的沟通人。他将会全权接管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()是Controller与Component之间的只读桥梁。那么@Inherit()就是Controller与Controller之间的只读桥梁。@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,但它一定适合那些让你夜不能寐的复杂系统。
Flutter ——流式布局(Wrap)
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')),
)
],
),
),
),
);
}
}
效果: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),
],
)
四、常见使用场景
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 |
| 适用场景 | 动态数量的子组件(标签、按钮) | 固定数量的子组件(导航栏、表单行) |
| 性能 | 略优(无需计算溢出) | 需计算主轴空间,溢出时性能无影响 |
六、常见问题与注意事项
-
Wrap 中使用 Expanded 无效:Expanded 是配合 Flex(Row/Column)的弹性布局组件,Wrap 不支持弹性分配空间,因此在 Wrap 的 children 中用 Expanded 不会有任何效果。
-
控制 Wrap 整体的宽度 / 高度:如果想让 Wrap 占满父容器宽度(而非仅包裹子组件),可以给 Wrap 包裹一个 Container 并设置宽度:
dart
Container( width: double.infinity, // 占满父容器宽度 child: Wrap(/* ... */), )
总结
-
核心定位:Wrap 是流式布局,解决 Row/Column 溢出问题,子组件超出父容器时自动换行 / 换列。
-
核心属性:
spacing(子组件间距)、runSpacing(行 / 列间距)、direction(排列方向)是最常用的三个属性。 -
使用技巧:
- 动态数量的标签、按钮优先用 Wrap;
- Wrap 不支持 Expanded,无需尝试弹性分配空间;
- 控制间距优先用
spacing/runSpacing,而非子组件的 margin(更统一)。
掌握 Wrap 布局,就能轻松实现 Flutter 中绝大多数 “流式排列” 的 UI 场景,是替代 Row/Column 解决溢出问题的最佳选择。