阅读视图

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

老司机 iOS 周报 #369 | 2026-04-27

ios-weekly
老司机 iOS 周报,只为你呈现有价值的信息。

你也可以为这个项目出一份力,如果发现有价值的信息、文章、工具等可以到 Issues 里提给我们,我们会尽快处理。记得写上推荐的理由哦。有建议和意见也欢迎到 Issues 提出。

文章

🌟 🐢 Under the hood: Android 17’s lock-free MessageQueue

@Crazy:Android MessageQueue 是从 Android 的核心框架,从 API1 就已经存在了,这次 Android 针对它进行了重构,是非常大的优化。

原始 MessageQueue 存在的问题: Android 的 MessageQueue 在过去的二十多年里靠着一把 monitor 同步锁保护,虽然没有大的问题,但是在多核多优先级场景下,这把锁会引发多线程争用同一把锁,并且进一步引发高优先级 UI 线程被低优先级后台线程间接拖慢。

新 MessageQueue 设计核心 DeliQueue: 新的 DeliQueue 采用 lock-free 数据结构的设计方式来解决上面的问题,简单用一句话来描述就是 “可以无锁写入的多生产者线程与独占排序和结构整理能力的单消费者 Looper 线程。” 核心方式就是利用原子操作来替代锁,下面我们把 lock-free 拆解一下,不涉及很多的源码。

  1. 插入序号: 利用 mNextInsertSeqValue/mNextFrontInsertSeqValue 两个 volatile 变量来进行插入排序, 主要判断在 enqueueMessageUnchecked 方法的第一行,就是判断 when 是否为 0 来保证不管来自多少个生产者线程的任何两个消息都有一个全序:先比 when,再比 insertSeq。最小堆就靠这个 key 排序。
long seq = when != 0 ? ((long) sNextInsertSeq.getAndAdd(this, 1L) + 1L) : ((long) sNextFrontInsertSeq.getAndAdd(this, -1L) - 1L);
  1. 唤醒判断: 利用 mWaitState 这个 volatile 变量实现 64 位状态机,然后加上 CAS 版本号操作实现整体唤醒判断.
if (WaitState.isCounter(waitState)) {
    // 情况 A:looper 已醒
} else if (msg.when >= WaitState.getTSMillis(waitState)) {
    // 情况 B:新消息不比当前 deadline 更早,我们不需要唤醒
} else if (msg.isAsynchronous()) {
    // 情况 C:新消息更早,且是 async(绕过消息屏障),需要唤醒
} else {
    // 情况 D:我们需要看消息屏障状态,决定是否需要唤醒
    if (blockedByBarrier) {
        newWaitState = WaitState.incrementDeadline(waitState);
        checkBarrier = barrier;
        needWake = false;
    } else {
        newWaitState = WaitState.initCounter();
        checkBarrier = null;
        needWake = true;
    }
}
  1. native 指针保护: 利用 mMptrRefCountValue 这个 volatile 变量实现对 native epoll 的句柄控制。native epoll 句柄,必须有人持有时不能 free,无人持有时要立即 free,Google 仅用一个 long 就这两件事。
private static final long MPTR_TEARDOWN_MASK = 1L << 63;

// 生产者增引用(incrementMptrRefs)
while (true) {
    final long oldVal = mMptrRefCountValue;
    if ((oldVal & MPTR_TEARDOWN_MASK) != 0) {
        return false;  // 已 teardown,拒绝新引用
    }
    if (sMptrRefCount.compareAndSet(this, oldVal, oldVal + 1L)) {
        return true;
    }
}

// 生产者减引用(decrementMptrRefs)
long oldVal = (long) sMptrRefCount.getAndAdd(this, -1L);
if (oldVal - 1 == MPTR_TEARDOWN_MASK) {
    LockSupport.unpark(mLooperThread);  // 我是最后一个活引用,且 looper 在等
}

// 与 wake 的协作
private void concurrentWake() {
    if (incrementMptrRefs()) {
        try { nativeWake(mPtr); }
        finally { decrementMptrRefs(); }
    }
}
  1. 单消息存在性: Tombstone CAS(墓碑 CAS),用一次 CAS 翻一个"已删除"标志位,而不是用锁去操作数据结构本身,保证消息的逻辑消失(真正能让消息消失的只有 looper 线程操作 min-heap),同时保证不会触发多线程修改同一个列表的情况。
// 代码位置 MessageStack,所有线程都可以调用
public int moveMatchingToFreelist(Message.MessageCompare compare, Handler h, int what, Object object, Runnable r, long when) {
    Message current = (Message) sTop.getAcquire(this);
    Message prev = null;
    Message firstRemoved = null;
    int numRemoved = 0;

    while (current != null) {
        if (messageMatches(current, compare, h, what, object, r, when)
                && current.markRemoved()) {
            if (firstRemoved == null) {
                firstRemoved = current;
            }
            current.clearReferenceFields();
            // nextFree links each to-be-removed message to the one processed before.
            current.nextFree = prev;
            prev = current;
            numRemoved++;
        }
        current = current.next;
    }

    if (firstRemoved != null) {
        Message freelist;
        do {
            freelist = mFreelistHeadValue;
            firstRemoved.nextFree = freelist;
        // prev points to the last to-be-removed message that was processed.
        } while (!sFreelistHead.compareAndSet(this, freelist, prev));
    }

        return numRemoved;
}

// looper 准备派发的消息已被并发删除
if (found != null && !peek) {
    if (!found.markRemoved()) {
        continue;  // 别人已经把它标记为删除,重新找下一条
    }
    mStack.remove(found);
}

// looper 线程
MessageStack.poplooper 线程):
if (!m.markRemoved()) {
    return null;  // 别人已经标记了,我让出
}
  1. 生产者/消费者职责切分: 任何线程都可以进行 pushMessage、markRemoved、freelist 和 nativeWake 等操作,但是只有 looper 线程可以进行 min-heap、nativePollOnce 等操作,将整体职责全部分开,昂贵的结构维护工作集中到单线程中完成。

最后我们总结一下新的设计的整体流程: 拿插入序号(原子 getAndAdd,1 条 CPU 指令) -> 写消息字段(不用同步控制) -> mStack.pushMessage(Treiber stack CAS push。失败重试,平均 1–2 次 CAS) -> 唤醒决策循环(读 mWaitState,CAS 写新状态) -> 可能调 concurrentWake。完成整体 pushMessage 操作,全程没有 synchronized 关键字,最坏情况也只有几次 CAS retry,最快路径 0 次内核调用,大大减轻了系统负担。

整篇文章其实不止写了 lock-free 数据结构的设计,其余还有很多,比如 Treiber stack、比如如何利用双链表机制是让 Looper 在线程内高效地把某个节点从 stack 链中摘掉。还有 Google 如何利用 Perfetto 和 PerfettoSQL 进行大量的 trace 分析,确认问题以及修复问题后的验证。可以说这篇文章中的每一部分都可以拿出来单独写一篇比较好的操作指南针,也可以看出 Google 在针对 MessageQueue 的修改上是有多么的慎重,以及在这种多线程上的恐怖控制力,可以说这是一篇值得所有人反复阅读的文章。

🐕 SQLite: Vacuuming the WALs

@ChengzhiHuang: sqlite 是常见的端用存储,一般也都会辅以开启 Write-Ahead Logging(WAL) 模式提升性能。对于一些低存储用户,我们还会辅以开启 incremental_vacuum 定期整理 .-wal 文件进一步减少磁盘占用(注:直接使用 vacuum 是不被推荐的,但是如果数据库本身已经存在,则必须先执行一次完整的 vacuum 才能开启 incremental_vacuum,因此最好是新建的时候默认打开)。本文对 incremental_vacuum 进行了进一步的细分,研究了配置不同的阈值(每次清理的页数)下,整体数据库的表现。大家可以参考自己数据库的实际情况选择不同阈值分批 incremental_vacuum 。

同时提醒大家记得在 incremental_vacuum 完成后再手动进行 checkpoint 才能有效减少磁盘占用,不然只是缩小了 free pages 的数量。

🐕 A Small SwiftUI Warning and a Long Journey to Understand It

@阿权:本文作者在开启 Swift InferSendableFromCaptures(SE-0418)特性后,遇到 SwiftUI 导航修饰器传递视图构造器函数引用的 Actor 隔离警告的问题。根本原因是:警告只是 Swift 5 迁移模式下的一个产物,升级到 Swift 6 后并不是问题。怎么理解呢?

  1. Swift 5 迁移模式的限制:当我们启用一个“未来特性”标志(如 InferSendableFromCaptures)来提前测试 Swift 6 的行为时,它可能会暴露一些问题,但由于底层的检查模型仍是旧版的,所以会产生一些在最终模型中并不存在的“过渡性警告”。
  2. 理解编译模式的差异:在 Swift 6 模式下,编译器能全盘理解上下文并得出正确结论,所以代码直接通过。而在 Swift 5 模式下,它只看到了部分信息,从而发出了多余的警告。

如何去一步步找到问题的根因也是文章的重点,通过作者的探索也能给到我们一些开发实践的建议:

  1. 优先升级语言版本:如果项目条件允许,尽早将 Swift 语言版本升级到 Swift 6。这能让你获得最准确、最一致的并发检查体验。
  2. 深入理解并发模型:花时间去理解 Swift 并发模型的核心概念,如 Actor 隔离和上下文继承。这能让你在处理更复杂的并发问题时游刃有余。
  3. 审慎看待过渡期警告:当你使用 -enable-upcoming-feature 等标志在旧语言模式下测试新特性时,要意识到看到的警告可能带有“过渡性”特征,需要结合最终的语言模型来理解其真正含义。
  4. 不满足于“能用”:在开发中,遇到一个修复方案时,多追问一句“为什么”,“为什么这样能解决问题”。这能帮助你真正理解问题本质,避免被表象解释所误导。

🐕 Lazy Properties in Swift - Why They Don't Always Work in SwiftUI

@Barney:这篇文章系统梳理了 Swift 里 lazy 属性的行为边界,重点不是语法本身,而是它在 SwiftUI 里的常见误用。作者先回顾了 lazy 适合解决的几类问题:延迟昂贵初始化、缓存只需计算一次的结果,以及依赖 self 的初始化;随后指出一个很容易踩的坑:SwiftUI 的 View 是值类型且会频繁重建,而 lazy 首次访问时需要发生写入,这使它既不适合作为稳定缓存,也无法直接放进 body 所依赖的视图属性里。文章给出的实践建议也很明确:在 SwiftUI 中优先用 @State@StateObject 或对象持有者管理生命周期,把 lazy 留给 class、service、formatter 或计算代价较高的缓存对象。对经常在 SwiftUI 中做性能优化的同学很有参考价值。

🐎 A Reusable Spotlight Onboarding Component in SwiftUI

@DylanYang:作者基于 SwiftUI 的 PreferenceKey 与锚点系统,实现了一款可复用的引导组件,无需依赖 UIKit 即可完成视图高亮、圆角镂空遮罩、自适应提示卡片展示与多步骤平滑动画切换。该组件适配导航栈、滚动视图、安全区、弹窗等各类场景,通过 tutorialSpotlight modifier 和 tutorialSpotlightSource modifier 即可快速接入,还支持自定义高亮内边距、圆角、背景点击关闭等配置,能便捷搭建完整的界面引导流程。感兴趣的开发同学可以阅读下具体的实现过程。

🐎 SwiftPM: 2x faster resolves, 3x smaller disk footprint

