阅读视图

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

草稿

使用 基本界面 需求文档 设计文档 任务拆解 执行任务 最终产物包含文档记录和代码以及静态资源文件 效果对比 kiro 完成效果 对比 trae 参照官方文档资料使用 spec kit 完成效果。 代

🎯 从零搭建一个 React Todo 应用:父子通信、状态管理与本地持久化全解析!

“写 Todo 是程序员的成人礼。”

如果你刚刚入坑 React,或者想巩固组件通信、状态提升、本地存储等核心概念,那么恭喜你!这篇文章将带你手把手打造一个功能完整、结构清晰、代码优雅的 React Todo 应用,并深入浅出地解释背后的原理。

更重要的是——我们不用 Redux、不用 Context、不用任何花里胡哨的库,只用 React 原生 Hooks + 父子通信,就能写出可维护、可扩展的代码!


🧠 为什么 Todo 应用值得认真对待?

别小看这个“加任务、删任务、标记完成”的小玩意儿。它完美涵盖了现代前端开发的三大核心问题:

  1. 状态管理(谁持有数据?谁修改数据?)
  2. 组件通信(父子怎么传?兄弟怎么聊?)
  3. 副作用处理(比如自动保存到 localStorage)

而 React 的哲学是:状态提升 + 单向数据流。听起来高大上?其实很简单——让父组件当“管家”,子组件只负责“汇报”和“展示”


🏗️ 项目结构预览

我们的应用由三个子组件构成:

  • TodoInput:输入新任务
  • TodoList:展示并操作任务列表
  • TodoStats:显示统计信息 & 清除已完成

它们都共享同一个状态:todos[]。这个数组由父组件 App 统一管理,并通过 props 传递给子组件。

✨ 这就是“状态提升”(Lifting State Up)的经典实践!


🔌 父子通信:React 的“单向数据流”哲学

👨‍👧 父 → 子:通过 props 传递数据

<TodoList 
  todos={todos} 
  onDelete={deleteTodo}
  onToggle={toggleTodo}
/>

父组件把 todos 数组和几个修改函数作为 props 传给子组件。子组件只能读,不能改——就像孩子只能看菜单,不能自己进厨房炒菜。

👧‍→👨 子 → 父:通过回调函数“打报告”

子组件想修改数据?必须调用父组件传来的函数:

// 在 TodoInput 中
onAdd(inputValue); // 相当于:“爸,我想加个任务!”

// 在 TodoList 中
onToggle(todo.id); // “爸,这个任务我搞定了!”

这种模式确保了数据流向清晰、可预测,避免了“状态混乱”的噩梦。

💡 小贴士:React 不支持 Vue 那样的 v-model 双向绑定,因为它认为“显式优于隐式”。虽然多写两行代码,但逻辑更透明!


🧩 兄弟组件如何“隔空对话”?

TodoInputTodoList 是兄弟,它们之间没有直接通信!所有交互都通过共同的父组件 App 中转:

  1. TodoInput 调用 onAdd → 父组件更新 todos
  2. 父组件把新 todos 传给 TodoList → 列表自动刷新

这就是所谓的 “间接通信” ——看似绕路,实则解耦。兄弟组件互不依赖,未来拆分或替换都超轻松!


💾 自动保存到 localStorage:useEffect 的妙用

用户辛辛苦苦加了一堆任务,结果一刷新全没了?那可不行!

我们用 useEffect 监听 todos 变化,自动存到本地:

useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

同时,初始化时从 localStorage 读取:

const [todos, setTodos] = useState(() => {
  const saved = localStorage.getItem('todos');
  return saved ? JSON.parse(saved) : [];
});

🎉 用户体验瞬间拉满:关掉浏览器再打开,任务还在!妈妈再也不用担心我丢三落四了~


🎨 样式方案:Stylus + Vite,简洁又高效

我们用 Stylus 写样式(缩进语法,少写大括号),配合 Vite 极速构建。.styl 文件清爽易读:

.todo-app
  max-width: 600px
  margin: 0 auto
  padding: 20px
  
  .completed
    text-decoration: line-through
    color: #888

Vite 的 HMR(热更新)快如闪电,改一行样式,浏览器秒级响应——开发幸福感爆棚!


🧪 完整代码亮点回顾

  • 状态集中管理:所有 todos 操作在 App 中定义
  • 函数式更新setTodos([...todos, newTodo]) 避免闭包陷阱
  • 条件渲染completed > 0 && <button> 避免无效按钮
  • 语义化 JSX<label> 包裹 checkbox,提升可访问性
  • 性能友好:无多余状态,无复杂计算

🤔 思考:为什么不用 Context 或 Zustand?

对于小型应用(如 Todo),过度设计反而增加复杂度。Context 适合跨多层组件共享状态,Zustand 适合大型状态树。而我们的场景——三个组件 + 一个状态数组,用 props 足矣!

🚀 记住:简单即强大。能用 props 解决的问题,就别急着上状态管理库!


🎁 结语:你的第一个 React 应用,也可以很优雅

通过这个 Todo 应用,你不仅学会了组件通信,更理解了 React 的核心思想:状态驱动视图、单向数据流、组合优于继承

下次面试官问:“React 组件怎么通信?” 你可以微微一笑,掏出这个项目说:

“看,我的 Todo,麻雀虽小,五脏俱全。”

ERC-4337 落地场景全盘点:游戏、DAO、DeFi 的下一个爆发点

前言

本文围绕ERC-4337标准展开全面梳理,先系统解析其核心内容,涵盖概念定义、核心价值、组件构成、工作流程、特性优势及当前面临的挑战与发展现状,构建起清晰的理论认知框架;再聚焦实践落地,基于OpenZeppelin与account-abstraction工具,完整实现ERC-4337钱包开发,并详细拆解从开发搭建、测试验证到部署上线的全流程,形成“理论梳理+实践落地”的完整内容体系,为ERC-4337标准的学习与应用提供兼具系统性与可操作性的参考。

概述

ERC4337(Account Abstraction)是以太坊的账户抽象标准,旨在消除外部账户(EOA)与合约账户的界限。用户可通过智能合约自定义账户逻辑,实现社交恢复、代付Gas、批量交易等高级功能,而无需修改以太坊共识层;

核心价值:保留EOA的简单性,同时赋予合约账户的灵活性,显著提升Web3用户体验。

核心组件架构

1. UserOperation(用户操作)

  • 定义:伪交易对象,代表用户意图,包含目标调用、验证元数据、Gas参数等信息
  • 特点:不直接发送到链上,而是提交至独立的 alt-mempool(替代内存池)

2. 智能合约账户(Smart Contract Account)

  • 角色:用户控制的代理钱包,作为身份主体
  • 功能:通过 validateUserOp() 验证签名,通过 executeUserOp() 执行交易
  • 优势:可编程化,支持自定义规则(如多重签名、限额控制)

3. Bundler(打包器)

  • 作用:链下服务,监听 alt-mempool 中的 UserOperations
  • 流程:批量打包多个操作,通过一个交易提交至 EntryPoint 合约
  • 经济性:由Bundler预付Gas,后续从EntryPoint获得补偿

4. EntryPoint(入口合约)

  • 地位:ERC4337的核心链上网关

  • 职责

    • 验证每个UserOperation的有效性
    • 路由至对应智能合约钱包执行
    • 计算总Gas消耗并补偿Bundler
  • 关键:所有Gas支付通过此合约完成(从用户存款或Paymaster)

5. Paymaster(支付主)

  • 定位:可选的智能合约,提供灵活Gas支付方案

  • 两种模式

    • 赞助模式:项目方/第三方直接代付Gas
    • 代币模式:允许用户用ERC-20代币(如USDT)而非ETH支付Gas
  • 接口validatePaymasterUserOp() 验证资格,postOp() 处理后续结算

标准工作流程

详细步骤

  1. 签名生成:用户使用私钥对操作签名(兼容ERC-191/ERC-712)
  2. 内存池广播:UserOperation进入链下alt-mempool
  3. Bundler聚合:Bundler收集多个操作,创建批量交易
  4. EntryPoint处理:验证签名、检查Nonce、执行调用、计算Gas
  5. 费用结算:从用户账户存款或Paymaster扣除Gas费,补偿Bundler

关键特性与优势

特性 说明 价值
社交恢复 通过守护人机制重置私钥 解决私钥丢失问题
无Gas交易 Paymaster代付或ERC-20支付 降低新用户门槛
批量操作 单次签名执行多笔调用 提升操作效率
可编程权限 自定义验证逻辑(如多签、限额) 增强安全性与灵活性
确定性地址 代理钱包地址跨网络一致 类似EOA的用户体验

当前挑战与现状

  • 采用进展:核心合约已就绪,多个团队正推出生产级原生钱包
  • 架构局限:虽改善用户体验,但仍依赖链下Bundler与独立内存池,面临去中心化与普及挑战
  • 意图层(Intent-centric)融合:ERC4337为意图驱动架构提供基础,但纯意图模式仍需深度整合Paymaster与跨链设计

一句总结

ERC4337通过链下打包+链上验证的架构,在不修改以太坊协议的前提下实现账户抽象,是智能合约钱包普及的关键基础设施。

智能合约开发、测试、部署

智能合约
1.治理代币合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {BaseAccount} from "@account-abstraction/contracts/core/BaseAccount.sol";
import {IEntryPoint} from "@account-abstraction/contracts/interfaces/IEntryPoint.sol";
import {PackedUserOperation} from "@account-abstraction/contracts/interfaces/PackedUserOperation.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";
import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

/**
 * @title MySmartAccount
 * @dev ✅ 基于 @account-abstraction/contracts 0.7.0 的标准实现
 */
contract MySmartAccount is BaseAccount, Ownable, Initializable, UUPSUpgradeable, IERC1271 {
    using MessageHashUtils for bytes32;
    
    bytes4 private constant EIP1271_MAGIC_VALUE = 0x1626ba7e;
    
    // ✅ 存储 EntryPoint 地址(避免与函数名冲突)
    IEntryPoint private immutable _ACCOUNT_ENTRY_POINT;
    
    // 事件
    event TransactionExecuted(address indexed target, uint256 value, bytes data);
    event BatchExecuted(address[] targets, uint256[] values, bytes[] datas);

    /**
     * @dev ✅ 修正:构造函数参数名加下划线,避免与函数冲突
     * @param entryPoint_ ERC-4337 EntryPoint 地址
     * @param initialOwner 初始所有者地址
     */
    constructor(
        IEntryPoint entryPoint_,
        address initialOwner
    ) 
        BaseAccount()                  // ✅ BaseAccount 构造函数无参数
        Ownable(initialOwner)          // ✅ OpenZeppelin 5.x Ownable 需要 initialOwner
    {
        _ACCOUNT_ENTRY_POINT = entryPoint_;  // ✅ 存储到自定义变量
        _disableInitializers();
    }

    /**
     * @dev ✅ 覆盖 entryPoint() 函数,返回存储的 EntryPoint
     */
    function entryPoint() public view virtual override returns (IEntryPoint) {
        return _ACCOUNT_ENTRY_POINT;
    }

    /**
     * @dev 初始化函数,会覆盖 Ownable 的初始所有者
     */
    function initialize(address initialOwner) public virtual initializer {
        _transferOwnership(initialOwner);
    }

    /**
     * @dev ✅ 实现 BaseAccount 的抽象函数:_validateSignature(内部函数)
     * @notice 在 v0.7.0 中,BaseAccount 同时要求 _validateSignature 和 validateUserOp
     */
    function _validateSignature(
        PackedUserOperation calldata userOp,
        bytes32 userOpHash
    ) internal virtual override returns (uint256 validationData) {
        // 检查签名长度
        if (userOp.signature.length != 65) {
            return 1; // SIG_VALIDATION_FAILED
        }
        
        // ✅ 直接传入完整的 signature bytes,ECDSA.recover 会自动解析
        // 注意:userOp.signature 是 bytes calldata,可以直接传给 recover
        bytes32 ethHash = userOpHash.toEthSignedMessageHash();
        address signer = ECDSA.recover(ethHash, userOp.signature);
        
        // 验证签名者是否为所有者
        if (signer != owner()) {
            return 1;
        }
        
        return 0; // 验证成功
    }

    /**
     * @dev ✅ 实现 BaseAccount 的 validateUserOp 外部函数
     * @notice v0.7.0 要求实现此函数,处理 missingAccountFunds
     */
    function validateUserOp(
        PackedUserOperation calldata userOp,
        bytes32 userOpHash,
        uint256 missingAccountFunds
    ) external virtual override returns (uint256 validationData) {
        // 验证调用者是 EntryPoint
        require(msg.sender == address(entryPoint()), "account: not from EntryPoint");
        
        // 调用内部签名验证
        validationData = _validateSignature(userOp, userOpHash);
        
        // 支付 missingAccountFunds 给 EntryPoint
        if (missingAccountFunds > 0) {
            (bool success,) = payable(msg.sender).call{value : missingAccountFunds, gas : type(uint256).max}("");
            (success); // 忽略失败(这是 EntryPoint 的责任)
        }
    }

    /**
     * @dev 辅助函数:要求调用者是 EntryPoint 或所有者
     */
    function _requireFromEntryPointOrOwner() internal view {
        require(
            msg.sender == address(entryPoint()) || msg.sender == owner(),
            "account: not Owner or EntryPoint"
        );
    }

    /**
     * @dev 执行单笔交易
     */
    function execute(
        address target,
        uint256 value,
        bytes calldata data
    ) external payable virtual {
        _requireFromEntryPointOrOwner();
        _call(target, value, data);
        emit TransactionExecuted(target, value, data);
    }

    /**
     * @dev 批量执行多笔交易
     */
    function executeBatch(
        address[] calldata targets,
        uint256[] calldata values,
        bytes[] calldata datas
    ) external payable virtual {
        _requireFromEntryPointOrOwner();
        require(targets.length == datas.length && targets.length == values.length, "Array length mismatch");
        
        for (uint256 i = 0; i < targets.length; i++) {
            _call(targets[i], values[i], datas[i]);
        }
        
        emit BatchExecuted(targets, values, datas);
    }

    /**
     * @dev 内部调用函数,处理执行结果
     */
    function _call(address target, uint256 value, bytes memory data) internal {
        (bool success, bytes memory result) = target.call{value: value}(data);
        if (!success) {
            assembly { revert(add(result, 32), mload(result)) }
        }
    }

    /**
     * @dev ✅ ERC-1271 签名验证实现
     * @notice 对于 memory bytes,不能切片,只能检查长度
     */
    function isValidSignature(
        bytes32 hash,
        bytes memory signature
    ) public view virtual override returns (bytes4 magicValue) {
        // ✅ 检查 signature 长度是否符合 65 字节的 EIP-2098 标准
        if (signature.length != 65) {
            return 0xffffffff;
        }
        
        // ✅ 直接传入完整的 signature,ECDSA.recover 会内部解析
        bytes32 ethHash = hash.toEthSignedMessageHash();
        address signer = ECDSA.recover(ethHash, signature);
        
        if (signer == owner()) {
            return EIP1271_MAGIC_VALUE;
        }
        
        return 0xffffffff;
    }

    /**
     * @dev UUPS 升级授权
     */
    function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}

    /**
     * @dev 接收 ETH
     */
    receive() external payable {}
}
2.治理代币合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;  // ✅ 添加这一行

import {MySmartAccount} from "./MySmartAccount.sol";
import {IEntryPoint} from "@account-abstraction/contracts/interfaces/IEntryPoint.sol";
import {Create2} from "@openzeppelin/contracts/utils/Create2.sol";

contract MySmartAccountFactory {
    IEntryPoint private immutable _entryPoint;
    
    event AccountCreated(address indexed account, address indexed owner);
    
    constructor(IEntryPoint entryPoint) {
        _entryPoint = entryPoint;
    }
    
    function createAccount(bytes32 salt, address owner) external returns (MySmartAccount account) {
        address predictedAddress = getAddress(salt, owner);
        
        if (predictedAddress.code.length > 0) {
            return MySmartAccount(payable(predictedAddress));
        }
        
        account = new MySmartAccount{salt: salt}(_entryPoint, owner);
        emit AccountCreated(address(account), owner);
    }
    
    function getAddress(bytes32 salt, address owner) public view returns (address) {
        bytes32 bytecodeHash = keccak256(
            abi.encodePacked(
                type(MySmartAccount).creationCode,
                abi.encode(_entryPoint, owner)  // ✅ 匹配构造函数参数
            )
        );
        
        return Create2.computeAddress(salt, bytecodeHash, address(this));
    }
}
3.治理代币合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {BasePaymaster} from "@account-abstraction/contracts/core/BasePaymaster.sol";
import {IEntryPoint} from "@account-abstraction/contracts/interfaces/IEntryPoint.sol";
import {PackedUserOperation} from "@account-abstraction/contracts/interfaces/PackedUserOperation.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

contract MyPaymaster is BasePaymaster {
    using MessageHashUtils for bytes32;
    
    mapping(address => bool) public whitelistedApps;
    mapping(address => uint256) public sponsoredTransactionCount;
    uint256 public maxSponsoredTransactionsPerApp = 100;
    
    event AppWhitelisted(address indexed app);
    event AppRemoved(address indexed app);
    
    constructor(IEntryPoint entryPoint) BasePaymaster(entryPoint) {}
    
    function _validatePaymasterUserOp(
        PackedUserOperation calldata userOp,
        bytes32 userOpHash,
         uint256 /*maxCost*/
    ) internal override returns (bytes memory context, uint256 validationData) {
        address targetApp = address(bytes20(userOp.callData[16:36]));
        require(whitelistedApps[targetApp], "App not whitelisted");
        
        require(
            sponsoredTransactionCount[targetApp] < maxSponsoredTransactionsPerApp,
            "Sponsored transaction limit reached"
        );
        
        if (userOp.paymasterAndData.length > 20) {
            bytes memory signature = userOp.paymasterAndData[20:];
            bytes32 hash = userOpHash.toEthSignedMessageHash();
            address signer = ECDSA.recover(hash, signature);
            require(signer == owner(), "Invalid paymaster signature");
        }
        
        sponsoredTransactionCount[targetApp]++;
        return ("", 0);
    }
    
    // ✅ 修正:移除 _postOp 覆盖,使用父类默认实现
    // 如果确实需要后处理,确保正确导入类型
    
    function whitelistApp(address app) external onlyOwner {
        whitelistedApps[app] = true;
        emit AppWhitelisted(app);
    }
    
    function removeApp(address app) external onlyOwner {
        whitelistedApps[app] = false;
        emit AppRemoved(app);
    }
    
    function resetSponsoredCount(address app) external onlyOwner {
        sponsoredTransactionCount[app] = 0;
    }
    
    function setMaxSponsoredTransactions(uint256 maxCount) external onlyOwner {
        maxSponsoredTransactionsPerApp = maxCount;
    }
}
编译指令
npx hardhat compile
智能合约部署
// scripts/deploy.ts
import { network, artifacts } from "hardhat";
import {parseEther} from "viem"
import EntryPointArtifact from "@account-abstraction/contracts/artifacts/EntryPoint.json";
async function main() {
   // 连接网络
  const { viem } = await network.connect({ network: network.name });//指定网络进行链接
  // 获取客户端
  const [deployer] = await viem.getWalletClients();
  const publicClient = await viem.getPublicClient();
  
  const entryPointHash = await deployer.deployContract({
    abi: EntryPointArtifact.abi,
    bytecode: EntryPointArtifact.bytecode,
    args: [], // EntryPoint 构造函数不需要参数
  });
  
  // 等待确认并获取合约地址
  const entryPointReceipt = await publicClient.waitForTransactionReceipt({ 
    hash: entryPointHash 
  });
  
  const entryPointAddress = entryPointReceipt.contractAddress;
  console.log("✅ EntryPoint 合约地址:", entryPointAddress);
  // 部署智能合约MySmartAccount
  const MySmartAccountRegistry = await artifacts.readArtifact("MySmartAccount");
  // 部署(构造函数参数:recipient, initialOwner)
  const MySmartAccountRegistryHash = await deployer.deployContract({
    abi: MySmartAccountRegistry.abi,//获取abi
    bytecode: MySmartAccountRegistry.bytecode,//硬编码
    args: [entryPointAddress,deployer.account.address],//process.env.RECIPIENT, process.env.OWNER
  });
  // 等待确认并打印合约地址
  const MySmartAccountReceipt = await publicClient.getTransactionReceipt({ hash: MySmartAccountRegistryHash });
  console.log("MySmartAccount合约地址:", MySmartAccountReceipt.contractAddress);
  // 部署工厂合约
  const MySmartAccountFactory = await  artifacts.readArtifact("MySmartAccountFactory"); 

  const MySmartAccountFactoryHash = await deployer.deployContract({
    abi: MySmartAccountFactory.abi,//获取abi
    bytecode: MySmartAccountFactory.bytecode,//硬编码
    args: [entryPointAddress],//process.env.RECIPIENT, process.env.OWNER
  });
    // 等待确认并打印合约地址
  const MySmartAccountFactoryReceipt = await publicClient.getTransactionReceipt({ hash: MySmartAccountFactoryHash });
  console.log("MySmartAccountFactory合约地址:", await MySmartAccountFactoryReceipt.contractAddress);

  // 部署支付合约MyPaymaster
  const MyPaymaster = await artifacts.readArtifact("MyPaymaster");
  const MyPaymasterHash = await deployer.deployContract({  // ← 使用 deployContract
    abi: MyPaymaster.abi,
    bytecode: MyPaymaster.bytecode,
    args: [entryPointAddress],
  });
  
  const MyPaymasterReceipt = await publicClient.waitForTransactionReceipt({ 
    hash: MyPaymasterHash 
  });
  console.log("MyPaymaster合约地址:", MyPaymasterReceipt.contractAddress);
  
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});
特殊说明:关于本地部署EntryPoint合约问题

本地开发直接在安装包中获取编译后的@account-abstraction/contracts/artifacts/EntryPoint.json 进行部署,如果是测试网或主网中直接使用在线的合约地址即可

部署指令
npx hardhat run ./scripts/xxx.ts
智能合约测试
// test/ERC4337Wallet.ts
import assert from "node:assert/strict";
import { describe, it, beforeEach } from "node:test";
import { parseEther, toHex, hashMessage } from "viem";
import { network } from "hardhat";
import EntryPointArtifact from "@account-abstraction/contracts/artifacts/EntryPoint.json";

describe("ERC4337钱包核心功能测试", async function () {
  let viem: any;
  let publicClient: any;
  let deployer: any, owner: any, user1: any;
  let entryPointAddress: string;
  let mySmartAccountFactory: any;
  let myPaymaster: any;

  beforeEach(async function () {
    const networkData = await network.connect();
    viem = networkData.viem;
    
    [deployer, owner, user1] = await viem.getWalletClients();
    publicClient = await viem.getPublicClient();

    // 部署 EntryPoint
    console.log("🚀 部署 EntryPoint...");
    const entryPointHash = await deployer.deployContract({
      abi: EntryPointArtifact.abi,
      bytecode: EntryPointArtifact.bytecode,
      args: [],
    });
    
    const entryPointReceipt = await publicClient.waitForTransactionReceipt({ 
      hash: entryPointHash 
    });
    entryPointAddress = entryPointReceipt.contractAddress!;
    console.log("✅ EntryPoint:", entryPointAddress);

    // 部署工厂和 Paymaster
    mySmartAccountFactory = await viem.deployContract("MySmartAccountFactory", [entryPointAddress]);
    console.log("✅ Factory:", mySmartAccountFactory.address);

    myPaymaster = await viem.deployContract("MyPaymaster", [entryPointAddress]);
    // await myPaymaster.write.deposit();
    console.log("✅ Paymaster:", myPaymaster.address);
  });

  it("应创建智能账户并验证所有权", async function () {
    const salt = toHex("test-salt", { size: 32 });
    
    // 1. 创建账户
    const createTx = await mySmartAccountFactory.write.createAccount([
      salt,
      owner.account.address
    ]);
    await publicClient.waitForTransactionReceipt({ hash: createTx });
    
    // 2. 获取新创建的账户地址(关键!)
    const accountAddress = await mySmartAccountFactory.read.getAddress([
      salt,
      owner.account.address
    ]);
    
    // 3. 验证地址是合约
    const code = await publicClient.getCode({ address: accountAddress });
    assert.ok(code && code.length > 2, "账户未成功部署");
    
    // 4. 使用新账户地址创建实例
    const mySmartAccount = await viem.getContractAt("MySmartAccount", accountAddress);
    
    // 5. 验证所有权(这会成功,因为账户已初始化)
    const actualOwner = await mySmartAccount.read.owner();
    assert.equal(actualOwner.toLowerCase(), owner.account.address.toLowerCase());
    
    console.log("✅ 账户地址:", accountAddress);
    console.log("✅ 所有者:", actualOwner);
  });

  it("应验证 ERC-1271 签名", async function () {
   const walletClient = (await viem.getWalletClients())[0]; 
    const salt = toHex("test-salt-1271", { size: 32 });
    
    const createTx = await mySmartAccountFactory.write.createAccount([
      salt,
      owner.account.address
    ]);
    await publicClient.waitForTransactionReceipt({ hash: createTx });
    
    const accountAddress = await mySmartAccountFactory.read.getAddress([
      salt,
      owner.account.address
    ]);
    const mySmartAccount = await viem.getContractAt("MySmartAccount", accountAddress);
    
    const message = "Hello ERC-1271";
    const messageHash = hashMessage({ raw:message });
    const signature = await walletClient.signMessage({
      account: owner.account,
      message
    });
    
    const result = await mySmartAccount.read.isValidSignature([messageHash, signature]);
    console.log("✅ 验证结果:", result);
    // assert.equal(result, "0x1626ba7e");
  });

  it("应允许所有者执行交易", async function () {
    const salt = toHex("test-salt-exec", { size: 32 });
    
    const createTx = await mySmartAccountFactory.write.createAccount([
      salt,
      owner.account.address
    ]);
    await publicClient.waitForTransactionReceipt({ hash: createTx });
    
    const accountAddress = await mySmartAccountFactory.read.getAddress([
      salt,
      owner.account.address
    ]);
    const mySmartAccount = await viem.getContractAt("MySmartAccount", accountAddress);
    
    // 存入 ETH
    await deployer.sendTransaction({
      to: accountAddress,
      value: parseEther("1")
    });
    
    // 执行转账
    const executeTx = await mySmartAccount.write.execute([
      user1.account.address,
      parseEther("0.5"),
      "0x"
    ], { account: owner.account });
    
    const receipt = await publicClient.waitForTransactionReceipt({ hash: executeTx });
    assert.equal(receipt.status, "success");
  });

  it("应管理 Paymaster 白名单", async function () {
    const app = user1.account.address;
    
    // 初始不在白名单
    const isWhitelistedBefore = await myPaymaster.read.whitelistedApps([app]);
    assert.equal(isWhitelistedBefore, false);
    
    // 添加白名单
    await myPaymaster.write.whitelistApp([app], { account: deployer.account });
    const isWhitelistedAfter = await myPaymaster.read.whitelistedApps([app]);
    assert.equal(isWhitelistedAfter, true);
    console.log("✅ 已添加白名单:", app);
  });
});
测试指令
npx hardhat test ./test/xxx.ts

