普通视图

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

Mac Claude Code

2026年4月2日 10:39

在 Mac 电脑上安装和配置 Claude Code(Anthropic 推出的命令行 AI 编程助手)主要分为安装基础配置以及自定义模型(第三方 API)配置三个部分。

以下是详细的操作步骤:

一、 安装 Claude Code

在 Mac 上,推荐使用 Homebrew 或官方脚本进行安装。

1. 使用 Homebrew 安装(推荐) 打开终端(Terminal),输入以下命令:

brew install claude-code

2. 使用官方脚本安装(备选) 如果没安装 Homebrew,可以使用 curl 脚本:

curl -fsSL https://claude.ai/install.sh | bash

3. 验证安装 安装完成后,检查版本以确认是否成功:

claude --version

二、 基础配置与登录

默认情况下,Claude Code 需要 Anthropic 的付费账号(Pro、Teams 或 Enterprise)。

  1. 启动: 在你的项目根目录下输入 claude
  2. 登录: 首次运行会提示登录。它会打开浏览器让你授权。
  3. 完成引导: 按照终端提示完成初始设置(如主题选择、权限授权等)。

三、 配置使用自定义模型(第三方 API)

如果你希望使用第三方中转接口或自定义模型(例如 DeepSeek、Poe 或国内镜像),可以通过环境变量配置文件来实现。

方法 1:通过环境变量(最简单)

在启动 claude 之前,设置以下环境变量。你可以将其添加到你的 ~/.zshrc~/.bash_profile 中以便永久生效。

# 设置第三方 API 的基础地址 (必须兼容 Anthropic 格式)
export ANTHROPIC_BASE_URL="https://your-proxy-api.com/v1"

# 设置你的 API Key
export ANTHROPIC_AUTH_TOKEN="your-api-key-here"

# 启动 Claude Code
claude

方法 2:修改本地配置文件(更稳定)

你可以创建一个本地设置文件来覆盖默认行为。

  1. 创建配置目录(如果不存在):

    mkdir -p ~/.claude
    
  2. 创建/编辑 settings.json~/.claude/settings.json 中添加环境配置:

    {
      "env": {
        "ANTHROPIC_BASE_URL": "https://api.your-provider.com",
        "ANTHROPIC_AUTH_TOKEN": "sk-xxxxxx"
      }
    }
    

方法 3:跳过官方登录流程(针对纯第三方用户)

如果你没有 Anthropic 官方账号,只想用第三方模型,需要手动“欺骗”程序通过初始化检查:

  1. 创建伪造的 Key 文件:

    echo '{"primaryApiKey": "any-string"}' > ~/.claude/config.json
    
  2. 标记已完成引导: 修改或创建 ~/.claude.json(注意文件名开头的点):

    {
      "hasCompletedOnboarding": true
    }
    

四、 进阶:配置特定模型名称

Claude Code 默认寻找 claude-3-5-sonnet。如果你的第三方供应商使用不同的模型名称(如 deepseek-chat),你可能需要设置默认模型变量:

export ANTHROPIC_DEFAULT_HAIKU_MODEL="your-model-name"
# 或者在 settings.json 的 env 块中添加

五、 常用操作命令

启动 Claude Code 后,你可以在其内部交互界面使用以下斜杠命令:

  • /config:查看和修改当前配置。
  • /help:获取详细帮助指南。
  • /login / /logout:管理官方账号登录。
  • /compact:压缩对话历史以节省 Token。
  • Ctrl+C:停止当前生成的代码或退出。

注意事项

  • 兼容性: Claude Code 的核心功能(如自动读取文件、运行测试、修复 Bug)是针对 Claude 3.5 Sonnet 模型高度优化的。使用非 Claude 系列模型时,可能会出现指令理解不到位或工具调用(Tool Use)失败的情况。
  • 网络: 如果你在国内使用,请确保终端环境可以正常访问你配置的 ANTHROPIC_BASE_URL

Claude Code 未登录 使用第三方模型

2026年4月2日 10:37

1. 最关键:未登录 (Not logged in)

右下角显示 Not logged in · Run /login。这意味着 Claude Code 还没连接到你的账号,无法开始写代码。

  • 处理方法 A(使用官方账号): 在控制台直接输入 /login 并按回车。它会弹出一个网页,你登录你的 Anthropic (Claude.ai) 账号并授权即可。
  • 处理方法 B: 如果你打算用第三方模型而不登录官方账号,你需要按照下面步骤,“欺骗”程序跳过登录:
    1. 按下 Ctrl + C 退出当前界面。
    2. 在终端执行:
      # 标记已完成引导
      mkdir -p ~/.claude && echo '{"hasCompletedOnboarding": true}' > ~/.claude.json
      # 伪造一个 key
      echo '{"primaryApiKey": "any-string"}' > ~/.claude/config.json
      
    3. 设置你的第三方 API 环境变量(例如 export ANTHROPIC_BASE_URL=...)。
    4. 重新输入 claude 启动。

2. 有新版本可用 (Update available!)

最下方提示 Update available! Run: brew upgrade claude-code

  • 处理方法: 如果你想使用最新功能,请先退出 Claude Code(Ctrl + C),然后在终端执行:
    brew upgrade claude-code
    

3. 引导建议 (Tips)

中间提示 Run /init to create a CLAUDE.md

  • 处理方法: 建议在你的项目根目录下输入 /init。这会生成一个 CLAUDE.md 文件,你可以里面写上你的项目规范(比如:使用什么技术栈、缩进是多少、代码风格等),这样 Claude 以后改代码会更符合你的习惯。

总结:现在该做什么?

如果你想立即开始对话,请直接在那个 符号后面输入你的要求,比如:

  • 如果你已配置好第三方 API:输入 你好,请帮我分析一下这个项目结构
  • 如果你还没登录也没配置:先输入 /login

提示: 看到这个界面说明你的 安装已经完全成功 了,只是需要完成“身份验证”这一步。

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

作者 SmalBox
2026年4月2日 10:21

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

Log 节点

Log 节点是 Unity URP Shader Graph 中用于数学计算的重要节点之一,专门用于计算输入值的对数。在图形着色器编程中,对数运算在多种视觉效果和数学计算中扮演着关键角色,特别是在处理非线性关系、数据范围压缩和特定算法实现时。理解 Log 节点的原理和应用对于创建复杂且高效的着色器效果至关重要。

对数函数是数学中的基本概念,它是指数函数的逆运算。在着色器编程中,对数运算常用于亮度调整、高动态范围(HDR)处理、数据归一化等场景。Log 节点通过提供三种不同的底数选项(自然对数、以 2 为底的对数和以 10 为底的对数),为开发者提供了灵活的对数计算能力。

与 Shader Graph 中的其他数学节点相比,Log 节点具有独特的特性。它能够将输入值从指数增长的范围转换为线性范围,这在处理光照计算、颜色校正和物理模拟时特别有用。例如,在 HDR 渲染中,对数运算可以帮助将高动态范围的颜色值映射到适合显示的低动态范围。

Log 节点的实现基于 GPU 的高效数学函数,能够并行处理多个数据通道,这对于实时图形应用至关重要。无论是处理单个浮点数还是多维向量,Log 节点都能提供一致且准确的结果。

描述

Log 节点是 Shader Graph 数学节点库中的基础组件,其主要功能是计算输入值的对数。该节点接收一个动态矢量输入,根据选择的底数类型,输出对应的对数值。作为 Exponential 节点的逆运算,Log 节点在数学运算链中扮演着反向计算的角色,能够将指数增长的数据转换回原始比例。

在数学定义上,如果 a^x = b,那么 log_a(b) = x。在 Log 节点的上下文中,输入值相当于 b,输出值相当于 x,而底数 a 则通过 Base 下拉选单进行选择。这种关系使得 Log 节点成为解决指数相关问题的有力工具。

例如,当使用 base-2 对数时,如果输入值为 8,由于 2^3 = 8,所以输出结果为 3。这一特性在计算机图形学中尤为重要,因为许多计算机系统中的数据存储和处理都是基于二进制的。

Log 节点的应用范围十分广泛。在颜色处理方面,它可用于实现 gamma 校正,将线性颜色空间转换为感知均匀的颜色空间。在光照计算中,对数运算可以帮助处理高动态范围的光照强度,使其适合在标准显示器上呈现。此外,在特效制作中,Log 节点可以用于创建非线性插值、实现特定的衰减曲线或生成复杂的图案。

节点的输入输出类型为动态矢量,这意味着它可以处理从单个浮点数到四维向量的各种数据类型。这种灵活性使得 Log 节点能够同时处理多个颜色通道或空间坐标,大大提高了着色器编程的效率。

底数选择是 Log 节点的核心特性之一。BaseE(自然对数)使用数学常数 e(约等于 2.718)作为底数,在连续增长模型和微积分相关计算中最为常见。Base2(以 2 为底的对数)在计算机科学领域应用广泛,特别适合处理与二进制系统相关的计算。Base10(以 10 为底的对数)则常用于工程和科学计算,特别是在处理数量级和分贝计算时。

端口

Log 节点的端口系统设计简洁而高效,遵循 Shader Graph 标准的数据流模式。了解每个端口的特性和行为对于正确使用该节点至关重要。

名称 方向 类型 描述
In 输入 动态矢量 输入值
Out 输出 动态矢量 输出值

输入端口 (In)

输入端口标记为 "In",是 Log 节点接收数据的入口。这个端口接受动态矢量类型,意味着它可以连接多种数据类型的输出:

  • 单个浮点数值(Float)
  • 二维向量(Vector2)
  • 三维向量(Vector3)
  • 四维向量(Vector4)

输入值的有效范围取决于所选的底数类型:

  • 对于所有底数类型,输入值必须大于零。对数函数在实数范围内仅对正数有定义。
  • 如果输入值为零或负数,结果将是未定义的,通常会导致 NaN(非数字)或平台特定的异常值。

输入端口支持连接其他节点的输出,包括:

  • 常量值节点
  • 属性节点
  • 纹理采样节点
  • 其他数学运算节点的输出
  • 时间节点等动态值源

输出端口 (Out)

输出端口标记为 "Out",提供对数计算的结果。输出数据的维度和类型始终与输入保持一致:

  • 如果输入是标量,输出也是标量
  • 如果输入是矢量,输出的每个分量都会独立计算对数值

输出值的特性:

  • 输出值的范围取决于输入值和选择的底数
  • 对于 base-e 对数,输出范围从负无穷大到正无穷大
  • 对于 base-2 和 base-10 对数,输出同样覆盖整个实数范围
  • 输出值的数据精度遵循 Shader Graph 的精度设置

输出端口可以连接到多种类型的输入:

  • 其他数学节点的输入
  • 颜色节点的输入
  • 材质属性的输入
  • 着色器阶段的输入(如片段着色器颜色输出)

数据类型转换与兼容性

Log 节点在处理不同类型的数据时遵循 Shader Graph 的隐式转换规则:

  • 当连接不同维度的数据时,会自动进行广播操作
  • 例如,将标量连接到矢量输入时,标量值会被复制到所有分量
  • 输出数据的精度与输入数据的精度保持一致

控件

Log 节点的控件系统设计直观,提供了对节点行为的精确控制。主要控件是 Base 下拉选单,它决定了对数计算的数学基础。

名称 类型 选项 描述
Base 下拉选单 BaseE、Base2、Base10 选择对数的底数

Base 下拉选单

Base 下拉选单是 Log 节点的核心控制元素,提供了三种不同的底数选项。每种底数对应不同的数学特性和应用场景。

BaseE(自然对数)

自然对数以数学常数 e(约等于 2.71828)作为底数,在数学和物理学中具有基础性地位:

  • 标记为 "ln" 或在编程中常表示为 "log"
  • 是微积分中的标准对数,与自然指数函数互为逆运算
  • 在连续增长或衰减模型中应用广泛
  • 在概率论、统计学和复杂系统建模中尤为重要

自然对数的特性:

  • 导数简单:d(ln(x))/dx = 1/x
  • 积分关系:∫(1/x)dx = ln|x| + C
  • 在复变函数理论中具有重要地位

Base2(以 2 为底的对数)

以 2 为底的对数在计算机科学和信息技术领域应用广泛:

  • 通常标记为 "log₂"
  • 与二进制系统直接相关,适合处理计算机中的数据
  • 在信息论中用于计算信息熵
  • 在算法分析中用于评估时间复杂度

Base2 对数的特殊应用:

  • 计算数据存储所需的位数
  • 分析分治算法的递归深度
  • 处理纹理 mipmap 级别
  • 实现基于二进制的插值和衰减

Base10(以 10 为底的对数)

以 10 为底的对数在工程和科学计算中最为常见:

  • 通常标记为 "log" 或 "log₁₀"
  • 与十进制计数系统直接对应
  • 在测量科学中用于表示数量级
  • 在声学中用于分贝计算

Base10 对数的实际应用:

  • 计算 pH 值(酸碱性测量)
  • 表示地震的里氏震级
  • 在信号处理中计算信噪比
  • 数据可视化中的对数坐标轴

控件交互与动态行为

Base 下拉选单的交互特性:

  • 选择不同的底数会立即影响节点的计算行为
  • 节点外观可能会轻微变化以反映当前选择
  • 生成的着色器代码会根据选择而改变
  • 不影响输入输出端口的连接状态

性能考虑

不同底数选择的性能特征:

  • 在大多数现代 GPU 上,三种对数计算的性能差异可以忽略不计
  • Base2 对数在某些硬件架构上可能有轻微的性能优势
  • 实际性能取决于目标平台和驱动程序优化
  • 对于移动平台,建议进行性能测试以确认影响

生成的代码示例

Log 节点在编译时会根据底数选择生成相应的 HLSL 代码。理解生成的代码有助于深入掌握节点的内部工作机制,并为高级着色器编程提供基础。

Base E 代码生成

当选择 BaseE(自然对数)时,Log 节点生成类似于以下示例的 HLSL 代码:

void Unity_Log_float4(float4 In, out float4 Out)
{
    Out = log(In);
}

代码分析:

  • 函数名 Unity_Log_float4 表明这是处理 float4 类型数据的自然对数函数
  • In 参数接收输入的四维向量
  • Out 参数通过引用返回计算结果
  • log() 是 HLSL 内置函数,计算自然对数

扩展应用示例:

// 计算 RGB 颜色的自然对数,用于 HDR 色调映射
void ApplyNaturalLogToneMapping(float3 hdrColor, out float3 mappedColor)
{
    mappedColor = log(hdrColor + 1.0); // 加1避免对零取对数
}

// 基于自然对数的自定义光照衰减
float NaturalLogFalloff(float distance, float scale)
{
    return 1.0 / log(distance * scale + 1.0);
}

Base 2 代码生成

当选择 Base2(以 2 为底的对数)时,生成的代码示例如下:

void Unity_Log2_float4(float4 In, out float4 Out)
{
    Out = log2(In);
}

代码分析:

  • 函数名 Unity_Log2_float4 明确表示 base-2 对数计算
  • log2() 是 HLSL 标准函数,专门计算以 2 为底的对数
  • 输入输出结构与其他模式一致

实际应用场景:

// 计算 mipmap 级别选择
float CalculateMipLevel(float2 uvDerivative)
{
    float maxDerivative = max(length(uvDerivative.x), length(uvDerivative.y));
    return log2(maxDerivative * textureSize);
}

// 基于二进制对数的颜色量化
float3 BinaryLogQuantization(float3 color, int levels)
{
    float logColor = log2(color);
    float quantizedLog = floor(logColor * levels) / levels;
    return exp2(quantizedLog);
}

Base 10 代码生成

当选择 Base10(以 10 为底的对数)时,生成的代码形式为:

void Unity_Log10_float4(float4 In, out float4 Out)
{
    Out = log10(In);
}

代码特性:

  • 函数名 Unity_Log10_float4 标识 base-10 对数操作
  • log10() 是 HLSL 内置函数,计算以 10 为底的对数
  • 保持与其他模式一致的接口设计

工程应用示例:

// 计算分贝值
float CalculateDecibels(float signalPower, float referencePower)
{
    float powerRatio = signalPower / referencePower;
    return 10.0 * log10(powerRatio);
}

// 基于数量级的动态范围调整
float LogarithmicRangeAdjustment(float value, float threshold)
{
    if (value > threshold) {
        return log10(value / threshold) + 1.0;
    } else {
        return value / threshold;
    }
}

代码生成的高级特性

Log 节点的代码生成机制还包含一些高级特性:

动态精度支持

// 根据图形API和平台设置自动选择精度
#ifdef UNITY_USE_HIGH_PRECISION_MATH
    precise float4 logResult = log(In);
#else
    float4 logResult = log(In);
#endif

错误处理机制

// 防止对非正数取对数的安全版本
void Unity_SafeLog_float4(float4 In, out float4 Out)
{
    Out = log(max(In, 1e-8)); // 确保输入始终为正数
}

多平台兼容性

  • 生成的代码会自动适应不同的图形 API(DirectX、OpenGL、Vulkan 等)
  • 针对移动平台可能使用优化后的数学函数
  • 保持与各种着色器模型的兼容性

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

为了交付一个AI辅助开发的项目,我们搭了一套质量保障体系

2026年4月2日 10:17

为了交付一个AI辅助开发的项目,我们搭了一套质量保障体系

上周五代码评审会上,一个同事展示了他用 Cursor 二十分钟生成的完整 CRUD 模块,在场所有人都觉得挺酷。周一上线后,这个模块在并发场景下把数据库连接池打满了——AI 生成的代码在每次请求里都新建了一个数据库连接,没有复用。这件事让我想起刚结束的那个项目重构。

这篇文章不是要唱衰 Vibe Coding,恰恰相反,我们团队现在每个人都在用 AI 编程工具。但用了半年之后,我越来越确信一件事:AI 能帮你写出 80 分的代码,但从 80 分到 95 分的那段路,比从 0 到 80 分更难走。

我们踩过的三个典型坑

坑一:类型体操看着对,运行时炸了

我们项目用的是 TypeScript 4.9 + Vue 3.3 的组合。在重写权限模块时,一个同事让 AI 生成了一套基于角色的权限判断逻辑。AI 给出的代码类型定义写得很漂亮:

type Permission = 'read' | 'write' | 'admin';
type RolePermissionMap = Record<string, Permission[]>;

function hasPermission(
  userRoles: string[],
  requiredPermission: Permission,
  roleMap: RolePermissionMap
): boolean {
  return userRoles.some(role => 
    roleMap[role]?.includes(requiredPermission)
  );
}

TypeScript 编译没有任何报错,逻辑看起来也清晰。问题出在哪?出在我们的实际业务场景里,权限不是简单的字符串匹配。我们有"数据权限"的概念——同一个角色在不同部门下的权限不同。AI 生成的代码完全没有考虑这个维度,因为它不知道我们的业务上下文。这个 bug 直到 QA 用跨部门账号测试时才发现,整个权限模块的核心函数需要重写。

这就是 Vibe Coding 的第一个边界:AI 能写出语法正确、类型安全的代码,但它对你的业务上下文几乎一无所知。 你给它的 prompt 越简短,它脑补的业务逻辑就越多,而这些脑补往往是错的。

坑二:看似优雅的抽象,实则过度设计

重构营销活动模块时,我让 AI 帮忙设计一个活动配置的数据流方案。

// AI 生成的"优雅"方案(简化版)
interface ActivityStrategy {
  validate(config: ActivityConfig): ValidationResult;
  calculate(config: ActivityConfig): PriceResult;
  render(config: ActivityConfig): RenderSchema;
}

class ActivityStrategyFactory {
  private strategies = new Map<ActivityType, ActivityStrategy>();
  
  register(type: ActivityType, strategy: ActivityStrategy) {
    this.strategies.set(type, strategy);
  }
  
