普通视图

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

Best Linux Distributions for Every Use Case

A Linux distribution (or “distro”) is an operating system built on the Linux kernel, combined with GNU tools, libraries, and software packages. Each distro includes a desktop environment, package manager, and preinstalled applications tailored to specific use cases.

With hundreds of Linux distributions available, choosing the right one can be overwhelming. This guide covers the best Linux distributions for different types of users, from complete beginners to security professionals.

How to Choose a Linux Distro

When selecting a Linux distribution, consider these factors:

  • Experience level — Some distros are beginner-friendly, while others require technical knowledge
  • Hardware — Older computers benefit from lightweight distros like Xubuntu and other Xfce-based systems
  • Purpose — Desktop use, gaming, server deployment, or security testing all have ideal distros
  • Software availability — Check if your required applications are available in the distro’s repositories
  • Community support — Larger communities mean more documentation and help

Linux Distros for Beginners

These distributions are designed with user-friendliness in mind, featuring intuitive interfaces and easy installation.

Ubuntu

Ubuntu is the most popular Linux distribution and an excellent starting point for newcomers. Developed by Canonical, it offers a polished desktop experience with the GNOME environment and extensive hardware support.

Ubuntu desktop screenshot

Ubuntu comes in several editions:

  • Ubuntu Desktop — Standard desktop with GNOME
  • Ubuntu Server — For server deployments
  • Kubuntu, Lubuntu, Xubuntu — Alternative desktop environments

Ubuntu releases new versions every six months, with Long Term Support (LTS) versions every two years receiving five years of security updates.

Website: https://ubuntu.com/

Linux Mint

Linux Mint is an excellent choice for users coming from Windows. Its Cinnamon desktop environment provides a familiar layout with a taskbar, start menu, and system tray.

Linux Mint desktop screenshot

Key features:

  • Comes with multimedia codecs preinstalled
  • LibreOffice productivity suite included
  • Update Manager for easy system maintenance
  • Available with Cinnamon, MATE, or Xfce desktops

Website: https://linuxmint.com/

Pop!_OS

Developed by System76, Pop!_OS is based on Ubuntu but optimized for productivity and gaming. It ships with the COSMIC desktop environment (built in Rust by System76), featuring built-in window tiling, a launcher for quick app access, and excellent NVIDIA driver support out of the box.

Pop!_OS desktop screenshot

Pop!_OS is particularly popular among:

  • Gamers (thanks to Steam and Proton integration)
  • Developers (includes many development tools)
  • Users with NVIDIA graphics cards

Website: https://pop.system76.com/

Zorin OS

Zorin OS is a beginner-focused distribution designed to make the move from Windows or macOS easier. It includes polished desktop layouts, strong hardware compatibility, and a simple settings experience for new users.

Zorin OS is a strong option when you want:

  • A familiar desktop layout with minimal setup
  • Stable Ubuntu-based package compatibility
  • Good out-of-the-box support for everyday desktop tasks
Zorin OS desktop screenshot

Website: https://zorin.com/os/

elementary OS

elementary OS is a design-focused distribution with a macOS-like interface called Pantheon. It emphasizes simplicity, consistency, and a curated app experience through its AppCenter.

elementary OS desktop screenshot

elementary OS is a good fit when you want:

  • A clean, visually polished desktop out of the box
  • A curated app store with native applications
  • A macOS-like workflow on Linux

Website: https://elementary.io/

Lightweight Linux Distros

These distributions are designed for older hardware or users who prefer a minimal, fast system.

Xubuntu

Xubuntu combines Ubuntu’s reliability with the Xfce desktop environment, offering a good balance between performance and features. It is lighter than standard Ubuntu while remaining full-featured for daily desktop use.

Xubuntu is a practical choice when you need:

  • Better performance on older hardware
  • A traditional desktop workflow
  • Ubuntu repositories and long-term support options
Xubuntu desktop screenshot

Website: https://xubuntu.org/

Lubuntu

Lubuntu uses the LXQt desktop environment, making it one of the lightest Ubuntu-based distributions available. It is designed for very old or resource-constrained hardware where even Xfce feels heavy.

Lubuntu desktop screenshot

Lubuntu works well when you need:

  • Minimal memory and CPU usage
  • A functional desktop on very old hardware
  • Access to Ubuntu repositories and LTS support

Website: https://lubuntu.me/

Linux Distros for Advanced Users

These distributions offer more control and customization but require technical knowledge to set up and maintain.

Arch Linux

Arch Linux follows a “do-it-yourself” philosophy, providing a minimal base system that users build according to their needs. It uses a rolling release model, meaning you always have the latest software without major version upgrades.

Key features:

  • Pacman package manager with access to vast repositories
  • Arch User Repository (AUR) for community packages
  • Excellent documentation in the Arch Wiki
  • Complete control over every aspect of the system
Arch Linux desktop screenshot
Tip
Arch Linux requires manual installation via the command line. If you want the Arch experience with an easier setup, consider Manjaro or EndeavourOS.

Website: https://archlinux.org/

EndeavourOS

EndeavourOS is an Arch-based distribution that keeps the Arch philosophy while simplifying installation and initial setup. It is popular among users who want a near-Arch experience without doing a fully manual install.

EndeavourOS gives you:

  • Rolling release updates
  • Access to Arch repositories and AUR packages
  • A cleaner onboarding path than a base Arch install
EndeavourOS desktop screenshot

Website: https://endeavouros.com/

Fedora

Fedora is a cutting-edge distribution sponsored by Red Hat. It showcases the latest open-source technologies while maintaining stability, making it popular among developers and system administrators.

Fedora desktop screenshot

Fedora editions include:

  • Fedora Workstation — Desktop with GNOME
  • Fedora Server — For server deployments
  • Fedora Silverblue — Immutable desktop OS
  • Fedora Spins — Alternative desktops (KDE, Xfce, etc.)

Many Red Hat technologies debut in Fedora before reaching RHEL, making it ideal for learning enterprise Linux.

Website: https://fedoraproject.org/

openSUSE

openSUSE is a community-driven distribution known for its stability and powerful administration tools. It offers two main variants:

  • openSUSE Leap — Regular releases based on SUSE Linux Enterprise
  • openSUSE Tumbleweed — Rolling release with the latest packages

The YaST (Yet another Setup Tool) configuration utility makes system administration straightforward, handling everything from software installation to network configuration.

openSUSE desktop screenshot

Website: https://www.opensuse.org/

Linux Distros for Gaming

Gaming-focused distributions prioritize current graphics stacks, controller support, and compatibility with modern Steam and Proton workflows.

Bazzite

Bazzite is an immutable Fedora-based desktop optimized for gaming and handheld devices. It ships with gaming-focused defaults and integrates well with Steam, Proton, and modern GPU drivers.

Bazzite is ideal when you want:

  • A Steam-first gaming setup
  • Reliable rollback and update behavior from an immutable base
  • A distro tuned for gaming PCs and handheld hardware
Bazzite desktop screenshot

Website: https://bazzite.gg/

Linux Distros for Servers

These distributions are optimized for stability, security, and long-term support in server environments.

Debian

Debian is one of the oldest and most influential Linux distributions. Known for its rock-solid stability and rigorous testing process, it serves as the foundation for Ubuntu, Linux Mint, Kali Linux, and many other distributions.

Debian desktop screenshot

Debian offers three release channels:

  • Stable — Thoroughly tested, ideal for production servers
  • Testing — Upcoming stable release with newer packages
  • Unstable (Sid) — Rolling release with the latest software

With over 59,000 packages in its repositories, Debian supports more hardware architectures than any other Linux distribution.

Website: https://www.debian.org/

Red Hat Enterprise Linux (RHEL)

RHEL is the industry standard for enterprise Linux deployments. It offers:

  • 10-year support lifecycle
  • Certified hardware and software compatibility
  • Red Hat Insights for predictive analytics
  • Professional support from Red Hat

RHEL runs on multiple architectures including x86_64, ARM64, IBM Power, and IBM Z.

Website: https://www.redhat.com/

Rocky Linux

After CentOS shifted to CentOS Stream, Rocky Linux emerged as a community-driven RHEL-compatible distribution. Founded by one of the original CentOS creators, it provides 1:1 binary compatibility with RHEL.

Rocky Linux desktop screenshot

Rocky Linux is ideal for:

  • Organizations previously using CentOS
  • Production servers requiring stability
  • Anyone needing RHEL compatibility without the cost

Website: https://rockylinux.org/

Ubuntu Server

Ubuntu Server is widely used for cloud deployments and containerized workloads. It powers a significant portion of public cloud instances on AWS, Google Cloud, and Azure.

Features include:

  • Regular and LTS releases
  • Excellent container and Kubernetes support
  • Ubuntu Pro for extended security maintenance
  • Snap packages for easy application deployment

Website: https://ubuntu.com/server

SUSE Linux Enterprise Server (SLES)

SUSE Linux Enterprise Server is designed for mission-critical workloads. It excels in:

  • SAP HANA deployments
  • High-performance computing
  • Mainframe environments
  • Edge computing

SLES offers a common codebase across different environments, simplifying workload migration.

Website: https://www.suse.com/products/server/

Linux Distros for Security and Privacy

These distributions focus on security testing, anonymity, and privacy protection.

Kali Linux

Kali Linux is the industry-standard platform for penetration testing and security research. Maintained by Offensive Security, it includes hundreds of security tools preinstalled.

Common use cases:

  • Penetration testing
  • Security auditing
  • Digital forensics
  • Reverse engineering
Kali Linux desktop screenshot
Warning
Kali Linux is designed for security professionals. It should not be used as a daily driver operating system.

Website: https://www.kali.org/

Tails

Tails (The Amnesic Incognito Live System) is a portable operating system designed for privacy and anonymity. It runs from a USB drive and routes all traffic through the Tor network.

Key features:

  • Leaves no trace on the host computer
  • All connections go through Tor
  • Built-in encryption tools
  • Amnesic by design (forgets everything on shutdown)
Tails desktop screenshot

Website: https://tails.net/

Qubes OS

Qubes OS takes a unique approach to security by isolating different activities in separate virtual machines called “qubes.” If one qube is compromised, others remain protected.

The Xen hypervisor runs directly on hardware, providing strong isolation between:

  • Work applications
  • Personal browsing
  • Untrusted software
  • Sensitive data

Website: https://www.qubes-os.org/

Parrot Security OS

Parrot Security is a Debian-based distribution for security testing, development, and privacy. It is lighter than Kali Linux and can serve as a daily driver.

Parrot offers several editions:

  • Security Edition — Full security toolkit
  • Home Edition — Privacy-focused daily use
  • Cloud Edition — For cloud deployments

Website: https://parrotsec.org/

Getting Started

Once you have chosen a distro, the next steps are:

  1. Download the ISO from the official website
  2. Create a bootable USB drive — See our guide on creating a bootable Linux USB
  3. Try it live before installing (most distros support this)
  4. Install following the distro’s installation wizard

Quick Comparison

Distro Best For Desktop Package Manager Based On
Ubuntu Beginners GNOME APT Debian
Linux Mint Windows users Cinnamon APT Ubuntu
Zorin OS New Linux users GNOME (Zorin desktop) APT Ubuntu
elementary OS macOS-like experience Pantheon APT Ubuntu
Fedora Developers GNOME DNF Independent
Debian Stability/Servers GNOME APT Independent
Arch Linux Advanced users Any Pacman Independent
EndeavourOS Arch with easier setup Xfce (default) Pacman Arch Linux
Pop!_OS Gaming/Developers COSMIC APT Ubuntu
Bazzite Gaming KDE/GNOME variants RPM-OSTree Fedora
Rocky Linux Enterprise servers None DNF RHEL
Xubuntu Older hardware Xfce APT Ubuntu
Lubuntu Very old hardware LXQt APT Ubuntu
Kali Linux Security testing Xfce APT Debian

FAQ

Which Linux distro is best for beginners?
Ubuntu, Linux Mint, and Zorin OS are the best choices for beginners. Ubuntu has the largest community and most documentation, while Linux Mint and Zorin OS provide a familiar desktop experience.

Can I try a Linux distro without installing it?
Yes. Most distributions support “live booting” from a USB drive, allowing you to test the system without making any changes to your computer.

Is Linux free?
Most Linux distributions are completely free to download and use. Some enterprise distros like RHEL offer paid support subscriptions.

Can I run Windows software on Linux?
Many Windows applications run on Linux through Wine or Proton (for games via Steam). Native alternatives like LibreOffice, GIMP, and Firefox are also available.

What is a rolling release distro?
A rolling release distro (like Arch Linux or openSUSE Tumbleweed) delivers continuous updates instead of major version upgrades. You always have the latest software, but updates require more attention.

Conclusion

The best Linux distribution depends entirely on your needs and experience level. If you are new to Linux, start with Ubuntu, Linux Mint, or Zorin OS. If you want full control over your system, try Arch Linux, EndeavourOS, or Fedora. For gaming, Pop!_OS and Bazzite are strong options. For servers, Debian, Rocky Linux, Ubuntu Server, and RHEL are all solid choices. For security testing, Kali Linux and Parrot Security are the industry standards.

Most distributions are free to download and try. Create a bootable USB, test a few options, and find the one that fits your workflow.

If you have any questions, feel free to leave a comment below.

SCP Cheatsheet

Basic Syntax

Use this general form for scp commands.

Command Description
scp SOURCE DEST General scp syntax
scp file.txt user@host:/path/ Copy local file to remote
scp user@host:/path/file.txt . Copy remote file to current directory
scp user@host:/path/file.txt /local/path/ Copy remote file to local directory

Upload Files

Copy local files to a remote host.

Command Description
scp file.txt user@host:/tmp/ Upload one file
scp file1 file2 user@host:/tmp/ Upload multiple files
scp *.log user@host:/var/log/archive/ Upload matching files
scp -p file.txt user@host:/tmp/ Preserve modification times and mode

Download Files

Copy files from a remote host to your local system.

Command Description
scp user@host:/tmp/file.txt . Download to current directory
scp user@host:/tmp/file.txt ~/Downloads/ Download to specific directory
scp user@host:'/var/log/*.log' . Download remote wildcard (quoted)
scp user@host:/tmp/file.txt ./new-name.txt Download and rename locally

Copy Directories

Use -r for recursive directory transfers.

Command Description
scp -r dir/ user@host:/tmp/ Upload directory recursively
scp -r user@host:/var/www/ ./backup/ Download directory recursively
scp -r dir1 dir2 user@host:/tmp/ Upload multiple directories
scp -rp project/ user@host:/srv/ Recursive copy and preserve attributes

Ports, Keys, and Identity

Connect with custom SSH settings.

Command Description
scp -P 2222 file.txt user@host:/tmp/ Use custom SSH port
scp -i ~/.ssh/id_ed25519 file.txt user@host:/tmp/ Use specific private key
scp -o IdentityFile=~/.ssh/id_ed25519 file.txt user@host:/tmp/ Set key with -o option
scp -o StrictHostKeyChecking=yes file.txt user@host:/tmp/ Enforce host key verification

Performance and Reliability

Tune speed, verbosity, and resilience.

Command Description
scp -C large-file.iso user@host:/tmp/ Enable compression
scp -l 8000 file.txt user@host:/tmp/ Limit bandwidth (Kbit/s)
scp -v file.txt user@host:/tmp/ Verbose output for debugging
scp -q file.txt user@host:/tmp/ Quiet mode
scp -o ConnectTimeout=10 file.txt user@host:/tmp/ Set connection timeout

Remote to Remote Copy

Transfer files between two remote hosts.

Command Description
scp user1@host1:/path/file user2@host2:/path/ Copy between remote hosts
scp -3 user1@host1:/path/file user2@host2:/path/ Route transfer through local host
scp -P 2222 user1@host1:/path/file user2@host2:/path/ Use custom port (applies to both hosts)

Common Patterns

Frequently used command combinations.

Command Description
scp -r ./site user@host:/var/www/ Deploy static site files
scp -i ~/.ssh/id_ed25519 -P 2222 backup.sql user@host:/tmp/ Upload with key and custom port
scp user@host:/etc/nginx/nginx.conf ./ Pull config for review
scp -rp ./configs user@host:/etc/myapp/ Copy configs and keep metadata

Troubleshooting

Check Command
Permission denied Verify user has write access to the destination path
Host key verification failed ssh-keygen -R hostname to remove old key, then retry
Connection refused on custom port scp -P PORT file user@host:/path/ (uppercase -P)
Transfer stalls or times out scp -o ConnectTimeout=10 -o ServerAliveInterval=15 file user@host:/path/
Not a regular file error Add -r for directories: scp -r dir/ user@host:/path/
Protocol error on OpenSSH 9.0+ scp -O file user@host:/path/ to use legacy SCP protocol
Debug connection issues scp -v file user@host:/path/ for verbose SSH output

Related Guides

Use these articles for detailed file transfer and SSH workflows.

Guide Description
How to Use SCP Command to Securely Transfer Files Full scp guide with practical examples
SSH Command in Linux SSH options, authentication, and connection examples
How to Use Linux SFTP Command to Transfer Files Interactive secure file transfer over SSH
How to Use Rsync for Local and Remote Data Transfer Incremental sync and directory transfer

WebMCP 时代:在浏览器中释放 AI 的工作能力

作者 CharlesYu01
2026年2月16日 17:49

随着 AI Agent 的广泛应用,传统的 Web 自动化与 Web 交互模式正在迎来根本性变化。WebMCP 是一个未来派的技术提案,它不仅改变了 AI 访问 Web 的方式,还为 AI 与前端应用之间建立起了 协议级的交互通道。本文从WebMCP架构分层解析这项技术及其工程意义。

面对 GEO 与 Agent 应用逐步弱化浏览器入口价值的趋势,浏览器厂商必须主动跟进,通过技术升级与生态重构来守住自身核心阵地。


一、WebMCP 是什么?

WebMCP(Web Model Context Protocol)是一种 客户端 JavaScript 接口规范,允许 Web 应用以结构化、可调用的形式向 AI Agent 暴露其功能(tools)。WebMCP 的核心目标是:

让 Web 应用拥有一组可被 AI Agents 调用的工具函数,避免 AI 通过截图 + DOM 模拟点击这样的低效方式去理解和操作页面。

WebMCP 允许开发者将 Web 应用的功能“以工具形式”公开,供 Agents、浏览器辅助技术等访问。页面将现有的 JavaScript 逻辑包装成与自然语言输入对应的“tools”,AI Agents 可以直接调用它们,而不是模拟用户行为。

换句话说:

WebMCP 是前端版的 MCP 工具协议:它让 Web 应用自己变成一个能被 AI 调用的、语义明确的接口服务器。


二、核心理念:让 Web App 成为 AI 可调用的工具集

WebMCP 的核心机制由三部分构成:

1. 工具注册与调用

页面通过 navigator.modelContext.registerTool() 或类似 API 把自己内部的 JS 功能(如搜索、筛选、提交、获取数据)注册为可调用的工具(tools)。这些 tools 带有:

  • 名称
  • 自然语言描述
  • 输入/输出结构定义(JSON schema)

Agents 识别到这些 tools 后,就可以直接调用,而不需要重新解析 DOM。


2. 语义描述与结构化调用

WebMCP 的工具接口是结构化的,而不是 UI 操作序列:

filterTemplates(description: string) → UI 更新
getDresses(size, color) → 返回商品列表
orderPrints(copies, page_size, page_finish) → 下单

这比视觉模拟更可靠、更高效。


3. 人机协作而非全自动

WebMCP 不是为了让 AI 完全替代用户,而是为了让用户和 AI 协同完成任务。它强调:

  • 共享上下文
  • AI 与用户同时可见的执行状态
  • 用户有权审查/接受 AI 的动作

不像纯后台机器人,WebMCP 是“在 UI 里协作”的模型。


三、基于 Browser + WebMCP 的 Agent 驱动架构

image.png

这张图展示了 WebMCP 在浏览器场景下的设计思路:


1)AI Platform(大模型)

  • 负责理解用户意图
  • 识别需要调用的 WebMCP 工具
  • 并发送工具调用指令

2)Browser-integrated Agent(浏览器 Agent)

这个组件负责:

  • 将 LLM 指令转为工具调用
  • 与 WebMCP JS 进行交互
  • 在当前网页上下文执行注册的 JavaScript 工具代码

它类似一个“中间控制层”,连接了一端的 AI 推理和另一端的前端工具。


3)WebMCP JS

运行在页面内部的代理代码:

  • 负责注册和执行 tools
  • 与 Agent 进行通信
  • 在正常 Web 环境中执行定义好的工具函数

这意味着:

页面本身是一个 MCP Server ,但运行在客户端。


4)Third-Party HTTP 服务

仍然是页面自身依赖的服务端业务逻辑:

  • 通常的业务 API
  • 页面使用这些 API 完成任务
  • 也可以在工具内部直接调用

核心意义总结

这张图的核心思想是:

在浏览器里增强 Web 应用,让 AI Agent 能调用前端定义好的交互能力,而不是模拟用户行为。

它是一个 “前端即服务的 MCP Server” 模式。


四、为什么这是一种范式级的变革?

1)结构良好的能力暴露

传统 Agents 访问网站靠:截图 +Vision 识别 + DOM 模拟

这是低效、易错且不稳定的。

WebMCP 直接告诉 AI:

你的工具是 filterTemplates(criteria)
不要再猜测页面结构

这意味着 AI 不再“模拟人”,而是“直接调用真实功能”。

2)前端逻辑复用

WebMCP 允许:

  • 复用现有前端逻辑
  • 业务功能无需写额外后端 API
  • 使用现有组件构建工具

3)提升安全和用户控制

WebMCP 需要用户授权,且工具执行会明显提示用户,这符合“人机协作”设计,还能避免:

  • 未授权数据泄露
  • 无感知的全自动操作

这比无 UI 后端自动化更可控。

五、典型使用场景

使用WebMCP订奶茶

你说:
帮我找一家评分高、离我近一点的奶茶店,最好 20 元以内。

当前页面注册了一个 WebMCP 工具:

/**
 * 根据自然语言描述搜索奶茶店
 *
 * description - 用户对店铺的需求描述(自然语言)
 * max_price - 人均价格上限(单位:人民币)
 */
searchMilkTeaShops(description, max_price)

浏览器Agent判断这个工具最符合用户意图,于是调用:

searchMilkTeaShops(
  "评分高,距离近,出餐快",
  20
)

页面内部会把自然语言转为已有筛选条件,例如:

  • 评分 ≥ 4.5
  • 距离 ≤ 2km
  • 人均 ≤ 20 元

然后刷新页面,只展示符合条件的店铺。

浏览器Agent回复:
我帮你筛选了几家评分高、距离近、价格合适的奶茶店,要不要限定品牌?

你说:
优先考虑喜茶或者蜜雪冰城,少糖。

页面还注册了一个工具:

/**
 * 在当前结果中按品牌和口味偏好筛选
 *
 * brands - 品牌数组,例如 ["喜茶", "蜜雪冰城"]
 * sweetness - 甜度偏好,例如 ["正常糖", "少糖", "无糖"]
 */
refineShops(brands, sweetness)

浏览器Agent调用:

refineShops(
  ["喜茶", "蜜雪冰城"],
  ["少糖"]
)