总结

以上内容完成了对 ERC-4337 标准的全面知识梳理,同时涵盖了其相关智能合约的完整实现流程。从核心知识解析到合约开发、测试验证,再到部署上线,每个环节均提供了详细的操作指引与逻辑说明,形成了 “理论梳理 + 实践落地” 的完整闭环,为开发者掌握 ERC-4337 标准应用与合约开发提供了清晰且可落地的参考框架

React学习:通过TodoList,完整理解组件通信

React 组件通信从零到精通:用一个完整 Todo List 项目彻底搞懂父子、子父与兄弟通信

最近学习了React中完整点的组件通信,包括父传子,子传父,兄弟组件通信。概念听起来简单——props 向下传、回调向上传、状态提升——但真正写代码时,总觉得迷迷糊糊。于是我通过一个 Todo List 功能讲解,结合真实代码,一点点拆解了组件通信的每一个细节。

从最基础的父传子开始,到子传父的回调机制,再到兄弟组件的状态提升,最后深入到大家经常问的“为什么 onChange 要包箭头函数”这类细节。全程基于一个可运行的 Todo List 项目,代码全部贴出,讲解尽量通俗、细致,适合初学者反复阅读,也适合有经验的同学复习巩固。

项目整体结构:经典的状态提升模式

先看整个项目的组件树:

App(父组件)
├── TodoInput(添加输入框)
├── TodoList(列表展示 + 删除 + 切换完成状态)
└── TodoStats(统计 + 清除已完成任务)

核心数据 todos 数组只在 App 组件中用 useState 管理。三个子组件都不直接持有或修改 todos,而是通过 props 接收数据和修改方法。

这就是 React 官方推荐的状态提升(Lifting State Up) :把多个组件需要共享的状态提升到它们最近的共同父组件中统一管理。

这样做的好处:

  • 数据有单一真相来源(single source of truth)
  • 避免数据不同步的 bug
  • 逻辑集中,容易维护

一、父组件 → 子组件:单向数据流与 Props 传递

React 的核心原则是单向数据流:数据只能从父组件通过 props 向下传递,子组件不能直接修改父组件的数据。

在 App.jsx 中,我们把 todos 数据、统计数字、各种操作函数都通过 props 传给了子组件:

// App.jsx 关键片段
<TodoInput onAdd={addTodo} />
<TodoList
  todos={todos}
  onDelete={deleteTodo}
  onToggle={toggleTodo}
/>
<TodoStats
  total={todos.length}
  active={activeCount}
  completed={completedCount}
  onClearCompleted={onClearCompleted}
/>

子组件只需要接收 props,使用即可,完全不需要关心数据是怎么来的、怎么改的。

关于父传子的单项数据可以看我上一篇文章# React 学习:父传子的单项数据流——props

二、子组件 → 父组件:回调函数上报事件(深度详解)

子组件如何影响父组件的状态?这正是 React 组件通信中最核心、最容易混淆的部分。

很多人误以为“子传父”是子组件把数据直接塞给父组件,其实完全不是!

React 中“子传父”的正确姿势是:父组件提前定义好一个回调函数,通过 props 传给子组件;子组件在合适时机调用这个函数,把必要的信息“上报”给父组件,由父组件决定如何更新自己的状态。

这套机制在我们的三个子组件中都有体现,下面结合代码一步一步彻底拆解它的实现原理。

子传父的完整四步流程
  1. 父组件定义回调函数(负责真正修改状态)
  2. 父组件通过 props 把回调函数传给子组件
  3. 子组件接收回调,并在事件触发时调用它(上报数据或事件)
  4. 回调执行 → 父组件状态更新 → 触发重新渲染 → 新数据通过 props 再次向下传递

下面以“添加新 Todo”为例,逐行代码演示这个闭环。

示例 1:TodoInput 添加新事项(子传父经典案例)

步骤 1:父组件定义回调函数 addTodo

// App.jsx
const addTodo = (text) => {
  setTodos(prev => [...prev, {
    id: Date.now(),
    text,
    completed: false
  }]);
};

这个函数接收一个 text 参数,负责把新事项添加到状态中。

步骤 2:父组件通过 props 传递回调

<TodoInput onAdd={addTodo} />  // 注意:传的是函数本身,不是调用

步骤 3:子组件接收并在提交时调用

// TodoInput.jsx
const TodoInput = ({ onAdd }) => {  // 解构接收
  const [inputValue, setInputValue] = useState("");

  const handleSubmit = (e) => {
    e.preventDefault();
    const text = inputValue.trim();
    if (!text) return;

    onAdd(text);          // ← 关键!上报用户输入的文本
    setInputValue("");    // 清空输入框
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
      />
      <button type="submit">Add</button>
    </form>
  );
};

步骤 4:闭环完成

  • 用户输入并提交 → onAdd(text) 被调用 → 执行父组件的 addTodo
  • setTodos 更新状态 → App 重新渲染 → 新 todos 通过 props 传给 TodoList → 列表自动显示新项
示例2. TodoList:删除和切换完成状态
// TodoList.jsx
const TodoList = ({ todos, onDelete, onToggle }) => {
  return (
    <ul className="todo-list">
      {todos.length === 0 ? (
        <li className="empty">No todos yet!</li>
      ) : (
        todos.map((todo) => (
          <li
            key={todo.id}
            className={todo.completed ? "completed" : ""}
          >
            <label>
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => onToggle(todo.id)}   // 上报 id
              />
              <span>{todo.text}</span>
            </label>
            <button
              className="delete-btn"
              onClick={() => onDelete(todo.id)}      // 上报 id
            >
              ×
            </button>
          </li>
        ))
      )}
    </ul>
  );
};

export default TodoList;

关键点:

  • 复选框也是受控组件:checked 值来自 props 中的 todo.completed
  • 点击复选框或删除按钮时,分别调用 onToggle(todo.id) 和 onDelete(todo.id),把当前事项的 id 上报给父组件
  • 父组件根据 id 找到对应项并更新状态

父组件中的实现:

const deleteTodo = (id) => {
  setTodos(prev => prev.filter(todo => todo.id !== id));
};

const toggleTodo = (id) => {
  setTodos(prev => prev.map(todo =>
    todo.id === id 
      ? { ...todo, completed: !todo.completed }
      : todo
  ));
};
示例3. TodoStats:清除已完成事项
// TodoStats.jsx
const TodoStats = ({ total, active, completed, onClearCompleted }) => {
  return (
    <div className="todo-stats">
      <p>
        Total: {total} | Active: {active} | Completed: {completed}
      </p>
      {completed > 0 && (
        <button onClick={onClearCompleted} className="clear-btn">
          Clear Completed
        </button>
      )}
    </div>
  );
};

export default TodoStats;

关键点:

  • 统计数字由父组件提前计算好传下来,避免子组件重复计算
  • 点击清除按钮时调用 onClearCompleted() 上报事件

父组件实现:

const onClearCompleted = () => {
  setTodos(prev => prev.filter(todo => !todo.completed));
};
子传父的核心本质总结
  • 不是子组件“给”父组件数据,而是子组件“通知”父组件:“嘿,发生了一件事(用户点了添加/删除/切换),需要的参数我给你,你自己看着办。”
  • 所有状态修改权永远掌握在父组件手里,子组件只有“上报权”。
  • 这种“事件向上冒泡、数据向下流动”的模式,正是 React 单向数据流的完美体现。

掌握了这个机制,你就真正理解了为什么 React 说“数据流是单向的”,却依然能轻松实现复杂的交互。

完整子组件代码(带详细注释)

TodoInput.jsx

import { useState } from "react";

const TodoInput = ({ onAdd }) => {
  const [inputValue, setInputValue] = useState("");

  const handleSubmit = (e) => {
    e.preventDefault();
    const text = inputValue.trim();
    if (!text) return;

    onAdd(text);          // 子 → 父:上报新事项文本
    setInputValue("");
  };

  return (
    <form className="todo-input" onSubmit={handleSubmit}>
      <input
        type="text"
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
        placeholder="输入事项后按回车或点击添加"
      />
      <button type="submit">Add</button>
    </form>
  );
};

export default TodoInput;

TodoList.jsx

const TodoList = ({ todos, onDelete, onToggle }) => {
  return (
    <ul className="todo-list">
      {todos.length === 0 ? (
        <li className="empty">No todos yet! 快去添加一个吧~</li>
      ) : (
        todos.map((todo) => (
          <li key={todo.id} className={todo.completed ? "completed" : ""}>
            <label>
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => onToggle(todo.id)}   // 子 → 父:上报要切换的 id
              />
              <span>{todo.text}</span>
            </label>
            <button
              className="delete-btn"
              onClick={() => onDelete(todo.id)}      // 子 → 父:上报要删除的 id
            >
              ×
            </button>
          </li>
        ))
      )}
    </ul>
  );
};

export default TodoList;

TodoStats.jsx

const TodoStats = ({ total, active, completed, onClearCompleted }) => {
  return (
    <div className="todo-stats">
      <p>Total: {total} | Active: {active} | Completed: {completed}</p>
      {completed > 0 && (
        <button onClick={onClearCompleted} className="clear-btn">
          Clear Completed
        </button>
      )}
    </div>
  );
};

export default TodoStats;

三、为什么 onChange={() => onToggle(todo.id)} 必须包箭头函数?

这是初学者最容易踩的坑之一,我们详细拆解。

错误写法 1:直接调用
onChange={onToggle(todo.id)}  // 灾难性错误!

渲染时就会立即执行 onToggle(todo.id),导致:

  • 页面加载瞬间所有任务状态翻转
  • 可能引发无限渲染循环

SnowShot_Video_2025-12-24_14-57-17.gif

错误写法 2:只传函数不传参

jsx

onChange={onToggle}

React 会把 event 对象传给 onToggle,但我们需要的是 id,导致切换失败。

正确写法:箭头函数包裹
onChange={() => onToggle(todo.id)}

只有用户真正点击时才执行,并正确传递 id。

四、完整 App 组件:数据管理中心

import { useEffect, useState } from "react";
import "./styles/app.styl";
import TodoList from "./components/TodoList";
import TodoInput from "./components/TodoInput";
import TodoStats from "./components/TodoStats";

function App() {
  // 子组件共享的数据状态
  const [todos, setTodos] = useState(() => {
    // 高级用法
    const saved = localStorage.getItem("todos");
    return saved ? JSON.parse(saved) : [];
  });
  // 子组件修改数据的方法
  const addTodo = (text) => {
    setTodos([
      ...todos,
      {
        id: Date.now(), // 时间戳
        text,
        completed: false,
      },
    ]);
  };

  const deleteTodo = (id) => {
    setTodos(todos.filter((todo) => todo.id !== id));
  };
  const toggleTodo = (id) => {
    setTodos(
      todos.map((todo) =>
        todo.id === id
          ? {
              ...todo,
              completed: !todo.completed,
            }
          : todo
      )
    );
  };

  const activeCount = todos.filter((todo) => !todo.completed).length;
  const completedCount = todos.filter((todo) => todo.completed).length;
  const onClearCompleted = () => {
    setTodos(todos.filter((todo) => !todo.completed));
  };

  useEffect(() => {
    localStorage.setItem("todos", JSON.stringify(todos));
  }, [todos]);

  return (
    <div className="todo-app">
      <h1>My Todo List</h1>
      {/* 自定义事件 */}
      <TodoInput onAdd={addTodo} />
      <TodoList todos={todos} onDelete={deleteTodo} onToggle={toggleTodo} />
      <TodoStats
        total={todos.length}
        active={activeCount}
        completed={completedCount}
        onClearCompleted={onClearCompleted}
      />
    </div>
  );
}

export default App;

五、最终效果展示:

初始状态

image.png

添加示例

image.png

勾选完成

image.png

六、总结:掌握这套模式,就掌握了 90% 的组件通信

通过这个 Todo List,我们完整实践了:

  1. 父传子:props 单向传递
  2. 子传父:回调函数上报事件(深度掌握)
  3. 兄弟通信:状态提升
  4. 常见坑避免:事件处理正确写法

这套模式简单、可靠、可预测,是 React 项目的基石。

当项目更大时,再学习 Context 或状态管理库。但请记住:万丈高楼平地起,先把这套基础打牢

希望这篇文章能帮你彻底弄懂 React 组件通信的本质。下次写代码时,遇到数据流动问题,先问自己:“这个状态该谁管?回调要不要传参?箭头函数包好了吗?”

MasterGo AI 实战教程:10分钟生成网页设计图(附案例演示)


一款能让你 写一句话,自动生成 UI 页面 的工具,你用过吗?本文带你从 0 上手 MasterGo AI,快速生成网页 / APP / 后台管理系统等高保真设计稿,全程 AI 一键完成,适合产品、设计、开发快速原型沟通!


🧩什么是 MasterGo AI?

MasterGo 是一款国产在线 UI 设计工具,类似 Figma,但更轻便、学习门槛低。其 AI 功能支持:

  • 一句话生成高保真 UI 页面
  • 支持移动端、网页、后台等多种场景
  • 输入产品描述、上传原型图、修改颜色风格等参数
  • 支持自动生成 Vue/React 框架代码(适配常用组件库)

🛠️使用步骤概览

✅ 第一步:新建设计文件

  • 登录 MasterGo 官网,点击右上角【新建文件】
  • 进入空白设计画布新建文件

✅ 第二步:启用 AI 生成界面功能

  • 点击上方工具栏中的【AI 图标】
  • 选择“AI 生成界面”,进入 AI 输入页启用 AI 生成界面

✅ 第三步:输入产品描述

在输入框中描述你想要的页面内容,比如:

  • 页面类型:移动端 / 网页 / 后台
  • 页面结构:导航栏、轮播图、商品列表等
  • 页面风格:颜色、圆角、字体大小、明暗模式等
  • 输入描述

🎯实战案例:快速生成健身电商 APP 页面

🧾 描述输入如下:

设计一个售卖健身器材的APP:首页包含搜索栏、轮播图、分类导航(跑步机、哑铃等),展示热门产品(含图片、名称、价格、评分、加入购物车按钮),底部有四个导航图标:首页、分类、购物车、我的。
  • 主题色设置为 #FA2549
  • 回车后,等待 10 秒左右 AI 生成页面结构
  • 点击“开始生成”后自动完成界面设计填写需求

✅ 生成效果展示(首页 UI)

APP首页效果图

  • 若有细节不满意,可输入 “文字放大1.5倍”、“优化组件间距”等进行调整
  • 点击“插入到画布”,进入可编辑设计图层

✨生成商品详情页

复制上一页,输入:

生成跑步机商品详情页,包含商品图、价格、评分、详情介绍、购买按钮等

即刻生成新页面:

商品详情页


🖥️后台管理系统:生成健身器材后台

描述示例:

健身器材后台:包含登录页、订单管理(搜索、筛选)、产品管理、销售统计看板

页面效果如下:

  • ✅ 订单管理模块订单管理模块
  • ✅ 可添加弹窗功能,如“新建订单”弹窗功能
  • ✅ 生成销售数据看板销售数据看板

🧾上传原型图自动生成 UI

你还可以上传草图/白板图,AI 自动还原成设计图!

👇 上传一张新闻网站原型图上传原型图

🔧 输入提示:

根据上图生成新闻网站首页,包含导航栏、新闻分类、头条推荐、搜索功能等。

✅ 最终效果图如下:

生成新闻首页


🌟多模态AI项目 UI 快速生成

除了通用网页,你还可以用它设计生产级 AI 应用平台,如:

✅ 笔记管理模块界面

多模态AI项目-笔记模块

✅ 聊天管理模块界面

多模态AI项目-聊天模块

✅ AI 绘画模块界面

🎯 从产品思路 → 页面原型 → 界面设计 → 代码输出,全流程 AI 帮你搞定

✨更多扩展能力

✅ 英文输入 → 生成英文页面

直接输入英文描述,自动生成英文 UI(可配合海外产品原型)

✅ 一键生成前端框架代码

  • 支持 Vue / React
  • 可选组件库(Element Plus、AntD 等)
  • 自动生成页面结构代码,点击可复制代码生成功能

📌总结

MasterGo AI 不仅是设计工具,更是一个面向 产品/设计/开发全链路协作 的高效原型工具。你只需要提供一句话描述,就能生成设计图、代码,快速验证想法、推进开发。

✅ 支持多端页面自动生成✅ 支持原型图上传自动识别✅ 支持代码导出与团队协作

绘制下面的UI 界面的结构拆解与组件关系示意图 ,还需要熟悉这款设计软件的基础操作

设计图(带二维码).png

Ant Design 6.0 尝鲜:上手现代化组件开发|得物技术

一、引言

组件体验的革新

在前端开发领域,Ant Design 一直是企业级 React 应用的首选 UI 库之一。随着 Ant Design 6.0 的发布,我们又见证了一次聚焦于组件功能与用户体验的革新。本次更新不仅引入了多个全新组件,更对现有核心组件进行了功能性增强,使开发者能够以更少的代码实现更丰富的交互效果。

二、Masonry 瀑布流组件:智能动态布局

传统网格布局在处理高度不一的元素时常出现大量空白区域,Masonry(瀑布流)布局则完美解决了这一问题。Ant Design 6.0 全新推出的 Masonry 组件让实现这种流行布局变得异常简单。

基础实现与响应式配置

import { useState, useEffect, useRef } from "react";
import { Masonry, Card, Image, Spin } from "antd";
/**
 * Masonry瀑布流页面
 */
export default () => {
  const [isLoading, setIsLoading] = useState(false);
  const containerRef = useRef<HTMLDivElement>(null);
  const isLoadingRef = useRef(false);
  const imageList = [
    "https://images.xxx.com/photo-xxx-4b4e3d86bf0f",
    ...
    "https://images.xxx.com/photo-xxx-98f7befd1a60",
  ];
  const titles = [
    "山间日出",
    ...
    "自然风光",
  ];
  const descriptions = [
    "清晨的第一缕阳光",
    ...
    "色彩鲜艳的料理",
  ];
  const heights = [240260280300320350380400];
  // Mock数据生成函数
  const generateMockData = (startIndex: number, count: number) => {
    return Array.from({ length: count }, (_, index) => ({
      id: startIndex + index + 1,
      src: imageList[Math.floor(Math.random() * imageList.length)],
      title: titles[(startIndex + index) % titles.length],
      description: descriptions[(startIndex + index) % descriptions.length],
      height: heights[Math.floor(Math.random() * heights.length)],
    }));
  };
  // 初始数据:20条
  const [photoItems, setPhotoItems] = useState(() => generateMockData(0, 20));
  // 滚动监听
  useEffect(() => {
    isLoadingRef.current = isLoading;
  }, [isLoading]);
  useEffect(() => {
    const loadMoreData = async () => {
      if (isLoadingRef.current) return;
      isLoadingRef.current = true;
      setIsLoading(true);
      // 模拟API请求延迟
      await new Promise((resolve) => setTimeout(resolve, 500));
      setPhotoItems((prev) => {
        const newItems = generateMockData(prev.length, 10);
        return [...prev, ...newItems];
      });
      isLoadingRef.current = false;
      setIsLoading(false);
    };
    const checkScroll = () => {
      if (isLoadingRef.current) return;
      const container = containerRef.current;
      if (!container) return;
      const scrollTop = container.scrollTop;
      const scrollHeight = container.scrollHeight;
      const clientHeight = container.clientHeight;
      // 当滚动到距离底部100px时触发加载
      if (scrollTop + clientHeight >= scrollHeight - 100) {
        loadMoreData();
      }
    };
    const handleWindowScroll = () => {
      if (isLoadingRef.current) return;
      const windowHeight = window.innerHeight;
      const documentHeight = document.documentElement.scrollHeight;
      const scrollTop =
        window.pageYOffset || document.documentElement.scrollTop;
      // 当滚动到距离底部100px时触发加载
      if (scrollTop + windowHeight >= documentHeight - 100) {
        loadMoreData();
      }
    };
    const container = containerRef.current;
    // 初始检查一次,以防内容不足一屏
    setTimeout(() => {
      checkScroll();
      handleWindowScroll();
    }, 100);
    // 监听容器滚动
    if (container) {
      container.addEventListener("scroll", checkScroll);
    }
    // 同时监听 window 滚动(作为备选)
    window.addEventListener("scroll", handleWindowScroll);
    return () => {
      if (container) {
        container.removeEventListener("scroll", checkScroll);
      }
      window.removeEventListener("scroll", handleWindowScroll);
    };
  }, []);
  return (
    <div ref={containerRef} className="w-full h-[100vh] overflow-auto p-[24px]">
      <Masonry
        // 响应式列数配置
        columns={{ xs: 2, sm: 3, md: 4, lg: 5 }}
        // 列间距与行间距
        gutter={16}
        items={photoItems as any}
        itemRender={(item: any) => (
          <Card
            key={item.id}
            hoverable
            cover={
              <div style={{ height: item.height, overflow: "hidden" }}>
                <Image
                  src={item.src}
                  alt={item.title}
                  height={item.height}
                  width="100%"
                  style={{
                    width: "100%",
                    height: "100%",
                    objectFit: "cover",
                  }}
                  preview={{
                    visible: false,
                  }}
                />
              </div>
            }
            styles={{
              body: {
                padding: "12px",
              },
            }}
          >
            <Card.Meta title={item.title} description={item.description} />
            <div
              className="mt-[8px] text-[12px] text-[#999]"
            >
              图片 #{item.id}
            </div>
          </Card>
        )}
      />
      {isLoading && (
        <div
          className="flex items-center justify-center p-[20px] text-[#999]"
        >
          <Spin style={{ marginRight: "8px" }} />
          <span>加载中...</span>
        </div>
      )}
    </div>
  );
};

布局效果说明

Masonry 组件会根据设定的列数自动将元素排列到高度最小的列中。与传统的网格布局相比,这种布局方式能有效减少内容下方的空白区域,特别适合展示高度不一的内容块。

对于图片展示类应用,这种布局能让用户的视线自然流动,提高浏览的沉浸感和内容发现率。

三、Tooltip 平滑移动:优雅的交互引导

在 Ant Design 6.0 中,Tooltip 组件引入了独特的平滑过渡效果,通过 ConfigProvider 全局配置的 tooltip.unique 配置项,当用户在多个带有提示的元素间移动时,提示框会以流畅的动画跟随,而不是突然消失和重现。

实现平滑跟随效果

import { Tooltip, Button, Card, ConfigProvider } from "antd";
import {
  UserOutlined,
  SettingOutlined,
  BellOutlined,
  MailOutlined,
  AppstoreOutlined,
} from "@ant-design/icons";
import { TooltipPlacement } from "antd/es/tooltip";
/**
 * Tooltip 示例
 */
export default () => {
  const buttonItems = [
    {
      icon: <UserOutlined />,
      text: "个人中心",
      tip: "查看和管理您的个人资料",
      placement: "top",
    },
    {
      icon: <SettingOutlined />,
      text: "系统设置",
      tip: "调整应用程序参数和偏好",
      placement: "top",
    },
    {
      icon: <BellOutlined />,
      text: "消息通知",
      tip: "查看未读提醒和系统消息",
      placement: "top",
    },
    {
      icon: <MailOutlined />,
      text: "邮箱",
      tip: "收发邮件和管理联系人",
      placement: "bottom",
    },
    {
      icon: <AppstoreOutlined />,
      text: "应用中心",
      tip: "探索和安装更多应用",
      placement: "bottom",
    },
  ];
  return (
    <div className="w-full h-[100vh] overflow-auto p-[24px] space-y-5">
      <ConfigProvider
        tooltip={{
          unique: true,
        }}
      >
        <Card title="平滑过渡导航工具栏" bordered={false}>
          <div className="flex justify-center gap-6 py-10 px-5 bg-gradient-to-br from-[#f5f7fa] to-[#c3cfe2] rounded-3xl">
            {buttonItems.map((item, index) => (
              <Tooltip
                placement={item.placement as TooltipPlacement}
                key={index}
                title={
                  <div>
                    <div className="font-bold mb-1">{item.text}</div>
                    <div className="text-xs text-[#fff]/60">{item.tip}</div>
                  </div>
                }
                color="#1677ff"
              >
                <Button
                  type="primary"
                  shape="circle"
                  icon={item.icon}
                  size="large"
                  className="w-[60px] h-[60px] text-2xl shadow-md transition-all duration-300 ease-in-out"
                />
              </Tooltip>
            ))}
          </div>
          <div className="mt-5 p-4 bg-green-50 border border-green-300 rounded-md">
            <div className="flex items-center">
              <div className="w-3 h-3 rounded-full bg-green-500 mr-2"></div>
              <span>
                提示:尝试将鼠标在不同图标间快速移动,观察 Tooltip
                的平滑过渡效果
              </span>
            </div>
          </div>
        </Card>
      </ConfigProvider>
      <Card title="非平滑过渡导航工具栏" bordered={false}>
        <div className="flex justify-center gap-6 py-10 px-5 bg-gradient-to-br from-[#f5f7fa] to-[#c3cfe2] rounded-3xl">
          {buttonItems.map((item, index) => (
            <Tooltip
              key={index}
              placement={item.placement as TooltipPlacement}
              title={
                <div>
                  <div className="font-bold mb-1">{item.text}</div>
                  <div className="text-xs text-[#fff]/60">{item.tip}</div>
                </div>
              }
              color="#1677ff"
            >
              <Button
                type="primary"
                shape="circle"
                icon={item.icon}
                size="large"
                className="w-[60px] h-[60px] text-2xl shadow-md transition-all duration-300 ease-in-out"
              />
            </Tooltip>
          ))}
        </div>
        <div className="mt-5 p-4 bg-green-50 border border-green-300 rounded-md">
          <div className="flex items-center">
            <div className="w-3 h-3 rounded-full bg-green-500 mr-2"></div>
            <span>
              提示:尝试将鼠标在不同图标间快速移动,观察 Tooltip 的非平滑过渡效果
            </span>
          </div>
        </div>
      </Card>
    </div>
  );
};

2.gif

交互效果说明

当 tooltip.unique 设置为 true 时,用户在不同元素间移动鼠标时,Tooltip 会呈现以下行为:

  1. 平滑位置过渡:Tooltip 不会立即消失,而是平滑移动到新目标位置

  2. 内容无缝切换:提示内容在新位置淡入,旧内容淡出

  3. 视觉连续性:保持同一时刻只有一个 Tooltip 显示,避免界面混乱

这种设计特别适合工具栏、导航菜单等元素密集的区域,能有效降低用户的认知负荷,提供更加流畅的交互体验。

四、InputNumber 拨轮模式:直观的数字输入

数字输入框是表单中的常见组件,但传统的上下箭头控件在小屏幕或触摸设备上操作不便。Ant Design 6.0 的 InputNumber 组件新增了 mode="spinner" 属性,提供了更直观的“加减按钮”界面。

拨轮模式实现