  getStrategy(type: ActivityType): ActivityStrategy {
    const strategy = this.strategies.get(type);
    if (!strategy) throw new Error(`Unknown activity type: ${type}`);
    return strategy;
  }
}

代码质量高吗?从设计模式的角度看,挑不出毛病。但我们的营销活动一共就三种类型:满减、折扣、赠品。三种。用 Map + 策略注册 + 工厂这一套,是拿大炮打蚊子。

我们最后用了一个简单的 switch-case,加上三个纯函数,总共不到 80 行代码,可读性远好于 AI 给的方案。

// 实际采用的方案
function calculatePrice(type: ActivityType, config: ActivityConfig, originalPrice: number) {
  switch (type) {
    case 'full_reduction':
      return originalPrice >= config.threshold 
        ? originalPrice - config.reduction 
        : originalPrice;
    case 'discount':
      return originalPrice * config.discountRate;
    case 'gift':
      return originalPrice; // 赠品不影响价格
    default:
      throw new Error(`Unsupported activity type: ${type}`);
  }
}

这背后的规律是:AI 训练数据里充斥着开源项目和技术博客的代码,这些代码天然倾向于展示"最佳实践"和"设计模式"。但在实际项目里,最佳实践要匹配问题规模。三种活动类型用策略模式,就像一个人住的房子装了中央空调——技术上没错,但没必要。

坑三:异步竞态,AI 的盲区

这个坑最隐蔽,也是排查时间最长的。我们的活动列表页有一个搜索功能,用户输入关键词后实时请求接口。AI 生成的搜索组件用了 watch + debounce 的经典组合:

<script setup lang="ts">
const keyword = ref('');
const list = ref<Activity[]>([]);

const debouncedSearch = useDebounceFn(async (val: string) => {
  const res = await fetchActivities({ keyword: val, page: 1 });
  list.value = res.data;
}, 300);

watch(keyword, (val) => {
  debouncedSearch(val);
});
</script>

看起来没问题对吧?debounce 300 毫秒,避免频繁请求,教科书式的写法。

但实际使用中出现了一个诡异的 bug:用户快速输入"春节"两个字,列表偶尔会显示搜索"春"的结果,而不是"春节"的结果。原因是第一个请求(搜"春")因为网络波动返回比第二个请求(搜"春节")慢,后发先至,把正确的结果覆盖了。

这就是经典的异步竞态问题。

// 修复后的方案:用 AbortController 取消过期请求
const abortControllerRef = ref<AbortController | null>(null);

const debouncedSearch = useDebounceFn(async (val: string) => {
  // 取消上一次未完成的请求
  abortControllerRef.value?.abort();
  const controller = new AbortController();
  abortControllerRef.value = controller;
  
  try {
    const res = await fetchActivities(
      { keyword: val, page: 1 },
      { signal: controller.signal }
    );
    list.value = res.data;
  } catch (e) {
    if (e instanceof DOMException && e.name === 'AbortError') return;
    throw e;
  }
}, 300);

这类问题 AI 几乎不会主动帮你处理,因为在大多数 demo 级别的代码里,竞态条件不会暴露。只有在真实网络环境、真实用户操作下,这些幽灵般的 bug 才会出现。我们在整个重构过程中统计了一下:AI 生成的代码中,和异步时序相关的 bug 占了所有线上问题的 35%。 这个比例远超我们的预期。

Vibe Coding 的四条边界线

踩完上面这些坑,我们团队逐渐摸清了 AI 编程的能力边界。

边界一:单文件能力强,跨文件协调弱

AI 在单个文件内生成代码的质量相当不错,函数逻辑、类型定义、组件结构都能处理。但一旦涉及跨文件的协调——比如修改一个公共类型定义后,所有引用处需要同步更新——AI 的表现就大幅下降。打个比方:AI 像一个手艺很好的泥瓦匠,你让他砌一面墙,砌得又快又整齐。但让他协调整栋楼的结构承重,他就力不从心了。他看不到隔壁那面墙的受力情况。

在我们项目中,公共模块(utilshookstypes)的修改,AI 的参与度不到 20%,基本靠人工维护。业务组件内部的逻辑,AI 的参与度能到 70% 以上。

边界三:生成快,审查难

生成一段 50 行的代码可能只要 10 秒,但认真审查这 50 行代码至少需要 5 分钟。如果 AI 一天帮你生成了 2000 行代码,你需要多少时间来审查?我们团队真实的数据是:在重构高峰期,每天新增代码量大约是以前的 2.5 倍,但代码评审的时间不但没有减少,反而增加了约 40%。因为 AI 生成的代码风格不统一、命名习惯各异、有时还会引入团队不熟悉的 API 用法,审查者需要额外的时间去理解和验证。

边界四:写得出来,解释不了

当 AI 生成的代码出了 bug,你让它解释为什么这样写,它的回答往往是重新组织一遍代码的逻辑描述,而不是真正的设计决策说明。它不知道自己为什么选了方案 A 而不是方案 B,因为它根本没有"选择"的过程——它只是根据概率生成了最可能的 token 序列。这在排查问题时尤其痛苦。你面对一段 AI 写的代码,既不知道它的设计意图,也不知道它是否考虑了某个边界条件,唯一的办法就是从头读一遍,自己重建心智模型。

我们搭建的质量保障体系

认清了这些边界之后,我们没有选择减少 AI 的使用,而是围绕 AI 的特点建了一套流程。核心思路是:让 AI 做它擅长的生成工作,让人做 AI 不擅长的审查和决策工作,然后用自动化工具覆盖两者都容易遗漏的部分。

第一层:Prompt 规范化

我们发现团队成员给 AI 写的 prompt 质量差异很大。有人写"帮我写一个用户列表",有人写"用 Vue 3 Composition API 写一个用户列表组件,使用 Element Plus 的 el-table,需要支持分页和按姓名搜索,接口是 GET /api/users,返回格式是 { list: User[], total: number }"。后者生成的代码质量远高于前者,这不难理解。但问题是,写好的 prompt 本身需要时间和经验。

我们的做法是建了一个 prompt 模板库,放在项目的 .ai/prompts/ 目录下,按场景分类。每个模板包含:项目技术栈、代码规范约束、必须处理的边界情况。使用时只需要填入业务相关的部分。

<!-- .ai/prompts/table-component.md -->
## 上下文
- 框架:Vue 3.3 + TypeScript 4.9 + Element Plus 2.4
- 状态管理:Pinia
- 请求库:封装的 axios,统一用 useRequest hook
- 代码规范:Composition API,禁止 Options API

## 必须处理的场景
- 空数据状态(显示 el-empty)
- 请求失败的错误提示(用 ElMessage.error)
- 分页参数变化时取消上一次未完成的请求
- 搜索输入做 300ms debounce

## 你的任务
生成一个 [具体业务] 的列表组件...

这个模板库的效果非常明显。使用模板之后,AI 生成代码的一次通过率(不需要人工修改就能通过代码评审)从大约 25% 提升到了 55% 左右。

第二层:AI 专项 Code Review Checklist

传统的 Code Review 主要关注逻辑正确性、性能、安全性。针对 AI 生成的代码,我们额外增加了一份检查清单:

依赖检查:AI 是否引入了项目中没有的第三方库?是否使用了已废弃的 API?我们遇到过 AI 在代码里用 moment.js 的情况——我们项目统一用的是 dayjs。还有一次 AI 用了 Array.prototype.at(),而我们需要兼容的最低浏览器版本不支持这个方法。

幻觉检查:AI 是否编造了不存在的 API?这在调用后端接口时尤其常见。AI 有时候会自信地写出一个看起来很合理但实际不存在的接口路径或参数名。我们要求所有 AI 生成的接口调用代码必须和后端 API 文档逐字段比对。

一致性检查:命名风格是否和项目现有代码一致?错误处理方式是否遵循团队约定?import 路径别名是否正确?我们项目用 @/ 指向 src/,但 AI 有时会生成 ../../ 的相对路径,混用之后代码很难维护。

冗余检查:AI 是否生成了不必要的代码?比如对已经在全局 axios 拦截器中处理过的 HTTP 错误,又在组件里写了一遍 try-catch。这类冗余不影响功能,但会误导后续维护者,以为业务上需要特殊的错误处理。

第三层:自动化防护网

人工检查能覆盖的面有限,我们用自动化工具兜底。

ESLint 自定义规则是第一道防线。我们针对 AI 常见问题写了几条自定义规则,比如禁止在组件内直接 new AbortController() 而不清理(必须在 onUnmounted 中 abort),比如禁止在 watch 回调里直接写 async 而不处理竞态。

// eslint-plugin-team/rules/no-unclean-abort-controller.js
module.exports = {
  meta: {
    type: 'problem',
    docs: {
      description: 'AbortController must be cleaned up in onUnmounted',
    },
  },
  create(context) {
    let hasAbortController = false;
    let hasCleanup = false;

    return {
      NewExpression(node) {
        if (node.callee.name === 'AbortController') {
          hasAbortController = true;
        }
      },
      CallExpression(node) {
        if (node.callee.name === 'onUnmounted') {
          hasCleanup = true;
        }
      },
      'Program:exit'() {
        if (hasAbortController && !hasCleanup) {
          context.report({
            message: 'AbortController created but no onUnmounted cleanup found',
            loc: { line: 1, column: 0 },
          });
        }
      },
    };
  },
};

这条规则看起来简单,但在我们项目里拦截了不下 10 次 AI 生成的遗漏清理的代码。

类型覆盖率检测是第二道防线。AI 有时候会用 any 来绕过复杂的类型推导,我们在 CI 流程中加了 typescript-coverage-report,要求类型覆盖率不低于 92%。重构前这个数字是 78%,重构完是 94%。每次 AI 偷偷塞的 any 都会在 CI 中被拦住。

集成测试覆盖关键路径是第三道防线。我们没有要求 AI 生成的每个函数都有单元测试,那样成本太高。但对核心业务路径——活动创建、价格计算、权限判断——我们写了端到端的集成测试,用 Playwright 模拟真实用户操作。这些测试不关心代码实现细节,只验证业务结果是否正确,AI 怎么写都行,只要测试过了就行。

// e2e/activity-create.spec.ts
test('创建满减活动后价格计算正确', async ({ page }) => {
  await page.goto('/activity/create');
  
  // 选择活动类型
  await page.getByLabel('活动类型').click();
  await page.getByText('满减').click();
  
  // 填写满减规则
  await page.getByLabel('满减门槛').fill('200');
  await page.getByLabel('减免金额').fill('30');
  
  await page.getByRole('button', { name: '保存' }).click();
  await expect(page.getByText('创建成功')).toBeVisible();
  
  // 验证价格计算
  await page.goto('/order/preview?amount=250');
  await expect(page.getByTestId('final-price')).toHaveText('220');
});

第四层:架构决策人工把关

这一层没有工具,纯靠人。我们在项目中明确了一个规则:所有涉及架构的决策,AI 只能提供参考方案,最终由架构师决定。

什么算"架构决策"?我们列了一个清单:

  • 新增或修改公共模块的接口定义
  • 引入新的第三方依赖
  • 改变数据流向(比如从 props 传递改为 Pinia 全局状态)
  • 定义新的文件组织规则或目录结构
  • 数据库表结构变更

之所以这样做,是因为架构决策的影响面大、修改成本高。AI 在这类问题上给出的方案往往技术上可行,但不一定适合你的团队和项目阶段。它不知道你的团队有三个前端一个后端、不知道你们下个季度要做微前端拆分、不知道你们的运维只支持 Docker 部署不支持 Serverless。

对比:同一个需求,纯 Vibe 模式 vs 质量保障模式

最后用一个具体案例收尾。需求是:做一个实时搜索的下拉选择器,支持远程数据源、防抖请求、键盘导航、支持清空。

纯 Vibe 模式下,把需求直接丢给 AI,平均 30 秒拿到一个组件。我们测了五次,五次生成的代码都有以下问题中的至少两个:

  1. 没有处理请求竞态(5/5 都没有)
  2. 键盘导航时 aria 属性缺失(4/5)
  3. 清空时没有重置搜索状态(3/5)
  4. onClickOutside 没有在卸载时移除(3/5)
  5. 下拉框定位没有考虑屏幕边缘翻转(5/5)

质量保障模式下的做法:先用模板写 prompt(包含竞态处理、无障碍、清理等要求),AI 生成初版代码,对照 checklist 做 review,让 AI 修复发现的问题,跑已有的组件集成测试,最后人工确认。整个过程大约 25 分钟。

25 分钟 vs 30 秒,差距看起来很大。但 30 秒生成的版本,实际投入使用前还需要人工修复上面那些问题,保守估计也要 30-40 分钟。而且因为是在 AI 已有代码上修修补补,修出来的代码往往不如一开始就约束好来得干净。

对比维度 纯 Vibe 模式 质量保障模式
初版生成时间 30s 8min(含写 prompt)
达到可上线的总时间 35-45min 25min
竞态处理
无障碍支持 缺失 完整
组件卸载清理 遗漏 完整
后续维护时心智负担 高(补丁式修复) 低(结构清晰)

这个对比不是要否定 Vibe Coding 本身,而是说明:在生产环境中,Vibe Coding 的"Vibe"不能停留在"看感觉差不多",而是要让这个"感觉"建立在可验证的规则之上。

我们团队现在的共识是:AI 是一个输出极快但质量波动大的初级工程师。你不会让一个刚入职的初级工程师独立负责核心模块,但你会让他在明确的规范和 review 机制下承担大量执行工作。对 AI 的定位也是一样的。

以上就是我们在项目重构中关于 AI 编程质量保障的完整实践。模板库、checklist、ESLint 规则这些东西都不复杂,团队花一两周就能搭起来。但如果你跳过这些直接 Vibe,等到线上出了问题再补,成本会高得多——我们最开始那两个月就是活生生的例子。

DepSleuth - 前端依赖分析工具的技术原理与实践

2026年4月2日 10:15

 1. 项目背景与开发动机

现代前端项目的依赖管理已成为开发过程中的重要挑战。随着项目规模的增长,依赖包数量呈指数级上升,由此带来的性能、安全、合规和维护问题日益突出。例如某应用中的一个主包依赖“xlsx@^0.18.5”就检测出存在“XSS漏洞可能导致会话劫持或数据泄露”的风险,需要升级更高版本。

典型依赖问题矩阵:

问题类型 具体表现 影响程度
性能问题 打包体积过大,加载缓慢
安全隐患 漏洞依赖,安全风险
法律合规 许可证冲突,商业风险
维护困难 依赖链复杂,排查困难

2. 核心功能实现原理

2.1 体积分析功能

2.1.1 实现架构

体积分析采用多阶段处理流程,确保分析的准确性和全面性:

image.png

2.1.2 核心算法实现

interface SizeAnalysis {
  rawSize: number;
  parsedSize: number;
  compressionRatio: number;
  modules: ModuleBreakdown[];
  impactLevel: 'low' | 'medium' | 'high';
}

function analyzePackageSize(packageName: string): Promise<SizeAnalysis> {
  // 实现多层级的体积分析
  return {
    rawSize: calculateRawSize(packageName),
    parsedSize: estimateParsedSize(packageName),
    compressionRatio: calculateCompressionRatio(packageName),
    modules: analyzeModuleBreakdown(packageName),
    impactLevel: calculateImpactLevel(packageName)
  };
}

2.2 许可证信息功能

2.2.1 许可证风险评估模型

DepSleuth 建立了一套完整的许可证风险评估体系:

image.png

许可证风险等级划分:

风险等级 许可证类型 合规要求 推荐操作
低风险 MIT, BSD, ISC 保留版权声明 安全使用
中风险 Apache-2.0 声明变更文件 注意合规
高风险 GPL-3.0, AGPL-3.0 开源衍生作品 法律审查

2.2.2 许可证识别流程

function scanLicenses(packages: PackageDetails[]): LicenseScanReport {
  const licenseMap = new Map();
  const issues: LicenseCompatibilityIssue[] = [];
  
  // 多维度许可证分析
  packages.forEach(pkg => {
    const licenseInfo = extractLicenseInfo(pkg);
    licenseMap.set(pkg.name, licenseInfo);
  });
  
  // 兼容性检测
  detectCompatibilityIssues(licenseMap, issues);
  
  return {
    licenses: Array.from(licenseMap.entries()),
    issues,
    suggestions: generateComplianceSuggestions(licenseMap, issues)
  };
}

2.3 安全漏洞功能

2.3.1 漏洞风险评级体系

基于 CVSS 评分建立四层风险评级:

image.png

2.3.2 漏洞检测流程

image.png

2.4 依赖链路功能

2.4.1 力导向图布局算法

依赖关系可视化采用改进的力导向算法:

function createForceSimulation(nodes: Node[], links: Link[]) {
  return d3.forceSimulation(nodes)
    .force('link', d3.forceLink(links).distance(100))
    .force('charge', d3.forceManyBody().strength(-300))
    .force('center', d3.forceCenter(width / 2, height / 2))
    .force('collision', d3.forceCollide().radius(d => calculateNodeRadius(d)));
}

2.4.2 依赖路径分析

image.png

3. 系统架构与技术选型

3.1 整体架构设计

DepSleuth 采用现代化前后端分离架构:

image.png

3.2 技术选型矩阵

技术领域 选型方案 优势分析
前端框架 React 18 + TypeScript 类型安全,开发体验优秀
构建工具 Vite 快速热更新,构建优化
数据可视化 D3.js 灵活强大,定制能力强
状态管理 React Hooks 轻量简洁,学习成本低
样式方案 CSS Modules 样式隔离,维护方便
后端运行 Node.js 全栈统一,生态丰富

4. 工具价值与应用场景

4.1 核心价值定位

DepSleuth 在多个维度为前端工程提供价值:

  1. 效率提升:统一分析平台,减少工具切换成本
  2. 风险控制:主动发现安全与合规风险
  3. 性能优化:精准定位体积瓶颈,优化加载性能
  4. 质量保障:依赖关系透明化,提升可维护性

4.2 典型应用场景

场景一:性能优化攻坚

问题:应用首屏加载超过 5s,需要紧急优化

DepSleuth 解决方案:

  1. 体积分析识别 lodash、moment 等大型依赖
  2. 建议替换为 lodash-es、dayjs 等轻量方案
  3. 分析结果显示可减少 40% 打包体积

场景二:安全审计自动化

问题:定期安全审计耗时耗力,容易遗漏

DepSleuth 解决方案:

  1. 自动化漏洞扫描,覆盖所有传递依赖
  2. 按 CVSS 评分优先处理高危漏洞
  3. 提供一键修复建议和版本升级路径

5. 未来发展方向

5.1 技术演进路线

image.png

5.2 性能优化策略

  1. 增量分析:基于文件监控,只分析变更依赖
  2. 缓存策略:多级缓存体系,减少重复计算
  3. 并行处理:Web Worker 并行执行分析任务
  4. WASM 加速:关键算法 WebAssembly 化

6. 总结

DepSleuth 通过系统化的技术架构和算法设计,为前端依赖管理提供了全方位的解决方案。其核心价值在于:

  • 技术深度:基于先进算法的多维度分析
  • 用户体验:直观的可视化展示和交互
  • 工程价值:切实解决开发中的痛点问题
  • 扩展能力:模块化设计支持功能持续演进

在现代前端工程化日益复杂的背景下,DepSleuth 这样的专业依赖分析工具将成为提升工程质量、保障应用安全、优化用户体验的重要基础设施。随着前端生态的不断发展,依赖分析工具的技术深度和应用广度都将持续扩展,为开发者提供更加智能、高效的工程支撑。

7. 团队介绍