@david-clang:SwiftPM 长期受限于 Git 全量克隆导致的解析缓慢与磁盘占用过大。受此影响的 Ordo One 公司提交了优化提案 (PR #9870),通过引入源码归档下载路径实现大幅优化。该方案能保持 Public API 不变,且无需开发者修改 Package.swift 。其核心流程如下:

  1. 执行 git ls-remote --tags —— 发现可用版本(无新增 API,与现有机制一致)。
  2. 从 CDN 获取 Package.swift —— 检查工具版本(Tools Version)的兼容性。
  3. 从 GitHub CDN 直接下载 ZIP 压缩包 —— 提取源码,完全绕过 Git 克隆过程。

降级机制 :

  • 子模块 (Submodules):降级为浅克隆。
  • 流程异常 (如下载失败):无缝回退至旧版全量克隆机制(git clone --mirror)。

基准测试与性能收益:

  • 测试方法:选取包含 swift-composable-architecture (TCA)、SwiftLint 等不同规模(9 至 67 个依赖项)的知名开源项目。分别对比新旧方案在冷解析(清空 .build 与全局缓存,模拟 CI 环境)和热解析(保留全局共享缓存,模拟本地开发)下的耗时与磁盘增量。
  • 解析提速:冷解析场景下速度最高提升约 2.1 倍;热解析场景下速度最高提升达 3.8 倍
  • 空间优化:因彻底免除本地 Git 历史数据的存储,.build/ 目录的磁盘占用平均锐减 3 倍(例如,某重度依赖项目的体积由 1.8GB 缩减至约 600MB)。

内推

重新开始更新「iOS 靠谱内推专题」,整理了最近明确在招人的岗位,供大家参考

具体信息请移步:https://www.yuque.com/iosalliance/article/bhutav 进行查看(如有招聘需求请联系 iTDriverr)

关注我们

我们是「老司机技术周报」,一个持续追求精品 iOS 内容的技术公众号,欢迎关注。

关注有礼,关注【老司机技术周报】,回复「2024」,领取 2024 及往年内参

同时也支持了 RSS 订阅:https://github.com/SwiftOldDriver/iOS-Weekly/releases.atom

说明

🚧 表示需某工具,🌟 表示编辑推荐

预计阅读时间:🐎 很快就能读完(1 - 10 mins);🐕 中等 (10 - 20 mins);🐢 慢(20+ mins)

每日一题-检查网格中是否存在有效路径🟡

给你一个 m x n 的网格 grid。网格里的每个单元都代表一条街道。grid[i][j] 的街道可以是:

  • 1 表示连接左单元格和右单元格的街道。
  • 2 表示连接上单元格和下单元格的街道。
  • 3 表示连接左单元格和下单元格的街道。
  • 4 表示连接右单元格和下单元格的街道。
  • 5 表示连接左单元格和上单元格的街道。
  • 6 表示连接右单元格和上单元格的街道。

你最开始从左上角的单元格 (0,0) 开始出发,网格中的「有效路径」是指从左上方的单元格 (0,0) 开始、一直到右下方的 (m-1,n-1) 结束的路径。该路径必须只沿着街道走

注意:不能 变更街道。

如果网格中存在有效的路径,则返回 true,否则返回 false

 

示例 1:

输入:grid = [[2,4,3],[6,5,2]]
输出:true
解释:如图所示,你可以从 (0, 0) 开始,访问网格中的所有单元格并到达 (m - 1, n - 1) 。

示例 2:

输入:grid = [[1,2,1],[1,2,1]]
输出:false
解释:如图所示,单元格 (0, 0) 上的街道没有与任何其他单元格上的街道相连,你只会停在 (0, 0) 处。

示例 3:

输入:grid = [[1,1,2]]
输出:false
解释:你会停在 (0, 1),而且无法到达 (0, 2) 。

示例 4:

输入:grid = [[1,1,1,1,1,1,3]]
输出:true

示例 5:

输入:grid = [[2],[2],[2],[2],[2],[2],[6]]
输出:true

 

提示:

  • m == grid.length
  • n == grid[i].length
  • 1 <= m, n <= 300
  • 1 <= grid[i][j] <= 6

How to Install Java on Ubuntu 26.04

Java is a popular programming language used for building applications and software solutions. It runs on all major operating systems and devices.

This guide covers installing OpenJDK and Oracle JDK on Ubuntu 26.04.

Quick Reference

Task Command
Install default JDK sudo apt install default-jdk
Install OpenJDK 21 sudo apt install openjdk-21-jdk
Install OpenJDK 25 sudo apt install openjdk-25-jdk
Check Java version java -version
Change default Java sudo update-alternatives --config java
Set JAVA_HOME Add to /etc/environment
Uninstall Java sudo apt remove openjdk-25-jdk

Before You Begin

There are several Java implementations available. OpenJDK and Oracle JDK are the two most common choices. OpenJDK is the default option in Ubuntu and the best fit for most systems.

Ubuntu 26.04 includes OpenJDK packages for both the Java Runtime Environment (JRE) and the Java Development Kit (JDK). The JRE includes the Java virtual machine (JVM) and the libraries needed to run Java programs. The JDK includes the JRE plus the tools needed to build Java applications.

If you are not sure which package to install, start with the default OpenJDK version. Some applications require a specific Java release, so check the application documentation before you install it.

Installing OpenJDK in Ubuntu

Check if Java is already installed:

Terminal
java -version

If Java is not installed, the output will tell you the command is not found. Otherwise, it shows the installed version.

Update the package index:

Terminal
sudo apt update

The current long-term supported (LTS) versions of Java are: 11, 17, 21, and 25. The default Java in Ubuntu 26.04 is Java 25, which you get by installing the default-jdk package.

Install latest LTS Java 25:

Terminal
sudo apt install openjdk-25-jdk
Info
If you want to install another version, replace the version number. For example, if your application requires Java 21, install the openjdk-21-jdk package.

Verify the installation:

Terminal
java -version
output
openjdk version "25.0.3-ea" 2026-04-21
OpenJDK Runtime Environment (build 25.0.3-ea+7-Ubuntu-2)
OpenJDK 64-Bit Server VM (build 25.0.3-ea+7-Ubuntu-2, mixed mode, sharing)

JRE is included in the JDK package. If you need only JRE, install the openjdk-25-jre package. For minimal Java runtime, install the openjdk-**25**-jre-headless package.

Installing Oracle Java in Ubuntu

Oracle JDK is not available in the default Ubuntu repositories. You can install it by downloading the .deb package from Oracle.

At the time of writing, Oracle’s downloads page offers both JDK 26, the latest feature release, and JDK 25, the latest LTS release. Oracle JDK 25 is available under Oracle No-Fee Terms and Conditions (NFTC), which allows free production use and redistribution for that release. If you plan to standardize on Oracle JDK, review Oracle’s current licensing terms before deployment.

Visit the Oracle Java Downloads page and select the version you need.

In this example, we will download and install Java 25 because it is the current LTS release. If you want the newest feature release instead, download JDK 26 from the same page. Choose the Linux x64 Debian Package for the version you want and download the .deb file.

java 25

If you are installing on a server, use wget to download the file:

Terminal
wget https://download.oracle.com/java/25/latest/jdk-25_linux-x64_bin.deb

Install the package:

Terminal
sudo apt install ./jdk-25_linux-x64_bin.deb

Replace the filename if you downloaded a different version.

Setting the Default Java Version

If you have multiple Java versions installed, check the current default:

Terminal
java -version

Change the default version with update-alternatives:

Terminal
sudo update-alternatives --config java

You will see a list of installed Java versions:

output
There are 2 choices for the alternative java (providing /usr/bin/java).
Selection Path Priority Status
------------------------------------------------------------
* 0 /usr/lib/jvm/jdk-25.0.3-oracle-x64/bin/java 419454976 auto mode
1 /usr/lib/jvm/java-25-openjdk-amd64/bin/java 2511 manual mode
2 /usr/lib/jvm/jdk-25.0.3-oracle-x64/bin/java 419454976 manual mode
Press <enter> to keep the current choice[*], or type selection number: 

Enter the number of the version you want as default and press Enter.

Verify the change:

Terminal
java -version

Setting the JAVA_HOME Environment Variable

Some Java applications use the JAVA_HOME environment variable to determine the JDK location.

First, find the Java installation path:

Terminal
sudo update-alternatives --config java

The paths are:

  • Oracle JDK 25 is located at /usr/lib/jvm/jdk-25-oracle-x64/bin/java
  • OpenJDK 25 is located at /usr/lib/jvm/java-25-openjdk-amd64/bin/java
Info
The java binary is located at JAVA_HOME/bin/java. Set JAVA_HOME to the path above, excluding the bin/java part.

Open the /etc/environment file:

Terminal
sudo nano /etc/environment

Add the following line (adjust the path for your preferred version):

/etc/environmentsh
JAVA_HOME="/usr/lib/jvm/java-25-openjdk-amd64"

Apply the changes:

Terminal
source /etc/environment

Verify the variable is set:

Terminal
echo $JAVA_HOME
output
/usr/lib/jvm/java-25-openjdk-amd64
Info
/etc/environment is system-wide. To set JAVA_HOME per user, add the line to .bashrc or another shell configuration file.

Uninstalling Java

Uninstall Java like any other package:

Terminal
sudo apt remove openjdk-25-jdk

Replace the package name with the version you want to remove.

Conclusion

We covered installing OpenJDK from the Ubuntu 26.04 repositories and downloading Oracle JDK manually. The default OpenJDK 25 works for most applications, but Java 26 is also available in the Oracle repositories for the latest features.

For more information, see the official OpenJDK documentation .

How to Install PHP on Ubuntu 26.04

PHP is one of the most used server-side programming languages. Many popular CMS and frameworks such as WordPress, Magento, and Laravel are written in PHP.

This guide covers the steps necessary to install PHP on Ubuntu 26.04 and integrate it with Nginx and Apache. The default Ubuntu 26.04 repositories include PHP 8.5. We will also show you how to install other PHP versions.

Quick Reference

Task Command
Install PHP with Apache sudo apt install php libapache2-mod-php
Install PHP with Nginx sudo apt install php-fpm
Install an extension sudo apt install php-[extname]
Check PHP version php -v
List installed modules php -m
Restart PHP-FPM sudo systemctl restart php8.4-fpm
Switch PHP version (CLI) sudo update-alternatives --config php
Add Ondřej PPA sudo add-apt-repository ppa:ondrej/php

Prerequisites

To follow this guide, you need to be logged in as a user with sudo privileges .

Installing PHP 8.5 with Apache

If you are using Apache as your web server, run the following commands to install PHP and the Apache PHP module:

Terminal
sudo apt update
sudo apt install php libapache2-mod-php

Once the packages are installed, restart Apache for the PHP module to get loaded:

Terminal
sudo systemctl restart apache2

Installing PHP 8.5 with Nginx

Unlike Apache, Nginx does not have built-in support for processing PHP files. We will use PHP-FPM (FastCGI Process Manager) to handle PHP files.

Run the following commands to install PHP and the PHP-FPM package:

Terminal
sudo apt update
sudo apt install php-fpm

Once the installation is completed, the FPM service starts automatically. To check the status of the service, run:

Terminal
sudo systemctl status php8.4-fpm
output
● php8.4-fpm.service - The PHP 8.5 FastCGI Process Manager
Loaded: loaded (/usr/lib/systemd/system/php8.4-fpm.service; enabled; preset: enabled)
Active: active (running) since Sat 2026-04-26 09:15:22 UTC; 12s ago

You can now edit the Nginx server block and add the following lines so that Nginx can process PHP files:

nginx
server {

 # . . . other code

 location ~ \.php$ {
 include snippets/fastcgi-php.conf;
 fastcgi_pass unix:/run/php/php8.4-fpm.sock;
 }
}

Restart the Nginx service so that the new configuration takes effect:

Terminal
sudo systemctl restart nginx

Installing PHP Extensions

PHP extensions are compiled libraries that extend the core functionality of PHP. Extensions are available as packages and can be easily installed with apt :

Terminal
sudo apt install php-[extname]

For example, to install the MySQL and GD extensions, run:

Terminal
sudo apt install php-mysql php-gd

Here are some of the most commonly used PHP extensions:

Extension Package Description
MySQL php-mysql MySQL and MariaDB database support
cURL php-curl URL transfer library
GD php-gd Image processing
Mbstring php-mbstring Multibyte string support
XML php-xml XML parsing and manipulation
ZIP php-zip ZIP archive support
Intl php-intl Internationalization functions
OPcache php-opcache Bytecode caching for performance
BCMath php-bcmath Arbitrary precision math

To install all commonly needed extensions at once:

Terminal
sudo apt install php-mysql php-curl php-gd php-mbstring php-xml php-zip php-intl php-opcache php-bcmath

After installing a new PHP extension, restart Apache or PHP-FPM depending on your setup:

Terminal
sudo systemctl restart apache2

Or for Nginx with PHP-FPM:

Terminal
sudo systemctl restart php8.4-fpm

To list all installed PHP modules:

Terminal
php -m

Testing PHP Processing

To confirm that the web server can process PHP files, create a new info.php file in /var/www/html:

Terminal
echo '<?php phpinfo();' | sudo tee /var/www/html/info.php

Open http://your_server_ip/info.php in your browser. If PHP is configured correctly, you will see the PHP information page:

PHP 8.5 information page on Ubuntu 26.04
Warning

The phpinfo() page exposes details about your server and PHP configuration. Remove it after testing:

Terminal
sudo rm /var/www/html/info.php

Installing Other PHP Versions

Ondřej Surý, a Debian developer, maintains a repository that includes multiple PHP versions. This is a third-party source, so use it only if you need a different PHP version than what Ubuntu provides. To enable the repository , run:

Terminal
sudo apt install software-properties-common
sudo add-apt-repository ppa:ondrej/php

You can now install any PHP version you need by appending the version number to the package name:

Terminal
sudo apt install php[version]

For example, to install PHP 8.3 and a few common modules:

Terminal
sudo apt install php8.3 php8.3-cli php8.3-common php8.3-opcache php8.3-gd php8.3-curl php8.3-mysql php8.3-mbstring php8.3-xml

Switching Between PHP Versions

If you have multiple PHP versions installed, you can switch the default CLI version using update-alternatives:

Terminal
sudo update-alternatives --set php /usr/bin/php8.3

To interactively choose the default version:

Terminal
sudo update-alternatives --config php

For Apache, disable the current PHP module and enable the one you want:

Terminal
sudo a2dismod php8.4
sudo a2enmod php8.3
sudo systemctl restart apache2

For Nginx, update the PHP-FPM socket path in your server block to point to the correct version, then restart Nginx:

Terminal
sudo systemctl restart nginx

Checking the PHP Version

To check which PHP version is installed on your system, run:

Terminal
php -v
output
PHP 8.5.4 (cli) (built: Apr 1 2026 09:36:11) (NTS)
Copyright (c) The PHP Group
Built by Ubuntu
Zend Engine v4.5.4, Copyright (c) Zend Technologies
with Zend OPcache v8.5.4, Copyright (c), by Zend Technologies

PHP Configuration Files

PHP configuration files are located at:

  • Main config: /etc/php/8.5/cli/php.ini (CLI) and /etc/php/8.5/apache2/php.ini (Apache) or /etc/php/8.5/fpm/php.ini (FPM)
  • FPM pool config: /etc/php/8.5/fpm/pool.d/www.conf
  • Extension configs: /etc/php/8.5/mods-available/

After changing any configuration, restart the relevant service:

Terminal
sudo systemctl restart php8.4-fpm

Uninstalling PHP

To remove PHP and related packages, you can purge them and clean up dependencies:

Terminal
sudo apt purge 'php*'
sudo apt autoremove

FAQ

What PHP version does Ubuntu 26.04 include?
Ubuntu 26.04 ships with PHP 8.5 in its default repositories.

What is the difference between php and php-fpm?
The php package includes the CLI interpreter and the Apache module. The php-fpm package provides a FastCGI Process Manager used by Nginx and other web servers that do not have built-in PHP support.

Can I run multiple PHP versions at the same time?
Yes. You can install multiple PHP versions from the Ondřej Surý PPA and configure each virtual host or server block to use a different PHP version.

Do I need to restart Apache or Nginx after installing an extension?
Yes. After installing a PHP extension, restart Apache (sudo systemctl restart apache2) or PHP-FPM (sudo systemctl restart php8.4-fpm) for the extension to be loaded.

Where is the php.ini file located?
PHP uses separate php.ini files for each SAPI. The CLI config is at /etc/php/8.5/cli/php.ini, the Apache config at /etc/php/8.5/apache2/php.ini, and the FPM config at /etc/php/8.5/fpm/php.ini.

Conclusion

We have shown you how to install PHP 8.5 on Ubuntu 26.04 with both Apache and Nginx. You can now install extensions, configure PHP, and start building your applications.

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

利用方向向量简化代码逻辑(Python/Java/C++/C/Go/JS/Rust)

我们需要解决两个关键问题:

  1. 站在 $(x,y)$,下一步可以往哪些方向移动?
  2. 如何判断相邻街道是否连通?示例 2 的街道不是连通的,无法移动。

对于第一个问题,我们可以创建一个方向向量数组,保存每种街道的移动方向。例如:

  • 站在街道 1,我们可以往左或者往右移动,对应的方向向量分别为 $(0,-1)$ 和 $(0,1)$。
  • 站在街道 3,我们可以往左或者往下移动,对应的方向向量分别为 $(0,-1)$ 和 $(1,0)$。

对于第二个问题,如果两条相邻街道可以互相到达,那么这两条街道就是连通的。

  • 如果街道 1 的右边是街道 3,我们可以从街道 1 往右移动到街道 3,也可以从街道 3 往左移动到街道 1。
  • 如果要从街道 1 往右移动,那么只要右边相邻街道能往左移动就行,也就是包含往左的方向向量 $(0,-1)$。
  • 一般地,如果从当前位置往 $(\textit{dx},\textit{dy})$ 方向移动到相邻街道,那么相邻街道必须包含相反的方向向量 $(-\textit{dx},-\textit{dy})$。

###py

DIRS = (
    (),
    ((0, -1), (0, 1)),  # 站在街道 1,可以往左或者往右
    ((-1, 0), (1, 0)),  # 站在街道 2,可以往上或者往下
    ((0, -1), (1, 0)),  # 站在街道 3,可以往左或者往下
    ((0, 1), (1, 0)),   # 站在街道 4,可以往右或者往下
    ((0, -1), (-1, 0)), # 站在街道 5,可以往左或者往上
    ((0, 1), (-1, 0)),  # 站在街道 6,可以往右或者往上
)

class Solution:
    def hasValidPath(self, grid: list[list[int]]) -> bool:
        m, n = len(grid), len(grid[0])
        vis = [[False] * n for _ in range(m)]

        def dfs(x: int, y: int) -> bool:
            if x == m - 1 and y == n - 1:
                return True
            vis[x][y] = True  # 标记 (x, y) 访问过,从而避免重复访问
            for dx, dy in DIRS[grid[x][y]]:  # 枚举下一步往哪走
                i, j = x + dx, y + dy
                if 0 <= i < m and 0 <= j < n and not vis[i][j] and \
                   (-dx, -dy) in DIRS[grid[i][j]] and dfs(i, j):
                    return True
            return False

        return dfs(0, 0)

###java

class Solution {
    private static final int[][][] DIRS = {
        {},
        {{0, -1}, {0, 1}},  // 站在街道 1,可以往左或者往右
        {{-1, 0}, {1, 0}},  // 站在街道 2,可以往上或者往下
        {{0, -1}, {1, 0}},  // 站在街道 3,可以往左或者往下
        {{0, 1}, {1, 0}},   // 站在街道 4,可以往右或者往下
        {{0, -1}, {-1, 0}}, // 站在街道 5,可以往左或者往上
        {{0, 1}, {-1, 0}},  // 站在街道 6,可以往右或者往上
    };

    public boolean hasValidPath(int[][] grid) {
        int m = grid.length;
        int n = grid[0].length;
        boolean[][] vis = new boolean[m][n];
        return dfs(0, 0, grid, vis);
    }

    private boolean dfs(int x, int y, int[][] grid, boolean[][] vis) {
        int m = grid.length;
        int n = grid[x].length;
        if (x == m - 1 && y == n - 1) {
            return true;
        }
        vis[x][y] = true; // 标记 (x, y) 访问过,从而避免重复访问
        for (int[] d : DIRS[grid[x][y]]) { // 枚举下一步往哪走
            int i = x + d[0];
            int j = y + d[1];
            if (0 <= i && i < m && 0 <= j && j < n && !vis[i][j] &&
                contains(grid[i][j], -d[0], -d[1]) && dfs(i, j, grid, vis)) {
                return true;
            }
        }
        return false;
    }

    // 判断街道 street 是否包含移动方向 (dx, dy)
    private boolean contains(int street, int dx, int dy) {
        int[][] ds = DIRS[street];
        return ds[0][0] == dx && ds[0][1] == dy ||
               ds[1][0] == dx && ds[1][1] == dy;
    }
}

###cpp

class Solution {
    static constexpr int DIRS[7][2][2] = {
        {},
        {{0, -1}, {0, 1}},  // 站在街道 1,可以往左或者往右
        {{-1, 0}, {1, 0}},  // 站在街道 2,可以往上或者往下
        {{0, -1}, {1, 0}},  // 站在街道 3,可以往左或者往下
        {{0, 1}, {1, 0}},   // 站在街道 4,可以往右或者往下
        {{0, -1}, {-1, 0}}, // 站在街道 5,可以往左或者往上
        {{0, 1}, {-1, 0}},  // 站在街道 6,可以往右或者往上
    };

    // 判断街道 street 是否包含移动方向 (dx, dy)
    bool contains(int street, int dx, int dy) {
        auto& ds = DIRS[street];
        return ds[0][0] == dx && ds[0][1] == dy ||
               ds[1][0] == dx && ds[1][1] == dy;
    }

public:
    bool hasValidPath(vector<vector<int>>& grid) {
        int m = grid.size(), n = grid[0].size();
        vector vis(m, vector<int8_t>(n));

        auto dfs = [&](this auto&& dfs, int x, int y) -> bool {
            if (x == m - 1 && y == n - 1) {
                return true;
            }
            vis[x][y] = true; // 标记 (x, y) 访问过,从而避免重复访问
            for (auto& [dx, dy] : DIRS[grid[x][y]]) { // 枚举下一步往哪走
                int i = x + dx, j = y + dy;
                if (0 <= i && i < m && 0 <= j && j < n && !vis[i][j] &&
                    contains(grid[i][j], -dx, -dy) && dfs(i, j)) {
                    return true;
                }
            }
            return false;
        };

        return dfs(0, 0);
    }
};

###c

static const int DIRS[7][2][2] = {
    {},
    {{0, -1}, {0, 1}},  // 站在街道 1,可以往左或者往右
    {{-1, 0}, {1, 0}},  // 站在街道 2,可以往上或者往下
    {{0, -1}, {1, 0}},  // 站在街道 3,可以往左或者往下
    {{0, 1}, {1, 0}},   // 站在街道 4,可以往右或者往下
    {{0, -1}, {-1, 0}}, // 站在街道 5,可以往左或者往上
    {{0, 1}, {-1, 0}},  // 站在街道 6,可以往右或者往上
};

// 判断街道 street 是否包含移动方向 (dx, dy)
bool contains(int street, int dx, int dy) {
    return DIRS[street][0][0] == dx && DIRS[street][0][1] == dy ||
           DIRS[street][1][0] == dx && DIRS[street][1][1] == dy;
}

bool hasValidPath(int** grid, int gridSize, int* gridColSize) {
    int m = gridSize, n = gridColSize[0];
    bool** vis = malloc(m * sizeof(bool*));
    for (int i = 0; i < m; i++) {
        vis[i] = calloc(n, sizeof(bool));
    }

    bool dfs(int x, int y) {
        if (x == m - 1 && y == n - 1) {
            return true;
        }
        vis[x][y] = true; // 标记 (x, y) 访问过,从而避免重复访问
        for (int k = 0; k < 2; k++) { // 枚举下一步往哪走
            int* dir = DIRS[grid[x][y]][k];
            int i = x + dir[0], j = y + dir[1];
            if (0 <= i && i < m && 0 <= j && j < n && !vis[i][j] &&
                contains(grid[i][j], -dir[0], -dir[1]) && dfs(i, j)) {
                return true;
            }
        }
        return false;
    }

    bool ans = dfs(0, 0);

    for (int i = 0; i < m; i++) {
        free(vis[i]);
    }
    free(vis);

    return ans;
}

###go

var dirs = [7][2][2]int{
{},
{{0, -1}, {0, 1}},  // 站在街道 1,可以往左或者往右
{{-1, 0}, {1, 0}},  // 站在街道 2,可以往上或者往下
{{0, -1}, {1, 0}},  // 站在街道 3,可以往左或者往下
{{0, 1}, {1, 0}},   // 站在街道 4,可以往右或者往下
{{0, -1}, {-1, 0}}, // 站在街道 5,可以往左或者往上
{{0, 1}, {-1, 0}},  // 站在街道 6,可以往右或者往上
}

// 判断街道 street 是否包含移动方向 dir
func contains(street int, dir [2]int) bool {
// 也可以写 slices.Contains(dirs[street][:], dir)
return dirs[street][0] == dir || dirs[street][1] == dir
}

func hasValidPath(grid [][]int) bool {
m, n := len(grid), len(grid[0])
vis := make([][]bool, m)
for i := range vis {
vis[i] = make([]bool, n)
}

var dfs func(int, int) bool
dfs = func(x, y int) bool {
if x == m-1 && y == n-1 {
return true
}
vis[x][y] = true // 标记 (x, y) 访问过,从而避免重复访问
for _, d := range dirs[grid[x][y]] { // 枚举下一步往哪走
i, j := x+d[0], y+d[1]
if 0 <= i && i < m && 0 <= j && j < n && !vis[i][j] &&
contains(grid[i][j], [2]int{-d[0], -d[1]}) && dfs(i, j) {
return true
}
}
return false
}

return dfs(0, 0)
}

###js

const DIRS = [
    [],
    [[0, -1], [0, 1]],  // 站在街道 1,可以往左或者往右
    [[-1, 0], [1, 0]],  // 站在街道 2,可以往上或者往下
    [[0, -1], [1, 0]],  // 站在街道 3,可以往左或者往下
    [[0, 1], [1, 0]],   // 站在街道 4,可以往右或者往下
    [[0, -1], [-1, 0]], // 站在街道 5,可以往左或者往上
    [[0, 1], [-1, 0]],  // 站在街道 6,可以往右或者往上
];

// 判断街道 street 是否包含移动方向 (dx, dy)
function contains(street, dx, dy) {
    const ds = DIRS[street];
    return ds[0][0] === dx && ds[0][1] === dy ||
           ds[1][0] === dx && ds[1][1] === dy;
}

var hasValidPath = function(grid) {
    const m = grid.length, n = grid[0].length;
    const vis = Array.from({ length: m }, () => Array(n).fill(false));

    function dfs(x, y) {
        if (x === m - 1 && y === n - 1) {
            return true;
        }
        vis[x][y] = true; // 标记 (x, y) 访问过,从而避免重复访问
        for (const [dx, dy] of DIRS[grid[x][y]]) { // 枚举下一步往哪走
            const i = x + dx, j = y + dy;
            if (0 <= i && i < m && 0 <= j && j < n && !vis[i][j] &&
                contains(grid[i][j], -dx, -dy) && dfs(i, j)) {
                return true;
            }
        }
        return false;
    }

    return dfs(0, 0);
};

###rust

impl Solution {
    const DIRS: [[(i32, i32); 2]; 7] = [
        [(0, 0), (0, 0)],
        [(0, -1), (0, 1)],  // 站在街道 1,可以往左或者往右
        [(-1, 0), (1, 0)],  // 站在街道 2,可以往上或者往下
        [(0, -1), (1, 0)],  // 站在街道 3,可以往左或者往下
        [(0, 1), (1, 0)],   // 站在街道 4,可以往右或者往下
        [(0, -1), (-1, 0)], // 站在街道 5,可以往左或者往上
        [(0, 1), (-1, 0)],  // 站在街道 6,可以往右或者往上
    ];

    // 判断街道 street 是否包含移动方向 dir
    fn contains(street: i32, dir: (i32, i32)) -> bool {
        let ds = Self::DIRS[street as usize];
        ds[0] == dir || ds[1] == dir
    }

    pub fn has_valid_path(grid: Vec<Vec<i32>>) -> bool {
        fn dfs(x: usize, y: usize, grid: &[Vec<i32>], vis: &mut [Vec<bool>]) -> bool {
            let m = grid.len();
            let n = grid[x].len();
            if x == m - 1 && y == n - 1 {
                return true;
            }
            vis[x][y] = true; // 标记 (x, y) 访问过,从而避免重复访问
            for &(dx, dy) in Solution::DIRS[grid[x][y] as usize].iter() { // 枚举下一步往哪走
                let i = x + dx as usize;
                let j = y + dy as usize;
                if i < m && j < n && !vis[i][j] &&
                   Solution::contains(grid[i][j], (-dx, -dy)) && dfs(i, j, grid, vis) {
                    return true;
                }
            }
            false
        }

        let m = grid.len();
        let n = grid[0].len();
        let mut vis = vec![vec![false; n]; m];
        dfs(0, 0, &grid, &mut vis)
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(mn)$,其中 $m$ 和 $n$ 分别是 $\textit{grid}$ 的行数和列数。
  • 空间复杂度:$\mathcal{O}(mn)$。

专题训练

见下面网格图题单的「一、网格图 DFS」。

分类题单

如何科学刷题?

  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站@灵茶山艾府

C++DFS解法,容易理解

解题思路:

通过构建pipe数组,将每个拼图转化为四个方向上的移动限制图。

例:

pipe[3][2]=3,代表3号拼图可以由向上的方向进入其中,并转向左方向继续前进。

pipe[5][3]=-1,代表5号拼图可以由向左的方向进入其中。

其中0代表向下、1代表向右、2代表向上、3代表向左、-1代表不可走

image.png

这之后问题就变成了一个简单的DFS了

class Solution {
    int m,n,dx[4]={1,0,-1,0},dy[4]={0,1,0,-1};//0下、1右、2上、3左
    int pipe[7][4]={{-1,-1,-1,-1},{-1,1,-1,3},{0,-1,2,-1},{-1,0,3,-1},{-1,-1,1,0},{3,2,-1,-1},{1,-1,-1,2}};
    //记录各个拼图块路径的方向,0、1、2、3代表方向,-1代表不可走。
    bool dfs(int x,int y,int dir,vector<vector<int>>& grid){//(x,y,当前方向,地图)
        if(x==m-1&&y==n-1) return 1;//到达终点
        int xx=x+dx[dir];
        int yy=y+dy[dir];//得到下一个准备走的坐标
        if(xx<0||yy<0||xx>=m||yy>=n)return 0;//越界
        int nxt=grid[xx][yy];//得到下一块拼图的编号
        if(pipe[nxt][dir]!=-1)return dfs(xx,yy,pipe[nxt][dir],grid);//如果当前方向可走,则方向改变,继续走。
        return 0;//无法走,返回0
    }
    public:
    bool hasValidPath(vector<vector<int>>& grid) {    
        m=grid.size();
        n=grid[0].size();
        int sta=grid[0][0];//起点的拼图编号
        for(int i=0;i<4;++i)//朝着四个方向都试一下
            if(pipe[sta][i]!=-1)//当前方向可以走
                if(dfs(0,0,pipe[sta][i],grid))//沿着当前方向搜索
                    return 1;//拼图都有两个方向可以走,只要沿着一个初始方向走通就可以。
        return 0;
    }
};

3.23 updata

之前是加了vis数组判断是否访问过的,之后感觉没啥用,就删掉了,发现也能过题目,便没再多想。

这里很感谢@study11 @xm9304同学的质疑

同时很感谢@mapleking同学的指正。

之后,再@LeetCode加一下测试用例。

class Solution {
    int m,n,dx[4]={1,0,-1,0},dy[4]={0,1,0,-1};//0下、1右、2上、3左
    int pipe[7][4]={
        {-1,-1,-1,-1},
        {-1,1,-1,3},
        {0,-1,2,-1},
        {-1,0,3,-1},
        {-1,-1,1,0},
        {3,2,-1,-1},
        {1,-1,-1,2}
    };
    //记录各个拼图块路径的方向,0、1、2、3代表方向,-1代表不可走。
    bool vis[302][302];
    bool dfs(int x,int y,int dir,vector<vector<int>>& grid){//(x,y,当前方向,地图)
        vis[x][y]=1;
        if(x==m-1&&y==n-1) return 1;//到达终点
        int xx=x+dx[dir];
        int yy=y+dy[dir];//得到下一个准备走的坐标
        if(xx<0||yy<0||xx>=m||yy>=n)return 0;//越界
        int nxt=grid[xx][yy];//得到下一块拼图的编号
        if(pipe[nxt][dir]!=-1&&!vis[xx][yy])
            return dfs(xx,yy,pipe[nxt][dir],grid);//如果当前方向可走,则方向改变,继续走。
        return 0;//无法走,返回0
    }
    public:
    bool hasValidPath(vector<vector<int>>& grid) {    
        m=grid.size();
        n=grid[0].size();
        memset(vis,0,sizeof(vis));
        int sta=grid[0][0];//起点的拼图编号
        for(int i=0;i<4;++i)//朝着四个方向都试一下
            if(pipe[sta][i]!=-1)//当前方向可以走
                if(dfs(0,0,pipe[sta][i],grid))//沿着当前方向搜索
                    return 1;//拼图都有两个方向可以走,只要沿着一个初始方向走通就可以。
        return 0;
    }
};

建图+dfs 40 行左右

把每个格子转化成3*3的格子,有道路的地方写上1,之后dfs即可

class Solution {
    int map[1000][1000];
    void fill(int i, int j, int s)
    {
        map[i+1][j+1]=1;
        if(s==1) map[i+1][j]=map[i+1][j+2]=1;
        if(s==2) map[i][j+1]=map[i+2][j+1]=1;
        if(s==3) map[i+1][j]=map[i+2][j+1]=1;
        if(s==4) map[i+1][j+2]=map[i+2][j+1]=1;
        if(s==5) map[i+1][j]=map[i][j+1]=1;
        if(s==6) map[i+1][j+2]=map[i][j+1]=1;
    }
    int dir[4][2] = {0,1,1,0,-1,0,0,-1};
    void dfs(int x, int y)
    {
        map[x][y]=0;
        for(int i=0;i<4;i++)
        {
            int xx = x+dir[i][0];
            int yy = y+dir[i][1];
            if(map[xx][yy]==0)continue;
            dfs(xx, yy);
        }
    }
public:
    bool hasValidPath(vector<vector<int>>& grid) {
        memset(map,0,sizeof map);
        int n = grid.size();
        int m = grid[0].size();
        for(int i=1;i<=3*n;i+=3)
            for(int j=1;j<=3*m;j+=3)
                fill(i,j,grid[i/3][j/3]);
        dfs(2,2);
        return map[3*n-1][3*m-1]==0;
    }
};

顺便推荐一道很类似的题,Maze Connect,http://serjudging.vanb.org/wp-content/uploads/Maze-Connect.pdf
不过这样写可能不是最快的,我花了15分钟。。几乎是剩下三道题时间之和。。

后来想到map部分的函数也可以压缩,这样写比赛的时候可能会更省时间,代码也更短:

class Solution {
    int map[1000][1000];
    int o[6][4]={1,0,1,2,0,1,2,1,1,0,2,1,1,2,2,1,1,0,0,1,1,2,0,1};
    void fill(int i, int j, int s)
    {
        map[i+1][j+1]= map[i+o[s][0]][j+o[s][1]] = map[i+o[s][2]][j+o[s][3]]=1;
    }
    int dir[4][2] = {0,1,1,0,-1,0,0,-1};
    void dfs(int x, int y)
    {
        map[x][y]=0;
        for(int i=0;i<4;i++)
        {
            int xx = x+dir[i][0];
            int yy = y+dir[i][1];
            if(map[xx][yy]==0)continue;
            dfs(xx, yy);
        }
    }
public:
    bool hasValidPath(vector<vector<int>>& grid) {
        memset(map,0,sizeof map);
        int n = grid.size();
        int m = grid[0].size();
        for(int i=1;i<=3*n;i+=3)
            for(int j=1;j<=3*m;j+=3)
                fill(i,j,grid[i/3][j/3]-1);
        dfs(2,2);
        return map[3*n-1][3*m-1]==0;
    }
};

在 Github 中通过创建 issue 来唤醒 claude 工作

前置条件

  • 你是目标 repo 的 admin
  • 已有 Anthropic API Key(或 AWS Bedrock 凭证)
    • 申请 Anthropic API Key 可以使用 claude setup-token 命令,得到一个 sk 开头的 key

方式一:安装官方 Claude App(最快)

  1. 打开 https://github.com/apps/claude,点击 Install
  2. 选择你要授权的 repo(建议只勾选需要的 repo,不要 All repositories)
  3. 确认安装

安装完成后跳到下面的「配置 Secrets 和 Workflow」章节。


方式二:创建自定义 GitHub App(完全掌控权限)

适用场景:组织策略不允许装第三方 App、需要更严格的权限控制、使用 AWS Bedrock / Vertex AI。

A)快速创建(推荐)

  1. https://github.com/anthropics/claude-code-action/blob/main/docs/create-app.html 右键「另存为」下载 create-app.html
  2. 用浏览器打开这个文件
    • 个人账号:点击「Create App for Personal Account」
    • 组织账号:输入组织名称,点击「Create App for Organization」
  3. GitHub 会展示 App 配置预览 → 确认名称 → 点击 Create GitHub App
  4. 创建后自动跳转到 App 设置页,往下滚到 Private keys → 点 Generate a private key → 下载 .pem 文件(妥善保管)
  5. 跳到下面的「安装 App 到 Repo」步骤

B)手动创建

  1. 打开 https://github.com/settings/apps(个人)或组织的 Settings → Developer settings → GitHub Apps
  2. New GitHub App,配置以下权限:
权限类别 权限项 级别
Repository permissions Contents Read & Write
Repository permissions Issues Read & Write
Repository permissions Pull requests Read & Write
Account permissions
  1. 「Where can this GitHub App be installed?」选 Only on this account
  2. Create GitHub App
  3. 创建完成后,滚到 Private keysGenerate a private key → 下载 .pem 文件

安装 App 到 Repo

  1. 进入你刚创建的 App 设置页
  2. 左侧菜单点 Install App
  3. 选择要安装的 repo,确认

配置 Secrets 和 Workflow

第一步:添加 Repository Secrets

进入 repo → SettingsSecrets and variablesActionsNew repository secret

Secret 名称
ANTHROPIC_API_KEY 你的 Anthropic API Key(sk-ant- 开头)
CLAUDE_CODE_OAUTH_TOKEN(可选,替代上一条) claude setup-token 生成的 OAuth Token
APP_ID(自定义 App 才需要) App 设置页里的 App ID
APP_PRIVATE_KEY(自定义 App 才需要) .pem 文件的完整内容

⚠️ 绝对不要把 API Key 写在代码里,只通过 Secrets 引用。
ANTHROPIC_API_KEYCLAUDE_CODE_OAUTH_TOKEN 二选一即可,下面示例以 API Key 为主,OAuth 用法是把 anthropic_api_key: 换成 claude_code_oauth_token:

第二步:创建 Workflow 文件

在 repo 中创建 .github/workflows/claude.yml

如果用官方 Claude App:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
name: Claude Assistant
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
issues:
types: [opened, assigned]

# 仓库级权限:按需最小化——只读场景可把三个 write 都改成 read
permissions:
contents: write
pull-requests: write
issues: write
# 用 CLAUDE_CODE_OAUTH_TOKEN 时必加,OAuth 流程要用 OIDC token 去换;
# 用 ANTHROPIC_API_KEY 时可省。
id-token: write
# 让 Claude 能读 CI run 日志("我 PR 的 CI 挂了帮我看看")
actions: read

jobs:
claude-response:
# 双重门槛:
# 1. actor 必须是仓库主人本人——防外部用户触发
# 2. 触发载体里必须出现 @claude——没有则直接 skip,连 runner 都不起,省 Action 额度
# 注意:include_comments_by_actor 只过滤"评论",不一定覆盖 issue body /
# PR description / review body,所以第 1 条的 actor 校验是必须的纵深防御。
if: |
github.actor == '你的用户名' && (
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude')
|| contains(github.event.issue.title, '@claude')))
)
runs-on: ubuntu-latest
steps:
# claude-code-action 需要 .git 目录才能创建分支去提 PR
- uses: actions/checkout@v4
with:
fetch-depth: 1

- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# 第三道门:评论场景也只接受白名单
include_comments_by_actor: "你的用户名"
# 工具权限:纵深防御——Bash 不再裸开,按命令前缀逐条白名单。
# 只读场景可把所有 Bash(...) 和 Edit/Write 删掉。
claude_args: |
--max-turns 30
--allowedTools "WebFetch,WebSearch,Edit,Write,Read,Glob,Grep,TodoWrite,Bash(git:*),Bash(gh:*),Bash(npm:*),Bash(pnpm:*),Bash(yarn:*),Bash(npx:*),Bash(node:*),Bash(curl:*),Bash(jq:*),Bash(rg:*),Bash(fd:*)"

如果用自定义 GitHub App:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
name: Claude with Custom App
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
issues:
types: [opened, assigned]

permissions:
contents: write
pull-requests: write
issues: write
id-token: write # 用 OAuth Token 时必加
actions: read # 让 Claude 能看 CI run 日志

jobs:
claude-response:
if: |
github.actor == '你的用户名' && (
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude')
|| contains(github.event.issue.title, '@claude')))
)
runs-on: ubuntu-latest
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}

- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ steps.app-token.outputs.token }}
include_comments_by_actor: "你的用户名"
claude_args: |
--max-turns 30
--allowedTools "WebFetch,WebSearch,Edit,Write,Read,Glob,Grep,TodoWrite,Bash(git:*),Bash(gh:*),Bash(npm:*),Bash(pnpm:*),Bash(yarn:*),Bash(npx:*),Bash(node:*),Bash(curl:*),Bash(jq:*),Bash(rg:*),Bash(fd:*)"

