阅读视图

发现新文章,点击刷新页面。

上海将设立AI青年创业基金

上海市人民政府主题记者会2月7日举行。上海市经济信息化委主任汤文侃表示,在具身智能领域,上海已有近40款人形机器人从实验室走进日常生活、赋能实体经济。超过三分之一的海外回国人才优先选择在上海创业就业。上海将提供高效服务,打造AI国际开源社区,设立AI青年创业基金,支持年轻创业者在上海发展。(界面)

千问App宣布即日起免单范围扩至盒马

2月7日,千问App提示,用户也可以使用“免单卡”在千问内购买盒马的居家日用、水果生鲜、零食酒水、粮油米面,无论买鱼、买菜还是买蛋糕,最快30分钟就能送到家门口。用户只需对千问说“帮我在盒马买一条鲫鱼”或者“帮我在盒马买一盒鸡蛋”,千问就可以从盒马的海量商品中挑出适合的商品,并根据用户位置匹配附近的盒马门店,在用户下单后即时配送。(新浪财经)

我国成功发射可重复使用试验航天器

今日,我国在酒泉卫星发射中心使用长征二号F运载火箭,成功发射一型可重复使用试验航天器。试验航天器将按计划开展可重复使用试验航天器技术验证,为和平利用太空提供技术支撑。(财联社)

法国对美出口去年四季度显著下滑

法国海关6日发布的数据显示,受美国关税政策及汇率因素影响,2025年第四季度法国对美国出口下滑趋势明显加剧。 数据显示,去年第四季度,除航空业外,法国对美出口规模同比下降13%。其中烈酒出口额下降47%,葡萄酒下降39%,香水和化妆品下降25%,皮具下降15%。海关部门指出,法国对美出口下滑反映出在美国关税措施冲击下,法国出口持续承压。(新华社)

日本免税销售额持续下滑

日本各大百货公司1月份的免税销售额再度下滑,凸显出随着中日间的紧张关系未见缓和迹象,该国零售业正持续承受压力。高岛屋的报告称,受中国游客减少赴日旅行的影响,其免税销售额下降19%,尽管同店销售额增长7.4%。J. Front零售公司则表示,其大丸和松坂屋门店的免税销售额下降约17%,导致整体销售额增长仅为0.7%。因1月份中国游客减少,来自中国客户的销售额暴跌约60%,拖累了H2O零售公司的整体销售额增长,导致其增幅仅为4.2%。三越伊势丹公司的报告称,去年12月其国内门店的免税销售额下降15%,导致总销售额增长仅为2.1%。松屋公司报告称,因缺少中国客户,其银座旗舰店上个月的免税销售额下降约16%。 (参考消息)

LABUBU全年销量超1亿只

2月7日上午消息,昨日,泡泡玛特举办2026年年会。会上,泡泡玛特透露了过去一年的年度核心数据。数据显示,2025年,泡泡玛特全球员工伙伴超1万人,全球注册会员超1亿人。值得注意的是,泡泡玛特还透露,LABUBU全年销量超1亿只,全品类全IP产品销量超4亿只。此外,泡泡玛特业务已覆盖100+国家和地区,全球门店超700家,拥有6大供应链基地,并赋能超20万个就业岗位。(新浪财经)

国家外汇局:截至1月末我国外汇储备规模为33991亿美元,环比上升1.23%

国家外汇管理局统计数据显示,截至2026年1月末,我国外汇储备规模为33991亿美元,较2025年12月末上升412亿美元,升幅为1.23%。2026年1月,受主要经济体财政政策、货币政策及预期等因素影响,美元指数下跌,全球主要金融资产价格总体上涨。汇率折算和资产价格变化等因素综合作用,当月外汇储备规模上升。我国经济运行稳中有进,发展韧性进一步彰显,为外汇储备规模保持基本稳定提供支撑。(界面新闻)

去中心化预测市场实战开发: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 {}
}

测试脚本

测试用例

  1. Minting (铸造头寸)
  2. Resolution & Redemption (结算与兑付)
  3. 当价格超过目标时,YES 持有者应能兑现
  4. 未结算前不应允许兑现
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 生成单元测试"

自动分析代码,生成完整测试文件。

为什么这么快

  1. 无 GUI 开销 - 纯命令行,没有渲染负担
  2. 按需加载 - 只加载需要的工具
  3. 流式响应 - 边生成边输出,不等全部完成
  4. 轻量设计 - 核心只有几 MB

20+ 内置工具

Blade Code 不只是个 AI 对话工具,它内置了 20+ 实用工具:

  • 文件操作:读、写、搜索、批量处理
  • 代码分析:AST 解析、依赖分析
  • Shell 执行:安全的命令执行
  • Git 集成:提交、分支、历史查询
  • Web 搜索:实时查询最新信息

安全设计

很多人担心 AI 工具会误删代码。Blade Code 有三层保护:

  1. 权限控制 - 危险操作需要确认
  2. 工具白名单 - 只能用预定义的工具
  3. 操作日志 - 所有操作可追溯

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 开源。

项目地址:github.com/echoVic/bla…

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

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

央行连续第15个月增持黄金

央行发布数据,中国2026年1月末黄金储备报7419万盎司,2025年12月末为7415万盎司,为连续第15个月增持黄金。(华尔街见闻)

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

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

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

前言

在处理海量数据渲染(如万级甚至十万级列表)时,直接操作 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 做异步批处理。

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

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

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

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

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

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

这 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 文件,用起来非常省心。

网站: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应用和工具的分享。关注我,少走弯路,一起进步!

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

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

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

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