三翼鸟数字化技术平台-应用软件框架开发」主要负责设计工具的研发,包括营销设计工具、家电VR设计和展示、水电暖通前置设计能力,研发并沉淀素材库,构建家居家装素材库,集成户型库、全品类产品库、设计方案库、生产工艺模型,打造基于户型和风格的AI设计能力,快速生成算量和报价;同时研发了门店设计师中心和项目中心,包括设计师管理能力和项目经理管理能力。实现了场景全生命周期管理,同时为水,空气,厨房等产业提供商机管理工具,从而实现了以场景贯穿的B端C端全流程系统。

同样做中文平台自动化:为什么你越跑越贵,而 OpenCLI 越跑越稳

2026年4月2日 10:11

同样是做中文平台自动化,有的团队一周后就停了:Token 成本飙升、登录态反复失效、脚本一改版就崩。基于 opencli 仓库与 opencli.info 的实测,我把“高频任务低成本落地”的路径拆成可执行步骤:安装连通、中文平台命令验证、插件扩展、CDP 远程执行、退出码分流与报错修复。按文中命令操作,你今天就能把 B 站/知乎/微信公众号流程接进脚本和 CI。

大家好,我是 iDao。10 年全栈开发,做过架构、运维,也在落地 AI 工程化。这里不搞虚的,只分享能直接跑、能直接用的代码、方案和经验。内容包括:全栈开发实战、系统搭建、可视化大屏、自动化部署、AI 应用、私有化部署等。关注我,一起写能落地的代码,做能上线的项目。

1. 场景与问题现象

冲突就发生在同一个需求上:你要的是“每天稳定抓取中文平台数据”,但常见做法给你的却是“每次都重新推理、每月持续烧 Token、结果还不稳定”。再叠加账号登录态过期、平台页面改版、脚本输出字段漂移,很多自动化项目不是死在技术深度,而是死在长期可维护性。opencli 的价值点是把“站点能力”收敛成命令,统一输出 table/json/yaml/md/csv,并复用本机 Chrome 登录态,让高频重复任务回到可预测、可复用、可审计的轨道。

2. 根因分析

自动化项目不稳定,核心通常不是“命令不够多”,而是工程边界没定义清楚:第一,未知网站探索和已知站点批量任务混在一起;第二,认证链路散落在 Cookie 文件、环境变量和浏览器 profile 之间;第三,失败状态没有统一退出码,CI 很难做重试和分流。opencli 在仓库文档里明确了定位:它擅长“有适配器的站点 + 可重复任务 + 结构化输出”,不擅长未知站点的一次性探索。这个边界反而让它适合生产脚本。

3. 解决步骤

3.1 步骤一

先完成最小可用链路:安装 CLI、装 Browser Bridge、检查 daemon 与扩展连通,再跑一个公共命令和一个中文平台命令。这样可以把“环境问题”和“站点问题”快速分离。

npm install -g @jackwener/opencli

# 浏览器扩展安装后执行
opencli doctor
opencli daemon status

# 公共 API(无需登录)
opencli hackernews top --limit 5 -f json

# 中文平台(需要 Chrome 已登录)
opencli bilibili hot --limit 5 -f json
opencli zhihu hot -f yaml

3.2 步骤二

把“中文平台采集 + 文章归档”做成可复用脚本,并加插件能力。对于微信公众号和知乎,opencli 已提供 Markdown 导出命令,适合接入知识库或日报流水线。

# 下载公众号文章为 Markdown
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin

# 下载知乎专栏并可选抓图
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --download-images

# 安装插件(示例:掘金热榜)
opencli plugin install github:Astro-Han/opencli-plugin-juejin
opencli plugin list

4. 关键参数说明

  • -f/--format:建议在自动化链路里固定为 jsonyaml。表格输出适合人工查看,结构化输出适合脚本解析和 AI 二次处理。
  • OPENCLI_CDP_ENDPOINT:当你在远程服务器跑任务、无法加载本地扩展时,用该变量接入 CDP 端点(如 http://localhost:9222 或反向代理地址)。

5. 验证方式

建议按“连通性 -> 认证态 -> 命令稳定性 -> 退出码”四层验证。只要这四层都过,基本可以进入定时任务或 CI。

# 1) 连通性
opencli doctor

# 2) 认证态(中文平台)
opencli xiaohongshu search "AI 编程" --limit 3 -f json

# 3) 输出稳定性(重复两次比对字段)
opencli bilibili hot --limit 5 -f json > run1.json
opencli bilibili hot --limit 5 -f json > run2.json

# 4) 退出码分流
opencli bilibili hot 2>/dev/null
case $? in
  0)   echo "ok" ;;
  69)  echo "Browser Bridge 未连接" ;;
  77)  echo "账号未登录或登录过期" ;;
  75)  echo "临时失败,建议重试" ;;
esac

预期结果:公共命令稳定返回;中文平台命令在登录态有效时返回非空结构化数据;异常情况下退出码可用于自动化分流。

6. 常见报错与修复

  • Extension not connected -> 去 chrome://extensions 检查扩展是否启用;再执行 opencli doctoropencli daemon status
  • Unauthorized 或返回空数据 -> 在同一个 Chrome 用户里重新登录目标站点,刷新页面后重试命令。

7. 常见坑

  • 把 opencli 当成“任意网站自动化”工具使用。它更适合有适配器的高频任务,未知站点建议先用 Browser-Use/Stagehand 探索,再沉淀适配器。
  • 忽略退出码,只看 stdout。生产环境必须按 69/75/77 等退出码做重试或告警,不然排障会非常慢。
  • 在 CI 容器里直接跑浏览器命令却没有 CDP 端点或扩展链路,导致本地可跑、服务器失败。

8. 快速自检清单

  • Node.js 是否 >= 20,并且 opencli 已升级到最新版本。
  • Chrome 是否已登录目标中文平台账号(B 站/知乎/小红书/微信)。
  • opencli doctoropencli daemon status 是否通过。
  • 关键命令是否固定使用 -f json/-f yaml 供脚本消费。
  • CI 是否按退出码设置了重试、降级与告警。

9. 今天就能做的下一步

  1. 先把你最常用的 3 个中文平台命令跑通,并把输出统一成 JSON,接入现有脚本。
  2. 把“未知站点探索”与“已知站点批处理”拆成两条流水线:前者用通用浏览器 Agent,后者用 opencli 做稳定执行。

实测下来,opencli 最有价值的不是“命令多”,而是把登录态、输出格式、退出码和扩展机制统一成工程可控面。对中文平台自动化尤其友好,因为它把大量重复动作前置成适配器能力。你可以把它作为 Agent 工具链里的“稳定执行层”,把 LLM 预算留给真正需要推理的环节。

关注 【iDao技术魔方】,获取更多全栈到AI可落地的实战干货。

Vant4源码阅读之Upload

作者 岭子笑笑
2026年4月2日 10:04

前言

通过学习 Vant4 的 Upload 组件核心实现,可以帮助我们更好地理解其设计思路,并在实际项目中进行二次封装。


源码解析

1. renderUpload —— 渲染上传 UI 并绑定事件

  • renderUpload 函数用于返回上传组件的 UI 结构,包括:

    • 文件上传的 <input> 元素
    • Icon 图标渲染
    • 自定义 slot 内容渲染
  • 其中,<input type="file"> 用于调用浏览器原生的文件选择能力:

带有 type="file"<input> 元素允许用户从本地设备中选择一个或多个文件。选择完成后,可以通过:

  • 表单提交的方式上传到服务器
  • 或通过 JavaScript + File API 对文件进行处理
const renderUpload = () => {
  const lessThanMax = props.modelValue.length < +props.maxCount;

  // 创建 input,用于文件上传
  const Input = props.readonly ? null : (
    <input
      ref={inputRef}
      type="file"
      class={bem('input')}
      accept={props.accept}
      capture={props.capture as unknown as boolean}
      multiple={props.multiple && reuploadIndex.value === -1}
      disabled={props.disabled}
      onChange={onChange}
      onClick={onInputClick}
    />
  );

  // 如果外部传入了 slot,则优先渲染 slot
  if (slots.default) {
    return (
      <div
        v-show={lessThanMax}
        class={bem('input-wrapper')}
        onClick={onClickUpload}
      >
        {slots.default()}
        {Input}
      </div>
    );
  }

  // 默认上传 UI
  return (
    <div
      v-show={props.showUpload && lessThanMax}
      class={bem('upload', { readonly: props.readonly })}
      style={getSizeStyle(props.previewSize)}
      onClick={onClickUpload}
    >
      <Icon name={props.uploadIcon} class={bem('upload-icon')} />
      {props.uploadText && (
        <span class={bem('upload-text')}>{props.uploadText}</span>
      )}
      {Input}
    </div>
  );
};

2. onInputClick —— 点击 input 时触发

  • 用于初始化上传相关状态:

    • 如果不是重新上传,则重置 reuploadIndex
    • 标记当前不是重新上传状态
const onInputClick = () => {
  if (!isReuploading.value) {
    reuploadIndex.value = -1;
  }
  isReuploading.value = false;
};

3. onChange —— 文件选择后的核心入口

  • 获取用户选择的文件
  • 执行 beforeRead 钩子(如存在)
  • 最终调用 readFile 进行文件处理
const onChange = (event: Event) => {
  const { files } = event.target as HTMLInputElement;

  // 无文件或禁用状态直接返回
  if (props.disabled || !files || !files.length) {
    return;
  }

  const file =
    files.length === 1 ? files[0] : ([].slice.call(files) as File[]);

  // 执行 beforeRead 钩子
  if (props.beforeRead) {
    const response = props.beforeRead(file, getDetail());

    // 返回 false,则终止流程并重置 input
    if (!response) {
      resetInput();
      return;
    }

    // 如果返回 Promise
    if (isPromise(response)) {
      response
        .then((data) => {
          readFile(data || file);
        })
        .catch(resetInput);
      return;
    }
  }

  // 默认执行
  readFile(file);
};

// 判断是否为 Promise
export const isPromise = <T = any>(val: unknown): val is Promise<T> =>
  isObject(val) && isFunction(val.then) && isFunction(val.catch);

4. readFile —— 文件读取调度中心

  • 控制上传数量(maxCount
  • 调用 readFileContent 读取内容
  • 最终统一进入 onAfterRead
const readFile = (files: File | File[]) => {
  const { maxCount, modelValue, resultType } = props;

  if (Array.isArray(files)) {
    const remainCount = +maxCount - modelValue.length;

    // 超出数量限制则裁剪
    if (files.length > remainCount) {
      files = files.slice(0, remainCount);
    }

    Promise.all(
      files.map((file) => readFileContent(file, resultType)),
    ).then((contents) => {
      const fileList = (files as File[]).map((file, index) => {
        const result: UploaderFileListItem = {
          file,
          status: '',
          message: '',
          objectUrl: URL.createObjectURL(file),
        };

        if (contents[index]) {
          result.content = contents[index] as string;
        }

        return result;
      });

      onAfterRead(fileList);
    });
  } else {
    readFileContent(files, resultType).then((content) => {
      const result: UploaderFileListItem = {
        file: files,
        status: '',
        message: '',
        objectUrl: URL.createObjectURL(files),
      };

      if (content) {
        result.content = content;
      }

      onAfterRead(result);
    });
  }
};

5. readFileContent —— 文件内容读取

  • 根据 resultType 决定读取方式:
说明
file 不读取内容,仅返回 File 对象(推荐大文件)
text 读取为文本
dataUrl 读取为 base64
export function readFileContent(file: File, resultType: UploaderResultType) {
  return new Promise<string | void>((resolve) => {
    // file 类型不读取内容
    if (resultType === 'file') {
      resolve();
      return;
    }

    const reader = new FileReader();

    reader.onload = (event) => {
      resolve((event.target as FileReader).result as string);
    };

    if (resultType === 'dataUrl') {
      reader.readAsDataURL(file);
    } else if (resultType === 'text') {
      reader.readAsText(file);
    }
  });
}

6. onAfterRead —— 文件读取后的统一处理

  • 校验文件大小(maxSize
  • 过滤无效文件
  • 处理重新上传逻辑
  • 更新 v-model
  • 触发用户自定义 afterRead
const onAfterRead = (
  items: UploaderFileListItem | UploaderFileListItem[],
) => {
  // 重置 input
  resetInput();

  // 校验文件大小
  if (isOversize(items, props.maxSize)) {
    if (Array.isArray(items)) {
      const result = filterFiles(items, props.maxSize);
      items = result.valid;

      emit('oversize', result.invalid, getDetail());

      if (!items.length) return;
    } else {
      emit('oversize', items, getDetail());
      return;
    }
  }

  items = reactive(items);

  // 处理重新上传
  if (reuploadIndex.value > -1) {
    const arr = [...props.modelValue];
    arr.splice(reuploadIndex.value, 1, items as UploaderFileListItem);
    emit('update:modelValue', arr);
    reuploadIndex.value = -1;
  } else {
    // 正常追加
    emit('update:modelValue', [...props.modelValue, ...toArray(items)]);
  }

  // 用户自定义回调
  if (props.afterRead) {
    props.afterRead(items, getDetail());
  }
};

总结

  • 本文梳理了 Upload 组件的核心流程:
    UI 渲染 → 文件选择 → 前置校验 → 文件读取 → 后置处理 → 数据回传

  • 组件的核心职责包括:

    • 上传 UI 的渲染与交互
    • 文件数据的读取与转换
    • 上传前后钩子的扩展能力
    • 通过 v-model 与父组件进行数据同步

👉 理解这一流程后,你可以更灵活地实现:

  • 自定义上传逻辑(如直传 OSS)
  • 文件预处理(压缩 / 加密)
  • 更复杂的上传状态管理

深入理解 Node.js:生态体系与事件循环机制详解

作者 往日种种
2026年4月2日 09:43

深入理解 Node.js:生态体系与事件循环机制详解

Node.js 的诞生彻底打破了 JavaScript 仅能运行在浏览器端的边界,依托 Chrome V8 引擎,它将 JavaScript 带入服务器端开发领域,凭借轻量、高效、生态丰富的特性,成为大前端全栈开发(尤其是 BFF 层)的主流技术选型。本文将从 Node.js 核心特性、生态体系出发,深入解析其核心的事件循环机制,结合实操代码让读者理解其高并发能力的底层逻辑。

一、Node.js 核心特性与生态体系

1. 核心特性:异步无阻塞与单线程高并发

不同于 Java/Go 等多线程语言,Node.js 采用单线程 + 异步无阻塞 I/O 架构,这是其核心优势所在:

  • 单线程的优势:避免了多线程上下文切换的开销,代码逻辑更简单,开发和调试成本更低;
  • 异步无阻塞:对于文件 I/O、网络请求、数据库操作等耗时任务,Node.js 不会阻塞线程等待结果,而是将任务放入事件循环(Event Loop),立刻切换处理新的请求。少量线程即可支撑成千上万的并发连接,服务器资源开销仅为 Java 的一半,这也是 Node.js 高并发能力的核心来源。

2. 核心模块:夯实底层能力

Node.js 内置了丰富的核心模块,覆盖开发核心场景,也是开发者必须掌握的基础:

  • 文件模块(fs) :支持文件的同步 / 异步读写,以及流式处理。例如readFile/writeFile可通过promisify转换为 Promise 风格(避免回调地狱),而readFileSync则是阻塞式读取;createReadStream结合pipe可实现大文件的流式输出,避免一次性加载大文件占用过多内存;
  • 路径模块(path) :处理不同操作系统的路径兼容问题,简化路径拼接、解析等操作;
  • HTTP 模块:原生支持 HTTP 服务搭建,是后续框架的底层基础。

3. 框架生态:从轻量到企业级

Node.js 的框架生态满足不同层级的开发需求:

  • 轻量框架:Express、Koa 是前端开发者入门后端的首选,轻量、灵活,适合快速搭建接口、Mock 服务或 BFF 层应用;
  • 企业级框架:NestJS 基于 TypeScript 开发,天然支持模块化、依赖注入,贴合后端工程化理念,适合大型项目、AI 网关等企业级场景开发。

4. 应用场景与周边生态

Node.js 的生态覆盖全栈开发核心场景:

  • 核心场景:接口转发、实时通信(如 WebSocket)、AI 网关、管理后台开发;
  • BFF 层(Backend For Frontend) :前端开发者可基于 Node.js 封装 Go/Java 后端接口,适配前端业务需求,提升前后端协作效率;
  • 数据库与 ORM:主流适配 MySQL、PostgreSQL,结合 Prisma 等 ORM 工具,简化数据库操作,提升开发效率;
  • AI 开发生态:基于 LangChain 可封装 LLM 调用、构建工具调用(Tool),实现 Agent 开发流程;结合 RAG 技术(文档切分、向量化、向量数据库存储、检索增强),可快速搭建知识库问答系统。

二、Node.js 事件循环机制深度解析

事件循环(Event Loop)是 Node.js 实现异步编程的核心机制,它决定了异步任务的执行顺序。虽然 Node.js 和浏览器的事件循环本质都是 “事件驱动的异步模型”,但因运行环境(服务器 vs 浏览器)不同,实现细节差异显著。

1. 事件循环的核心逻辑

Node.js 的事件循环是一个 “无限循环”,其核心是:同步代码执行完毕后,按阶段依次处理异步任务,每个阶段都有专属的任务队列,且每个阶段执行完后会清空对应微任务队列

2. Node.js 事件循环的核心阶段

Node.js 的事件循环分为多个核心阶段,按执行顺序依次为:

  • timers 阶段:执行setTimeoutsetInterval调度的回调函数,注意setTimeout(fn, 0)并非立即执行,而是等待 timers 阶段触发;
  • poll 阶段:处理 I/O 异步任务(文件、网络、数据库等),是事件循环中最核心的阶段。若 poll 队列有任务,则依次执行;若队列空,则会等待新的 I/O 任务进入,或跳转到 check 阶段;
  • check 阶段:执行setImmediate调度的回调函数,在 poll 阶段空闲后 “强制触发”;
  • 其他阶段(如 idle、prepare、close callbacks):主要为内部逻辑服务,开发者无需重点关注。

3. 微任务队列:优先级高于宏任务

Node.js 的微任务队列包含两类,优先级从高到低为:

  • process.nextTick:独立于事件循环阶段,优先级最高,同步代码执行完后立即执行;
  • Promise 微任务(如Promise.resolve().then()):优先级低于process.nextTick,但高于所有宏任务(timers、poll、check)。

4. 浏览器 vs Node.js 事件循环

  • 浏览器事件循环:核心是 “宏任务→清空微任务→渲染→下一轮宏任务”,宏任务包含 script、setTimeout,微任务仅关注 Promise;
  • Node.js 事件循环:核心是 “多阶段调度”,每个阶段执行完后清空微任务队列,微任务包含process.nextTick和 Promise,且阶段划分更细(适配服务器的文件、网络 I/O 场景)。

5. 代码实例:拆解事件循环执行顺序

结合以下代码,我们逐行分析执行流程,理解事件循环的执行逻辑:

const fs = require('fs')

console.log('start')
// timers 阶段
setTimeout(() => {
  console.log('timeout')
}, 0)
// check 阶段
setImmediate(() => {
  console.log('immediate')
})
// poll 阶段
fs.readFile(__filename, () => {
  console.log('readFile')

  setTimeout(() => {
    console.log('timeout in I/O')
  }, 0)

  setImmediate(() => {
    console.log('immediate in I/O')
  })
})
// microtask 
Promise.resolve().then(() => {
  console.log('promise')
})
// microtask 优先级高于promise
process.nextTick(() => {
  console.log('nextTick')
})

console.log('end')
执行步骤拆解:
  1. 同步代码执行

    • 先执行console.log('start'),输出start
    • 遇到setTimeout(timers 阶段)、setImmediate(check 阶段)、fs.readFile(poll 阶段),均为异步任务,放入对应队列;
    • 遇到Promise.resolve().then()process.nextTick(),放入微任务队列;
    • 执行console.log('end'),输出end
  2. 同步代码执行完毕,清空微任务队列

    • 先执行process.nextTick,输出nextTick
    • 再执行 Promise 微任务,输出promise
  3. 进入事件循环的 timers 阶段

    • 执行setTimeout(fn, 0)的回调,输出timeout(注:若程序启动耗时极短,timers 阶段会优先执行;若耗时稍长,可能先进入 poll 阶段)。
  4. 进入 poll 阶段

    • 等待fs.readFile执行完成,触发回调函数,输出readFile
    • 回调内的setTimeout(timers)和setImmediate(check)被加入对应队列;
    • 因 poll 阶段执行完 I/O 回调后,会优先跳转到 check 阶段,因此先执行setImmediate,输出immediate in I/O
    • 下一轮事件循环的 timers 阶段,执行setTimeout回调,输出timeout in I/O
  5. check 阶段

    • 执行最外层的setImmediate回调,输出immediate(注:若 timers 阶段先执行,check 阶段会后执行)。
最终输出顺序(核心逻辑):
start
end
nextTick
promise
timeout
immediate
readFile
immediate in I/O
timeout in I/O

(注:timeoutimmediate的顺序可能因程序启动耗时略有波动,但 I/O 回调内的immediate in I/O一定早于timeout in I/O

三、理解事件循环的实践意义

掌握 Node.js 事件循环机制,是写出高性能异步代码的关键:

  1. 避免异步任务执行顺序问题:例如明确process.nextTick和 Promise 的优先级,避免回调执行顺序不符合预期;
  2. 优化性能:理解 poll 阶段的阻塞逻辑,避免 I/O 任务堆积导致事件循环卡顿;
  3. 排查问题:定位异步代码的执行延迟、内存泄漏等问题,例如区分 timers 和 check 阶段的任务调度差异。

总结

Node.js 凭借异步无阻塞、单线程高并发的特性,以及丰富的生态(从轻量框架 Express 到企业级框架 NestJS,从基础文件操作到 AI Agent 开发),成为全栈开发的核心技术。而事件循环作为其异步模型的核心,决定了异步任务的执行顺序,理解其阶段划分、微任务优先级,才能真正发挥 Node.js 的高并发优势。无论是接口转发、BFF 层开发,还是 AI 网关、Agent 系统搭建,掌握 Node.js 的核心特性与事件循环机制,都是开发者必备的能力。

React 闭包陷阱详解:为什么你的定时器总在“说谎”?

作者 AAA阿giao
2026年4月2日 00:23

引言

“我明明点了十次按钮,为什么控制台还在说 count 是 0?”
—— 每一个刚接触 React Hooks 的开发者,几乎都曾被这个问题狠狠“教育”过。

今天,我们就来彻底揭开 React 中的“闭包陷阱” (Closure Trap)这一经典问题的神秘面纱。我们将通过一段真实代码、深入原理剖析、对比错误与正确写法,并解释 为什么依赖项如此重要。无论你是初学者还是有经验的开发者,这篇文章都会让你对 React 的执行机制和闭包行为有更清晰的认识。


问题复现:一段看似无害的代码

先来看这段你可能写过无数次的代码:

import {
 useState,
 useEffect
} from 'react'

export default function App() {
  const [count, setCount] = useState(0)
  console.log('----------')
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('Current count:', count)
    }, 1000)
    // return的clearInterval函数不只是组件卸载时调用
    // 每次useEffect重新执行之前,都会执行上一次的clearInterval函数
    return () => { clearInterval(timer) }
  // }, []) // 此处没有依赖项 count,会导致闭包陷阱 ,运行时会出现访问到旧值的情况 一直显示'Current count: 0'
  }, [count]) // 此处一定要加依赖项 count,否则会导致闭包陷阱

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