页面更新,只展示符合条件的店铺和推荐饮品。

你点进一家店铺页面。页面加载后注册了新的工具:

/**
 * 根据口味偏好推荐饮品
 *
 * description - 对饮品口味的自然语言描述
 * max_price - 单杯价格上限
 */
recommendDrinks(description, max_price)

你说:
给我推荐一杯清爽一点的水果茶,不要太甜,20 元以内。

调用:

recommendDrinks(
  "清爽水果茶,少糖,不腻",
  20
)

页面自动高亮并展示 2–3 款符合条件的饮品。

你选中其中一杯。

页面注册了下单相关工具:

/**
 * 将指定饮品加入购物车
 *
 * product_id - 饮品 ID
 * options - 规格选项,例如甜度、冰量
 */
addDrinkToCart(product_id, options)

/**
 * 提交订单
 */
checkout()

浏览器Agent调用:

addDrinkToCart(
  5567890,
  {
    sweetness: "少糖",
    ice: "少冰"
  }
)

页面提示“已加入购物车”。

浏览器Agent在界面上显示一个提示按钮:
<去结算>

你点击。

浏览器Agent调用:

checkout()

页面跳转到确认订单页,你确认地址并完成支付。

整个过程中,浏览器Agent并没有去“点击筛选按钮”或“模拟输入搜索框”,而是直接调用页面注册的结构化工具函数。页面把原有的搜索、筛选、推荐、加购、下单逻辑封装成 WebMCP 工具,让 AI 可以用更稳定、更语义化的方式操作。

这就是 WebMCP 的核心理念:
不是让 AI 像人一样操作页面,而是让页面主动把能力暴露出来,供 AI 调用。

六、demo

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <title>WebMCP Article Demo</title>
  <style>
    body { max-width: 800px; margin: auto; font-family: sans-serif; }
    article { line-height: 1.6; }
  </style>
</head>
<body>

  <h1>WebMCP Article Demo</h1>

  <article id="main-article">
    <h2>示例文章标题</h2>
    <p>这是第一段正文内容。</p>
    <p>这是第二段正文内容。</p>
    <p>这是第三段正文内容。</p>
  </article>

  <script>
    function extractArticleText() {
      // 优先找 article 标签
      let article = document.querySelector("article");

      // 如果没有 article,就尝试主内容容器
      if (!article) {
        article = document.querySelector("main") ||
                  document.querySelector("#content") ||
                  document.body;
      }

      // 清除 script/style
      const clone = article.cloneNode(true);
      clone.querySelectorAll("script, style, nav, footer").forEach(el => el.remove());

      const text = clone.innerText.trim();

      return {
        title: document.title,
        url: location.href,
        content: text,
        length: text.length
      };
    }

    if (navigator.modelContext?.registerTool) {
      console.log("[WebMCP] registering getArticleContent");

      navigator.modelContext.registerTool({
        name: "getArticleContent",
        description: "获取当前页面的文章正文内容",
        inputSchema: {
          type: "object",
          properties: {}
        },
        async execute() {
          const data = extractArticleText();

          return {
            content: [
              {
                type: "text",
                text: JSON.stringify(data, null, 2)
              }
            ]
          };
        }
      });

      console.log("[WebMCP] tool registered");
    } else {
      console.warn("WebMCP not supported in this browser.");
    }
  </script>

</body>
</html>

2026 技术风向:为什么在 AI 时代,PostgreSQL 彻底成为了全栈工程师的首选数据库

作者 NEXT06
2026年2月16日 15:12

在 Web 开发的黄金十年里,LAMP 架构(Linux, Apache, MySQL, PHP)奠定了 MySQL 不可撼动的霸主地位。那是互联网的草莽时代,业务逻辑相对简单,读多写少,开发者对数据库的诉求仅仅是“稳定存储”。

然而,时间来到 2026 年。随着 Node.js 与 TypeScript 生态的统治级渗透,以 Next.js、NestJS 为代表的现代全栈框架(Modern Stack)彻底改变了应用开发的范式。在这个由 Serverless、Edge Computing 和 AI 驱动的新时代,MySQL 逐渐显得力不从心。与此同时,PostgreSQL(下文简称 PG)凭借其惊人的演进速度,成为了全栈工程师事实上的“默认选项”。

这不仅仅是技术偏好的转移,更是架构复杂性倒逼下的必然选择。

建筑学的视角:预制板房 vs 模块化摩天大楼

要理解为什么 PG 在现代架构中胜出,我们必须从底层设计哲学说起。如果把数据库比作建筑:

MySQL 像是一栋“预制板搭建的经济适用房”。
它结构紧凑,开箱即用,对于标准的居住需求(基础 CRUD、简单事务)来说,它表现优异且成本低廉。但是,它的结构是固化的。如果你想在顶楼加建一个停机坪(向量搜索),或者把承重墙打通做成开放式空间(非结构化数据存储),你会发现极其困难。它的存储引擎(InnoDB)虽然优秀,但与上层逻辑耦合较紧,扩展性受限。

PostgreSQL 像是一座“钢结构模块化摩天大楼”。
它的底座(存储与事务引擎)极其坚固,严格遵循 SQL 标准与 ACID 原则。但它最核心的竞争力在于其可插拔的模块化设计(Extensibility)

  • 你需要处理地理空间数据?插入 PostGIS 模块,它立刻变成专业的 GIS 数据库。
  • 你需要做高频时序分析?插入 TimescaleDB 模块。
  • 你需要 AI 向量搜索?插入 pgvector 模块。

PG 不仅仅是一个数据库,它是一个数据平台内核。这种“无限生长”的能力,完美契合了 2026 年复杂多变的业务需求。

全栈工程师偏爱 PG 的三大理由

在 Next.js/NestJS 的全栈生态中,Prisma 和 Drizzle ORM 的流行进一步抹平了数据库的方言差异,让开发者更能关注数据库的功能特性。以下是 PG 胜出的三个关键维度。

1. JSONB:终结 NoSQL 的伪需求

在电商系统中,我们经常面临一个棘手的问题:商品(SKU)属性的非结构化。

  • 衣服:颜色、尺码、材质。
  • 手机:屏幕分辨率、CPU型号、内存大小。
  • 图书:作者、ISBN、出版社。

在 MySQL 时代,为了处理这些动态字段,开发者通常有两种痛苦的选择:要么设计极其复杂的 EAV(实体-属性-值)模型,要么引入 MongoDB 专门存储商品详情,导致需要维护两个数据库,并在应用层处理数据同步(Distributed Transaction 问题)。

MySQL 虽然支持 JSON 类型,但在索引机制和查询性能上一直存在短板。

PG 的解法是 JSONB(Binary JSON)。
PG 不仅仅是将 JSON 作为文本存储,而是在写入时将其解析为二进制格式。这意味着:

  1. 解析速度极快:读取时无需重新解析。
  2. 强大的索引支持:你可以利用 GIN(Generalized Inverted Index,通用倒排索引)对 JSON 内部的任意字段建立索引。

场景示例:
不需要引入 MongoDB,你可以直接在 PG 中查询:“查找所有红色且内存大于 8GB 的手机”。

SQL

-- 利用 @> 操作符利用 GIN 索引进行极速查询
SELECT * FROM products 
WHERE attributes @> '{"color": "red"}' 
AND (attributes->>'ram')::int > 8;

对于全栈工程师而言,这意味着架构的极度简化:One Database, All Data Types.

2. pgvector:AI 时代的“降维打击”

AI 应用的爆发,特别是 RAG(检索增强生成)技术的普及,催生了向量数据库(Vector Database)的需求。

传统的 AI 架构通常是割裂的:

  • MySQL:存储用户、订单等元数据。
  • Pinecone/Milvus:存储向量数据(Embeddings)。
  • Redis:做缓存。

这种架构对全栈团队简直是噩梦。你需要维护三套基础设施,处理数据一致性,还要编写复杂的胶水代码来聚合查询结果。

PG 的解法是 pgvector 插件。
通过安装这个插件,PG 瞬间具备了存储高维向量和进行相似度搜索(Cosine Similarity, L2 Distance)的能力。更重要的是,它支持 HNSW(Hierarchical Navigable Small World)索引,查询性能足以应对绝大多数生产场景。

实战场景:AI 电商系统的“以图搜图”
用户上传一张图片,系统需要推荐相似商品,但同时必须满足“价格低于 1000 元”且“有库存”的硬性条件。

在 PG 中,这只是一个 SQL 查询

SQL

SELECT id, name, price, attributes
FROM products
WHERE stock > 0                       -- 关系型过滤
  AND price < 1000                    -- 关系型过滤
ORDER BY embedding <=> $1             -- 向量相似度排序($1 为用户上传图片的向量)
LIMIT 10;

这种混合查询(Hybrid Search)能力是 PG 对专用向量数据库的降维打击。它消除了数据搬运的成本,保证了事务的一致性(你肯定不希望搜出来的商品其实已经下架了)。

3. 生态与插件:长期主义的选择

MySQL 的功能迭代主要依赖于 Oracle 官方的发版节奏。而 PG 的插件机制允许社区在不修改核心代码的前提下扩展数据库功能。

在 Node.js 全栈项目中,我们经常会用到:

  • pg_cron:直接在数据库层面运行定时任务,无需在 NestJS 里写 cron job。
  • PostGIS:处理配送范围、地理围栏,这是目前地球上最强大的开源 GIS 引擎。
  • zombodb:将 Elasticsearch 的搜索能力集成到 PG 索引中。

对于全栈工程师来说,PG 就像是一个拥有海量 npm 包的运行时环境,你总能找到解决特定问题的插件。

实战架构图谱:构建 Next-Gen AI 电商

基于上述分析,一个典型的 2026 年现代化全栈电商系统的后端架构可以被压缩得极其精简。我们不再需要“全家桶”式的中间件,一个 PostgreSQL 集群足矣。

架构设计

  • 技术栈:Next.js (App Router) + Prisma ORM + PostgreSQL.
  • 数据模型设计

TypeScript

// Prisma Schema 示例
model Product {
  id          Int      @id @default(autoincrement())
  name        String
  price       Decimal
  stock       Int
  // 核心特性 1: 结构化数据与非结构化数据同表
  attributes  Json     // 存储颜色、尺码等动态属性
  
  // 核心特性 2: 原生向量支持 (通过 Prisma Unsupported 类型)
  embedding   Unsupported("vector(1536)") 
  
  // 核心特性 3: 强一致性关系
  orders      OrderItem[]
  
  @@index([attributes(ops: JsonbPathOps)], type: Gin) // GIN 索引加速 JSON 查询
  @@index([embedding], type: Hnsw) // HNSW 索引加速向量搜索
}

业务流转

  1. 商品录入:结构化字段存入 Column,非结构化规格存入 attributes (JSONB),同时调用 OpenAI API 生成 Embedding 存入 embedding 字段。
  2. 交易环节:利用 PG 成熟的 MVCC(多版本并发控制)和 ACID 事务处理高并发订单写入,无需担心锁竞争(相比 MySQL 的 Gap Lock,PG 在高并发写入下往往表现更优)。
  3. 搜索推荐:利用 pgvector 实现基于语义或图片的推荐,同时结合 attributes 中的 JSON 字段进行精准过滤。

结论:Simplicity is Scalability(简单即是扩展)。少维护一个 MongoDB 和一个 Pinecone,意味着系统故障点减少了 66%,开发效率提升了 100%。

结语:数据库的终局

在 2026 年的今天,我们讨论 PostgreSQL 时,已经不再仅仅是在讨论一个关系型数据库(RDBMS)。

PostgreSQL 已经演变成了一个通用多模态数据平台(General-Purpose Multi-Model Data Platform) 。它既是关系型数据库,也是文档数据库,更是向量数据库和时序数据库。

对于追求效率与掌控力的全栈工程师而言,MySQL 依然是 Web 1.0/2.0 时代的丰碑,但在构建 AI 驱动的复杂应用时,PostgreSQL 提供了更广阔的自由度和更坚实的底层支撑。

拥抱 PostgreSQL,不仅是选择了一个数据库,更是选择了一种“做减法”的架构哲学。

拒绝“盲盒式”编程:规范驱动开发(SDD)如何重塑 AI 交付

作者 NEXT06
2026年2月16日 14:53

前言

在过去的一年里,每一位尝试将 AI 引入生产环境的开发者,大概都经历过从“极度兴奋”到“极度疲惫”的心路历程。

我们惊叹于 LLM(大型语言模型)在几秒钟内生成数百行代码的能力,但随后便陷入了无休止的调试与修正。这种现象被形象地称为“盲盒式编程(Gacha Coding)”:输入一个模糊的提示词,就像投下一枚硬币,得到的结果可能是令人惊喜的 SSR(超级稀有)代码,但更多时候是无法维护的 N 卡(废代码)。

为了修正这些错误,我们被迫化身为“保姆”,在对话框中喋喋不休地纠正 AI 的变量命名、UI 样式和逻辑漏洞。最终我们发现,Debug AI 代码的时间甚至超过了自己手写的时间。

这种困境的根源在于:AI 拥有极强的编码能力(How),但它完全缺乏对业务边界、上下文约束和系统设计的理解(What)。

为了打破这一僵局,软件工程领域正在经历一场从“提示词工程(Prompt Engineering)”向“规范驱动开发(Spec-Driven Development, SDD)”的范式跃迁。

一、核心概念:什么是 SDD?

规范驱动开发(Specification-Driven Development, SDD)并非一个全新的概念,但在 AI 时代,它被赋予了全新的生命力。

在传统的软件开发模式中,代码是唯一的真理(Source of Truth) 。文档(PRD、API 文档)往往只是开发的参考,随着项目的迭代,文档与代码必然发生脱节,最终沦为具文。

而在 SDD 模式下,规范(Specification)成为了唯一的真理

The Product Requirements Document (PRD) isn't a guide for implementation; it's the source that generates implementation.

这是一个根本性的认知反转:

  • 传统模式:想法 

    →→
    

     文档(参考)

    →→
    

     人脑翻译 

    →→
    

     代码(真理)。

  • SDD 模式:想法 

    →→
    

     规范(真理)

    →→
    

     AI 翻译 

    →→
    

     代码(衍生品)。

在这种架构下,AI 不再是一个需要你时刻盯着的“副驾驶(Copilot)”,它晋升为一个高效的“编译器(Compiler)”或“引擎”。它读取自然语言编写的、结构严密的规范,并将其确定性地转化为可执行代码。

二、从“聊天”到“契约”:普通提示词 vs. SDD

许多开发者误以为 SDD 就是写更长的 Prompt,这是一种误解。Prompt Engineering 与 SDD 在本质上存在维度级的差异。

1. 提示词工程(Prompt Engineering)

  • 本质:基于对话的口头指令。
  • 特征:线性、碎片化、易遗忘上下文。
  • 痛点:由于缺乏全局约束,AI 容易产生幻觉。每次对话都是一次独立的“抽卡”,结果高度随机。
  • 维护性:极低。一旦业务逻辑变更,需要重新进行多轮对话,且难以保证不破坏原有功能。

2. 规范驱动开发(SDD)

  • 本质:基于文档的工程合同。
  • 特征:结构化、持久化、可版本控制。
  • 优势:通过预先定义的数据结构、状态机和接口规范,锁定了 AI 的解空间。
  • 维护性:高。修改业务逻辑只需修改规范文档,然后让 AI 重新生成代码。

为什么 SDD 现在才爆发?

在过去 20 年(如 MDA 模型驱动架构时期),我们一直试图用 UML 或 DSL 生成代码,但失败了。因为传统的转换器太僵化,无法处理模糊的自然语言。

现在的 LLM 跨越了一个关键门槛:能够准确理解复杂的逻辑上下文,并将自然语言规范可靠地转化为工作代码。  AI 填补了从“非形式化规范”到“形式化代码”之间缺失的拼图。

三、实战方法论:如何构建“虚拟流水线”

要落地 SDD,不能指望一句通用的指令。我们需要在 Prompt 中构建一个“虚拟团队”,让 AI 分阶段产出规范,最后再执行编码。

这是一个分层约束的过程:

第一步:虚拟产品经理(The PM)——产出 PRD

AI 需要首先明确业务的边界。不要直接让它写代码,而是让它生成一份包含以下内容的 PRD:

  • 用户故事:谁在什么场景下解决什么问题。
  • 异常流程:断网了怎么办?输入负数怎么办?数据为空怎么显示?
  • 数据闭环:数据从哪里来,存到哪里去,如何流转。

第二步:虚拟设计师(The Designer)——产出设计规范

禁止 AI 随意发挥审美。需要通过规范文件(如 JSON 或 Markdown 表格)定义:

  • Design Tokens:色板、间距、字号的原子化定义。
  • 交互状态:Hover、Active、Disabled 状态下的具体表现。
  • 组件规范:复用哪些现有的 UI 库组件,而非手写 CSS。

第三步:虚拟架构师(The Architect)——产出技术方案

这是保证代码可维护性的关键。在编码前,必须强制约定:

  • 目录结构:明确 /utils、/components、/hooks 的职责划分。
  • 技术栈约束:强制使用特定的库(如 Tailwind, MobX, React Query)。
  • 命名规范:文件命名、变量命名的具体规则。

第四步:执行者(The Coder)——执行合同

当且仅当上述三份文档(Spec)确认无误后,我们才向 AI 下达最终指令:

“作为资深工程师,请阅读上述 PRD、设计规范和技术方案,严格按照规范实现该系统。”

此时,AI 生成的代码将不再是随机的“盲盒”,而是严格遵循合同的工业级交付物。

四、角色重塑:从“码农”到“数字立法者”

随着 SDD 的普及,软件工程师的职业内核正在发生剧变。

生成代码的边际成本正在趋近于零。如果一个功能的实现只需要几秒钟,那么“写代码”本身就不再是核心竞争力。核心竞争力转移到了“定义问题”和“制定规则”上。

未来的开发者将进化为“意图工程师(Intent Engineer)”“数字世界的立法者(Legislator)”。

  • 立法(Legislating) :你需要具备极强的结构化思维,能够将模糊的业务需求拆解为严密、无歧义的 Spec 文档(即法律条文)。
  • 执法(Executing) :AI 负责执行这些条文。如果系统运行结果不符合预期,你不需要去修改 AI 生成的代码(执法过程),而是应该去修改 Spec(法律条款),然后重新触发生成。

结语:回归创造的本质

软件工程界长久以来面临的“文档与代码不同步”的千古难题,极有可能在 SDD 范式下被彻底终结。

当规范成为真理,代码回归工具属性,我们终于可以从繁琐的语法细节和“保姆式纠错”中解放出来。这不是让开发者失业,而是对开发工作的高维升级。

请停止在 IDE 里漫无目的地“抽卡”。从今天起,试着写一份高质量的 Markdown 规范,定义好你的系统边界与意图。这才是 AI 时代开发者应有的姿态。

我写了个 code-review 的 Agent Skill, 没想到火了

作者 神三元
2026年2月16日 14:35

前两天随手写了个 Claude Code 的 Skill,专门做 Code Review 的,发了条推之后就没太在意。

结果第二天醒来一看,GitHub Star 刷刷往上涨,评论区也炸了,不少人说"终于有个靠谱的 Code Review 工具了"。

image.png

说实话,有点意外。

倒不是说这个 Skill 有多了不起,而是它戳中了一个很真实的痛点——大部分团队的 Code Review,要么走过场,要么全靠人肉。

先说说为什么要做这个

做这个 Skill 的起因其实很简单。

我自己平时写代码,改完了之后经常想让 Claude Code 帮我 review 一下。直接跟它说"帮我看看代码有没有问题",它确实会给你一些反馈,但说实话,质量参差不齐。

这就跟新来的实习生做 Code Review 一样,不是他不想认真看,是他不知道该看什么、怎么看、按什么优先级来。

所以问题的本质是:模型需要一套结构化的 Review 框架,告诉它该检查什么、怎么分级、用什么格式输出。

这不就是 Skill 最擅长干的事吗?

code-review-expert 是什么

一句话概括:一个让 AI 用资深工程师的视角帮你做 Code Review 的 Skill。

安装方式就一行:

npx skills add sanyuan0704/code-review-expert

装好之后在 Claude Code 里输入 /code-review-expert,它就会自动 review 你当前的 git changes。

整个 review 流程我是精心设计过的,分成这么几步:

第一步:Preflight(了解改动范围)

它会先跑 git diff 看看你改了哪些文件、改了多少行。如果改动量超过 500 行,它会先按模块分批 review,不会一口气全看完然后给你一堆乱七八糟的反馈。

第二步:SOLID + 架构检查

这一步是我花了最多时间打磨的。我写了一份详细的 SOLID checklist,把每个原则对应的"坏味道"都列出来了。

比如检查 SRP(单一职责),它不会只是泛泛地说"这个文件职责太多了",而是会问一个很具体的问题:"这个模块有几个不同的修改理由?" 如果一个文件既管 HTTP 请求,又管数据库操作,还管业务逻辑,那它大概率违反了 SRP。

第三步:发现可以删掉的代码

这步其实挺有意思的。很多项目里都有一堆死代码——feature flag 关掉的、被废弃的 API、没人用的工具函数。它会帮你找出来,并且区分"可以直接删"和"需要制定计划再删"两种情况。

第四步:安全扫描

XSS、SQL 注入、SSRF、路径穿越、竞态条件、密钥泄露……这些它都会检查。

其中竞态条件(Race Condition)这块我写的特别详细,因为这是很多人在 review 时最容易忽略的。它会专门去找 check-then-act 模式、读-改-写操作、并发数据库访问这些容易出问题的场景。

第五步:代码质量扫描

错误处理有没有吞掉异常?有没有数据库的 N+1 查询?空值检查到不到位?这些"小问题"在生产环境里都可能变成大事故或者性能问题。

最后:结构化输出 + 确认

所有发现按严重程度分成四个等级:

等级 含义 怎么处理
P0 严重 必须 block merge
P1 高危 应该在合并前修复
P2 中等 这个 PR 修或者建个 follow-up
P3 低优 可选优化

输出之后,它不会自作主张去改代码。而是先问你:要修全部,还是只修 P0/P1,或者修指定的。

这个"先 review 再确认"的设计是我特意做的——Code Review 的价值不只是发现问题,更重要的是让你理解问题。如果 AI 直接帮你改了,你连有什么问题都不知道,那这个 review 就没意义了。

为什么我觉得它火了

发完推之后,仓库几天内涨到了 460+ Star,40+ Fork。

评论区和私信里,大家反馈最多的是两点:

第一,"终于有个体系化的 Review 方案了"

很多独立开发者和小团队,根本没有 Code Review 的流程。不是不想做,是没人帮你 review。有了这个 Skill,相当于随时有个资深工程师帮你把关。

这个需求其实比我想象的要大。我之前以为 Code Review 主要是大厂的需求,没想到独立开发者和小团队对这块的渴求更强烈——因为他们更没有犯错的资本。

第二,"终于不是 AI 味十足的泛泛建议了"

image.png

这要归功于那几份 checklist。我把 security-checklist、solid-checklist、code-quality-checklist 都放在了 references/ 目录下,每份都是实打实的检查清单,不是那种"注意安全问题"之类的废话。

