阅读视图
千问App宣布即日起免单范围扩至盒马
我国成功发射可重复使用试验航天器
法国对美出口去年四季度显著下滑
日本免税销售额持续下滑
LABUBU全年销量超1亿只
国家外汇局:截至1月末我国外汇储备规模为33991亿美元,环比上升1.23%
去中心化预测市场实战开发:Solidity+Viem 从合约设计到工程化落地
前言
在 Web3 生态迈向 2026 年的新阶段,去中心化预测市场(Decentralized Prediction Market,DPM)早已突破单纯的博弈工具属性,成为融合群体智慧价格发现、去中心化金融对冲、现实世界事件价值映射的核心 DeFi 组件。与中心化预测平台相比,基于智能合约的 DPM 凭借链上透明、无需信任、资产自管的特性,成为 Web3 落地现实应用的重要载体。
本文将从底层核心逻辑出发,拆解去中心化预测市场的设计精髓,通过Solidity实现可落地的智能合约架构,并结合 Web3 开发新标椎Viem完成工程化测试,同时给出从基础版本到生产级应用的优化方向与技术栈选型,让开发者能够快速上手并搭建属于自己的去中心化预测市场。
一、核心定位与底层逻辑
1. 市场定位
2026 年的 DPM 已突破博弈属性,成为融合群体智慧价格发现、去中心化金融对冲、现实世界事件价值映射的核心 DeFi 组件,核心优势是链上透明、无需信任、资产自管。
2. 底层核心法则:抵押品守恒(1=1+1)
所有设计围绕 “1 单位抵押品 = 1 份 Yes 代币 + 1 份 No 代币” 展开,分三个核心环节:
| 环节 | 核心动作 | 价值逻辑 |
|---|---|---|
| 资产对冲 | 用户存入抵押品,合约 1:1 铸造 Yes/No 结果代币 | 抵押品等价于 Yes+No 代币组合,单一代币成为投注头寸 |
| 概率定价 | Yes/No 代币二级市场自由交易 | 代币价格直接反映市场对事件结果的概率预期(如 Yes=0.7ETH→70% 发生概率) |
| 最终结算 | 预言机提交结果,胜出代币 = 1 单位抵押品,失败代币归零 | 抵押品总量不变,用户销毁胜出代币赎回抵押品 |
二、技术实现(Solidity+Viem)
智能合约
IOutcomeToken接口合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IOutcomeToken is IERC20 {
function mint(address to, uint256 amount) external;
function burn(address from, uint256 amount) external;
}
OutcomeToken(结果代币)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "./IOutcomeToken.sol";
contract OutcomeToken is ERC20, IOutcomeToken {
address public immutable market;
error OnlyMarketAllowed();
constructor(
string memory name,
string memory symbol,
address _market
) ERC20(name, symbol) {
market = _market;
}
modifier onlyMarket() {
if (msg.sender != market) revert OnlyMarketAllowed();
_;
}
function mint(address to, uint256 amount) external onlyMarket {
_mint(to, amount);
}
function burn(address from, uint256 amount) external onlyMarket {
_burn(from, amount);
}
}
MockV3Aggregator(预言机合约):在本地开发和测试环境中部署模拟合约,而在正式生产环境的项目中,则使用 Chainlink 提供的 MockV3Aggregator 合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract MockV3Aggregator4 {
int256 private _price;
constructor(int256 initialPrice) { _price = initialPrice; }
function updatePrice(int256 newPrice) external { _price = newPrice; }
function latestRoundData() external view returns (uint80, int256 price, uint256, uint256, uint80) {
return (0, _price, 0, 0, 0);
}
}
PredictionMarket(预测市场合约)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
import "./OutcomeToken.sol";
contract PredictionMarket is Ownable, ReentrancyGuard {
IOutcomeToken public immutable yesToken;
IOutcomeToken public immutable noToken;
AggregatorV3Interface public immutable priceFeed;
uint256 public immutable targetPrice;
bool public resolved;
bool public winningOutcome; // true = Yes, false = No
event MarketMinted(address indexed user, uint256 amount);
event MarketResolved(bool winningOutcome, int256 finalPrice);
event Redeemed(address indexed user, uint256 amount);
error AlreadyResolved();
error NotResolved();
error InsufficientBalance();
constructor(
address _priceFeed,
uint256 _targetPrice
) Ownable(msg.sender) {
priceFeed = AggregatorV3Interface(_priceFeed);
targetPrice = _targetPrice;
// 实例化 Outcome 代币
yesToken = new OutcomeToken("Predict YES", "YES", address(this));
noToken = new OutcomeToken("Predict NO", "NO", address(this));
}
/**
* @notice 存入 ETH 铸造 1:1 的 Yes 和 No 头寸
*/
function mintPositions() external payable nonReentrant {
if (resolved) revert AlreadyResolved();
if (msg.value == 0) revert InsufficientBalance();
yesToken.mint(msg.sender, msg.value);
noToken.mint(msg.sender, msg.value);
emit MarketMinted(msg.sender, msg.value);
}
/**
* @notice 调用 Chainlink 获取价格并结算市场
*/
function resolveMarket() external onlyOwner {
if (resolved) revert AlreadyResolved();
(, int256 price, , , ) = priceFeed.latestRoundData();
// 判定逻辑:当前价 > 目标价则 Yes 赢
winningOutcome = uint256(price) > targetPrice;
resolved = true;
emit MarketResolved(winningOutcome, price);
}
/**
* @notice 结算后,胜方销毁代币取回 1:1 的 ETH
*/
function redeem(uint256 amount) external nonReentrant {
if (!resolved) revert NotResolved();
if (winningOutcome) {
yesToken.burn(msg.sender, amount);
} else {
noToken.burn(msg.sender, amount);
}
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "Transfer failed");
emit Redeemed(msg.sender, amount);
}
receive() external payable {}
}
测试脚本
测试用例:
- Minting (铸造头寸)
- Resolution & Redemption (结算与兑付)
- 当价格超过目标时,YES 持有者应能兑现
- 未结算前不应允许兑现
import assert from "node:assert/strict";
import { describe, it, beforeEach } from "node:test";
import { parseEther, formatEther } from 'viem';
import { network } from "hardhat";
describe("PredictionMarket", function () {
let market: any, mockOracle: any;
let yesToken: any, noToken: any;
let publicClient: any;
let owner: any, user1: any;
let deployerAddress: string;
const TARGET_PRICE = 3000n * 10n**8n; // 假设目标价 3000 (8位小数)
const INITIAL_PRICE = 2500n * 10n**8n; // 初始价 2500
beforeEach(async function () {
const { viem } = await (network as any).connect();
publicClient = await viem.getPublicClient();
[owner, user1] = await viem.getWalletClients();
deployerAddress = owner.account.address;
// 1. 部署 Mock 预言机 (初始价格 2500)
mockOracle = await viem.deployContract("MockV3Aggregator4", [INITIAL_PRICE]);
// 2. 部署预测市场主合约
market = await viem.deployContract("PredictionMarket", [
mockOracle.address,
TARGET_PRICE
]);
// 3. 获取生成的 YES/NO 代币合约实例
const yesAddr = await market.read.yesToken();
const noAddr = await market.read.noToken();
yesToken = await viem.getContractAt("OutcomeToken", yesAddr);
noToken = await viem.getContractAt("OutcomeToken", noAddr);
console.log(`市场部署成功: ${market.address}`);
});
describe("Minting (铸造头寸)", function () {
it("应该允许用户存入 ETH 并获得 1:1 的 Yes/No 代币", async function () {
const mintAmount = parseEther("1");
// 执行铸造
const hash = await market.write.mintPositions({
value: mintAmount,
account: user1.account
});
await publicClient.waitForTransactionReceipt({ hash });
// 检查余额
const userYesBalance = await yesToken.read.balanceOf([user1.account.address]);
const userNoBalance = await noToken.read.balanceOf([user1.account.address]);
assert.equal(userYesBalance, mintAmount, "YES 代币数量不匹配");
assert.equal(userNoBalance, mintAmount, "NO 代币数量不匹配");
console.log(`用户1 成功铸造: ${formatEther(userYesBalance)} YES & NO`);
});
});
describe("Resolution & Redemption (结算与兑付)", function () {
beforeEach(async function () {
// 预先为 user1 铸造 2 ETH 的头寸
await market.write.mintPositions({
value: parseEther("2"),
account: user1.account
});
});
it("当价格超过目标时,YES 持有者应能兑现", async function () {
// 1. 模拟价格上涨至 3500 (超过目标 3000)
const newPrice = 3500n * 10n**8n;
await mockOracle.write.updatePrice([newPrice]);
// 2. 所有者结算市场
await market.write.resolveMarket();
const winningOutcome = await market.read.winningOutcome();
assert.equal(winningOutcome, true, "应该是 YES 赢");
// 3. 用户兑现 YES 代币
const redeemAmount = parseEther("2");
const balanceBefore = await publicClient.getBalance({ address: user1.account.address });
const hash = await market.write.redeem([redeemAmount], {
account: user1.account
});
await publicClient.waitForTransactionReceipt({ hash });
// 4. 检查结果
const yesBalanceAfter = await yesToken.read.balanceOf([user1.account.address]);
const balanceAfter = await publicClient.getBalance({ address: user1.account.address });
assert.equal(yesBalanceAfter, 0n, "兑现后代币应销毁");
assert.ok(balanceAfter > balanceBefore, "用户余额应增加 (忽略 Gas)");
console.log("✅ YES 胜出,用户成功兑回 ETH");
});
it("未结算前不应允许兑现", async function () {
await assert.rejects(
market.write.redeem([parseEther("1")], { account: user1.account }),
/NotResolved/,
"未结算时不应允许 redeem"
);
});
});
});
部署脚本
// scripts/deploy.js
import { network, artifacts } from "hardhat";
async function main() {
// 连接网络
const { viem } = await network.connect({ network: network.name });//指定网络进行链接
// 获取客户端
const [deployer] = await viem.getWalletClients();
const publicClient = await viem.getPublicClient();
const deployerAddress = deployer.account.address;
console.log("部署者的地址:", deployerAddress);
// 加载合约
const MockV3AggregatorArtifact = await artifacts.readArtifact("MockV3Aggregator4");
const PredictionMarketArtifact = await artifacts.readArtifact("PredictionMarket");
const TARGET_PRICE = 3000n * 10n**8n; // 假设目标价 3000 (8位小数)
const INITIAL_PRICE = 2500n * 10n**8n; // 初始价 2500
// 部署(构造函数参数:recipient, initialOwner)
const MockV3AggregatorHash = await deployer.deployContract({
abi: MockV3AggregatorArtifact.abi,//获取abi
bytecode: MockV3AggregatorArtifact.bytecode,//硬编码
args: [INITIAL_PRICE],//部署者地址,初始所有者地址
});
const MockV3AggregatorReceipt = await publicClient.waitForTransactionReceipt({ hash: MockV3AggregatorHash });
console.log("预言机合约地址:", MockV3AggregatorReceipt.contractAddress);
//
const PredictionMarketHash = await deployer.deployContract({
abi: PredictionMarketArtifact.abi,//获取abi
bytecode: PredictionMarketArtifact.bytecode,//硬编码
args: [MockV3AggregatorReceipt.contractAddress,TARGET_PRICE],//部署者地址,初始所有者地址
});
// 等待确认并打印地址
const PredictionMarketReceipt = await publicClient.waitForTransactionReceipt({ hash: PredictionMarketHash });
console.log("预测市场合约地址:", PredictionMarketReceipt.contractAddress);
}
main().catch(console.error);
三、生态价值与未来展望
在 2026 年的 Web3 生态中,去中心化预测市场不仅是 DeFi 的核心组件,更是连接链上与链下世界的重要桥梁,其生态价值早已超越单纯的预测功能
1. 核心生态价值
| 价值方向 | 具体应用场景 |
|---|---|
| 群体智慧价格发现 | 金融对冲、企业决策、政策制定的概率数据支撑 |
| DeFi 生态补充 | 开发事件保险、对冲工具、合成资产等创新产品 |
| DAO 治理工具 | 为 DAO 提案提供社区预期参考,提升治理科学性 |
| RWA 价值映射 | 实现现实事件(大宗商品 / 体育赛事 / 宏观数据)的链上价值映射 |
2. 未来演进方向
- 技术层面:结合 ZKP、乐观预言机、跨链技术,提升去中心化程度、降低参与门槛;
- 待解决问题:合规性、流动性、用户教育;
- 核心目标:实现 “群体智慧的价值化”,成为 Web3 落地现实应用的核心载体。
总结
-
底层逻辑:DPM 的核心是 “1=1+1 抵押品守恒法则”,贯穿铸造、定价、结算全流程;
-
技术实现:合约采用模块化设计,结合 OpenZeppelin 保障安全,Viem 替代 Ethers 实现高效测试 / 部署;
-
生态价值:DPM 的核心价值是挖掘群体智慧、推动链上链下融合,而非单纯的博弈功能;
-
拓展性:本文代码为基础框架,可基于此拓展 AMM(提升流动性)、ERC1155(多结果支持)等生产级功能。
Cursor 500MB 太重?试试这个 5MB 的 CLI 方案
Cursor 500MB 太重?试试这个 5MB 的 CLI 方案
为什么我放弃了 Cursor
上个月团队让我试用 Cursor。下载完 500MB 安装包后,我开始怀疑人生。
启动要 10 秒,打开大项目要 30 秒,内存占用 2GB+。我只是想让 AI 帮我写个脚本,为什么要装个这么重的 IDE?
后来发现了 Blade Code。
Blade Code 是什么
一个 5MB 的 Node.js CLI 工具,专门做一件事:让 AI 快速完成编程任务。
不是 IDE,不是编辑器,就是个命令行工具。
对比数据
| 维度 | Cursor | Blade Code |
|---|---|---|
| 安装包大小 | 500MB | 5MB (npm 包) |
| 启动速度 | 10秒 | 1秒 |
| 内存占用 | 2GB+ | 50MB |
| 适用场景 | 完整开发环境 | 快速任务、脚本、自动化 |
| 学习成本 | 需要适应新 IDE | 会用命令行就行 |
| 价格 | $20/月 | MIT 开源 |
真实场景
场景 1:快速重构代码
blade "把这个文件的所有 var 改成 let/const"
3 秒完成,不用打开 IDE。
场景 2:批量处理文件
blade "把 src/ 下所有 .js 文件加上 'use strict'"
20 个文件,5 秒搞定。
场景 3:生成测试用例
blade "给 utils.ts 生成单元测试"
自动分析代码,生成完整测试文件。
为什么这么快
- 无 GUI 开销 - 纯命令行,没有渲染负担
- 按需加载 - 只加载需要的工具
- 流式响应 - 边生成边输出,不等全部完成
- 轻量设计 - 核心只有几 MB
20+ 内置工具
Blade Code 不只是个 AI 对话工具,它内置了 20+ 实用工具:
- 文件操作:读、写、搜索、批量处理
- 代码分析:AST 解析、依赖分析
- Shell 执行:安全的命令执行
- Git 集成:提交、分支、历史查询
- Web 搜索:实时查询最新信息
安全设计
很多人担心 AI 工具会误删代码。Blade Code 有三层保护:
- 权限控制 - 危险操作需要确认
- 工具白名单 - 只能用预定义的工具
- 操作日志 - 所有操作可追溯
5 分钟上手
安装
npm install -g blade-code
配置 API Key
blade config
支持 OpenAI、Claude、Gemini、国产大模型。
开始使用
blade "帮我重构这个函数"
就这么简单。
适合谁用
适合:
- 需要快速完成小任务的开发者
- 喜欢命令行的极客
- 想要轻量级 AI 工具的人
- 需要脚本化 AI 能力的场景
不适合:
- 需要完整 IDE 功能的人
- 不习惯命令行的人
- 需要图形界面的场景
和其他工具对比
vs Cursor
- Cursor:完整 IDE,适合长时间开发
- Blade Code:快速任务,适合脚本化场景
vs GitHub Copilot
- Copilot:代码补全,需要在编辑器里用
- Blade Code:独立工具,可以批量处理
vs OpenCode
- OpenCode:95K stars,功能全面但复杂
- Blade Code:专注 CLI,简单直接
开源 + 可扩展
Blade Code 是 MIT 开源的,代码在 GitHub: github.com/echoVic/bla…
支持 MCP (Model Context Protocol),可以自己写插件扩展功能。
总结
如果你觉得 Cursor 太重,需要快速完成小任务,喜欢命令行,想要免费的 AI 编程工具,试试 Blade Code。
5MB,1 秒启动,MIT 开源。
千问称春节免单活动热度远超预期,延长免单卡有效期
央行连续第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应用和工具的分享。关注我,少走弯路,一起进步!