乍一看,这段代码逻辑清晰:

  • 点击按钮,count 加 1;
  • 每隔 1 秒,打印当前 count 值。

但如果你把 useEffect 的依赖项写成空数组 [](即注释掉 [count] 的那一行),神奇的事情发生了:

控制台永远输出:Current count: 0,哪怕你点击了 100 次!

这,就是 React 闭包陷阱 的典型表现。


什么是闭包陷阱?

闭包本身不是问题

首先澄清一点:闭包是 JavaScript 的正常特性,不是 bug。它允许内部函数“记住”并访问其创建时所在作用域中的变量。

但在 React 函数组件中,每一次渲染都会生成全新的函数作用域。这意味着每次 App() 被调用(即每次 state 变化触发重新渲染),都会创建一组全新的变量(如 count)和函数(如 setCountuseEffect 回调等)。

陷阱在哪里?

当我们在 useEffect 中使用了某个状态变量(比如 count),却没有把它加入依赖数组时,就会导致:

useEffect 的回调函数“捕获”了第一次渲染时的 count 值(即 0),并在后续所有执行中始终使用这个“快照”

这就是所谓的 “闭包陷阱” —— 并非闭包错了,而是我们错误地让闭包“锁住”了一个过期的状态。


深入原理:React 渲染、Effect 与闭包的关系

让我们一步步拆解整个过程。

第一次渲染(count = 0)

  1. App() 执行。

  2. useState(0) 返回 count = 0

  3. useEffect 被调用(因为依赖项是 [],只在首次挂载时运行)。

  4. useEffect 内部:

    • 创建 setInterval,其回调函数引用了当前作用域的 count(值为 0)。
    • 这个回调函数形成了一个闭包,牢牢“记住”了 count = 0
  5. 组件挂载完成。

此时,定时器开始每秒打印 'Current count: 0'

用户点击按钮(count 变为 1)

  1. setCount(1) 被调用。

  2. React 触发第二次渲染

  3. App() 再次执行:

    • useState 返回 count = 1
    • 但由于 useEffect 的依赖是 []它不会重新执行
    • 所以之前的 setInterval 依然在运行,且它的闭包中 count 仍然是 0。
  4. 页面显示 count: 1,但控制台仍打印 0

后续点击(count = 2, 3, 4...)

同理:useEffect 不会重新运行,定时器回调始终使用第一次渲染时捕获的 count = 0

💡 关键点:定时器回调函数是在第一次渲染的作用域中定义的,它只能看到那个时刻的变量值


正确做法:把依赖项加上!

现在,我们把依赖项改为 [count]

}, [count]) // 此处一定要加依赖项 count,否则会导致闭包陷阱

会发生什么变化?

每次 count 改变,useEffect 都会重新执行!

  1. 第一次渲染:count = 0 → 启动定时器 A(打印 0)。

  2. 点击按钮 → count = 1 → 第二次渲染:

    • React 先调用上一次 useEffect 返回的清理函数clearInterval(timerA)
    • 然后执行新的 useEffect:启动定时器 B(打印 1)。
  3. 再次点击 → count = 2 → 第三次渲染:

    • 清理定时器 B。
    • 启动定时器 C(打印 2)。

这样,每个定时器都“绑定”到它创建时的最新 count,控制台就能正确输出当前值。

✅ 这正是 React 官方推荐的模式:Effect 应该明确声明它所依赖的所有响应式值


为什么很多人一开始会写 []

常见误区包括:

  • 误以为 useEffect 类似于 class 组件的 componentDidMount,只想在“挂载时”运行一次。
  • 担心频繁创建/销毁定时器会影响性能
  • 不了解闭包在 React 渲染循环中的行为

但 React 的哲学是:不要对抗重渲染,而是拥抱它。正确的依赖管理比“避免重运行”更重要。


补充说明:清理函数的调用时机

注意代码中的注释:

// return的clearInterval函数不只是组件卸载时调用
// 每次useEffect重新执行之前,都会执行上一次的clearInterval函数

这是 React 的一个重要机制:

每当依赖项变化导致 useEffect 重新运行时,React 会先调用上一次返回的清理函数,再执行新的 Effect

这确保了资源(如定时器、订阅、WebSocket 连接等)不会泄漏,也避免多个定时器同时运行。


其他避免闭包陷阱的方法(进阶)

虽然添加依赖项是最直接的方式,但在某些场景下(比如高性能动画或复杂逻辑),频繁重建定时器可能不理想。这时可以考虑:

方法 1:使用 useRef 保存最新值

const countRef = useRef(count);
useEffect(() => {
  countRef.current = count;
}, [count]);

useEffect(() => {
  const timer = setInterval(() => {
    console.log('Current count:', countRef.current); // 总是最新值
  }, 1000);
  return () => clearInterval(timer);
}, []); // 依赖为空,但通过 ref 获取最新值

注意:这种方式绕过了 React 的响应式模型,应谨慎使用。

方法 2:使用函数式更新(适用于 setState)

如果只是想在定时器中更新状态,可以用:

setInterval(() => {
  setCount(c => c + 1); // 基于最新状态计算
}, 1000);

但这不适用于“读取”状态(如日志打印)。


总结:如何避免闭包陷阱?

场景 错误做法 正确做法
useEffect 中使用状态变量 依赖项为空 [] 将变量加入依赖数组 [count]
需要长期运行的副作用(如 WebSocket) 忽略依赖 使用 useRef 或合理设计依赖
不确定依赖项 随意省略 使用 ESLint 插件 eslint-plugin-react-hooks 自动检测

📌 黄金法则:只要你在 Effect、Callback 或其他闭包中用了某个响应式值(state、props、由它们派生的值),就必须把它加入依赖数组!


结语

React 的闭包陷阱,本质上不是 React 的缺陷,而是 函数式编程 + 响应式更新 模型带来的自然结果。理解它,不仅能写出更健壮的代码,还能真正掌握 React 的心智模型。

下次当你看到控制台打印出“过期”的状态时,别慌——
检查你的依赖项,闭包陷阱就无处遁形!


📚 延伸阅读:

希望这篇文章帮你彻底搞懂闭包陷阱!如果你觉得有用,欢迎分享给正在“被 0 困扰”的朋友 😄

手撕代码之事件委托

作者 im_AMBER
2026年4月1日 22:47

一、题目

请补全 JavaScript 代码,要求如下:

  1. ul 标签添加点击事件
  2. 当点击某 li 标签时,该标签内容拼接 . 符号。如:某 li 标签被点击时,该标签内容为 ..

注意: 必须使用 DOM0 级标准事件(onclick

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <style>
        /* 填写样式 */
    </style>
</head>
<body>
    <ul>
        <li>.</li>
        <li>.</li>
        <li>.</li>
    </ul>
    <!-- 填写标签 -->
    <script type="text/javascript">
        // 填写JavaScript
        document.querySelector('ul').onclick = event => {

        }
    </script>
</body>
</html>

二、思路与笔记

1. 事件委托的核心概念

事件冒泡

提到事件委托,首先会先引入一个 事件冒泡 的概念。

事件冒泡 可以去读一读 MDN 的文档,当然在 React 等前端框架的基础下我认为这个可以暂且不提。

核心思想说,事件冒泡在描述一个浏览器对于嵌套元素的事件是如何处理的。

在子元素上面增加事件处理器时,事件会一层层往父级元素冒泡,父级的父级……

MDN 这里有原话是:

在这种情况下:

  • 最先触发按钮上的单击事件
  • 然后是按钮的父元素(<div> 元素)
  • 然后是 <div> 的父元素(<body> 元素)

我们可以这样描述:事件从被点击的最里面的元素 冒泡 而出。

这种行为可能是有用的,也可能引起意想不到的问题。在接下来的章节中,我们将看到它引起的一个问题,并找到解决方案。

对于一些功能我们并不希望冒泡的,会有 stopPropagation(),当然在今天的手撕代码里并不是重点。

事件委托

事件委托

事件冒泡可以实现 事件委托

在这种做法中,当我们想在用户与大量的子元素中的任何一个互动时运行一些代码时,我们在它们的父元素上设置事件监听器,让发生在它们身上的事件冒泡到它们的父元素上,而不必在每个子元素上单独设置事件监听器。

事件委托就是“自己不处理,让祖先元素代为处理”。

事件委托 = 把监听器挂到父级,利用冒泡 + event.target 来统一处理子元素事件。

事件对象中的 target 属性指向 实际触发事件的元素(不是绑定事件的元素)。

具体来说:

你不必给每个子元素单独绑定事件监听器,而是把监听器绑定在它们的 父元素(或更高层祖先) 上。

当子元素上的事件(比如点击)触发后,事件会沿着 DOM 树 向上冒泡,被父元素捕获并执行对应的处理函数。

示例1

假设有一个 <ul> 列表,里面有 1000 个 <li>

<ul id="list">
    <li>项目 1</li>
    <li>项目 2</li>
    <li>项目 3</li>
    ……
</ul>

不用事件委托的做法:

const items = document.querySelectorAll('#list li');
items.forEach(item => {
    item.addEventListener('click', () => {
        console.log('点击了', item.textContent);
    });
});

缺点:

  • 如果动态新增 <li>,新增的项不会有点击事件(除非重新绑定)
  • 性能差(1000 个监听器)

用事件委托的做法:

const parent = document.getElementById('list');
parent.addEventListener('click', (event) => {
    // event.target 是真正被点击的元素
    if (event.target.tagName === 'LI') {
        console.log('点击了', event.target.textContent);
    }
});

优点:

  • 只有 一个 监听器,性能好
  • 动态新增的子元素 自动具备 点击响应
  • 代码更简洁
示例2

场景:一个待办事项列表,我只关心“项目 3”是否被点击

<ul id="list">
    <li id="item-1">项目 1</li>
    <li id="item-2">项目 2</li>
    <li id="item-3">项目 3</li>
    <li id="item-4">项目 4</li>
</ul>

情况1:直接监听(不是事件委托)

const specificLi = document.getElementById('item-3');
specificLi.addEventListener('click', () => {
    console.log('点击了第3项');
});

特征:

  • 监听器直接挂在 item-3 这个 <li>
  • 不依赖冒泡机制
  • 如果 item-3 被删除再重新添加,需要重新绑定
  • 只监听这一个元素

情况2:事件委托(只关心特定子元素)

const parent = document.getElementById('list');
parent.addEventListener('click', (event) => {
    const li = event.target.closest('li');
    if (li && li.id === 'item-3') {
        console.log('点击了第3项');
    }
});

特征:

  • 监听器挂在父元素 <ul>
  • 依赖冒泡机制:子元素点击 → 事件冒泡到 <ul> → 执行回调
  • 即使 item-3 被删除后重新动态添加,仍然自动有效
  • 一个监听器覆盖了所有子元素,但通过条件过滤只处理 item-3

很多人误以为事件委托只能用来批量处理所有子元素(比如给所有 <li> 加点击)。

实际上,事件委托的核心是利用冒泡 + 祖先监听,至于处理哪些子元素,由条件判断灵活控制

// 可以处理特定子元素
if (li.id === 'item-3') { ... }

// 可以处理某一类子元素
if (li.classList.contains('important')) { ... }

// 可以处理所有子元素(最常用)
if (li) { ... }

Event 接口

Event 接口表示在 EventTarget 上出现的事件。

一些事件是由用户触发的,例如鼠标或键盘事件;或者由 API 生成以表示异步任务的进度。事件也可以通过编程方式触发,例如对元素调用 HTMLElement.click() 方法,或者定义一些自定义事件,再使用 EventTarget.dispatchEvent() 方法将自定义事件派发往指定的目标(target)。

有许多不同类型的事件,其中一些使用基于 Event 主接口的其他接口。Event 本身包含适用于所有事件的属性和方法。

很多 DOM 元素可以被设计接收(或者监听)这些事件,并且执行代码去响应(或者处理)它们。通过 EventTarget.addEventListener() 方法可以将事件处理器绑定到不同的 HTML 元素上(比如 <button><div><span> 等等)。这种方式基本替换了老版本中使用 HTML 事件处理器属性的方式。此外,在正确添加后,还可以使用 removeEventListener() 方法移除这些事件处理器。

备注: 一个元素可以绑定多个事件处理器,甚至是对于完全相同的事件。尤其是相互独立的代码模块出于不同的目的附加事件处理器。(比如,一个网页同时有着广告模块和统计模块同时监听视频播放。)

当有很多嵌套的元素,每个元素都有着自己的事件处理器,事件处理过程会变得非常复杂。尤其当一个父元素和子元素绑定完全相同的事件时,因为结构上的重叠,事件在技术层面发生在两个元素中,触发的顺序取决于每个处理器的事件冒泡的设置。


2. Event.target 属性

Event 接口的 target 只读属性是对事件被分派到的对象的引用。当事件处理器在事件的冒泡或捕获阶段被调用时,它与 Event.currentTarget 不同。

event.target 属性可以用于实现 事件委托

  • event.target实际被点击的那个元素,不是绑定事件的元素。
  • ul.onclick,点击 li 时,event.target 就是那个 li,不是 ul。

在 JS 里,对象传递的就是引用(内存地址的拷贝),你可以直接修改它。

MDN 中有这个示例代码:

// 创建列表
const ul = document.createElement("ul");
document.body.appendChild(ul);

const li1 = document.createElement("li");
const li2 = document.createElement("li");
ul.appendChild(li1);
ul.appendChild(li2);

function hide(evt) {
    // evt.target 指向被点击的 <li> 元素
    // 这与 evt.currentTarget 不同,后者在这个上下文中将指向父级 <ul>
    evt.target.style.visibility = "hidden";
}

// 将监听器附加到列表上
// 点击每个 <li> 时都会触发
ul.addEventListener("click", hide, false);

3. Node.textContent 属性

Node 接口的 textContent 属性表示一个节点及其后代的文本内容。

textContent 的 MDN 文档,它 既可以读,也可以写

innerHTML 的区别

正如其名称,Element.innerHTML 返回 HTML。通常,为了在元素中检索或写入文本,人们使用 innerHTML。但是,textContent 通常具有更好的性能,因为文本不会被解析为 HTML。

此外,使用 textContent 可以防止 XSS 攻击

textContent 获取的是元素内的纯文本内容 string,会过滤掉所有 HTML 标签,只返回文字部分。设置时,内容会作为普通文本插入,不会被解析成 HTML 标签。


4. Element.tagName 属性

注意 tagName 获取当前元素标签名。

if (被点击的元素是 li) {
    获取当前文本内容
    新内容 = 当前内容 + "."
    把新内容设置回去
}

自己乱写了一波

if (event.target.tagName === 'li') {
    let text = event.target.texContent ;
    let newText = text + '.';
    // 不知道怎么设置回去
    event.target.textContent = newText;
}

注意这个不知道怎么设置回去就是依赖 event.target.textContent 是可以读也可以写的。

语法

elementName = element.tagName
  • elementName 是一个字符串,包含了 element 元素的标签名。

备注

在 XML(或者其他基于 XML 的语言,比如 XHTML, xul)文档中,tagName 的值会保留原始的大小写。

比如 span 会返回 SPAN,那在判定的时候要写成大写的。

在 HTML 文档中,tagName 会返回其大写形式。对于元素节点来说,tagName 属性的值和 nodeName 属性的值是相同的。


三、解法

1. 思路推导

根据以上笔记,解题思路如下:

  • 使用 DOM0 级事件 onclick 给 ul 绑定点击事件
  • 通过 event.target 获取实际点击的元素
  • 通过 tagName 判断点击的是否为 li 元素
  • 通过 textContent 读取并修改 li 的内容

2. 最终代码

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <style>
        /* 填写样式 */
    </style>
</head>
<body>
    <ul>
        <li>.</li>
        <li>.</li>
        <li>.</li>
    </ul>
    <!-- 填写标签 -->
    <script type="text/javascript">
        document.querySelector('ul').onclick = event => {
            let eventName = event.target.tagName;
            if (eventName === 'LI') {
                let eventText = event.target.textContent;
                event.target.textContent = eventText + '.';
            }
        }
    </script>
</body>
</html>

四、小结

还是比较简单的,第一次手撕有点没有习惯这个写法,具体思路很清晰,多查查 API,多看 MDN 的示例。

参考链接

【Vue | initial】 创建初始化项目

作者 Jacob0000
2026年4月1日 22:22

chuang'jian'chu创建初始化

1. 使用命令创建

官方官网快速上手

npm create vite@latest 

This command will install and execute create-vue, the official Vue project scaffolding tool. You will be presented with prompts for several optional features such as TypeScript and testing support:

image.png

  • 询问是否继续 ;请填写项目名

image.png

  • 是否要添加 ts (typescript)

image.png

  • 选择其他依赖

image.png

  • 跳过

image.png

  • 是否选择示例代码

image.png

image.png

初始化完成

image.png

因此,我们可以采用“架构师视角” 较为自动化的初始化

架构师视角

# 架构师写在公司自动化脚本里的命令,瞬间生成标准项目
 npm create vue@latest my-company-project -- --ts --router --pinia --eslint --prettier

2. 进入创建的目录中,安装依赖(基础依赖)

Once the project is created, follow the instructions to install dependencies and start the dev server:


cd <your-project-name>
npm install
npm run dev


image.png

粗心粗心???

image.png

基础依赖 npm install 是根据这个文件进行执行安装的package.json

You should now have your first Vue project running!

3. 安装“全家桶”依赖

安装“全家桶”依赖 npm install axios element-plus @element-plus/icons-vue

image.png

4.安装并配置 Tailwind CSS V4

npm install tailwindcss @tailwindcss/vite

image.png

5. 配置 vite.config.ts

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'

import tailwindcss from '@tailwindcss/vite'

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    vueDevTools(),  // register the plugin
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    },
  },
})