比如安全检查那份,光竞态条件就列了四个子类:共享状态访问、TOCTOU(检查后使用)、数据库并发、分布式系统。每个子类下面都有具体的代码模式和需要问的问题。

这就是 Skill 的魅力——你把专业知识结构化地喂给模型,它的输出质量会有质的提升。

怎么做到的?聊聊 Skill 的设计思路

这个 Skill 的结构很简单:

code-review-expert/
├── SKILL.md                  # 主文件,定义整个 review 流程
├── agents/
│   └── agent.yaml            # Agent 配置
└── references/
    ├── solid-checklist.md    # SOLID 原则检查清单
    ├── security-checklist.md # 安全检查清单
    ├── code-quality-checklist.md # 代码质量检查清单
    └── removal-plan.md       # 代码清理计划模板

核心设计有几个关键点:

1. references 实现按需加载

这是 Skill 体系最优雅的地方。

四份 checklist 的内容加起来好几千字,如果全塞进 SKILL.md,一上来就会吃掉大量上下文窗口。所以我把它们放在 references/ 里,SKILL.md 里只在需要的步骤写 Load references/xxx.md

模型执行到那个步骤时才会去读对应的文件,用完就可以"忘掉"了。这就是之前文章里讲过的 Progressive Disclosure(渐进式加载),Skills 最精妙的设计之一。

2. Workflow 要设计得有节奏感

我试过把所有检查点平铺在一起,效果很差——模型会东一榔头西一棒子,安全问题和命名规范混在一起说。

最后我按照真实的 Code Review 流程来编排:先看改动范围,再看架构设计,然后看安全,最后看代码质量。每一步之间是递进关系,从宏观到微观。

这个设计借鉴了人来做 Code Review 的习惯——好的 reviewer 不会上来就抠细节,而是先理解整体改动的意图和影响范围。

写在最后

你猜我写这个 skill 花了多久?

3,2,1,揭晓答案。

我只花了 10 分钟。不可思议吧。

怎么做到的?现在 claude 官方有一个叫 skill-creator 的 skill,帮你来写 skill,然后基于它可以很快搭出骨架来。后续,就是基于我的专业经验,引导 agent 帮我把一些关键的原则拆分为各个 checklist 文档,聊个几轮,这个高质量的 skill 就完工了。

回头看这件事,我觉得这也是 Skills 生态最让人兴奋的地方:每个有专业积累的开发者,都可以很快把自己的经验沉淀成一个 Skill,让 AI 帮更多人受益。

你不需要会写 MCP Server,不需要懂协议,不需要搞 OAuth 鉴权。就是一个 Markdown 文件 + 几份参考文档,仅此而已。

仓库在这里,欢迎 Star 和提 PR:

GitHub: sanyuan0704/code-review-expert

安装:npx skills add sanyuan0704/code-review-expert

如果你也在做 Skill 开发,或者有什么好用的 Skill 推荐,评论区欢迎来聊。

JWT 登录:原理剖析与实战应用

作者 冻梨政哥
2026年2月16日 14:33

JWT 登录:原理剖析与实战应用

在前后端分离的 Web 应用架构中,身份认证是核心环节之一。HTTP 协议的无状态特性,决定了我们需要一种可靠的方式来维护用户的登录状态,JWT(JSON Web Token)正是解决这一问题的主流方案。本文结合实际代码案例,从 JWT 登录的核心概念、底层原理到落地实现,全方位解析 JWT 登录机制。

一、JWT 登录的核心概念

1. 为什么需要 JWT?

HTTP 协议是无状态的,服务器无法通过协议本身记住用户的登录状态。传统的 Cookie+Session 方案存在跨域难处理、服务器存储压力大等问题;而 JWT 通过将用户身份信息加密为令牌,由客户端存储,服务器无需持久化保存状态,完美适配前后端分离、分布式系统的认证需求。

2. JWT 的核心定义

JWT 是一种基于 JSON 的轻量级身份认证令牌,本质是将用户的核心身份信息(如 ID、用户名)通过加密算法生成一串字符串,客户端在后续请求中携带该令牌,服务器通过解密令牌即可验证用户身份,无需查询数据库。

二、JWT 登录的底层原理

1. JWT 的结构

JWT 令牌由三部分组成,以.分隔:

  • Header(头部) :声明加密算法(如 HS256)和令牌类型(JWT),示例:{"alg":"HS256","typ":"JWT"}
  • Payload(载荷) :存储用户核心身份信息(如 id、name),支持自定义字段,同时包含令牌过期时间(exp)等元数据;
  • Signature(签名) :将 Header 和 Payload 经 Base64 编码后,通过指定算法 + 服务器密钥(secret)加密生成,用于验证令牌是否被篡改。

2. JWT 登录的核心流程

JWT 登录的完整链路可分为 “颁发令牌” 和 “验证令牌” 两个阶段:

阶段 1:颁发令牌(登录请求)
  1. 前端提交用户名 / 密码到服务器;
  2. 服务器验证用户名密码是否正确;
  3. 验证通过后,服务器使用jwt.sign()方法,将用户身份信息、密钥、过期时间作为参数,生成 JWT 令牌;
  4. 服务器将令牌返回给前端,前端将令牌存储(如 localStorage、Cookie);
阶段 2:验证令牌(后续请求)
  1. 前端在请求头(通常是Authorization)中携带 JWT 令牌(格式:Bearer <token>);
  2. 服务器从请求头中提取令牌,通过jwt.decode()/jwt.verify()方法,结合密钥解析令牌;
  3. 验证令牌的有效性(是否过期、是否被篡改),验证通过则识别用户身份,返回对应数据;验证失败则拒绝请求。

3. 核心算法解析

  • sign 方法:核心是 “加密”,输入参数为「用户身份对象」「密钥」「配置项(如过期时间)」,输出为 JWT 令牌。密钥(secret)是服务器的核心机密,需严格保管,避免泄露
  • verify/decode 方法:核心是 “解密 / 验证”,verify会校验令牌的完整性和过期时间,decode仅解析令牌内容(不验证),服务器通过这两个方法还原用户身份。

三、JWT 登录的实战应用(结合代码解析)

以下基于 React+Zustand+Node.js 的实战代码,拆解 JWT 登录的完整实现。

1. 环境准备

安装 JWT 核心依赖:

pnpm i jsonwebtoken

2. 后端实现:令牌颁发与验证

后端基于 Mock 接口实现 JWT 的签发和验证:

import jwt from 'jsonwebtoken'; 
// 服务器密钥,生产环境需配置为环境变量,避免硬编码
const secret = 'cqy123!!!'; 

export default [
    // 1. 登录接口:颁发JWT令牌
    {
        url:'/api/auth/login',
        method:'post',
        response:(req,res) => {
            // 步骤1:获取并清洗前端提交的用户名/密码
            let { name,password } = req.body;
            name = name.trim();
            password = password.trim();

            // 步骤2:基础校验
            if(name == '' || password == ''){
                return { code:400, message:"用户名或密码不能为空" };
            }
            // 步骤3:验证用户名密码(生产环境需查数据库)
            if(name !== 'admin' || password !== '123456'){
                return { code:401, message:"用户名或密码错误" };
            }

            // 步骤4:签发JWT令牌
            const token = jwt.sign(
                { user: { id: 1, name: 'admin', avatar:"xxx" } }, // Payload:用户身份信息
                secret, // 密钥
                { expiresIn:86400*7 } // 过期时间:7天
            );

            // 步骤5:返回令牌和用户信息给前端
            return { token, user: { id: 1, name: 'admin', avatar:"xxx" } };
        }
    },
    // 2. 验证令牌接口:解析用户身份
    {
        url:'/api/auth/check',
        method:'get',
        response:(req,res) => {
            // 步骤1:从请求头提取令牌(格式:Bearer <token>)
            const token = req.headers['authorization'].split(" ")[1];
            try {
                // 步骤2:解析令牌(verify方法更安全,会验证签名和过期时间)
                const decode = jwt.verify(token, secret); 
                return { code: 200, user: decode.user };
            } catch(err) {
                return { code: 400, message:"invalid token" };
            }
        }
    }
]

3. 前端实现:登录交互与令牌存储

前端基于 React+Zustand 实现登录逻辑,将 JWT 令牌持久化存储。

步骤 1:状态管理(Zustand)—— 存储令牌和用户信息
// useUserStore.ts
import { create } from "zustand";
import { persist } from 'zustand/middleware';
import { doLogin } from '@/api/user';
import type { User, Credentials } from "@/types/index";

interface UserState {
    token: string;
    user: User | null;
    isLogin: boolean;
    login:(credentials: Credentials) => Promise<void>;
}

// 创建状态仓库,结合persist中间件持久化到localStorage
export const useUserStore = create<UserState>()(
    persist((set) => ({
        token:"",
        user: null,
        isLogin:false,
        // 登录方法:调用后端接口获取令牌
        login:async ({ name,password }) => {
            const res = await doLogin({name,password});
            // 存储令牌、用户信息,标记登录状态
            set({
                user: res.user,
                token: res.token,
                isLogin:true
            });
        }
    }),{
        name: 'user-store', // localStorage的key
        // 仅持久化核心字段
        partialize:(state) => ({
            token:state.token,
            user: state.user,
            isLogin: state.isLogin
        })
    })
);

步骤 2:登录页面 —— 交互与令牌获取

// Login.tsx
import React, { useState } from 'react';
import { useUserStore } from '@/store/useUserStore';
import { Button, Input, Label } from '@/components/ui';
import { Loader2 } from 'lucide-react';
import type { Credentials } from '@/types';
import { useNavigate } from 'react-router-dom';

export default function Login() {
  const { login } = useUserStore();
  const [loading,setLoading] = useState<boolean>(false);
  // 表单数据:用户名/密码
  const [formData,setFormData] = useState<Credentials>({ name:"", password:"" });
  const navigate = useNavigate();

  // 表单输入处理
  const handleChange = (e:React.ChangeEvent<HTMLInputElement>) => {
    const {id,value} = e.target;
    setFormData((prev) => ({ ...prev, [id]:value }));
  };

  // 登录提交逻辑
  const handleLogin = async (e:React.FormEvent) => {
    e.preventDefault();
    const name = formData.name.trim();
    const password = formData.password.trim();
    if(!name || !password) return;

    setLoading(true);
    try {
      // 调用登录方法,获取并存储JWT令牌
      await login({name,password});
      // 登录成功跳转到首页,替换路由历史(防止回退到登录页)
      navigate('/',{replace:true});
    } catch(err) {
      console.log(err,"登录失败");
    }finally {
      setLoading(false);
    }
  };

  return (
    <div className='min-h-screen flex flex-col items-center justify-center p-6 bg-white'>
      <div className='w-full max-w-sm space-y-6'>
        <form onSubmit={handleLogin} className='space-y-4'>
          <div className='space-y-2'>
            <Label htmlFor='name'>用户名</Label>
            <Input id='name' placeholder='请输入用户名' value={formData.name} onChange={handleChange}/>
          </div>
          <div className='space-y-2'>
            <Label htmlFor='password'>密码</Label>
            <Input id='password' type='password' placeholder='请输入密码' value={formData.password} onChange={handleChange}/>
          </div>
          <Button type='submit'>
            {loading?(<><Loader2 className='mr-2 h-4 w-4 animate-spin'/>登录中...</>):('立即登录')}
          </Button>
        </form>
      </div>
    </div>
  );
}

4. 前端后续请求:携带令牌认证

登录后,前端在发起需要权限的请求时,需在请求头中携带 JWT 令牌:

// 示例:axios请求拦截器
import axios from 'axios';
import { useUserStore } from '@/store/useUserStore';

const request = axios.create({ baseURL: '/api' });

// 请求拦截器:添加Authorization头
request.interceptors.request.use((config) => {
  const { token } = useUserStore.getState();
  if (token) {
    config.headers['Authorization'] = `Bearer ${token}`;
  }
  return config;
});

// 响应拦截器:处理令牌过期
request.interceptors.response.use(
  (res) => res,
  (err) => {
    if (err.response?.status === 401) {
      // 令牌过期,清空状态并跳转到登录页
      useUserStore.getState().set({ token: '', user: null, isLogin: false });
      window.location.href = '/login';
    }
    return Promise.reject(err);
  }
);

export default request;

四、JWT 登录的优缺点与注意事项

1. 优点

  • 无状态:服务器无需存储 Session,降低存储压力,适配分布式部署;
  • 跨域友好:令牌由前端存储,可轻松跨域携带,解决 Cookie 跨域问题;
  • 轻量高效:基于 JSON 格式,解析速度快,无需频繁查询数据库。

2. 缺点

  • 令牌无法主动作废:JWT 一旦签发,在过期前无法主动撤销(需结合黑名单机制解决);
  • Payload 不宜存敏感信息:Header 和 Payload 仅经 Base64 编码(非加密),可被解码,不可存储密码等敏感数据;
  • 令牌体积:Payload 内容越多,令牌越长,增加网络传输开销。

3. 生产环境注意事项

  • 密钥(secret)需通过环境变量配置,禁止硬编码;
  • 令牌过期时间不宜过长,结合刷新令牌(Refresh Token)机制;
  • 采用 HTTPS 协议传输令牌,防止中间人攻击;
  • 关键接口需校验令牌的签名和过期时间(使用jwt.verify()而非jwt.decode())。

五、总结

JWT 登录通过 “客户端存储令牌、服务器解密验证” 的方式,完美解决了 HTTP 无状态带来的身份认证问题,是前后端分离架构的首选方案。其核心是通过sign方法生成令牌、verify方法验证令牌,结合前端状态持久化和请求拦截器,可快速实现完整的登录认证体系。在实际应用中,需兼顾安全性和易用性,合理配置令牌过期时间、保管服务器密钥,才能充分发挥 JWT 的优势。

【节点】[CustomSpecular节点]原理解析与实际应用

作者 SmalBox
2026年2月16日 14:06

【Unity Shader Graph 使用与特效实现】专栏-直达

CustomSpecular 节点是 Unity URP Shader Graph 中用于实现自定义高光光照效果的核心节点。在计算机图形学中,高光反射是模拟光线在物体表面反射时产生的明亮区域,它对于表现材质的质感和真实感至关重要。与 Unity 内置的标准高光计算不同,CustomSpecular 节点提供了更高程度的自定义能力,允许开发者根据特定的材质属性和光照需求来精确控制高光的表现形式。

在物理渲染流程中,高光计算基于光线与材质表面的交互原理。当光线照射到物体表面时,一部分光线会被反射,形成镜面反射。CustomSpecular 节点通过输入材质的光学特性、表面粗糙度信息以及几何数据,能够模拟这种物理现象,生成符合真实世界光学规律的高光效果。

该节点的设计理念是提供灵活而强大的高光计算工具,使开发者能够突破标准光照模型的限制,实现各种特殊的高光效果。无论是模拟金属表面的强烈反射,还是表现非金属材质的微妙光泽,CustomSpecular 节点都能提供必要的计算支持。

在实际应用中,CustomSpecular 节点特别适合用于实现以下场景:需要精确控制高光颜色和强度的特殊材质、基于物理属性的金属材质渲染、自定义的光照模型开发,以及对性能有特殊要求的移动端高光优化。通过合理配置节点的输入参数,开发者可以创建出从逼真的物理材质到风格化的卡通渲染等各种类型的高光效果。

节点描述

CustomSpecular 节点的核心功能是基于经典的 Blinn-Phong 光照模型或更先进的物理渲染模型来计算高光反射。它通过接收多个输入参数来精确控制高光的各个方面,包括强度、颜色、大小和分布。这种计算方式使得材质在不同光照条件下都能保持视觉一致性,同时提供足够的灵活性来满足各种艺术需求。

从技术实现角度来看,CustomSpecular 节点执行的高光计算通常涉及以下几个关键步骤:首先,它根据输入的表面法线、光线方向和视线方向计算中间向量;然后,基于光泽度参数确定高光的光锥大小和强度分布;最后,结合材质的光学特性生成最终的高光颜色值。这个过程确保了高光效果既符合物理规律,又能满足艺术表现的需求。

在 URP 渲染管线中,CustomSpecular 节点的设计充分考虑了移动平台和性能受限环境的优化需求。它使用高效的计算方法,在保证视觉效果的同时尽可能减少着色器的计算开销。这使得它成为开发高质量、高性能渲染效果的理想选择。

物理基础

CustomSpecular 节点的计算基于光学物理原理,特别是菲涅尔效应和微表面理论。菲涅尔效应描述了光线在不同角度照射表面时的反射率变化,而微表面理论则解释了表面微观几何对光线散射的影响。这些物理原理的整合使得 CustomSpecular 节点能够生成更加真实的高光效果。

在能量守恒方面,CustomSpecular 节点的设计确保了反射光线的能量不会超过入射光线的能量,这是实现物理正确渲染的重要原则。通过合理设置输入参数,开发者可以创建出在各种光照环境下都能保持视觉一致性的材质。

艺术控制

除了物理准确性,CustomSpecular 节点还提供了丰富的艺术控制参数。通过调整光泽度、高光颜色和强度等参数,艺术家可以创造出从超现实到高度风格化的各种视觉效果。这种灵活性与物理基础的结合,使得 CustomSpecular 节点成为实现高质量渲染的强大工具。

端口详解

CustomSpecular 节点的端口系统设计精巧,每个端口都有特定的功能和数据要求。深入了解每个端口的作用和相互关系,对于充分发挥节点的潜力至关重要。

输入端口

Specular 输入端口是定义材质基本光学特性的核心输入。它接受两种类型的数据:用于非金属材质的浮点值和用于金属材质的 Vector3 值。这种设计反映了真实世界中不同材料的光学特性差异。

对于非金属材质(也称为电介质),Specular 输入通常使用范围为 0.0 到 1.0 的浮点值。这个值代表了材质的基础反射率,即垂直于表面观察时的反射强度。常见的非金属材质反射率值包括:

  • 水:约 0.02
  • 塑料:约 0.05
  • 玻璃:约 0.08
  • 钻石:约 0.17

对于金属材质,Specular 输入需要 Vector3 值,分别对应 RGB 三个颜色通道的反射率。这是因为金属的反射通常带有颜色,而非简单的灰度值。金属的反射率值通常较高,一般在 0.5 到 1.0 之间,并且不同颜色的反射率可能有所不同。

Smoothness 输入端口控制材质表面的光滑程度,直接影响高光区域的大小和锐利度。这个参数接受 0.0 到 1.0 范围内的浮点值,其中 0.0 表示完全粗糙的表面(产生大面积模糊的高光),1.0 表示完全光滑的表面(产生小而锐利的高光)。

从物理角度来看,Smoothness 参数实际上代表了表面微观粗糙度的倒数。较高的光滑度意味着表面微观几何更加均匀,导致光线反射更加集中;而较低的光滑度则表示表面有更多的微观不规则,导致光线向各个方向散射。

Normal WS 输入端口要求提供世界空间中的表面法线信息。法线定义了表面的朝向,是高光计算中的关键几何数据。正确提供法线信息对于产生准确的高光效果至关重要。

在 Shader Graph 中,获取世界空间法线的常见方法包括:

  • 使用 Vertex Normal 节点并设置为世界空间
  • 从法线贴图采样并转换到世界空间
  • 通过自定义计算生成特殊效果的法线

Light Direction WS 输入端口指定了光源的方向,同样在世界空间中表示。这个方向应该指向光源,即从表面点指向光源位置的向量。在多重光照环境中,通常需要对每个光源分别计算高光贡献。

获取光源方向的典型方法包括:

  • 使用主光源方向(Main Light Direction)
  • 使用额外光源方向(Additional Light Direction)
  • 通过自定义向量定义特殊光源

View Direction WS 输入端口提供了从表面点到摄像机的方向向量。这个向量与光线方向和法线一起,构成了高光计算的核心几何数据。视口方向的准确性直接影响高光位置的正确性。

在 Shader Graph 中,可以通过 View Direction 节点轻松获取世界空间的视口方向。需要注意的是,这个方向应该归一化以确保计算结果的准确性。

输出端口

Out 输出端口生成最终的高光颜色值,以 Vector3 形式表示。这个输出通常需要与漫反射光照和其他光照组件结合,形成完整的表面着色。

输出的高光颜色具有以下特性:

  • 强度与光源强度和材质反射率成正比
  • 颜色受材质光学特性和光源颜色影响
  • 空间分布依赖于表面几何和光泽度参数

在实际使用中,CustomSpecular 节点的输出通常与漫反射颜色相加,并可能受到环境光遮蔽等其他因素的影响,最终形成完整的像素颜色。

使用示例

基础金属材质设置

创建一个基础的金属材质是理解 CustomSpecular 节点功能的绝佳起点。金属材质的高光特性与非金属有显著不同,主要体现在高光强度和颜色方面。

首先,设置 Specular 输入为 Vector3 类型,值设为 (0.8, 0.8, 0.9),这表示一个略带蓝色的金属反射特性。金属的反射率通常较高,因此选择接近 1.0 的值是合适的。蓝色的色调可以模拟不锈钢或钛合金等金属的真实外观。

Smoothness 参数设置为 0.85,表示表面相当光滑但并非完美镜面。这个值会产生一个相对集中但仍有轻微扩散的高光区域,符合大多数抛光金属的视觉特性。

法线输入可以使用标准的顶点法线,通过 Normal Vector 节点获取并设置为世界空间。对于更加细致的表面效果,可以考虑添加法线贴图来模拟微观表面细节。

光源方向通常来自场景的主光源,可以使用 Main Light Direction 节点获取。视口方向通过 View Direction 节点获得,确保设置为世界空间。

连接所有这些输入后,CustomSpecular 节点将输出一个明亮且带有颜色 tint 的高光效果。这个输出可以直接与漫反射组件结合,或者通过乘法与光源颜色混合,以创建更加动态的光照响应。

非金属塑料材质

非金属材质的高光特性与金属有本质区别,主要体现在反射率较低且高光颜色通常为无色(灰度)。创建塑料材质是演示非金属高光的典型示例。

设置 Specular 输入为浮点值 0.05,这是塑料材质的典型反射率。非金属的反射率通常远低于金属,一般在 0.02 到 0.08 范围内。

Smoothness 参数可以根据塑料类型进行调整。对于光滑的注塑塑料,可以设置为 0.7 到 0.9;对于磨砂塑料,则可以设置为 0.3 到 0.6。这个示例中使用 0.75,模拟常见的光滑塑料表面。

法线、光源方向和视口方向的设置与金属材质类似。关键区别在于 Specular 输入使用标量值而非向量,这表示高光颜色将由光源颜色主导,而不受材质颜色影响。

最终的高光效果应该是明亮但不太强烈的白色高光,符合塑料的物理特性。这种设置可以广泛应用于各种塑料制品、涂层表面和其他非金属材料的渲染。

自定义高光形状

通过修改法线输入,可以实现各种特殊的高光形状和效果。这种技术常用于风格化渲染或特殊视觉效果。

一种常见的方法是使用噪声纹理或程序化噪声来扰动法线方向。将噪声纹理采样与基础法线结合,可以创建不规则的高光图案,模拟表面瑕疵或特殊材质特性。