Public Repo 安全清单

  • ifactor 白名单github.actor == '你的用户名'),不要只用”排除 bot”的黑名单
  • if 再叠一层 contains(<事件正文>, '@claude') 判断——没有触发词直接 skip,省 Action 额度也避免被误触
  • include_comments_by_actor 同步设置用户白名单(注意:它只过滤评论,不一定覆盖 issue body / PR description,所以上面两条 if 校验是必须的纵深防御)
  • 顶层 permissions: 块按需最小化授权——只读场景 contents/pull-requests/issues 都给 read 即可;要提 PR 才给 write
  • claude_argsBash 不要裸开——用 Bash(git:*),Bash(gh:*),... 这种命令前缀白名单收紧
  • allowed_bots 保持默认空值(不要设 *
  • show_full_output 保持默认 false
  • API Key / OAuth Token 只通过 ${{ secrets.XXX }} 引用,不硬编码
  • 定期轮换 API Key
  • 意识到一旦 actor 校验被绕过,攻击者拿到的就是 workflow permissions 里授予的全部能力——所以前两条最关键

验证

配置完成后,在 issue 里评论 @claude 你好,如果一切正常,Claude 会在几秒内回复。

参考文档:https://github.com/anthropics/claude-code-action

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

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

Clamp节点的数学原理

Clamp节点是ShaderGraph中基础且关键的数学运算模块,其核心算法基于线性代数中的区间映射理论。在图形学实践中,该节点通过以下数学公式确保数值稳定:

Output = (Input < Min) ? Min : (Input > Max) ? Max : Input

这种三段式条件判断机制保证输出值始终处于[Min,Max]闭区间内。从工程角度看,Clamp节点不仅有效防止数值溢出,还在以下场景中展现独特价值:

  • 物理准确性维护:在PBR材质系统中,确保金属度、粗糙度等物理参数符合现实约束
  • 艺术控制强化:为美术人员提供可视化参数安全边界,避免数值输入失误导致的视觉异常
  • 性能安全保障:防止极端数值在GPU计算中引发异常分支或计算溢出

核心功能:多维约束与动态控制

随着图形渲染需求的演进,Clamp节点已从简单数值限制发展为多维控制系统:

矢量维度智能处理

处理多维矢量时,Clamp节点支持分通道独立运算。以HSV颜色空间转换为例:

  • 对Hue分量实施环形钳制(0-1循环)
  • 对Saturation分量进行非对称限制(Min=0.3, Max=1.0)
  • 对Value分量执行动态范围压缩

时间轴集成方案

结合Time节点构建动画约束系统:

// 脉动光环效果示例 
float pulse = sin(_Time.y * 3.0) * 0.5 + 0.5; float clampedPulse = clamp(pulse, 0.2, 0.8);

此方案适用于UI动效、场景过渡等需要平滑节奏控制的场景。

参数配置:工程化实践指南

大型项目开发中,Clamp节点的配置需遵循严格工程规范:

数据类型一致性原则

  • 标量对齐:Min/Max为标量时自动广播至输入矢量所有分量
  • 维度匹配:矢量输入需确保Min/Max维度相同,避免隐式转换误差
  • 精度优化:移动端建议使用half精度,主机/PC平台可采用float精度

动态参数绑定策略

通过Blackboard实现运行时调控:

  1. 创建MaterialParameter类型的Range参数
  2. 设置合适默认值与边界条件
  3. 添加Tooltip注释说明参数用途
  4. 建立参数变更回调机制

实践案例

基础案例进阶:智能颜色管理系统

构建自适应环境光照的材质系统:

  1. 通过Light Probe获取场景光照强度
  2. 使用Clamp节点限制Albedo颜色反射率
  3. 根据平台性能动态调整钳制范围:
// 移动端使用更严格的范围 
#if defined(SHADER_API_MOBILE)
     float minReflectance = 0.1;
     float maxReflectance = 0.7;
#else
     float minReflectance = 0.05;
     float maxReflectance = 0.9;
#endif

进阶案例扩展:物理准确的天气系统

实现动态天气转换的着色器方案:

  1. 采集环境湿度、温度等物理参数
  2. 使用多层Noise模拟云层运动
  3. 通过Clamp控制降水强度与能见度范围
  4. 结合URP Volume系统实现无缝过渡

性能优化深度方案

针对不同硬件架构的优化策略:

  • TBDR架构(移动平台):利用片上内存减少钳制操作带宽
  • IMR架构(桌面平台):使用计算着色器批量处理钳制运算
  • 混合架构(游戏主机):基于Command Buffer的异步计算

常见问题与系统性解决方案

数值异常诊断体系

建立完整调试工作流:

  1. 可视化诊断:通过Custom Function节点输出中间值
  2. 范围追溯:使用Debug模式逐节点检查数值流
  3. 单元测试:创建Shader Graph测试场景验证边界条件

跨平台兼容性矩阵

平台 等效实现 注意事项
Unity URP Clamp节点 原生支持
Unreal Engine Clamp材质表达式 参数顺序差异
Godot Engine clamp()函数 需要手动编码
Three.js GLSL clamp() 语法差异

扩展应用:现代渲染管线集成

与Shader Feature深度集成

利用URP的Shader Keyword系统:

#pragma shader_feature_local _CLAMP_MODE_SOFT 
#ifdef _CLAMP_MODE_SOFT     
// 软钳制实现     
output = smoothstep(Min, Max, Input); 
#else     
// 硬钳制实现     
output = clamp(Input, Min, Max); 
#endif

实时GI系统协同

在全局光照计算中的特殊应用:

  • 限制反射探针强度避免过曝光
  • 控制光照贴图采样范围减少 artifacts
  • 管理体积雾浓度提升视觉层次感

最佳实践:企业级开发标准

代码质量管理

  1. 静态分析:使用Shader Graph linter检查节点连接合理性
  2. 性能剖析:集成Frame Debugger验证钳制操作开销
  3. 版本管理:建立Shader Graph资产变更追踪机制

团队协作规范

  • 文档标准化:每个Clamp节点必须包含设计意图说明
  • 参数审计:定期检查Blackboard参数的有效范围
  • 知识传承:建立Clamp节点使用案例库与反模式清单

持续集成流程

将Shader验证纳入CI/CD管道:

  • 自动化功能测试(边界值、异常值)
  • 性能基准测试(帧时间、内存占用)
  • 视觉回归测试(截图对比、差异分析)

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

Polyline 组件如何绘制渐变区域?

✅ 方案一:用 Polygon 替代 Polyline(最推荐)

如果你是想做“线下方渐变”(类似折线图面积图),可以:

  1. 把 Polyline 的点复制一份
  2. 补齐底部闭合路径
  3. Polygon 填充渐变

示例(以高德地图 JS API 为例)

const path = [
  [116.3, 39.9],
  [116.4, 39.8],
  [116.5, 39.85],
];

// 构造闭合区域(补到底部)
const polygonPath = [
  ...path,
  [116.5, 39.7],
  [116.3, 39.7],
];

const polygon = new AMap.Polygon({
  path: polygonPath,
  fillColor: 'rgba(0, 0, 255, 0.5)', // 基础色
  fillOpacity: 0.5,
});

👉 渐变实现:
高德原生不支持渐变填充,但你可以:

  • CanvasLayer 自绘渐变
  • 或使用 自定义覆盖物

✅ 方案二:CanvasLayer + 渐变(高级玩法)

如果你需要真正的渐变(linear-gradient),可以:

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');

// 创建渐变
const gradient = ctx.createLinearGradient(0, 0, 0, 200);
gradient.addColorStop(0, 'rgba(0,0,255,0.8)');
gradient.addColorStop(1, 'rgba(0,0,255,0)');

ctx.fillStyle = gradient;

// 画路径(类似 polygon)
ctx.beginPath();
ctx.moveTo(...);
ctx.lineTo(...);
ctx.fill();

然后通过:

new AMap.CanvasLayer({
  canvas: canvas,
  bounds: ...
});

👉 优点:

  • 完全自定义渐变方向/颜色
  • 可做动态效果

👉 缺点:

  • 需要自己处理坐标转换(经纬度 → 像素)

✅ 方案三:ECharts(如果你是数据可视化场景)

你之前用过 ECharts,这个其实最简单:

series: [{
  type: 'line',
  data: [...],
  areaStyle: {
    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
      { offset: 0, color: 'blue' },
      { offset: 1, color: 'transparent' }
    ])
  }
}]

👉 直接支持渐变区域,不用自己画


✅ 方案四:Polyline + 多条叠加(伪渐变)

如果不想用 Canvas,可以:

  • 画多条宽度不同、透明度不同的 Polyline
  • 模拟渐变效果
// 多层叠加
strokeOpacity: 0.3 / 0.2 / 0.1
strokeWeight: 10 / 20 / 30

👉 效果有限,但实现简单


💡 总结

你要的是“渐变区域”,关键不是 Polyline,而是:

需求 推荐方案
简单渐变区域 Polygon + 伪渐变
高质量渐变 CanvasLayer
数据图表 ECharts
快速hack 多Polyline叠加

构建无障碍组件之Spinbutton Pattern

Spinbutton Pattern 详解:构建无障碍数字输入控件

Spinbutton(旋转按钮,也称为 Number InputStepperNumeric SpinnerCounter)是一种输入控件,用于在预定义范围内选择离散数值。本文基于 W3C WAI-ARIA Spinbutton Pattern 规范,详解如何构建无障碍的数字输入组件。

一、Spinbutton 的定义与核心概念

1.1 什么是 Spinbutton

Spinbutton 是一种受限的数字输入控件,具有以下特征:

  • 值被限制在一组或一个范围内的离散值
  • 通常包含三个组件:
    • 文本输入框:显示当前值,通常是唯一可聚焦的组件
    • 增加按钮:用于增加数值
    • 减少按钮:用于减少数值
  • 支持直接编辑按钮调整两种方式
  • 支持小步长大步长调整

1.2 核心术语

术语 说明
Text Field 显示当前值的文本输入框
Increase Button 增加数值的按钮
Decrease Button 减少数值的按钮
Small Step 小步长调整(如按 1 增减)
Large Step 大步长调整(如按 10 增减)
Valid Value 允许范围内的有效值
┌─────────────────────────────────────────────────────────────┐
│                      Spinbutton Container                   │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │                                                     │    │
│  │  ┌─────────────────┐  ┌──────┐  ┌──────┐            │    │
│  │  │                 │  │  ▲   │  │  ▼   │            │    │
│  │  │   Value: 30     │  │  +   │  │  -   │            │    │
│  │  │                 │  │      │  │      │            │    │
│  │  └─────────────────┘  └──────┘  └──────┘            │    │
│  │                                                     │    │
│  │  ┌─────────────────────────────────────────────┐    │    │
│  │  │  role="spinbutton"                          │    │    │
│  │  │  aria-valuenow="30"                         │    │    │
│  │  │  aria-valuemin="0"                          │    │    │
│  │  │  aria-valuemax="100"                        │    │    │
│  │  │  aria-label="Quantity"                      │    │    │
│  │  └─────────────────────────────────────────────┘    │    │
│  │                                                     │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  Keyboard: ↑↓ (±1) | Page Up/Down (±10) | Home/End (Min/Max)│
│                                                             │
└─────────────────────────────────────────────────────────────┘

1.3 典型应用场景

  • 数量选择器:购物车商品数量、酒店预订人数
  • 时间选择器:小时、分钟选择
  • 日期选择器:日、月、年选择
  • 数值调节:音量控制、亮度调节
  • 评分输入:1-5 星评分

二、WAI-ARIA 角色与属性

2.1 基本角色

Spinbutton 使用 role="spinbutton" 标记。

<input
  type="text"
  role="spinbutton"
  aria-label="数量"
  aria-valuenow="1"
  aria-valuemin="0"
  aria-valuemax="10"
  value="1" />

2.2 必需属性

属性 说明 示例值
role="spinbutton" 标记为旋转按钮角色 -
aria-valuenow 当前值 "1"
aria-valuemin 最小值(如果有) "0"
aria-valuemax 最大值(如果有) "10"
aria-labelaria-labelledby 可访问标签 "数量"

2.3 可选属性

属性 说明 示例值
aria-valuetext 用户友好的值描述 "Monday"
aria-invalid 值是否无效 "true" / "false"

2.4 属性详解

aria-valuetext

aria-valuenow 的值不够友好时,使用 aria-valuetext 提供更易理解的描述:

<!-- 星期选择器:数值 1 显示为 "Monday" -->
<input
  type="text"
  role="spinbutton"
  aria-label="星期"
  aria-valuenow="1"
  aria-valuemin="1"
  aria-valuemax="7"
  aria-valuetext="Monday"
  value="Monday" />
aria-invalid

当值超出允许范围时,设置 aria-invalid="true"

<input
  type="text"
  role="spinbutton"
  aria-label="数量"
  aria-valuenow="15"
  aria-valuemin="0"
  aria-valuemax="10"
  aria-invalid="true"
  value="15" />

注意:大多数实现会阻止输入无效值,但在某些场景下可能无法完全阻止。

三、键盘交互规范

3.1 基本键盘交互

按键 功能
↑ Up Arrow 增加数值(小步长)
↓ Down Arrow 减少数值(小步长)
Home 设置值为最小值
End 设置值为最大值
Page Up(可选) 增加数值(大步长)
Page Down(可选) 减少数值(大步长)

3.2 文本编辑键盘交互

如果文本框允许直接编辑,还支持以下标准单行文本编辑键:

  • 可打印字符:在文本框中输入字符
  • 光标移动键:左右箭头、Home、End
  • 选择键:Shift + 方向键
  • 文本操作键:复制、粘贴、删除等

重要提示:确保 JavaScript 不干扰浏览器提供的文本编辑功能。

3.3 焦点行为

  • 操作过程中焦点始终保持在文本框
  • 不需要将焦点移到增减按钮上

四、实现方式

4.1 基础 Spinbutton 结构

<div class="spinbutton-container">
  <label for="quantity">数量</label>
  <div class="spinbutton-wrapper">
    <input
      type="text"
      id="quantity"
      class="spinbutton"
      role="spinbutton"
      aria-label="数量"
      aria-valuenow="1"
      aria-valuemin="0"
      aria-valuemax="10"
      value="1" />
    <div class="spinbutton-buttons">
      <button
        type="button"
        class="spinbutton-up"
        aria-label="增加"
        tabindex="-1"></button>
      <button
        type="button"
        class="spinbutton-down"
        aria-label="减少"
        tabindex="-1"></button>
    </div>
  </div>
</div>

4.2 JavaScript 实现

class Spinbutton {
  constructor(element) {
    this.input = element;
    this.min = parseFloat(this.input.getAttribute('aria-valuemin')) || 0;
    this.max = parseFloat(this.input.getAttribute('aria-valuemax')) || 100;
    this.smallStep = 1;
    this.largeStep = 10;

    this.init();
  }