import { InputNumber, Card, Row, Col, Typography, Space } from "antd";
import {
  ShoppingCartOutlined,
  DollarOutlined,
  GiftOutlined,
} from "@ant-design/icons";
const { Title, Text } = Typography;
/**
 * InputNumber 示例
 */
export default () => {
  return (
    <div className="w-full h-[100vh] overflow-auto p-[24px] space-y-5">
      <Card title="商品订购面板" bordered={false}>
        <Row gutter={[2424]}>
          <Col span={8}>
            <div className="text-center">
              <div className="w-[80px] h-[80px] mx-auto mb-4 rounded-3xl bg-[#f0f5ff] flex items-center justify-center text-[32px] text-[#1677ff]">
                <ShoppingCartOutlined />
              </div>
              <Title level={5} className="mb-3">
                购买数量(非数字拨轮)
              </Title>
              <InputNumber
                min={1}
                max={50}
                defaultValue={1}
                size="large"
                className="w-[250px]!"
                addonBefore="数量"
              />
              <div className="mt-2 text-xs text-gray-600">限购50件</div>
            </div>
          </Col>
          <Col span={8}>
            <div className="text-center">
              <div className="w-[80px] h-[80px] mx-auto mb-4 rounded-3xl bg-[#fff7e6] flex items-center justify-center text-[32px] text-[#fa8c16]">
                <DollarOutlined />
              </div>
              <Title level={5} className="mb-3">
                折扣力度(数字拨轮)
              </Title>
              <InputNumber
                min={0}
                max={100}
                defaultValue={10}
                mode="spinner"
                size="large"
                formatter={(value) => `${value ?? 0}%`}
                parser={(value) =>
                  Number.parseFloat(value?.replace("%", "") ?? "0") as any
                }
                className="w-[250px]!"
                addonBefore="折扣"
              />
              <div className="mt-2 text-xs text-gray-600">0-100%范围</div>
            </div>
          </Col>
          <Col span={8}>
            <div className="text-center">
              <div className="w-[80px] h-[80px] mx-auto mb-4 rounded-3xl bg-[#f6ffed] flex items-center justify-center text-[32px] text-[#52c41a]">
                <GiftOutlined />
              </div>
              <Title level={5} className="mb-3">
                礼品数量(数字拨轮,自定义加减按钮)
              </Title>
              <Space.Compact block className="justify-center!">
                <Space.Addon>
                  <span>礼品</span>
                </Space.Addon>
                <InputNumber
                  min={0}
                  max={10}
                  defaultValue={0}
                  mode="spinner"
                  size="large"
                  className="w-[250px]!"
                  controls={{
                    upIcon: <span className="text-base">➕</span>,
                    downIcon: <span className="text-base">➖</span>,
                  }}
                />
              </Space.Compact>
              <div className="mt-2 text-xs text-gray-600">每单最多10份</div>
            </div>
          </Col>
        </Row>
        <div className="mt-8 p-4 bg-[#fff0f6] rounded-lg border border-dashed border-[#ffadd2]">
          <Text type="secondary">
            <strong>设计提示:</strong>
            拨轮模式相比传统箭头控件,提供了更大的点击区域和更明确的视觉反馈,特别适合触摸设备和需要频繁调整数值的场景。加减按钮的分离式设计也降低了误操作的可能性。
          </Text>
        </div>
      </Card>
    </div>
  );
};

3.gif

交互优势分析

拨轮模式相比传统数字输入框具有明显优势:

  1. 触摸友好:更大的按钮区域适合移动端操作

  2. 意图明确:“+”和“-”符号比小箭头更直观

  3. 快速调整:支持长按连续增减数值

  4. 视觉反馈:按钮有明确的状态变化(按下、悬停)

在电商、数据仪表盘、配置面板等需要频繁调整数值的场景中,这种设计能显著提升用户的操作效率和满意度。

五、Drawer 拖拽调整:灵活的侧边面板

抽屉组件常用于移动端导航或详情面板,但固定尺寸有时无法满足多样化的内容展示需求。Ant Design 6.0 为 Drawer 组件新增了 resizable 属性,允许用户通过拖拽边缘实时调整面板尺寸。

可调整抽屉实现

import { Drawer, Button, Card, Typography, Divider, List, Flex } from "antd";
import {
  DragOutlined,
  CalendarOutlined,
  FileTextOutlined,
  TeamOutlined,
  CommentOutlined,
  PaperClipOutlined,
} from "@ant-design/icons";
import { useState } from "react";
import { DrawerResizableConfig } from "antd/es/drawer";
const { Title, Text, Paragraph } = Typography;
/**
 * Drawer 示例
 */
export default () => {
  const [open, setOpen] = useState(false);
  const [drawerWidth, setDrawerWidth] = useState(400);
  const [resizable, setResizable] = useState<boolean | DrawerResizableConfig>(
    false,
  );
  const tasks = [
    { id: 1, title: "完成项目需求文档", time: "今天 10:00", priority: "high" },
    { id: 2, title: "团队周会", time: "今天 14:30", priority: "medium" },
    { id: 3, title: "代码审查", time: "明天 09:00", priority: "high" },
    { id: 4, title: "客户演示准备", time: "后天 15:00", priority: "medium" },
  ];
  const showDrawerWithResizable = () => {
    setOpen(true);
    setDrawerWidth(400);
    setResizable({
      onResize: (size) => {
        setDrawerWidth(size);
      },
    });
  };
  const showDrawerWithoutResizable = () => {
    setOpen(true);
    setDrawerWidth(600);
    setResizable(false);
  };
  const onClose = () => {
    setOpen(false);
  };
  return (
    <div className="w-full h-[100vh] flex items-center justify-center overflow-auto p-[24px] space-y-5">
      <Card
        title="任务管理面板"
        variant="outlined"
        className="max-w-[800px] mx-auto"
      >
        <div className="py-10 px-5 text-center">
          <div className="w-20 h-20 mx-auto mb-6 rounded-full bg-[#1677ff] flex items-center justify-center text-[36px] text-white">
            <DragOutlined />
          </div>
          <Title level={3}>可调整的任务详情面板</Title>
          <Paragraph type="secondary" className="max-w-[600px] my-4 mx-auto">
            点击下方按钮打开一个可拖拽调整宽度的抽屉面板。尝试拖动抽屉左侧边缘,根据内容需要调整面板尺寸。
          </Paragraph>
          <Flex justify="center" gap={10}>
            <Button
              type="primary"
              size="large"
              onClick={showDrawerWithResizable}
              icon={<CalendarOutlined />}
              className="mt-6"
            >
              打开任务抽屉(可拖拽宽度)
            </Button>
            <Button
              type="primary"
              size="large"
              onClick={showDrawerWithoutResizable}
              icon={<CalendarOutlined />}
              className="mt-6"
            >
              打开任务抽屉(不可拖拽宽度)
            </Button>
          </Flex>
        </div>
      </Card>
      <Drawer
        title={
          <div className="flex items-center">
            <CalendarOutlined className="mr-2 text-[#1677ff]" />
            <span>任务详情与计划</span>
            {resizable && (
              <div className="ml-3 py-0.5 px-2 bg-[#f0f5ff] rounded-[10px] text-xs text-[#1677ff]">
                可拖拽调整宽度
              </div>
            )}
          </div>
        }
        placement="right"
        onClose={onClose}
        open={open}
        size={drawerWidth}
        resizable={resizable}
        extra={
          <Button type="text" icon={<DragOutlined />}>
            {resizable ? "拖拽边缘调整" : "不可拖拽"}
          </Button>
        }
        styles={{
          body: {
            paddingTop: "12px",
          },
          header: {
            borderBottom: "1px solid #f0f0f0",
          },
        }}
      >
        <div className="mb-6">
          <div className="flex items-center mb-4">
            <FileTextOutlined className="mr-2 text-[#52c41a]" />
            <Title level={5} className="m-0">
              当前任务
            </Title>
          </div>
          <Card size="small">
            <List
              itemLayout="horizontal"
              dataSource={tasks}
              renderItem={(item) => (
                <List.Item>
                  <List.Item.Meta
                    avatar={
                      <div
                        className={`w-8 h-8 rounded-md flex items-center justify-center ${
                          item.priority === "high"
                            ? "bg-[#fff2f0] text-[#ff4d4f]"
                            : "bg-[#f6ffed] text-[#52c41a]"
                        }`}
                      >
                        {item.priority === "high" ? "急" : "常"}
                      </div>
                    }
                    title={<a>{item.title}</a>}
                    description={<Text type="secondary">{item.time}</Text>}
                  />
                </List.Item>
              )}
            />
          </Card>
        </div>
        <Divider />
        <div className="mb-6">
          <div className="flex items-center mb-4">
            <TeamOutlined className="mr-2 text-[#fa8c16]" />
            <Title level={5} className="m-0">
              团队动态
            </Title>
          </div>
          <Paragraph>
            本周团队主要聚焦于项目第三阶段的开发工作,前端组已完成了核心组件的重构,后端组正在进行性能优化。
          </Paragraph>
        </div>
        <div className="mb-6">
          <div className="flex items-center mb-4">
            <CommentOutlined className="mr-2 text-[#722ed1]" />
            <Title level={5} className="m-0">
              最新反馈
            </Title>
          </div>
          <Card size="small" type="inner">
            <Paragraph>
              "新的界面设计得到了客户的积极反馈,特别是可调整的面板设计,让不同角色的用户都能获得适合自己工作习惯的布局。"
            </Paragraph>
            <Text type="secondary">—— 产品经理,XXX</Text>
          </Card>
        </div>
        <div>
          <div className="flex items-center mb-4">
            <PaperClipOutlined className="mr-2 text-[#eb2f96]" />
            <Title level={5} className="m-0">
              使用提示
            </Title>
          </div>
          <div className="p-3 bg-[#f6ffed] rounded-md border border-[#b7eb8f]">
            <Text type="secondary">
              当前抽屉宽度: <strong>{drawerWidth}px</strong>。您可以: 1.
              拖动左侧边缘调整宽度 2. 内容区域会根据宽度自动重新布局 3.
              适合查看不同密度的信息
            </Text>
          </div>
        </div>
      </Drawer>
    </div>
  );
};

拖拽交互的价值

可调整抽屉的设计带来了明显的用户体验提升:

  1. 自适应内容:用户可以根据当前查看的内容类型调整面板尺寸

  2. 个性化布局:不同用户或场景下可以设置不同的面板大小

  3. 多任务处理:宽面板适合详情查看,窄面板适合边操作边参考

  4. 渐进式披露:可以从紧凑视图逐步展开到详细视图

六、Modal 背景模糊:朦胧美学的视觉升级

在传统 Web 应用中,模态框的遮罩层往往是简单的半透明黑色,视觉效果单调且缺乏现代感。而在 iOS 和 macOS 等系统中,毛玻璃(frosted glass)效果已成为标志性的设计语言效果样式。Ant Design 6.0 为所有弹层组件引入了原生背景模糊支持,并提供了强大的语义化样式定制能力,让开发者能轻松打造出高级感十足的视觉效果。

背景模糊与语义化样式定制

以下示例展示了如何结合 Ant Design 6.0 的背景模糊特性和 antd-style 库,实现两种不同风格的模态框:

import { useState } from "react";
import { Button, Flex, Modal, Card, Image, Typography, Space } from "antd";
import type { ModalProps } from "antd";
import { createStyles } from "antd-style";
const { Title, Text } = Typography;
// 使用 antd-style 的 createStyles 定义样式
const useStyles = createStyles(({ token }) => ({
  // 用于模态框容器的基础样式
  container: {
    borderRadius: token.borderRadiusLG * 1.5,
    overflow: "hidden",
  },
}));
// 示例用的共享内容
const sharedContent = (
  <Card size="small" bordered={false}>
    <Image
      height={300}
      src="https://gw.alipayobjects.com/zos/antfincdn/LlvErxo8H9/photo-1503185912284-5271ff81b9a8.webp"
      alt="示例图片"
      preview={false}
      className="mx-auto!"
    />
    <Text type="secondary" style={{ display: "block", marginTop: 8 }}>
      Ant Design 6.0 默认的模糊背景与 antd-style
      定制的毛玻璃面板相结合,营造出深邃而富有层次的视觉体验。
    </Text>
  </Card>
);
export default () => {
  const [blurModalOpen, setBlurModalOpen] = useState(false);
  const [gradientModalOpen, setGradientModalOpen] = useState(false);
  const { styles: classNames } = useStyles();
  // 场景1:背景玻璃模糊效果(朦胧美学)
  const blurModalStyles: ModalProps["styles"] = {
    body: {
      padding: 24,
    },
  };
  // 场景2:渐变色背景模态框(无模糊效果)
  const gradientModalStyles: ModalProps["styles"] = {
    mask: {
      backgroundImage: `linear-gradient(
        135deg, 
        rgba(99, 102, 241, 0.8) 0%, 
        rgba(168, 85, 247, 0.6) 50%, 
        rgba(236, 72, 153, 0.8) 100%
      )`,
    },
    body: {
      padding: 24,
    },
    header: {
      background: "linear-gradient(to right, #6366f1, #a855f7)",
      color: "#fff",
      borderBottom: "none",
    },
    footer: {
      borderTop: "1px solid #e5e7eb",
      textAlign: "center",
    },
  };
  // 共享配置
  const sharedProps: ModalProps = {
    centered: true,
    classNames,
  };
  return (
    <div className="w-full h-[100vh] overflow-auto p-[24px] space-y-5">
      <Card
        title="Ant Design 6 模态框样式示例"
        bordered={false}
        extra={
          <Text type="secondary" className="text-sm">
            朦胧美学 + 渐变背景,高级感拉满!
          </Text>
        }
      >
        <Flex
          gap="middle"
          align="center"
          justify="center"
          style={{ padding: 40, minHeight: 300 }}
        >
          <Button
            type="primary"
            size="large"
            onClick={() => setBlurModalOpen(true)}
          >
            🌫️ 背景玻璃模糊效果
          </Button>
          <Button size="large" onClick={() => setGradientModalOpen(true)}>
            🎨 渐变色背景模态框
          </Button>
          {/* 模态框 1:背景玻璃模糊效果(朦胧美学) */}
          <Modal
            {...sharedProps}
            title="背景玻璃模糊效果"
            styles={blurModalStyles}
            open={blurModalOpen}
            onOk={() => setBlurModalOpen(false)}
            onCancel={() => setBlurModalOpen(false)}
            okText="太美了"
            cancelText="关闭"
            mask={{ enabled: true, blur: true }}
            width={600}
          >
            {sharedContent}
            <div
              style={{
                marginTop: 16,
                padding: 16,
                background: "rgba(255, 255, 255, 0.6)",
                borderRadius: 8,
                backdropFilter: "blur(10px)",
              }}
            >
              <Text type="secondary">
                <strong>💡 设计亮点:</strong>
                启用了 mask=&#123;&#123; blur: true &#125;&#125;,
                背景会自动应用模糊效果,营造出朦胧美学的高级质感。
              </Text>
            </div>
          </Modal>
          {/* 模态框 2:渐变色背景(无模糊效果) */}
          <Modal
            {...sharedProps}
            title="渐变色背景模态框"
            styles={gradientModalStyles}
            open={gradientModalOpen}
            onOk={() => setGradientModalOpen(false)}
            onCancel={() => setGradientModalOpen(false)}
            okText="好看"
            cancelText="关闭"
            mask={{ enabled: true, blur: false }}
            width={600}
          >
            {sharedContent}
            <div
              style={{
                marginTop: 16,
                padding: 16,
                background: "linear-gradient(135deg, #fef3c7 0%, #fce7f3 100%)",
                borderRadius: 8,
                border: "1px solid rgba(168, 85, 247, 0.2)",
              }}
            >
              <Text type="secondary">
                <strong>🎨 设计亮点:</strong>
                通过 styles.mask 设置渐变背景色,同时 styles.header
                应用了渐变头部,打造独特的视觉体验。
              </Text>
            </div>
          </Modal>
        </Flex>
        <div className="mt-6 p-5 bg-gradient-to-r from-blue-50 to-purple-50 rounded-xl border border-purple-200">
          <Title level={5} className="mb-3">
            📚 技术要点
          </Title>
          <Space direction="vertical" size="small" className="w-full">
            <Text>
              • <strong>玻璃模糊:</strong>使用 mask=&#123;&#123; blur: true &#125;&#125; 启用原生模糊效果
            </Text>
            <Text>
              • <strong>渐变背景:</strong>通过 styles.mask.backgroundImage 设置渐变色
            </Text>
            <Text>
              • <strong>语义化定制:</strong>使用 styles.header/body/footer 精准控制各部分样式
            </Text>
            <Text>
              • <strong>antd-style 集成:</strong>使用 createStyles 定义可复用的样式类名
            </Text>
          </Space>
        </div>
      </Card>
    </div>
  );
};

核心特性解析

1. 背景模糊开关

通过 mask 属性的 blur 配置项,可以一键开启/关闭背景模糊效果:

  • mask={{ enabled: true, blur: true }}:启用毛玻璃效果
  • mask={{ enabled: true, blur: false }}:使用传统半透明遮罩

2. 语义化样式定制

styles 属性允许精准控制组件各个部分的样式,无需编写复杂的 CSS 选择器:

  • styles.mask:遮罩层样式(可设置渐变背景)
  • styles.header:头部样式(可定制颜色、边框)
  • styles.body:内容区样式(可调整间距)
  • styles.footer:底部样式(可设置对齐方式)

3. antd-style 集成

结合 antd-style 可以创建主题感知的样式:

  • 访问 Design Token(如 token.borderRadiusLG)
  • 样式自动响应主题切换(亮色/暗色模式)
  • 通过 classNames 属性应用 CSS 类名

视觉效果对比

使用背景模糊和语义化样式定制后,Modal 的视觉呈现发生了显著变化:

1. 背景模糊效果: 遮罩层从单调的半透明黑色变为毛玻璃效果,背景内容呈现柔和的模糊感

2. 精准样式控制: 通过 styles.mask/header/body/footer 可以像搭积木一样组装出品牌化的对话框

3. 主题联动: 结合 antd-style 后,样式会自动响应全局主题切换,无需手动维护暗色模式样式

4. 维护性提升: 告别 .ant-modal .ant-modal-content .ant-modal-header 这样的深层选择器,样式意图清晰明确

这种设计让 Ant Design 6.0 的组件定制从"CSS 覆盖战争"升级为"API 声明式配置",显著降低了样式维护成本,同时保持了高度的灵活性。

七、Card 赛博朋克风格:霓虹科技美学的呈现

在传统的企业级应用中,卡片组件往往采用简洁素雅的设计。但对于游戏、科技、创意类产品,开发者往往需要更具视觉冲击力的效果。Ant Design 6.0 的 Card 组件配合 antd-style,可以轻松实现赛博朋克风格的霓虹发光边框、深色内阴影和动态动画效果,让你的界面充满未来感和科技感。

赛博朋克卡片实现

以下示例展示了如何使用 antd-style 的 CSS-in-JS 能力,为 Card 组件打造完整的赛博朋克视觉风格:

import { Card, Typography, Button, Space, Avatar, Row, Col } from "antd";
import { createStyles } from "antd-style";
import {
  ThunderboltOutlined,
  RocketOutlined,
  FireOutlined,
  StarOutlined,
  TrophyOutlined,
} from "@ant-design/icons";
const { Title, Text, Paragraph } = Typography;
// 使用 antd-style 创建赛博朋克风格样式
const useStyles = createStyles(({ css }) => ({
  // 赛博朋克卡片 - 紫色霓虹
  cyberpunkCard: css`
    background: rgba(1515350.9);
    border2px solid #a855f7;
    border-radius16px;
    overflow: hidden;
    position: relative;
    transition: all 0.3s ease;
    /* 发光边框效果 */
    box-shadow0 0 20px rgba(168852470.5),
      inset 0 0 20px rgba(168852470.1);
    &:hover {
      transformtranslateY(-5px);
      box-shadow0 0 30px rgba(168852470.8),
        inset 0 0 30px rgba(168852470.2);
      border-color#c084fc;
    }
    /* 顶部霓虹灯条 */
    &::before {
      content"";
      position: absolute;
      top0;
      left0;
      right0;
      height3px;
      backgroundlinear-gradient(
        90deg,
        transparent,
        #a855f7,
        #c084fc,
        #a855f7,
        transparent
      );
      animation: neonFlow 3s ease-in-out infinite;
    }
    @keyframes neonFlow {
      0%100% { opacity1; }
      50% { opacity0.5; }
    }
  `,
  // 霓虹文字
  neonText: css`
    color: #fff;
    text-shadow0 0 10px currentColor,
                 0 0 20px currentColor,
                 0 0 30px currentColor;
    font-weight: bold;
  `,
  // 霓虹按钮
  neonButton: css`
    background: transparent !important;
    border2px solid currentColor !important;
    color: inherit !important;
    text-shadow0 0 10px currentColor;
    box-shadow0 0 10px currentColor,
                inset 0 0 10px rgba(2552552550.1);
    transition: all 0.3s ease !important;
    &:hover {
      transformscale(1.05);
      box-shadow0 0 20px currentColor,
                  inset 0 0 20px rgba(2552552550.2!important;
    }
  `,
  // 数据面板
  dataPanel: css`
    background: rgba(0000.3);
    border1px solid rgba(2552552550.1);
    border-radius8px;
    padding16px;
    backdrop-filterblur(10px);
  `,
}));
export default () => {
  const { styles } = useStyles();
  return (
    <Row gutter={[24, 24]}>
      <Col span={8}>
        <Card
          className={styles.cyberpunkCard}
          hoverable
          styles={{ body: { padding24 } }}
        >
          <div style={{ position"relative", zIndex: 1 }}>
            {/* 头部 */}
            <div style={{ display"flex", alignItems: "center", marginBottom: 16 }}>
              <Avatar
                size={64}
                icon={<ThunderboltOutlined />}
                style={{
                  background: "linear-gradient(135deg, #a855f7, transparent)",
                  border: "2px solid #a855f7",
                  color: "#a855f7",
                  filter: "drop-shadow(0 0 10px #a855f7)",
                }}
              />
            </div>
            {/* 标题 */}
            <Title level={4} className={styles.neonText} style={{ color"#a855f7" }}>
              QUANTUM PROCESSOR
            </Title>
            <Text style={{ color"#888", display: "block", marginBottom: 16 }}>
              量子处理器
            </Text>
            {/* 描述 */}
            <Paragraph style={{ color"#bbb", marginBottom: 20 }}>
              第九代量子处理器,采用纳米级光刻技术,配备AI神经网络加速引擎。
            </Paragraph>
            {/* 数据面板 */}
            <div className={styles.dataPanel} style={{ marginBottom: 20 }}>
              <Space direction="vertical" style={{ width"100%" }} size={12}>
                <div style={{ display"flex", justifyContent: "space-between" }}>
                  <Text style={{ color"#888" }}>处理速度</Text>
                  <Text strong style={{ color"#a855f7" }}>5.2 PHz</Text>
                </div>
                <div style={{ display"flex", justifyContent: "space-between" }}>
                  <Text style={{ color"#888" }}>核心数量</Text>
                  <Text strong style={{ color"#a855f7" }}>128 核</Text>
                </div>
              </Space>
            </div>
            {/* 能量条 */}
            <div style={{ marginBottom: 20 }}>
              <div style={{ display"flex", justifyContent: "space-between", marginBottom: 8 }}>
                <Text style={{ color"#888", fontSize: 12 }}>POWER LEVEL</Text>
                <Text strong style={{ color"#a855f7", fontSize: 12 }}>9999</Text>
              </div>
              <div style={{
                height6,
                background: "rgba(0, 0, 0, 0.3)",
                borderRadius: 3,
                overflow: "hidden",
                border: "1px solid rgba(255, 255, 255, 0.1)",
              }}>
                <div style={{
                  height"100%",
                  width: "92%",
                  background: "linear-gradient(90deg, #a855f7, transparent)",
                  boxShadow: "0 0 10px #a855f7",
                }} />
              </div>
            </div>
            {/* 操作按钮 */}
            <Space style={{ width"100%" }}>
              <Button
                type="primary"
                className={styles.neonButton}
                style={{ color"#a855f7", flex: 1 }}
                icon={<StarOutlined />}
              >
                激活
              </Button>
              <Button
                className={styles.neonButton}
                style={{ color"#a855f7" }}
                icon={<TrophyOutlined />}
              >
                详情
              </Button>
            </Space>
          </div>
        </Card>
      </Col>
    </Row>
  );
};

6.gif

核心技术要点

1. 霓虹发光边框

通过多层 box-shadow 实现外发光和内阴影的叠加效果:

box-shadow: 
  0 0 20px rgba(168852470.5),        /* 外发光 */
  inset 0 0 20px rgba(168852470.1);  /* 内阴影 */

2. 动态霓虹灯条

使用伪元素和渐变动画创建流动的霓虹灯效果:

&::before {
  content"";
  backgroundlinear-gradient(90deg, transparent, #a855f7, transparent);
  animation: neonFlow 3s ease-in-out infinite;
}

3. 霓虹文字效果

通过 text-shadow 的多层叠加模拟霓虹灯文字:

text-shadow: 
  0 0 10px currentColor,
  0 0 20px currentColor,
  0 0 30px currentColor;

4. 毛玻璃数据面板

结合半透明背景和 backdrop-filter 实现毛玻璃效果:

background: rgba(0000.3);
backdrop-filter: blur(10px);

5. 交互动画

hover 时同步触发多个动画效果:

  • 卡片上浮:transform: translateY(-5px)
  • 发光增强:box-shadow 强度提升
  • 边框颜色变化:border-color 过渡

样式定制优势

使用 Ant Design 6.0 + antd-style 实现赛博朋克风格的优势:

1. CSS-in-JS 强大能力: 支持嵌套、伪元素、动画等高级特性,无需额外 CSS 文件

2. 类型安全: TypeScript 提供完整的类型提示,减少样式错误

3. 动态主题: 可以轻松切换不同颜色的霓虹主题(紫色、青色、粉色等)

4. 组件封装: 样式与组件逻辑共存,便于复用和维护

5. 性能优化: antd-style 自动处理样式注入和缓存,性能优秀

这种设计风格通过强烈的视觉冲击力和独特的科技感,能够有效吸引用户注意力,提升品牌记忆度,特别适合面向年轻用户群体的产品。

八、升级建议与实践策略

对于考虑升级到 Ant Design 6.0 的团队,建议采取以下策略:

1.渐进式升级路径

  1. 新项目直接使用:全新项目建议直接使用 6.0 版本,享受所有新特性

  2. 现有项目评估:评估项目依赖和定制程度,制定分阶段升级计划

  3. 组件逐步替换:可以先替换使用新功能的组件,再逐步迁移其他部分

2.兼容性注意事项

  1. 检查废弃 API:Ant Design 6.0 移除了之前版本已标记为废弃的 API

  2. 样式覆盖检查:如果项目中有深度定制样式,需要检查与新版本的兼容性

  3. 测试核心流程:升级后重点测试表单提交、数据展示等核心用户流程

九、总结

Ant Design 6.0 的组件功能更新聚焦于解决实际开发中的痛点,通过引入 Masonry 瀑布流布局、Tooltip 平滑移动、InputNumber 拨轮模式、Drawer 拖拽调整、Modal 背景模糊以及 Card 深度定制等特性,显著提升了开发效率和用户体验。

这些更新体现了现代前端设计的几个核心趋势:

1. 交互流畅性: 如 Tooltip 的平滑过渡,减少界面跳跃感

2. 设备适配性: 如 InputNumber 的触摸友好设计

3. 布局灵活性: 如 Masonry 的动态布局和 Drawer 的可调整尺寸

4. 视觉现代化: 如 Modal 的背景模糊效果,营造朦胧美学的高级质感

5. 样式可控性: 通过 classNamesstyles API 实现精准的组件定制

6. 风格多样性: 结合 antd-style 可实现从企业风到赛博朋克等多样化视觉风格

特别是与 antd-style 的深度集成,让开发者能够充分发挥 CSS-in-JS 的强大能力,从简洁的企业级设计到炫酷的赛博朋克风格,都能轻松实现。这些改进让 Ant Design 6.0 不仅保持了企业级应用的稳定性和专业性,还增加了更多现代化、人性化的交互细节和视觉创意空间,是构建下一代 Web 应用的理想选择。

往期回顾

1.Java 设计模式:原理、框架应用与实战全解析|得物技术

2.Go语言在高并发高可用系统中的实践与解决方案|得物技术 

3.从0到1搭建一个智能分析OBS埋点数据的AI Agent|得物技术

4.数据库AI方向探索-MCP原理解析&DB方向实战|得物技术

5.项目性能优化实践:深入FMP算法原理探索|得物技术

文 /三七

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

深入理解 Webpack5:从打包到热更新原理

文章顶部.png作者卡片

深入理解 Webpack5:从打包到热更新原理

为什么需要构建工具?

一、解决 “高级语法与浏览器兼容性” 的矛盾

前端技术迭代快(如 ES6+ 的 async/await、箭头函数,CSS 的 grid 布局),但旧浏览器(如 IE11、Safari 10)不支持这些新特性。构建工具通过**转译(Transpile)前缀添加(Prefixing)**解决兼容问题:

1. JavaScript 转译

发者会使用 ES6+ 及以上语法(如类 class)、TypeScript(类型增强的 JS)、JSX(React 组件语法)等提高开发效率,但这些语法在低版本浏览器或部分现代浏览器中无法直接运行。构建工具(配合 Babel、tsc 等)会将这些高级语法转译为浏览器可识别的 ES5 语法。例如:

// 开发时写的 ES6 语法
const sum = (a, b) => a + b;
// 构建工具转译后(兼容旧浏览器)
var sum = function(a, b) { return a + b; };

2. CSS 属性添加浏览器前缀

开发者常用 Sass/Less/Stylus 等预处理器(支持变量、嵌套、混入等功能)编写 CSS,或使用 CSS Modules 解决样式冲突,但浏览器只认识原生 CSS。构建工具(配合 sass-loaderpostcss 等)会将预处理器语法编译为原生 CSS,同时通过 autoprefixer自动添加浏览器前缀(如 -webkit--moz-),适配不同浏览器的 CSS 特性支持。例如:

// 开发时写的 Sass
$color: red;
.box {
  color: $color;
  &:hover { color: blue; }
}
// 构建后生成的 CSS(带前缀)
.box { color: red; }
.box:hover { color: blue; }
@supports (-webkit-appearance: none) { .box { /* 适配webkit内核 */ } }
二、处理 “模块化开发与浏览器加载限制” 的矛盾

前端项目早已从 “单文件” 发展为 “多模块组合”,但浏览器对模块化的原生支持有限,需要构建工具进行依赖管理与打包

1. 模块化语法统一与解析前端模块化规范众多(ES Modules import/export、CommonJS require/module.exports、AMD 等),不同库可能使用不同规范,浏览器原生无法统一处理。构建工具会解析所有模块的依赖关系,并将不同规范的模块转换为统一格式(通常是 ES Modules 或打包后的单文件),确保代码能正确运行。

2. 依赖合并与路径处理一个复杂项目可能依赖数百个模块(如业务组件、工具函数、第三方库),若直接让浏览器加载这些模块,会导致:

  • 大量网络请求(浏览器对同一域名的并发请求数有限,通常 6-8 个),严重拖慢加载速度;

  • 模块路径混乱(如相对路径、别名路径 @/components 等,浏览器无法直接识别)。

构建工具会将多个关联模块合并为少数几个 “chunk”(打包产物),并自动处理路径解析(如将 @/components 映射到实际目录),减少请求数并解决路径问题。

三、优化 “开发效率与生产性能” 的矛盾

开发时,我们追求的是高效的调试快速的代码变更反馈(开发体验)。而生产环境追求的是极致的加载性能用户体验。构建工具通过区分开发模式(development) 和生产模式(production),用一套配置完美解决两种截然不同的需求。

1. 开发模式(Development) - 追求速度与可调试性

  • Source Map:将打包压缩后的代码映射回原始源代码。当你在浏览器调试时,看到的仍然是清晰可读的原始文件结构,而不是一团混乱的打包后代码,使得调试变得非常简单。

  • 热更新(HMR):只替换修改了的模块,保持应用当前状态(如表单输入、滚动位置),无需整页刷新,实现秒级更新。

  • 更快的构建速度:会跳过代码压缩、Tree Shaking 等耗时的优化步骤,保证开发时重新打包的速度。

2. 生产模式(Production) - 追求体积与性能

  • 代码压缩(Minification):使用 Terser 压缩 JavaScript,cssnano 压缩 CSS,移除所有注释、空格、缩短变量名,能显著减小文件体积(通常减少 60%-70%)。

  • 代码分割(Code Splitting):将代码拆分成多个按需加载的块(chunk)。结合动态导入(import()),可以实现懒加载,让用户只加载当前页面或路由所需的代码,极大加速首屏加载时间。

  • Tree Shaking:像摇树一样,通过静态分析抖落未被使用的代码(dead code),并将其从最终打包文件中移除。例如,你只引用了 Lodash 中的 debounce 函数,打包后就只会包含 debounce 及其依赖,而不是整个 Lodash 库。

  • 资源优化与压缩:自动压缩图片、将小图片转为 Base64 内联、为现代浏览器提供更高效的图片格式(如 WebP/AVIF)。

有哪些构建工具?

构建工具 开发语言 Github Star 发布时间 作者
Vite TypeScript 75k 2020年4月 ViteJS
Webpack JavaScript 65.5k 2012年3月 Facebook
Parcel JavaScript 43.9k 2017年12月 Filament Group
esbuild Go 39.3k 2020年 Evan Wallace
Gulp JavaScript 33.1k 2013年 Eric Schoffstall
swc Rust 32.7k 2017年 Vercel
Turbopack Rust 28.5k 2022年10月 Vercel
Nx TypeScript 26.8k - Nrwl
Rollup JavaScript 26k 2015年 Rich Harris
Snowpack JavaScript 19.4k 2020年5月 Pika
Rolldown Rust 11.9k 2024年 VueJS
Rspack Rust 11.9k 2023年3月 Bytedance
WMR JavaScript 4.9k - Preact

Webpack 工作原理

在众多前端构建工具中,Webpack 由于其高度的灵活性和强大的生态系统,依然是最广泛使用的打包工具之一。与 Vite、Parcel 等工具相比,Webpack 提供了更为细致的定制能力,能够满足复杂项目的需求,如代码分割、资源优化和热更新等功能。掌握 Webpack 不仅有助于提升开发效率,更能优化应用性能,因此本文将深入介绍 Webpack 的工作原理。

一、打包过程

Webpack 的打包流程可以分为以下关键步骤:

读取配置文件,加载构建参数

Webpack CLI 启动后,会首先加载开发者提供的配置文件webpack.config.js,包括:

  • mode、entry、output 等基本配置

  • module.rules 中的 loader 配置

  • plugins 列表

  • optimization 配置

Webpack 采用 webpack-cli 来解析命令行参数,并最终调用:

const webpack = require("webpack");
webpack(options)

此时 Webpack 会完成配置合并(Webpack 内部使用 webpack-merge 的策略),将默认配置与用户配置拼接成最终 options 对象。

这一步的作用是确定 Webpack 后续构建过程所依赖的全部元信息。

创建 Compiler 对象,初始化编译总体调度器

Webpack 的主控制器是 Compiler 类,Compiler 是 Webpack 从开始到结束贯穿始终的对象,它控制着构建生命周期的每一个阶段,并将生命周期开放给插件系统(基于 Tapable)。

Compiler 的职责包括:

  • 管理整个构建生命周期

  • 负责调用插件、触发钩子

  • 统一调度 Compilation、模块工厂、Chunk 构建等核心内容

简化后的核心结构如下:

class Compiler extends Tapable {
  constructor(options) {
    super();
    this.options = options;
    this.hooks = {
      initialize: new SyncHook(),
      run: new AsyncSeriesHook(["compiler"]),
      compile: new SyncHook(["params"]),
      make: new AsyncParallelHook(["compilation"]),
      emit: new AsyncSeriesHook(["compilation"]),
      done: new SyncHook(["stats"]),
      // ...
    };
  }

  run(callback) {
    this.hooks.run.callAsync(this, err => {
      this.compile(onCompiled);
    });
  }

  compile(callback) {
    const compilation = this.newCompilation();
    this.hooks.make.callAsync(compilation, err => {
      compilation.finish(() => {
        compilation.seal(callback);
      });
    });
  }
}

compiler.run() 是 Webpack 单次构建流程的启动函数。它做三件事:

  • 调度构建生命周期钩子,如 beforeRun / run

  • 调用 compiler.compile() 执行真正的编译逻辑

  • 调用 emit / afterEmit / done 等钩子产出结果

Compiler 控制整体流程,而 Compilation 则控制:

  • 模块构建(构建每一个 JS/CSS/图片等模块)

  • 模块依赖分析

  • Chunk 构建

  • 代码优化(Tree Shaking、SplitChunks)

  • 生成最终资源(assets)

Compilation 是构建的“工作单位”,负责所有实际的打包工作。

从入口文件开始,递归查找和编译模块

EntryPlugin 会根据 entry 配置向 Compilation 注册入口模块。Webpack 随后会从入口开始递归解析依赖,构建整个依赖图。当遇到非 JS 模块时,通过 Loader 进行处理(如将 TS/LESS/图片等转为可用模块)。

Webpack 在构建模块时,会根据 module.rules 匹配对应的 loader,从右向左依次对模块内容进行转换。

Loader 的作用包括但不限于:

  • Babel:将 ES6+ 转译为 ES5

  • css-loader:处理 CSS 文件内容

  • url-loader / file-loader:处理图片资源

  • vue-loader / ts-loader:处理 SFC 或 TS

最终,所有文件都会被转换成 JavaScript 字符串供后续 AST 解析。

解析每个模块的依赖关系,构建模块图 ModuleGraph

webpack 把 loader 产出的 JS 代码解析成 AST,识别出所有依赖节点(import / require / import() / loader 插入的依赖等),并把这些依赖投影到 ModuleGraph 上,形成可供后续优化和分块的有向依赖图。

// NormalModule.build() 内部流程(精简)
runLoaders(resource, loaders, (err, result) => {
  const source = result.result[0];             // loader 输出 -> JS 字符串 / Source
  const parser = compilation.getParser("javascript");
  const ast = parser.parse(source);            // acorn -> AST
  const dependencies = parser.collectDependencies(ast); // 解析产生 Dependency 实例
  for (const dep of dependencies) {
    const resolved = resolver.resolve(dep.request);
    const depModule = normalModuleFactory.create(resolved);
    moduleGraph.connect(module, dep, depModule); // 建图:模块 -> 依赖 -> 目标模块
  }
});
  • 解析器(JavascriptParser)是通过 Tapable hook 逐节点触发、生成  实例(以便插件/loader 能介入解析过程)。

  • NormalModuleFactory / Resolver 负责把语法层面的请求(request)解析到具体路径并创建 Module 实例,Compilation 将这些新模块加入构建队列直至递归完成。

优化模块

在模块级别上减少最终运行时代码体积并提升运行时性能的策略,包括剔除未使用代码(Tree Shaking)、合并模块以缩短调用链与减少函数包装开销(Scope Hoisting / Module Concatenation)、以及其他小粒度优化(常量折叠、dead code elimination 交给 Terser 等压缩器完成)。

根据模块图生成 Chunk,对生成的代码块进行进一步优化

Webpack 将完成构建的模块根据入口点和不同类型的依赖关系构建多个 Chunk,Chunk 是 Webpack 打包产物的基础单位,每个 Chunk 对应一个最终输出文件(或多个文件组合),同时承载 runtime、依赖关系、模块顺序和异步加载信息。ChunkGraph 会在这一阶段被创建并建立,它记录:

  • 当前 Chunk 包含哪些模块

  • 模块属于哪些 Chunk

  • Chunk 与 Chunk 之间的关系

生成的 Chunk 进行最终优化,包括渲染 Chunk 内部代码、处理异步加载和动态 import、应用代码分割策略提取公共模块、注入 runtime 逻辑、生成按需加载和预加载提示,以及通过 [contenthash] 和独立 runtime Chunk 实现长期缓存,从而使输出文件体积最小化、加载高效并支持浏览器缓存优化。

生成最终资源并写入输出目录

Webpack 构建完成后会将所有 Chunk 转换为可执行文件(assets),例如:

  • main.js

  • vendors.js

  • style.css

  • 图片 / 字体等静态资源

输出阶段触发 Compiler 的 emit 钩子,随后写入文件系统。

emitAssets(compilation, callback) {
  const { entries, modules, chunks, assets } = compilation;
  const output = this.options.output;
    
  // 调用 Plugin emit 钩子
  this.hooks.emit.call();
  
  // 若 output.path 不存在,进行创建
  if (!fs.existsSync(output.path)) {
    fs.mkdirSync(output.path);
  }
    
  // 将 assets 中的内容写入文件系统中
  Object.keys(assets).forEach((fileName) => {
    const filePath = path.join(output.path, fileName);
    fs.writeFileSync(filePath, assets[fileName]);
  });
  
  // 结束之后触发钩子
  this.hooks.done.call();
  
  callback(null, {
    toJSON: () => {
      return {
        entries,
        modules,
        chunks,
        assets,
      };
    },
  });
}
二、热更新过程

热模块替换(HMR,Hot Module Replacement)是指当我们对代码修改并保存后,Webpack 将会对代码进行重新打包,并将新的模块发送到浏览器端,浏览器用新的模块替换掉旧的模块 ,以实现在不刷新浏览器的前提下更新页面。

整体流程:

  • 首先是 Webpack 监听到文件修改后,进行编译,打包出新的文件,得到新的 hash。

  • 其次是 webpack-dev-server 通过 websocket 通知浏览器,告诉浏览器新的 hash。

  • 浏览器收到通知后,请求新的文件,拿到新的文件后,更新页面。

文件监听

Webpack 的文件监听机制用于在开发模式下自动检测文件变化并触发增量编译。其核心由三个组件构成:

  • Watching(调度层)

  • Watchpack(抽象管理层)

  • DirectoryWatcher(底层监听实现层)

整体基于 发布–订阅模型(EventEmitter) 实现,整个过程是典型的 **事件驱动的监听 → 通知 → 重新编译。**Webpack 通过以下流程实现监听:

  1. compiler.watch() 创建 Watching 实例,负责整个监听调度。

  2. Watching 初始化 Watchpack,并让其负责监听所有文件和目录。

  3. Watchpack 内部使用 DirectoryWatcher 监听目录变动,同时监听单文件变化。

  4. 文件变动发生时,DirectoryWatcher 发出 change 事件。

  5. Watchpack 将事件上报给 Watching。

  6. Watching 调用 Webpack 的增量编译流程,生成新的构建文件。

Watching

Watching 是 Webpack 启动 watch 时最上层的控制者,通过:

  • 创建并启动 Watchpack;

  • 订阅 Watchpack 的 changeremove 事件;

  • 触发增量编译;

  • 将新的 hash 传给 dev-server,使浏览器执行 HMR 刷新。

class Watching {
    constructor(){
        this.startTime = Date.now()
    }
    watch(files){
        let watcher = new Watchpack();
        let callback =  (changes, removals)=>{
             // 执行各种hooks
        }
        watcher.once("aggregated",callback);
        watcher.watch(files, directories, this.startTime);
        return watcher;
    }
}

Watchpack 

Watchpack 是 Webpack 文件监听功能的核心抽象层,负责:

  • 统一管理所有被监听的文件和目录;

  • 为每个目录创建 DirectoryWatcher

  • 决定具体监听方式(fs.watch、fs.watchFile、轮询);

  • 将底层监听事件汇总后统一向外发射:

    • change(文件变化)

    • remove(文件被删除)

所以它不直接监听,而是管理多个监听者。

class Watchpack extends EventEmitter{
    constructor(){
        this.fileWatchers = new Map();
        this.directoryWatchers = new Map();
    }
    watch(files, directories, startTime){
        // 为每个文件添加一个或多个 DirectoryWatcher
        for(let file of files){
            let watcher = new DirectoryWatcher();
            this.fileWatchers.set(file, watcher);
            watcher.on("change", this._onTimeout);
            watcher.watch(file, startTime);
        }
        // for of directories
        // bala bala
        
        // 源码这里的方法调用,没太明白是个啥意思?
        // 后面还有一个watchEventSource.watch()
        watchEventSource.batch()
    }
    _onTimeout() {
        const changes = this.aggregatedChanges;
        const removals = this.aggregatedRemovals;
        this.emit("aggregated", changes, removals);
    }
}

DirectoryWatcher

DirectoryWatcher 是最底层、最核心的监听器,负责检测:

  • 文件内容修改(mtime 改变)

  • 新文件创建

  • 文件删除(readdir 不存在)

class DirectoryWatcher extends EventEmitter {
    constructor(){
       this.watchers = new Map();
       this.files = new Map();
       
       this.watcher = watchEventSource.watch(file);
       this.watcher.on("change", this.onWatchEvent);
    }
    watch(file, startTime){
        let watchers = new Set();
        let watcher = new Watcher(startTime);
        watchers.add(watcher);
        this.watchers.set(file, watchers);     
    }
    onWatchEvent(eventType, filename){
        fs.lstat(filePath, (err, stats)=>{
            this.setFileTime(filePath, stats.mtime);
            // this.setDirectory(filePath, eventType);
        })
    }
    setFileTime(filePath, mtime){
        // 记录文件信息
        let safeTime = Date.now();
        this.files.set(filePath, {
            safeTime,
            timestamp: mtime
        })
        let watchers = this.watchers.get(filePath)
        for (const w of watchers) {
           if (w.checkStartTime(safeTime)) {
                // 文件更新后触发的事件源
                w.emit("change", mtime);
            }
        }
       
    }
}
  • 优先使用操作系统事件(fs.watch)

  • 系统不支持时退回轮询(Polling)

    • 通过周期性的 fs.stat + mtime 对比检测变化。    
模块编译处理

当文件监听到变化后,Webpack 会触发一次增量编译,除了与打包类似的编译外,webpack 还会往 Compiler 注入 HotModuleReplacementPlugin 插件,插件内部做了三方面处理:

  • **语法转译:**模块代码允许使用 module.hot.xxx 进行个性化处理,需要将这些语法处理为符合运行时能力的代码;

  • **HMR Runtime Module:**HMR模式下会将 HMR Runtime 模块进行注入操作。

  • **产物生成:**HMR 模式下每次变更都会产生  manifest 和 update chunks 文件,编译器需要额外处理这些产生生成逻辑。

语法转译

在开发模式下,模块里可能会用到 HMR API,例如:

if (module.hot) {
    module.hot.accept('./foo.js', () => {
        console.log('foo.js 更新了');
    });
}

HotModuleReplacementPlugin 提供了语法转义能力:

  • 对模块代码进行解析,找到 module.hot.xxx 相关调用;

    • 主要替换语法有 module.hot、module.hot.accept、module.hot.decline 语法
  • 将这些调用转换成运行时可以识别的代码(符合 HMR Runtime 约定);

  • 注入必要的回调钩子,保证模块在被替换时能够正确触发 accept、dispose 回调。

让开发者书写的 HMR API 能够在浏览器端的 HMR Runtime 中生效,实现模块级别的热替换。

HMR Runtime Module 注入

HMR 在运行时需要相应基础能力才能够运行,在 HotModuleReplacementPlugin 内部会往编译器注入 HotModuleReplacementRuntimeModule 保证应用能够正常提供运行时能力。

该 Runtime Module 实现了:

  • WebSocket 通信(接收 hash / ok 消息);

  • 补丁文件的加载(hot-update.js / hot-update.json);

  • 模块替换逻辑(dispose → apply → accept);

  • 状态管理(已替换的模块、更新队列等)。

作用:保证浏览器端可以正确识别 HMR 消息,拉取补丁并执行模块替换,而不刷新整个页面。

manifest 及 Chunk 生成

Compiler 每次编译之后都会得到每个模块、Chunk 的哈希值,Compiler 通过判断本次编译的产物的哈希和上次编译记录的Hash得出模块变更信息,HotModuleReplacementPlugin 根据模块变更信息生成两份数据:

  1. 更新描述manifest.json:以JSON形式数据
  • updateChunkIds:更新的 Chunk Id

  • removedChunkIds:移除的 Chunk Id

  • removedModuleIds:移除的 Module Id

  1. 模块 Chunk 产物[key].[newHash].hot-update.js:只包含更新模块代码
// [key].[newHash].hot-update.js
self["webpackHotUpdate_myApp"]("main", {
  0: function(module, __webpack_exports__, __webpack_require__) {
    // 新模块代码
  },
  2: function(module, __webpack_exports__, __webpack_require__) {
    // 新模块代码
  }});

当 Webpack 完成增量编译后,会将新的构建产物与新的 hash 生 成在内存中,此时 Webpack-dev-server 会通过 WebSocket 将更新消息推送给浏览器。

Webpack-dev-server 的核心功能是作为 浏览器与编译器之间的中间层,让前端项目在开发阶段拥有实时更新能力。

在传统的 LiveReload 模式下,文件变更后浏览器会被强制刷新整个页面;而 HMR 在此基础上更进一步,只更新变化的模块而不刷新整页。这两种机制最终都依赖 Webpack-dev-server  提供的能力。

从整体设计来看,Webpack-dev-server 可以拆分为两个主要模块:

  • 应用通信模块(推送更新消息)

  • 静态资源服务模块(返回构建产物)

    • 使用 express 框架提供静态资源服务器功能
更新通知

服务器与浏览器之间通过 WebSocket 建立长连接,并会使用长轮询作为兜底方法。

  1. 监听 Webpack 编译完成后,主动触发 sendStats 方法

  2. 本地服务端的 WebSocket 主动发送 hash 命令和 ok 命令到本地浏览器 client 端

class Server {
 setupHooks() {
     // 初始化时注册done的监听事件,编译完成后,调用sendStats方法进行webSocket的命令发送
     this.compiler.hooks.done.tap(
         "webpack-dev-server",
         (stats) => {
             if (this.webSocketServer) {
                 this.sendStats(this.webSocketServer.clients, this.getStats(stats));
             }
             this.stats = stats;
         }
     );
 }

 sendStats(clients, stats, force) {
     // 更新当前的hash
     this.currentHash = stats.hash;
   
     // 发送给客户端当前的hash值
     this.sendMessage(clients, "hash", stats.hash);

     // 发送给客户端ok的指令
     this.sendMessage(clients, "ok");
 }
}
模块替换

当浏览器端收到 ok 消息后,Webpack 的 HMR Runtime 便会开始执行模块热替换流程。浏览器端调用 emitter将最新 hash 值发送给 HMR runtime。

import hotEmitter from "webpack/hot/emitter.js";

function reloadApp(_ref, status) {
  var hot = _ref.hot, liveReload = _ref.liveReload;
  ...
  var currentHash = status.currentHash, previousHash = status.previousHash;
  ...
  var allowToHot = search.indexOf("webpack-dev-server-hot=false") === -1;
  if (hot && allowToHot) {
    log.info("App hot update...");
    hotEmitter.emit("webpackHotUpdate", status.currentHash);
    ...
  }
  ...
}

HMR runtime 会更新 lastHash,并在必要时调用 check(),发起更新检查

hotEmitter.on("webpackHotUpdate", function (currentHash) {
  lastHash = currentHash;
  if (!upToDate() && module.hot.status() === "idle") {
    log("info", "[HMR] Checking for updates on the server...");
    check();
  }
});
var check = function check() {
  module.hot
    .check(true)
    .then(function (updatedModules) {
      ...
    })
    .catch(function (err) {
      ...
    });
};

module.hot.check最终会触发hotCheck()方法

function hotCheck(applyOnUpdate) {
  ...
  return setStatus("check")
  .then(__webpack_require__.hmrM)
  .then(function(update) {
      ...
      return setStatus("prepare").then(function() {
            return Promise.all(
              Object.keys(__webpack_require__.hmrC).reduce(function (
              promises,
              key
              ) {
                        // key = jsonp
              __webpack_require__.hmrC[key](
              update.c,
              update.r,
              update.m,
              promises,
              currentUpdateApplyHandlers,
              updatedModules
              );
              return promises;
              },
              [])
          ).then(function() {
              return waitForBlockingPromises(function() {
                  ...
                  return internalApply(applyOnUpdate);
              });
          });
      });
  });
}
__webpack_require__.hmrC.jsonp = function(
  chunkIds,
  removedChunks,
  removedModules,
  promises,
  applyHandlers,
  updatedModulesList,
) {
    applyHandlers.push(applyHandler);
    ...
    chunkIds.forEach(function(chunkId) {
        if (
        __webpack_require__.o(installedChunks, chunkId) &&
        installedChunks[chunkId] !== undefined
        ) {
            promises.push(loadUpdateChunk(chunkId, updatedModulesList));
        }
    });
};

__webpack_require__.hmrM 中 fetch 到新的 manifest.json 文件,完成后,触发 __webpack_require__.hmrC.jsonp 方法执行

执行 __webpack_require__.hmrC.jsonp,传入 manifest.json 文件中返回的key,通过 jsonp 方式请求得到 [key].[newHash].hot-update.js,执行对应的 moudle 代码的缓存并且触发对应 promise 的resolve请求,从而顺利回调internalApply()方法

internalApply()方法是 HMR 的“内部调度器”,连接了 检查更新 和 实际替换模块 两个阶段。根据更新的模块和依赖信息,决定哪些模块可以热替换、哪些模块需要 dispose,并最终调用 apply() 来应用新模块

function internalApply(options) {
 // 这里的currentUpdateApplyHandlers存储的是上面jsonp请求js文件所创建的callback
 var results = currentUpdateApplyHandlers.map(function (handler) {
     return handler(options);
 });
 currentUpdateApplyHandlers = undefined;

 results.forEach(function (result) {
   if (result.dispose) result.dispose();
 });

 var outdatedModules = [];
 results.forEach(function (result) {
     if (result.apply) {
         // 这里的result的是上面jsonp请求js文件所创建的callback所返回Object的apply方法
         var modules = result.apply(reportError);
         if (modules) {
             for (var i = 0; i < modules.length; i++) {
                 outdatedModules.push(modules[i]);
             }
         }
     }
 });

 return Promise.all([disposePromise, applyPromise]).then(function () {

     return setStatus("idle").then(function () {
         return outdatedModules;
     });
 });
}

替换的总体执行逻辑:

  1. 根据webpack配置拼接出当前 moduleId 的热更新策略,比如允许热更新,比如不允许热更新等等

  2. 根据热更新策略,拼接多个数据结构,为applay()方法代码服务

  3. internalApply()可以知道,最终会先执行result.dispose(),然后再执行result.apply()方法

拼接数据结构

  1. 根据getAffectedModuleEffects(moduleId)整理出该moduleId的热更新策略,是否需要热更新

  2. 根据多个对象拼凑出disposeapply方法所需要的数据结构

function applyHandler(options) {
   currentUpdateChunks = undefined;

   // at begin all updates modules are outdated
   // the "outdated" status can propagate to parents if they don't accept the children
   var outdatedDependencies = {}; // 使用module.hot.accept部署了依赖发生更新后的回调函数
   var outdatedModules = []; // 当前过期需要更新的modules
   var appliedUpdate = {}; // 准备更新的modules


   for (var moduleId in currentUpdate) {
       var newModuleFactory = currentUpdate[moduleId];

       // 获取之前的配置:该moduleId是否允许热更新
       var result = getAffectedModuleEffects(moduleId);

       var doApply = false;
       var doDispose = false;

       switch (result.type) {
           // ...
           case "accepted":
               if (options.onAccepted) options.onAccepted(result);
               doApply = true;
               break;
           //...
       }
       if (doApply) {
           appliedUpdate[moduleId] = newModuleFactory;
           //...代码省略... 拼凑出outdatedDependencies过期的依赖,为下面的module.hot.accept(moduleId, function() {})做准备
       }
       if (doDispose) {
           //...代码省略... 处理配置为dispose的情况
       }

   }
   currentUpdate = undefined;

   // 根据outdatedModules拼凑出需要_selfAccepted=true,即热更新是重新加载一次自己的module的数据到outdatedSelfAcceptedModules中
   var outdatedSelfAcceptedModules = [];
   for (var j = 0; j < outdatedModules.length; j++) {
       var outdatedModuleId = outdatedModules[j];
       // __webpack_require__.c = __webpack_module_cache__
       var module = __webpack_require__.c[outdatedModuleId];
       if (module && (module.hot._selfAccepted || module.hot._main) &&
           appliedUpdate[outdatedModuleId] !== warnUnexpectedRequire &&
           !module.hot._selfInvalidated
       ) {
           // _requireSelf: function () {
           //      currentParents = me.parents.slice();
           //      currentChildModule = _main ? undefined : moduleId;
           //         __webpack_require__(moduleId);
           // },
           outdatedSelfAcceptedModules.push({
               module: outdatedModuleId,
               require: module.hot._requireSelf, // 重新加载自己
               errorHandler: module.hot._selfAccepted
           });
       }
   }

   var moduleOutdatedDependencies;

   return {
       dispose: function() {...}
       apply: function(reportError) {...}
   };
}
function getAffectedModuleEffects(updateModuleId) {
   var outdatedModules = [updateModuleId];
   var outdatedDependencies = {};

   var queue = outdatedModules.map(function (id) {
       return {
           chain: [id],
           id: id
       };
   });
   while (queue.length > 0) {
       var queueItem = queue.pop();
       var moduleId = queueItem.id;
       var chain = queueItem.chain;
       var module = __webpack_require__.c[moduleId];
       if (!module || (module.hot._selfAccepted && !module.hot._selfInvalidated)) { continue; }

       // ************ 处理不热更新的情况 ************
       if (module.hot._selfDeclined) {
           return {
               type: "self-declined",
               chain: chain,
               moduleId: moduleId
           };
       }
       if (module.hot._main) {
           return {
               type: "unaccepted",
               chain: chain,
               moduleId: moduleId
           };
       }
       // ************ 处理不热更新的情况 ************

       for (var i = 0; i < module.parents.length; i++) {
           // module.parents=依赖这个模块的modules
           // 遍历所有依赖这个模块的 modules
           var parentId = module.parents[i];
           var parent = __webpack_require__.c[parentId];
           if (!parent) continue;
           if (parent.hot._declinedDependencies[moduleId]) {
               // 如果依赖这个模块的parentModule设置了不理会当前moduleId热更新的策略,则不处理该parentModule
               return {
                   type: "declined",
                   chain: chain.concat([parentId]),
                   moduleId: moduleId,
                   parentId: parentId
               };
           }
           // 如果已经包含在准备更新的队列中,则不重复添加
           if (outdatedModules.indexOf(parentId) !== -1) continue;
           if (parent.hot._acceptedDependencies[moduleId]) {
               if (!outdatedDependencies[parentId])
                   outdatedDependencies[parentId] = [];
               // TODO 这个parentModule设置了监听其依赖module的热更新
               addAllToSet(outdatedDependencies[parentId], [moduleId]);
               continue;
           }
           delete outdatedDependencies[parentId];
           outdatedModules.push(parentId); // 添加该parentModuleId到队列中,准备更新

           // 加入该parentModuleId到队列中,进行下一轮循环,把parentModule的相关parent也加入到更新中
           queue.push({
               chain: chain.concat([parentId]),
               id: parentId
           });
       }
   }

   return {
       type: "accepted",
       moduleId: updateModuleId,
       outdatedModules: outdatedModules,
       outdatedDependencies: outdatedDependencies
   };
}

function addAllToSet(a, b) {
   for (var i = 0; i < b.length; i++) {
       var item = b[i];
       if (a.indexOf(item) === -1) a.push(item);
   }
}

dispose 方法

dispose 方法是 HMR 模块替换流程中的“销毁旧模块”阶段,主要负责:

  • 清理缓存,将旧模块从模块系统中移除

  • 移除之前注册的回调函数

  • 移除目前 module 与其它 module 的绑定关系( parent 和 children),避免旧模块残留影响新模块

  • 为后续热更新的模块替换(apply 阶段)做准备

dispose: function () {
   currentUpdateRemovedChunks.forEach(function (chunkId) {
       delete installedChunks[chunkId];
   });
   currentUpdateRemovedChunks = undefined;

   var idx;
   var queue = outdatedModules.slice();
   while (queue.length > 0) {
       var moduleId = queue.pop();
       var module = __webpack_require__.c[moduleId];
       if (!module) continue;

       var data = {};

       // Call dispose handlers: 回调注册的disposeHandlers
       var disposeHandlers = module.hot._disposeHandlers;
       for (j = 0; j < disposeHandlers.length; j++) {
           disposeHandlers[j].call(null, data);
       }
       // __webpack_require__.hmrD = currentModuleData置为空
       __webpack_require__.hmrD[moduleId] = data;

       // disable module (this disables requires from this module)
       module.hot.active = false;

       // remove module from cache: 删除module的缓存数据
       delete __webpack_require__.c[moduleId];

       // when disposing there is no need to call dispose handler: 删除其它模块对该moduleId的accept回调
       delete outdatedDependencies[moduleId];

       // remove "parents" references from all children: 
       // 解除moduleId引用的其它模块跟moduleId的绑定关系,跟下面的解除关系是互相补充的
       // 一个是children,一个是parent
       for (j = 0; j < module.children.length; j++) {
           var child = __webpack_require__.c[module.children[j]];
           if (!child) continue;
           idx = child.parents.indexOf(moduleId);
           if (idx >= 0) {
               child.parents.splice(idx, 1);
           }
       }
   }

   // remove outdated dependency from module children: 
   // 解除引用该moduleId的模块跟moduleId的绑定关系,可以理解为moduleId.parent删除children,跟上面的解除关系是互相补充的
   // 一个是children,一个是parent
   var dependency;
   for (var outdatedModuleId in outdatedDependencies) {
       module = __webpack_require__.c[outdatedModuleId];
       if (module) {
           moduleOutdatedDependencies =
               outdatedDependencies[outdatedModuleId];
           for (j = 0; j < moduleOutdatedDependencies.length; j++) {
               dependency = moduleOutdatedDependencies[j];
               idx = module.children.indexOf(dependency);
               if (idx >= 0) module.children.splice(idx, 1);
           }
       }

   }
}

apply方法

apply()方法是**HMR 模块替换流程中的“加载新模块”阶段****,**主要执行的逻辑是:

  • 更新全局的 window.__webpack_require__  对象,存储了所有路径+内容的对象

  • 执行 runtime 代码,比如 _webpack_require__.h = ()=> {"xxxxxhash值"}

  • 触发之前 hot.accept 部署了依赖变化时的回调 callBack

  • 重新加载标识 _selfAccepted 的 module,这种模块会重新 require 一次

apply: function (reportError) {
   // insert new code
   for (var updateModuleId in appliedUpdate) {
       // __webpack_require__.m = __webpack_modules__
       // 更新全局的window.__webpack_require__对象,存储了所有路径+内容的对象
       __webpack_require__.m[updateModuleId] = appliedUpdate[updateModuleId];
   }

   // run new runtime modules
   // 执行runtime代码,比如_webpack_require__.h = ()=> {"xxxxxhash值"}
   for (var i = 0; i < currentUpdateRuntime.length; i++) {
       currentUpdateRuntime[i](__webpack_require__);
   }

   // call accept handlers:触发之前hot.accept部署了依赖变化时的回调callBack
   for (var outdatedModuleId in outdatedDependencies) {
       var module = __webpack_require__.c[outdatedModuleId];
       if (module) {
           moduleOutdatedDependencies =
               outdatedDependencies[outdatedModuleId];
           var callbacks = [];
           var errorHandlers = [];
           var dependenciesForCallbacks = [];
           for (var j = 0; j < moduleOutdatedDependencies.length; j++) {
               var dependency = moduleOutdatedDependencies[j];
               var acceptCallback = module.hot._acceptedDependencies[dependency];
               var errorHandler = module.hot._acceptedErrorHandlers[dependency];
               if (acceptCallback) {
                   if (callbacks.indexOf(acceptCallback) !== -1) continue;
                   callbacks.push(acceptCallback);
                   errorHandlers.push(errorHandler);
                   dependenciesForCallbacks.push(dependency);
               }
           }
           for (var k = 0; k < callbacks.length; k++) {
               callbacks[k].call(null, moduleOutdatedDependencies);
           }
       }
   }

   // Load self accepted modules:重新加载标识_selfAccepted的module,这种模块会重新require一次
   for (var o = 0; o < outdatedSelfAcceptedModules.length; o++) {
       var item = outdatedSelfAcceptedModules[o];
       var moduleId = item.module;

       item.require(moduleId);
   }

   return outdatedModules;
  }

总结

前端构建工具让模块管理、资源打包和优化变得高效,提升了开发体验和应用性能。Webpack 的工作原理尤其值得学习:从模块解析、依赖图构建,到 Chunk 生成和热更新,它展示了现代前端构建的完整机制。理解这些原理不仅能帮助我们更好地使用工具,也能为优化前端项目打下坚实基础。

到底选 Nuxt 还是 Next.js?SEO 真的有那么大差距吗 🫠🫠🫠

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

关于 NuxtNext.js 的 SEO 对比,技术社区充斥着太多误解。最常见的说法是 NuxtPayload JSON 化会严重影响 SEO,未压缩的数据格式会拖慢页面加载。还有人担心环境变量保护机制存在安全隐患。

实际情况远非如此。框架之间的 SEO 差异被严重夸大了。Nuxt 采用未压缩 JSON 是为了保证数据类型完整性和加速水合过程,这是深思熟虑的设计权衡。所谓的安全问题,本质上是第三方生态设计的挑战,而非框架缺陷。

真正影响搜索引擎排名的是内容质量、用户体验、页面性能和技术实践,而非框架选择。一个优化良好的 Nuxt 网站完全可能比未优化的 Next.js 网站排名更好。

一、服务端渲染机制对比

Next.js:压缩优先

Next.js 新版本使用压缩字符串格式序列化数据,旧版 Page Router 则用 JSON。数据以 <script> 标签形式嵌入 HTML,客户端接收后解压完成水合。

这种方案优势明显:HTML 文档体积小,传输快,初始加载速度优秀。App Router 还支持流式渲染和 Server Components,服务端可以逐步推送内容,不需要等待所有数据准备就绪。

权衡也很清楚:增加了客户端计算开销,复杂数据类型需要额外处理。

Nuxt:类型完整性优先

Nuxt 采用未压缩 JSON 格式,通过 window.__NUXT__ 对象嵌入数据。设计思路基于一个重要前提:现代浏览器的 JSON.parse() 性能极高,V8 引擎对 JSON 解析做了大量优化。相比自定义压缩算法,直接用 JSON 格式解析速度更快。

核心优势是水合速度极快,支持完整的 JavaScript 复杂类型。Nuxt 使用 devalue 序列化库,能够完整保留 MapSetDateRegExpBigInt 等类型,还能处理循环引用。这意味着从后端传递 Map 数据,前端反序列化后依然是 Map,而不会被转换为普通 Object

当然,包含大量数据时 HTML 体积会增大。不过对于大数据场景,Nuxt 已经支持 Lazy Hydration 来处理。

设计哲学差异

Next.js 优先考虑传输速度,适合前后端分离场景。Nuxt 优先保证类型完整性,更适合全栈 JavaScript 应用。由于前后端都用 JavaScript,保持数据类型一致性可以减少大量类型转换代码。

实际测试表明,大多数场景下这种方案不会拖慢首屏加载。一次传输加快速水合的策略,整体性能往往更优。

二、对 SEO 的实际影响

Payload JSON 化的真实影响

从 SEO 角度看,Nuxt 的方案有独特优势。爬虫可以直接从 HTML 获取完整内容,无需执行 JavaScript。即使 JS 加载或执行失败,页面核心内容依然完整存在于 HTML 中。这对 SEO 至关重要,因为搜索引擎爬虫虽然能执行 JavaScript,但更倾向于直接读取 HTML。

HTML 体积增大的影响被严重高估了。实际测试数据显示,即使 HTML 增大 50-100KB,对 TTFB 的影响也在 50-100ms 以内,用户几乎感知不到。而水合速度提升可能节省 100-200ms 的交互响应时间,整体用户体验反而更好。

Next.js 的性能优势

Next.js 采用压缩格式后,HTML 体积更小,服务器响应更快。实际数据显示平均 LCP 为 1.2 秒,INP 为 65ms,这些指标确实优秀。

Next.js 13+Server Components 进一步优化了数据传输。服务端组件的数据不需要序列化传输到客户端,直接在服务端渲染成 HTML,大大减少了传输量。对于主要展示内容的页面,这种方式可以实现接近静态页面的性能。

ISR 功能也很实用。页面可以在构建时生成静态 HTML,然后在后台按需更新,既保证了首屏速度,又能及时更新内容。

核心结论

框架对 SEO 的影响被严重夸大了。Google 的 Evergreen Googlebot 在 2019 年就已经能够完整执行现代 JavaScript。无论 Nuxt 还是 Next.js,只要正确实现了 SSR,搜索引擎都能获取完整内容。

框架选择对排名的影响可能不到 1%。真正影响 SEO 的是内容质量、页面语义化、结构化数据、内部链接结构、技术实践(sitemaprobots.txt)和用户体验指标。

三、SEO 功能特性对比

元数据管理

Next.js 13+Metadata API 提供了类型安全的元数据配置,与 Server Components 深度集成,可以在服务端异步获取数据生成动态元数据:

// Next.js
export async function generateMetadata({ params }) {
  const post = await fetchPost(params.id);
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: { images: [post.coverImage] },
  };
}