另一种技术是使用数学函数生成自定义法线模式。例如,使用正弦波函数可以创建条纹状的高光效果,适用于CD表面或全息材质等特殊场景。

还可以通过法线贴图引入复杂的高光细节,而无需增加几何复杂度。高质量的法线贴图可以显著增强表面的视觉丰富性,同时保持较低的性能开销。

动态高光效果

通过动态修改输入参数,可以创建响应环境变化的高光效果。这种技术常用于交互元素或动态环境中的物体。

例如,可以将 Smoothness 参数与时间变量关联,创建高光闪烁或脉动效果。这种效果适用于模拟霓虹灯、魔法效果或用户界面元素。

另一种应用是根据视角变化调整高光强度,实现类似菲涅尔效应的增强效果。当视线与表面法线夹角增大时,增加高光强度可以模拟某些特殊材质的视觉特性。

还可以根据场景深度或距离调整高光参数,实现基于距离的细节层次变化。远距离物体可以使用较低的高光精度以优化性能,而近距离物体则展示详细的高光特性。

注意事项

Specular 输入的专业考量

Specular 输入的正确设置对于实现物理正确的渲染至关重要。不同材质的反射率值基于真实的物理测量数据,使用准确的值可以显著提高渲染的真实感。

对于非金属材质,需要特别注意反射率值的范围。虽然技术上可以使用 0.0 到 1.0 的任何值,但真实世界的非金属材质反射率很少超过 0.08。使用超出这个范围的值可能导致不自然的视觉效果。

金属材质的 Specular 输入应该使用 Vector3 值,并且通常包含颜色信息。这是因为金属的反射率通常随波长变化,不同颜色的光可能被不同程度地反射。例如,铜会有偏红的高光,而金则有偏黄的高光。

在性能方面,使用常量 Specular 值通常比使用纹理采样更高效。但对于需要空间变化的反射率,如生锈金属或脏污表面,使用纹理仍然是必要的。

坐标系一致性

所有世界空间输入(Normal WS、Light Direction WS、View Direction WS)必须确保使用相同的坐标系系统。坐标系不一致会导致计算错误和视觉异常。

世界空间法线应该归一化处理,以确保光照计算的准确性。从法线贴图获取的法线需要从切线空间转换到世界空间,这个过程需要正确的切线空间基础向量。

光源方向应该指向光源,并且通常是归一化的向量。在多点光照情况下,需要对每个光源单独计算方向向量。

视口方向是从表面点指向摄像机位置的向量,同样需要归一化处理。在顶点着色器中计算视口方向时,需要注意插值导致的长度变化问题。

性能优化建议

CustomSpecular 节点的计算复杂度主要取决于输入数据的来源和处理方式。通过优化输入数据的获取方式,可以显著提高着色器的性能。

尽可能使用常量或插值数据,避免在片段着色器中进行复杂计算。例如,如果不需要每像素精确的高光,可以在顶点着色器计算高光然后插值到像素。

对于移动平台,考虑使用简化版的高光计算,或者通过质量设置动态调整高光精度。URP 提供了多种质量级别,可以根据目标平台选择适当的高光计算复杂度。

使用适当的精度限定符可以优化性能。对于不需要高精度的计算,可以使用 half 或 fixed 精度而非 float 精度,特别是在移动平台上。

与其他光照组件的整合

CustomSpecular 节点的高光输出需要与漫反射光照、环境光和其他光照组件正确结合,才能形成完整的表面着色。

通常,高光颜色会与光源颜色相乘,然后加到漫反射颜色上。这种加性混合模拟了光线在表面反射的物理过程。

在能量守恒的渲染模型中,需要确保高光和漫反射的总和不超过入射光线的能量。这通常通过适当调整漫反射和高光的相对强度来实现。

对于基于图像的光照(IBL)环境,高光计算可能还需要与环境反射相结合。URP 提供了专门的环境反射节点,可以与 CustomSpecular 节点结合使用。

常见问题排查

当 CustomSpecular 节点产生意外结果时,通常可以从以下几个方面进行排查:

检查所有世界空间向量是否归一化。未归一化的向量会导致光照计算错误,特别是高光强度和位置的不准确。

验证法线方向是否正确。反向法线会导致高光出现在错误的一侧,破坏视觉效果。

确认光源方向指向光源。错误的光源方向会导致高光完全消失或出现在不合理的位置。

检查 Specular 输入的数据类型是否正确。非金属应该使用浮点数,金属应该使用 Vector3,混淆两者会导致不正确的高光颜色。

验证 Smoothness 值是否在合理范围内。超出 0.0-1.0 范围的值可能导致未定义行为或视觉异常。


【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

JavaScript 防抖与节流进阶:从原理到实战

作者 wuhen_n
2026年2月16日 06:12

当用户疯狂点击按钮、疯狂滚动页面、疯狂输入搜索关键词时,应用还能流畅运行吗?防抖(Debounce)和节流(Throttle)是应对高频事件的终极武器。本文将从源码层面深入理解它们的差异,并实现一个支持立即执行、延迟执行、取消功能、记录参数的完整版本。

前言:高频事件带来的挑战

我们先来看一些简单的场景:

window.addEventListener('resize', () => {
    // 窗口大小改变时重新计算布局
    recalcLayout(); // 一秒可能触发几十次!
});

searchInput.addEventListener('input', () => {
    // 用户每输入一个字符就发起搜索请求
    fetchSearchResults(input.value); // 浪费大量请求!
});

window.addEventListener('scroll', () => {
    // 滚动时加载更多数据
    loadMoreData(); // 滚动一下触发几十次!
});

在这些场景中,当事件触发频率远高于我们需要的处理频率,就会出现卡顿、闪屏等现象,这就是防抖和节流要解决的核心问题。

理解防抖与节流的本质差异

核心概念对比

类型 防抖 节流
概念 将多次高频操作合并为一次,仅在最后一次操作后的延迟时间到达时执行 保证在单位时间内只执行一次,稀释执行频率
场景示例 电梯关门:等最后一个人进来后才关门,中间如果有人进来就重新计时 地铁安检:无论多少人排队,每秒钟只能通过一个人
执行次数 只执行最后一次 定期执行,不保证最后一次
频率 N次高频调用 → 1次执行 N次高频调用 → N/间隔时间次执行

适用场景对比

防抖场景

  • 搜索框输入(用户停止输入后才搜索)
  • 窗口大小调整(窗口调整完成后重新计算)
  • 表单验证(用户输完才验证)
  • 自动保存(停止编辑后保存)
  • 按钮防连点(避免重复提交)

节流场景

  • 滚动加载更多(滚动过程中定期检查)
  • 动画帧(控制动画执行频率)
  • 游戏循环(固定帧率)
  • 鼠标移动事件(实时位置但不过度频繁)
  • DOM元素拖拽(平滑移动)

防抖函数实现

基础防抖实现

function debounce(fn, delay) {
  let timer = null;

  return function (...args) {
    // 每次调用都清除之前的定时器
    if (timer) {
      clearTimeout(timer);
    }

    // 设置新的定时器
    timer = setTimeout(() => {
      fn.apply(this, args);
      timer = null;
    }, delay);
  };
}

支持立即执行的防抖

function debounceEnhanced(fn, delay, immediate = false) {
  let timer = null;
  let lastContext = null;
  let lastArgs = null;
  let lastResult = null;
  let callCount = 0;

  return function (...args) {
    lastContext = this;
    lastArgs = args;

    // 第一次调用且需要立即执行
    if (immediate && !timer) {
      lastResult = fn.apply(lastContext, lastArgs);
      callCount++;
      console.log(`立即执行 (调用 #${callCount})`);
    }

    // 清除之前的定时器
    if (timer) {
      clearTimeout(timer);
    }

    // 设置延迟执行
    timer = setTimeout(() => {
      // 如果不是立即执行模式,或者已经执行过立即执行
      if (!immediate) {
        lastResult = fn.apply(lastContext, lastArgs);
        callCount++;
        console.log(`延迟执行 (调用 #${callCount})`);
      }

      // 清理
      timer = null;
      lastContext = null;
      lastArgs = null;
    }, delay);

    return lastResult;
  };
}

完整版防抖(支持取消、取消、参数记录)

class DebouncedFunction {
  constructor(fn, delay, options = {}) {
    this.fn = fn;
    this.delay = delay;
    this.immediate = options.immediate || false;
    this.maxWait = options.maxWait || null;

    this.timer = null;
    this.lastArgs = null;
    this.lastContext = null;
    this.lastResult = null;
    this.lastCallTime = null;
    this.lastInvokeTime = null;

    // 参数历史记录
    this.history = [];
    this.maxHistory = options.maxHistory || 10;

    // 调用次数统计
    this.stats = {
      callCount: 0,
      invokedCount: 0,
      canceledCount: 0
    };
  }

  /**
   * 执行函数
   */
  _invoke() {
    const time = Date.now();
    this.stats.invokedCount++;
    this.lastInvokeTime = time;

    // 记录参数历史
    if (this.lastArgs) {
      this.history.push({
        args: [...this.lastArgs],
        timestamp: time,
        type: this.timer ? 'delayed' : 'immediate'
      });

      // 限制历史记录数量
      if (this.history.length > this.maxHistory) {
        this.history.shift();
      }
    }

    // 执行原函数
    this.lastResult = this.fn.apply(this.lastContext, this.lastArgs);

    // 清理
    this.lastArgs = null;
    this.lastContext = null;

    return this.lastResult;
  }

  /**
   * 调用防抖函数
   */
  call(...args) {
    const now = Date.now();
    this.stats.callCount++;
    this.lastArgs = args;
    this.lastContext = this;
    this.lastCallTime = now;

    // 立即执行模式处理
    if (this.immediate && !this.timer) {
      this._invoke();
    }

    // 清除现有定时器
    if (this.timer) {
      clearTimeout(this.timer);
    }

    // 最大等待时间处理
    if (this.maxWait && this.lastInvokeTime) {
      const timeSinceLastInvoke = now - this.lastInvokeTime;
      if (timeSinceLastInvoke >= this.maxWait) {
        this._invoke();
        return this.lastResult;
      }
    }

    // 设置新的定时器
    this.timer = setTimeout(() => {
      // 非立即执行模式,或者已经执行过立即执行
      if (!this.immediate) {
        this._invoke();
      }
      this.timer = null;
    }, this.delay);

    return this.lastResult;
  }

  /**
   * 取消当前待执行的防抖
   */
  cancel() {
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
      this.stats.canceledCount++;
    }

    this.lastArgs = null;
    this.lastContext = null;
  }

  /**
   * 立即执行并取消后续
   */
  flush() {
    if (this.lastArgs) {
      this._invoke();
      this.cancel();
    }
    return this.lastResult;
  }

  /**
   * 判断是否有待执行的任务
   */
  pending() {
    return this.timer !== null;
  }

  /**
   * 获取调用历史
   */
  getHistory() {
    return [...this.history];
  }

  /**
   * 获取统计信息
   */
  getStats() {
    return { ...this.stats };
  }

  /**
   * 重置状态
   */
  reset() {
    this.cancel();
    this.history = [];
    this.stats = {
      callCount: 0,
      invokedCount: 0,
      canceledCount: 0
    };
    this.lastResult = null;
    this.lastCallTime = null;
    this.lastInvokeTime = null;
  }
}

节流函数实现

基础节流实现

function throttleTimer(fn, interval) {
  let timer = null;

  return function (...args) {
    if (!timer) {
      timer = setTimeout(() => {
        fn.apply(this, args);
        timer = null;
      }, interval);
    }
  };
}

完整版节流(支持首尾执行)

class ThrottledFunction {
  constructor(fn, interval, options = {}) {
    this.fn = fn;
    this.interval = interval;
    this.leading = options.leading !== false; // 是否立即执行
    this.trailing = options.trailing !== false; // 是否最后执行

    this.timer = null;
    this.lastArgs = null;
    this.lastContext = null;
    this.lastResult = null;
    this.lastInvokeTime = 0;

    // 参数历史
    this.history = [];
    this.maxHistory = options.maxHistory || 10;

    // 统计信息
    this.stats = {
      callCount: 0,
      invokedCount: 0,
      throttledCount: 0
    };
  }

  /**
   * 执行函数
   */
  _invoke() {
    const now = Date.now();
    this.lastInvokeTime = now;
    this.stats.invokedCount++;

    // 记录历史
    if (this.lastArgs) {
      this.history.push({
        args: [...this.lastArgs],
        timestamp: now,
        type: 'executed'
      });

      if (this.history.length > this.maxHistory) {
        this.history.shift();
      }
    }

    // 执行函数
    this.lastResult = this.fn.apply(this.lastContext, this.lastArgs);
    this.lastArgs = null;
    this.lastContext = null;
  }

  /**
   * 调用节流函数
   */
  call(...args) {
    const now = Date.now();
    this.stats.callCount++;
    this.lastArgs = args;
    this.lastContext = this;

    // 检查是否在节流期内
    const timeSinceLastInvoke = now - this.lastInvokeTime;
    const isThrottled = timeSinceLastInvoke < this.interval;

    if (isThrottled) {
      this.stats.throttledCount++;

      // 如果需要尾部执行
      if (this.trailing) {
        // 清除现有的尾部执行定时器
        if (this.timer) {
          clearTimeout(this.timer);
        }

        // 设置尾部执行定时器
        const remainingTime = this.interval - timeSinceLastInvoke;
        this.timer = setTimeout(() => {
          if (this.lastArgs) {
            this._invoke();
          }
          this.timer = null;
        }, remainingTime);
      }

      return this.lastResult;
    }

    // 不在节流期内
    if (this.leading) {
      // 头部执行
      this._invoke();
    } else if (this.trailing) {
      // 延迟执行
      if (this.timer) {
        clearTimeout(this.timer);
      }
      this.timer = setTimeout(() => {
        if (this.lastArgs) {
          this._invoke();
        }
        this.timer = null;
      }, this.interval);
    }

    return this.lastResult;
  }

  /**
   * 取消尾部执行
   */
  cancel() {
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
    }
    this.lastArgs = null;
    this.lastContext = null;
  }

  /**
   * 立即执行并取消尾部执行
   */
  flush() {
    if (this.lastArgs) {
      this._invoke();
      this.cancel();
    }
    return this.lastResult;
  }

  /**
   * 判断是否有尾部待执行
   */
  pending() {
    return this.timer !== null;
  }

  /**
   * 获取历史记录
   */
  getHistory() {
    return [...this.history];
  }

  /**
   * 获取统计信息
   */
  getStats() {
    return { ...this.stats };
  }

  /**
   * 重置状态
   */
  reset() {
    this.cancel();
    this.history = [];
    this.stats = {
      callCount: 0,
      invokedCount: 0,
      throttledCount: 0
    };
    this.lastInvokeTime = 0;
    this.lastResult = null;
  }
}

进阶实现与组合优化

支持最大等待时间的防抖

支持最大等待时间的防抖,就是确保函数至少每隔 maxWait 时间执行一次:

function debounceMaxWait(fn, delay, maxWait) {
  let timer = null;
  let lastArgs = null;
  let lastContext = null;
  let lastInvokeTime = null;
  let maxTimer = null;

  const invoke = () => {
    lastInvokeTime = Date.now();
    fn.apply(lastContext, lastArgs);
    lastArgs = null;
    lastContext = null;
  };

  const startMaxWaitTimer = () => {
    if (maxTimer) clearTimeout(maxTimer);

    maxTimer = setTimeout(() => {
      if (lastArgs) {
        console.log('达到最大等待时间,强制执行');
        invoke();
      }
    }, maxWait);
  };

  return function (...args) {
    lastArgs = args;
    lastContext = this;

    // 清除现有延迟定时器
    if (timer) {
      clearTimeout(timer);
    }

    // 设置最大等待时间定时器
    if (maxWait && !lastInvokeTime) {
      startMaxWaitTimer();
    }

    // 设置新的延迟定时器
    timer = setTimeout(() => {
      invoke();
      timer = null;

      if (maxTimer) {
        clearTimeout(maxTimer);
        maxTimer = null;
      }
    }, delay);
  };
}

动态调整延迟时间的防抖

根据调用频率动态调整等待时间:

function debounceAdaptive(fn, baseDelay, options = {}) {
  const {
    minDelay = 100,
    maxDelay = 1000,
    factor = 0.8
  } = options;

  let timer = null;
  let lastArgs = null;
  let lastContext = null;
  let callTimes = [];
  let currentDelay = baseDelay;

  const calculateDelay = () => {
    // 计算最近1秒内的调用频率
    const now = Date.now();
    callTimes = callTimes.filter(t => now - t < 1000);
    const frequency = callTimes.length;

    // 根据频率调整延迟
    if (frequency > 10) {
      // 高频调用,增加延迟
      currentDelay = Math.min(currentDelay * (1 + frequency / 100), maxDelay);
    } else if (frequency < 2) {
      // 低频调用,减少延迟
      currentDelay = Math.max(currentDelay * factor, minDelay);
    }

    return currentDelay;
  };

  return function (...args) {
    callTimes.push(Date.now());
    lastArgs = args;
    lastContext = this;

    if (timer) {
      clearTimeout(timer);
    }

    const delay = calculateDelay();
    console.log(`  当前延迟: ${Math.round(delay)}ms (调用频率: ${callTimes.length}/秒)`);

    timer = setTimeout(() => {
      fn.apply(lastContext, lastArgs);
      timer = null;
    }, delay);
  };
}

实际应用场景实战

搜索框自动补全

class SearchAutoComplete {
  constructor(options = {}) {
    this.searchAPI = options.searchAPI || this.mockSearchAPI;
    this.minLength = options.minLength || 2;
    this.debounceDelay = options.debounceDelay || 300;
    this.maxResults = options.maxResults || 10;
    this.cacheResults = options.cacheResults !== false;

    // 搜索缓存
    this.cache = new Map();

    // 创建防抖搜索函数
    this.debouncedSearch = this.createDebouncedSearch();

    // 请求计数器
    this.requestCount = 0;
    this.cacheHitCount = 0;
  }

  /**
   * 模拟搜索API
   */
  async mockSearchAPI(query) {
    // 模拟网络延迟
    await new Promise(resolve => setTimeout(resolve, 200));

    // 模拟搜索结果
    const results = [];
    const prefixes = ['apple', 'banana', 'orange', 'grape', 'watermelon'];

    for (let i = 1; i <= 5; i++) {
      results.push({
        id: i,
        text: `${query} 结果 ${i}`,
        category: prefixes[i % prefixes.length]
      });
    }

    return results;
  }

  /**
   * 创建防抖搜索函数
   */
  createDebouncedSearch() {
    const searchFn = async (query) => {
      // 检查缓存
      if (this.cacheResults && this.cache.has(query)) {
        this.cacheHitCount++;
        return this.cache.get(query);
      }

      // 执行真实搜索
      this.requestCount++;
      console.log(`  🌐 [请求#${this.requestCount}] "${query}"`);

      try {
        const results = await this.searchAPI(query);

        // 存入缓存
        if (this.cacheResults) {
          this.cache.set(query, results);

          // 限制缓存大小
          if (this.cache.size > 50) {
            const oldestKey = this.cache.keys().next().value;
            this.cache.delete(oldestKey);
          }
        }

        return results;
      } catch (error) {
        console.error(`搜索失败: ${query}`, error);
        return [];
      }
    };

    // 使用完整版防抖
    return debounceComplete(searchFn, this.debounceDelay, {
      immediate: false,
      maxWait: 1000
    });
  }

  /**
   * 用户输入处理
   */
  onInput(query) {

    // 忽略空查询
    if (!query || query.length < this.minLength) {
      console.log('  查询太短,忽略');
      return Promise.resolve([]);
    }

    // 执行防抖搜索
    return this.debouncedSearch(query)
      .then(results => {
        const limited = results.slice(0, this.maxResults);
        console.log(`返回 ${limited.length} 条结果`);
        this.renderResults(limited);
        return limited;
      })
      .catch(error => {
        console.error('搜索失败:', error);
        return [];
      });
  }

  /**
   * 渲染搜索结果
   */
  renderResults(results) {
    // 实际项目中这里会更新DOM
    console.log(' 搜索结果:');
    results.slice(0, 3).forEach((result, i) => {
      console.log(`    ${i + 1}. ${result.text}`);
    });
    if (results.length > 3) {
      console.log(`... 等 ${results.length} 条`);
    }
  }

  /**
   * 清空缓存
   */
  clearCache() {
    this.cache.clear();
    this.cacheHitCount = 0;
    console.log('搜索缓存已清空');
  }

  /**
   * 获取统计信息
   */
  getStats() {
    return {
      requestCount: this.requestCount,
      cacheHitCount: this.cacheHitCount,
      cacheSize: this.cache.size,
      pending: this.debouncedSearch.pending(),
      debounceStats: this.debouncedSearch.getStats?.()
    };
  }
}

无限滚动加载

console.log('\n=== 无限滚动加载 ===\n');

class InfiniteScroll {
  constructor(options = {}) {
    this.loadMoreAPI = options.loadMoreAPI || this.mockLoadMoreAPI;
    this.throttleInterval = options.throttleInterval || 200;
    this.threshold = options.threshold || 200;
    this.pageSize = options.pageSize || 20;

    this.currentPage = 0;
    this.hasMore = true;
    this.isLoading = false;
    this.items = [];

    // 创建节流滚动处理函数
    this.throttledScroll = this.createThrottledScroll();

    // 记录最后一次滚动位置
    this.lastScrollPosition = 0;
    this.scrollHistory = [];
  }

  /**
   * 模拟加载更多数据
   */
  async mockLoadMoreAPI(page, pageSize) {
    // 模拟网络延迟
    await new Promise(resolve => setTimeout(resolve, 300));

    // 模拟数据
    const start = page * pageSize;
    const items = [];

    for (let i = 0; i < pageSize; i++) {
      items.push({
        id: start + i,
        title: `项目 ${start + i}`,
        content: `这是第 ${start + i} 个项目的内容`,
        timestamp: Date.now()
      });
    }

    // 模拟没有更多数据
    const hasMore = page < 10;

    return { items, hasMore };
  }

  /**
   * 创建节流滚动处理函数
   */
  createThrottledScroll() {
    const scrollHandler = async (scrollTop, clientHeight, scrollHeight) => {
      const distanceFromBottom = scrollHeight - scrollTop - clientHeight;

      console.log(`滚动位置: ${scrollTop}, 距离底部: ${distanceFromBottom}px`);

      // 记录滚动位置
      this.lastScrollPosition = scrollTop;
      this.scrollHistory.push({
        position: scrollTop,
        timestamp: Date.now()
      });

      // 限制历史记录大小
      if (this.scrollHistory.length > 20) {
        this.scrollHistory.shift();
      }

      // 检查是否需要加载更多
      if (distanceFromBottom < this.threshold) {
        await this.loadMore();
      }
    };

    return throttleComplete(scrollHandler, this.throttleInterval, {
      leading: true,
      trailing: true
    });
  }

  /**
   * 处理滚动事件
   */
  onScroll(event) {
    const target = event.target;
    const scrollTop = target.scrollTop || target.scrollingElement?.scrollTop || 0;
    const clientHeight = target.clientHeight || window.innerHeight;
    const scrollHeight = target.scrollHeight || document.documentElement.scrollHeight;

    this.throttledScroll(scrollTop, clientHeight, scrollHeight);
  }