  init() {
    // 键盘事件
    this.input.addEventListener('keydown', this.handleKeyDown.bind(this));

    // 直接编辑
    this.input.addEventListener('change', this.handleChange.bind(this));
    this.input.addEventListener('blur', this.handleBlur.bind(this));

    // 按钮点击
    const container = this.input.closest('.spinbutton-wrapper');
    const upButton = container.querySelector('.spinbutton-up');
    const downButton = container.querySelector('.spinbutton-down');

    if (upButton) {
      upButton.addEventListener('click', () => this.increment(this.smallStep));
    }
    if (downButton) {
      downButton.addEventListener('click', () =>
        this.decrement(this.smallStep),
      );
    }
  }

  handleKeyDown(e) {
    const currentValue =
      parseFloat(this.input.getAttribute('aria-valuenow')) || 0;

    switch (e.key) {
      case 'ArrowUp':
        e.preventDefault();
        this.increment(this.smallStep);
        break;
      case 'ArrowDown':
        e.preventDefault();
        this.decrement(this.smallStep);
        break;
      case 'PageUp':
        e.preventDefault();
        this.increment(this.largeStep);
        break;
      case 'PageDown':
        e.preventDefault();
        this.decrement(this.largeStep);
        break;
      case 'Home':
        e.preventDefault();
        this.setValue(this.min);
        break;
      case 'End':
        e.preventDefault();
        this.setValue(this.max);
        break;
    }
  }

  handleChange() {
    const value = parseFloat(this.input.value);
    if (!isNaN(value)) {
      this.setValue(value);
    }
  }

  handleBlur() {
    // 失去焦点时验证并修正值
    const value = parseFloat(this.input.value);
    if (isNaN(value)) {
      this.setValue(this.min);
    } else {
      this.setValue(value);
    }
  }

  increment(step) {
    const currentValue =
      parseFloat(this.input.getAttribute('aria-valuenow')) || 0;
    this.setValue(currentValue + step);
  }

  decrement(step) {
    const currentValue =
      parseFloat(this.input.getAttribute('aria-valuenow')) || 0;
    this.setValue(currentValue - step);
  }

  setValue(value) {
    // 限制在范围内
    value = Math.max(this.min, Math.min(this.max, value));

    // 更新 ARIA 属性
    this.input.setAttribute('aria-valuenow', value);

    // 更新显示值
    this.input.value = value;

    // 更新有效性状态
    const isValid = value >= this.min && value <= this.max;
    this.input.setAttribute('aria-invalid', !isValid);
  }
}

// 初始化
const spinbuttons = document.querySelectorAll('[role="spinbutton"]');
spinbuttons.forEach((spinbutton) => new Spinbutton(spinbutton));

4.3 带 aria-valuetext 的示例

class WeekdaySpinbutton extends Spinbutton {
  constructor(element) {
    super(element);
    this.weekdays = [
      '',
      'Monday',
      'Tuesday',
      'Wednesday',
      'Thursday',
      'Friday',
      'Saturday',
      'Sunday',
    ];
    this.smallStep = 1;
    this.largeStep = 1; // 星期没有大步长
  }

  setValue(value) {
    // 限制在范围内
    value = Math.max(this.min, Math.min(this.max, value));

    // 更新 ARIA 属性
    this.input.setAttribute('aria-valuenow', value);
    this.input.setAttribute('aria-valuetext', this.weekdays[value]);

    // 显示星期名称
    this.input.value = this.weekdays[value];
  }
}

五、最佳实践

5.1 提供清晰的标签

始终为 Spinbutton 提供描述性的标签:

<!-- 好的示例 -->
<label for="adults">成人数量</label>
<input
  type="text"
  id="adults"
  role="spinbutton"
  aria-label="成人数量"
  ... />

<!-- 不好的示例 -->
<input
  type="text"
  role="spinbutton"
  ... />

5.2 设置合理的范围

根据实际场景设置最小值和最大值:

<!-- 好的示例:酒店预订成人数量 -->
<input
  type="text"
  role="spinbutton"
  aria-label="成人数量"
  aria-valuemin="1"
  aria-valuemax="10"
  ... />

<!-- 不好的示例:没有限制 -->
<input
  type="text"
  role="spinbutton"
  ... />

5.3 使用 aria-valuetext 增强可读性

当数值不够直观时,使用 aria-valuetext

<!-- 好的示例:月份选择 -->
<input
  type="text"
  role="spinbutton"
  aria-label="月份"
  aria-valuenow="1"
  aria-valuemin="1"
  aria-valuemax="12"
  aria-valuetext="January"
  value="January" />

5.4 验证用户输入

阻止无效字符输入,或在失去焦点时修正值:

// 阻止非数字输入
spinbutton.addEventListener('keypress', (e) => {
  if (!/\d/.test(e.key)) {
    e.preventDefault();
  }
});

// 失去焦点时验证
spinbutton.addEventListener('blur', () => {
  const value = parseInt(spinbutton.value);
  if (isNaN(value) || value < min || value > max) {
    // 修正为有效值
    setValue(Math.max(min, Math.min(max, value || min)));
  }
});

5.5 考虑移动端体验

在移动设备上,考虑使用数字键盘:

<input
  type="number"
  inputmode="numeric"
  pattern="[0-9]*"
  role="spinbutton"
  ... />

5.6 提供视觉反馈

  • 无效值时显示错误状态
  • 焦点状态清晰可见
  • 按钮悬停效果
[role='spinbutton'][aria-invalid='true'] {
  border-color: #ef4444;
  background-color: #fef2f2;
}