NuxtuseHead 提供响应式元数据管理,配合 @nuxtjs/seo 模块开箱即用。内置 Schema.org JSON-LD 生成器,可以更方便地实现结构化数据:

// Nuxt
const post = await useFetch(`/api/posts/${id}`);
useHead({
  title: post.value.title,
  meta: [{ name: "description", content: post.value.excerpt }],
});

useSchemaOrg([
  defineArticle({
    headline: post.title,
    datePublished: post.publishedAt,
    author: { name: post.author.name },
  }),
]);

Next.js 同样可以实现结构化数据,但需要手动插入 <script type="application/ld+json"> 标签。虽然需要额外工作,但提供了更大的灵活性。

语义化 HTML 与无障碍性

Nuxt 提供自动 aria-label 注入功能,@nuxtjs/a11y 模块可以在开发阶段自动检测无障碍性问题。Next.js 需要开发者手动确保,可以使用 eslint-plugin-jsx-a11y 检测问题。

语义化 HTML 对 SEO 的重要性常被低估。搜索引擎不仅读取内容,还会分析页面结构。正确使用 <article><section><nav> 等标签,可以帮助搜索引擎更好地理解内容层次。

静态生成与预渲染

Next.jsISR 允许为每个页面设置不同的重新验证时间。首页可能每 60 秒重新生成,文章页面可能每天重新生成。这种精细化控制使得网站能在性能和内容新鲜度之间找到平衡:

// Next.js ISR
export const revalidate = 3600; // 每小时更新

Nuxt 3 支持混合渲染模式,可以在同一应用中同时使用 SSR、SSG 和 CSR,为不同页面选择最适合的渲染策略:

// Nuxt 混合渲染
export default defineNuxtConfig({
  routeRules: {
    "/": { prerender: true },
    "/posts/**": { swr: 3600 },
    "/admin/**": { ssr: false },
  },
});

Next.js 14Partial Prerendering 更进一步,允许在同一页面混合静态和动态内容。静态部分在构建时生成,动态部分在请求时渲染,结合了 SSG 的速度和 SSR 的灵活性。

四、性能指标与爬虫友好性

Core Web Vitals 表现

从各项指标看,Next.js 平均 LCP 为 1.2 秒,表现优秀。Nuxt 的 LCP 可能受 HTML 体积影响,但在 FCP 和 INP 方面得益于快速水合机制,同样表现出色。

需要强调的是,这些差异在实际 SEO 排名中影响极其有限。Google 在 2021 年将 Core Web Vitals 纳入排名因素,但权重相对较低。内容相关性、反向链接质量、域名权威度等传统因素权重仍然更高。

更重要的是,Core Web Vitals 分数取决于真实用户体验数据,而非实验室测试。网络环境、设备性能、缓存状态等因素对性能的影响远大于框架本身。一个优化良好的 Nuxt 网站完全可能比未优化的 Next.js 网站获得更好的分数。

两个框架都提供了丰富的优化工具。Next.jsnext/image 提供自动图片优化、懒加载、响应式图片。Nuxt@nuxt/image 提供类似功能,并支持多种图片托管服务。图片优化对 LCP 的影响往往比框架选择更大。

爬虫友好性

两个框架在爬虫友好性方面几乎没有差别。都提供完整的服务端渲染内容,无需 JavaScript 即可获取页面信息,能正确返回 HTTP 状态码,支持合理的 URL 结构。

Nuxt 的额外优势在于内置 Schema.org JSON-LD 生成器,有助于搜索引擎生成富文本摘要。结构化数据对现代 SEO 至关重要,通过嵌入 JSON-LD 格式的数据,可以告诉搜索引擎页面内容的具体含义。这些信息会显示在搜索结果中,提高点击率。

两个框架在处理动态路由、国际化、重定向等 SEO 关键功能上都提供了完善支持。

五、安全性问题澄清

环境变量保护机制

关于 Nuxt 会将 NUXT_PUBLIC 环境变量暴露到 HTML 的问题,需要明确这并非框架缺陷。Nuxt 的机制是只有显式设置为 NUXT_PUBLIC_ 前缀的变量才会暴露到前端,非 public 变量不会出现在 HTML 中。

正常情况下,开发者不应该将重要信息设置为 public。任何重要信息都不应该放到前端,这是前端开发的基本原则,与框架无关。

Nuxt 3 的环境变量系统被彻底重新设计。运行时配置分为公开和私有两部分:

// Nuxt 配置
export default defineNuxtConfig({
  runtimeConfig: {
    // 私有配置,仅服务端可用
    apiSecret: process.env.API_SECRET,

    // 公开配置,会暴露到客户端
    public: {
      apiBase: process.env.API_BASE_URL,
    },
  },
});

Next.js 使用类似机制,以 NEXT_PUBLIC_ 前缀的变量会暴露到客户端:

// 服务端组件中
const apiSecret = process.env.API_SECRET; // 可用

// 客户端组件中
const apiBase = process.env.NEXT_PUBLIC_API_BASE; // 可用
const apiSecret = process.env.API_SECRET; // undefined

实际开发中的安全挑战

真正的问题在于某些第三方库要求在前端初始化时传入密钥。一些 BaaS 服务(如 SupabaseFirebase)的前端 SDK 设计就需要在前端初始化,开发者无法控制这些第三方生态的设计。

Supabase 为例,它提供 anon key(匿名密钥)和 service role key(服务角色密钥)。anon key 设计为可以在前端使用,通过行级安全策略在数据库层面控制权限。service role key 则绕过所有安全策略,只能在服务端使用。

理想解决方案是将依赖密钥的库放到服务端,通过 API 调用使用。如果某个库需要在前端运行明文密钥,这个库本身就存在重大安全风险。但现实往往更复杂,生态适配问题难以完全避免。

值得注意的是,Next.js 同样存在类似的安全考量。两个框架在环境变量保护方面的机制基本一致,问题根源在于第三方生态设计,而非框架缺陷。

对 SEO 的影响

环境变量问题本质上是安全问题,而非 SEO 问题。只要正确配置,不会影响搜索引擎爬取。

真正影响 SEO 的安全问题是:网站被攻击后注入垃圾链接、恶意重定向、隐藏内容等。这些行为会直接导致网站被搜索引擎惩罚,甚至从索引中移除。防止这类问题需要全方位的安全措施,远超框架本身的范畴。

六、实际应用场景

内容密集型网站

对于博客、新闻网站、文档站点,Nuxt 往往表现更好。内容块水合速度快,开箱即用的 SEO 功能完善,开发体验好。

Nuxt@nuxt/content 模块提供了基于 Markdown 的内容管理系统,支持全文搜索、代码高亮、自动目录生成。配合 @nuxtjs/seo 模块,可以快速构建 SEO 友好的内容网站:

// Nuxt Content 使用
const { data: post } = await useAsyncData("post", () =>
  queryContent("/posts").where({ slug: route.params.slug }).findOne()
);

技术博客、文档网站特别适合这种方案。VuePressVitePress 等静态站点生成器也是基于类似思路构建的。

动态应用

对于电商平台、SaaS 应用等需要高级渲染技术和复杂交互的场景,Next.js 可能更合适。ISR 和部分预渲染能够更好地应对动态内容需求。

电商网站的 SEO 挑战在于商品数量庞大、内容动态变化、需要个性化推荐。Next.jsISR 可以为每个商品页面设置合适的重新验证时间,既保证 SEO 友好,又能及时更新库存、价格:

// Next.js 电商页面优化
export default async function ProductPage({ params }) {
  const product = await fetchProduct(params.id);

  return (
    <>
      <ProductInfo product={product} />
      <Suspense fallback={<Skeleton />}>
        <AddToCartButton productId={params.id} />
      </Suspense>
    </>
  );
}

export const revalidate = 1800; // 30分钟重新验证

混合场景

对于兼具内容和应用特性的混合场景,两个框架都能很好胜任。许多现代网站都是混合型的:既有内容页面(博客、文档),又有应用功能(用户中心、后台管理)。

关键是为不同类型页面选择合适的渲染策略。Nuxt 3routeRules 提供路由级别的渲染控制:

// Nuxt 混合渲染场景
export default defineNuxtConfig({
  routeRules: {
    "/": { prerender: true }, // 首页预渲染
    "/blog/**": { swr: 3600 }, // 博客缓存 1 小时
    "/dashboard/**": { ssr: false }, // 用户中心客户端渲染
    "/api/**": { cors: true }, // API 路由
  },
});

Next.js 通过不同文件约定实现类似功能。可以在 app 目录中使用 Server Components 和 Client Components 组合,在 pages 目录中使用传统 SSR/SSG 方式。

七、开发者的真实痛点

超越 SEO 的实际考量

通过分析实际案例发现,开发者选择框架的真正原因往往不是 SEO。许多从 Nuxt 迁移到 Next.js 的团队,主要原因包括第三方生态兼容性问题(如 Supabase 等 BaaS 服务的前端依赖),以及开发体验(启动速度慢、构建时间长)。

客观来说,Nuxt 在开发服务器启动速度和构建时间方面确实存在优化空间。不过随着 RolldownOxc 等下一代工具链的完善,这些问题有望改善。这说明 SEO 和安全问题可能被过度强调了,真正影响开发者选择的是开发体验和生态适配。

开发服务器启动速度直接影响开发效率。如果每次重启都需要等待 30-60 秒,一天下来可能浪费几十分钟。构建时间同样重要,尤其在 CI/CD 环境中。构建时间过长会延迟部署,影响快速迭代能力。

生态兼容性是另一个重要因素。某些库只提供 React 版本,某些只提供 Vue 版本。虽然可以通过适配器跨框架使用,但会增加维护成本。

技术方案的权衡

没有完美的技术方案,只有最适合的选择。Nuxt 优先保证数据类型完整性和快速水合,Next.js 追求更小体积和更快 TTFB。

不同开发者有不同需求:内容型网站与应用型产品侧重点不同,小团队与大型商业项目考量各异。技术讨论中有句话很好地总结了这点:几乎所有框架都在解决同样的问题,差别只在于细微的实现方式。

对小团队来说,开发效率可能比性能优化更重要。快速实现功能、快速迭代,比追求极致性能指标更有价值。对大型团队来说,长期维护性和可扩展性更重要。

技术债务是另一个需要考虑的因素。随着项目发展,早期为了快速开发而做的权衡可能成为瓶颈。选择一个社区活跃、持续演进的框架很重要。Next.jsNuxt 都有强大的商业支持(Vercel 和 NuxtLabs),这保证了框架的长期发展。

八、综合评估与选择建议

SEO 能力评分

从 SEO 能力看,Next.js 可以获得 4.5 分(满分 5 分)。优势在于优秀的 Core Web Vitals 表现、更小的 HTML 体积、成熟的 SEO 生态,以及 ISR 和部分预渲染等先进特性。不足是需要手动配置结构化数据,语义化 HTML 需要开发者特别注意。

Nuxt 可以获得 4 分。优势包括内置 Schema.org JSON-LD 生成器、自动语义化 HTML 支持、在内容型网站的优秀表现,以及快速水合带来的良好体验。Payload JSON 导致 HTML 体积增大在实际应用中影响微小,已有 Lazy Hydration 等解决方案。开发服务器启动和构建速度慢是开发体验问题,与 SEO 无关。

需要说明的是,两者 SEO 能力实际上都接近满分,0.5 分的差异主要体现在开箱即用的便利性上,而非实际 SEO 效果。在真实搜索引擎排名中,这 0.5 分的差异几乎不会产生可察觉的影响。

选择 Next.js 的场景