错误边界处理

作者 Csvn
2026年4月1日 22:18

引言

在 React 应用中,一个组件的错误不应导致整个应用崩溃。错误边界(Error Boundaries)是 React 提供的错误隔离机制,它能捕获子组件树中的 JavaScript 错误,并显示降级 UI 而非让整个应用白屏。


什么是错误边界?

错误边界是类组件,通过实现 getDerivedStateFromError()componentDidCatch() 生命周期方法来捕获子组件的错误。

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    // 渲染降级 UI
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // 记录错误日志
    console.error('捕获错误:', error, errorInfo);
    this.setState({ error });
  }

  render() {
    if (this.state.hasError) {
      return <FallbackUI error={this.state.error} />;
    }
    return this.props.children;
  }
}

核心 API 对比

方法 调用时机 用途 能否访问组件实例
getDerivedStateFromError 渲染阶段 更新 state 显示降级 UI ❌ 静态方法
componentDidCatch 提交阶段 记录错误日志、上报 ✅ 可访问实例

实战:完整的错误边界组件

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { 
      hasError: false, 
      error: null,
      errorInfo: null
    };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    this.setState({ error, errorInfo });
    
    // 上报到错误监控平台
    logErrorToService(error, errorInfo);
  }

  handleRetry = () => {
    this.setState({ hasError: false, error: null, errorInfo: null });
  };

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-boundary">
          <h2>😕 出错了</h2>
          <p>组件渲染失败,请尝试刷新</p>
          <button onClick={this.handleRetry}>重试</button>
          {process.env.NODE_ENV === 'development' && (
            <details>
              <summary>错误详情</summary>
              <pre>{this.state.error?.toString()}</pre>
            </details>
          )}
        </div>
      );
    }
    return this.props.children;
  }
}

错误上报策略

// 错误上报服务
async function logErrorToService(error, errorInfo) {
  const errorData = {
    message: error.message,
    stack: error.stack,
    componentStack: errorInfo.componentStack,
    url: window.location.href,
    userAgent: navigator.userAgent,
    timestamp: Date.now()
  };

  // 使用 sendBeacon 确保上报成功
  navigator.sendBeacon('/api/log-error', JSON.stringify(errorData));
  
  // 或发送到第三方监控平台
  // Sentry.captureException(error, { contexts: { react: errorInfo } });
}

使用场景

// ✅ 包裹可能出错的子组件
<ErrorBoundary>
  <UserProfile userId={123} />
</ErrorBoundary>

// ✅ 多个独立边界,隔离错误
<ErrorBoundary fallback={<ChatFallback />}>
  <ChatWidget />
</ErrorBoundary>

<ErrorBoundary fallback={<FeedFallback />}>
  <NewsFeed />
</ErrorBoundary>

// ❌ 不要包裹整个应用(失去隔离意义)
<ErrorBoundary>
  <App />
</ErrorBoundary>

注意事项

能捕获 不能捕获
子组件渲染错误 事件处理器中的错误
生命周期错误 异步代码(setTimeout、requestAnimationFrame)
构造函数错误 SSR 服务端错误
边界组件自身的错误

总结

  1. 错误边界必须是类组件(Hooks 方案需用第三方库如 react-error-boundary)
  2. 精细化包裹:在可能出错的组件周围单独设置边界
  3. 优雅降级:提供友好的错误提示和重试机制
  4. 错误上报:记录错误信息用于后续分析修复
  5. 开发环境:显示详细错误信息便于调试

React全家桶笔记(三):React进阶 — 事件处理、表单与生命周期

2026年4月1日 21:33

React全家桶笔记(三):React进阶 — 事件处理、表单与生命周期

本篇涵盖 React 事件处理机制、受控/非受控组件、高阶函数与柯里化、生命周期(新旧对比)、Diffing 算法。这些是从"会用"到"理解原理"的关键跨越。 📺 对应张天禹react全家桶视频:P32 - P48


一、React 中的事件处理(P32)

1.1 事件处理机制

class Demo extends React.Component {
  myRef = React.createRef()

  // 发生事件的元素正好是你要操作的元素 → 可以省略 ref
  showData = (event) => {
    alert(event.target.value)
  }

  render() {
    return (
      <div>
        <input onBlur={this.showData} type="text" placeholder="失去焦点提示" />
      </div>
    )
  }
}

React 事件处理的两个要点

  1. React 使用的是自定义(合成)事件,而不是原生 DOM 事件 — 为了更好的兼容性
  2. React 中的事件是通过事件委托方式处理的(委托给组件最外层的元素) — 为了高效

💡 实践建议:不要过度使用 ref。当发生事件的元素就是你要操作的元素时,可以通过 event.target 获取 DOM,不需要 ref。


二、受控组件与非受控组件(P33-P34)

2.1 非受控组件(P33)

表单数据在需要时才通过 ref "现取现用":

class Login extends React.Component {
  handleSubmit = (event) => {
    event.preventDefault() // 阻止表单默认提交行为
    const { username, password } = this
    alert(`用户名:${username.value},密码:${password.value}`)
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        用户名:<input ref={c => this.username = c} type="text" name="username" />
        密码:<input ref={c => this.password = c} type="password" name="password" />
        <button>登录</button>
      </form>
    )
  }
}

特点:输入类 DOM 的值是"现用现取"的,页面中的表单数据由 DOM 自身管理。

2.2 受控组件(P34)— 推荐

表单数据随着输入实时维护到 state 中,需要时从 state 取:

class Login extends React.Component {
  state = {
    username: '',
    password: ''
  }

  saveUsername = (event) => {
    this.setState({ username: event.target.value })
  }

  savePassword = (event) => {
    this.setState({ password: event.target.value })
  }

  handleSubmit = (event) => {
    event.preventDefault()
    const { username, password } = this.state
    alert(`用户名:${username},密码:${password}`)
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        用户名:<input onChange={this.saveUsername} type="text" name="username" />
        密码:<input onChange={this.savePassword} type="password" name="password" />
        <button>登录</button>
      </form>
    )
  }
}

特点:输入类 DOM 的值实时存入 state,等于 Vue 中的双向绑定效果。推荐使用,因为可以省略 ref。

🎯 面试高频:受控组件 vs 非受控组件

  • 受控组件:表单数据由 React 的 state 管理,输入即存储,类似 Vue 的 v-model
  • 非受控组件:表单数据由 DOM 自身管理,需要时通过 ref 获取
  • 推荐受控组件,因为数据集中管理,且不需要大量 ref

三、高阶函数与函数柯里化(P35-P36)

3.1 问题引出

上面的受控组件中,每个表单项都要写一个 saveXxx 方法,如果有 20 个表单项就要写 20 个方法,太冗余了。

3.2 用柯里化优化(P35)

class Login extends React.Component {
  state = { username: '', password: '' }

  // 高阶函数 + 柯里化:返回一个函数作为事件回调
  saveFormData = (dataType) => {
    return (event) => {
      this.setState({ [dataType]: event.target.value })
    }
  }

  handleSubmit = (event) => {
    event.preventDefault()
    const { username, password } = this.state
    alert(`用户名:${username},密码:${password}`)
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        {/* 注意:onChange 的值必须是一个函数,这里 saveFormData('username') 的返回值就是一个函数 */}
        用户名:<input onChange={this.saveFormData('username')} type="text" />
        密码:<input onChange={this.saveFormData('password')} type="password" />
        <button>登录</button>
      </form>
    )
  }
}

概念解析