[role='spinbutton']:focus {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

六、常见错误

6.1 忘记设置 aria-valuenow

<!-- 错误 -->
<input
  type="text"
  role="spinbutton"
  value="5" />

<!-- 正确 -->
<input
  type="text"
  role="spinbutton"
  aria-valuenow="5"
  value="5" />

6.2 按钮可聚焦

<!-- 错误:按钮不应该可聚焦 -->
<button class="spinbutton-up"></button>

<!-- 正确:按钮设置 tabindex="-1" -->
<button
  class="spinbutton-up"
  tabindex="-1"></button>

6.3 忽略键盘交互

只实现按钮点击,不实现键盘支持(方向键、Home/End)。

6.4 不验证输入值

允许用户输入超出范围的值或无效字符。

七、Spinbutton vs 其他输入控件

7.1 Spinbutton vs Slider

特性 Spinbutton Slider
输入方式 键盘输入 + 按钮 拖拽滑块
适用场景 精确数值、离散值 连续范围、粗略选择
精度 中等
典型用例 数量、时间 音量、亮度

7.2 Spinbutton vs 普通文本输入

特性 Spinbutton 普通文本输入
值限制 有最小/最大值 无限制
步长调整 支持 不支持
辅助技术 读出当前值和范围 只读出文本
典型用例 年龄、评分 姓名、地址

八、总结

构建无障碍的 Spinbutton 组件需要关注:

  1. 正确的角色:使用 role="spinbutton"
  2. 必需的属性aria-valuenowaria-valueminaria-valuemaxaria-label
  3. 可选属性aria-valuetextaria-invalid
  4. 完整的键盘支持:方向键调整、Page Up/Down 大步长、Home/End 快捷键
  5. 直接编辑支持:允许用户直接输入值
  6. 输入验证:阻止无效字符,修正超出范围的值
  7. 清晰的标签:帮助用户理解控件用途
  8. 按钮不可聚焦:只有文本框可聚焦

遵循 W3C Spinbutton Pattern 规范,我们能够创建既实用又无障碍的数字输入控件,为所有用户提供便捷的数值选择体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

古法编程: React思维模型快速建立

d179110117a86708aa993eb40f13a3c4_720.jpg

使用React的方法论与思想,方便我这个古法编程者Vibe Coding的时候更能游刃有余。

从原始操作DOM API,编写每个操作步骤的命令式,到使用React负责处理DOM的细节,程序员只描述最终结果的声明式编程,最终实现DOM响应数据的变化而自发做出更改的响应式。其中声明式编程,以现代的我的视角了看很像Vibe Coding,我们只需要描述需求即可。

一层一层的抽象出来。命令式->声明式->Vibe Coding(氛围编程)

React思维模型

HTML上凿洞,动态数据露脸

就像旅游景区镂空的拍照墙,我们只需要露个头,一个完美的姿势拍照就好了。

import { useState } from "react";

export default function App() {
  let [who, setWho] = useState("Pkmer");
  return (
    <div className="icons">💪{who}👊</div>
  );
}

JSX是伪装成HTML的JavaScript代码

React开发工具将JSX标签自动转化为相应的JavaScript代码

function App(){
    let [username] = userState(() => "Pkmer")
    return <div className="container">
        <text className="name">💪{username}👊</text>
        <button />
    <div>
}

转化成的JavaScript

import {jsx as _jsx} from "react"

function App(){
    let [username] = userState(() => "Pkmer")
    return _jsx("div",{
        className: "container",
        children: [
            _jsx("text",{className: "name",children: ["💪",username,"👊"]}),
            _jsx("button")
        ]
    })
}

一次组件渲染,一页手翻书

组件每次渲染的时候,都会重新执行一次

function ComponentXxx(){
    console.log("run run run...")
    return // ...jsx...
}

不可变特性(快照)

组件的state数据是不可变的,每一次都只是一个快照,要想更新数据,需要推倒重建。一个组件就像一座大厦一样,就算只给窗户换一种颜色,那么这个大厦就得重建(重新渲染)

下面的代码第一种错误的示范中:在React看来house还是原来的那个house,同一个引用,React会不会重新渲染。第二种正确的方式: house已经不再是原来的house了,尽管大部分内容是一样的,但是它已经是一个新的house.

const [house,setHouse] = useState({windowColor: "蓝色",floors: 2});

// ❌️这样修改不会有效果,house只是快照
house.windowColor = "白色"
setHouse(house)  


// 正确✅️
const newHouse = [...hourse,windowColor: "白色"]
setHouse(newHouse)

可以这样理解:React为了性能考虑,没有进行深层比较,这里只是浅层的比较,发现house的引用变了,才更新。

组件的动态组合方式:children

相比搭积木一样一个一个组件固定的组合方式,children这个特殊的属性,提供了动态组件组合方式

function Dialog({children}){
    return <div>
        <div>{children}</div>
    </div>
}

// 使用
function App(){
    return <Dialog>
        <Heading>I Love Coding</Heading>
        <Slogon>此刻我在深圳图书馆-北馆,充电学习</Slogon>
    </Dialog>
}

传送工程师的接力:单向数据流

props层层传递

数据所有者要想将数据传递给消费者,需要进行层层传递,尽管中间传递者并不消费这个数据

image.png

context电梯,按需取货

在提供数据的楼层(上层)将包裹(数据),放入电梯(context)。电梯往下走,下面的哪个楼层需要这个包裹(数据),自己在对应的楼层打开电梯取走。

const ThemeContext = React.createContext("light")

// 上层数据所有者,提供数据,放入电梯
function Home(){
    const [theme,setTheme] = useState("light")
    
    return <ThemeContext.Provider value={theme}>
        <Page />
    </ThemeContext.Provider>
}

App->Page->Header->Logo,中间层Page,Header都不需要数据,只有Logo需要。

function Page(){
    return <div>
        <Header />
        <Content />
        <Footer />
    </div>
}

function Header(){
    return <div>
        <Logo />
        <Title />
    </div>
}

function Logo(){
    // 在我这层,打开电梯取出包裹(数据)
    const theme = useContext(ThemContext)
    
    return theme === "dark" ? <DarkLogo /> : <LightLogo />
}

便携式虫洞

由于数据是单向传递的,如果子组件要想改变数据,需要数据提供层进行修改。想要修改上层数据,上层提供则需要将权力下放。而这个下放的过程,就像虫洞一样,子组件员工可以将手伸向上层老板的办公室,直接进行签合同。

function BossOffice() {
  const [contract, setContract] = useState('初始合同');

  // 老板下放修改权限的方法
  const signContract = (newContract) => {
    setContract(newContract);
  };

  return (
    <div>
      <h1>老板办公室 - 当前合同: {contract}</h1>
      {/* 将修改权限通过 props 下放给子组件 */}
      <EmployeePortal onSignContract={signContract} />
    </div>
  );
}

function EmployeePortal({ onSignContract }) {
  const handleSign = () => {
    // 子组件员工直接调用上层传来的方法,就像穿过虫洞伸手改数据
    onSignContract('员工新签的合同');
  };

  return (
    <div>
      <h2>员工虫洞通道</h2>
      <button onClick={handleSign}>伸向老板办公室签合同</button>
    </div>
  );
}

Hook勾子将数据放入React大海又勾回来

Hook将函数组件内的数据保存到外部环境,以备下次渲染所用。

  • 保存只读数据: useMemo(保存函数的返回值),useCallback(保存的是回调函数本身)
  • 保存可变数据,更改时触发渲染: useState和useReducer(更底层)
  • 保存可变数据,更改时不触发渲染: useRef

useEffect与生命周期回调方法

useEffect完全可以代替类组件中的三个生命周期回调方法

class Xxx extends Component{
    // 挂载以后运行
    componentDidMount{}
    
    // 每次更新以后运行
    componentDidUpdate(prevProps){}
    
    // 将要卸载前运行
    componentWillUnmount(){}

}

useEffect对应的行为方式

useEffect(() => {
    // 数组参数为空,只在组件第一次渲染时调用
},[])

useEffect(() => {
    // 当数组中的元素变化更新时会执行
},[要变化的值,或者函数]) // 要诚实的告诉哪些值会变。


useEffect(() => {
    // 省略数组。将在组件每次渲染后运行此处的代码
})

useEffect的真正职责:管理组件副作用

药物的副作用并不是药物的目的,当然是越少越好。而程序里的副作用却是我们有意而为,是程序的功能之一

所谓副作用,是函数组件与其周边环境发生了交互的额外任务,比如操作window对象,访问网络请求后端api,读取本地文件等,这些作用都超出了当前函数组件的范围。函数组件关心的是state和props

function Boat(props){
    useEffect(() => {
        const listener = ...
        window.addEventListener('keydown',listener)
        ...
        return () => {
            window.removeListener('keydown',listener)
        }
    },[])

}

幽灵依赖:本地跑得好好的,线上部署却炸了

这是我在开发 My-Notion 项目时踩的一个真实坑——本地开发一切正常,推到 GitHub 后 CI/CD 构建直接失败。排查后发现是幽灵依赖(Phantom Dependencies)问题,而罪魁祸首竟然是 AI Agent 用错了包管理器。

问题复现

某天我 push 代码后,GitHub Actions 的 Build 流水线报红了:

Error: Cannot find module '@qdrant/js-client-rest'

奇怪,我本地跑得好好的啊。

看了一下代码,packages/ai/rag/qdrantVectorStore.ts 里确实用了 @qdrant/js-client-rest

import { QdrantClient } from "@qdrant/js-client-rest";

再看 packages/ai/package.json,依赖声明是这样的:

{
  "dependencies": {
    "@langchain/qdrant": "^1.0.1",
    // ... 其他依赖
  }
}

注意——@qdrant/js-client-rest 并没有在 package.json 中声明,但代码里直接 import 了它。

本地能跑是因为 @langchain/qdrant 依赖了 @qdrant/js-client-rest,而 npm 在安装时会把它提升(hoist)到 node_modules 根目录,所以代码能找到这个包。但线上用 pnpm 构建时,pnpm 严格的依赖结构不允许访问未声明的依赖,直接报错。

什么是幽灵依赖

幽灵依赖(Phantom Dependencies)是指代码中实际使用了某个包,但该包没有在 package.json 中显式声明,而是通过其他包的依赖间接引入的

用一张图来解释:

你的代码
  └─ import { QdrantClient } from "@qdrant/js-client-rest"  ← 直接使用
       ↑
       │  (没有在 package.json 中声明)
       │
@langchain/qdrant (package.json 中声明了)
  └─ @qdrant/js-client-rest  ← 间接依赖

你的代码能访问 @qdrant/js-client-rest,完全是因为 @langchain/qdrant 装了它。但这个关系是隐式的、脆弱的——一旦 @langchain/qdrant 升级版本不再依赖 @qdrant/js-client-rest,或者换了一个替代包,你的代码就会莫名其妙地挂掉。

为什么 npm 会有幽灵依赖,pnpm 不会

核心区别在于 node_modules 的目录结构。

npm 的扁平结构(Flat)

npm v3+ 采用扁平化安装,所有依赖(包括间接依赖)都会被提升到 node_modules 根目录:

node_modules/
├── @qdrant/js-client-rest/     ← 被提升上来了,你的代码能直接访问
├── @langchain/qdrant/
│   └── node_modules/
│       └── (空的,因为被提升了)
├── langchain/
├── openai/
└── ...

这种设计的好处是安装快、兼容性好,但代价就是幽灵依赖——你可以 import 任何被提升到根目录的包,不管你有没有声明它。

pnpm 的严格结构(Strict)

pnpm 采用软链接 + 硬链接的方式,每个包只能访问自己声明的依赖:

node_modules/
├── .pnpm/                           ← 真实存储位置
│   ├── @qdrant+js-client-rest@1.17.0/
│   │   └── node_modules/
│   │       └── @qdrant/js-client-rest/
│   └── @langchain+qdrant@1.0.1/
│       └── node_modules/
│           ├── @langchain/qdrant/
│           └── @qdrant/js-client-rest/  ← 软链接,只有 @langchain/qdrant 能访问
├── @langchain/qdrant/               ← 软链接到 .pnpm
├── langchain/                        ← 软链接到 .pnpm
└── (没有 @qdrant/js-client-rest!)   ← 你的代码找不到它

在 pnpm 的结构下,@qdrant/js-client-rest 只存在于 @langchain/qdrant 的依赖树中,你的代码如果不显式声明,根本访问不到。

这正是 pnpm 的设计初衷——通过严格的依赖隔离,在开发阶段就暴露幽灵依赖问题,而不是等到线上部署才炸。

这个坑是怎么产生的

在我的场景中,问题出在 AI Agent 用了 npm install 而不是 pnpm add 来安装包。

我:帮我安装 @langchain/qdrant
Agent:npm install @langchain/qdrant   ← 用了 npm!

npm 安装后,@qdrant/js-client-rest 被提升到了 node_modules 根目录。Agent 在写代码时,直接 import 了 @qdrant/js-client-rest,本地运行完全没问题——因为 npm 的扁平结构让它"看得见"这个包。

但 CI/CD 环境用的是 pnpm,严格的依赖结构直接暴露了这个幽灵依赖。

怎么解决

1. 显式声明依赖

把代码中实际使用的间接依赖,显式添加到 package.json 中:

pnpm add @qdrant/js-client-rest
  {
    "dependencies": {
      "@langchain/qdrant": "^1.0.1",
+     "@qdrant/js-client-rest": "^1.17.0"
    }
  }

这是最根本的解决方案——你用了什么就声明什么,不依赖其他包的间接引入。

2. 清理并重装依赖

如果项目之前用 npm 装过包,node_modules 里可能残留着扁平结构下的幽灵依赖。需要彻底清理后用 pnpm 重装:

# 删除所有 node_modules
find . -name "node_modules" -type d -prune -exec rm -rf {} +

# 删除 lock 文件(如果有 package-lock.json)
find . -name "package-lock.json" -delete

# 用 pnpm 重新安装
pnpm install

重装后,pnpm 的严格结构会立刻暴露所有幽灵依赖——import 不到的包就是没声明的,一个个补上就行。

3. 让 AI Agent 统一使用 pnpm

问题的根源是 Agent 用了 npm。为了防止再犯,我让 Agent 写了一个全局 Skill,后续所有安装包的操作都强制使用 pnpm:

## 包管理器规则
- 本项目使用 pnpm 作为包管理器
- 安装依赖:pnpm add <package>
- 安装开发依赖:pnpm add -D <package>
- 全局禁止使用 npm install / yarn add
- Monorepo 中安装到指定包:pnpm --filter <package-name> add <dep>

这样 Agent 每次对话都会读取这条规则,不会再出现用错包管理器的问题。

如何检测幽灵依赖

除了等 pnpm 报错,还有更主动的检测方式:

pnpm 的 --strict-peer-dependencies

pnpm install --strict-peer-dependencies

安装时严格检查 peer dependencies,有冲突直接报错而不是静默跳过。

dpdm 工具

dpdm 可以扫描代码中的依赖引用,找出未声明的依赖:

npx dpdm src/index.ts

knip 工具

knip 可以检测未使用的依赖、未声明的依赖、以及各种死代码:

npx knip

总结

npm pnpm
依赖结构 扁平化,间接依赖提升到根目录 严格隔离,只能访问声明的依赖
幽灵依赖 本地不会报错,线上可能炸 开发阶段直接暴露
安装速度 较慢 快(硬链接 + 内容寻址)
磁盘占用 每个项目独立存储 全局存储,多项目共享

幽灵依赖的本质是依赖声明和实际使用不一致。npm 的扁平结构掩盖了这个问题,让它在本地"看起来没问题",但线上部署时就会暴露。pnpm 的严格结构在开发阶段就强制你声明所有使用的依赖,虽然前期多写几行 package.json,但换来的是部署时的安心。

如果你也在用 pnpm + Monorepo,建议:

  1. 永远不要混用 npm 和 pnpm——一旦用 npm 装过包,node_modules 结构就被污染了
  2. 代码中 import 了什么,package.json 就声明什么——不要依赖间接依赖
  3. 让 AI Agent 也遵守包管理器规则——写好项目规则文件,防止 Agent 用错工具
  4. CI/CD 用 pnpm 构建——线上构建和本地开发保持一致,问题在本地就能发现

本文基于 My-Notion 项目的真实踩坑经历撰写,项目是一个 AI 原生的个人版 Notion,欢迎 Star ⭐

lint-staged与ls-lint配合使用时的陷阱

问题背景

在最近的一个 React + TypeScript 项目中,我使用了 lint-staged 配合 ls-lint 来实现 Git 提交时的文件名规范自动检查。配置看起来很简单:

{
  "lint-staged": {
    "*.{ts,tsx,js,jsx}": ["eslint --fix", "ls-lint"]
  }
}

.ls-lint.yml 配置文件规定了命名规则:

ignore:
  - node_modules
  - .git
  - dist

ls:
  .tsx: PascalCase # .tsx 文件应该使用帕斯卡命名(首字母大写)
  .ts: kebab-case # .ts 文件使用短横线命名
  .js: kebab-case
  .css: kebab-case

按照这个规则,src/main.tsx 这样的文件名(小写开头)应该在提交时被拦截并报错。但奇怪的是,git commit 时检测通过了,而手动执行却报错了。

问题复现

场景一:手动执行 ls-lint(相对路径)

$ npx ls-lint src/main.tsx
src/main.tsx failed for `.tsx` rules: pascalcase

✅ 符合预期:检测到命名不规范,退出码为 1。

场景二:Git 提交时通过 lint-staged 执行

$ git add src/main.tsx
$ git commit -m "test"
✔ Preparing...
✔ Running tasks...
  ✔ eslint --fix
  ✔ ls-lint
✔ Applying modifications...

❌ 不符合预期:ls-lint git提交成功,没有检测到命名问题!

深入调试

为了找出原因,我创建了一个调试脚本来查看 lint-staged 实际传递给 ls-lint 的参数:

#!/bin/bash
# test-ls-lint.sh
echo "Arguments received: $@" >> /tmp/lint-staged-debug.log
echo "Number of arguments: $#" >> /tmp/lint-staged-debug.log
npx ls-lint "$@" 2>&1
exit $?

修改 package.json 临时使用这个脚本:

{
  "lint-staged": {
    "*.{ts,tsx,js,jsx}": ["eslint --fix", "./test-ls-lint.sh"]
  }
}

再次提交后查看日志:

$ cat /tmp/lint-staged-debug.log
Arguments received: /Users/jesse/Web/study/React/my-app/src/main.tsx
Number of arguments: 1

关键发现lint-staged 传递的是绝对路径,而不是相对路径!

验证假设

我分别测试了相对路径和绝对路径的情况:

# 测试 1:相对路径
$ npx ls-lint src/main.tsx
src/main.tsx failed for `.tsx` rules: pascalcase
$ echo $?
1  # 报错,符合预期

# 测试 2:绝对路径
$ npx ls-lint /Users/jesse/Web/study/React/my-app/src/main.tsx
$ echo $?
0  # 通过,不符合预期!

真相大白ls-lint 在处理绝对路径时,无法正确应用 .ls-lint.yml 中定义的命名规则,导致检测被静默绕过。

根本原因分析

经过分析和查阅 ls-lint 的文档,我发现:

  1. ls-lint 的设计初衷:它是一个基于项目结构的文件命名 lint 工具,需要根据文件相对于项目根目录的路径来应用规则。

  2. 绝对路径的问题:当传入绝对路径时,ls-lint 无法正确解析文件在项目中的相对位置,导致规则匹配失败或被忽略。

  3. lint-staged 的行为:默认情况下,lint-staged 会将暂存文件的绝对路径作为参数传递给命令,这是为了保证命令在任何工作目录下都能正确找到文件。

这就造成了一个矛盾:

  • lint-staged 传递绝对路径(为了保证可靠性)
  • ls-lint 需要相对路径(为了正确应用规则)

官方回应

这个问题已经被社区发现并报告给 ls-lint 开发团队。在 GitHub Issue #321 中,有开发者提出了相同的疑问。

好消息:ls-lint 开发团队已经确认了这个问题,并计划在 v2.4.0 版本中添加对绝对路径的支持。 但在 v2.4.0 发布之前,我们仍然需要使用本文提到的解决方案来确保文件名校验正常工作。

最终解决方案

经过多次尝试,我找到了最优雅的解决方案: 在 Husky 的 pre-commit 钩子中直接调用 ls-lint,并使用 git diff --staged --name-only 获取暂存文件的相对路径列表。

实现步骤

1. 从 lint-staged 配置中移除 ls-lint

修改 package.json,将 ls-lintlint-staged 配置中移除:

{
  "lint-staged": {
    "*.{ts,tsx,js,jsx}": ["eslint --fix"],
    "*.{css,scss}": "stylelint --fix"
  }
}

2. 在 pre-commit 钩子中添加 ls-lint 检查

编辑 .husky/pre-commit 文件,在执行 lint-staged 之前添加 ls-lint 检查:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

# 检查暂存文件的命名规范(使用相对路径)
pnpm exec ls-lint $(git diff --staged --name-only)

# 执行其他 lint-staged 检查
pnpm exec lint-staged

关键点

  • git diff --staged --name-only 返回的是相对路径(如 src/main.tsx),这正是 ls-lint 所需要的
  • lint-staged 之前执行,可以尽早发现命名问题
  • 使用 pnpm exec 确保使用项目本地安装的 ls-lint 版本

验证效果

现在再次提交不符合命名规范的文件:

$ git add src/main.tsx
$ git commit -m "test"

src/main.tsx failed for `.tsx` rules: pascalcase
husky - pre-commit hook exited with code 1 (error)

✅ 成功拦截:现在可以正确检测到命名问题了!

提交符合规范的文件:

$ git add src/MainComponent.tsx
$ git commit -m "feat: add main component"

✔ Preparing...
✔ Running tasks...
✔ Applying modifications...

✅ 正常通过:符合规范的文件可以正常提交。

注意事项

1. 首次提交时的边界情况

如果是第一次提交(没有任何历史提交),git diff --staged --name-only 仍然可以正常工作,它会列出所有暂存的文件。

2. 空暂存区的处理

如果暂存区为空,git diff --staged --name-only 会返回空字符串,ls-lint 会自动跳过检查,不会报错。

3. 删除文件的处理

git diff --staged --name-only 也会包含被删除的文件。如果这些文件已经不存在,ls-lint 会忽略它们,不会影响检查结果。

4. 团队协作建议

  • 确保所有团队成员都安装了 Husky:pnpm exec husky install
  • 在项目的 README.md 中说明命名规范和检查机制
  • 考虑在 CI/CD 流程中也加入 ls-lint 检查,作为双重保障

总结

这次问题的根本原因是:lint-staged 传递绝对路径,而 ls-lint 需要相对路径才能正确应用规则。

最终的解决方案是:在 Husky 的 pre-commit 钩子中使用 git diff --staged --name-only 获取相对路径列表,然后直接传递给 ls-lint,同时从 lint-staged 配置中移除 ls-lint

这个方案简洁、高效、可靠,完美解决了绝对路径导致的检测失效问题。希望这篇文章能帮助你避免类似的坑!

参考资料

Vue 响应式对象异步赋值作为 Props:二次渲染问题与组件设计哲学

前言:一个看似简单的场景

<!-- 父组件 -->
<template>
  <Article
    :title="articleTitle"
    :description="articleDescription"
  />
</template>

<script setup>
import { ref } from 'vue'
import Article from './Article.vue'

const articleTitle = ref("")
const articleDescription = ref("")

// 这里发起请求
fetchArticles(user, publishedDate).then(response => {
  articleTitle.value = response.data.title // 响应式数据发生变化,派发更新
  articleDescription.value = response.data.description
})
</script>

上面的代码会导致子组件 <Article> 渲染两次:第一次收到空字符串,第二次收到真实数据。

这看起来只是一个技术细节。但当开发者把目光从“如何解决”转向“为什么会产生这个问题”时,会发现它触及了 Vue 组件设计中一个深层的问题——数据所有权与副作用边界之间的张力


问题复现——二次渲染的本质

执行过程

  1. 组件挂载前articleTitlearticleDescription 初始值为空字符串。
  2. 首次渲染:子组件收到 { title: "", description: "" },完成第一次渲染(空白或加载占位)。
  3. 异步数据返回articleTitle.value = ... 触发 ref 的 setter。
  4. 父组件重新渲染:Vue 检测到响应式数据变化,重新执行父组件的 render 函数。
  5. 子组件二次渲染:子组件因为 props 变化而再次更新,显示正确内容。

结果:子组件渲染了两次(一次空数据,一次真实数据)。

为什么需要关注这个问题?

并非所有场景都需要关注二次渲染。但在以下情况中,它会成为实际问题:

  • 子组件内部开销较大:图表库、大量 DOM 计算等重复执行,造成性能浪费。
  • 子组件依赖 props 发起副作用:比如 watchEffect 根据 props 去请求图片或接口,导致请求重复发送。
  • 动画或过渡异常:元素从无到有,又从有到空再到有,造成视觉闪烁。
  • 表单组件收到两次初始值:可能导致用户输入被意外重置。

一个常见的“改进”及其设计困境

面对上述问题,很多开发者会做出一个看似更优的选择:

<!-- 父组件只传递 ID,让子组件自己获取数据 -->
<UserArticleDisplay :article-id="articleId" />

子组件内部:

<script setup>
const props = defineProps<{ articleId: string }>()
const article = ref(null)

watch(() => props.articleId, async (id) => {
  if (id) {
    article.value = await fetchArticle(id)
  }
}, { immediate: true })
</script>

效果:子组件只渲染一次(数据加载完成后直接渲染真实内容,中间用 loading 态占位)。

设计困境:副作用归属问题

传递的内容 副作用的承担者 渲染次数 副作用可见性
title / description(数据) 父组件 2 次 副作用在父组件,透明
articleId(标识符) 子组件 1 次 副作用被子组件隐藏,不透明

传递 articleId 意味着:子组件不仅接收一个 ID,还被默认有能力、有责任去获取数据并处理网络请求。这相当于将副作用责任从父组件转移到了子组件。

更深层的矛盾:声明式与命令式的冲突

Vue 本质上是声明式的:开发者声明“UI 应该是什么样”,框架帮助实现。

但网络请求本质上是命令式的:在某个时刻“命令”组件去获取数据。

当传递 articleId 时,实际上是在声明式的外壳里隐藏了一个命令式的副作用:

<!-- 从代码上看是声明 -->
<Article :article-id="id" />

<!-- 实际运行时等价于命令 -->
<Article @mount="fetchArticle(id)" @update:id="fetchArticle(newId)" />

这是声明式 UI 与命令式副作用之间的一个固有矛盾。没有绝对正确的答案,只有基于具体场景的权衡。


解决方案的分类与取舍

方案一:显式副作用设计

明确告知子组件需要产生副作用,并暴露钩子供父组件参与。

<Article 
  :article-id="id" 
  :fetch-on-mount="true"
  @loading="showSpinner"
  @error="handleError"
/>

设计立场:副作用是必要的,但必须可见、可控。使用者清楚知道这个组件会发起网络请求。

方案二:副作用保留在父组件,保持子组件纯净

保持子组件为纯展示组件,父组件负责所有数据获取。

<!-- 父组件获取数据,子组件只负责渲染 -->
<Article :title="title" :description="description" />

配合 v-if 缓解二次渲染:

<Article
  v-if="articleTitle && articleDescription"
  :title="articleTitle"
  :description="articleDescription"
/>

设计立场:子组件应该是可预测的纯函数。二次渲染是声明式 UI 的合理代价,可以通过条件渲染避免。

方案三:提取独立服务层

// 独立的 ArticleService
const articleService = useArticleService()

// 父组件调用服务,把结果传给子组件
const { data: article, execute } = useAsyncState(
  () => articleService.fetch(id),
  null
)
<Article :data="article" v-if="article" />

设计立场:副作用既不在父组件也不在子组件,而在独立的服务层。这是最符合关注点分离原则的方案。

方案四:接受双重渲染,优化中间状态

承认异步 Props 必然导致多次渲染,但把中间状态(loading/error)作为一等公民暴露出来。

<Article :article-id="id">
  <template #loading>加载中...</template>
  <template #error="{ retry }">加载失败,<button @click="retry">重试</button></template>
</Article>

设计立场:与其隐藏副作用,不如将其显式化、可定制化,让使用者拥有更好的控制权。


如何做出选择

在组件设计时,需要明确回答三个问题:

  1. 谁负责发起副作用?(父组件?子组件?服务层?)
  2. 副作用的可见性如何?(用户是否应该看到 loading?其他开发者是否应该知道组件会发请求?)
  3. 可测试性优先还是渲染次数优先?
优先级 推荐方案 副作用归属
子组件纯净、易测试 传递数据,接受二次渲染 + v-if 父组件
子组件自包含、减少渲染 传递 ID,子组件自治,暴露 loading/error 子组件(显式声明)
架构清晰、可维护 独立服务层 + 传递数据 服务层
用户体验优先 传递 ID + 子组件智能加载(骨架屏 + 一次渲染) 子组件

何时不必过度设计

以下场景中,最简单的方案(即最初的双重渲染方案)完全够用:

  • 子组件非常轻量,二次渲染开销可忽略。
  • 产品明确需要 loading 状态作为用户体验的一部分。
  • 数据请求速度极快(有缓存或 Service Worker),用户感知不到两次渲染。

在这些场景下,无需引入复杂的设计模式。


结语

Vue 响应式系统与异步数据流结合时,ref 的初始值与最终值必然导致响应式派发更新。这不是 Vue 的设计缺陷,而是声明式 UI 框架的固有特性。

真正的组件设计不是消灭副作用或消灭二次渲染,而是:

  1. 明确决定副作用归属于谁
  2. 让这个决定在代码中显而易见
  3. 根据场景选择在哪个环节承担中间状态(父组件、子组件、服务层,或 Suspense)

传递 articleId 确实会将副作用责任转移给子组件。这本身不是错误——前提是开发者有意识地做出这个选择,并理解其代价(可测试性降低、副作用隐藏)。

优秀的组件设计在于理解每个决策的含义后,做出符合当前场景的权衡。


快速参考

场景 推荐方案
子组件渲染开销大,需要避免二次渲染 v-if 就绪后渲染
子组件有独立的数据获取逻辑 传递 ID + 显式 loading/error 钩子
需要 loading 态作为产品需求 保留两次渲染,优化默认占位内容
追求架构清晰、组件可复用 独立服务层 + 传递数据
极致性能,数据返回极快 使用 Suspense 或预取数据

Promise的理解

Promise为什么会产生?

在开发中,我们经常需要写出这样的代码:当某件事完成后,才去做另一件事。比如:先登录,拿到用户信息后,再去获取订单列表。

我们可以通过回调函数来实现,简单场景还好,但如果逻辑嵌套多了(比如登录 → 用户信息 → 订单列表 → 订单详情 → 支付信息…),代码就会一层套一层,这就是“回调地狱”。

// 登录 → 获取用户信息 → 获取用户的订单
login('张三', '123456', function(result) {
  getUserInfo(result.userId, function(user) {
    getOrders(user.id, function(orders) {
      console.log(orders)
    }, function(error) {
      console.log('获取订单失败', error)
    })
  }, function(error) {
    console.log('获取用户失败', error)
  })
}, function(error) {
  console.log('登录失败', error)
})

从以上示例可以看出回调地狱的缺点:

  1. 最直观的就是阅读困难,理解难度大
  2. 修改困难,想要加一步或者改某处都需要重新理解,然后再往这层层嵌套中添加代码

于是,Promise应运而生,它解决了回调地狱的问题,很容易理解。

Promise的写法?

上述回调地狱的Promise写法:

function login(username, password) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (username === 'zhang' && password === '123') {
        resolve({ userId: 1001, token: 'abc' })
      } else {
        reject('登录失败')
      }
    }, 500)
  })
}