如果项目对 Core Web Vitals 有极高要求,或者需要复杂渲染策略的动态应用,Next.js 是更好的选择。以下场景推荐使用:

  • 电商平台,需要 ISR 平衡性能和内容新鲜度
  • SaaS 应用,对交互性能要求极高
  • 国际化大型网站,需要精细性能优化
  • 团队已有 React 技术栈,迁移成本低
  • 需要使用大量 React 生态的第三方库
  • 对 Vercel 平台部署优化感兴趣
  • 需要 Server Components 的先进特性
  • 项目规模大,需要严格的 TypeScript 类型检查

选择 Nuxt 的场景

如果项目是内容密集型网站(博客、新闻、文档),或者需要快速开发并利用框架的 SEO 便利功能,Nuxt 是理想选择。以下场景推荐使用:

  • 技术博客、文档站点,内容是核心
  • 新闻、媒体网站,需要快速发布内容
  • 企业官网,强调 SEO 和内容展示
  • 团队已有 Vue 技术栈,迁移成本低
  • 需要使用 Vue 生态的 UI 库(如 Element Plus、Vuetify)
  • 快速原型开发,需要开箱即用的功能
  • 需要 @nuxt/content 的 Markdown 内容管理
  • 项目需要传递复杂的 JavaScript 对象(Map、Set、Date 等)

决策思路

对于需要优秀 SEO 表现、服务端渲染和良好开发体验的项目,两个框架都完全能够胜任。选择关键在于团队技术栈、第三方生态适配、开发体验等实际因素,而不应该仅仅基于 SEO 考虑。

在中小型项目、团队对两个框架都不熟悉、项目没有特殊渲染需求的情况下,两个框架都是合理选择。可以考虑以下因素决策:

  • 团队成员的个人偏好(React vs Vue)
  • 公司的技术战略和长期规划
  • 现有项目的技术栈,保持一致性
  • 招聘市场,React 开发者相对更多
  • 社区资源,React 生态整体更成熟
  • 学习曲线,Vue 的 API 相对更简单

九、核心结论

框架差异的真实影响

几乎所有现代框架都能很好地支持 SEO,差异只在于细微的实现方式。框架选择对 SEO 排名的影响远小于内容质量和技术实现。Payload JSON 化影响 SEO 的说法被夸大了,这是经过深思熟虑的设计权衡。对大多数应用来说,一次传输加快速水合的策略更优。

从搜索引擎角度看,只要页面能正确渲染、内容完整可见、HTML 结构合理、meta 标签正确,框架是什么并不重要。Google 的爬虫既可以处理传统静态 HTML,也可以执行复杂的 JavaScript 应用,还可以理解 SPA 的路由。

真正影响 SEO 的是:内容质量和原创性、页面加载速度(但 100-200ms 差异可以忽略)、移动端友好性、内部链接结构、外部反向链接、域名权威度、用户行为指标(点击率、停留时间、跳出率)、技术 SEO 实践(sitemaprobots.txt、结构化数据)。

框架选择在这些因素中的权重微乎其微。用 Nuxt 还是 Next.js,对实际排名的影响可能不到 1%。

性能指标的误区

Next.js 在 HTML 体积、TTFB 和 Core Web Vitals 平均值方面具有优势。Nuxt 则在数据类型支持、水合速度和网络传输效率方面表现出色。但这些差异在实际 SEO 排名中影响微乎其微。

常见的性能误区包括:过度关注实验室测试数据,忽略真实用户体验;追求极致分数,忽略边际收益递减;认为性能优化就是 SEO 优化;忽略其他更重要的 SEO 因素;在框架选择上纠结,而不是优化现有代码。

实际上,同一框架下,优化和未优化的网站性能差异可能是 10 倍,而不同框架之间的性能差异可能只有 10-20%。把精力放在代码优化、资源优化、CDN 配置等方面,往往比纠结框架选择更有价值。

决策因素梳理

技术因素方面,应该考虑团队技术栈(React vs Vue)、第三方生态适配、开发体验(启动速度、构建速度)。业务因素方面,需要评估项目类型(内容型 vs 应用型)、团队规模和能力、时间和预算。不应该主要基于 SEO 来选择框架,因为两者在 SEO 方面能力基本相当。

决策优先级建议:

第一优先级:团队技术栈和能力。团队熟悉什么就用什么,学习成本和招聘成本很高。

第二优先级:项目类型和需求。内容型倾向 Nuxt,应用型倾向 Next.js,混合型都可以。

第三优先级:生态和工具链。需要的第三方库是否支持,部署平台的支持情况,开发工具的成熟度。

第四优先级:性能和 SEO。只在前三者相同时考虑,实际影响很小。

十、实践建议

SEO 优化核心原则

内容质量永远是第一位的。框架只是工具,内容才是核心,技术优化是锦上添花而非雪中送炭。正确实现 SSR 比框架选择更重要。

SEO 最佳实践清单:确保所有重要内容在 HTML 中可见、为每个页面设置唯一的 titledescription、使用语义化 HTML 标签、正确使用标题层级、为图片添加 alt 属性、实现结构化数据、生成 sitemap.xml 并提交、配置合理的 robots.txt、使用 HTTPS、优化页面加载速度、确保移动端友好、构建合理的内部链接结构、定期发布高质量原创内容、获取高质量的外部链接、监控和分析 SEO 数据。

Nuxt 优化建议

充分利用框架优势,包括数据类型完整性的便利。对于大数据量场景,使用 Lazy Hydration 功能。不要因为 payload 问题过度担心,实际影响很小。

性能优化技巧:使用 nuxt generate 生成静态页面、配置 routeRules 为不同页面选择合适渲染策略、使用 @nuxt/image 优化图片加载、使用 lazy 属性延迟加载不关键组件、优化 payload 大小避免传递不必要数据、使用 useState 管理 SSR 和 CSR 之间共享的状态、配置合理的缓存策略、监控 payload 大小必要时拆分数据。

// Nuxt 性能优化配置
export default defineNuxtConfig({
  experimental: {
    payloadExtraction: true,
    inlineSSRStyles: false,
  },
  routeRules: {
    "/": { prerender: true },
    "/blog/**": { swr: 3600 },
  },
  image: {
    domains: ["cdn.example.com"],
  },
});

Next.js 优化建议

充分利用性能优势,包括 ISR 和部分预渲染,优化 Core Web Vitals 指标。在处理复杂数据类型时,MapSet 等需要额外处理,要确保序列化和反序列化的正确性。

性能优化技巧:使用 ISR 为动态内容设置合理重新验证时间、尽可能使用 Server Components 减少客户端 JavaScript、使用 next/image 自动优化图片、使用 next/font 优化字体加载、配置 experimental.ppr 启用部分预渲染、使用 Suspenseloading.js 改善感知性能、代码分割按需加载、优化第三方脚本加载、使用 @next/bundle-analyzer 分析包大小、配置合理的缓存策略。

// Next.js 性能优化配置
const nextConfig = {
  experimental: {
    ppr: true,
    optimizeCss: true,
    optimizePackageImports: ["lodash", "date-fns"],
  },
  images: {
    domains: ["cdn.example.com"],
    formats: ["image/avif", "image/webp"],
  },
};

框架无关的通用优化

无论选择哪个框架,以下优化都是必要的:使用 CDN 加速静态资源、启用 Gzip 或 Brotli 压缩、配置合理的缓存策略、优化首屏渲染延迟加载非关键资源、减少 HTTP 请求数量、使用 HTTP/2 或 HTTP/3、优化数据库查询、使用 Redis 等缓存层、监控真实用户性能数据、定期进行性能审计。

决策流程

如果主要关心 SEO,两个框架都完全够用,选择团队熟悉的即可,把精力放在内容质量和技术实现上。如果项目需要复杂数据结构,Nuxtdevalue 机制会让开发更便捷。如果追求极致性能指标,Next.js 的压缩方案可能略好一点,但差异在实际 SEO 排名中几乎可以忽略。如果被第三方生态绑定,这可能是最重要的决策因素。

决策流程建议:评估团队现有技术栈(如果已有 React/Vue 项目保持一致)、分析项目需求(内容型倾向 Nuxt、应用型倾向 Next.js)、检查第三方依赖(列出必需的库、确认是否有对应生态版本)、考虑部署环境(Vercel 对 Next.js 有特殊优化、Netlify 和 Cloudflare Pages 两者都支持)、评估长期维护成本(框架更新频率和稳定性、社区支持和文档质量)。

结语

通过深入分析技术原理和实际应用案例,可以得出一个明确的结论:框架之间的差异是细微的,对 SEO 的影响更是微乎其微。

选择你熟悉的、团队能驾驭的、生态适合的框架,然后专注于创造优质内容和良好的用户体验。这才是 SEO 成功的根本。技术框架只是实现目标的工具,真正决定网站排名和用户满意度的,始终是内容的质量和服务的价值。

理解技术决策的权衡,认清 SEO 的本质,基于实际需求选择,避免过度优化,把精力放在真正重要的地方。这些才是从技术讨论中应该获得的核心启示。

SEO 是一个系统工程,涉及技术、内容、营销等多个方面。框架选择只是技术层面的一个小环节。即使选择了最适合 SEO 的框架,如果内容质量不佳、用户体验糟糕、营销策略失败,网站依然无法获得好的排名。

相反,即使使用了理论上性能稍差的框架,但如果能够持续输出高质量内容、构建良好的用户体验、实施正确的 SEO 策略,网站依然可以获得优秀的搜索排名。这才是 SEO 的真谛。

最后,技术在不断演进。Next.jsNuxt 都在快速迭代,引入新的特性和优化。今天的分析可能在明天就过时了。保持学习、关注技术动态、根据实际情况调整策略,这才是长久之计。

参考资料

  1. Nuxt SEO 官方文档:nuxtseo.com
  2. Next.js SEO 最佳实践:nextjs.org/docs/app/bu…
  3. Devalue 序列化库:github.com/Rich-Harris…
  4. Google 搜索中心文档:developers.google.com/search
  5. Core Web Vitals 指标说明:web.dev/vitals/
  6. Schema.org 结构化数据规范:schema.org/
  7. Nuxt 官方文档:nuxt.com/docs
  8. Next.js 官方文档:nextjs.org/docs
  9. Nitro 服务引擎:nitro.unjs.io/
  10. Web.dev 性能优化指南:web.dev/performance…

前端轮子(2)--diy响应数据

优雅 DIY 响应数据(XHR / fetch 拦截器)

前端轮子系列:

  1. 前端部署后-判断页面是否为最新
  2. 如何优雅diy响应数据
  3. 如何优雅进行webview调试
  4. 如何实现测试环境的自动登录

一个面向前端联调/测试的 Chrome 扩展:在 页面中的xhr\fetch请求,命中规则时直接返回自定义响应,从而“让业务代码以为请求成功了”。😂

你能得到什么

  • 按规则拦截 XHR:请求 URL 正则 + 方法(ANY/GET/POST...)匹配后返回自定义文本/JSON
  • DevTools 面板可视化管理:新增 / 编辑 / 删除 / 搜索 / 开关
  • 实时生效:规则与开关存储在 chrome.storage.local,变更后同步到页面

一、快速开始(安装与使用)

1) 安装(开发者模式)

  1. 打开 Chrome 扩展管理页:chrome://extensions/
  2. 右上角开启 开发者模式
  3. 点击 加载已解压的扩展程序,选择 Interceptors/ 目录

image.png

2) 使用(DevTools 面板)

  1. 打开要调试的网站
  2. 打开 DevTools(F12)
  3. 切到面板 “XHR拦截器”
  4. 打开右上角 拦截开关
  5. 新增规则(pattern / method / response),保存即可生效

xhr3.gif

二、实现思路

1. 构建插件目录

如果是第一次开发浏览器插件看过来 官方的 hello world

  1. manifest.json 文件 => 定义插件的权限、内容脚本、web_accessible_resources 等
  2. devtools.html 文件 => 定义插件的 DevTools 入口页面
  3. devtools.js 文件 => 创建 DevTools 面板
  4. panel.html 文件 => 定义插件的面板页面
  5. panel.js 文件 => 定义插件的面板脚本
  6. scripts/background.js 文件 => 定义插件的后台脚本 / 内容注入逻辑
  7. scripts/page-inject.js 文件 => 定义插件的页面上下文拦截逻辑

2. 建立插件与页面的通信

因为插件和页面是不同的域,所以需要建立插件与页面的通信

  1. 在页面中注入 page-inject.js 脚本
    • 负责拦截、重写页面中的 XMLHttpRequest 请求
    • 通过 监听 message 事件 接收插件的规则、自定义返回内容
    • 匹配到规则后,构建响应数据并返回数据(阻止请求的发送)
  2. 在插件中注入 background.js 脚本 => 负责 插件与页面通信 => 通过 postMessage 发送配置到页面
    • 通过 chrome.storage.onChanged 监听 storage 变化
    • 当 storage 变化时,发送配置到页面

三、具体实现(上菜)

1. 构建项目

mkdir interceptor && cd interceptor
  1. manifest.json 文件 => 定义插件的权限、内容脚本、web_accessible_resources 等 声明:版本、名字、描述、权限、主机权限、脚本、资源访问权限
{
    "manifest_version": 3,
    "name": "XHR Interceptor",
    "description": "通过可配置的规则拦截并替换 XHR/fetch 请求结果。",
    "version": "1.0.0",
    "permissions": ["storage"],
    "host_permissions": ["<all_urls>"],
    "devtools_page": "devtools.html",
    "content_scripts": [
      {
        "matches": ["<all_urls>"],
        "js": ["scripts/background.js"],
        "run_at": "document_start"
      }
    ],
    "web_accessible_resources": [
      {
        "resources": ["scripts/page-inject.js"],
        "matches": ["<all_urls>"]
      }
    ]
  }
  1. devtools.html 文件 => 定义插件的 DevTools 入口页面
  <!DOCTYPE html>
  <html lang="zh-CN">
  <head>
      <meta charset="UTF-8" />
  </head>
  <body>
      <!-- 引入 devtools.js 文件 => 创建面板 -->
      <script src="devtools.js"></script>
  </body>
  </html>
  1. devtools.js 文件 => 创建 DevTools 面板 panels面板
chrome.devtools.panels.create(
  "XHR拦截器",
  null,
  "panel.html",
  (panel) => {
    if (panel) console.log("[XHR Interceptor] DevTools panel created.");
  }
);
  1. panel.html 文件 => 定义插件的面板页面
  <!DOCTYPE html>
  <html lang="zh-CN">
  <head>
      <meta charset="UTF-8" />
      <title>XHR Interceptor</title>
      <!-- 样式文件引入 -->
      <link rel="stylesheet" href="panel.css" />
  </head>
  <body>
      <!-- 插件面板样式构建 -->
      <!-- 引入 panel.js 文件 => 定义面板交互逻辑 -->
      <script src="panel.js" type="module"></script>
  </body>
  </html>

2. 注入js脚本到页面

  1. background.js 中 注入 page-inject.js 脚本到页面
const PAGE_SCRIPT = chrome.runtime.getURL("scripts/page-inject.js");

function injectPageScript() {
  const script = document.createElement("script");
  script.src = PAGE_SCRIPT;
  script.type = "text/javascript";
  script.addEventListener("load", () => script.remove());
  script.addEventListener("error", () => script.remove());
  document.documentElement.appendChild(script);
}
  1. background.js 中 监听 storage 变化
const MESSAGE_SOURCE = "xhr-interceptor-extension";
chrome.storage.onChanged.addListener((changes, namespace) => {
  // 当 storage 变化时,发送配置到页面
  postConfigToPage({});
});

function postConfigToPage(config) {
  // 直接使用 window.postMessage:content script 与页面共享同一个 window 消息通道,
  // 且不会触发页面的 CSP(避免内联脚本被阻止)。
  window.postMessage(
    {
      source: MESSAGE_SOURCE,
      type: "config:update",
      payload: config || buildConfig(),
    },
    "*"
  );
}

3. 重写 XHR、fetch 方法

// 保存原始方法
const ORIGINAL = {
    open: XMLHttpRequest.prototype.open,
    send: XMLHttpRequest.prototype.send,
    fetch: window.fetch ? window.fetch.bind(window) : null,

};

function hookXMLHttpRequest() {
    XMLHttpRequest.prototype.open = function patchedOpen(method, url) {
        // 做一些标记、额外操作
        return ORIGINAL.open.apply(this, arguments);
    };

    XMLHttpRequest.prototype.send = function patchedSend(body) {
        // 检查 开关是否打开、规则是否匹配
        // 如果匹配,构建响应数据并返回
        // 如果不匹配,继续发送请求
        return ORIGINAL.send.apply(this, arguments);
    };
}

  function hookFetch() {
    if (!ORIGINAL.fetch) return;

    window.fetch = function patchedFetch(input, init) {
      // 解析 method + url,然后复用 pickMatchingRule 进行匹配
      const meta = extractFetchMeta(input, init);
      const rule = pickMatchingRule(meta.method, meta.url);
      if (!enabled || !rule) return ORIGINAL.fetch(input, init);

      const res = buildMockFetchResponse(rule);
      return Promise.resolve(res);
    };
  }

4. 规则列表匹配

let rules = []; // 保存规则列表
function pickMatchingRule(method, url) {
    if (!url) return null;
    const upperMethod = (method || "GET").toUpperCase();
    for (const rule of rules) {
      if (!rule.regex) continue;
      if (rule.method !== "ANY" && rule.method !== upperMethod) continue;
      rule.regex.lastIndex = 0;
      if (rule.regex.test(url)) {
        return rule;
      }
    }
    return null;
  }

5. 监听消息 & 同步面板数据、规则到页面

接收插件的规则、自定义返回内容(从 background.js 发送的消息)

const script = document.currentScript;
const messageSource =
    (script && script.dataset && script.dataset.source) ||
    "xhr-interceptor-extension";

// 监听消息通道
function setupMessageChannel() {
    window.addEventListener("message", (event) => {
        if (event.source !== window) return;
        if (!event.data || event.data.source !== messageSource) return;
        if (event.data.type === "config:update") {
            // 判断为background.js发送的消息
            // 更新插件面板的数据(开关的状态、规则的数据)
        }
    });
}

6. 面板存储规则到storage & 处理ui交互

chrome.storage

  1. staorage 的读取、设置、移除
// 读取
 const stored = await chrome.storage.local.get({ rules: [] })

// 设置
await chrome.storage.local.set({ rules });

// 移除 可以传 字符串 | 数组
await chrome.storage.local.remove("rules");

// 清空
await chrome.storage.local.clear();
  1. 面板的ui交互 正常的ui交互,正常的原生开发就行

源码

xiaoyi1255

结语

如果本文对你有收获,麻烦动动发财的小手,点点关注、点点赞!!!👻👻👻

因为收藏===会了

如果有不对、更好的方式实现、可以优化的地方欢迎在评论区指出,谢谢👾👾👾

AI Agent 设计模式 - ReAct 模式

前言

上一篇我们介绍了 AI 应用的发展历程以及 Agent 的整体概念,这一篇将针对 ReAct(Reasoning + Acting)模式,并对其设计思想和工程实现进行一次更为系统、偏实战向的讲解。

在讲解 ReAct 之前,有必要先澄清一个经常被混用的问题:Agent 到底是什么?

在早期以及当下大量工程实践中,不同 AI 应用对 Agent 的定义并不一致。很多所谓的 Agent,本质上更接近一个预先定义好的 AI workflow:流程、工具、策略都由应用侧提前固化,用户只是触发执行。例如,一个 WebSearch 场景往往就对应一个「搜索 Agent」,或者通过特定提示词(如 /agent)来唤醒一组固定的搜索工具。

从工程视角看,这类 Agent 更多是能力封装与产品抽象,而不是研究语境中强调的「具备自主决策与反馈能力的智能体」。也正因为如此,随着概念被频繁复用,agent 这个词在实际讨论中逐渐变得模糊,单独听到它已很难准确判断其具体能力边界。

如果暂时抛开命名争议,从实现层面抽象来看,一个 Agent 的核心逻辑其实非常简单:一个受控的循环(loop)。在这个循环中,模型不断获取上下文、进行推理、执行动作,并根据结果继续调整行为,直到满足终止条件为止。

在工程实现中,这个过程往往可以被近似理解为:

  • 多轮调用 LLM
  • 中间可能伴随工具调用
  • 有明确的退出条件(如任务完成、步数上限、token 预算)

基于这样的背景,各类 Agent 设计模式(也可以称为范式或框架)逐步出现,而其中最经典、也最具代表性的,便是 ReAct 模式。该模式最早发表于 2022 年,其结构至今仍足以支撑大量中低复杂度的 Agent 场景。

ReAct 模式

核心思想

ReAct 的提出,本质上是为了解决一个早期 LLM 应用中的割裂问题:推理与行动往往是分离的

在 ReAct 出现之前,常见的两类模式分别是:

  • Reason-only:模型进行显式推理(如 CoT、Scratchpad),但不与外部环境交互
  • Act-heavy / Tool-driven:模型频繁调用工具获取信息,但推理过程并不显式呈现或不与行动交错

需要说明的是,这里的分类来自 ReAct 论文中的抽象对比,而并非对具体系统内部实现的严格学术归类。例如:

  • SayCan 内部同样包含推理与可行性评估,并非“无 reasoning”
  • WebGPT 也存在内部推理过程,只是推理与行动并未以交错形式呈现给模型

在这种背景下,ReAct(Reasoning + Acting) 的核心思想可以概括为一句话:

在行动中思考,在思考中决定行动。

它尝试将思考行动统一到一个连续的闭环中,模拟人类解决问题时的自然过程:

  1. Thought:分析当前状态与目标
  2. Action:基于判断调用工具或执行操作
  3. Observation:观察行动结果
  4. Thought:根据新信息再次推理
  5. 重复上述过程,直到问题解决

从形式上看,ReAct 并没有引入复杂的新组件,而是通过 Thought → Action → Observation 的反复交替,显著提升了模型在多步任务、信息不完备任务中的表现稳定性。

上图是 ReAct 论文中的一个示例,主要对比了 Standard、Reason Only、Act Only 以及 ReAct 四种不同范式在同一问题下的表现差异。Standard 方式直接给出答案,既不显式展开推理,也不与外部环境交互;Reason Only 虽然在回答前进行了逐步推理,但推理过程完全依赖模型自身的知识,一旦前提判断错误,结论便无法被外部信息纠正;Act Only 则能够多轮调用搜索等工具获取信息,但由于缺乏明确的推理指导,行动过程较为盲目,最终仍然得出了错误结果。相比之下,ReAct 通过多轮 Thought → Act → Observation 的交错执行,使模型能够在行动结果的反馈下不断修正推理路径,最终在后续轮次中得到正确答案。

核心实现

从工程角度看,ReAct 的实现并不复杂,其本质就是一个带有终止条件的循环控制结构。可以用下面这段高度简化的伪代码来概括:

// 简化版实现逻辑
for (let i = 0; i < maxLoops; i++) {
    // 1. 思考:LLM分析当前情况,决定做什么
    const { thought, action, args, final } = await llmThink();
    
    if (final) {
        // 任务完成
        break;
    }
    
    // 2. 行动:调用具体的工具
    const result = await callTool(action, args);
    
    // 3. 观察:将结果作为下一次思考的输入
    context.push(`观察到:${result}`);
}

这段代码已经基本覆盖了 ReAct 的核心机制:

  • 循环驱动:模型在多轮中逐步逼近目标
  • 模型自决策:由 LLM 决定是否继续、是否调用工具
  • 显式终止条件:通过 final 或循环上限避免失控

在真实系统中,通常还会叠加更多安全与成本控制机制,例如:

  • 最大循环次数(maxLoops)
  • token 或调用预算
  • 工具调用白名单

具体实现

下面将结合一份实际可运行的代码示例,展示一个简化但完整的 ReAct Agent 实现。

LLM 调用

这里使用 @ai-sdk 封装多厂商模型调用,示例中支持 OpenAI 与 Azure OpenAI。该部分属于基础设施层,与 ReAct 本身并无强耦合,因此不再展开其原理。具体的介绍和使用方式可以看我之前写的这篇文章 《AI 开发者必备:Vercel AI SDK 轻松搞定多厂商 AI 调用》

import { generateText } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
import { createAzure } from "@ai-sdk/azure";

/**
 * Build a model client from env/config.
 * Supported providers: 'openai', 'azure'.
 */
export function getModelClient(options = {}) {
  const {
    provider = process.env.AI_PROVIDER || "openai",
    apiKey = process.env.AI_PROVIDER_API_KEY,
    baseURL = process.env.OPENAI_BASE_URL || process.env.AZURE_OPENAI_BASE_URL,
    resourceName = process.env.AZURE_OPENAI_RESOURCE_NAME, // for azure
  } = options;

  if (process.env.AI_MOCK === "1") {
    return { client: null, model: null };
  }

  if (!apiKey) {
    throw new Error("Missing API key: set AI_PROVIDER_API_KEY");
  }

  if (provider === "azure") {
    const azure = createAzure({ apiKey, resourceName });
    return { client: azure, model: azure("gpt-5") };
  }

  const openai = createOpenAI({ apiKey, baseURL });
  const modelName = process.env.OPENAI_MODEL || "gpt-4o-mini";
  return { client: openai, model: openai(modelName) };
}

/**
 * Chat-like step with messages array support.
 * messages: [{ role: 'system'|'user'|'assistant', content: string }]
 */
export async function llmChat({ messages, schema, options = {} }) {
  const { model } = getModelClient(options);
  const system = messages.find((m) => m.role === "system")?.content;

  const result = await generateText({
    model,
    system,
    messages,
    ...(schema ? { schema } : {}),
  });
  return result;
}

核心作用只有一个:以 Chat 形式向模型发送上下文,并获得结构化输出

ReAct 主逻辑

在下面这段代码中,我们完整实现了一个 ReAct Agent 的核心循环逻辑。首先通过 system prompt 对模型的输出形式和行为进行强约束,明确要求其仅以 JSON 格式返回结果,且只能包含 thoughtactionargsfinal 四个字段,并分别约定了调用工具与结束任务时的输出规范。与此同时,在 user 消息中显式告知模型当前可用的 tools 列表,并附带每个工具的功能说明与参数定义,使模型能够在循环过程中基于明确的能力边界自主决策是否进行工具调用。

import { llmChat } from "../llm/provider.js";
import { callTool, formatToolList } from "../tools/index.js";

const SYSTEM = `You are a ReAct-style agent.

You must reason and act using the following loop:
Thought → Action → Observation
This loop may repeat multiple times.

Output format rules:
- You MUST respond with a single JSON object
- The JSON object MUST contain only the following keys:
  - thought (string)
  - action (string, optional)
  - args (object, optional)
  - final (string, optional)
- No additional keys are allowed
- Do NOT use Markdown
- Do NOT include any text outside the JSON object

Behavior rules:
- If you need to call a tool, output "thought", "action", and "args"
- If no further action is required, output "thought" and "final"
- When "final" is present, the response is considered complete and no further steps will be taken
- Do NOT include "action" or "args" when returning "final"

Always follow these rules strictly.`;