  /**
   * 加载更多数据
   */
  async loadMore() {
    if (this.isLoading || !this.hasMore) {
      console.log(`${this.isLoading ? '正在加载中' : '没有更多数据'}`);
      return;
    }

    this.isLoading = true;
    this.currentPage++;

    console.log(`加载第 ${this.currentPage} 页数据...`);

    try {
      const result = await this.loadMoreAPI(this.currentPage, this.pageSize);

      this.hasMore = result.hasMore;
      this.items.push(...result.items);

      console.log(`加载完成,当前总条目: ${this.items.length}`);
      console.log(`还有更多: ${this.hasMore}`);

      this.renderItems(result.items);
    } catch (error) {
      console.error('加载失败:', error);
      this.currentPage--; // 回退页数
    } finally {
      this.isLoading = false;
    }
  }

  /**
   * 渲染新加载的项目
   */
  renderItems(newItems) {
    // 实际项目中这里会更新DOM
    console.log('新增项目:');
    newItems.slice(0, 3).forEach((item, i) => {
      console.log(`${item.id}. ${item.title}`);
    });
    if (newItems.length > 3) {
      console.log(`... 等 ${newItems.length} 条`);
    }
  }

  /**
   * 重置到顶部
   */
  reset() {
    this.currentPage = 0;
    this.hasMore = true;
    this.isLoading = false;
    this.items = [];
    this.scrollHistory = [];
    console.log('滚动列表已重置');
  }

  /**
   * 获取滚动统计
   */
  getScrollStats() {
    if (this.scrollHistory.length < 2) {
      return { avgSpeed: 0 };
    }

    const recent = this.scrollHistory.slice(-10);
    let totalSpeed = 0;

    for (let i = 1; i < recent.length; i++) {
      const distance = recent[i].position - recent[i - 1].position;
      const timeDiff = recent[i].timestamp - recent[i - 1].timestamp;
      const speed = distance / timeDiff; // px/ms
      totalSpeed += speed;
    }

    return {
      avgSpeed: totalSpeed / (recent.length - 1),
      scrollCount: this.scrollHistory.length,
      lastPosition: this.lastScrollPosition
    };
  }
}

最佳实践指南

防抖最佳实践

  • 默认延迟时间:300-500ms(用户输入)、200-300ms(窗口调整)、1000ms(自动保存)
  • 搜索框建议使用防抖,避免频繁请求
  • 表单验证使用防抖,用户输完再验证
  • 提交按钮使用防抖,防止重复提交
  • 需要立即反馈的操作设置 immediate: true

节流最佳实践

  • 滚动加载:200-300ms(平衡响应性和性能)
  • 拖拽事件:16-33ms(约30-60fps)
  • 窗口大小调整:100-200ms
  • 游戏循环:使用 requestAnimationFrame 替代定时器节流
  • 频繁的状态更新:考虑使用 requestAnimationFrame

内存管理实践

  • 组件卸载时取消未执行的防抖/节流
  • 避免在全局作用域创建过多的防抖/节流函数
  • 使用缓存时注意设置最大缓存大小
  • 定期清理过期的缓存数据

调试技巧

  • 添加日志追踪函数调用
  • 记录调用历史便于回溯问题
  • 使用 Stats 统计调用次数和节流情况
  • 开发环境设置更短的延迟时间便于测试

防抖节流选择决策树

是否需要处理高频事件?
        │
        ├─→ 是
        │   │
        │   ├─→ 是否需要关注最后一次执行?
        │   │   │
        │   │   ├─→ 是 → 使用防抖
        │   │   │   │
        │   │   │   ├─→ 搜索建议、自动保存、表单验证
        │   │   │   └─→ 窗口调整、拖拽结束
        │   │   │
        │   │   └─→ 否 → 使用节流
        │   │       │
        │   │       ├─→ 滚动加载、拖拽中、动画帧
        │   │       └─→ 游戏循环、鼠标移动
        │   │
        │   └─→ 是否需要立即执行?
        │       │
        │       ├─→ 是 → immediate: true
        │       │   │
        │       │   ├─→ 按钮提交(防止双击)
        │       │   └─→ 数据埋点
        │       │
        │       └─→ 否 → immediate: false
        │           │
        │           ├─→ 搜索建议(避免每个字符都请求)
        │           └─→ 自动保存(停止编辑后保存)
        │
        └─→ 否 → 不需要特殊处理

最终建议

  1. 不要盲目使用防抖/节流,先评估是否真的需要
  2. 根据用户体验选择合理的延迟时间
  3. 为防抖/节流函数命名时标明其特性
  4. 在类组件中绑定this时注意上下文
  5. 优先使用成熟的库实现(lodash、underscore)
  6. 理解原理,但不一定需要每次都自己实现
  7. 监控实际效果,根据数据持续优化

结语

防抖和节流是前端性能优化的基本工具,掌握它们不仅能提升应用性能,还能优化用户体验。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

html与CSS伪类技巧

2026年2月16日 05:01

CSS选择器文档

本文档基于15个HTML示例文件介绍了CSS的各种技巧实践,旨在记录开发时候CSS的场景应用。

  1. 影子DOM样式隔离
  2. :not()伪类简化代码
  3. :is()伪类简化选择器
  4. :active伪类实现埋点统计
  5. :focus-within实现搜索交互
  6. <small>标签的使用
  7. <details><summary>实现折叠面板
  8. :placeholder-shown实现输入提示
  9. :default伪类标记默认选项
  10. <label>交互与计数器
  11. 表单校验与反馈
  12. <fieldset><legend>组织表单
  13. :empty伪类处理空内容
  14. :only-child伪类条件显示
  15. :not()伪类初始化样式

1. 影子DOM样式隔离

使用attachShadow()方法创建影子DOM,实现组件的样式隔离,避免样式冲突。

<body>
   <p>外部文字,颜色为黑色</p>
   <div id="hostElement"></div>
</body>

<script>
   const hostElement = document.querySelector('#hostElement')
   const shadow = hostElement.attachShadow({ mode: 'open' })
   shadow.innerHTML = `<p>可以实现组件的样式隔离,颜色为红色</p>`
   shadow.innerHTML += `<style>p{color:red}</style>`
</script>
  • 组件开发,特别是自定义元素
  • 第三方插件集成
  • 样式封装和隔离

2. :not()伪类简化代码

使用:not()伪类排除特定元素,简化CSS选择器,避免重复样式定义。

<ul>
      <li>列表1</li>
      <li>列表2</li>
      <li>列表3</li>
</ul>

<style>
    /*列表最后一项排除*/
    li:not(:last-child){
        border-bottom: 1px solid black;
        padding-bottom:8px;
        margin-bottom: 8px;
    }
</style>
  • 列表项样式处理(如最后一项无边框)
  • 导航菜单样式
  • 表单元素样式排除

3. :is()伪类简化选择器

使用:is()伪类将多个选择器组合成一个,简化CSS代码结构,提高可读性。

<body>
    <header> <a href='#'> 头部字体 </a>  </header>
    <main> <a href='#'> 主题字体 </a>  </main>
    <footer> <a href='#'> 底部字体 </a> </footer>
</body>

<style>
   /* 常见嵌套写法,需经过编译
    header,main,footer{  
       a{   color: red;   } 
    } 
   编译过后 
   header a,main a,footer a{  color: red  } 
   */

    /* :is() 是原生 CSS 语法,无需编译,浏览器直接识别 */
    :is(header, main, footer) a  {   color: red   }
</style>
  • 多个容器内相同元素的样式统一
  • 复杂选择器的简化

4. :active伪类实现埋点统计

使用:active伪类结合content属性,实现无JavaScript的埋点统计功能。

<body>
    <button class="button1">点我上传</button>
    <button class="button2">点我上传2</button>
</body>

<style>
    /* 第一次点击可以触发 */
.button1:active::after{
    content: url(./pixxel.gif?action=click&id=button1);
}
.button2:active::after{
    content: url(./pixxel.gif?action=click&id=button2);
}
</style>
  • 简单的用户行为统计
  • 按钮点击事件追踪
  • 无需JavaScript的埋点方案

5. :focus-within实现搜索交互

使用:focus-within伪类实现父元素在子元素获得焦点时的样式变化,适用于搜索框下拉菜单等交互场景。

<!-- 适合做 搜索框结果的 下拉 -->
<div class="cs-details">
    <a href="javascript:" class="cs-summary">我的消息</a>
    <div class="cs-datalist">
        <a href>我的回答<sup>12</sup></a>
        <a href>我的私信</a>
        <a href>未评价订单</a>
        <a href>我的关注</a>
    </div>
</div>


<style>
    .cs-datalist {
        display: none;
        position: absolute;
        border: 1px solid #000;
        background-color: #fff;
    }

    .cs-details:focus-within .cs-datalist {
        display: block;
    }
</style>
  • 搜索框下拉菜单
  • 导航菜单的子菜单显示
  • 表单元素的关联信息展示

6. <small>标签的使用

使用<small>标签表示小号文本,通常用于免责声明、注释等次要信息。

<small>你好</small>
<div>你好123</div>
  • 法律声明和条款
  • 文章注释和说明
  • 表单字段的辅助信息

7. <details><summary>实现折叠面板

使用<details><summary>标签实现原生的折叠面板功能,无需JavaScript。

<details style="user-select:none;">
    <summary>请选择</summary>
    <ul>
        <li>选项1</li>
        <li>选项2</li>
        <li>选项3</li>
    </ul> 
</details>  
  • 常见问题解答(FAQ)
  • 内容折叠展示
  • 配置选项面板

8. :placeholder-shown实现输入提示

使用:placeholder-shown伪类检测输入框是否显示占位符,实现输入状态的样式变化。

<input type="search" placeholder="请输入内容">
<small>尚未输入内容</small>

<style>
    :not(:placeholder-shown)+small {
        color: transparent;
    }
</style>
  • 输入框的状态提示
  • 表单验证的视觉反馈
  • 提升用户输入体验

9. :default伪类标记默认选项

使用:default伪类标记表单中的默认选项,如默认选中的单选按钮。

<!-- 更换选择,自动补充(推荐) -->
<p>请选择支付方式:</p>
<p><input name="pay" type="radio"><label>支付宝</label></p>
<p><input name="pay" type="radio" checked><label>微信</label></p>
<p><input name="pay" type="radio"><label>银行卡</label></p>

<style>
    input:default+label::after {
        content: '(推荐)';
    }
</style>
  • 表单默认选项标记
  • 推荐选项提示
  • 提高用户表单填写效率

10. <label>交互与计数器

使用<label>标签与复选框关联,结合CSS计数器实现选中项数量统计。

<body>
    <p>请选择你感兴趣的话题:</p>

    <input type="checkbox" id="topic1">
    <label for="topic1" class="cs-topic">科技</label>

    <input type="checkbox" id="topic2">
    <label for="topic2" class="cs-topic">体育</label>

    <input type="checkbox" id="topic3">
    <label for="topic3" class="cs-topic">军事</label>

    <input type="checkbox" id="topic4">
    <label for="topic4" class="cs-topic">娱乐</label>

    <p>您已选择 <span class="cs-topic-counter"></span>个话题。</p>

</body>

<style>
    .cs-topic {
        padding:5px 15px;
        cursor: pointer;
        border: 1px solid #000;
    }
    :checked+.cs-topic {
        border-color: skyblue;
        background-color: azure;
    }
    [type='checkbox'] {
        position: absolute;
        clip: rect(0 0 0 0);
    }

    body {
        counter-reset: topicCounter;
    }

    :checked+.cs-topic {
        counter-increment: topicCounter;
    }

    .cs-topic-counter::before {
        content: counter(topicCounter);
    }
</style>
  • 兴趣标签选择
  • 商品属性选择
  • 多选项表单交互

11. 表单校验与反馈

使用CSS伪类实现表单验证的视觉反馈,包括输入合法、非法和空值状态。

<!-- 表单校验 -->

<form id="csForm" novalidate>
    <p>
        验证码:
        <input class="cs-input" required pattern="\w{4}" placeholder="">
        <span class="cs-vaild-tips"></span>
    </p>

    <input type="submit" />
</form>

<style>
    /* 校验通过 */
    .cs-input:valid {
        background-color: green;
        color: #fff;
    }
    .valid .cs-input:valid+.cs-vaild-tips::before {
        content: '√';
        color: green;
    }

    /* 校验不合法提示 */
    .valid .cs-input:not(:placeholder-shown):invalid {
        border: 2px solid red;
    }
    .valid .cs-input:not(:placeholder-shown):invalid+.cs-vaild-tips::before {
        content: '不符合要求';
        color: red;
    }

    /* 空值提示 */
    .valid .cs-input:placeholder-shown+.cs-vaild-tips::before {
        content: '尚未输入值';
    }
</style>

<script>
    const form = document.querySelector('#csForm')
    const input = document.querySelector('.cs-input')

    // 即时的校验
    // form.addEventListener('input',(e)=>{
    //     form.classList.add('valid')
    // })

    // 优化:输入时实时更新校验提示(可选,提升体验)
    input.addEventListener('input', () => {
        if (form.classList.contains('valid')) {
            // 强制重绘,更新样式
            void form.offsetWidth;
        }
    })

    form.addEventListener('submit', (e) => {
        e.preventDefault()
        form.classList.add('valid') // 触发校验样式

        if (form.checkValidity()) {
            alert('校验通过')
        }
    })
</script>
  • 表单验证反馈
  • 实时输入校验
  • 提升表单填写体验

12. <fieldset><legend>组织表单

使用<fieldset><legend>标签组织表单内容,提高表单的结构性。

<form>
    <fieldset>
        <legend>问卷调查</legend>
        <ol>
            <li>1-3年</li>
            <li>3-5年</li>
            <li>5年以上</li>
            <h4>你从事前端几年了?</h4>
        </ol>
    </fieldset>
</form>
  • 复杂表单的分组
  • 提高表单的可访问性
  • 增强表单的语义结构

13. :empty伪类处理空内容

使用:empty伪类检测元素是否为空,为空白元素添加默认内容或样式。

<dl>
    <dt>姓名:</dt>   <dd>张三</dd>
    <dt>性别:</dt>   <dd></dd>
    <dt>手机:</dt>   <dd></dd>
    <dt>邮箱:</dt>   <dd></dd>
</dl>

<!-- :empty 兼容 ''、null,配合伪元素可填充自定义内容 -->
<style>
    dt {   float: left    }

    dd:empty::before {
        color: gray;
        /* content: '-'; */
        content: '暂无';
    }
</style>
  • 数据展示中的空值处理
  • 搜索结果为空的提示
  • 表单字段的默认显示

14. :only-child伪类条件显示

使用:only-child伪类检测元素是否为唯一子元素,实现条件性的样式显示。

<ul>
    <li> 仅剩一项时不可删除 <button>删除</button> </li>
    <li> 仅剩一项时不可删除 <button>删除</button> </li>
    <li> 仅剩一项时不可删除 <button>删除</button> </li>
</ul>

<style>
    li:only-child button { display: none  }
</style>


<script>
    // 点击删除 li
    const buttons = document.querySelectorAll('li button')
    buttons.forEach(btn => {
        btn.addEventListener('click', function () {
            btn.parentElement.remove()
        })
    })
</script>
  • 列表项的删除按钮控制
  • 条件性UI元素显示
  • 动态内容的样式调整

15. :not()伪类初始化样式

使用:not()伪类排除特定元素,实现样式的初始化和重置。

<!-- 激活面板优雅切换 -->
<!-- 
<div class="cs-panel">面板1</div>
<div class="cs-panel active">面板2</div>
<div class="cs-panel">面板3</div>

<style>  .cs-panel:not(.active) { display: none} </style>
 -->


<!-- 灵活性 -->
<div class="cs-panel">面板1</div>
<div class="cs-panel  flex">面板2</div>
<div class="cs-panel active grid">面板3</div>

<style>
    .cs-panel:not(.active) {  display: none  }
    
    .flex {    display: flex  }
    .grid {    display: grid   }
</style>
  • 选项卡面板切换
  • 激活状态的样式控制
  • 组件的默认状态管理

总结

本文档介绍了CSS新世界中的15个实用技巧,涵盖了样式隔离、选择器优化、表单交互、内容展示等多个方面。这些技巧充分利用了现代CSS的新特性,能够帮助开发者编写更简洁、高效、可维护的代码,同时提升用户体验。

随着CSS标准的不断发展,我们可以期待更多强大的特性和技巧出现。希望本文档能够为开发者提供参考,让大家在CSS的世界中探索更多可能性。

参考资源

  • 张鑫旭《css选择器》

Elpis 动态组件扩展设计:配置驱动的边界与突破

作者 飞雪飘摇
2026年2月15日 22:01

配置驱动的边界问题

Elpis 通过配置驱动解决了 80% 的中后台 CRUD 场景,但总会遇到内置组件无法覆盖的情况:

  • 需要省市区三级联动选择器
  • 需要带千分位格式化的金额输入框
  • 需要集成公司自研的图片裁剪上传组件
  • 需要富文本编辑器、图表组件等第三方库

这时候有三个选择:

方案 A:放弃配置驱动,回到手写代码

方案 B:等框架作者更新内置组件

方案 C:自己扩展组件,像内置组件一样使用

Elpis 选择了方案 C,通过动态组件扩展机制,让框架既保持标准化,又具备灵活性。

核心设计:一个"字符串"的魔法

Elpis 的扩展机制说穿了就一个核心思想:配置里写的是字符串,渲染时才决定用哪个组件

看这段配置:

product_name: {
  createFormOption: {
    comType: 'input',  // 这只是个字符串
  }
}

这个 'input' 不是直接对应某个组件,而是一个"代号"。真正的组件在哪?在一个叫"注册中心"的地方:

// form-item-config.js
const FormItemConfig = {
  'input': { component: InputComponent },
  'select': { component: SelectComponent },
  'richEditor': { component: RichEditorComponent }
};

渲染时,Elpis 做的事情很简单:

<component :is="FormItemConfig[配置里的comType].component" />

就这样,配置和组件解耦了。你想加新组件?往注册中心加一行,配置里就能用。

这个设计妙在哪?

1. 配置稳定: 即使你把 InputComponent 整个重写了,配置文件一个字都不用改。因为配置里只是写了个 'input' 字符串。

2. 场景隔离: 搜索栏有自己的注册中心,表单有自己的注册中心。同样是 'input',在搜索栏可能是个简单输入框,在表单里可能是个带校验的复杂组件。

3. 扩展简单: 不需要改框架代码,不需要发 PR,不需要等更新。自己加一行注册,立刻就能用。

实战:扩展一个富文本编辑器组件

通过实际案例演示如何扩展组件。假设需要添加富文本编辑器支持。

第一步:实现组件

创建文件 app/pages/widgets/schema-form/complex-view/rich-editor/rich-editor.vue

<template>
  <div class="form-item">
    <div class="item-label">
      <span>{{ schema.label }}</span>
      <span v-if="schema.option?.required" class="required">*</span>
    </div>
    <div class="item-value">
      <QuillEditor v-model:content="value" />
      <div v-if="!isValid" class="valid-tips">{{ validMessage }}</div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { QuillEditor } from '@vueup/vue-quill';

const props = defineProps({
  schemaKey: String,    // 字段名
  schema: Object,       // 字段配置
  model: String         // 初始值
});

const value = ref(props.model || '');
const isValid = ref(true);
const validMessage = ref('');

// 必须实现的接口方法
const validate = () => {
  if (props.schema.option?.required && !value.value) {
    isValid.value = false;
    validMessage.value = '这个字段必填';
    return false;
  }
  isValid.value = true;
  return true;
};

const getValue = () => {
  return { [props.schemaKey]: value.value };
};

defineExpose({ validate, getValue });
</script>

关键约定

  • Props 必须包含 schemaKeyschemamodel
  • 必须暴露 validate()getValue() 方法
  • 其他实现细节可自由发挥

第二步:注册组件

app/pages/widgets/schema-form/form-item-config.js 中注册:

import richEditor from "./complex-view/rich-editor/rich-editor.vue";

const FormItemConfig = {
  input: { component: input },
  select: { component: select },
  richEditor: { component: richEditor }  // 新增注册
};

第三步:配置使用

在业务模型中使用新组件:

product_description: {
  type: 'string',
  label: '商品描述',
  createFormOption: {
    comType: 'richEditor',  // 使用扩展组件
    required: true
  }
}

完成。刷新页面,富文本编辑器自动渲染,校验、提交等功能自动生效。

背后的技术:Vue 3 的动态组件

你可能好奇 Elpis 是怎么做到"运行时决定渲染哪个组件"的。答案是 Vue 3 的 <component :is>

看 Elpis 的核心渲染代码:

<template>
  <template v-for="(itemSchema, key) in schema.properties">
    <component
      :is="FormItemConfig[itemSchema.option?.comType]?.component"
      :schemaKey="key"
      :schema="itemSchema"
      :model="model[key]"
    />
  </template>
</template>