function getUserInfo(userId, token) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ userId, name: '张三', vipLevel: 3 })
    }, 500)
  })
}

function getOrders(userId) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(['订单1', '订单2', '订单3'])
    }, 500)
  })
}

// 链式调用,简单易懂
login('zhang', '123')
  .then(user => {
    return getUserInfo(user.userId, user.token)
  })
  .then(userInfo => {
    console.log('用户信息:', userInfo)
    return getOrders(userInfo.userId)
  })
  .then(orders => {
    console.log('订单列表:', orders)
  })
  .catch(err => {
    console.log('出错:', err)
  })

Promise的根本作用?

首先要明确Promise的好处:链式调用,先执行谁,然后才能再执行谁,代码非常直观。

比如在调用第二个then之前,我们需要先确定第一个then调用成功了,然后才能调用第二个,第一个失败了就不会再调用第二个了,会直接catch错误。那么怎么知道前一段代码执行成功了呢?resolve和reject就出现了。前一段成功时,会调用resolve,前一段的resolve会引发后一个then的调用,而前一段的resolve是怎么引发后一个then的呢?Promise的功劳。

那么Promise的作用就是:建立联系。建立resolve和then的联系,建立reject和catch的联系。

为了方便大家理解,我举个例子:传话。第一个人(第一个then)知道了(resolve)才能传给第二个人(第二个then),第一个人怎么传给第二个人?打电话,Promise就是那个电话,就是传话的工具。

resolve是什么呢?

resolve是Promise底层传过来的函数,不是由我们编写定义的,我们只负责在new Promise的时候拿到它,然后使用就可以了。

new Promise((resolve, reject) => {}) // 拿到 resolve 和 reject 函数

接下来有对resolve更加详细的讲解,现在可以先不纠结这个。

Promise具体做了什么?

Promise有三种状态:待定(pending)、履行(fulfilled)、拒绝(rejected),初始状态为pending,成功时变为fulfilled并执行相应代码,失败时变为rejected并执行相应代码。

接下来我们通过示例来解释这段话:

function checkNum(num) {
  return new Promise((resolve, reject) => {
    if (num === 1) {
      resolve('数字是1,成功') // 状态变为fulfilled
    } else {
      reject('数字不是1,失败') // 状态变为rejected
    }
  })
}

checkNum(1)
  .then(res => console.log(res)) // fulfilled状态下才执行的回调函数
  .catch(err => console.log(err)) // rejected状态下才执行的回调函数

执行checkNum(1),也就是执行:

new Promise((resolve, reject) => {
    if (num === 1) {
      resolve('数字是1,成功') // 状态变为fulfilled
    } else {
      reject('数字不是1,失败') // 状态变为rejected
    }
  })
上面的话提到“成功时变为fulfilled并执行相应代码”,那么我们怎么定义成功呢?

其实是很简单的问题,执行resolve就是成功,反之执行reject就是失败。比如当前示例num===1的结果是true,所以能执行resolve,那么这就是成功。代码如下:

if(num===1){
    resolve('数字是1,成功')
}

resolve(1)底层做了什么呢?

  1. 改状态:把pending改为fulfilled
  2. 由于状态为fulfilled,所以把参数1传给then(res => console.log(res))(res的值就是1),并将这个回调函数放到微任务队列(这里明天可以再拓展一下)(reject会将状态改为rejected,并将catch的回调函数放到任务队列)
  3. 待当前所有同步代码执行完成后,执行任务队列中的res => console.log(res)回调函数

总结:resolve(1) 的执行决定了 Promise 的状态变为 fulfilled,因此会执行 .then() 的回调函数,而不是 .catch() 的。

笔者有点累了,明天再回来写(●'◡'●)~

收藏即复用!50个极致实用JavaScript单行代码,前端开发效率直接拉满

50个原生JS/TS高频单行工具函数!零依赖、生产可用,告别重复造轮子

前言

作为前端开发者,日常业务开发中,字符串处理、数组运算、日期格式化、浏览器API、对象数据清洗等基础逻辑几乎无处不在。

很多小伙伴为了省事,项目里习惯性引入 Lodash、Dayjs 等第三方工具库。但绝大多数场景下,完全不需要引入庞大依赖。

几行原生 JS/TS 代码,就能优雅实现需求,不仅可以减少项目打包体积、降低项目依赖,还能提升代码熟练度,写出更简洁优雅的业务代码。

今天给大家整理了 50个生产可用的原生单行代码片段,覆盖前端9大高频开发场景。

告别玩具代码,全部适配浏览器/Node.js/Vue/React 所有前端项目,开箱即用,建议收藏!

一、字符串操作(最高频)

所有方法默认空值兜底,防止传参 undefined 导致代码报错

1. 字符串首字母大写

const capitalize = (str = '') => str.charAt(0).toUpperCase() + str.slice(1);

2. 反转字符串

const reverseString = (str = '') => str.split('').reverse().join('');

3. 判断字符串是否为回文

const isPalindrome = (str = '') => str === str.split('').reverse().join('');

二、数组操作

1. 数组扁平化一层

const flatArr = arr => arr.flat(1);

2. 移除数组所有假值

自动过滤:false、0、空字符串、null、undefined、NaN

const removeFalsy = arr => arr.filter(Boolean);

3. 快速生成 0-99 连续数组

const createArr = () => Array.from({length: 100}, (_, i) => i);

4. 随机打乱数组(标准洗牌算法)

Fisher–Yates 算法

const shuffleArr = arr => {
  const list = [...arr];
  for (let i = list.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [list[i], list[j]] = [list[j], list[i]];
  }
  return list;
};

5. 基础数组去重

const uniqueArr = arr => [...new Set(arr)];

6. 对象数组根据指定字段去重

const uniqueByKey = (arr, key) => [...new Map(arr.map(item => [item[key], item])).values()];

7. 获取多个数组交集

const getIntersection = (a = [], ...arr) => [...new Set(a)].filter(v => arr.every(b => b.includes(v)));

8. 查找数组最大值索引

const maxIndex = (arr = []) => arr.length ? arr.indexOf(Math.max(...arr)) : -1;

9. 查找数组最小值索引

const minIndex = (arr = []) => arr.length ? arr.indexOf(Math.min(...arr)) : -1;

10. 找到数组中最接近指定数字的值

const closestNum = (arr = [], n = 0) => arr.reduce((a, b) => Math.abs(b - n) < Math.abs(a - n) ? b : a);

11. 多个数组合并为二维数组

const merge2D = (...arrList) => [...arrList];

12. 矩阵行列转置

const transpose = (matrix = []) => matrix[0]?.map((_, i) => matrix.map(row => row[i])) ?? [];

三、数制转换

原生 API 一行搞定,无需手写复杂计算公式

1. 十进制转换为任意 n 进制

const decToBase = (num = 0, base = 10) => num.toString(base);

2. 任意 n 进制转换为十进制

const baseToDec = (str = '', base = 10) => parseInt(str, base);

四、正则与文本处理

全部增加异常捕获,适配不规则入参

1. 从URL中提取域名

const getDomain = (url = '') => {
  try { return new URL(url).hostname; } catch { return ''; }
};

2. 验证电子邮箱格式

const isEmail = (mail = '') => /^[^\s@]+@[^\s@]+.[^\s@]+$/.test(mail);

3. 移除文本所有多余空格

const trimAll = (str = '') => str.replace(/\s+/g, ' ').trim();

五、浏览器原生 Web 操作

零框架依赖,兼容所有现代浏览器

1. 重新加载当前页面

const reloadPage = () => location.reload();

2. 平滑滚动到页面顶部

const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' });

3. 平滑滚动到指定元素

const scrollToEl = (el) => el?.scrollIntoView({ behavior: 'smooth' });

4. 检测当前浏览器是否为IE

const isIE = () => !!window.ActiveXObject || /msie|trident/i.test(navigator.userAgent);

5. 移除文本中所有 HTML 标签

const stripHtml = (html = '') => html.replace(/<[^>]*>/g, '');

6. 页面重定向跳转

const redirect = (url = '') => location.href = url;

7. 一键复制文本到剪贴板

const copyText = async (text = '') => {
  try { await navigator.clipboard.writeText(text); return true; } 
  catch { return false; }
};

六、日期时间处理(重点修复时区BUG)

1. 判断日期是否为今天

const isToday = (date) => {
  const d1 = new Date(date);
  const d2 = new Date();
  return d1.getFullYear() === d2.getFullYear() &&
         d1.getMonth() === d2.getMonth() &&
         d1.getDate() === d2.getDate();
};

2. 日期转为标准 YYYY-MM-DD

const formatDate = (date = new Date()) => {
  const d = new Date(date);
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, '0');
  const day = String(d.getDate()).padStart(2, '0');
  return `${y}-${m}-${day}`;
};

3. 秒数转为 hh:mm:ss 时长格式

const secToTime = (s = 0) => {
  const t = Math.floor(s);
  const h = String(Math.floor(t / 3600)).padStart(2, '0');
  const m = String(Math.floor((t % 3600) / 60)).padStart(2, '0');
  const ss = String(t % 60).padStart(2, '0');
  return `${h}:${m}:${ss}`;
};

4. 获取指定年月的第一天

const firstDay = (y, m) => new Date(y, m - 1, 1);

5. 获取指定年月的最后一天

const lastDay = (y, m) => new Date(y, m, 0);

七、函数相关操作

1. 判断是否为异步 async 函数

const isAsyncFn = (fn) => fn?.constructor.name === 'AsyncFunction';

八、数字精度处理(金额展示必备)

专门用于前端金额、小数展示,精准可控

1. 截断小数(不四舍五入)

const toFixedFloor = (num = 0, len = 2) => Math.trunc(num * Math.pow(10, len)) / Math.pow(10, len);

2. 截断小数(自动四舍五入)

const toFixedRound = (num = 0, len = 2) => Number(num.toFixed(len));

3. 数字前置补零

const padNum = (num = 0, len = 2) => num.toString().padStart(len, '0');

九、对象常用操作(接口数据清洗神器)

1. 清除对象 null、undefined 空属性

const cleanObj = (obj = {}) => Object.fromEntries(Object.entries(obj).filter(([_, v]) => v != null));

2. 交换对象键值

const invertObj = (obj = {}) => Object.fromEntries(Object.entries(obj).map(([k, v]) => [v, k]));

3. JSON 字符串转对象

增加异常捕获,非法字符串不报错

const strToObj = (str = '') => {
  try { return JSON.parse(str); } catch { return null; }
};

4. 生产级对象深度对比(重点推荐)

避坑说明: 网上主流的 JSON.stringify 对比方式存在大量BUG,键顺序、undefined、NaN、日期都会对比失效。以下是轻量递归深对比方案,生产稳定可用

const deepEqual = (a, b) => {
  if (a === b) return true;
  if (!(a && b) || typeof a !== typeof b) return false;
  if (typeof a !== 'object') return false;
  const keysA = Object.keys(a);
  const keysB = Object.keys(b);
  if (keysA.length !== keysB.length) return false;
  return keysA.every(k => deepEqual(a[k], b[k]));
};

十、通用万能工具函数

1. 生成随机十六进制颜色

const randomColor = () => '#' + Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0');

2. RGB 转 HEX

const rgbToHex = (r = 0, g = 0, b = 0) => '#' + [r, g, b].map(x => String(x.toString(16)).padStart(2, '0')).join('');

3. HEX 转 RGB

const hexToRgb = (hex = '') => {
  const h = hex.replace('#', '');
  return {
    r: parseInt(h.slice(0, 2), 16),
    g: parseInt(h.slice(2, 4), 16),
    b: parseInt(h.slice(4, 6), 16)
  };
};

4. 生成全局唯一 UUID

const getUUID = () => crypto.randomUUID();

5. 获取当前页面 Cookie

const getCookie = () => document.cookie;

6. 延迟等待函数

const wait = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms));

写在最后

本文所有代码全部修复网络通用BUG,解决了市面上大部分前端工具合集存在的:时区错误、算法不均、空值报错、对象对比失效、浏览器报错等问题。

所有方法零第三方依赖、轻量简洁,兼容浏览器、Node.js、Vue、React、uniapp 等绝大部分前端项目。

日常开发中,大家可以将这些工具函数统一封装到项目的 utils.ts / utils.js 工具文件中,全局复用,彻底告别重复造轮子,大幅提升开发效率,写出更优雅、更健壮的业务代码。

文章干货满满,建议收藏+点赞,开发随时查阅!也欢迎各位大佬在评论区补充更多优质工具函数,一起交流精进✨

拒绝“首屏爆炸”:用 React 哨兵模式与懒加载打造丝滑列表

拒绝“首屏爆炸”:用 React 哨兵模式与懒加载打造丝滑列表

想象一下,你开了一家名为“无限画廊”的餐厅(也就是你的 Web 应用)。

如果你的做法是:先把菜单上的一万道菜全部做好,堆在门口(首屏加载),然后让顾客自己找想吃的。

结果会怎样?门口堵死了,服务员累瘫了,顾客还没进门就被吓跑了。这就是典型的性能灾难

今天,我们就来聊聊如何用 React 哨兵模式(Infinite Scroll)图片懒加载(Lazy Load) 这两把利器,把你的“餐厅”改造成米其林级别的流畅体验。

️‍♂️ 第一章:守门员——IntersectionObserver 哨兵模式

传统的滚动加载是怎么做的?监听 windowscroll 事件,疯狂计算 (scrollTop + clientHeight) >= scrollHeight。这就像是你雇了个保安,每过一毫秒就问你一次:“到底了吗?到底了吗?到底了吗?” —— 太吵了,而且费脑子(主线程阻塞)。

现代浏览器的救星来了:IntersectionObserver

它的逻辑是:“嘿,浏览器大哥,帮我盯着那个叫‘哨兵’的 <div>。只要它一露脸,你就喊我一声。” 浏览器内部优化极佳,完全不用我们操心性能。

让我们看看你提供的这个“通用哨兵组件”是如何工作的:

// InfiniteScroll.js - 我们的核心守卫
const InfiniteScroll = ({ hasMore, onLoadMore, isLoading, children }) => {
  const sentinelRef = useRef(null); // 这是一个隐形的“间谍”节点

  useEffect(() => {
    // 1. 安全检查:没数据了或者正在加载中,就别折腾了
    if (!hasMore || isLoading) return;

    // 2. 雇佣观察员 (Observer)
    const observer = new IntersectionObserver((entries) => {
      // 3. 只要哨兵出现在视野里(哪怕只露出一像素)
      if (entries[0].isIntersecting) {
        onLoadMore(); // 吹哨子:该上菜了!
      }
    }, { threshold: 0 }); // threshold: 0 意味着“只要看见一点点就算”

    // 4. 告诉观察员盯着谁
    if (sentinelRef.current) {
      observer.observe(sentinelRef.current);
    }

    // 5.  cleanup:组件卸载或更新时,记得解雇观察员,防止内存泄漏
    return () => {
      if (sentinelRef.current) {
        observer.unobserve(sentinelRef.current);
      }
    };
  }, [onLoadMore, hasMore, isLoading]); // 依赖项要写对,不然哨兵会罢工

  return (
    <>
      {children} {/* 这里放你的列表内容 */}
      
      {/* 这是一个高度极小的隐形 div,它就是我们的“哨兵” */}
      <div ref={sentinelRef} className="h-4" />
      
      {isLoading && <div className="text-center py-4">加载中...</div>}
    </>
  );
};

为什么叫它“哨兵”? 因为它混在列表的最底部。当用户滚动页面,列表内容被顶上去,原本藏在底部的“哨兵”就会暴露在视口(Viewport)中。一旦暴露,IntersectionObserver 捕捉到信号,立即触发 onLoadMore,新数据进来,把哨兵继续往下顶。完美闭环!

️ 第二章:视觉欺诈——图片懒加载的艺术

解决了列表的分页,我们还得解决列表里的“胖子”——图片

如果你的列表有 100 项,每项一张图,那就是 100 个 HTTP 请求。用户打开页面的瞬间,带宽直接被占满,白屏时间长得让人想关掉网页。

懒加载的核心思想: “不见兔子不撒鹰”。只有当图片快要进入屏幕时,才给它真正的 src 地址。

虽然原生的 <img loading="lazy" /> 已经很强了,但在 React 生态中,我们通常会结合 react-lazy-load 这样的库,利用它们封装好的 IntersectionObserver 能力,实现更精细的控制(比如提前加载、占位符防抖动)。

实战代码长这样:

import LazyLoad from 'react-lazy-load';

const PostItem = ({ post }) => {
  return (
    <div className="card">
      <h3>{post.title}</h3>
      {/* 
         方案 A: 使用第三方库(推荐用于复杂场景,如瀑布流)
         height 属性很重要,用来撑开高度,防止图片加载前布局塌陷
      */}
      <LazyLoad height={200} offset={100}>
        <img src={post.thumbnail} alt={post.title} className="w-full h-auto" />
      </LazyLoad>

      {/* 
         方案 B: 原生偷懒法 (简单粗暴,兼容性也不错)
         <img src={post.thumbnail} loading="lazy" /> 
      */}
    </div>
  );
};

双重保障: 你可以同时使用 loading="lazy" 属性和 LazyLoad 组件。前者是给浏览器的指令,后者是 React 层面的兜底,两者结合,稳如老狗。

第三章:终极合体——打造无限流

现在,我们将这两个概念结合起来。InfiniteScroll 负责宏观的节奏(什么时候加载下一页数据),而内部的 LazyLoad 负责微观的体验(图片按需显示)。

使用场景模拟:

  1. Store/State: 维护一个 posts 数组,page 页码,hasMore 是否还有下一页。
  2. UI 层:
    • 外层包裹 <InfiniteScroll ...>
    • 中间是 .map() 渲染出来的文章列表。
    • 每篇文章里的图片都被 <LazyLoad> 包裹。
  3. 交互流程:
    • 用户刷刷刷,看到了第 10 篇文章。
    • 第 10 篇的图片因为快进视口了,自动加载高清大图(懒加载生效)。
    • 用户继续刷到底部,踩到了“哨兵”。
    • onLoadMore 触发,API 请求第 2 页数据。
    • 新数据拼接到 posts 数组,React 重新渲染,列表变长。

避坑指南(老司机的经验)

  • 锁住并发:一定要用 isLoading 状态锁!千万别让用户在数据请求回来的那几百毫秒内,连续触发两次哨兵,导致发了两个一样的 API 请求。
  • 高度塌陷:做图片懒加载时,如果图片没加载出来,容器高度为 0,页面会发生剧烈的跳动(Layout Shift)。解决办法:给图片容器设置固定的宽高比(aspect-ratio)或者预设高度。
  • 路由切换:记得在 useEffect 的清理函数中 observer.disconnect()unobserve。否则当你跳转到详情页再回来时,可能会发现旧的观察器还在后台幽灵般地运行。

总结

前端开发的艺术,往往就在于**“拖延”**。

能晚点加载的代码(Code Splitting),就晚点加载;能晚点请求的数据(Infinite Scroll),就晚点请求;能晚点下载的图片(Lazy Load),就晚点下载。

用好 IntersectionObserver 和 React 的组合模式,让你的应用像丝绸一样顺滑。

React 常用 Hooks 函数及使用方法完全指南(useState / useEffect / useRef / useContext / useCallback / useMemo / useReducer)

前言

React Hooks 自 React 16.8 引入以来,已经彻底改变了我们编写 React 组件的方式。Hooks 让我们在函数组件中使用状态和生命周期能力,告别了类组件的繁琐写法。本文将从最常用的几个 Hook 入手,详细介绍它们的使用方法、最佳实践和常见陷阱。

如果你刚开始接触 React Hooks,或者想系统地梳理一遍常用 Hook 的用法,这篇文章应该能帮到你。


一、useState — 组件状态管理

useState 是最基础、最常用的 Hook,用于在函数组件中声明和管理状态。

基本用法

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>计数:{count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

useState 接收一个初始值,返回一个长度为 2 的数组:当前状态更新状态的函数

更新状态的两种方式

方式一:直接传入新值

setCount(count + 1);

方式二:传入更新函数(推荐,当新值依赖旧值时)

setCount(prev => prev + 1);

第二种方式可以避免闭包陷阱。如果你的 setCount 在异步回调中调用,使用函数式更新能确保拿到最新的状态值。

状态更新的异步性

很多人刚接触时会被这个问题困扰:setState 之后立刻读取状态,发现值没变。

const [count, setCount] = useState(0);

setCount(1);
console.log(count); // 还是 0,不是 1

这是因为 React 的状态更新是异步且批量的。实际的重新渲染会在当前事件循环结束后进行。

复杂状态:多个字段

如果状态是一个对象,更新时要手动合并:

const [form, setForm] = useState({ name: '', age: 0 });

// 错误:会丢失 name 字段
setForm({ age: 18 });

// 正确:需要手动合并
setForm(prev => ({ ...prev, age: 18 }));

💡 如果你的状态逻辑比较复杂(多个子字段、相互依赖),考虑用 useReducer 替代。


二、useEffect — 副作用处理

useEffect 用于处理组件中的副作用:数据请求、DOM 操作、订阅、计时器等。

基本用法

import { useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]); // 依赖项

  return <div>{user?.name}</div>;
}

理解依赖数组

依赖数组是 useEffect 的灵魂,它决定了 effect 何时执行:

依赖数组 执行时机
undefined (不传) 每次渲染后执行
[] (空数组) 只在挂载后执行一次
[a, b] 当 a 或 b 发生变化时执行

清理函数

如果 effect 产生了订阅、计时器等需要清理的资源,返回一个清理函数:

useEffect(() => {
  const timer = setInterval(() => {
    console.log('tick');
  }, 1000);

  return () => {
    clearInterval(timer); // 组件卸载时清理
  };
}, []);

这里的 return () => clearInterval(timer) 就是清理函数,会在组件卸载时执行,防止内存泄漏。

常见陷阱

陷阱一:忘记添加依赖

useEffect(() => {
  fetch(`/api/users/${userId}`).then(...)
}, []); // ❌ userId 变了也不会重新请求

陷阱二:不必要的依赖导致死循环

useEffect(() => {
  setCount(count + 1); // ❌ count 变化 → 重新渲染 → effect 触发 → count 变化 → 死循环
}, [count]);

陷阱三:在 effect 中使用旧值(闭包问题)

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // ❌ count 永远是 0
    }, 1000);
    return () => clearInterval(id);
  }, []); // 没有依赖 count
}

解决方案:使用函数式更新 setCount(c => c + 1),或者把 count 加入依赖数组并清理/重建定时器。

useEffect 与 useLayoutEffect 的区别

  • useEffect异步执行,在浏览器绘制之后触发。适合数据请求、事件绑定等不需要阻塞视觉更新的操作。
  • useLayoutEffect同步执行,在 DOM 更新后、浏览器绘制前触发。适合需要读取 DOM 布局的场景(如测量元素尺寸)。

绝大多数情况下用 useEffect 就够了,只有当你遇到闪烁(flicker)问题时才考虑使用 useLayoutEffect


三、useRef — 引用 DOM 和可变值

useRef 有两个主要用途:引用 DOM 元素存储可变值(不触发重新渲染)。

引用 DOM 元素

import { useRef, useEffect } from 'react';

function AutoFocusInput() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current?.focus(); // 组件挂载后自动聚焦
  }, []);

  return <input ref={inputRef} />;
}

存储可变值(改变不触发重渲染)

function Timer() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  // 始终保持 ref 与 state 同步
  countRef.current = count;

  useEffect(() => {
    const id = setInterval(() => {
      console.log('当前 count:', countRef.current); // 能拿到最新值
    }, 1000);
    return () => clearInterval(id);
  }, []); // 空依赖,定时器只创建一次
}

这个模式常用于解决闭包陷阱——ref.current 永远指向最新的值,因为它是一个可变对象。

useRef vs useState 的关键区别

特性 useState useRef
修改触发重渲染 ✅ 是 ❌ 否
跨渲染周期保存数据 ✅ 是 ✅ 是
在异步回调中获取最新值 ❌ 闭包问题 ✅ 始终最新
修改方式 setState(newVal) ref.current = newVal

四、useContext — 跨组件数据共享

useContext 让你在不使用 props 层层传递的情况下,在组件树中共享数据。

三步使用法

第一步:创建 Context

import { createContext } from 'react';

const ThemeContext = createContext('light');

第二步:使用 Provider 提供数据

function App() {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <ThemedComponent />
    </ThemeContext.Provider>
  );
}

第三步:子组件消费数据

import { useContext } from 'react';

function ThemedComponent() {
  const { theme, setTheme } = useContext(ThemeContext);

  return (
    <div className={theme}>
      当前主题:{theme}
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
        切换主题
      </button>
    </div>
  );
}

性能注意点

当 Provider 的 value 发生变化时,所有使用 useContext 的子组件都会重新渲染。如果 value 是一个对象,每次父组件渲染都会创建新引用,导致所有消费者重渲染。

解决方案:用 useMemo 包裹 value,或者将 Context 拆分为多个(读写分离)。

// 分离读和写,避免不必要的重渲染
const ThemeContext = createContext('light');
const ThemeUpdateContext = createContext(() => {});

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  const toggleTheme = useCallback(
    () => setTheme(t => (t === 'light' ? 'dark' : 'light')),
    []
  );

  return (
    <ThemeContext.Provider value={theme}>
      <ThemeUpdateContext.Provider value={toggleTheme}>
        {children}
      </ThemeUpdateContext.Provider>
    </ThemeContext.Provider>
  );
}

五、useCallback — 缓存函数引用

useCallback 用于缓存函数的引用,避免因函数重新创建导致子组件不必要的重新渲染。

import { useCallback } from 'react';

function Parent() {
  const [count, setCount] = useState(0);

  // 每次 Parent 渲染都会创建新的函数引用
  const handleClick = () => setCount(c => c + 1);

  // useCallback 缓存函数,只有依赖变化时才重建
  const handleClickCached = useCallback(
    () => setCount(c => c + 1),
    []
  );

  return <ExpensiveChild onClick={handleClickCached} />;
}

什么时候用 useCallback

不是所有函数都需要包裹 useCallback。过度使用反而会降低可读性和性能(因为 useCallback 本身也有开销)。

适合的场景:

  • 函数作为 props 传给使用了 React.memo 的子组件
  • 函数作为其他 Hook 的依赖项(比如 useEffect 的依赖)
  • 函数在自定义 Hook 中返回给外部使用

💡 一句话法则:当你确定不缓存会导致不必要的性能问题时再用。初期先正常写函数,遇到性能瓶颈再优化。


六、useMemo — 缓存计算结果

useMemo 用于缓存复杂计算的结果,避免每次渲染都重复执行。

import { useMemo } from 'react';

function Dashboard({ transactions }) {
  // 复杂计算:过滤 + 聚合
  const summary = useMemo(() => {
    return transactions
      .filter(t => t.amount > 0)
      .reduce((acc, t) => ({
        total: acc.total + t.amount,
        count: acc.count + 1,
        avg: (acc.total + t.amount) / (acc.count + 1)
      }), { total: 0, count: 0, avg: 0 });
  }, [transactions]);

  return <div>总金额:{summary.total},平均:{summary.avg}</div>;
}

useMemo vs useCallback

  • useCallback(fn, deps) 等价于 useMemo(() => fn, deps)
  • useCallback 缓存的是函数本身
  • useMemo 缓存的是计算的结果

不要滥用 useMemo

useCallback 一样,useMemo 也不是免费的。简单的计算(如数组 map、filter)其开销可能还不如 useMemo 的对比开销大。

适合 useMemo 的场景:

  • 计算复杂度较高(O(n²) 及以上)
  • 计算结果作为 props 传给 React.memo 子组件
  • 计算结果作为其他 Hook 的依赖项

七、useReducer — 复杂状态管理

当状态逻辑变得复杂(多个子值、相互依赖、多层次更新),useState 就不太够用了。这时 useReducer 是更好的选择。

import { useReducer } from 'react';

// 1. 定义 reducer 函数
function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD':
      return [...state, { id: Date.now(), text: action.payload, done: false }];
    case 'TOGGLE':
      return state.map(t =>
        t.id === action.payload ? { ...t, done: !t.done } : t
      );
    case 'DELETE':
      return state.filter(t => t.id !== action.payload);
    default:
      return state;
  }
}

function TodoApp() {
  // 2. 使用 useReducer
  const [todos, dispatch] = useReducer(todoReducer, []);

  return (
    <div>
      <button onClick={() => dispatch({ type: 'ADD', payload: '新任务' })}>
        添加
      </button>
      {todos.map(todo => (
        <div key={todo.id}>
          <span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
            {todo.text}
          </span>
          <button onClick={() => dispatch({ type: 'TOGGLE', payload: todo.id })}>
            完成
          </button>
        </div>
      ))}
    </div>
  );
}

useReducer vs useState 的选择

场景 推荐
独立、简单的状态 useState
包含多个子值的复杂状态 useReducer
下一个状态依赖前一个 useReducer(或函数式 setState)
更新逻辑在组件外可独立测试 useReducer
只需浅层更新表单字段 useState

八、自定义 Hooks — 逻辑复用

自定义 Hook 是 React Hooks 的精髓之一。当你发现多个组件中有相似的逻辑时,可以提取成一个自定义 Hook。

import { useState, useEffect } from 'react';

// 自定义 Hook:获取数据
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;

    setLoading(true);
    fetch(url)
      .then(res => {
        if (!res.ok) throw new Error('请求失败');
        return res.json();
      })
      .then(data => {
        if (!cancelled) {
          setData(data);
          setLoading(false);
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err);
          setLoading(false);
        }
      });

    return () => { cancelled = true; }; // 清理:防止竞态
  }, [url]);

  return { data, loading, error };
}

// 使用
function UserList() {
  const { data, loading, error } = useFetch('/api/users');

  if (loading) return <div>加载中...</div>;
  if (error) return <div>出错了:{error.message}</div>;
  return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

自定义 Hook 的命名规则

  • 必须以 use 开头(React 官方约定,linter 也会检查)
  • 内部可以调用其他 Hook
  • 普通函数不能调用 Hook,但自定义 Hook 可以

九、Hooks 使用规则

最后,牢记 React Hooks 的两条铁律:

规则一:只在顶层调用 Hooks

不要在循环、条件语句或嵌套函数中调用 Hook。

// ❌ 错误:在条件中调用
if (isLoading) {
  useEffect(() => { ... }, []);
}

// ✅ 正确:始终在顶层
useEffect(() => {
  if (!isLoading) { ... }
}, [isLoading]);

规则二:只在 React 函数中调用 Hooks

  • 在函数组件中调用 ✅
  • 在自定义 Hook 中调用 ✅
  • 在普通函数中调用 ❌
  • 在类组件中调用 ❌
  • 在回调中调用 ❌

总结

本文介绍了 React 中最常用的 7 个核心 Hook:

Hook 用途
useState 声明和管理组件状态
useEffect 处理副作用(请求、订阅、DOM 操作)
useRef 引用 DOM 元素、存储可变值
useContext 跨组件层级共享数据
useCallback 缓存函数引用
useMemo 缓存计算结果
useReducer 管理复杂状态逻辑

记住:Hooks 是工具,不是目的。不要在不需要的地方强行使用性能优化 Hook(useCallback、useMemo),先写出清晰的代码,遇到性能问题再针对性地优化。

希望这篇文章能帮你更好地理解和运用 React Hooks。有什么问题欢迎在评论区交流讨论~

❌