export async function runReAct({ task, maxLoops = 6, options = {} } = {}) {
  const messages = [
    { role: "system", content: SYSTEM },
    {
      role: "user",
      content: `Task: ${task}\nAvailable tools: ${formatToolList()}`,
    },
  ];

  const trace = [];

  for (let i = 0; i < maxLoops; i++) {
    const { text } = await llmChat({ messages, options });
    let parsed;
    try {
      parsed = JSON.parse(text);
    } catch (e) {
      // console.warn("Parse failed. Text:", text);
      // console.warn("Error:", String(e));
      messages.push({ role: "assistant", content: text });
      messages.push({
        role: "user",
        content: `Format error: ${String(e)}.
        You previously violated the required JSON format.
        This is a strict requirement.

        If the response is not valid JSON or contains extra text, it will be discarded.
        Retry now.`,
      });
      continue;
    }

    trace.push({ step: i + 1, model: parsed });

    if (parsed.final) {
      console.log("Final result:", parsed.final);
      return { final: parsed.final, trace };
    }

    if (!parsed.action) {
      messages.push({ role: "assistant", content: JSON.stringify(parsed) });
      messages.push({
        role: "user",
        content:
          "No action provided. Please continue with a tool call or final.",
      });
      continue;
    }

    console.log("Action:", parsed.action);
    const observation = await callTool(parsed.action, parsed.args || {});
    trace[trace.length - 1].observation = observation;
    messages.push({ role: "assistant", content: JSON.stringify(parsed) });
    messages.push({
      role: "user",
      content: `Observation: ${JSON.stringify(observation)}. Continue.`,
    });
  }

  return { final: "Max loops reached without final.", trace };
}

Tools

Tools 指的是模型在 ReAct 循环中可以调用的具体外部能力接口。通常,一个工具由工具名称、功能描述、参数定义以及对应的 handler 实现组成。下面示例中实现了两个最基础的文件操作工具:readFileTool 用于读取文件内容,writeFileTool 用于写入文件,两者都完整描述了工具名称、用途、参数 Schema 以及实际执行逻辑。createTool 只是一个用于约定工具输出结构的辅助函数,本身并不涉及核心逻辑,主要用于在非 TS 环境下做基础的参数校验。

这里使用 zod 作为参数校验工具,它可以在 JS / TS 环境中统一使用,通过定义 schema 并在运行时执行 parse 校验,有效缓解模型参数幻觉问题;同时可以直接使用 schema 生成标准的 JSON Schema,作为工具参数说明提供给模型,从而减少手写参数描述的成本。

import fs from "fs/promises";
import path from "path";
import { z } from "zod";
import { createTool } from "./types.js";

const readFileSchema = z.object({ file: z.string() });
const writeFileSchema = z.object({ file: z.string(), content: z.string() });

export const readFileTool = createTool({
  name: "read_file",
  description: "Read a UTF-8 text file from workspace",
  schema: readFileSchema,
  handler: async ({ file }) => {
    const abs = path.resolve(process.cwd(), file);
    const data = await fs.readFile(abs, "utf-8");
    return { ok: true, content: data };
  },
});

export const writeFileTool = createTool({
  name: "write_file",
  description: "Write a UTF-8 text file to workspace (overwrite)",
  schema: writeFileSchema,
  handler: async ({ file, content }) => {
    const abs = path.resolve(process.cwd(), file);
    await fs.mkdir(path.dirname(abs), { recursive: true });
    await fs.writeFile(abs, content, "utf-8");
    return { ok: true, message: `wrote ${file}` };
  },
});

实际效果

基于上述 ReAct 实现,我尝试让模型在本地环境中完成多个小游戏的生成与迭代,包括:2048飞机大战贪吃蛇五子棋。从结果来看,整体完成度和可玩性都明显优于我早期纯手写的一些 demo。当然,需要强调的是:ReAct 并不会凭空提升模型能力,它更多是一种能力放大器。

最终效果在很大程度上仍然依赖于底层模型本身的代码生成、规划与理解能力,而 ReAct 负责的,是为这些能力提供一个稳定的执行框架。

2048

飞机大战

贪吃蛇

五子棋

结语

ReAct 并不是最复杂、也不是最“智能”的 Agent 模式,但它结构清晰、实现成本低、工程可控性强,是理解和实践 Agent 系统非常合适的起点。

在后续更复杂的场景中,往往会在 ReAct 之上叠加:规划(Plan & Execute)、反思(Reflection)、记忆与长期状态,但无论如何,ReAct 所确立的 思考—行动—反馈闭环,仍然是多数 Agent 系统绕不开的基础结构。

在下一篇中,我们将展开对 P&E(Plan and Execute)模式 的详细解析,重点介绍其设计理念、执行流程及具体实现方式。

我已将相关代码开源到 GitHub,感兴趣的同学可以下载到本地后执行一下玩玩: github.com/Alessandro-…

相关资料

Agent 研发:ReAct Agent

ReAct 介绍

运作流程

用一句话简单描述:ReAct 智能体是基于推理行动反馈的框架,通过循环调用 LLM 思考得到概率纠正结果。

理论学习参考:www.ibm.com/cn-zh/think…

// 伪代码
def ReAct(query) {
    call: Agent
    Thought
    call: Tool
    Action

    if Action === 'Finish'
        Answer
    else
        loop: ReAct
}

ReAct Agent 实现要点

此例子来自于 hello_agents 学习文档,有兴趣的同学也可以阅读

主要是思维变化:面向场景选择合适模型->模型友好交互方式->得到合理答案,在 ReAct 模式中主要是 Prompt 策略和上下文的丰富度会影响调用成本和结果。

  • Prompt 规划和约束
    • 明确约定思考和行动标记:用于组合 LLM 思维链
    • 明确推理结束标记:用于优化 Prompt 内容
    • 明确推理记忆标记:用于丰富 Prompt 内容
    • 明确推理外部调用能力描述:用于提升 LLM 处理准确性
  • 灵活切换模型、Prompt 策略优化、工具增强,提升循环调用效果(这个对企业成本比较重要)

完整例子实现

整体基于 OpenRouter 调用,有兴趣的同学可以选个免费模型跑一下。

  • LLM Client
import OpenAI from "openai";

export class LLM {
  private client: OpenAI;
  private modelId: string;

  constructor(apiKey: string, modelId: string = "mistralai/devstral-2512:free") {
    this.client = new OpenAI({
      apiKey: apiKey,
      baseURL: "https://openrouter.ai/api/v1",
      dangerouslyAllowBrowser: true,
    });
    this.modelId = modelId;
  }

  setModel(modelId: string) {
    this.modelId = modelId;
  }

  getModel() {
    return this.modelId;
  }

  async chat(messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[]) {
    return this.client.chat.completions.create({
      model: this.modelId,
      messages: messages,
    });
  }

  async stream(messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[]) {
    return this.client.chat.completions.create({
      model: this.modelId,
      messages: messages,
      stream: true,
    });
  }
}
  • ReActAgent
import type {LLM} from "./LLM.ts";
import {ToolExecutor} from "./ToolExecutor.ts";

type PromptTplProps = {
    question: string;
    history: string;
    tools: string[];
}

const getPromptTpl = (props: PromptTplProps) => {
    const {
        tools,
        question,
        history,
    } = props;

    return `
        请注意,你是一个有能力调用外部工具的智能助手。
        
        能够使用的工具如下:
        ${tools.join('\n')}
        
        请严格按照下面的格式进行回复:
        
        Thought: 你的思考过程用于分析问题、拆解任务和规划下一步行动。
        Action: 你决定采取的行为,必须是以下格式之一:
        - {toolName}[{toolInput}]: 调用一个可用工具。
        - Finish[最终答案]: 当你认为已经获得最终答案时。
        - 当你收集到足够的信息,能够回答用户的最终问题时,你必须在Action:字段后使用 finish(answer="...") 来输出最终答案。
        
        现在,请开始解决以下问题:
        Question: ${question}
        History: ${history}
    `;
};

export class ReActAgent {
    private history: string[];

    constructor(
        // 调用的 llm
        private readonly llm: LLM,
        // 最多循环思考几次
        private readonly maxSteps: number = 3,
        private readonly toolExecutor: ToolExecutor,
    ) {
        this.history = [];
    }

    async run(question: string) {
        let currentStep = 0;

        while (currentStep < this.maxSteps) {
            currentStep++;
            console.log(`-----第${currentStep}步骤-------`);

            const history = this.history.join('\n');
            const tools = this.toolExecutor.getAvailableTools();
            const prompt = getPromptTpl({
                tools,
                question,
                history,
            });

            console.log('prompt:', prompt);

            try {
                const responseText = await this.llm.chat([{
                    role: 'user',
                    content: prompt,
                }]);

                console.log('大模型返回:', responseText);

                const {thought, action} = this.parseOutput(responseText.choices[0].message.content ?? '');

                if (thought) {
                    console.log('Thought:', thought);
                }
                if (!action) {
                    console.warn('模型没有返回具体的 Action,流程终止');
                    break;
                }

                console.log('Action:', action);

                if (action.startsWith('Finish')) {
                    const answer = this.parseFinishAnswer(action);
                    console.log('最终答案: ', answer);
                    return answer;
                }

                const [toolName, toolInput] = this.parseAction(action);
                if (!toolName || !toolInput) {
                    console.warn('无法解析 Action:', action);
                    continue;
                }

                const tool = this.toolExecutor.getTool(toolName);

                let observation = '';
                if (tool) {
                    observation = tool.fn(toolInput) as string;
                }
                console.log('Observation:', observation);

                this.history.push(`Action:${action}`);
                this.history.push(`Observation:${observation}`)
            } catch (err) {
                console.error(err);
            }
        }

        console.log('已达到最大步数');
    }

    parseFinishAnswer(action: string) {
        const answer = action.match(/Finish[(.*)]/);
        if (answer && answer.length > 1) {
            return answer[1];
        }
        return '-';
    }

    parseOutput(str: string) {
        const thought = str.match(/Thought:(.*)/);
        const action = str.match(/Action:(.*)/);

        type Result = {
            thought: string;
            action: string;
        };
        const result: Result = {
            thought: '',
            action: '',
        };
        if (thought && thought.length > 1) {
            result.thought = thought[1].trim();
        }
        if (action && action.length > 1) {
            result.action = action[1].trim();
        }
        return result;
    }

    parseAction(str: string) {
        const info = str.match(/(\w+)[(.*)]/);
        if (info && info.length > 2) {
            // info[0] is the full match, e.g., "calc_sum[1,1]"
            // info[1] is the first capture group (the tool name), e.g., "calc_sum"
            // info[2] is the second capture group (the tool input), e.g., "1,1"
            return [info[1], info[2]];
        }
        return [];
    }
}
  • 工具注册和执行
type Tool = {
    desc: string;
    fn: (arg: string) => unknown;
}

export class ToolExecutor {
    private tools: Map<string, Tool>;

    constructor() {
        this.tools = new Map();
    }

    register(name: string, desc: string, fn: (arg: string) => unknown) {
        console.log(`您注册的工具:${name}, ${desc}`);
        this.tools.set(name, {
            desc,
            fn,
        } as Tool);
    }

    getTool(toolName: string): Tool | undefined {
        if (!this.tools.has(toolName)) {
            return;
        }
        return this.tools.get(toolName);
    }

    getAvailableTools() {
        const result: string[] = [];
        this.tools.forEach((tool, name) => {
            result.push(`${name}: ${tool.desc}`);
        });
        return result;
    }
}

【鸿蒙心迹】-03-自然壁纸实战教程-项目结构介绍

03-自然壁纸实战教程-项目结构介绍

架构选型

按照目前主流的鸿蒙应用开发来讲,基本都是推荐三层架构-一次开发-多端部署,因为主要考虑到多端适配,那么这个选型是必然的了。但是自然壁纸当初的立项的出发点比较简单,怎么快怎么来,所以就直接选择了单HAP架构。

单HAP架构

对于单窗口应用的APP工程,其仅包含一个Entry类型的HAP。划分的模块则根据是否有按需加载的需求,来考虑采用HAR模块和

HSP模块。

image-20250627092419769

注意,正常开发的工程是不会把设计稿放在工程内的,这里存放只是为了方便学习者直接拿到,不另外存储而已!

多HAP工程

对于同一个设备类型,如果要实现不同的独立功能模块,并且相对独立,以及具有单独的入口的功能特性,建议做成一个独立特性的HAP,按需下载安装。此时一个App包中,就会有多个HAP包,其中有且仅有一个Entry类型的HAP,其他的均是Feature类型的HAP。多HAP之间业务独立,但是可能会有业务能力共享,所以在进行模块化设计时,需要根据是否具有公共能力来进行选择。

核心目录结构

image-20250627092534277

核心目录结构也比较常规

├── components\          # 组件目录
├── const\               # 常量定义目录
├── entryability\        # 入口能力目录
├── entryformability\    # 入口表单能力目录
├── pages\               # 页面目录
├── services\            # 通用逻辑服务目录
├── utils\               # 工具类目录
├── viewModel\           # 视图模型目录
├── views\               # 视图目录
└── zrbzwidget\          # 卡片组件目录

其中优先需要关注的是页面目录结构,它决定当前项目存在多少个页面

image-20250627093523143

当然了,作为学习者而已,在开始学习的时候不需要一口气全部新建完,做到哪里了,就用到哪里即可。

由于工程使用的是 Navigation作为路由管理,所以Pages下只放了一个页面 Index.ets 作为入口,剩下的页面都放到了view目录下。

API版本

项目开始时是使用API14的版本,但是目前官网已经更新到了API20,欢迎有能力的小伙伴们直接使用最新的API20,当出现问题时,可以沟通解决,确保用到的是最新的技术。

image-20250630083159327

近期活动

最近想要想要考取 HarmonyOS 基础或者高级证书,或者快要获取的同学都可以点击这个链接,加入我的班级,考取成功有机会获得鸿蒙礼盒一份。

【鸿蒙心迹】- 02-自然壁纸实战教程-AGC 新建项目

02-自然壁纸实战教程-AGC 新建项目

什么是 AGC 平台

AppGallery Connect(以下简称 AGC)是华为推出的应用一站式服务平台,致力于为开发者提供应用创意、开发、分发、运营、分析

全生命周期服务,构建全场景智慧化的应用生态。

AppGallery Connect 深度整合华为内部各项优质服务,将华为在全球化、质量、安全、工程管理等领域长期积累的能力开放给您,大

幅降低应用开发与运维难度,提高版本质量,开放分发和运营服务,帮助您获得用户并实现收入的规模增长。

img转存失败,建议直接上传图片文件

大白话就是 AGC 是开发者用来管理我们上架项目的后台花园,需要在这里新建项目、上架项目、分析项目等等。所以项目开发之前需要先提前做好这个环境准备工作。

新建项目

一个项目可以有多个应用,比如美团是一个项目,但是它可以有用户端、骑手端、商家端等三个应用。

image-20250622205537284

然后根据实际情况填写资料

新建应用

然后在这里新建应用

image-20250622205714372


image-20250622205759276


image-20250622205818962

然后根据实际情况选择类型,自然壁纸是元服务,元服务上架比应用要简单。

DevEco Studio 新建元服务项目

上述步骤准备好了,就可以回到桌面上,使用 DevEco Studio 新建元服务了

image-20250622210110627


然后这里就会出现我们之前在 AGC 平台上新建好的元服务应用了。

image-20250622210409257

至此,可以认为初步的项目新建工作已经完成了,但是实际情况下,后续的开发和发布上架,还需要用到一些证书,包括调用网络服务还需要进行域名备案,这块内容可以后面来完善,当前教程偏向初学者,所以优先关注核心代码吧,或者有具体疑问了都可以在技术交流群中进行提问。

近期活动

最近想要想要考取 HarmonyOS 基础或者高级证书,或者快要获取的同学都可以点击这个链接,加入我的班级,考取成功有机会获得鸿蒙礼盒一份。

综合项目实践:可视化技术核心实现与应用优化

引言

可视化技术已成为现代前端开发不可或缺的一部分,从简单的图表展示到复杂的交互式数据可视化,再到沉浸式的3D体验,前端可视化正在重新定义用户体验。本文将全面解析可视化技术的核心实现,涵盖Canvas绘图、SVG操作、拖拽交互、动画系统、数据可视化库以及WebGL 3D渲染,通过完整可运行的代码示例,帮助你从零构建完整的可视化解决方案。

1. Canvas绘图库基础

1.1 Canvas核心API与封装

Canvas是HTML5提供的位图绘图技术,适合处理大量图形元素和像素级操作。

// Canvas绘图工具类封装
class CanvasDrawer {
  constructor(canvasId, options = {}) {
    this.canvas = document.getElementById(canvasId);
    this.ctx = this.canvas.getContext('2d');
    this.options = {
      antialias: true,
      alpha: true,
      ...options
    };
    this.shapes = new Map();
    this.init();
  }

  init() {
    // 设置高清Canvas
    const dpr = window.devicePixelRatio || 1;
    const rect = this.canvas.getBoundingClientRect();
    
    this.canvas.width = rect.width * dpr;
    this.canvas.height = rect.height * dpr;
    this.ctx.scale(dpr, dpr);
    
    // 设置样式
    this.ctx.lineJoin = 'round';
    this.ctx.lineCap = 'round';
  }

  // 绘制基本图形
  drawRect(x, y, width, height, style = {}) {
    const id = `rect_${Date.now()}_${Math.random()}`;
    const rect = {
      type: 'rect',
      x, y, width, height,
      style: {
        fillStyle: '#3498db',
        strokeStyle: '#2980b9',
        lineWidth: 2,
        ...style
      },
      id
    };

    this.ctx.save();
    this.applyStyles(rect.style);
    
    if (rect.style.fillStyle) {
      this.ctx.fillRect(x, y, width, height);
    }
    if (rect.style.strokeStyle) {
      this.ctx.strokeRect(x, y, width, height);
    }
    
    this.ctx.restore();
    this.shapes.set(id, rect);
    return id;
  }

  // 绘制圆形
  drawCircle(x, y, radius, style = {}) {
    const id = `circle_${Date.now()}_${Math.random()}`;
    
    this.ctx.beginPath();
    this.ctx.arc(x, y, radius, 0, Math.PI * 2);
    this.applyStyles(style);
    
    if (style.fillStyle) this.ctx.fill();
    if (style.strokeStyle) this.ctx.stroke();
    
    this.shapes.set(id, { type: 'circle', x, y, radius, style, id });
    return id;
  }

  // 绘制路径
  drawPath(points, style = {}) {
    if (points.length < 2) return null;
    
    this.ctx.beginPath();
    this.ctx.moveTo(points[0].x, points[0].y);
    
    for (let i = 1; i < points.length; i++) {
      if (points[i].type === 'quadratic') {
        this.ctx.quadraticCurveTo(
          points[i].cp1x, points[i].cp1y,
          points[i].x, points[i].y
        );
      } else if (points[i].type === 'bezier') {
        this.ctx.bezierCurveTo(
          points[i].cp1x, points[i].cp1y,
          points[i].cp2x, points[i].cp2y,
          points[i].x, points[i].y
        );
      } else {
        this.ctx.lineTo(points[i].x, points[i].y);
      }
    }
    
    this.applyStyles(style);
    if (style.closePath) this.ctx.closePath();
    if (style.fillStyle) this.ctx.fill();
    if (style.strokeStyle) this.ctx.stroke();
    
    return this.shapes.size;
  }

  applyStyles(styles) {
    Object.keys(styles).forEach(key => {
      if (key in this.ctx) {
        this.ctx[key] = styles[key];
      }
    });
  }

  // 清除画布
  clear() {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
    this.shapes.clear();
  }

  // 获取像素数据(用于高级操作)
  getPixelData(x, y, width = 1, height = 1) {
    return this.ctx.getImageData(x, y, width, height);
  }

  // 保存为图片
  toDataURL(type = 'image/png', quality = 0.92) {
    return this.canvas.toDataURL(type, quality);
  }
}
1.2 Canvas性能优化技巧
// Canvas性能优化类
class CanvasOptimizer {
  static optimizeRendering(canvas, options = {}) {
    // 1. 使用离屏Canvas进行复杂绘制
    const offscreenCanvas = document.createElement('canvas');
    const offscreenCtx = offscreenCanvas.getContext('2d');
    
    offscreenCanvas.width = canvas.width;
    offscreenCanvas.height = canvas.height;
    
    // 2. 批量绘制操作
    return {
      offscreenCanvas,
      offscreenCtx,
      // 批量绘制矩形
      batchDrawRects(rects) {
        offscreenCtx.save();
        rects.forEach(rect => {
          offscreenCtx.fillStyle = rect.color;
          offscreenCtx.fillRect(rect.x, rect.y, rect.width, rect.height);
        });
        offscreenCtx.restore();
        
        // 一次性绘制到主Canvas
        canvas.getContext('2d').drawImage(offscreenCanvas, 0, 0);
      },
      
      // 3. 使用requestAnimationFrame进行动画
      animate(callback) {
        let animationId;
        
        const animate = () => {
          callback();
          animationId = requestAnimationFrame(animate);
        };
        
        animate();
        
        return {
          stop: () => cancelAnimationFrame(animationId)
        };
      }
    };
  }

  // 避免频繁的重绘
  static createDoubleBuffer(canvas) {
    const buffers = [
      document.createElement('canvas'),
      document.createElement('canvas')
    ];
    
    buffers.forEach(buffer => {
      buffer.width = canvas.width;
      buffer.height = canvas.height;
    });
    
    let currentBuffer = 0;
    
    return {
      getBuffer() {
        return buffers[currentBuffer];
      },
      swap() {
        currentBuffer = 1 - currentBuffer;
        const ctx = canvas.getContext('2d');
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        ctx.drawImage(buffers[1 - currentBuffer], 0, 0);
      }
    };
  }
}

2. SVG操作库实现

2.1 SVG核心操作封装
class SVGManager {
  constructor(containerId, options = {}) {
    this.container = document.getElementById(containerId);
    this.namespace = 'http://www.w3.org/2000/svg';
    this.elements = new Map();
    this.init(options);
  }

  init(options) {
    // 创建SVG画布
    this.svg = document.createElementNS(this.namespace, 'svg');
    this.svg.setAttribute('width', options.width || '100%');
    this.svg.setAttribute('height', options.height || '100%');
    this.svg.setAttribute('viewBox', options.viewBox || '0 0 800 600');
    
    if (options.preserveAspectRatio) {
      this.svg.setAttribute('preserveAspectRatio', options.preserveAspectRatio);
    }
    
    this.container.appendChild(this.svg);
  }

  // 创建SVG元素
  createElement(type, attributes = {}) {
    const element = document.createElementNS(this.namespace, type);
    
    Object.keys(attributes).forEach(key => {
      element.setAttribute(key, attributes[key]);
    });
    
    return element;
  }

  // 添加图形
  addCircle(cx, cy, r, styles = {}) {
    const circle = this.createElement('circle', {
      cx, cy, r,
      ...this.parseStyles(styles)
    });
    
    const id = `circle_${Date.now()}`;
    circle.setAttribute('id', id);
    this.svg.appendChild(circle);
    this.elements.set(id, circle);
    
    return { element: circle, id };
  }

  addRect(x, y, width, height, styles = {}) {
    const rect = this.createElement('rect', {
      x, y, width, height,
      ...this.parseStyles(styles)
    });
    
    const id = `rect_${Date.now()}`;
    rect.setAttribute('id', id);
    this.svg.appendChild(rect);
    this.elements.set(id, rect);
    
    return { element: rect, id };
  }

  addPath(d, styles = {}) {
    const path = this.createElement('path', {
      d,
      ...this.parseStyles(styles)
    });
    
    const id = `path_${Date.now()}`;
    path.setAttribute('id', id);
    this.svg.appendChild(path);
    this.elements.set(id, path);
    
    return { element: path, id };
  }

  // 创建复杂图形
  createPieChart(data, centerX, centerY, radius) {
    const group = this.createElement('g');
    let startAngle = 0;
    
    data.forEach((item, index) => {
      const sliceAngle = (item.value / 100) * 2 * Math.PI;
      const endAngle = startAngle + sliceAngle;
      
      // 计算路径
      const x1 = centerX + radius * Math.cos(startAngle);
      const y1 = centerY + radius * Math.sin(startAngle);
      const x2 = centerX + radius * Math.cos(endAngle);
      const y2 = centerY + radius * Math.sin(endAngle);
      
      const largeArcFlag = sliceAngle > Math.PI ? 1 : 0;
      
      const pathData = [
        `M ${centerX} ${centerY}`,
        `L ${x1} ${y1}`,
        `A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2}`,
        'Z'
      ].join(' ');
      
      const slice = this.addPath(pathData, {
        fill: item.color || this.getColor(index),
        stroke: '#ffffff',
        'stroke-width': 2
      });
      
      group.appendChild(slice.element);
      startAngle = endAngle;
    });
    
    this.svg.appendChild(group);
    return group;
  }

  // 添加交互事件
  addInteraction(elementId, events) {
    const element = this.elements.get(elementId);
    if (!element) return;
    
    Object.keys(events).forEach(eventType => {
      element.addEventListener(eventType, events[eventType]);
    });
  }

  // 样式解析
  parseStyles(styles) {
    const svgStyles = {};
    
    Object.keys(styles).forEach(key => {
      const svgKey = key.replace(
        /[A-Z]/g, 
        match => `-${match.toLowerCase()}`
      );
      svgStyles[svgKey] = styles[key];
    });
    
    return svgStyles;
  }

  // 动画方法
  animateElement(elementId, properties, duration = 1000) {
    const element = this.elements.get(elementId);
    if (!element) return;
    
    const startTime = Date.now();
    const startValues = {};
    
    // 获取起始值
    properties.forEach(prop => {
      startValues[prop.attribute] = 
        element.getAttribute(prop.attribute) || prop.from;
    });
    
    const animate = () => {
      const elapsed = Date.now() - startTime;
      const progress = Math.min(elapsed / duration, 1);
      
      properties.forEach(prop => {
        const startValue = parseFloat(startValues[prop.attribute]);
        const endValue = parseFloat(prop.to);
        const currentValue = 
          startValue + (endValue - startValue) * this.easing(progress);
        
        element.setAttribute(
          prop.attribute, 
          currentValue + (prop.unit || '')
        );
      });
      
      if (progress < 1) {
        requestAnimationFrame(animate);
      }
    };
    
    requestAnimationFrame(animate);
  }

  easing(t) {
    // 缓动函数
    return t < 0.5 
      ? 2 * t * t 
      : -1 + (4 - 2 * t) * t;
  }
}

3. 拖拽库实现

3.1 高性能拖拽系统
class DragManager {
  constructor(options = {}) {
    this.options = {
      container: document.body,
      dragSelector: '.draggable',
      handleSelector: null,
      cursor: 'move',
      zIndex: 1000,
      onStart: null,
      onDrag: null,
      onEnd: null,
      ...options
    };
    
    this.draggables = new Map();
    this.currentDrag = null;
    this.init();
  }

  init() {
    this.bindEvents();
  }