这段代码在做什么?

  1. 遍历配置里的每个字段
  2. 读取字段的 comType(比如 'input'
  3. 从注册中心找到对应的组件(FormItemConfig['input'].component
  4. :is 动态渲染这个组件

关键点:is 后面可以是一个变量,这个变量的值是什么组件,就渲染什么组件。

这就是为什么你改配置文件就能换组件——因为组件是运行时决定的,不是编译时写死的。

一个容易忽略的细节:统一接口

注意到没有,所有组件都接收同样的 props:

:schemaKey="key"
:schema="itemSchema"
:model="model[key]"

这是 Elpis 的"约定"。只要你的组件遵守这个约定,就能被动态渲染。

这就像 USB 接口,不管你是键盘、鼠标还是 U 盘,只要接口对得上,就能插上用。

所以写扩展组件时,记住三件事:

  1. Props 要有 schemaKeyschemamodel
  2. 要暴露 validate()getValue() 方法
  3. 其他的随便你发挥

对比:Elpis vs 其他方案

vs Element Plus / Ant Design(组件库)

组件库:给你一堆组件,你自己拼。

<el-form>
  <el-form-item label="商品名称">
    <el-input v-model="form.name" />
  </el-form-item>
  <el-form-item label="价格">
    <el-input-number v-model="form.price" />
  </el-form-item>
  <!-- 每个字段都要写 -->
</el-form>

Elpis:写个配置,自动生成。

{
  product_name: { createFormOption: { comType: 'input' } },
  price: { createFormOption: { comType: 'inputNumber' } }
}

结论:组件库灵活但重复劳动多,Elpis 标准化但省事。适用场景不同,不是替代关系。

vs Formily / React JSON Schema Form(表单方案)

JSON Schema 表单:只管表单,其他的你自己搞。

Elpis:搜索 + 表格 + 表单 + 详情,一套配置全搞定。

结论:Elpis 是 JSON Schema 思想在整个中后台系统的延伸。

写在最后

Elpis 的动态组件扩展机制核心就三件事:

  1. 配置里写字符串标识,不直接引用组件
  2. 用注册中心做类型映射,字符串对应具体组件
  3. 用 Vue 的 :is 实现运行时动态渲染

这套设计让框架在标准化和灵活性之间找到了平衡:

  • 80% 的场景用内置组件,配置驱动,快速开发
  • 20% 的场景扩展组件,一次封装,到处复用

扩展组件的成本是一次性的,但收益是长期的。当你的组件库逐渐丰富,配置驱动的威力就会越来越明显。

框架的价值不在于限制开发者,而在于提供清晰的扩展路径,让开发者在需要时能够突破标准化的边界。

引用: 抖音“哲玄前端”《大前端全栈实践》

【翻译】Rolldown工作原理:模块加载、依赖图与优化机制全揭秘

2026年2月15日 21:59

原文链接:www.atriiy.dev/blog/rolldo…

作者: Atriiy

引言

Rolldown 是一款基于 Rust 开发的极速 JavaScript 打包工具,专为无缝兼容 Rollup API 设计。其核心目标是在不久的将来成为 Vite 的统一打包器,为 Vite 提供底层支撑。目前 Vite 在本地开发阶段依赖 esbuild 实现极致的构建速度,而生产环境构建则基于 Rollup;切换为 Rolldown 这类单一打包器后,有望简化整个构建流程,让开发者更有信心 —— 开发环境所见的效果,与生产环境最终上线的结果完全一致。此外,Rolldown 的打包速度预计比 Rollup 快 10~30 倍。想了解更多细节?可查阅 Rolldown 官方文档。

本文将先从 Rolldown 的整体架构入手,帮你建立对其工作原理的全局认知,避免过早陷入细节而迷失方向。在此基础上,我们会深入本文的核心主题:模块加载器 —— 这是 Rolldown 扫描阶段的核心组件,我们将剖析其关键功能,以及支撑它运行的重要数据结构。

接下来,我们还会探讨依赖图,以及 Rolldown 采用的部分性能优化策略。尽管其中部分内容你可能此前有所接触,但结合上下文重新梳理仍有价值 —— 这些内容是理解 Rolldown 如何实现极致速度与效率的关键。

好了,让我们正式走进 Rolldown 的世界吧。😉

Rolldown 整体架构概览

Rolldown 的核心流程分为四个主要步骤:依赖图构建 → 优化 → 代码生成 / 打包 → 输出。最终生成的打包产物会根据场景(本地开发 / 生产构建)写入内存或文件系统。你可在 crates/rolldown/src/bundler.ts 路径下找到入口模块的实现。以下是该流程的示意图:

graph TD
start["Start: Read Config & Entry Points"]
parse["Parse Entry Module"]
build{"Build Module Graph"}
load["Load & Parse Dependency Modules"]
optimaze["Code optimization"]
generate["Code Generation: Generate\n Chunks"]
return["Return Output Assets In\n Memory"]
write["Write Output Files to Disk"]
start --> parse
parse --> build
build -->|Scan Dependencies|load
load -->|Repeat until all\n dependencies are processed|build
build --> optimaze
optimaze --> generate
generate -->|Generate Mode: rolldown.generate| return
generate -->|Write Mode: rolldown.write| write

模块加载器是构建模块依赖图阶段的核心组件,它由 Bundler 结构体中的 scan 函数触发调用。为实现更清晰的职责分离,整个扫描流程已被封装到专用的 ScanStage 结构体中。

但真正的核心工作都发生在 ModuleLoader(模块加载器)内部:它负责处理构建依赖图、解析单个模块等关键任务,也是 Rolldown 中大量核心计算逻辑的落地之处 —— 这正是本文要重点探讨的内容。

模块加载器(Module Loader)

简而言之,模块加载器的核心职责是定位、获取并解析单个模块(包括源码文件、CSS 文件等各类资源),并将这些模块转换为打包器能够识别和处理的内部数据结构。这一步骤是构建精准且高效的模块依赖图的关键。

以下示意图展示了 Rolldown 在打包流程中如何使用模块加载器:

graph TD
prepare["Bundler: Prepare needs to\n Build Module Graph"]
create["Create Module Loader\n Instance"]
calls["Bundler: Calls Module\n Loader's fetch_modules"]
load[["Module Loader Operation"]]
return["Module Loader: Returns\n Aggregated Results to\n Bundler"]
result["Bundler: Uses Results for\n Next Steps - e.g., Linking,\n Optimization, Code Gen"]
prepare --> create
create --> calls
calls --> load
load --> return
return --> result

上述所有步骤均发生在 ScanStage 结构体的 scan 函数内部。你可以将 scan 函数理解为一个编排器(builder) —— 它统筹并封装了运行模块加载器所需的全部逻辑。

拉取模块(Fetch modules)

fetch_modules 是整个流程的 “魔法起点”。它扮演着调度器(scheduler) 的角色,启动一系列异步任务来解析所有相关模块。该函数负责处理用户定义的入口点 —— 这也是模块扫描算法的起始位置。

在进入 fetch_modules 之前,scan 函数会先解析这些入口点,并将其转换为 Rolldown 内部的 ResolvedId 结构体。这一预处理步骤由 resolve_user_defined_entries 函数完成。

以下示意图展示了 fetch_modules 函数的核心工作流程:

graph TD
start["Start: Receive Resolved\n User Defined Entries"]
init["Initialize: Task Counter,\n Module Cache, Result\n Collectors"]
launch["Launch Async Tasks for Each\n Entry"]
loop{"Message Loop: Listen while\n Task Counter > 0"}
store["Store Results;\nProcess Dependencies"]
request["Launch Task for Requested\n Module; Increment Counter"]
resolve["Resolve & Launch Task for\n New Entry; Increment\n Counter"]
record["Record Error; Decrement\n Counter"]
depence{"New Dependencies?"}
tasks["Launch Tasks for New\n Dependencies; Increment\n Counter"]
dec["Decrement Task Counter"]
zero{"Task Counter == 0?"}
final["Finalize: Update\n Dependency Graph,\n Organize Results"]
return["End: Return Output to\n Caller"]
start --> init
init --> launch
launch --> loop
loop -->|Module Done| store
loop -->|Plugin Fetch Module| request
request --> loop
loop -->|Plugin Add Entry| resolve
resolve --> loop
loop -->|Build Error| record
record --> loop
store --> depence
depence -->|Yes| tasks
tasks --> loop
depence -->|No| dec
dec --> zero
zero -->|No| loop
zero -->|Yes| final
final --> return

看起来有点复杂,对吧?这是因为该阶段集成了大量优化策略和功能特性。不过别担心 —— 我们可以暂时跳过细枝末节,先聚焦整体流程。

如前文所述,fetch_modules 函数以解析后的用户定义入口点为输入,开始执行处理逻辑。对于每个入口点,它会调用 try_spawn_new_task 函数:该函数先判定模块属于内部模块还是外部模块,再执行对应的处理逻辑,最终返回一个类型安全的 ModuleIdx(模块索引) 。这个索引后续会作为整个系统中引用对应模块的唯一标识。

当所有入口点的初始任务都已启动后,fetch_modules 会进入循环,监听一个基于 tokio::sync::mpsc 实现的消息通道。每个模块处理任务都持有该通道的发送端句柄(sender handle),并向主进程上报事件。fetch_modules 内部的消息监听器会响应这些消息,具体包括以下类型:

  • 普通 / 运行时模块处理完成:存储处理结果,并调度该模块的所有依赖模块;
  • 拉取模块:响应插件的按需加载特定模块请求;
  • 添加入口模块:在扫描过程中新增入口点(通常由插件触发);
  • 构建错误:捕获加载或转换过程中出现的所有错误。

当所有模块处理完毕且无新消息传入时,消息通道会被关闭,循环随之退出。随后 fetch_modules 执行收尾清理工作:存储已处理的入口点、更新依赖图,并将聚合后的结果返回给调用方(即 scan 函数)。该结果包含模块、抽象语法树(AST)、符号、入口点、警告信息等核心数据 —— 这些都会被用于后续的优化和代码生成阶段。

启动新任务(Spawn new task)

try_spawn_new_task 函数首先尝试从模块加载器的缓存中获取 ModuleIdx(模块索引)。由于扫描阶段本质上是对依赖图的遍历过程,该缓存通过哈希映射表跟踪每个模块的访问状态 —— 其中键为模块 ID,值用于标识该模块是否已处理完成。

接下来,函数会根据模块类型,将其转换为普通模块或外部模块结构,以便进行后续处理。理解外部模块的处理逻辑尤为重要:这类模块不会被 Rolldown 打包 —— 它们预期由运行时环境提供(例如 node_modules 中的第三方库)。尽管不会被纳入最终打包产物,但 Rolldown 仍会记录其元数据,实际上是将其视为占位符(placeholder) 。打包产物会假定这些模块在运行时可用,并在需要时直接引用它们。

而普通模块(通常由用户编写)的处理方式则不同:try_spawn_new_task 会为每个普通模块创建一个专属的模块任务,并以异步方式执行。这些任务由 Rust 异步运行时 Tokio 管理。如前文所述,每个任务都持有消息通道的发送端,可在运行过程中上报错误、新发现的导入项,或动态添加的入口点。

数据结构(Data structures)

为提升性能和代码复用性,Rolldown 大量使用专用数据结构。理解模块加载器中几个核心数据结构的设计,能让你更清晰地认知扫描流程的底层实现逻辑。

ModuleIdx & HybridIndexVec

ModuleIdx 是一种自定义数值索引,会在模块处理过程中动态分配。这种索引设计兼顾类型安全与性能:Rolldown 不会传递或克隆完整的模块结构体,而是使用这种轻量级标识符(类似其他编程语言中的指针),在整个系统中引用模块。

pub struct ModuleIdx = u32;

HybridIndexVec 是 Rolldown 用于存储模块数据的智能自适应容器。由于 Rolldown 核心操作的对象是 ModuleIdx(模块索引)而非实际的模块数据,实现高效的 “基于 ID 查找” 就至关重要 —— 而这正是 HybridIndexVec 的设计初衷:它会针对不同的打包场景做针对性优化。

pub enum HybridIndexVec<I: Idx, T> {
  IndexVec(IndexVec<I, T>),
  Map(FxHashMap<I, T>),
}

打包工具通常运行在两种模式下:

  • 全量打包(Full bundling) (生产环境构建的主流模式):所有模块仅扫描一次,并以连续存储的方式保存。针对这种场景,Rolldown 采用名为 IndexVec紧凑高性能结构—— 它的行为类似向量(vector),但强制要求类型安全的索引访问。
  • 增量打包(Partial bundling) (常用于开发环境):模块依赖图可能频繁变化(例如开发者编辑文件时)。这种场景下,稀疏结构(sparse structure)更适用,Rolldown 会使用基于 FxHash 算法的哈希映射表,以实现高效的键值对访问。

FxHash 算法比 Rust 默认哈希算法更快,尽管其哈希冲突的概率略高。由于键和值均由 Rolldown 内部管理,且 “可预测的性能” 比安全性更重要,因此这种取舍对于 Rolldown 的使用场景而言是可接受的。

模块(Module)

普通模块由用户定义 —— 通常是需要解析、转换或分析的源码文件。Rolldown 会加载这些文件,并根据文件扩展名进行处理。例如,.ts(TypeScript)文件会通过高性能的 JavaScript/TypeScript 解析器 Oxc 完成解析。

pub enum Module {
  Normal(Box<NormalModule>),
  External(Box<ExternalModule>),
}

内部的 NormalModule 结构体存储着每个模块的详细信息:既包含 idx(索引)、module_type(模块类型)等基础元数据,也涵盖模块内容的富表示形式(richer representations) 。根据文件类型的不同,这些内容具体包括:

  • ecma_view:用于 JavaScript/TypeScript 模块
  • css_view:用于样式表文件
  • asset_view:用于静态资源文件

这种结构化设计,能让打包流程后续阶段(如优化、代码生成)高效处理已解析的模块内容。

ScanStageCache(扫描阶段缓存)

这是一个在模块加载过程中存储所有缓存数据的结构体。以下是该数据结构的定义:

pub struct ScanStageCache {
  snapshot: Option<NormalizedScanStageOutput>,
  pub module_id_to_idx: FxHashMap<ArcStr, VisitState>,
  pub importers: IndexVec<ModuleIdx, Vec<ImporterRecord>>,
}

snapshot(快照)存储着上一次扫描阶段的执行结果,用于支持增量构建。Rolldown 无需从头重新扫描所有模块,而是复用上次扫描的部分结果 —— 当仅有少量文件变更时,这一机制能大幅缩短构建耗时。

module_id_to_idx 是一个哈希映射表,存储模块 ID 与其访问状态的映射关系。程序可通过它快速判断某个模块是否已处理完成。

该映射表的键类型为 ArcStr—— 这是一种内存高效、支持引用计数的字符串类型,专为跨线程共享场景优化。更重要的是,这个字符串是模块的全局唯一且稳定的标识符,在多次构建过程中保持一致,这对缓存的可靠性至关重要。

importers 是模块依赖图的反向邻接表:针对每个模块,它会跟踪 “哪些其他模块导入了该模块”。这在增量构建中尤为实用:当某个模块内容变更时,importers 能帮助 Rolldown 快速确定受影响模块的范围 —— 本质上就是识别出需要重新处理的模块。

需注意,importers 还会有一个临时版本存储在 IntermediateNormalModules(中间普通模块)中。你可以将其理解为 “草稿状态”,会在当前构建过程中动态生成。

依赖图(Dependency graph)

依赖图描述了模块间的相互依赖关系,也是扫描阶段最重要的输出之一。Rolldown 会在后续阶段(如摇树优化、代码分块、代码生成)利用这份关系映射表完成各类核心任务。

在深入讲解具体实现前,我们先介绍邻接表的概念 —— 它是依赖图的表示与遍历的核心载体。

图与邻接表(Graph and Adjacency table)

众所周知,图是用于表示 “事物间关联关系” 的数据结构,由两部分组成:

  • 节点(Nodes):被关联的项或实体(对应 Rolldown 中的模块)
  • 边(Edges):节点之间的关联或依赖关系(对应模块间的导入导出关系)

图有两种常见的表示方式:邻接矩阵邻接表

邻接矩阵是一个二维网格(矩阵),每行和每列对应一个节点。矩阵中某个单元格的值表示两个节点之间是否存在边:例如,值为 1 表示存在关联,值为 0 则表示无关联。

  | A | B | C
A | 0 | 1 | 0
B | 1 | 0 | 1
C | 0 | 1 | 0

这种方式(邻接矩阵)简单直观,在稠密图场景下表现优异 —— 即大多数节点之间都存在关联的图。但对于稀疏图而言,它的内存利用率极低,而 Rolldown 这类打包工具中的模块依赖图恰好属于稀疏图。(相信没人会在项目里把所有模块都导入到每一个文件中吧。😉)

邻接表则是另一种存储方式:每个节点都维护一个 “邻居节点列表”。它不会使用固定大小的矩阵,而是只存储实际存在的关联关系,因此在稀疏图场景下效率更高。

举个例子:若节点 A 关联到节点 B,节点 B 关联到节点 A 和 C,最终节点 C 仅关联到节点 B。

A[B]
B[A, C]
C → [B]

这种结构(邻接表)内存利用率高,且能轻松适配大型稀疏图场景 —— 比如 Rolldown 这类打包工具所处理的模块依赖图。同时,它还能让程序仅遍历相关的关联关系,这一点在扫描或优化阶段尤为实用。

正向与反向依赖图(Forward & reverse dependency graph)

在扫描阶段,Rolldown 会构建两种类型的依赖图:正向依赖图和反向依赖图。其中,正向依赖图存储在每个模块的 ecma_view 中,记录当前模块所导入的其他模块。

pub struct ecma_view {
  pub import_records: IndexVec<ImportRecordIdx, ResolvedImportRecord>,
  // ...
}

正向依赖图对打包至关重要。模块加载器从用户定义的入口点出发,构建这张图来确定最终打包产物需要包含哪些模块。它在确定执行顺序管理变量作用域方面也扮演着关键角色。

此外,模块加载器还会创建一张反向依赖图,方便追踪哪些模块导入了指定模块。这对摇树优化(Tree Shaking)、副作用分析、增量构建、代码分块和代码分割等功能至关重要。

这些功能涉及大量上下文,这里就不展开细讲。你可以简单这样理解:如果我(某个模块)发生了变化,谁会受到影响? 答案是:所有依赖这个变更模块的模块都需要重新处理。这就是实现增量构建热模块替换(HMR) 的核心思想。

性能优化

Rolldown 底层包含大量性能优化手段。得益于 Rust 的零成本抽象所有权模型,再搭配 Tokio 强大的异步运行时,开发者拥有了将性能推向新高度的工具。模块加载器本身也运用了多种提速技术,这里我们简要介绍一下,大部分内容前面已经提到过。

异步并发处理

并发是模块加载器的核心。如前所述,它的主要职责是遍历所有模块并构建依赖图。在实际项目中,导入关系会迅速变得复杂且嵌套很深,这使得异步并发至关重要。

在 Rust 中,asyncawait 是异步函数的基础构建块。异步函数会返回一个 Future,它不会立即执行,只有在显式 await 时才会运行。Rolldown 基于 Rust 最主流的异步运行时 Tokio,高效并发地执行这些模块处理任务。

缓存

由于 Rolldown 会执行大量异步操作,并且在本地开发环境中会频繁重复运行,缓存就成了避免重复工作的关键。

模块加载器的缓存存放在 ModuleLoader 结构体内部,包含 snapshotmodule_id_to_idximporters 等数据,大部分我们在前面章节已经介绍过。这些缓存能帮助 Rolldown 避免重复处理相同模块,让增量构建速度大幅提升。

未来展望

Rolldown 仍在积极开发中。未来,它有望成为 Vite 的底层引擎,提供一致的构建结果极致的性能。你可以在这里查看路线图。

我写这篇文章是为了记录我研究 Rolldown 的过程,也希望能为你揭开它那些出色底层实现的神秘面纱。如果你发现错误或觉得有遗漏,欢迎在下方留言 —— 我非常期待你的反馈!😊

感谢阅读,我们下篇文章见!

从零开始学 React Hooks:useState 与 useEffect 核心解析

2026年2月15日 21:47

从零开始学 React Hooks:useState 与 useEffect 核心解析

作为 React 官方主推的语法,Hooks 让函数组件拥有了状态管理和生命周期的能力,彻底摆脱了类组件的繁琐语法,让 React 代码更贴近原生 JS。本文从纯函数与副作用的基础概念出发,由浅入深讲解useStateuseEffect两个核心 Hooks 的使用,适合 JS 初学者快速上手,所有案例均基于实战代码拆解,易懂易练。

一、前置基础:纯函数与副作用

在学习 Hooks 前,必须先理解纯函数副作用这两个核心概念,它们是 Hooks 设计的底层逻辑,也是 React 组件设计的重要原则。

1.1 纯函数

纯函数是相同输入始终返回相同输出,且无任何副作用的同步函数,这是纯函数的三大核心特征:

  1. 输入确定,输出确定:不会因外部变量、环境变化改变返回结果
  2. 无副作用:不修改函数外部的变量、不操作 DOM、不发起网络请求等
  3. 必须同步:不包含异步操作(异步会导致返回结果不确定)

纯函数示例

// 纯函数:输入x和y,输出固定的和,无任何外部影响
const add = (x, y) => x + y;
// React中useState的初始值计算函数也是纯函数
const getInitNum = () => {
  const a = 1 + 2;
  const b = 2 + 3;
  return a + b; // 输入固定,返回值永远是8
};

1.2 副作用

副作用是指函数执行过程中,对函数外部环境产生的一切影响,简单来说:非纯函数的操作,基本都是副作用

常见的副作用场景:

  • 修改函数外部的变量、数组、对象(如给数组 push 元素)
  • 发起网络请求(fetch/axios)、定时器 / 延时器(setTimeout/setInterval
  • 操作 DOM、本地存储(localStorage
  • 订阅 / 取消订阅事件

副作用示例

// 有副作用:修改了外部的nums2数组
function add(nums2) {
  nums2.push(3); // 改变外部变量,副作用
  return nums2.reduce((pre, cur) => pre + cur, 0);
}
const nums2 = [1, 2];
add(nums2);
console.log(nums2); // [1,2,3],原数组被修改

// 有副作用:包含网络请求(不确定操作)
const add2 = (x, y) => {
  fetch('https://www.baidu.com'); // 网络请求,副作用
  return x + y;
};

1.3 组件与纯函数的关系

React 函数组件的核心逻辑应该是纯函数:输入 props/state,输出固定的 JSX,不包含副作用。而所有的副作用操作,都需要交给专门的 Hooks 来处理(如useEffect),这是 React 的设计规范,能保证组件的可预测性和稳定性。

二、useState:让函数组件拥有响应式状态

useState是 React 最基础的 Hooks,作用是为函数组件添加响应式状态,并提供修改状态的方法。状态(state)就是组件中会变化的数据,也是组件的核心,状态变化时,组件会自动重新渲染,更新页面内容。

2.1 基本使用

语法

import { useState } from 'react';
// 解构赋值:state为当前状态值,setState为修改状态的方法
const [state, setState] = useState(initialValue);
  • initialValue:状态的初始值,可以是任意 JS 类型(数字、字符串、数组、对象等)
  • state:获取当前的状态值
  • setState:修改状态的方法,调用后会更新 state 并触发组件重新渲染

基础示例

import { useState } from 'react'
export default function App(){
  // 初始化数字状态,初始值为1
  const [num, setNum] = useState(1);
  return (
    // 点击div,修改num状态
    <div onClick={() => setNum(num + 1)}>
      当前数字:{num}
    </div>
  )
}

点击页面中的 div,数字会逐次加 1,页面自动更新,这就是响应式状态的核心效果。

2.2 高级用法 1:函数式初始化

如果状态的初始值需要复杂计算(如多个变量运算、循环处理),直接传值会导致每次组件渲染都重复计算,造成性能浪费。此时可以使用函数式初始化,该函数只会在组件首次挂载时执行一次,后续渲染不再执行。

语法

// 传入纯函数,返回值作为初始值
const [state, setState] = useState(() => {
  // 复杂的同步计算逻辑(纯函数,无异步、无副作用)
  return 计算后的初始值;
});

实战示例

import { useState } from 'react'
export default function App(){
  // 函数式初始化:仅首次挂载执行,计算初始值为8
  const [num, setNum] = useState(() => {
    const num1 = 1 + 2;
    const num2 = 2 + 3;
    return num1 + num2;
  });
  return (
    <div onClick={() => setNum(num + 1)}>
      初始值计算后:{num}
    </div>
  )
}

⚠️ 注意:初始化的函数必须是纯函数,不能包含异步操作(如setTimeout、网络请求),因为异步会导致初始值不确定,而 React 要求状态的初始值必须是确定的。

2.3 高级用法 2:函数式更新状态

修改状态时,setState不仅可以直接传入新值,还可以传入一个函数,该函数的参数是上一次的状态值,返回值为新的状态值。

适用场景:当新的状态值依赖于上一次的状态值时,推荐使用函数式更新,能避免因 React 状态更新的异步性导致的取值错误。

语法

setState(preState => {
  // preState:上一次的状态值(React自动传入)
  return 新的状态值;
});

实战示例

import { useState } from 'react'
export default function App(){
  const [num, setNum] = useState(1);
  return (
    // 函数式更新:preNum为上一次的num值
    <div onClick={() => setNum((preNum) => {
      console.log('上一次的数字:', preNum);
      return preNum + 1; // 返回新值
    })}>
      当前数字:{num}
    </div>
  )
}

点击 div 时,会先打印上一次的数字,再返回新值,确保状态更新的准确性。

2.4 核心注意点

  1. useState必须在函数组件的顶层调用,不能在 if、for、嵌套函数中使用(React 通过调用顺序识别 Hooks)
  2. setState异步操作,调用后不能立即获取到新的状态值
  3. 状态更新是不可变的:如果状态是对象 / 数组,不能直接修改原数据,需返回新的对象 / 数组(如setArr(pre => [...pre, newItem])

三、useEffect:处理组件的所有副作用

useEffect是 React 处理副作用的核心 Hooks,作用是在函数组件中执行副作用操作,同时它还能模拟类组件的生命周期(如挂载、更新、卸载),让函数组件拥有了生命周期的能力。

3.1 基本概念

  • useEffect的直译是副作用效果,专门用来包裹组件中的所有副作用代码
  • 组件的核心逻辑(纯函数)负责渲染 JSX,副作用逻辑(请求、定时器、DOM 操作)全部放在useEffect
  • useEffect接收两个参数:副作用函数依赖项数组

3.2 基本语法

import { useEffect } from 'react';
useEffect(() => {
  // 副作用函数:执行所有副作用操作(请求、定时器、DOM操作等)
  // 可选:返回一个清理函数
  return () => {
    // 清理函数:清除副作用(如清除定时器、取消订阅、关闭请求)
  };
}, [deps]); // 依赖项数组:控制useEffect的执行时机

3.3 三种使用场景(核心)

useEffect的执行时机完全由第二个参数(依赖项数组) 控制,分为三种核心场景,对应组件的不同生命周期阶段,这是useEffect的重点,一定要掌握!

场景 1:无依赖项数组 → 每次渲染都执行
useEffect(() => {
  console.log('每次渲染/更新都会执行');
});
  • 组件首次挂载时执行一次
  • 组件每次状态更新 / 重新渲染时都会再次执行
  • 适用场景:需要实时响应组件所有变化的副作用(较少使用,注意性能)
场景 2:空依赖项数组 [] → 仅组件挂载时执行一次
useEffect(() => {
  console.log('仅挂载时执行,模拟onMounted');
  // 示例:挂载时发起异步请求
  queryData().then(data => setNum(data));
}, []);
  • 仅在组件首次挂载到 DOM时执行一次,后续无论状态如何更新,都不会再执行
  • 对应类组件的componentDidMount生命周期,是最常用的场景
  • 适用场景:初始化请求数据、初始化定时器、添加全局事件监听等
场景 3:有依赖项的数组 [state1, state2] → 依赖项变化时执行
const [num, setNum] = useState(0);
useEffect(() => {
  console.log('num变化时执行', num);
}, [num]); // 依赖项为num
  • 组件首次挂载时执行一次
  • 只有当依赖项数组中的值发生变化时,才会再次执行
  • 对应类组件的componentDidUpdate生命周期
  • 适用场景:依赖某个 / 某些状态的副作用(如状态变化时更新定时器、重新请求数据)

3.4 清理函数:清除副作用(避免内存泄漏)

useEffect的副作用函数可以返回一个清理函数,这是 React 的重要设计,用于清除副作用,避免内存泄漏。

清理函数的执行时机
  1. 当组件重新渲染,且useEffect即将再次执行时,先执行上一次的清理函数
  2. 当组件从 DOM 中卸载时,执行清理函数
核心使用场景:清除定时器 / 延时器

定时器是最常见的副作用,如果不及时清除,组件卸载后定时器仍会运行,导致内存泄漏,useEffect的清理函数完美解决这个问题。

实战示例

import { useState, useEffect } from 'react'
export default function App() {
  const [num, setNum] = useState(0);
  useEffect(() => {
    console.log('num更新,创建新定时器');
    // 创建定时器:每秒打印当前num
    const timer = setInterval(() => {
      console.log(num);
    }, 1000);
    // 返回清理函数:清除上一次的定时器
    return () => {
      console.log('清除定时器');
      clearInterval(timer);
    };
  }, [num]); // 依赖num,num变化时执行

  return (
    <div onClick={() => setNum(pre => pre + 1)}>
      点击修改num:{num}
    </div>
  )
}

执行效果

  1. 组件挂载时,创建定时器,每秒打印 num
  2. 点击 div 修改 num,useEffect先执行清理函数清除旧定时器,再创建新定时器
  3. 组件卸载时,执行清理函数清除定时器,避免内存泄漏
其他清理场景
  • 取消网络请求(如 AbortController)
  • 移除全局事件监听(如window.removeEventListener
  • 取消订阅(如 Redux 订阅、WebSocket 订阅)

3.5 实战:结合 useEffect 实现异步请求初始化数据

前面提到,useState的函数式初始化不支持异步,因此组件挂载时的异步请求数据,需要结合useEffect(空依赖)实现,这是项目中的高频用法。

实战示例

import { useState, useEffect } from 'react'
// 模拟异步请求接口
async function queryData() {
  const data = await new Promise(resolve => {
    setTimeout(() => {
      resolve(666); // 模拟接口返回数据
    }, 2000);
  });
  return data;
}
export default function App() {
  const [num, setNum] = useState(0);
  // 空依赖:仅挂载时请求数据
  useEffect(() => {
    queryData().then(data => {
      setNum(data); // 请求成功后修改状态,更新页面
    });
  }, []);

  return <div>接口返回数据:{num}</div>;
}

组件挂载后,发起异步请求,请求成功后修改num状态,页面自动更新为接口返回的 666。

四、Hooks 的通用使用规则

除了useStateuseEffect,React 所有的 Hooks(包括自定义 Hooks)都遵循以下两条核心规则,这是 React 官方强制要求的,违反会导致组件运行异常:

4.1 只能在函数组件 / 自定义 Hooks 中调用

Hooks 只能在React 函数组件的顶层,或者自定义 Hooks中调用,不能在普通 JS 函数、类组件中使用。

4.2 只能在顶层调用,不能嵌套

Hooks 不能在 if、for、while、嵌套函数(如 useEffect 的副作用函数)中调用,必须在函数组件的顶层作用域调用。因为 React 通过调用顺序来识别和管理每个 Hooks 的状态,如果嵌套调用,会导致调用顺序混乱,Hooks 状态失效。

五、实战综合案例:条件渲染 + 副作用清理

结合useState的状态管理、useEffect的副作用处理、React 的条件渲染,实现一个完整的小案例,覆盖本文所有核心知识点:

  1. 点击页面修改数字状态,数字为偶数时渲染Demo组件,奇数时卸载
  2. Demo组件挂载时创建定时器,卸载时清除定时器
  3. 主组件的数字变化时,更新定时器并实时打印

主组件 App.jsx

import { useState, useEffect } from 'react'
import Demo from './Demo';
export default function App() {
  const [num, setNum] = useState(0);
  // 依赖num的副作用,处理定时器
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('当前num:', num);
    }, 1000);
    return () => clearInterval(timer);
  }, [num]);

  // 条件渲染:num为偶数时渲染Demo组件
  return (
    <div onClick={() => setNum(pre => pre + 1)} style={{ fontSize: '24px' }}>
      点击修改数字:{num}
      {num % 2 === 0 && <Demo />}
    </div>
  )
}

子组件 Demo.jsx

import { useEffect } from 'react'
export default function Demo() {
  // 空依赖:仅挂载时创建定时器,卸载时清除
  useEffect(()=>{
      console.log('Demo组件挂载');
      const timer=setInterval(()=>{
          console.log('Demo组件的定时器');
      },1000)
      // 组件卸载时执行,清除定时器
      return ()=>{
          console.log('Demo组件卸载,清除定时器');
          clearInterval(timer)
      }
  },[])
  return <div style={{ marginTop: '20px' }}>我是偶数时显示的Demo组件</div>
}

案例效果

  1. 初始 num=0(偶数),渲染 Demo 组件,Demo 挂载并创建定时器
  2. 点击一次 num=1(奇数),卸载 Demo 组件,执行 Demo 的清理函数清除定时器
  3. 每次点击修改 num,主组件的useEffect都会先清除旧定时器,再创建新定时器
  4. 组件卸载时,所有定时器都会被清除,无内存泄漏

六、总结

本文从基础的纯函数与副作用出发,讲解了 React 中最核心的两个 Hooks,核心知识点总结如下:

  1. 纯函数:相同输入返回相同输出,无副作用、同步执行;副作用:修改外部变量、请求、定时器等对外部环境的操作
  2. useState:为函数组件添加响应式状态,支持函数式初始化(复杂计算)和函数式更新(依赖上一次状态)
  3. useEffect:处理所有副作用,通过依赖项数组控制执行时机,返回清理函数清除副作用,避免内存泄漏
  4. Hooks 通用规则:仅在函数组件 / 自定义 Hooks 的顶层调用
  5. 异步请求初始化数据:使用useEffect空依赖实现,而非useState的初始化函数

useStateuseEffect是 React Hooks 的基础,掌握这两个 Hooks,就能实现大部分函数组件的开发需求。后续可以继续学习useRefuseContextuseReducer等进阶 Hooks,以及自定义 Hooks 的封装,让 React 代码更简洁、更高效。

最后:建议大家跟着本文的案例手动敲一遍代码,体会状态更新和副作用执行的时机,只有实战才能真正掌握 Hooks 的核心逻辑!

Elpis NPM 包抽离过程

作者 温言winslow
2026年2月15日 21:25

Elpis NPM 包抽离过程 - 难点与卡点分析

背景

将 elpis 从一个独立运行的应用抽离成可被其他项目引用的 npm 包(@shhhwm/elpis)。


难点一:路径解析的根本性变化

问题描述

独立应用时,process.cwd()__dirname 指向同一个项目根目录。但作为 npm 包被引用后:

  • process.cwd() → 业务项目根目录
  • __dirname → node_modules/@shhhwm/elpis/xxx

解决方案

所有 loader 都需要区分两套路径:

// elpis 框架自身的路径(使用 __dirname)
const elpisControllerPath = path.resolve(__dirname, `..${sep}..${sep}app${sep}controller`);

// 业务项目的路径(使用 process.cwd())
const businessControllerPath = path.resolve(app.businessPath, `.${sep}controller`);

难点二:Webpack Loader 找不到

问题描述

作为 npm 包时,webpack 配置中直接写 loader: "vue-loader" 会报错找不到 loader,因为 webpack 默认从业务项目的 node_modules 查找。

解决方案

所有 loader 配置改用 require.resolve() 确保从 elpis 包内解析:

// 错误写法
use: "vue-loader"

// 正确写法
use: require.resolve("vue-loader")

同理,样式相关 loader 也需要处理:

use: [
  require.resolve("style-loader"),
  require.resolve("css-loader"),
  require.resolve("less-loader"),
]

难点三:双层加载机制设计

问题描述

框架需要同时加载自身的 controller/service/router 和业务项目的,且业务项目的可以覆盖框架的。

解决方案

所有 loader 改造为"先加载 elpis,再加载业务"的模式:

// 1. 先加载 elpis 框架的 controller
const elpisFileList = glob.sync(path.resolve(elpisControllerPath, `.${sep}**${sep}**.js`));
elpisFileList.forEach((file) => handleFile(file));

// 2. 再加载业务项目的 controller(可覆盖同名)
const businessFileList = glob.sync(path.resolve(businessControllerPath, `.${sep}**${sep}**.js`));
businessFileList.forEach((file) => handleFile(file));

config 加载也是类似逻辑,业务配置覆盖框架默认配置:

defaultConfig = {
  ...elpisDefaultConfig,
  ...businessConfig,
};

难点四:前端钩子扩展机制

问题描述

框架提供了 dashboard、schema-form、schema-table 等通用页面,但业务项目需要能够:

  1. 扩展路由
  2. 扩展自定义组件
  3. 扩展表单项类型

如何在编译时动态判断业务项目是否提供了扩展配置?

解决方案

引入 blank.js 空模块 + webpack alias 动态判断:

// blank.js - 空模块作为默认值
module.exports = {};

webpack alias 配置中动态判断:

const blankModulePath = path.resolve(__dirname, "../libs/blank.js");

// 判断业务项目是否提供了路由扩展配置
const businessDashboardRouterConfig = path.resolve(
  process.cwd(),
  "./app/pages/dashboard/router.js"
);
aliasMap["$businessDashboardRouterConfig"] = fs.existsSync(businessDashboardRouterConfig)
  ? businessDashboardRouterConfig
  : blankModulePath;

业务代码中使用:

import businessDashboardRouterConfig from "$businessDashboardRouterConfig";

// 如果业务项目提供了扩展,则执行
if (typeof businessDashboardRouterConfig === "function") {
  businessDashboardRouterConfig({ routes, siderRoutes });
}

支持的扩展点:

  • $businessDashboardRouterConfig - 路由扩展
  • $businessComponentConfig - schema-view 组件扩展
  • $businessFormItemConfig - schema-form 表单项扩展
  • $businessSearchItemConfig - schema-search-bar 搜索项扩展

难点五:依赖分类调整

问题描述

npm 包被安装时,devDependencies 不会被安装。但 webpack、babel、loader 等构建依赖在运行时是必需的。

解决方案

将构建相关依赖从 devDependencies 移到 dependencies

{
  "dependencies": {
    "webpack": "^5.88.1",
    "webpack-cli": "^5.1.4",
    "vue-loader": "^17.2.2",
    "babel-loader": "^8.0.4",
    "css-loader": "^0.23.1",
    "less-loader": "^11.1.3",
    // ... 其他构建依赖
  },
  "devDependencies": {
    // 只保留开发/测试工具
    "eslint": "^7.32.0",
    "mocha": "^6.1.4"
  }
}

难点六:中间件分层加载

问题描述

框架有自己的全局中间件,业务项目也可能有自己的中间件,需要确保加载顺序正确。

解决方案

在 elpis-core 中分两步加载:

// 1. 先注册 elpis 框架的全局中间件
const elpisMiddleware = require(`${elpisPath}/app/middleware.js`);
elpisMiddleware(app);

// 2. 再注册业务项目的全局中间件
try {
  require(`${app.businessPath}/middleware.js`)(app);
} catch (e) {
  console.log("[exception] there is no global business middleware file.");
}

难点七:入口改造为 SDK 模式

问题描述

原来是直接启动应用,现在需要导出 API 供业务项目调用。

解决方案

// 之前:直接执行
ElpisCore.start({ name: 'Elpis' });

// 之后:导出 SDK 接口
module.exports = {
  Controller: { Base: require("./app/controller/base.js") },
  Service: { Base: require("./app/service/base.js") },

  frontendBuild(env) {
    if (env === "local") FEBuildDev();
    else if (env === "production") FEBuildProd();
  },

  serverStart(options) {
    return ElpisCore.start(options);
  }
};

webpack 构建脚本也从"直接执行"改为"导出函数":

// 之前
const compiler = webpack(webpackConfig);
app.listen(port);

// 之后
module.exports = () => {
  const compiler = webpack(webpackConfig);
  app.listen(port);
};

总结

难点 核心问题 解决思路
路径解析 cwd vs dirname 语义变化 区分 elpis 路径和业务路径
Loader 找不到 webpack 默认从业务项目查找 使用 require.resolve()
双层加载 框架和业务代码需要合并 先加载框架,再加载业务
钩子扩展 编译时动态判断扩展配置 blank.js + webpack alias
依赖分类 devDeps 不会被安装 构建依赖移到 dependencies
中间件分层 加载顺序问题 框架中间件先于业务中间件
SDK 模式 从应用变为库 导出函数而非直接执行

AI Agent开发之向量检索:一篇讲清「稀疏 + 稠密 + Hybrid Search」怎么落地

2026年2月15日 20:02

AI Agent开发之向量检索:一篇讲清「稀疏 + 稠密 + Hybrid Search」怎么落地

核心结论

在 AI 搜索和知识库场景中,混合检索(Hybrid Search)是当前最优解:

  • 稠密向量(Dense):擅长处理语义相似的查询,能够理解同义词、口语化表达
  • 稀疏向量(Sparse):擅长精确匹配关键词,如产品名称、接口名、错误码等专有术语
  • 混合检索(Hybrid):通过 RRF(Reciprocal Rank Fusion)算法融合两者优势,在生产环境中表现最稳定

单独使用稠密向量会导致专有名词召回不准确,而仅使用稀疏向量则无法理解语义相近的不同表述。混合检索能够同时规避这两个问题。

应用场景与痛点分析

典型应用场景

混合检索方案特别适用于以下前端场景:

  • 站内搜索:用户使用自然语言或关键词检索站内内容
  • 帮助中心问答:智能匹配用户问题与知识库文档
  • 聊天助手上下文召回:为 AI 助手提供相关上下文信息

单一检索方案的局限性

仅使用稠密向量检索时的问题:

  • 专有名词召回不稳定:如 "ERR_CONNECTION_RESET" 等错误码可能无法准确匹配
  • 短查询偏移:当用户输入 2~6 个词的短查询时,容易产生语义偏移

仅使用 BM25/关键词检索时的问题:

  • 语义理解缺失:"登录失败" 和 "无法完成认证" 虽然语义相近,但因关键词不同导致召回效果差

技术架构

flowchart LR
  A["文档内容"] --> B["生成稠密向量<br/>Embedding"]
  A --> C["生成稀疏向量<br/>分词+词频"]
  B --> D["写入向量库(dense)"]
  C --> E["写入向量库(sparse)"]

  Q["用户Query"] --> Q1["Query Embedding"]
  Q --> Q2["Query Sparse Vector"]

  Q1 --> S1["Dense Search"]
  Q2 --> S2["Sparse Search"]
  S1 --> F["RRF 融合排序"]
  S2 --> F
  F --> R["TopK 返回前端"]

稀疏向量与稠密向量的本质区别

稀疏向量(Sparse)

  • 来源:分词 + 词频(TF),可叠加 IDF/BM25
  • 特征:高维稀疏(大部分是 0)
  • 长处:关键词强匹配、可解释

示例文本:我是好学生,每天8点起床

分词后:

["我", "是", "好", "学生", "每天", "8", "点", "起床"]

稀疏结构(示意):

{
  indices: [102, 1552, 30091],
  values: [1, 1, 1]
}

稠密向量(Dense)

  • 来源:Embedding 模型(如 text-embedding-3-small
  • 特征:低维连续浮点向量
  • 长处:语义理解强(能懂同义改写)

核心实现

以下代码采用通用写法,不依赖特定项目结构,可直接迁移到任意 TypeScript 项目中

文档入库:双向量写入策略

async function addDocument(content: string, metadata?: Record<string, any>) {
  const dense = await embedText(content) // number[]
  const sparse = textToSparseVector(content) // { indices, values }

  await qdrant.upsert('documents', {
    points: [
      {
        id: crypto.randomUUID(),
        vector: {
          dense,
          bm25: sparse,
        },
        payload: { content, metadata },
      },
    ],
  })
}

稀疏向量生成:分词 + 哈希 + 词频统计

import { createRequire } from 'node:module'
import { Jieba } from '@node-rs/jieba'

type SparseVector = { indices: number[]; values: number[] }

const require = createRequire(import.meta.url)
const { dict } = require('@node-rs/jieba/dict') as { dict: Uint8Array }
const jieba = Jieba.withDict(dict)

function fnv1aHash(str: string): number {
  let hash = 0x811c9dc5
  for (let i = 0; i < str.length; i++) {
    hash ^= str.charCodeAt(i)
    hash = Math.imul(hash, 0x01000193)
  }
  return hash >>> 0
}

function textToSparseVector(text: string): SparseVector {
  const tokens = jieba
    .cutForSearch(text, true)
    .map((t) => t.trim().toLowerCase())
    .filter(Boolean)
    .filter((t) => !/^[\p{P}\p{S}\p{Z}]+$/u.test(t))

  const tf = new Map<number, number>()
  for (const token of tokens) {
    const idx = fnv1aHash(token)
    tf.set(idx, (tf.get(idx) ?? 0) + 1)
  }

  const entries = [...tf.entries()].sort((a, b) => a[0] - b[0])
  return {
    indices: entries.map(([i]) => i),
    values: entries.map(([, v]) => v),
  }
}

向量数据库配置:双向量索引声明

await qdrant.createCollection('documents', {
  vectors: {
    dense: { size: 512, distance: 'Cosine' },
  },
  sparse_vectors: {
    bm25: { modifier: 'idf' },
  },
})

说明:

  • 稀疏向量通常先传 TF(词频)
  • IDF 在向量库侧处理(这里是 modifier: 'idf'

查询实现:三种检索模式

type SearchMode = 'dense' | 'sparse' | 'hybrid'

async function search(query: string, topK = 5, mode: SearchMode = 'hybrid') {
  const querySparse = textToSparseVector(query)
  const queryDense = mode === 'sparse' ? null : await embedText(query)

  if (mode === 'dense') return searchDense(queryDense!, topK)
  if (mode === 'sparse') return searchSparse(querySparse, topK)

  const [denseRes, sparseRes] = await Promise.all([
    searchDense(queryDense!, topK),
    searchSparse(querySparse, topK),
  ])
  return fuseByRRF(denseRes, sparseRes, topK)
}

RRF 融合算法:工程化的最佳选择

const RRF_K = 60
const rrf = (rank: number) => 1 / (RRF_K + rank + 1)

RRF(Reciprocal Rank Fusion)算法的核心思想是基于排名而非分数进行融合。当同一文档在稠密检索和稀疏检索的结果中排名都靠前时,其最终融合分数会更高。

相比于传统的加权融合方法,RRF 的优势在于:

  • 无需手动调整稠密向量和稀疏向量的权重比例
  • 对不同业务场景的适应性更强
  • 实现简单且效果稳定

实施路径

基于上述技术方案,完整的实施流程包括以下步骤:

  1. 选择 Embedding 模型:初期可选择 512 维的轻量级模型,平衡性能与成本
  2. 实现双向量生成:在文本入库时同时生成稠密向量和稀疏向量
  3. 配置向量数据库:创建包含 vectorssparse_vectors 的集合
  4. 实现混合检索:搜索接口默认使用 hybrid 模式
  5. 提供模式切换:为前端提供检索模式切换能力,支持关键词优先场景(sparse 模式)

常见问题与最佳实践

稀疏查询向量为空的处理

当查询文本全是标点符号或停用词时,稀疏向量可能为空。此时应返回空数组或降级到纯稠密检索,避免出现异常。

稠密向量的必要性检查

在稠密检索分支中,必须确保 embedding 已成功生成。空向量应直接抛出错误,而非静默返回不可靠的结果。

向量维度变更与数据迁移

当 embedding 模型的向量维度发生变化(如从 512 维升级到 1536 维)时,现有的向量集合通常无法直接复用,需要重新生成所有文档的向量并迁移数据。

中文分词词典的重要性

业务专有术语如果未被包含在分词词典中,会显著影响稀疏向量的召回效果。建议根据业务场景定制分词词典,加入领域特定术语。

总结

混合检索方案将向量检索技术从算法研究转化为可落地的搜索体验工程实践:

  • 稠密向量负责语义理解,解决同义词和口语化表达问题
  • 稀疏向量负责关键词精确匹配,确保专有名词召回准确性
  • 混合检索通过 RRF 算法融合两者优势,保证生产环境的稳定性

《对象与解构赋值:接口数据解包的 10 个常见写法》

作者 SuperEugene
2026年2月15日 19:14

前言

后台接口返回的数据,常常是嵌套对象或数组,很多人习惯一层层 data.user.name 这样写,既啰嗦又容易在某一层是 undefined 时直接报错。
解构赋值 + 默认值,可以把取数写得又短又安全。本文用 10 个常见写法,帮你把「接口数据解包」这件事理清楚。

适合读者:

  • 已经会写 JS,但对解构、默认值组合用法不熟
  • 刚学 JS,希望一开始就养成规范写法
  • 有一定经验,想统一团队里的接口数据处理方式

一、先搞清楚:解构在干什么

解构不是黑魔法,本质是按结构从对象/数组中「拆包」出变量,语法更短,逻辑更直观。

// 传统写法:手动挨个取值
const user = { name: '张三', age: 28, city: '北京' };
const name = user.name;
const age = user.age;

// 解构写法:一次性拆出来
const { name, age } = user;

如果接口返回的 user 某天变成 null,传统写法会在 user.name 直接报错,解构可以配合默认值一起用,后面会展开。

二、接口数据解包的 10 个常见写法

假设后台返回结构类似:

{
  code: 200,
  data: {
    user: {
      id: 1,
      name: '李四',
      profile: {
        avatar: 'https://xxx/avatar.png',
        bio: '前端工程师'
      }
    },
    list: [
      { id: 1, title: '文章1' },
      { id: 2, title: '文章2' }
    ]
  }
}

下面 10 个写法,都是日常会用到的。

写法 1:只解构第一层,其余用 rest 收走

const { user, list, ...rest } = response.data;
// user、list 单独用,其他字段在 rest 里

适用: 只需要其中几个字段,但不想丢掉其他字段。
注意: rest 不会包含已解构的 userlist

写法 2:解构 + 默认值,防止 undefined

const { user = {}, list = [] } = response.data || {};

适用: 接口可能返回 datanullundefined,或字段缺失。
注意: 默认值只在值为 undefined 时生效,null 不会触发默认值。

写法 3:多层嵌套一次解构

const { user: { profile: { avatar, bio } = {} } = {} } = response.data || {};

适用: 需要深层字段,不想写 data.user.profile.avatar
踩坑: 每一层都要给默认值 = {},否则中间某层是 undefined 会报错。

写法 4:解构时重命名,避免变量冲突

const { user: currentUser, list: articleList } = response.data || {};

适用: 接口字段名不直观,或和已有变量重名。
语法: 原属性名: 新变量名

写法 5:解构 + 默认值 + 重命名一起用

const { user: currentUser = {}, list: articleList = [] } = response.data || {};

适用: 既要改名,又要防缺。
推荐: 作为接口数据解包的常规写法,可读性和安全性都较好。

写法 6:数组解构取首项

const [firstItem] = response.data?.list || [];

适用: 列表只关心第一项(例如「最新一条」)。
注意: 用可选链 ?.|| [] 避免 listnull/undefined 时报错。

ps· 如果你不知道可选链请点击这里,一文让你轻松了解

写法 7:解构数组元素并设默认值

const [first = {}, second = {}] = response.data?.list || [];

适用: 需要前几项,且要保证拿到的一定是对象。
注意: 空数组时 firstsecond 都是 {}

写法 8:在 map 中解构,简化遍历

const titles = (response.data?.list || []).map(({ id, title }) => title);

适用: 列表只需部分字段,不想写 item.iditem.title
好处: 代码短,意图清晰。

写法 9:解构函数参数,配合默认值

function renderUser({ name = '游客', avatar = '/default.png' } = {}) {
  // 函数内部直接用 name、avatar
}
renderUser(response.data?.user); // 即使传入 undefined 也不报错

适用: 组件、工具函数接收配置对象时。
双重默认值:

  • = {}:整个参数缺失时
  • name = '游客'name 缺失时

写法 10:安全取出深层字段的「一层层解构」写法

const { data } = response || {};
const { user } = data || {};
const { profile } = user || {};
const { avatar } = profile || {};

// 或者一行(每层都要默认值)
const avatar = ((response || {}).data || {}).user?.profile?.avatar ?? '默认头像';

适用: 接口结构不稳定,或经常变更。
建议: 优先用可选链 ?. 和空值合并 ??,逻辑更简洁。

三、容易踩的坑

1. 默认值只对 undefined 生效

const { name = '默认' } = { name: null };
// name 是 null,不是 '默认'

需要兼容 null 时,用空值合并运算符 ??

const name = (obj.name ?? '默认');

2. 嵌套解构少了中间层的默认值

const { user: { profile } } = response.data;  // 若 user 为 undefined,直接报错
const { user: { profile } = {} } = response.data;  // 依然可能报错,user 本身可能 undefined
const { user: { profile } = {} } = response.data || {};  // 正确:两层都要有兜底

3. 解构赋值和变量声明混在一起

const obj = { a: 10 };

// ✅ 正确:声明+解构一步完成({}是声明语法的一部分,解析器认解构)
let {a} = obj; 

let b;
// {b} = obj; // ❌ 报错:语句开头的{}被解析为“块级作用域”,而非解构
({b} = obj);  // ✅ 正确:括号让{}变成表达式,解析器认解构

4. 把 rest 用在已解构过的属性上

const obj = { a: 1, b: 2, c: 3 };
// 解构:单独取出a,剩余属性打包到rest
const { a, ...rest } = obj;

console.log(a);    // 输出:1(单独提取的a)
console.log(rest); // 输出:{ b: 2, c: 3 }(rest不含已解构的a)

四、实战推荐写法模板

通用接口解包:

const response = {
  code: 200,
  msg: "请求成功",
  data: {
    user: {
      name: "张三",
      age: 25,
      profile: {
        avatar: "https://example.com/avatar.jpg"
      }
    },
    list: [
      { id: 1, title: "文章1", content: "内容1" },
      { id: 2, title: "文章2", content: "内容2" }
    ]
  }
};

// 1. 最外层兜底:避免response/null/undefined导致解构报错
const { data = {} } = response || {};
// 2. 解构data层:给user/List设默认值,避免属性不存在
const { user = {}, list = [] } = data;

// 3. 深层解构user:给profile兜底,避免profile为undefined时报错
const { name, profile: { avatar } = {} } = user;

// 4. 列表解构:只提取需要的id/title,过滤无用字段
const items = list.map(({ id, title }) => ({ id, title }));

// 输出结果(验证解构效果)
console.log(name);   // 张三
console.log(avatar); // https://example.com/avatar.jpg
console.log(items);  // [{id:1,title:"文章1"}, {id:2,title:"文章2"}]

封装成工具函数:

function parseUserResponse(response) {
  const { data: { user = {} } = {} } = response || {};
  const { name = '未知', profile: { avatar = '/default.png' } = {} } = user;
  return { name, avatar };
}

五、小结

场景 推荐写法
防缺 const { a = {} } = obj || {}
嵌套解构 每一层都写 = {} 兜底
需要改名 const { a: newName } = obj
取列表首项 const [first] = list || []
列表 map list.map(({ id, title }) => ...)
函数参数 ({ a = 1 } = {}) 双重默认值

记住一点:解构是语法糖,默认值是兜底,把两者结合起来,接口数据处理会干净很多,也更容易排查问题。


以上就是本次的学习分享,欢迎大家在评论区讨论指正,与大家共勉。

我是 Eugene,你的电子学友。

如果文章对你有帮助,别忘了点赞、收藏、加关注,你的认可是我持续输出的最大动力~

用 ASCII 草图 + AI 快速生成前端代码

作者 风象南
2026年2月16日 08:05

引言

从想法到代码,中间往往要经历画原型、出设计稿等环节。

用 ASCII 草图,可以跳过大量原型绘制、结构拆解和手动搭骨架的中间步骤。

这种表达方式其实一直存在,但真正让它进入工程流程的,是 AI 的能力提升。大语言模型对结构化文本具有很强的解析能力,能够识别文本中的层级、对齐关系与空间划分,并将这些结构信息稳定地映射为组件树和页面布局。

因此,ASCII 不再只是沟通草稿,而成为一种可执行的结构描述。

什么是 "ASCII 草图"

提到 ASCII,很多人的第一反应可能是那个年代久远的“字符画”。没错,ASCII 草图就是用字符来构建页面布局。

在 AI 时代,这种看似简陋的草图,其实蕴含着巨大的能量。大语言模型(LLM)对结构化文本的理解能力极强。相比于模糊的自然语言描述(“我要一个左边宽右边窄的布局”),ASCII 草图提供了一种所见即所得的结构化 Prompt

简单来说,ASCII 草图充当了视觉蓝图的角色,AI 根据这个结构生成代码。

为什么要让 AI 先生成 ASCII 草图 ?

你可能会想:直接让 AI 生成代码不就行了吗?为什么要中间多这一步?

这就涉及到一个沟通精度的问题。

直接描述布局的问题

用自然语言描述布局,很容易产生歧义。比如你说"左边放导航,右边放内容",AI 可能会理解成左右各占 50%,而你想要的是导航 200px 宽度。你说"卡片要突出一点",AI 理解的"突出"可能是加阴影,而你想要的是加大字号。

这些细节上的偏差,会导致生成出来的代码需要反复调整。

ASCII 草图作为中间层的价值

让 AI 先生成 ASCII 草图,相当于在需求和代码之间加了一个可视化确认步骤

结构一目了然:ASCII 图能直观展示层级关系、组件位置、相对大小,比自然语言描述更精确

快速迭代:草图不对,让 AI 改几句就行,比改代码快得多

专注布局:这一步只讨论结构,不涉及样式细节,避免过早陷入细节纠结

简单来说,ASCII 草图充当了视觉蓝图的角色——先确认布局结构没问题,再让 AI 填充代码实现。

实战演练:三步构建一个 Dashboard

光说不练假把式。假设我们要开发一个常见的后台管理系统 Dashboard,包含顶部导航、侧边栏、数据统计卡片和图表区域。

第一步:描述需求,让 AI 生成草图

你只需要用自然语言描述布局结构,让 AI 来生成 ASCII 草图。

Prompt 示例

> 我需要一个后台管理系统的 Dashboard 布局,包含:
> - 顶部导航栏:左侧是 LOGO,中间是导航菜单,右侧是用户头像
> - 左侧边栏:垂直排列的导航菜单项
> - 主内容区:
>   - 标题"Dashboard Overview"
>   - 三个横向排列的统计卡片:Users、Revenue、Orders
>   - 下方是一个大的区域图表
>
> 请帮我生成对应的 ASCII 布局草图。

AI 会输出类似这样的草图:

+-------------------------------------------------------+
|  LOGO    [ Home ] [ Dashboard ] [ Settings ]   [User] |
+-------------------------------------------------------+
|        |                                              |
| Menu   |  Dashboard Overview                         |
|        |                                              |
| [Nav1] |  +----------+  +----------+  +----------+   |
| [Nav2] |  | Users    |  | Revenue  |  | Orders   |   |
| [Nav3] |  |  1,234   |  |  $12,000 |  |  567     |   |
|        |  +----------+  +----------+  +----------+   |
|        |                                              |
|        |  +----------------------------------------+  |
|        |  |                                        |  |
|        |  |         Revenue Chart (Area)           |  |
|        |  |                                        |  |
|        |  +----------------------------------------+  |
+--------+----------------------------------------------+

这一步的核心是让 AI 帮你理清布局结构,而不是自己手工画图。

第二步:让 AI 根据草图生成代码

草图确认无误后,让 AI 基于这个结构生成实际代码。 Prompt 模板建议

> **角色设定**:你是一位精通现代前端架构的高级工程师。
> **任务**:请根据我提供的 ASCII 布局草图,生成对应的前端代码。
> **技术栈**:React + Tailwind CSS (或者 Vue3 + UnoCSS)。
> **具体要求**
> 1. 响应式设计:侧边栏在移动端折叠。
> 2. 组件化:请将顶栏、侧边栏、卡片、图表区域拆分为独立组件。
> 3. 样式:使用现代扁平化风格,配色参考 Stripe 官网。
>
> **ASCII 草图如下**
> [在此处粘贴上面的 ASCII 图]
第三步:见证奇迹,微调与落地

点击发送,AI 会迅速解析你的 ASCII 结构,并输出代码。

AI 的思考路径通常是这样的

1.解析外层结构:识别出 +---+| 包围的区域,判定这是一个 Header + Sidebar + Main Content 的经典布局。

2.识别组件:看到 Dashboard Overview 下的三个方块,识别为“统计卡片”,并且知道要复用三次。

3.推断样式:根据你的描述“现代扁平化”,它会自动填充 shadow-mdrounded-lg 等类名。

生成出来的代码通常已经具备了 80% 的可用性。你需要做的仅仅是:

  • 替换掉 AI 臆造的假数据。
  • 引入真实的图表库(如 Echarts 或 Recharts)替换占位符。
  • 微调一下 Tailwind 的间距。 短短几分钟,一个结构清晰、样式现代化的页面骨架就诞生了。

附效果图

111.png

进阶技巧:让 ASCII 更“懂” AI

如果你想把这把“瑞士军刀”用得更溜,这里有几个实战技巧

1. 标注优于复杂图形 不要试图用 ASCII 画出圆角或阴影,那是浪费时间。你应该在图形旁边写注释。 例如:

+-----------------+
| [Icon] Title    |  <-- 这里的 Icon 请使用 lucide-react
+-----------------+
| Content here... |  <-- 文字限制两行,超出省略
+-----------------+

AI 能够读懂这些注释,并将其转化为代码约束。

2. 模块化思维 面对复杂的页面,不要试图画一张巨大的图。你可以分块输入:

  • Prompt A:画 Header。
  • Prompt B:画 Sidebar。
  • Prompt C:画 Content Area。 最后让 AI 把它们组合起来。这样能大大降低 AI 解析错误的概率。

3. 迭代式修改 如果你觉得布局不对,不需要重画。直接在对话中修改字符:

  • 用户:“把侧边栏移到右边,宽度缩小一点。”
  • AI:(自动调整 CSS,将侧边栏 DOM 移到主内容区后面或改变 Flex 属性)。 这种**“草图重构”**比“代码重构”要快得多,也更直观。

局限性

草图虽然方便,在效率上有极大提升,但是也存在一定的限制

1. 细节缺失:ASCII 无法表达字体大小、微妙的颜色渐变或复杂的动画。它解决的是布局问题,而不是视觉设计问题。

2. 非结构化内容:如果是图文混排非常复杂的文章页,ASCII 往往难以精确描述,这时候不如直接写 HTML 伪代码。

3. 逻辑盲区:AI 生成的是 UI 骨架,具体的业务逻辑(点击按钮触发什么 API)依然需要你手动注入。

总结

从 ASCII 草图到前端代码,本质上是一种降低沟通损耗的尝试。它让我们从繁琐的 HTML 标签嵌套中解脱出来,回归到结构设计本身。

而 AI,则让这种朴素的结构表达拥有了执行力。

当机器能够理解结构,文本就不再只是说明,而成为代码的源头。

每日一题-颠倒二进制位🟢

2026年2月16日 00:00

颠倒给定的 32 位有符号整数的二进制位。

 

示例 1:

输入:n = 43261596

输出:964176192

解释:

整数 二进制
43261596 00000010100101000001111010011100
964176192 00111001011110000010100101000000

示例 2:

输入:n = 2147483644

输出:1073741822

解释:

整数 二进制
2147483644 01111111111111111111111111111100
1073741822 00111111111111111111111111111110

 

提示:

  • 0 <= n <= 231 - 2
  • n 为偶数

 

进阶: 如果多次调用这个函数,你将如何优化你的算法?

O(1) 位运算分治,原理讲解(Python/Java/C++/Go)

作者 endlesscheng
2026年2月12日 09:58

以反转一个 $8$ 位整数为例。

为方便阅读,我把这个数字记作 $12345678$。目标是得到 $87654321$。

用分治思考,反转 $12345678$ 可以分成如下三步:

  1. 递归反转左半 $1234$,得到 $4321$。
  2. 递归反转右半 $5678$,得到 $8765$。
  3. 交换 $4321$ 和 $8765$,得到 $87654321$。

反转 $1234$ 可以拆分为反转 $12$ 和 $34$,反转 $5678$ 可以拆分为反转 $56$ 和 $78$。

对于 $12$ 这种长为 $2$ 的情况,交换 $1$ 和 $2$ 即可完成反转。

无法加载 SVG 图片,请在网页上查看

你可能会问:这样做,算法能更快吗?

利用位运算「并行计算」的特点,我们可以高效地实现上述过程。

去掉递归的「递」,直接看「归」的过程(自底向上)。

递归的最底层是反转 $12$,反转 $34$,反转 $56$,反转 $78$。利用位运算,这些反转可以同时完成

$$
\begin{array}{c}
\text{12345678} \
\left\downarrow \rule{0pt}{1.5em} \right. \rlap{\text{分离}} \
\text{1\phantom{2}3\phantom{4}5\phantom{6}7\phantom{8}} \
\text{\phantom{1}2\phantom{3}4\phantom{5}6\phantom{7}8} \
\left\downarrow \rule{0pt}{1.5em} \right. \rlap{\text{移位}} \
\text{\phantom{2}1\phantom{2}3\phantom{4}5\phantom{6}7} \
\text{2\phantom{3}4\phantom{5}6\phantom{7}8\phantom{7}} \
\left\downarrow \rule{0pt}{1.5em} \right. \rlap{\text{合并}} \
\text{21436587} \
\end{array}
$$

然后两个两个交换:

$$
\begin{array}{c}
\text{21436587} \
\left\downarrow \rule{0pt}{1.5em} \right. \rlap{\text{分离}} \
\text{21\phantom{11}65\phantom{11}} \
\text{\phantom{11}43\phantom{11}87} \
\left\downarrow \rule{0pt}{1.5em} \right. \rlap{\text{移位}} \
\text{\phantom{11}21\phantom{11}65} \
\text{43\phantom{11}87\phantom{11}} \
\left\downarrow \rule{0pt}{1.5em} \right. \rlap{\text{合并}} \
\text{43218765} \
\end{array}
$$

然后四个四个交换:

$$
\begin{array}{c}
\text{43218765} \
\left\downarrow \rule{0pt}{1.5em} \right. \rlap{\text{分离}} \
\text{4321\phantom{1111}} \
\text{\phantom{1111}8765} \
\left\downarrow \rule{0pt}{1.5em} \right. \rlap{\text{移位}} \
\text{\phantom{1111}4321} \
\text{8765\phantom{1111}} \
\left\downarrow \rule{0pt}{1.5em} \right. \rlap{\text{合并}} \
\text{87654321} \
\end{array}
$$

依此类推。

对于 $32$ 位整数,还需要执行八个八个交换,最后把高低 $16$ 位交换。

m0 = 0x55555555  # 01010101 ...
m1 = 0x33333333  # 00110011 ...
m2 = 0x0f0f0f0f  # 00001111 ...
m3 = 0x00ff00ff  # 00000000111111110000000011111111
m4 = 0x0000ffff  # 00000000000000001111111111111111

class Solution:
    def reverseBits(self, n: int) -> int:
        n = n>>1&m0 | (n&m0)<<1  # 交换相邻位
        n = n>>2&m1 | (n&m1)<<2  # 两个两个交换
        n = n>>4&m2 | (n&m2)<<4  # 四个四个交换
        n = n>>8&m3 | (n&m3)<<8  # 八个八个交换
        return n>>16 | (n&m4)<<16  # 交换高低 16 位
class Solution {
    private static final int m0 = 0x55555555; // 01010101 ...
    private static final int m1 = 0x33333333; // 00110011 ...
    private static final int m2 = 0x0f0f0f0f; // 00001111 ...
    private static final int m3 = 0x00ff00ff; // 00000000111111110000000011111111

    public int reverseBits(int n) {
        n = n>>>1&m0 | (n&m0)<<1; // 交换相邻位
        n = n>>>2&m1 | (n&m1)<<2; // 两个两个交换
        n = n>>>4&m2 | (n&m2)<<4; // 四个四个交换
        n = n>>>8&m3 | (n&m3)<<8; // 八个八个交换
        return n>>>16 | n<<16;    // 交换高低 16 位
    }
}
class Solution {
    static constexpr uint32_t m0 = 0x55555555; // 01010101 ...
    static constexpr uint32_t m1 = 0x33333333; // 00110011 ...
    static constexpr uint32_t m2 = 0x0f0f0f0f; // 00001111 ...
    static constexpr uint32_t m3 = 0x00ff00ff; // 00000000111111110000000011111111

    uint32_t reverseBits32(uint32_t n) {
        n = n>>1&m0 | (n&m0)<<1; // 交换相邻位
        n = n>>2&m1 | (n&m1)<<2; // 两个两个交换
        n = n>>4&m2 | (n&m2)<<4; // 四个四个交换
        n = n>>8&m3 | (n&m3)<<8; // 八个八个交换
        return n>>16 | n<<16;    // 交换高低 16 位
    }

public:
    int reverseBits(int n) {
        return reverseBits32(n);
    }
};
const m0 = 0x55555555 // 01010101 ...
const m1 = 0x33333333 // 00110011 ...
const m2 = 0x0f0f0f0f // 00001111 ...
const m3 = 0x00ff00ff // 00000000111111110000000011111111
const m4 = 0x0000ffff // 00000000000000001111111111111111

func reverseBits(n int) int {
n = n>>1&m0 | n&m0<<1   // 交换相邻位
n = n>>2&m1 | n&m1<<2   // 两个两个交换
n = n>>4&m2 | n&m2<<4   // 四个四个交换
n = n>>8&m3 | n&m3<<8   // 八个八个交换
return n>>16 | n&m4<<16 // 交换高低 16 位
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(1)$。无论输入的是 $0$ 还是 $2^{31}-2$,计算量没有任何区别。更精细地说,时间复杂度是 $\mathcal{O}(\log W)$,其中 $W=32$ 是位宽。
  • 空间复杂度:$\mathcal{O}(1)$。

附:库函数写法

class Solution:
    def reverseBits(self, n: int) -> int:
        # 没有 O(1) 的库函数,只能用字符串转换代替
        # 032b 中的 b 表示转成二进制串,032 表示补前导零到长度等于 32
        return int(f'{n:032b}'[::-1], 2)
class Solution:
    def reverseBits(self, n: int) -> int:
        # 没有 O(1) 的库函数,只能用字符串转换代替
        return int(bin(n)[2:].zfill(32)[::-1], 2)
class Solution {
    public int reverseBits(int n) {
        return Integer.reverse(n);
    }
}
class Solution {
public:
    int reverseBits(int n) {
        return __builtin_bitreverse32(n);
    }
};
func reverseBits(n int) int {
return int(bits.Reverse32(uint32(n)))
}

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

❌
❌