高阶函数:满足以下任一条件的函数

  • 接收的参数是一个函数(如 PromisesetTimeoutarr.map()
  • 返回值是一个函数(如上面的 saveFormData

函数柯里化:通过函数调用继续返回函数的方式,实现多次接收参数最后统一处理的函数编码形式

// 普通函数
function sum(a, b, c) { return a + b + c }
sum(1, 2, 3) // 6

// 柯里化
function sum(a) {
  return (b) => {
    return (c) => {
      return a + b + c
    }
  }
}
sum(1)(2)(3) // 6

3.3 不用柯里化的写法(P36)

class Login extends React.Component {
  state = { username: '', password: '' }

  saveFormData = (dataType, event) => {
    this.setState({ [dataType]: event.target.value })
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        {/* 用箭头函数包一层,在回调中自己调用 saveFormData */}
        用户名:<input onChange={event => this.saveFormData('username', event)} type="text" />
        密码:<input onChange={event => this.saveFormData('password', event)} type="password" />
        <button>登录</button>
      </form>
    )
  }
}

🔗 概念扩展:两种写法的本质 不管是柯里化还是箭头函数包裹,核心目的都是一样的:确保 onChange 的值是一个函数,同时能把额外的参数(dataType)传进去。


四、生命周期(P37-P47)

4.1 引出生命周期(P37)

class Life extends React.Component {
  state = { opacity: 1 }

  // 组件挂载完毕后调用
  componentDidMount() {
    this.timer = setInterval(() => {
      let { opacity } = this.state
      opacity -= 0.1
      if (opacity <= 0) opacity = 1
      this.setState({ opacity })
    }, 200)
  }

  // 组件将要卸载时调用 — 适合做收尾工作(清除定时器、取消订阅等)
  componentWillUnmount() {
    clearInterval(this.timer)
  }

  death = () => {
    // 卸载组件
    ReactDOM.unmountComponentAtNode(document.getElementById('test'))
  }

  render() {
    return (
      <div>
        <h2 style={{opacity: this.state.opacity}}>React 学不会怎么办?</h2>
        <button onClick={this.death}>不活了</button>
      </div>
    )
  }
}

生命周期(又叫生命周期回调函数、生命周期钩子函数):React 组件从创建到销毁会经历一系列特定阶段,React 会在特定时刻调用特定的方法,这些方法就是生命周期钩子。

4.2 生命周期(旧)— React 16 之前(P38-P42)

挂载阶段(Mount)— P38

constructor()                → 构造器
componentWillMount()         → 组件将要挂载
render()                     → 渲染
componentDidMount()          → 组件挂载完毕 ⭐ 常用

更新阶段(Update)— P39-P41

三种触发更新的方式:

方式1setState() — P39
shouldComponentUpdate()      → 组件是否应该更新(返回 true/false,默认返回 true)
componentWillUpdate()        → 组件将要更新
render()                     → 重新渲染
componentDidUpdate()         → 组件更新完毕

方式2forceUpdate() — P40(强制更新,跳过 shouldComponentUpdate)
componentWillUpdate()        → 组件将要更新
render()                     → 重新渲染
componentDidUpdate()         → 组件更新完毕

方式3:父组件重新 render — P41
componentWillReceiveProps()  → 组件将要接收新的 props ⚠️ 第一次不算
shouldComponentUpdate()      → 组件是否应该更新
componentWillUpdate()        → 组件将要更新
render()                     → 重新渲染
componentDidUpdate()         → 组件更新完毕

卸载阶段(Unmount)

componentWillUnmount()       → 组件将要卸载 ⭐ 常用

旧版生命周期总结(P42)

旧版生命周期流程图:
┌─ 挂载时 ──────────────────────────────────┐
│  constructor → componentWillMount → render │
│  → componentDidMount                       │
└────────────────────────────────────────────┘
┌─ 更新时 ──────────────────────────────────────────────────┐
│  父组件render → componentWillReceiveProps                  │
│  → shouldComponentUpdate(true) → componentWillUpdate       │
│  → render → componentDidUpdate                             │
│                                                            │
│  setState → shouldComponentUpdate(true)                    │
│  → componentWillUpdate → render → componentDidUpdate       │
│                                                            │
│  forceUpdate → componentWillUpdate → render                │
│  → componentDidUpdate                                      │
└────────────────────────────────────────────────────────────┘
┌─ 卸载时 ──────────────────────────────────┐
│  componentWillUnmount                      │
└────────────────────────────────────────────┘

4.3 对比新旧生命周期(P43)

React 17+ 中,三个带 Will 的钩子被标记为不安全(UNSAFE),需要加 UNSAFE_ 前缀才能使用:

❌ componentWillMount        → UNSAFE_componentWillMount
❌ componentWillUpdate       → UNSAFE_componentWillUpdate
❌ componentWillReceiveProps → UNSAFE_componentWillReceiveProps

为什么废弃? 这三个钩子经常被误用/滥用,在 React 未来的异步渲染(Fiber)中可能会出问题。

新版生命周期新增了两个钩子:

  • getDerivedStateFromProps — 从 props 派生 state
  • getSnapshotBeforeUpdate — 在更新前获取快照

4.4 getDerivedStateFromProps(P44)

class Count extends React.Component {
  state = { count: 0 }

  // 注意:这是一个 static 方法,接收 props 和 state
  // 返回一个状态对象或 null
  static getDerivedStateFromProps(props, state) {
    console.log('getDerivedStateFromProps', props, state)
    // 返回的对象会与 state 合并
    // 如果返回 null,则不影响 state
    return null
  }

  render() {
    return <h1>当前求和为:{this.state.count}</h1>
  }
}

使用场景:state 的值在任何时候都取决于 props 时使用(极少用)。一旦使用,state 就会被 props "控制"住。

⚠️ 这个钩子使用场景非常罕见,了解即可。

4.5 getSnapshotBeforeUpdate(P45-P46)

class NewsList extends React.Component {
  state = { newsArr: [] }

  componentDidMount() {
    setInterval(() => {
      const { newsArr } = this.state
      const news = `新闻${newsArr.length + 1}`
      this.setState({ newsArr: [news, ...newsArr] })
    }, 1000)
  }

  // 在更新之前获取快照(DOM 更新前的信息)
  // 返回值会作为 componentDidUpdate 的第三个参数
  getSnapshotBeforeUpdate() {
    return this.refs.list.scrollHeight
  }

  componentDidUpdate(prevProps, prevState, snapshotValue) {
    // snapshotValue 就是 getSnapshotBeforeUpdate 的返回值
    // 用来保持滚动位置不变
    this.refs.list.scrollTop += this.refs.list.scrollHeight - snapshotValue
  }

  render() {
    return (
      <div ref="list" className="list">
        {this.state.newsArr.map((n, index) => (
          <div key={index} className="news">{n}</div>
        ))}
      </div>
    )
  }
}

使用场景:在 DOM 更新前捕获一些信息(如滚动位置),传递给 componentDidUpdate 使用。

4.6 新版生命周期总结(P47)

新版生命周期流程图:
┌─ 挂载时 ──────────────────────────────────────────────┐
│  constructor → getDerivedStateFromProps → render        │
│  → componentDidMount ⭐                                │
└────────────────────────────────────────────────────────┘
┌─ 更新时 ──────────────────────────────────────────────────────────┐
│  getDerivedStateFromProps → shouldComponentUpdate(true)             │
│  → render → getSnapshotBeforeUpdate → componentDidUpdate           │
└────────────────────────────────────────────────────────────────────┘
┌─ 卸载时 ──────────────────────────────────────────────┐
│  componentWillUnmount ⭐                               │
└────────────────────────────────────────────────────────┘

最重要的三个钩子

componentDidMount    → 组件挂载完毕
  常用于:发送网络请求、订阅消息、开启定时器

componentDidUpdate   → 组件更新完毕
  常用于:根据更新后的 props/state 做操作

componentWillUnmount → 组件将要卸载
  常用于:清除定时器、取消订阅、清理工作

🎯 面试高频:React 新旧生命周期的区别?

  1. 废弃了三个 Will 钩子(componentWillMount/Update/ReceiveProps)
  2. 新增了 getDerivedStateFromProps(从 props 派生 state)和 getSnapshotBeforeUpdate(更新前快照)
  3. 废弃原因:为 React 未来的异步渲染(Concurrent Mode)做准备
  4. 最常用的仍然是:componentDidMountcomponentWillUnmount

五、DOM 的 Diffing 算法(P48)

5.1 Diffing 算法的最小粒度

Diffing 算法对比的最小粒度是标签(节点) ,不是整棵树。

// 假设 state 中 time 每秒更新一次
render() {
  return (
    <div>
      <h1>Hello</h1>
      <span>
        现在是:{this.state.time}
      </span>
    </div>
  )
}
// Diffing 对比时:
// <h1>Hello</h1> → 没变,不更新
// <span>现在是:xxx</span> → 内容变了,只更新这个 span

5.2 key 的作用

// 用 index 作为 key 的问题演示
// 初始数据:
//   { id: 1, name: '小张', age: 18 }
//   { id: 2, name: '小李', age: 19 }

// 初始虚拟 DOM(用 index 作 key):
<li key={0}>小张---18 <input type="text"/></li>
<li key={1}>小李---19 <input type="text"/></li>

// 在头部插入 { id: 3, name: '小王', age: 20 } 后:
<li key={0}>小王---20 <input type="text"/></li>  // key=0 对比:内容变了,更新!
<li key={1}>小张---18 <input type="text"/></li>  // key=1 对比:内容变了,更新!
<li key={2}>小李---19 <input type="text"/></li>  // key=2:新增

// 如果用 id 作 key:
<li key={3}>小王---20 <input type="text"/></li>  // key=3:新增,只创建这一个
<li key={1}>小张---18 <input type="text"/></li>  // key=1 对比:没变,复用!
<li key={2}>小李---19 <input type="text"/></li>  // key=2 对比:没变,复用!

5.3 用 index 作为 key 的问题

  1. 效率问题:逆序添加、逆序删除等破坏顺序的操作,会产生没有必要的真实 DOM 更新(界面没问题,但效率低)
  2. 严重 Bug:如果结构中包含输入类 DOM(input),会产生错误的 DOM 更新(输入框内容错位)

5.4 key 的选择原则

key 的选择:
├── 最好使用数据的唯一标识(id、手机号、身份证号等)
├── 如果只是简单的展示数据(不涉及逆序操作),用 index 也可以
└── 绝对不要用 Math.random() 作为 key

🎯 面试高频:React/Vue 中 key 的作用和原理?

  1. key 是虚拟 DOM 对象的标识,在更新时起关键作用

  2. 当数据变化时,React 生成新的虚拟 DOM,然后与旧的进行 Diff 对比

  3. 对比规则:

    1. 旧虚拟 DOM 中找到了与新虚拟 DOM 相同的 key:

      • 内容没变 → 直接复用之前的真实 DOM
      • 内容变了 → 生成新的真实 DOM,替换掉旧的
    2. 旧虚拟 DOM 中未找到与新虚拟 DOM 相同的 key:

      • 创建新的真实 DOM,渲染到页面

本章知识图谱

React 进阶
├── 事件处理
│   ├── 合成事件(非原生事件)
│   ├── 事件委托机制
│   └── event.target 可以省略 ref
├── 表单处理
│   ├── 非受控组件:ref 现用现取
│   └── 受控组件:onChange + state 实时存储(推荐)
├── 高阶函数与柯里化
│   ├── 高阶函数:参数或返回值是函数
│   ├── 柯里化:多次接收参数,最后统一处理
│   └── 替代方案:箭头函数包裹
├── 生命周期
│   ├── 旧版:Will 系列 + Did 系列
│   ├── 新版:废弃 3 个 Will,新增 2get
│   ├── 最常用:componentDidMount / componentWillUnmount
│   └── 废弃原因:为异步渲染(Concurrent Mode)铺路
└── Diffing 算法
    ├── 最小对比粒度:标签(节点)
    ├── key 是虚拟 DOM 的标识
    ├── 用 id 作 key(推荐)
    └── 用 index 作 key 的两个问题:效率低 + 输入框错位

📌 下一篇:[React全家桶笔记(四):React脚手架与TodoList实战] 将进入工程化开发阶段,学习 Create React App 脚手架和第一个完整的实战案例。

React全家桶笔记(二):React组件核心 — State、Props、Refs

2026年4月1日 21:32

React全家桶笔记(二):React组件核心 — State、Props、Refs

本篇覆盖 React 两种组件定义方式,以及组件实例的三大核心属性:state、props、refs。这是 React 开发的基石。 📺 对应视频:张天禹react全家桶P8 - P31


一、开发者工具安装(P8)

Chrome 安装 React Developer Tools 扩展,安装后浏览器右上角会出现 React 图标:

  • 🔴 红色:当前页面使用了未压缩的 React(开发环境)
  • 🔵 蓝色:当前页面使用了压缩后的 React(生产环境)
  • 灰色:当前页面没有使用 React

安装后 DevTools 会多出两个面板:Components(组件树)和 Profiler(性能分析)。


二、函数式组件(P9)

// 函数式组件 — 用函数定义组件
function MyComponent() {
  console.log(this) // undefined(babel 编译后开启了严格模式)
  return <h2>我是用函数定义的组件(适用于简单组件)</h2>
}

// 渲染组件到页面
ReactDOM.render(<MyComponent/>, document.getElementById('test'))

执行流程

  1. React 解析组件标签,找到 MyComponent 组件
  2. 发现组件是函数定义的,随后调用该函数
  3. 将返回的虚拟 DOM 转为真实 DOM,渲染到页面

⚠️ 注意:函数式组件中的 thisundefined,因为 Babel 编译后默认开启严格模式。函数式组件在 Hooks 出现之前只能做"简单组件"(无状态),Hooks 出现后函数式组件也能拥有状态了。


三、类的基础知识复习(P10)

在学习类式组件之前,需要先回顾 ES6 的 class 语法:

// 创建一个 Person 类
class Person {
  // 构造器方法
  constructor(name, age) {
    // this 指向类的实例对象
    this.name = name
    this.age = age
  }

  // 一般方法 — 放在类的原型对象上,供实例使用
  speak() {
    // speak 方法通过 Person 实例调用时,this 指向实例
    console.log(`我叫${this.name},今年${this.age}岁`)
  }
}

// 创建实例
const p1 = new Person('Tom', 18)
p1.speak() // 我叫Tom,今年18岁

// 继承
class Student extends Person {
  constructor(name, age, grade) {
    super(name, age) // 必须在最前面调用 super
    this.grade = grade
  }

  // 重写从父类继承的方法
  speak() {
    console.log(`我叫${this.name},读${this.grade}年级`)
  }
}

类的核心知识点

  1. 类中的构造器不是必须写的,只有对实例进行初始化操作时才写
  2. 子类继承父类,如果写了构造器,super 必须在构造器最前面调用
  3. 类中定义的方法都放在了原型对象上,供实例使用

四、类式组件(P11)

// 类式组件 — 必须继承 React.Component
class MyComponent extends React.Component {
  render() {
    // render 放在 MyComponent 的原型对象上,供实例使用
    // render 中的 this 指向 MyComponent 的组件实例对象
    console.log('render中的this:', this)
    return <h2>我是用类定义的组件(适用于复杂组件)</h2>
  }
}

ReactDOM.render(<MyComponent/>, document.getElementById('test'))

执行流程

  1. React 解析组件标签,找到 MyComponent 组件
  2. 发现组件是类定义的,随后 new 出该类的实例,并通过该实例调用原型上的 render 方法
  3. render 返回的虚拟 DOM 转为真实 DOM,渲染到页面

🔗 概念扩展:组件实例对象上有三个重要属性 — statepropsrefs,这就是接下来要学的三大核心属性。


五、State — 组件状态(P12-P19)

5.1 理解 state(P12)

  • state 是组件对象最重要的属性,值是对象(可以包含多个 key-value)
  • 组件被称为"状态机",通过更新组件的 state 来更新对应的页面显示(重新渲染组件)
  • 数据驱动视图:数据变了 → 视图自动更新

5.2 初始化 state(P13)

class Weather extends React.Component {
  constructor(props) {
    super(props)
    // 初始化状态
    this.state = { isHot: true }
  }

  render() {
    return <h1>今天天气很{this.state.isHot ? '炎热' : '凉爽'}</h1>
  }
}

5.3 React 中的事件绑定(P14)

class Weather extends React.Component {
  constructor(props) {
    super(props)
    this.state = { isHot: true }
  }

  render() {
    // React 事件绑定:onClick(注意大小写,不是 onclick)
    return <h1 onClick={demo}>今天天气很{this.state.isHot ? '炎热' : '凉爽'}</h1>
  }
}

function demo() {
  console.log('标题被点击了')
}

React 事件绑定 vs 原生事件绑定

// 原生 JS
<button onclick="demo()">    // 注意是小写 onclick,值是字符串

// React JSX
<button onClick={demo}>      // 注意是大驼峰 onClick,值是函数引用(不加括号)

5.4 类中方法的 this 指向问题(P15-P16)

class Weather extends React.Component {
  constructor(props) {
    super(props)
    this.state = { isHot: true }
    // 🔑 关键:解决 this 指向问题
    // 在原型上的 changeWeather 基础上,生成一个绑定了 this 的新函数,挂到实例自身
    this.changeWeather = this.changeWeather.bind(this)
  }

  render() {
    return <h1 onClick={this.changeWeather}>
      今天天气很{this.state.isHot ? '炎热' : '凉爽'}
    </h1>
  }

  changeWeather() {
    // 如果不做 bind 处理,这里的 this 是 undefined
    // 原因:changeWeather 不是通过实例调用的,而是作为回调函数直接调用
    // 加上类中默认开启了严格模式,所以 this 不会指向 window,而是 undefined
    console.log(this)
  }
}

🎯 面试高频:为什么类组件方法中的 this 是 undefined?

  1. 事件回调函数不是通过实例调用的,而是直接调用
  2. 类中的方法默认开启了局部严格模式
  3. 严格模式下,直接调用函数 this 不会指向 window,而是 undefined
  4. 解决方案:在构造器中用 bind 绑定 this

5.5 setState 的使用(P17)

changeWeather() {
  // ❌ 错误!直接修改 state 不会触发重新渲染
  // this.state.isHot = false

  // ✅ 正确!必须通过 setState 修改状态
  const isHot = this.state.isHot
  this.setState({ isHot: !isHot })
}

setState 的核心规则

  1. 状态(state)不可直接更改,必须通过 setState() 修改
  2. setState 是一次合并操作,不是替换。只会更新你传入的属性,其他属性保持不变
  3. setState 会触发 render 重新调用

5.6 state 的简写方式(P18)

class Weather extends React.Component {
  // ✅ 简写:直接用赋值语句初始化 state(类中可以直接写赋值语句)
  state = { isHot: true, wind: '微风' }

  render() {
    const { isHot, wind } = this.state
    return <h1 onClick={this.changeWeather}>
      今天天气很{isHot ? '炎热' : '凉爽'},{wind}
    </h1>
  }

  // ✅ 简写:用箭头函数定义方法,箭头函数没有自己的 this,会找外层的 this
  // 这样就不需要在构造器中 bind 了
  changeWeather = () => {
    const isHot = this.state.isHot
    this.setState({ isHot: !isHot })
  }
}

🔗 概念扩展:为什么箭头函数能解决 this 问题? 箭头函数没有自己的 this,它会捕获其所在上下文(定义时的位置)的 this 值。在类中用赋值语句 + 箭头函数,相当于在实例上直接定义了一个方法,其 this 永远指向该实例。

5.7 state 总结(P19)

state 要点:
├── state 是对象,包含多个 key-value
├── 通过 setState() 修改状态,不能直接赋值
├── setState 是合并操作,不是替换
├── setState 会触发 render 重新执行
├── 构造器调用 1 次,render 调用 1+n 次(初始化1次 + 每次setState)
└── this 指向问题的两种解决方案:
    ├── 方案1:构造器中 bind
    └── 方案2:箭头函数(推荐 ✅)

六、Props — 组件属性(P20-P26)

6.1 props 的基本使用(P20)

class Person extends React.Component {
  render() {
    const { name, age, sex } = this.props
    return (
      <ul>
        <li>姓名:{name}</li>
        <li>性别:{sex}</li>
        <li>年龄:{age}</li>
      </ul>
    )
  }
}

// 渲染时传递 props
ReactDOM.render(
  <Person name="Tom" age="18" sex="男"/>,
  document.getElementById('test')
)

核心理解

  • state 是组件内部的数据(自己管理)
  • props 是从组件外部传入的数据(父组件传递)
  • props 是只读的,组件内部不能修改自己的 props

6.2 批量传递 props(P21)

const person = { name: 'Tom', age: 18, sex: '男' }

// 使用展开运算符批量传递
ReactDOM.render(<Person {...person}/>, document.getElementById('test'))

⚠️ 注意:这里的 {...person} 不是 JS 的展开运算符语法。在原生 JS 中,... 不能展开对象(只能在字面量中使用)。这是 React + Babel 的特殊语法,仅适用于标签属性的传递。

6.3 对 props 进行限制(P22)

// 需要引入 prop-types 库
// <script src="prop-types.js"></script>

class Person extends React.Component {
  render() {
    const { name, age, sex } = this.props
    return (
      <ul>
        <li>姓名:{name}</li>
        <li>性别:{sex}</li>
        <li>年龄:{age + 1}</li>
      </ul>
    )
  }
}

// 对标签属性进行类型、必要性的限制
Person.propTypes = {
  name: PropTypes.string.isRequired, // 字符串类型,必传
  sex: PropTypes.string,             // 字符串类型
  age: PropTypes.number,             // 数值类型
  speak: PropTypes.func              // 函数类型(注意是 func 不是 function)
}

// 指定默认值
Person.defaultProps = {
  sex: '未知',
  age: 18
}

6.4 props 的简写方式(P23)

class Person extends React.Component {
  // 使用 static 关键字将限制规则写在类内部
  static propTypes = {
    name: PropTypes.string.isRequired,
    sex: PropTypes.string,
    age: PropTypes.number,
  }

  static defaultProps = {
    sex: '未知',
    age: 18,
  }

  render() {
    const { name, age, sex } = this.props
    return (
      <ul>
        <li>姓名:{name}</li>
        <li>性别:{sex}</li>
        <li>年龄:{age + 1}</li>
      </ul>
    )
  }
}

6.5 类式组件中的构造器与 props(P24)

class Person extends React.Component {
  constructor(props) {
    // 如果写了构造器,是否接收 props 并传给 super,取决于:
    // 你是否希望在构造器中通过 this.props 访问 props
    super(props)
    console.log(this.props) // ✅ 有值
  }
  // ...
}

class Person2 extends React.Component {
  constructor() {
    super()
    console.log(this.props) // ❌ undefined
  }
  // 但在 render 等其他方法中 this.props 仍然可用
}

💡 实际开发中:构造器几乎不写。state 用赋值语句初始化,方法用箭头函数定义,完全不需要构造器。

6.6 函数式组件使用 props(P25)

// 函数式组件只能使用 props(在 Hooks 之前)
function Person(props) {
  const { name, age, sex } = props
  return (
    <ul>
      <li>姓名:{name}</li>
      <li>性别:{sex}</li>
      <li>年龄:{age}</li>
    </ul>
  )
}

// 限制仍然写在函数外面
Person.propTypes = {
  name: PropTypes.string.isRequired,
}
Person.defaultProps = {
  sex: '未知',
}

6.7 props 总结(P26)

props 要点:
├── 从组件外部传入数据,组件内部只读
├── 批量传递:<Person {...obj}/>(React+Babel 特殊语法)
├── 类型限制:propTypes(需引入 prop-types 库)
├── 默认值:defaultProps
├── 简写:static propTypes / static defaultProps
├── 构造器中要用 this.props → 必须 super(props)
├── 函数式组件通过参数接收 props
└── props 是只读的!不能在组件内部修改

七、Refs — 组件引用(P27-P31)

Refs 提供了一种方式,允许我们访问 DOM 节点或在 render 方法中创建的 React 元素。

7.1 字符串形式的 ref(P27)— 已过时

class Demo extends React.Component {
  showData = () => {
    // 通过 this.refs 获取 DOM 节点
    const { input1 } = this.refs
    alert(input1.value)
  }

  render() {
    return (
      <div>
        <input ref="input1" type="text" placeholder="点击按钮提示数据" />
        <button onClick={this.showData}>点我提示左侧数据</button>
      </div>
    )
  }
}

⚠️ 注意:字符串形式的 ref 已被 React 官方标记为过时 API,存在效率问题,不推荐使用。了解即可。

7.2 回调形式的 ref(P28-P29)

class Demo extends React.Component {
  showData = () => {
    const { input1 } = this
    alert(input1.value)
  }

  render() {
    return (
      <div>
        {/* 回调 ref:React 会在渲染时调用这个回调,把 DOM 节点传进来 */}
        <input ref={c => this.input1 = c} type="text" placeholder="点击按钮提示数据" />
        <button onClick={this.showData}>点我提示左侧数据</button>
      </div>
    )
  }
}

回调 ref 的调用次数问题(P29)

如果 ref 回调函数是以内联函数的方式定义的,在更新过程中它会被执行两次

  • 第一次传入 null(清空旧的 ref)
  • 第二次传入 DOM 元素
// 内联写法 — 更新时会调用两次(无关紧要,不影响功能)
<input ref={c => this.input1 = c} />

// class 绑定写法 — 更新时只调用一次
saveInput = (c) => {
  this.input1 = c
}
<input ref={this.saveInput} />

💡 大多数情况下,内联写法的两次调用是无关紧要的。

7.3 createRef 的使用(P30)— 推荐

class Demo extends React.Component {
  // React.createRef() 返回一个容器
  // 该容器可以存储被 ref 所标识的节点
  // 该容器是"专人专用"的,一个 createRef 只能存一个节点
  myRef = React.createRef()
  myRef2 = React.createRef()

  showData = () => {
    alert(this.myRef.current.value)
  }

  showData2 = () => {
    alert(this.myRef2.current.value)
  }

  render() {
    return (
      <div>
        <input ref={this.myRef} type="text" placeholder="点击按钮提示数据" />
        <button onClick={this.showData}>点我提示左侧数据</button>
        <input ref={this.myRef2} onBlur={this.showData2} type="text" placeholder="失去焦点提示数据" />
      </div>
    )
  }
}

7.4 refs 总结(P31)

refs 三种形式:
├── 字符串 ref:<input ref="input1"/>
│   └── ❌ 已过时,存在效率问题,不推荐
├── 回调 ref:<input ref={c => this.input1 = c}/>
│   ├── 内联写法:更新时调用两次(无影响)
│   └── class 绑定写法:更新时调用一次
└── createRefmyRef = React.createRef()
    ├── <input ref={this.myRef}/>
    ├── 通过 this.myRef.current 获取节点
    └── ✅ React 最推荐的方式

🎯 面试高频:三种 ref 的区别和推荐度? 字符串 ref 最简单但已过时;回调 ref 灵活但有调用次数的小问题;createRef 是官方最推荐的方式,语义清晰,一个 ref 对应一个节点。


八、三大属性对比总结

组件实例三大核心属性:
┌──────────┬──────────────────────────────────────┐
│ 属性      │ 说明                                  │
├──────────┼──────────────────────────────────────┤
│ state    │ 组件内部的状态数据,驱动视图更新          │
│          │ 通过 setState() 修改,触发重新渲染        │
├──────────┼──────────────────────────────────────┤
│ props    │ 外部传入的数据,组件内只读                │
│          │ 父子组件通信的桥梁                       │
├──────────┼──────────────────────────────────────┤
│ refs     │ 获取 DOM 节点的引用                     │
│          │ 推荐 createRef,尽量少用                 │
└──────────┴──────────────────────────────────────┘

🔗 概念扩展:React 的数据流是单向

  • 父组件通过 props 向子组件传递数据
  • 子组件不能直接修改 props
  • 如果子组件需要改变父组件的数据,父组件需要通过 props 传递一个回调函数给子组件
  • 这就是 React 的"单向数据流"设计哲学

📌 下一篇:[React全家桶笔记(三):React进阶 — 事件处理、表单与生命周期] 将深入受控/非受控组件、高阶函数、以及 React 最重要的生命周期机制。

网页排版与编码的隐形神器:HTML字符实体从入门到精通

作者 CharlesY
2026年4月1日 21:31

你有没有过这些崩溃时刻?维护官网时敲满空格想缩进段落,发布后却毫无效果;贴一段HTML代码示例,页面直接乱码;想加个版权符号©,复制粘贴还怕出现乱码……

其实解决这些问题,只需要HTML里最基础却被90%人忽略的工具——字符实体。它既能让浏览器精准识别你要显示的内容,又能实现精细化排版,不用写一行CSS。本文从实用场景出发,把字符实体讲透,帮你快速实现精准排版与规范编码。

一、HTML字符实体的核心认知:两类实体,四大场景

1.1 字符实体的两种核心类型

HTML字符实体分为两类,覆盖所有使用场景:

  • 命名字符实体:格式为&名称;,好读好记(如&nbsp;);
  • 数字字符实体:格式为&#编号;,对应Unicode编码,全浏览器兼容(如&#160;)。

1.2 四大高频使用场景(附可直接复用示例)

1.2.1 排版救命:空白字符实体(解决空格无效问题)

HTML默认会合并所有普通空格、换行,而空白字符实体是“不压缩的硬空格”,可精准控制间距:

显示结果 描述 实体名称 实体编号 代码示例&适用场景
不换行空格 &nbsp; &#160; <p>商品售价:199&nbsp;元</p>
场景:数字与单位、人名之间,禁止换行,连续多个不合并
全角空格(1汉字宽度) &emsp; &#8195; <p>&emsp;&emsp;这是正文开头,首行缩进2个汉字</p>
场景:中文排版首行缩进,无需CSS
半角空格(0.5汉字宽度) &ensp; &#8194; <p>HTML&nbsp;CSS&nbsp;JavaScript</p>
场景:中英文混排间距微调

1.2.2 必背规范:HTML保留字符实体(避免页面错乱)

<>&等字符是HTML解析器的“指令标记”,直接写会被当成代码执行,字符实体可将其还原为普通文本:

显示结果 描述 实体名称 实体编号 强制转义场景
< 小于号/左尖括号 &lt; &#60; 展示HTML/XML代码、数学公式,必须转义
大于号/右尖括号 &gt; &#62; 同上,与&lt;成对使用
& 和号/与符号 &amp; &#38; 所有场景必须转义,尤其是URL参数、实体本身
" 双引号 &quot; &#34; 标签属性内的双引号,必须转义
' 单引号/撇号 &apos; &#39; 标签属性内的单引号,老IE环境优先用&#39;

1.2.3 高频商用:符号与特殊字符实体(告别复制粘贴)

官网版权声明、商品价格等场景用字符实体,比图片加载快、矢量不失真,还能被搜索引擎识别:

显示结果 描述 实体名称 实体编号
美分符号 &cent; &#162;
£ 英镑符号 &pound; &#163;
¥ 人民币/日元符号 &yen; &#165;
欧元符号 &euro; &#8364;
§ 小节符号 &sect; &#167;
© 版权符号 &copy; &#169;
® 注册商标符号 &reg; &#174;
商标符号 &trade; &#8482;
× 乘号 &times; &#215;
÷ 除号 &divide; &#247;

1.2.4 万能兼容:Unicode数字字符实体(生僻字/emoji通杀)

数字实体对应字符的Unicode编码,支持所有合法字符,无兼容问题:

十进制实体 显示结果 字符说明
&#235641; CJK扩展区生僻汉字
&#128512; 😀 笑脸emoji
&#128591; 🙏 双手合十emoji
&#128164; 💤 睡觉emoji

补充:全量字符实体参考(300+个):希腊字母与变体(96个)、数学符号(120个)、箭头符号(48个)、标点与技术符号(40个)、扩展货币符号(16个),均符合W3C HTML5规范。

二、底层技术原理:字符实体为什么能生效?

字符实体的核心逻辑,是HTML解析器的有限状态机规则——解析器会根据不同字符切换“工作模式”,决定内容是当成“代码指令”还是“普通文本”。

2.1 解析器的三大核心状态

  • 数据状态:默认模式,字符被当成“要显示的文本”处理;
  • 标签开始状态:遇到<触发,字符被当成“HTML标签指令”处理;
  • 字符引用状态:遇到&触发,字符被当成“转义序列”处理,还原为对应文本。

2.2 关键字符的状态切换(实战拆解)

2.2.1 场景1:<触发“标签模式”,用&lt;还原文本

错误写法(页面乱码):

<p>5 < 3 是错误的</p>

解析器流程:遇到<切换到“标签模式”,把< 3当成无效标签,导致内容错乱。

正确写法(正常显示):

<p>5 &lt; 3 是错误的</p>

解析器流程:遇到&切换到“字符引用状态”,解析&lt;<,还原为文本后切回“数据状态”。

2.2.2 场景2:&触发“字符引用模式”,用&amp;显示&本身

错误写法(解析异常):

<p>苹果&香蕉</p>

解析器流程:遇到&切换到“字符引用状态”,因无;结尾且后续无合法实体名,可能忽略&或渲染出错。

正确写法(正常显示):

<p>苹果&amp;香蕉</p>

解析器流程:解析&amp;&,还原为文本后继续处理后续内容。

三、关键细节:分号;是字符实体的“结束键”,绝对不能漏

;是“字符引用状态”的结束信号,解析器只有遇到;才会停止收集字符并还原。漏写分号虽可能被现代浏览器容错,但不符合规范,易引发隐蔽bug:

错误示例:

<p>这是一个&not示例</p>

实际渲染:这是一个¬示例&not被误解析为逻辑非符号)。