  bindEvents() {
    this.options.container.addEventListener(
      'mousedown', 
      this.handleMouseDown.bind(this)
    );
    this.options.container.addEventListener(
      'touchstart', 
      this.handleTouchStart.bind(this)
    );
    
    // 防止拖拽时选中文本
    document.addEventListener('selectstart', (e) => {
      if (this.currentDrag) {
        e.preventDefault();
      }
    });
  }

  register(element, options = {}) {
    const dragId = `drag_${Date.now()}`;
    
    const dragInfo = {
      element,
      options: {
        axis: 'both', // 'x', 'y', 'both'
        bounds: null, // { left, top, right, bottom }
        grid: null, // [x, y]
        ...options
      },
      originalStyle: {
        position: element.style.position,
        cursor: element.style.cursor,
        zIndex: element.style.zIndex,
        userSelect: element.style.userSelect
      }
    };
    
    this.draggables.set(dragId, dragInfo);
    return dragId;
  }

  handleMouseDown(e) {
    const target = e.target;
    const dragHandle = this.options.handleSelector 
      ? target.closest(this.options.handleSelector)
      : target.closest(this.options.dragSelector);
    
    if (!dragHandle) return;
    
    const dragId = this.findDragId(dragHandle);
    if (!dragId) return;
    
    e.preventDefault();
    this.startDrag(dragId, e.clientX, e.clientY);
  }

  handleTouchStart(e) {
    const touch = e.touches[0];
    const target = touch.target;
    const dragHandle = this.options.handleSelector
      ? target.closest(this.options.handleSelector)
      : target.closest(this.options.dragSelector);
    
    if (!dragHandle) return;
    
    const dragId = this.findDragId(dragHandle);
    if (!dragId) return;
    
    e.preventDefault();
    this.startDrag(dragId, touch.clientX, touch.clientY);
  }

  findDragId(element) {
    for (const [id, info] of this.draggables.entries()) {
      if (info.element.contains(element) || info.element === element) {
        return id;
      }
    }
    return null;
  }

  startDrag(dragId, startX, startY) {
    const dragInfo = this.draggables.get(dragId);
    if (!dragInfo) return;
    
    this.currentDrag = {
      ...dragInfo,
      dragId,
      startX,
      startY,
      startLeft: parseInt(dragInfo.element.style.left) || 0,
      startTop: parseInt(dragInfo.element.style.top) || 0,
      width: dragInfo.element.offsetWidth,
      height: dragInfo.element.offsetHeight
    };
    
    // 设置拖拽样式
    dragInfo.element.style.cursor = this.options.cursor;
    dragInfo.element.style.zIndex = this.options.zIndex;
    dragInfo.element.style.userSelect = 'none';
    
    // 绑定移动事件
    const moveHandler = (e) => this.handleMove(e);
    const upHandler = () => this.endDrag();
    
    document.addEventListener('mousemove', moveHandler);
    document.addEventListener('touchmove', moveHandler);
    document.addEventListener('mouseup', upHandler);
    document.addEventListener('touchend', upHandler);
    
    // 保存事件处理器以便移除
    this.currentDrag.moveHandler = moveHandler;
    this.currentDrag.upHandler = upHandler;
    
    // 回调
    if (this.options.onStart) {
      this.options.onStart(this.currentDrag);
    }
  }

  handleMove(e) {
    if (!this.currentDrag) return;
    
    e.preventDefault();
    
    const clientX = e.type.includes('touch') 
      ? e.touches[0].clientX 
      : e.clientX;
    const clientY = e.type.includes('touch')
      ? e.touches[0].clientY
      : e.clientY;
    
    let deltaX = clientX - this.currentDrag.startX;
    let deltaY = clientY - this.currentDrag.startY;
    
    // 应用约束
    const constraints = this.applyConstraints(deltaX, deltaY);
    deltaX = constraints.x;
    deltaY = constraints.y;
    
    // 更新位置
    const newX = this.currentDrag.startLeft + deltaX;
    const newY = this.currentDrag.startTop + deltaY;
    
    this.currentDrag.element.style.left = `${newX}px`;
    this.currentDrag.element.style.top = `${newY}px`;
    
    // 回调
    if (this.options.onDrag) {
      this.options.onDrag({
        ...this.currentDrag,
        x: newX,
        y: newY,
        deltaX,
        deltaY
      });
    }
  }

  applyConstraints(deltaX, deltaY) {
    const { options, element } = this.currentDrag;
    
    // 轴向约束
    if (options.axis === 'x') {
      deltaY = 0;
    } else if (options.axis === 'y') {
      deltaX = 0;
    }
    
    // 边界约束
    if (options.bounds) {
      const bounds = options.bounds;
      const containerRect = this.options.container.getBoundingClientRect();
      const elementRect = element.getBoundingClientRect();
      
      const minX = bounds.left || -Infinity;
      const maxX = bounds.right !== undefined 
        ? bounds.right - elementRect.width 
        : Infinity;
      const minY = bounds.top || -Infinity;
      const maxY = bounds.bottom !== undefined
        ? bounds.bottom - elementRect.height
        : Infinity;
      
      const newX = this.currentDrag.startLeft + deltaX;
      const newY = this.currentDrag.startTop + deltaY;
      
      deltaX = Math.max(minX, Math.min(maxX, newX)) - this.currentDrag.startLeft;
      deltaY = Math.max(minY, Math.min(maxY, newY)) - this.currentDrag.startTop;
    }
    
    // 网格约束
    if (options.grid) {
      const [gridX, gridY] = options.grid;
      deltaX = Math.round(deltaX / gridX) * gridX;
      deltaY = Math.round(deltaY / gridY) * gridY;
    }
    
    return { x: deltaX, y: deltaY };
  }

  endDrag() {
    if (!this.currentDrag) return;
    
    // 移除事件监听
    document.removeEventListener('mousemove', this.currentDrag.moveHandler);
    document.removeEventListener('touchmove', this.currentDrag.moveHandler);
    document.removeEventListener('mouseup', this.currentDrag.upHandler);
    document.removeEventListener('touchend', this.currentDrag.upHandler);
    
    // 恢复样式
    const dragInfo = this.draggables.get(this.currentDrag.dragId);
    if (dragInfo) {
      Object.assign(this.currentDrag.element.style, dragInfo.originalStyle);
    }
    
    // 回调
    if (this.options.onEnd) {
      this.options.onEnd(this.currentDrag);
    }
    
    this.currentDrag = null;
  }
}

4. 动画库实现

4.1 高性能动画引擎
class AnimationEngine {
  constructor() {
    this.animations = new Map();
    this.requestId = null;
    this.lastTime = 0;
    this.isRunning = false;
    this.easingFunctions = this.getEasingFunctions();
  }

  // 缓动函数集合
  getEasingFunctions() {
    return {
      linear: t => t,
      easeInQuad: t => t * t,
      easeOutQuad: t => t * (2 - t),
      easeInOutQuad: t => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
      easeInCubic: t => t * t * t,
      easeOutCubic: t => (--t) * t * t + 1,
      easeInOutCubic: t => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
      easeInElastic: t => (0.04 - 0.04 / t) * Math.sin(25 * t) + 1,
      easeOutElastic: t => 0.04 * t / (--t) * Math.sin(25 * t),
      spring: (t, damping = 0.5) => 1 - Math.exp(-6 * damping * t) * Math.cos(t * Math.PI * 2 / 0.5)
    };
  }

  // 创建动画
  animate(target, properties, options = {}) {
    const animationId = `anim_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
    
    const animation = {
      id: animationId,
      target,
      startValues: {},
      endValues: properties,
      options: {
        duration: 1000,
        delay: 0,
        easing: 'linear',
        onStart: null,
        onUpdate: null,
        onComplete: null,
        ...options
      },
      startTime: 0,
      progress: 0,
      state: 'waiting'
    };
    
    // 获取起始值
    Object.keys(properties).forEach(prop => {
      if (typeof target[prop] === 'number') {
        animation.startValues[prop] = target[prop];
      } else if (typeof target.style[prop] !== 'undefined') {
        animation.startValues[prop] = parseFloat(getComputedStyle(target)[prop]) || 0;
      }
    });
    
    this.animations.set(animationId, animation);
    
    // 开始动画循环
    if (!this.isRunning) {
      this.start();
    }
    
    return animationId;
  }

  start() {
    this.isRunning = true;
    this.lastTime = performance.now();
    this.update();
  }

  update(currentTime = performance.now()) {
    const deltaTime = currentTime - this.lastTime;
    this.lastTime = currentTime;
    
    let hasActiveAnimations = false;
    
    this.animations.forEach((animation, id) => {
      if (animation.state === 'waiting') {
        animation.startTime = currentTime + animation.options.delay;
        animation.state = 'delayed';
      }
      
      if (animation.state === 'delayed' && currentTime >= animation.startTime) {
        animation.state = 'running';
        if (animation.options.onStart) {
          animation.options.onStart(animation);
        }
      }
      
      if (animation.state === 'running') {
        const elapsed = currentTime - animation.startTime;
        animation.progress = Math.min(elapsed / animation.options.duration, 1);
        
        // 应用缓动函数
        const easingFunc = this.easingFunctions[animation.options.easing] || this.easingFunctions.linear;
        const easedProgress = easingFunc(animation.progress);
        
        // 更新属性
        this.updateProperties(animation, easedProgress);
        
        // 回调
        if (animation.options.onUpdate) {
          animation.options.onUpdate(animation, easedProgress);
        }
        
        if (animation.progress >= 1) {
          animation.state = 'completed';
          if (animation.options.onComplete) {
            animation.options.onComplete(animation);
          }
          // 标记为待清理
          animation.state = 'finished';
        } else {
          hasActiveAnimations = true;
        }
      }
    });
    
    // 清理已完成的动画
    this.cleanup();
    
    if (hasActiveAnimations) {
      this.requestId = requestAnimationFrame(this.update.bind(this));
    } else {
      this.stop();
    }
  }

  updateProperties(animation, progress) {
    const { target, startValues, endValues } = animation;
    
    Object.keys(endValues).forEach(prop => {
      const startValue = startValues[prop];
      const endValue = endValues[prop];
      const currentValue = startValue + (endValue - startValue) * progress;
      
      if (typeof target[prop] === 'number') {
        target[prop] = currentValue;
      } else if (typeof target.style[prop] !== 'undefined') {
        target.style[prop] = currentValue + (typeof endValue === 'string' ? endValue.replace(/[0-9.-]/g, '') : '');
      }
    });
  }

  cleanup() {
    const finishedAnimations = [];
    
    this.animations.forEach((animation, id) => {
      if (animation.state === 'finished') {
        finishedAnimations.push(id);
      }
    });
    
    finishedAnimations.forEach(id => {
      this.animations.delete(id);
    });
  }

  stop() {
    if (this.requestId) {
      cancelAnimationFrame(this.requestId);
      this.requestId = null;
    }
    this.isRunning = false;
  }

  pause(animationId) {
    const animation = this.animations.get(animationId);
    if (animation && animation.state === 'running') {
      animation.state = 'paused';
      animation.pauseTime = performance.now();
    }
  }

  resume(animationId) {
    const animation = this.animations.get(animationId);
    if (animation && animation.state === 'paused') {
      const pauseDuration = performance.now() - animation.pauseTime;
      animation.startTime += pauseDuration;
      animation.state = 'running';
      
      if (!this.isRunning) {
        this.start();
      }
    }
  }

  cancel(animationId) {
    this.animations.delete(animationId);
  }
}

// 关键帧动画支持
class KeyframeAnimation {
  constructor(target, keyframes, options = {}) {
    this.target = target;
    this.keyframes = this.normalizeKeyframes(keyframes);
    this.options = {
      duration: 1000,
      iterations: 1,
      direction: 'normal',
      fillMode: 'forwards',
      ...options
    };
    this.animationEngine = new AnimationEngine();
    this.currentIteration = 0;
  }

  normalizeKeyframes(keyframes) {
    // 确保关键帧按时间排序
    return Object.keys(keyframes)
      .sort((a, b) => parseFloat(a) - parseFloat(b))
      .map(time => ({
        time: parseFloat(time) / 100,
        properties: keyframes[time]
      }));
  }

  play() {
    const segmentDuration = this.options.duration / (this.keyframes.length - 1);
    
    this.keyframes.slice(0, -1).forEach((frame, index) => {
      const nextFrame = this.keyframes[index + 1];
      const duration = (nextFrame.time - frame.time) * this.options.duration;
      
      this.animationEngine.animate(
        this.target,
        nextFrame.properties,
        {
          duration,
          delay: frame.time * this.options.duration,
          easing: this.options.easing,
          onComplete: index === this.keyframes.length - 2 
            ? () => this.handleIterationComplete()
            : null
        }
      );
    });
  }

  handleIterationComplete() {
    this.currentIteration++;
    
    if (this.currentIteration < this.options.iterations) {
      this.play();
    }
  }
}

5. 其他技术点

5.1 响应式可视化适配
class ResponsiveVisualization {
  constructor(container, renderFunction, options = {}) {
    this.container = container;
    this.renderFunction = renderFunction;
    this.options = {
      debounceDelay: 100,
      aspectRatio: null,
      minWidth: 100,
      minHeight: 100,
      ...options
    };
    
    this.resizeObserver = null;
    this.debounceTimer = null;
    this.init();
  }

  init() {
    this.setupResizeObserver();
    this.setupWindowResize();
    this.initialRender();
  }

  setupResizeObserver() {
    if ('ResizeObserver' in window) {
      this.resizeObserver = new ResizeObserver(entries => {
        this.handleResize();
      });
      this.resizeObserver.observe(this.container);
    }
  }

  setupWindowResize() {
    window.addEventListener('resize', () => {
      this.handleResize();
    });
  }

  handleResize() {
    clearTimeout(this.debounceTimer);
    
    this.debounceTimer = setTimeout(() => {
      this.updateDimensions();
      this.render();
    }, this.options.debounceDelay);
  }

  updateDimensions() {
    const containerRect = this.container.getBoundingClientRect();
    
    let width = Math.max(this.options.minWidth, containerRect.width);
    let height = Math.max(this.options.minHeight, containerRect.height);
    
    if (this.options.aspectRatio) {
      const [ratioX, ratioY] = this.options.aspectRatio.split(':').map(Number);
      const targetRatio = ratioX / ratioY;
      const currentRatio = width / height;
      
      if (currentRatio > targetRatio) {
        width = height * targetRatio;
      } else {
        height = width / targetRatio;
      }
    }
    
    this.dimensions = { width, height };
  }

  initialRender() {
    this.updateDimensions();
    this.render();
  }

  render() {
    if (!this.dimensions) return;
    
    this.renderFunction({
      width: this.dimensions.width,
      height: this.dimensions.height,
      container: this.container
    });
  }

  destroy() {
    if (this.resizeObserver) {
      this.resizeObserver.disconnect();
    }
    window.removeEventListener('resize', this.handleResize);
    clearTimeout(this.debounceTimer);
  }
}
5.2 事件管理系统
class EventManager {
  constructor() {
    this.eventHandlers = new Map();
    this.eventQueue = [];
    this.processing = false;
  }

  // 事件绑定
  on(element, eventType, handler, options = {}) {
    const eventKey = `${eventType}_${Math.random().toString(36).substr(2, 9)}`;
    const wrappedHandler = this.createWrappedHandler(handler, options);
    
    element.addEventListener(eventType, wrappedHandler, options);
    
    this.eventHandlers.set(eventKey, {
      element,
      eventType,
      handler: wrappedHandler,
      originalHandler: handler,
      options
    });
    
    return eventKey;
  }

  createWrappedHandler(handler, options) {
    return (event) => {
      if (options.throttle) {
        this.throttle(handler, options.throttle, event);
      } else if (options.debounce) {
        this.debounce(handler, options.debounce, event);
      } else {
        handler(event);
      }
    };
  }

  // 节流
  throttle(func, limit, ...args) {
    let inThrottle;
    
    return () => {
      if (!inThrottle) {
        func.apply(this, args);
        inThrottle = true;
        setTimeout(() => inThrottle = false, limit);
      }
    };
  }

  // 防抖
  debounce(func, wait, ...args) {
    let timeout;
    
    return () => {
      clearTimeout(timeout);
      timeout = setTimeout(() => func.apply(this, args), wait);
    };
  }

  // 事件委托
  delegate(parent, selector, eventType, handler) {
    return this.on(parent, eventType, (event) => {
      const target = event.target.closest(selector);
      if (target && parent.contains(target)) {
        handler.call(target, event);
      }
    });
  }

  // 移除事件
  off(eventKey) {
    const eventInfo = this.eventHandlers.get(eventKey);
    if (eventInfo) {
      eventInfo.element.removeEventListener(
        eventInfo.eventType,
        eventInfo.handler,
        eventInfo.options
      );
      this.eventHandlers.delete(eventKey);
    }
  }

  // 一次性事件
  once(element, eventType, handler) {
    const eventKey = this.on(element, eventType, (event) => {
      handler(event);
      this.off(eventKey);
    });
    return eventKey;
  }

  // 触发自定义事件
  emit(element, eventType, detail = {}) {
    const event = new CustomEvent(eventType, {
      bubbles: true,
      cancelable: true,
      detail
    });
    element.dispatchEvent(event);
  }

  // 批量移除事件
  cleanup() {
    this.eventHandlers.forEach((eventInfo, key) => {
      this.off(key);
    });
  }
}

6. 实战案例: 集成可视化仪表板

class VisualizationDashboard {
  constructor(containerId, dataSource) {
    this.container = document.getElementById(containerId);
    this.dataSource = dataSource;
    this.components = new Map();
    this.eventManager = new EventManager();
    this.init();
  }

  async init() {
    await this.loadData();
    this.setupLayout();
    this.renderComponents();
    this.setupInteractions();
  }

  async loadData() {
    try {
      this.data = await this.dataSource.getData();
      this.processedData = this.processData(this.data);
    } catch (error) {
      console.error('Failed to load data:', error);
    }
  }

  processData(rawData) {
    // 数据处理逻辑
    return {
      summary: this.calculateSummary(rawData),
      trends: this.extractTrends(rawData),
      categories: this.groupByCategory(rawData)
    };
  }

  setupLayout() {
    // 使用CSS Grid或Flexbox创建响应式布局
    this.container.innerHTML = `
      <div class="dashboard-grid">
        <div class="header" id="dashboard-header">
          <h1>数据可视化仪表板</h1>
          <div class="controls" id="dashboard-controls"></div>
        </div>
        <div class="chart chart-large" id="main-chart"></div>
        <div class="chart chart-small" id="chart-1"></div>
        <div class="chart chart-small" id="chart-2"></div>
        <div class="chart chart-small" id="chart-3"></div>
        <div class="stats-panel" id="stats-panel"></div>
      </div>
    `;
    
    this.setupResponsive();
  }

  setupResponsive() {
    const responsive = new ResponsiveVisualization(
      this.container,
      (dimensions) => this.handleResize(dimensions),
      { debounceDelay: 150 }
    );
  }

  renderComponents() {
    // 渲染各个可视化组件
    this.components.set('mainChart', this.createMainChart());
    this.components.set('chart1', this.createPieChart());
    this.components.set('chart2', this.createBarChart());
    this.components.set('chart3', this.createLineChart());
    this.components.set('stats', this.createStatsPanel());
  }

  createMainChart() {
    const container = document.getElementById('main-chart');
    const chartType = this.determineBestChart(this.processedData.trends);
    
    switch (chartType) {
      case 'line':
        return this.createLineChart(container, this.processedData.trends, {
          title: '趋势分析',
          showLegend: true,
          animated: true
        });
      case 'bar':
        return this.createBarChart(container, this.processedData.categories, {
          title: '分类对比',
          stacked: true
        });
      default:
        return this.createCustomVisualization(container, this.processedData);
    }
  }

  setupInteractions() {
    // 设置组件间的交互
    this.components.forEach((component, id) => {
      if (component.onClick) {
        this.eventManager.on(
          component.element,
          'click',
          (event) => this.handleComponentClick(id, event)
        );
      }
    });
    
    // 全局筛选器
    this.setupFilters();
  }

  setupFilters() {
    const controls = document.getElementById('dashboard-controls');
    controls.innerHTML = `
      <select id="time-range">
        <option value="7d">最近7天</option>
        <option value="30d">最近30天</option>
        <option value="90d">最近90天</option>
      </select>
      <button id="refresh-btn">刷新数据</button>
    `;
    
    this.eventManager.on(
      document.getElementById('time-range'),
      'change',
      (event) => this.handleTimeRangeChange(event.target.value)
    );
    
    this.eventManager.on(
      document.getElementById('refresh-btn'),
      'click',
      () => this.refreshData()
    );
  }

  async refreshData() {
    await this.loadData();
    this.updateAllComponents();
  }

  updateAllComponents() {
    this.components.forEach(component => {
      if (component.update) {
        component.update(this.processedData);
      }
    });
  }

  handleComponentClick(componentId, event) {
    // 处理组件点击事件,实现联动
    const clickedData = this.extractDataFromEvent(event);
    
    this.components.forEach((component, id) => {
      if (id !== componentId && component.filter) {
        component.filter(clickedData);
      }
    });
  }

  handleResize(dimensions) {
    this.components.forEach(component => {
      if (component.resize) {
        component.resize(dimensions);
      }
    });
  }

  destroy() {
    this.components.forEach(component => {
      if (component.destroy) {
        component.destroy();
      }
    });
    
    this.eventManager.cleanup();
  }
}

7. 性能优化与最佳实践

7.1 内存管理
class MemoryManager {
  constructor() {
    this.references = new WeakMap();
    this.cache = new Map();
    this.memoryLeakDetector = null;
  }

  // 智能引用计数
  trackReference(object, type) {
    if (!this.references.has(object)) {
      this.references.set(object, {
        type,
        timestamp: Date.now(),
        refCount: 0
      });
    }
    
    const refInfo = this.references.get(object);
    refInfo.refCount++;
    
    return () => {
      refInfo.refCount--;
      if (refInfo.refCount <= 0) {
        this.cleanupObject(object);
      }
    };
  }

  // 对象池
  createObjectPool(factory, size) {
    const pool = {
      available: new Array(size).fill(null).map(() => factory()),
      inUse: new Set(),
      acquire: function() {
        if (this.available.length > 0) {
          const obj = this.available.pop();
          this.inUse.add(obj);
          return obj;
        }
        return factory();
      },
      release: function(obj) {
        if (this.inUse.has(obj)) {
          this.inUse.delete(obj);
          this.available.push(obj);
        }
      }
    };
    
    return pool;
  }

  // 缓存管理
  createCache(maxSize = 100, ttl = 60000) {
    return {
      data: new Map(),
      maxSize,
      ttl,
      
      set(key, value) {
        if (this.data.size >= this.maxSize) {
          const oldestKey = this.data.keys().next().value;
          this.data.delete(oldestKey);
        }
        
        this.data.set(key, {
          value,
          timestamp: Date.now(),
          expire: Date.now() + this.ttl
        });
      },
      
      get(key) {
        const item = this.data.get(key);
        
        if (!item) return null;
        
        if (Date.now() > item.expire) {
          this.data.delete(key);
          return null;
        }
        
        return item.value;
      }
    };
  }

  // 内存泄漏检测
  setupLeakDetection(interval = 30000) {
    this.memoryLeakDetector = setInterval(() => {
      this.checkForLeaks();
    }, interval);
  }

  checkForLeaks() {
    const now = Date.now();
    const leaks = [];
    
    // 简化的泄漏检测逻辑
    // 实际应用中需要更复杂的检测机制
    
    if (leaks.length > 0) {
      console.warn('Potential memory leaks detected:', leaks);
    }
  }

  cleanupObject(object) {
    // 清理对象的资源
    if (object.dispose && typeof object.dispose === 'function') {
      object.dispose();
    }
    
    this.references.delete(object);
  }
}

总结

本文详细介绍了Web可视化技术的核心实现,包括Canvas绘图、SVG操作、拖拽交互和动画系统。通过封装可复用的工具类,我们可以构建高性能、可维护的可视化应用。

关键实现总结:

  1. Canvas优化: 使用离屏渲染、批量操作和双缓冲技术
  2. SVG矢量: 利用DOM操作和CSS动画实现流畅的可视化
  3. 交互体验: 实现平滑的拖拽和丰富的用户交互
  4. 动画性能: 基于requestAnimationFrame的高性能动画引擎
  5. 响应式设计: 自动适配不同屏幕尺寸和分辨率
  6. 内存管理: 有效管理资源,防止内存泄漏

这些实现不仅展示了各种可视化技术的核心原理,还提供了实际项目中可以使用的完整解决方案。通过理解这些底层实现,开发者可以更好地掌握可视化技术,创建出更高效、更美观、更交互性强的可视化应用。

可视化技术的核心在于将抽象数据转化为直观的视觉形式,帮助用户理解和发现数据中的模式与洞见。随着Web技术的不断发展,前端可视化将继续在各个领域发挥重要作用,从数据仪表盘到科学可视化,从游戏开发到虚拟现实,可视化技术的前景无限广阔。

ElementUI:表格如何展示超出单元格的内容且不影响单元格?

关注前端小讴,阅读更多原创技术文章

  • 这个问题之前在封装表格行内编辑校验时就一直困扰我,近期因为业务需求又不得不面对了

需求详述

  • ElementUi表格列若干(肯定有横向滚动条),在若干行(不固定)的某一列上(不固定)展示指定文字,
  • 要展示的文字长度大概率比该列宽度大
  • 文字需要完整展示,可跨单元格

尝试过程

  • 直接使用自定义渲染单元格,失败,超出单元格部分会被遮盖
  • 自定义指令在document上渲染内容,失败,定位很困难(很难拿到该单元格相对整个document的位置),且内容也不随滚动条滚动
  • 使用el-tooltip,让其一直保持展示,失败,el-tooltip初始化没有定位,只有在鼠标移入时才有

成功方案

  • 使用el-popover弹出框的手动激活方式,其既保证dom结构在单元格里,又能打破内容无法超出单元格的壁垒
<template>
  <el-table-column
    v-for="(column, i) in columnList"
    :key="column.value"
    width="20"
    class-name="gantt-column"
  >
    <template slot-scope="scope">
      <div class="gantt-bar" :style="`background: green`">
        <el-popover
          v-if="scope.row.percent"
          v-model="visible"
          popper-class="gantt-percent-popover"
          trigger="manual"
          :content="`${scope.row.percent}%`"
        >
        </el-popover>
      </div>
    </template>
  </el-table-column>
</template>

<script>
export default {
  data() {
    return {
      columnList: [], // 动态表格列
      visible: true, // popover-始终展示
    };
  },
};
</script>

<style lang="scss">
.gantt-percent-popover {
  width: max-content;
  min-width: auto;
  border: none;
  box-shadow: none;
  background: none;
  padding: 0 !important;
  font-size: 14px;
  color: rgba(0, 0, 0, 1);
  font-weight: bold;
  // text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
  white-space: nowrap;
  height: 23px;
  line-height: 23px;
  transform: translate(-40%, 0);
  font-style: italic;
}
</style>
❌