正确示例:

<p>这是一个&amp;not示例</p>

实际渲染:这是一个&not示例

四、避坑指南:不规范写法的“容错陷阱”

很多人发现<p>苹果&香蕉</p>能正常显示,就忽略规范写法——但这是现代浏览器的“容错机制”兜底,并非代码合规。

4.1 规范写法 vs 容错写法:解析流程对比

4.1.1 规范写法(<p>苹果&amp;香蕉</p>)的完美流程

  1. 数据状态:处理“苹果”,正常渲染;
  2. 遇到&:切换到字符引用状态;
  3. 收集amp;:查字典还原为&
  4. 切回数据状态:继续处理“香蕉”,最终显示苹果&香蕉

4.1.2 容错写法(<p>苹果&香蕉</p>)的兜底逻辑

  1. 数据状态:处理“苹果”,正常渲染;
  2. 遇到&:切换到字符引用状态;
  3. 遇到“香”(非实体名合法字符):停止收集,判定为无效实体;
  4. 容错处理:把&当普通文本渲染,切回数据状态处理“香蕉”。

4.2 不规范写法的两大致命隐患

4.2.1 隐患1:预定义实体名被误解析

比如<p>苹果&not香蕉</p>,部分浏览器会把&not解析为¬,最终显示苹果¬香蕉,完全偏离预期。

4.2.2 隐患2:URL参数解析异常

错误写法:

<a href="page.html?a=1&b=2">点击跳转</a>

严格环境下&b会被当成无效实体,导致参数b=2丢失;规范写法必须是:

<a href="page.html?a=1&amp;b=2">点击跳转</a>

4.3 实证验证:3个实验证明容错机制的存在

以下3组实验均提供可直接复制运行的完整代码明确输出结果,从「渲染模式对比」「旧版兼容差异」「官方规范依据」三个维度,实锤“不规范写法能正常显示,是HTML5容错解析机制在兜底”。

4.3.1 实验1:HTML5模式 vs XHTML模式(最直观对比)

测试代码1:HTML5标准模式(html5-test.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>HTML5 容错测试</title>
</head>
<body>
    <!-- 不规范写法:& 未转义 -->
    <p>苹果&香蕉</p>
    <!-- 不规范写法:< 未转义 -->
    <p>5 < 3 是错误的数学式</p>
</body>
</html>

浏览器正常渲染,无任何报错:

苹果&香蕉
5 < 3 是错误的数学式

测试代码2:XHTML严格模式(xhtml-test.xhtml,后缀必须为.xhtml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="zh-CN">
<head>
    <meta http-equiv="Content-Type" content="application/xhtml+xml; charset=UTF-8" />
    <title>XHTML 严格测试</title>
</head>
<body>
    <!-- 同一段不规范代码 -->
    <p>苹果&香蕉</p>
    <p>5 < 3 是错误的数学式</p>
</body>
</html>

浏览器直接抛出XML解析错误,页面白屏,提示:解析EntityName时出错 | 错误的实体名称

实验结论:同一段不规范代码,HTML5有容错机制可正常显示,XHTML无容错直接报错,证明容错是HTML5专属特性。

4.3.2 实验2:切换浏览器文档模式(新旧解析器差异)

测试代码(old-ie-test.html

<!-- HTML4 文档类型,触发旧版渲染模式 -->
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>旧浏览器模式测试</title>
</head>
<body>
    <!-- 无分号、未转义的风险写法 -->
    <p>苹果&not香蕉</p>
    <!-- 规范转义写法(对照组) -->
    <p>苹果&amp;not香蕉</p>
</body>
</html>

不同文档模式下的运行结果

  1. IE8标准模式/IE5怪异模式 第一行输出:苹果¬香蕉&not被误解析为逻辑非符号,无容错) 第二行输出:苹果&not香蕉(规范写法始终正常)
  2. 现代HTML5模式(Chrome/Edge默认) 第一行输出:苹果&not香蕉(触发容错机制,修正解析) 第二行输出:苹果&not香蕉

实验结论:旧版HTML解析器无容错,会错误解析无分号的&开头字符;现代HTML5解析器会按容错规则修正,证明结果由**解析算法(容错机制)**决定。

4.3.3 实验3:W3C规范条文验证(官方依据)

规范原文(W3C HTML5.2 §13.2.5.7 字符引用状态)

当解析器在字符引用状态下,遇到非实体名字符(如汉字、空格)且无分号;时,判定为无效字符引用:

  1. &作为普通文本直接渲染;
  2. 回退到数据状态,重新处理后续字符。

验证代码(spec-test.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>W3C规范验证</title>
</head>
<body>
    <!-- 触发规范定义的「无效字符引用」场景 -->
    <p>测试:苹果&香蕉</p>
</body>
</html>

解析流程(严格遵循W3C规范)

  1. 解析器初始为数据状态,渲染“测试:苹果”;
  2. 遇到&,切换至字符引用状态
  3. 后续字符“香”是汉字,不属于实体名合法字符,停止收集;
  4. 判定为无效字符引用,执行规范容错:渲染&,回退数据状态;
  5. 正常渲染“香蕉”。

运行结果

测试:苹果&香蕉

实验结论:浏览器渲染结果完全匹配W3C HTML5规范的容错算法,证明容错机制是官方标准,而非浏览器私有行为。

  1. 模式对比:HTML5容错 → 正常显示;XHTML无容错 → 直接报错;
  2. 版本差异:旧IE无容错 → 解析错误;现代浏览器容错 → 正常显示;
  3. 规范依据:渲染结果1:1符合W3C官方容错规则。

三者共同证明:不规范写法能显示,完全是HTML5容错解析机制的功劳

五、总结

  1. 字符实体分“命名型”和“数字型”,数字型全兼容,命名型易记忆;
  2. 四大核心场景:空白排版、保留字符转义、商用符号、生僻字/emoji;
  3. 必须遵守规范:所有&转义为&amp;,字符实体必须以;结尾;
  4. 容错机制是“兜底”,不是“合规”,避免依赖容错引发兼容/解析问题。

掌握这些知识点,你就能摆脱网页排版的各种坑,用最简单的方式实现精准排版与规范编码,不用再靠“猜”和“试”来调试页面!

React Native 登陆 Meta Quest(专业译文)

作者 吴敬悦
2026年4月1日 21:28

React Native 登陆 Meta Quest(专业译文)

原文:React Native Comes to Meta Quest
链接:reactnative.dev/blog/2026/0…

React Native 一直聚焦于帮助开发者在不同平台之间复用知识与能力。它最初支持 Android 和 iOS,随后逐步扩展到 Apple TV、Windows、macOS,甚至通过 react-strict-dom 覆盖 Web。2021 年发布的 Many Platform Vision 一文提出了一个方向:React Native 可以适配新的设备形态,而不必割裂生态。

在 React Conf 2025 上,团队沿着这一方向又迈进一步:正式宣布 React Native 官方支持 Meta Quest 设备。本文重点介绍如何在 Meta Quest 上开始 React Native 开发、当前可用能力,以及如何使用熟悉的工具链与开发模式构建并发布 VR 应用。

要点速览

  • React Native 在 Meta Quest 上的支持情况
  • 如何在 Meta Quest 上快速开始
  • Development Build 与原生能力接入
  • 平台特定配置及其与移动端差异
  • VR 场景下的设计与 UX 注意事项

React Native 在 Meta Quest 上

Meta Quest 设备运行的是 Meta Horizon OS(基于 Android)。从 React Native 的视角看,这意味着既有 Android 工具、构建系统和调试流程都可以在几乎不改动的前提下沿用。对已经在 Android 上开发 React Native 应用的团队来说,现有开发范式大部分都能直接迁移。

这与 React Native 过去扩展到其他 Android 生态环境的方式一致:不是引入一套全新的运行时或开发模型,而是复用 Android 基础能力并接入 React Native 既有抽象层。这样既能引入平台专属能力,也不会造成框架层面的割裂,开发流程也不需要重建。

在 Meta Quest 上快速开始

这一部分介绍 Meta Quest 的基础开发流程:先从 Expo Go 起步,再过渡到 Development Build 和平台特定配置。

分步操作:在 Meta Quest 上运行 Expo 应用

要在 Meta Quest 上运行 Expo 应用,流程是:创建标准 Expo 项目、启动开发服务器、再通过头显中的 Expo Go 打开应用。除少量 Meta Horizon OS 特定要求外,整体工作流与 Android 基本一致。

  1. 在头显中安装 Expo Go
    Expo Go 可从 Meta Horizon Store 获取,可直接安装在 Meta Quest 设备上,适合开发阶段快速迭代。

  2. 创建(或使用现有)Expo 项目
    如果从零开始,直接创建标准 Expo 应用即可,无需特殊模板。

npx create-expo-app@latest my-quest-app
cd my-quest-app
  1. 启动开发服务器
npx expo start
  1. 在 Quest 上通过 Expo Go 连接
    打开头显中的 Expo Go,使用头显摄像头扫描 Expo CLI 展示的二维码。应用会在设备中新窗口启动,支持 Live Reload,迭代速度与移动端开发体验一致。

  2. 按常规节奏迭代
    修改代码后可立即在设备端看到变化,遵循与 Android / iOS 相同的编辑-刷新循环。

Development Build 与原生能力

Expo Go 足以支持早期开发和 UI 调试。当你需要接入原生模块或更深的平台能力时,应切换到 Development Build。该构建方式沿用 Expo 的标准 Development Build 流程,并可直接在 Quest 设备上运行。

平台特定配置与移动端差异

虽然总体开发流程保持一致,但 Meta Quest 仍需要少量平台特定调整。

Meta Horizon OS 的项目配置

Meta Quest 应用要想正确运行并具备商店提审资格,必须满足一系列要求,包括 Android 平台特定配置、product flavor 以及应用元数据。

Expo 提供了面向 Meta Horizon OS 的插件,可在构建阶段自动应用这些要求,避免手工修改原生文件。

安装 expo-horizon-core,并在 app.jsonapp.config.js 中加入:

{
  "expo": {
    "plugins": [
      [
        "expo-horizon-core",
        {
          "horizonAppId": "your-horizon-app-id",
          "defaultHeight": "640dp",
          "defaultWidth": "1024dp",
          "supportedDevices": "quest2|quest3|quest3s",
          "disableVrHeadtracking": false,
          "allowBackup": false
        }
      ]
    ]
  }
}

同时将方向配置改为:

{
  "...": "...",
  "orientation": "default"
}

package.json 中新增 Quest 相关脚本:

{
  "scripts": {
    "android": "expo run:android --variant mobileDebug",
    "quest": "expo run:android --variant questDebug",
    "android:release": "expo run:android --variant mobileRelease",
    "quest:release": "expo run:android --variant questRelease"
  }
}

不使用 Expo 时如何接入 React Native

Expo 是在 Meta Quest 上启动 React Native 的最简路径。如果你希望不依赖框架,也可以在原生 Android 工程里手动应用 Meta Horizon OS 所需配置。

至少需要处理以下事项:

  • android/app/build.gradle 中新增 Meta Quest 专用 build flavor
  • 设置 horizonAppId
  • 在 Android Manifest 中定义默认面板尺寸
  • 声明支持设备(如 quest2|quest3|quest3s
  • 移除被禁止的权限
  • 调整最低支持 Android SDK 版本
  • 增加运行时判断,例如 isHorizonDevice()isHorizonBuild()

如果要完整对齐配置,可查看 expo-horizon-core 插件实现,并在原生工程中等价落地。

无 Google Play Services 的 Android 环境

Meta Horizon OS 基于 AOSP(Android Open Source Project),提供 Android 核心平台能力,但不包含 Google 的专有服务。对开发者而言,这意味着应用运行在标准 Android API 上,但不能依赖 Play Services、Play Store 专属集成等 Google Mobile Services 能力。

因此,面向 Meta Quest 时,应避免对 Google 服务建立硬依赖,或提供平台特定替代方案。
不支持的依赖列表见 Meta Horizon OS 文档

权限与设备能力差异

移动设备上常见的一些 Android 权限和硬件假设不适用于 VR 头显。例如蜂窝网络能力(如 SMS)、某些传感器(如 GPS)以及受限权限可能不可用或被禁止。项目在配置阶段必须显式处理这些差异。

评估第三方库兼容性

大部分 React Native 库可在 Meta Quest 上工作,但兼容性取决于库对底层平台的假设。典型风险包括:依赖移动端专属硬件、仅面向触摸输入,或依赖 Horizon OS 不可用服务

通用判断原则:

  • 仅依赖标准 React Native / Android API 的自包含库:通常可直接使用。
  • 假设触摸输入、移动专属硬件或 Google Mobile Services 的库:需要适配或条件化接入。
  • 依赖受限权限或不可用设备能力的库:通常不受支持。

对于一些高频场景,如定位通知,Expo 已提供可替换方案。其他库则需按其依赖特征决定是否可直接使用或进行平台分支处理。

平台感知代码路径

同时面向 Meta Quest 与其他平台的应用,应对平台特定行为做保护分支。Meta Horizon OS 提供运行时工具用于判断当前是否运行在 Quest 设备,从而在必要时禁用或替换不支持能力。

import ExpoHorizon from 'expo-horizon-core';

// 是否运行在 Horizon 设备
if (ExpoHorizon.isHorizonDevice) {
  console.log('Running on Meta Horizon OS!');
}

// 当前构建是否为 Horizon 变体
if (ExpoHorizon.isHorizonBuild) {
  console.log('This is a Horizon build variant');
}

// 获取 Horizon App ID
const appId = ExpoHorizon.horizonAppId;
console.log('Horizon App ID:', appId ?? 'Not configured');

VR 设计与 UX 考量

为头戴式显示设备设计界面,与触屏移动端有明显差异:界面通常在一定距离被观看、在空间中呈现,并通过更多输入方式交互。

因此 UI 通常需要更大的可命中区域、更充足的间距,以及在不同观看距离下仍具可读性的字体。这些问题与桌面、平板和折叠屏上的可变窗口场景有共性:布局必须具备更强响应式能力。

Meta Quest 与 Android 手机的核心差异之一在于输入方式:除触摸外,Quest 常通过控制器、手势追踪,且可选鼠标键盘。控制器行为更接近“指针设备”,交互模式也更像 Web / 桌面,包括 hover 与基于 focus 的导航。

React Native 的事件系统与组件模型能够支持这些交互模式,但应用应避免“仅触摸”前提,并确保 UI 在指针控制下具备清晰 focus 状态与可预期导航行为。

综合来看,VR 更偏向“响应式布局 + 输入无关(input-agnostic)交互”的设计思路。React Native 的布局系统和组件模型为构建舒适、可用的 VR 界面提供了良好基础。

更多细则参见官方设计要求:Meta Horizon Design Requirements

示例与参考

参考项目

延伸阅读

致谢

将 React Native 带到新平台,远不止是代码层面的工作。感谢所有贡献时间、反馈与支持的同仁。

一文了解 pnpm,并快速上手操作!

作者 GentlyBeing
2026年4月1日 20:51

PNPM 简介

npm(Node Package Manager)中文名为 Node 包管理器,是 Node.js 官方自带、全球最大的 JavaScript 软件包管理工具,也是前端 / Node.js 开发最基础、最常用的工具之一。

pnpm(Performant NPM),翻译过来就是高性能的 npm,旨在解决传统包管理器(如 npm 和 Yarn Classic)在性能、磁盘空间使用和依赖管理结构上的不足。

目前,pnpm 已成为前端生态最受欢迎的包管理器之一,被 Vue、Vite、Nuxt、Next.js、Svelte 等顶级开源项目,以及字节、阿里、腾讯等大厂的前端团队广泛采用,是公认的「当前最先进的包管理工具」。

pnpm 比传统方案安装包的速度快了两倍,以下是官方给出的benchmarks(对比了npm, pnpm, Yarn Classic, and Yarn PnP),在多种常见情况下,执行install的速度比较

Graph of the alotta-files results

pnpm 的核心优势,源于它从底层架构上彻底重构了依赖管理方式,精准解决了传统工具的多个顽疾:

1. 解决“磁盘空间浪费”与“安装速度慢”

使用 npm 时,若你有 100 个项目都依赖同一个包,硬盘中就会重复存储 100 份该依赖包的完整副本,造成大量磁盘空间浪费。

pnpm 则通过硬链接搭配符号链接(软链接) 的机制,从根源上避免依赖重复拷贝,同时大幅提升安装效率:

  1. 增量存储:针对同一依赖包的不同版本,pnpm 仅会存储版本间存在差异的文件。例如新版本仅修改了单个文件,pnpm update 只需新增这一个文件至存储仓库,无需完整保存整个依赖包。
  2. 全局共享:pnpm 会在本地维护一个全局内容寻址存储库(默认路径为 ~/.pnpm-store),所有项目用到的依赖包,在全局仓库仅留存一份真实物理文件。
  3. 零拷贝:项目安装依赖时,pnpm 不会复制文件,而是通过硬链接(项目里 /node_modules/.pnpm/)指向全局仓库中的源文件;
  4. 兼容结构:最后通过软链接搭建符合 Node.js 规范的 node_modules 根目录结构,让项目和构建工具能正常识别依赖,同时为依赖隔离打下基础。

2. 解决“幽灵依赖” (Phantom Dependencies)

幽灵依赖是 npm 体系中一个极具隐患的问题。npm 会通过依赖扁平化(Dependency Hoisting) 机制,将间接依赖提升至 node_modules 根目录,这就导致项目可以直接引入并使用那些并未在自身 package.json 中声明的依赖包。这类依赖之所以能被访问,只是因为它们是其他直接依赖的子依赖,并在依赖提升后暴露在了根目录下。

幽灵依赖看似能简化开发,实则暗藏风险:

  1. 依赖版本不可控,易造成构建结果不稳定,排查问题时需要逐层追溯依赖树,调试与维护成本极高;
  2. 不同环境下依赖结构可能存在差异,极易出现本地正常、线上构建失败的情况;
  3. 间接依赖若存在安全漏洞,开发者往往难以感知,会带来潜在的安全风险。

pnpm 则通过虚拟存储(Virtual Store) 机制,从底层解决这一痛点:

它在项目内模拟传统 node_modules 的嵌套结构,同时在 .pnpm 目录下以包名 + 版本号的形式为每个版本创建独立文件夹,通过硬链接从全局仓库精准关联对应版本的真实文件;

再通过根目录node_modules 的符号链接,构建出严格的依赖隔离层级。

最终实现不同版本的依赖独立存放、精准引用、互不干扰,从根源上杜绝版本冲突,完美支持 Monorepo 场景下多子包的不同版本依赖,保障复杂依赖关系下项目的稳定运行。

3. 解决“多版本依赖冲突”

在实际项目中,常会出现不同第三方库依赖同一依赖包不同版本的情况(如部分组件库依赖 React 17,另一部分依赖 React 18)。

npm 针对该问题采用嵌套 node_modules 的基础隔离方案,将不兼容的版本安装在对应第三方包的内部目录中。但此方案存在明显缺陷:依赖目录结构变得杂乱无章,Windows 系统下易因路径过长导致报错,大量重复嵌套的依赖会造成磁盘空间浪费;加之 npm 保留的扁平化逻辑无法实现严格隔离,版本冲突与项目运行异常的风险始终存在。

pnpm 使用了一种叫做虚拟存储(Virtual Store) 机制。它在项目内模拟传统 node_modules 的嵌套结构,同时在 .pnpm 目录下以包名 + 版本号的形式为每个版本创建独立文件夹,通过硬链接从全局仓库精准关联对应版本的真实文件;再通过根目录 node_modules 的符号链接,构建出严格的依赖隔离层级。最终实现不同版本的依赖独立存放、精准引用、互不干扰,从根源上杜绝版本冲突,保障复杂依赖关系下项目的稳定运行。

总结:三层架构

层级 位置 内容性质 作用
L1: 全局仓库 ~/.pnpm-store 真实文件 节省磁盘空间,所有项目共享。
L2: 项目仓库 node_modules/.pnpm 硬链接 管理项目内复杂的依赖版本和嵌套关系。
L3: 暴露接口 node_modules/ 符号链接 方便构建工具寻找依赖。

pnpm.png

PNPM 安装

必须先安装 Node.js(npm 自带),先去官网装:nodejs.org/(选 LTS 版本)

# 安装 pnpm
npm install -g pnpm

# 检查是否安装成功
pnpm -v

# 初始化 pnpm
pnpm setup

# 尝试升级
pnpm self-update

# 国内换源
pnpm config set registry https://registry.npmmirror.com/

PNPM 快速上手

以下是列出常用的命令,具体可以参考官网管理依赖

安装依赖包

pnpm add <pkg>
命令 说明
pnpm add sax 安装并保存到 dependencies(生产依赖)
pnpm add -D sax 安装并保存到 devDependencies(开发依赖)
pnpm add -O sax 安装并保存到 optionalDependencies(可选依赖)
pnpm add -g sax 全局安装
pnpm add sax@next 安装 next 标签对应的版本
pnpm add sax@3.0.0 安装指定版本 3.0.0

安装项目全部依赖

pnpm install
# 或简写 pnpm i

更新依赖

pnpm update
# 或简写 pnpm up
命令 说明
pnpm up package.json 约定的版本范围内,更新所有依赖
pnpm up --latest 忽略版本范围约束,更新所有依赖到最新版
pnpm up foo@2 foo 更新到 v2 系列的最新版本
pnpm up "@babel/*" 更新 @babel scope 下的所有依赖

删除依赖

pnpm remove # 或简写 pnpm rm
pnpm uninstall # 或简写 pnpm un
# remove 和 uninstall 作用上完全等价

运行脚本

  • 执行 package.json 中定义的脚本:

    pnpm run <script>
    
  • 运行测试脚本:

    pnpm test
    
  • 运行启动脚本:

    pnpm start
    # 或
    pnpm run start
    

初始化 / 创建项目

create-*@foo/create-* 模板快速创建项目:

pnpm create <starter> [项目名]

示例:

pnpm create react-app my-app

常用进阶小技巧

  1. 清理无用依赖

    pnpm prune
    
  2. 查看依赖来源(排查冲突用)

    pnpm why react
    
  3. 删除 node_modules(比手动删更快)

    pnpm store prune
    

VanityH – 面向前端渲染函数的优雅 Hyperscript DSL

作者 laamfun
2026年4月1日 20:02

VanityH – 面向前端渲染函数的优雅 Hyperscript DSL

我开发了 VanityH,用来解决在原生 JS/TS、低代码引擎以及非 JSX 环境中编写 hyperscript 代码的痛点。

它是一个零依赖、超轻量的 DSL,基于 Proxy 和闭包实现,把混乱嵌套的 h(tag, props, children) 写法,变成类似 SwiftUI / Flutter 那样清晰、链式调用的代码。

核心亮点

  • 告别嵌套地狱:DOM 结构一目了然
  • 完全不可变:写时复制,避免意外修改属性
  • 无黑魔法:行为明确,无隐式转换
  • 极致轻量:gzip 后仅约 600 字节
  • 全平台兼容:Vue、React、Preact、Snabbdom 及任何兼容 hyperscript 的渲染器

示例(Vue 3)

import { h } from "vue";
import createVanity from "vanity-h";

const { div, button, h1 } = createVanity(h);

const app = div.class("app").style("padding: 20px")(
  h1("VanityH Demo"),
  button.onClick(() => alert("Hello!"))("Click Me")
);

传统写法 vs VanityH

// 之前
h("div", { class: "card" }, [h("button", { onClick: fn }, "Click")]);

// 之后
div.class("card")(button.onClick(fn)("Click"));

技术特点

  • 基于 Proxy + 闭包实现链式配置
  • 终结符式渲染逻辑
  • 完整 TypeScript 类型推导
  • MIT 开源协议

仓库:github.com/VanityH/van…
可在线试用:README 中附有 StackBlitz 示例

欢迎反馈:API 设计、边界场景、渲染器兼容等任何建议。

Next.js第二课 - 项目结构详解 - 优栈

2026年4月1日 19:00

上节我们搭建好了 Next.js 开发环境,本节就来详细了解一下 Next.js 的项目结构。很多初学者刚打开项目时会看到一堆文件和文件夹,不知道每个都是干什么的。别担心,本节会带你理清这些目录和文件的用途,让你对项目结构有一个清晰的认识。

项目结构概览

my-nextjs-app/
├── app/                          # App Router(主要工作目录)
│   ├── (auth)/                   # 路由组(不影响 URL)
│   │   ├── login/
│   │   │   └── page.tsx
│   │   └── register/
│   │       └── page.tsx
│   ├── api/                      # API 路由
│   │   └── users/
│   │       └── route.ts
│   ├── blog/                     # 应用路由
│   │   ├── [slug]/              # 动态路由
│   │   │   └── page.tsx
│   │   └── page.tsx
│   ├── layout.tsx               # 根布局
│   ├── page.tsx                 # 首页
│   ├── loading.tsx              # 加载状态
│   ├── error.tsx                # 错误处理
│   ├── not-found.tsx            # 404 页面
│   └── globals.css              # 全局样式
├── components/                   # 共享组件
│   ├── ui/                      # UI 基础组件
│   │   ├── Button.tsx
│   │   ├── Input.tsx
│   │   └── Card.tsx
│   └── layout/                  # 布局组件
│       ├── Header.tsx
│       └── Footer.tsx
├── lib/                         # 工具函数
│   ├── utils.ts
│   ├── api.ts
│   └── constants.ts
├── hooks/                       # 自定义 Hooks
│   ├── useAuth.ts
│   └── useData.ts
├── types/                       # TypeScript 类型
│   └── index.ts
├── public/                      # 静态资源
│   ├── images/
│   ├── fonts/
│   └── favicon.ico
├── styles/                      # 样式文件(可选)
│   └── globals.css
├── .env.local                   # 环境变量
├── .eslintrc.json              # ESLint 配置
├── .gitignore                  # Git 忽略文件
├── next.config.js              # Next.js 配置
├── package.json                # 项目配置
├── tsconfig.json               # TypeScript 配置
└── README.md                   # 项目说明

image.png

核心目录详解

1. app/ 目录 - App Router

app/ 目录是 Next.js 13+ 推荐的新路由系统,基于 React Server Components 构建。这是你工作中最常打交道的目录,绝大部分页面和路由都会放在这里。

特殊文件

文件 用途 必需
layout.tsx 定义布局和 UI 可选
page.tsx 定义路由的独特 UI 必需(可访问路由)
loading.tsx 加载时的 UI 替换 可选
error.tsx 错误边界 UI 可选
not-found.tsx 404 页面 可选
route.ts API 端点 API 路由必需

示例结构

app/
├── (marketing)/              # 路由组
│   ├── about/
│   │   └── page.tsx         # /about
│   ├── layout.tsx           # 营销页面共享布局
│   └── page.tsx             # /
├── (shop)/                   # 另一个路由组
│   ├── account/
│   │   └── page.tsx         # /account
│   └── layout.tsx           # 商店页面共享布局
├── products/
│   ├── [id]/                # 动态段
│   │   └── page.tsx         # /products/123
│   └── page.tsx             # /products
├── api/
│   └── users/
│       └── route.ts         # /api/users (API)
├── layout.tsx               # 根布局(所有页面共享)
└── page.tsx                 # 首页

2. components/ 目录

这里存放可复用的 React 组件。当你发现一段 UI 代码在多个页面重复出现时,就可以把它抽取成一个组件放到这里。随着项目变大,良好的组件组织会让代码更容易维护。

components/
├── ui/                       # 基础 UI 组件
│   ├── Button.tsx
│   ├── Input.tsx
│   ├── Modal.tsx
│   └── Table.tsx
├── layout/                   # 布局组件
│   ├── Header.tsx
│   ├── Footer.tsx
│   ├── Sidebar.tsx
│   └── Navigation.tsx
├── features/                 # 功能组件
│   ├── UserProfile.tsx
│   ├── ProductCard.tsx
│   └── CommentList.tsx
└── forms/                    # 表单组件
    ├── LoginForm.tsx
    └── ContactForm.tsx

组件示例

// components/ui/Button.tsx
interface ButtonProps {
  children: React.ReactNode
  onClick?: () => void
  variant?: 'primary' | 'secondary'
}

export function Button({ children, onClick, variant = 'primary' }: ButtonProps) {
  return (
    <button
      onClick={onClick}
      className={`px-4 py-2 rounded ${
        variant === 'primary'
          ? 'bg-blue-500 text-white'
          : 'bg-gray-200 text-gray-800'
      }`}
    >
      {children}
    </button>
  )
}

3. lib/ 目录

这里存放工具函数、API 客户端、常量等辅助代码。把不属于任何特定业务逻辑的通用代码放在这里是个好习惯。

lib/
├── utils/                    # 工具函数
│   ├── format.ts            # 格式化函数
│   ├── validation.ts        # 验证函数
│   └── helpers.ts           # 辅助函数
├── api/                      # API 客户端
│   ├── client.ts
│   ├── users.ts
│   └── products.ts
├── db/                       # 数据库相关
│   ├── connect.ts
│   └── queries.ts
└── constants.ts              # 常量定义

工具函数示例

// lib/utils/format.ts

export function formatDate(date: Date): string {
  return new Intl.DateTimeFormat('zh-CN', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  }).format(date)
}

export function formatCurrency(amount: number): string {
  return new Intl.NumberFormat('zh-CN', {
    style: 'currency',
    currency: 'CNY',
  }).format(amount)
}

4. hooks/ 目录

存放自定义 React Hooks。如果你有一些状态逻辑需要在多个组件中复用,就可以封装成自定义 Hook 放在这里。

hooks/
├── useAuth.ts                # 认证相关
├── useData.ts                # 数据获取
├── useForm.ts                # 表单处理
└── useLocalStorage.ts        # 本地存储

Hook 示例

// hooks/useAuth.ts
'use client'

import { useState, useEffect } from 'react'

export function useAuth() {
  const [isAuthenticated, setIsAuthenticated] = useState(false)
  const [user, setUser] = useState(null)

  useEffect(() => {
    // 检查认证状态
    const token = localStorage.getItem('token')
    setIsAuthenticated(!!token)
  }, [])

  return { isAuthenticated, user }
}

5. public/ 目录

这里存放静态资源,比如图片、字体、favicon 等。放在 public 目录下的文件可以直接通过 URL 访问,不需要 import。

public/
├── images/
│   ├── logo.png
│   └── banner.jpg
├── fonts/
│   └── custom-font.woff2
├── favicon.ico
└── robots.txt

使用方式

// 在组件中引用
<Image src="/images/logo.png" alt="Logo" width={200} height={100} />

6. 根配置文件

next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  swcMinify: true,

  // 图片优化配置
  images: {
    domains: ['example.com'],
    formats: ['image/avif', 'image/webp'],
  },

  // 环境变量
  env: {
    CUSTOM_KEY: process.env.CUSTOM_KEY,
  },

  // 重定向
  async redirects() {
    return [
      {
        source: '/old-path',
        destination: '/new-path',
        permanent: true,
      },
    ]
  },
}

module.exports = nextConfig

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

路由组织最佳实践

1. 使用路由组

路由组 (group-name) 是 Next.js 的一个很有用的特性,它不会影响 URL 路径,但可以帮助你更好地组织代码和共享布局。比如你想把某些页面放在一起管理,但又不想改变 URL 结构,就可以用路由组。

app/
├── (marketing)/              # /about, /contact
│   ├── about/
│   │   └── page.tsx
│   ├── contact/
│   │   └── page.tsx
│   └── layout.tsx           # 营销页面共享布局
├── (dashboard)/              # /dashboard, /settings
│   ├── dashboard/
│   │   └── page.tsx
│   ├── settings/
│   │   └── page.tsx
│   └── layout.tsx           # 需要认证的布局
└── page.tsx                 # 首页

2. 动态路由

动态路由在实际开发中非常常见,比如博客文章页、用户详情页等。使用方括号 [param] 就可以创建动态路由段,Next.js 会自动匹配并解析参数。

app/
├── blog/
│   ├── [slug]/              # /blog/hello-world
│   │   └── page.tsx
│   └── page.tsx             # /blog
├── products/
│   ├── [category]/          # /products/electronics
│   │   └── page.tsx
│   └── page.tsx             # /products
└── users/
    └── [id]/                # /users/123
        ├── [action]/        # /users/123/edit
        │   └── page.tsx
        └── page.tsx         # /users/123

3. 并行和拦截路由

并行路由和拦截路由是 Next.js 的高级特性,可以实现一些复杂的交互效果,比如模态框、并行加载多个页面等。这些特性在实际项目中非常有用,但理解起来可能需要一点时间。

app/
├── @dashboard/               # 并行路由槽
│   └── page.tsx
├── (.)modal/                 # 拦截路由
│   └── photo/[id]/page.tsx
├── dashboard/
│   └── page.tsx
└── layout.tsx

文件命名约定

路由相关

模式 说明 示例 URL
folder/page.tsx 标准路由 /folder
folder/[slug]/page.tsx 动态路由 /folder/value
folder/[[...slug]]/page.tsx 捕获所有路由 /folder/a/b/c
(group)/page.tsx 路由组 /page
folder/(.)modal/... 拦截路由 -

特殊文件

文件 说明
_filename.tsx 私有文件,不创建路由
filename.server.tsx 仅在服务器运行
filename.client.tsx 仅在客户端运行

代码组织建议

1. 按功能组织

按功能组织是一种常见的项目结构方式,把相关的功能放在一起。这种方式适合中小型项目,代码结构清晰易懂。

app/
├── (auth)/
│   ├── login/
│   ├── register/
│   └── forgot-password/
├── (dashboard)/
│   ├── overview/
│   ├── analytics/
│   └── settings/
└── (public)/
    ├── about/
    ├── contact/
    └── pricing/

2. 按层级组织

按层级组织适合大型项目,比如有 API 版本管理、多级管理后台等场景。这种方式可以让结构更有层次感。

app/
├── api/
│   ├── v1/
│   │   ├── users/
│   │   └── posts/
│   └── v2/
│       └── users/
└── admin/
    └── users/
        ├── [id]/
        └── new/

3. 组件分层

组件分层是一种借鉴原子设计的组织方式,把组件按照复杂度分成原子、分子、组织、模板等层级。这种方式适合 UI 组件库或者设计系统比较完善的项目。

components/
├── atoms/                    # 最小单元
│   ├── Button.tsx
│   └── Input.tsx
├── molecules/                # 组合原子
│   ├── SearchBar.tsx
│   └── FormField.tsx
├── organisms/                # 复杂组件
│   ├── Header.tsx
│   └── ProductCard.tsx
└── templates/                # 页面模板
    └── BlogLayout.tsx

环境变量

环境变量用来存储一些敏感信息或者配置,比如数据库连接字符串、API 密钥等。创建 .env.local 文件来存放这些信息,记得把这个文件加到 .gitignore 里,不要提交到代码仓库。

# 数据库
DATABASE_URL=postgresql://...

# API 密钥
API_KEY=your_api_key
API_SECRET=your_api_secret

# 应用配置
NEXT_PUBLIC_APP_URL=http://localhost:3000

访问方式:

// 服务器端
const dbUrl = process.env.DATABASE_URL

// 客户端(必须以 NEXT_PUBLIC_ 开头)
const appUrl = process.env.NEXT_PUBLIC_APP_URL

总结

本节我们详细了解了 Next.js 的项目结构,包括各个目录的用途、路由组织方式、以及一些最佳实践。掌握项目结构是学好 Next.js 的基础,建议你多花点时间理解这些内容。

如果你对本节内容有任何疑问,欢迎在评论区提出来,我们一起学习讨论。

原文链接:https://blog.uuhb.cn/archives/Next-js-02.html

❌
❌