普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月16日掘金 前端

Claude Code Skill 从入门到自定义完整教程(Windows 版)

作者 烛阴
2026年3月16日 22:35

Claude Code Skill(技能)是 Claude CLI 的插件系统,允许你通过简单的斜杠命令(/skill-name)调用预先定义好的复杂工作流。你可以把 Skill 理解为"给 Claude 装外挂"——一个 Skill 文件,即可让 Claude 具备生成小红书图片、转换 Markdown、发布公众号等专业能力。

01-cover-claude-skill-tutorial.png


一、什么是 Claude Skill

核心概念

Skill 本质上是一个包含 SKILL.md 文件的目录,它告诉 Claude:

  • 这个 Skill 能做什么(功能描述)
  • 何时自动触发(触发关键词)
  • 如何操作(分步工作流)
  • 可执行哪些脚本(可选的 scripts/ 目录)

当你在对话中输入 /skill-name 或说出触发关键词时,Claude 会自动加载对应的 Skill 并按照其定义的流程工作。

Skill 与普通对话的区别

对比项 普通对话 使用 Skill
指令复杂度 需要详细描述每一步 一个命令自动执行完整流程
可重复性 每次结果不一致 标准化流程,结果稳定
专业能力 通用能力 领域专精(图像生成、发布等)
配置 无法记住偏好 支持 EXTEND.md 保存个人配置

两种 Skill 类型

纯提示词型:仅包含 SKILL.md,通过详细的自然语言指令引导 Claude 完成任务,无需安装任何依赖。

脚本增强型:包含 SKILL.md + scripts/ 目录,通过 Bun/Node.js 脚本调用 API、处理文件等,能力更强大。


二、安装与目录结构

Skill 存放位置

Skills 存放在固定目录,Claude CLI 启动时会自动扫描:

位置 路径 说明
项目级 .claude/skills/<skill-name>/SKILL.md 仅对当前项目生效
用户级 Users/用户名/.claude/skills/<skill-name>/SKILL.md

在 Windows 下,项目级 Skill 路径示例:

C:\Users\你的用户名\projects\myproject\
└── .claude\
    └── skills\
        ├── baoyu-image-gen\
        │   └── SKILL.md
        ├── baoyu-markdown-to-html\
        │   ├── SKILL.md
        │   └── scripts\
        │       └── main.ts
        └── my-custom-skill\
            └── SKILL.md

安装社区 Skill

社区提供了大量开箱即用的 Skill,以 baoyu 系列为例:

方式一:

# 进入你的项目目录
cd C:\Users\你的用户名\projects\myproject

# 创建 skills 目录
New-Item -ItemType Directory -Force -Path ".claude\skills"

# 下载 baoyu-image-gen Skill(示例)
# 将 SKILL.md 等文件复制到对应目录即可

方式二:

直接告诉 Claude Code:

请帮我安装 github.com/JimLiu/baoyu-skills 中的 Skills

方式三:

  1. 注册插件市场

在 Claude Code 中运行:

/plugin marketplace add JimLiu/baoyu-skills

2. 直接安装

2. 安装指定插件
/plugin install content-skills@baoyu-skills

安装 Skill 后无需重启,Claude CLI 会在下次对话时自动识别。


三、使用 Skill

方式一:斜杠命令

在 Claude CLI 对话中,直接输入斜杠加 Skill 名称:

/baoyu-image-gen 一只可爱的猫咪坐在窗边

/baoyu-markdown-to-html article.md --theme grace

/baoyu-xhs-images mcp-tutorial.md

方式二:自然语言触发

每个 Skill 都定义了触发关键词,说出对应词语时 Claude 会自动加载 Skill:

帮我生成一张封面图片

把这篇 Markdown 转成 HTML

为这篇文章生成小红书图片系列

方式三:带参数调用

许多 Skill 支持参数选项:

/baoyu-image-gen --provider openai --ar 16:9 一片金色的麦田

/baoyu-markdown-to-html article.md --theme modern --color red

/baoyu-xhs-images mcp-tutorial.md --style notion --layout dense

查看可用 Skill

在 Claude CLI 中查看当前项目已安装的所有 Skill:

/skills

四、Skill 偏好配置(EXTEND.md)

什么是 EXTEND.md

EXTEND.md 是 Skill 的个人配置文件,用于保存你的使用偏好,避免每次都重复选择。

存放路径(优先级从高到低):

项目级:.baoyu-skills/<skill-name>/EXTEND.md
用户级:C:\Users\你的用户名\.baoyu-skills\<skill-name>\EXTEND.md

配置示例

baoyu-image-gen 的 EXTEND.md:

# baoyu-image-gen Preferences

default_provider: openai
default_quality: 2k
default_model:
  openai: gpt-image-1

首次运行自动引导

大多数 Skill 在首次使用时会自动弹出配置引导,回答几个问题后自动生成 EXTEND.md:

> /baoyu-image-gen 一只猫咪

[首次使用检测]
请选择默认图片生成服务商:
① OpenAI (gpt-image-1)
② Google (gemini-3-pro-image)
③ DashScope (通义万象)

你的选择:

五、开发自定义 Skill

最简 Skill 结构

只需一个 SKILL.md 文件即可创建 Skill:

.claude/skills/my-translator/
└── SKILL.md

SKILL.md 编写规范

SKILL.md 使用 YAML frontmatter + Markdown 正文格式:

---
name: my-translator
description: Translates text between Chinese and English. Use when user asks to "translate", "翻译", or provides text to convert between languages.
---

# 智能翻译助手

将用户提供的文字在中英文之间互译,保持原文风格和语气。

## 工作流程

### Step 1:识别语言

检测输入文字的语言:
- 主要为中文 → 翻译成英文
- 主要为英文 → 翻译成中文
- 混合 → 询问用户目标语言

### Step 2:翻译

翻译时注意:
- 保持原文的语气(正式/口语)
- 专业术语保持准确
- 地道表达,避免直译

### Step 3:输出

输出格式:
- 原文(引用块显示)
- 译文(直接显示)
- 如有歧义,附加说明

## 使用示例

/my-translator Hello, how are you? /my-translator 今天天气真好 翻译:这段英文合同条款

带脚本的 Skill

如需调用外部 API 或处理文件,可以添加 scripts/main.ts

.claude/skills/my-weather/
├── SKILL.md
└── scripts/
    └── main.ts

SKILL.md 中通过 Bash 调用脚本:

## 执行查询

```bash
npx -y bun ${SKILL_DIR}/scripts/main.ts --city "北京"

**scripts/main.ts** 示例:

```typescript
import { parseArgs } from "util";

const { values } = parseArgs({
  args: process.argv.slice(2),
  options: { city: { type: "string" } },
});

// 调用天气 API
const res = await fetch(`https://api.weather.com/?city=${values.city}`);
const data = await res.json();

console.log(JSON.stringify({
  city: values.city,
  temp: data.temperature,
  condition: data.condition,
}));

EXTEND.md 配置支持

在 SKILL.md 中说明支持哪些配置项,让用户可以通过 EXTEND.md 自定义行为:

## 用户配置(EXTEND.md)

支持以下配置项:

# yaml
# 默认目标语言(zh / en)
default_target: zh

# 是否显示原文
show_original: true

# 翻译风格(formal / casual / literary)
style: casual

配置文件路径:$HOME/.skills/my-translator/EXTEND.md


六、总结

Claude Skill 将复杂的 AI 工作流封装成一个斜杠命令,大幅降低了重复性操作的门槛。通过本教程,你已经掌握了:

  • Skill 的核心概念与两种类型
  • 安装和目录结构规范
  • 三种使用方式:斜杠命令、自然语言、带参数调用
  • EXTEND.md 个人偏好配置
  • 从零开发自定义 Skill 的完整流程

推荐资源:

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

作者 SmalBox
2026年3月16日 19:17

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

在Unity URP Shader Graph中,Sample Cubemap节点是一个功能强大的工具,专门用于对立方体贴图资源进行采样操作。立方体贴图是一种特殊类型的纹理,由六个二维纹理面组成,形成一个完整的立方体环境映射。这种纹理格式在实时渲染中广泛应用于天空盒、环境反射、折射效果以及基于图像的光照计算。

Sample Cubemap节点的核心功能是根据给定的三维方向向量,从立方体贴图中获取对应的颜色信息。与普通的2D纹理采样不同,立方体贴图采样不需要UV坐标,而是使用方向向量来确定采样位置。这使得它特别适合表示全方位环境数据,如周围环境的全景视图。

在URP渲染管线中,Sample Cubemap节点优化了现代图形API的使用,包括对移动平台和高端PC平台的良好支持。它能够无缝集成到Shader Graph的可视化编程环境中,让开发者无需编写复杂的HLSL代码即可实现高级的立方体贴图效果。

该节点支持多种高级功能,包括多级渐远纹理采样、自定义采样器状态配置,以及与Shader Graph其他节点的灵活连接。这些特性使得Sample Cubemap节点成为创建高质量环境反射、天空盒渲染和复杂光照效果的关键工具。

描述

Sample Cubemap节点的主要作用是从立方体贴图资源中提取颜色信息,并将其转换为可在着色器中使用的Vector 4格式数据。立方体贴图本质上是一个由六个2D纹理组成的立方体结构,每个面对应立方体的正负X、Y、Z轴方向。这种结构使其能够完整地表示一个三维空间中的环境信息。

采样原理

立方体贴图的采样过程基于方向向量而非传统的UV坐标。当提供一个三维方向向量时,采样器会确定该向量与立方体哪个面的交点,并在对应的2D纹理上进行采样。这一过程完全在GPU层面实现,具有极高的效率。

在世界空间中进行采样时,方向向量通常需要归一化处理,以确保采样结果的准确性。方向向量的长度不会影响采样位置,只有方向本身决定了采样点。这意味着无论向量的模长是多少,只要方向相同,就会采样到立方体贴图上的同一位置。

LOD功能

Sample Cubemap节点支持细节级别参数,通常称为LOD。LOD技术允许在不同的观察距离或条件下使用不同精度的纹理版本。当LOD值为0时,采样器使用最高质量的原始纹理;随着LOD值的增加,采样器会使用预先生成的、分辨率更低的mipmap级别。

LOD功能在实现多种视觉效果时非常有用:

  • 通过使用较高的LOD值,可以实现立方体贴图的模糊效果,常用于创建柔和的反射或远处的环境细节
  • 在性能优化方面,可以根据物体与相机的距离动态调整LOD值,减少远处物体的纹理采样开销
  • 在特定的艺术效果中,如梦境场景或水下效果,可以通过LOD控制环境反射的清晰度

自定义采样器

Sampler输入允许开发者定义自定义的采样器状态,包括纹理过滤模式和寻址模式等参数。默认情况下,节点使用立方体贴图资源自带的采样器设置,但通过此端口可以覆盖这些设置。

自定义采样器的应用场景包括:

  • 更改纹理过滤模式,如在像素艺术风格中使用点过滤,或在高质量渲染中使用各向异性过滤
  • 修改寻址模式,控制当采样方向超出正常范围时的行为
  • 实现特殊的纹理采样效果,如边缘钳制或镜像重复

版本兼容性

如果在包含自定义函数节点或子图形的图形中使用Sample Cubemap节点时遇到纹理采样错误,这可能是由于早期Shader Graph版本的已知问题。Unity建议通过升级到10.3或更高版本来解决这些问题。新版本提供了更稳定的纹理采样机制和更好的节点兼容性。

升级到较新版本不仅可以解决采样错误,还能获得性能改进和新功能支持,如增强的立方体贴图压缩格式支持和改进的mipmap生成算法。

端口

Sample Cubemap节点包含多个输入和输出端口,每个端口都有特定的功能和数据类型。理解这些端口的作用对于正确使用该节点至关重要。

输入端口

  • Cube端口

    Cube端口是Sample Cubemap节点的主要输入,用于接收立方体贴图资源。此端口的数据类型是Cubemap,只能连接立方体贴图类型的资源。在Shader Graph中,可以通过Expose功能将立方体贴图作为材质的可配置属性,或在子图中定义特定的立方体贴图资源。

    使用Cube端口时需要注意:

    • 确保连接的资源确实是立方体贴图类型,普通的2D纹理无法正常工作
    • 立方体贴图的分辨率会影响采样质量和性能,需要根据目标平台和用途选择适当的分辨率
    • 立方体贴图的压缩格式会影响内存使用和视觉效果,在移动平台上尤其需要注意
  • Dir端口

    Dir端口接收Vector 3类型的方向向量,用于确定立方体贴图上的采样位置。此端口的世界空间绑定为法线空间,意味着输入的方向向量通常在世界空间坐标系中表示。

    Dir端口的使用要点:

    • 方向向量通常需要归一化处理,但非归一化的向量也能工作,采样器会自动处理
    • 向量的方向决定了采样点,与向量长度无关
    • 在实际应用中,方向向量可以来自表面法线、反射向量、视图方向或其他计算得到的方向数据
  • Sampler端口

    Sampler端口允许指定自定义的采样器状态,覆盖立方体贴图的默认采样设置。此端口是可选的,如果未连接,节点将使用立方体贴图资源自带的采样器状态。

    自定义采样器的常见配置:

    • 过滤模式:控制纹理缩放时的插值方式,如点过滤、双线性过滤、三线性过滤等
    • 寻址模式:定义当采样坐标超出[0,1]范围时的行为,如重复、钳制、镜像等
    • 各向异性级别:控制各向异性过滤的质量,适用于大角度观察纹理表面的情况
  • LOD端口

    LOD端口接收Float类型的值,用于指定采样的细节级别。当LOD值为0时,使用最高分辨率的mipmap级别;随着LOD值增加,使用更低分辨率的mipmap级别。

    LOD端口的应用场景:

    • 值为0:使用最清晰的纹理,适合近处物体的高质量反射
    • 值为1-3:使用中等模糊级别的纹理,适合中等距离的反射效果
    • 值大于3:使用高度模糊的纹理,适合远处环境或特殊视觉效果

输出端口

  • Out端口

    Out端口输出采样得到的Vector 4颜色值。这四个分量通常对应RGBA颜色通道,但具体含义取决于立方体贴图的内容和用途。

    输出颜色的应用方式:

    • 直接作为表面颜色,用于天空盒渲染
    • 与环境光、漫反射和镜面反射结合,实现基于图像的光照
    • 作为反射/折射效果的颜色来源
    • 与其他纹理或颜色数据混合,创建复杂的材质效果

生成的代码示例

Sample Cubemap节点在编译时生成的HLSL代码基于Unity的纹理采样函数。理解这些底层代码有助于更深入地掌握节点的功能,并在需要时进行自定义扩展。

基本代码结构

以下示例代码表示此节点的一种可能结果:

float4 _SampleCubemap_Out = SAMPLE_TEXTURECUBE_LOD(Cubemap, Sampler, Dir, LOD);

这行代码展示了Sample Cubemap节点的核心功能:使用SAMPLE_TEXTURECUBE_LOD宏对立方体贴图进行采样。该宏是Unity对HLSL原生texCUBElod函数的封装,提供了跨平台兼容性和优化。

代码分解

  • SAMPLE_TEXTURECUBE_LOD:这是Unity提供的宏,用于在着色器中采样立方体贴图的特定mipmap级别。它会根据目标平台和图形API转换为适当的采样指令。
  • Cubemap:参数对应Cube输入端连接的立方体贴图资源。在编译后的代码中,这通常是一个纹理对象,通过Unity的材质属性系统或全局着色器属性进行绑定。
  • Sampler:参数指定使用的采样器状态。如果通过Sampler输入端提供了自定义采样器,则使用该采样器;否则使用立方体贴图默认的采样器状态。
  • Dir:参数是从Dir输入端接收的方向向量。这个向量决定了在立方体贴图上的采样位置,采样器会找到与该方向向量对应的立方体面和纹理坐标。
  • LOD:参数控制采样的mipmap级别。当LOD为0时,使用最高分辨率的mipmap级别;随着LOD值增加,使用更低分辨率的mipmap级别,产生更模糊的结果。

变体与平台差异

根据目标平台和设置,Sample Cubemap节点可能会生成不同的代码变体:

  • 在支持现代图形API的平台(如DX11、Vulkan、Metal)上,通常会使用更高效的采样指令
  • 在移动平台上,可能会使用简化版的采样函数以减少着色器复杂度
  • 当LOD输入端未连接或使用固定值时,编译器可能会优化为不使用LOD的采样函数,如SAMPLE_TEXTURECUBE
  • 在特定的图形特性(如HDR渲染)启用时,采样函数可能会自动处理高动态范围数据

自定义扩展

了解生成的代码结构后,开发者可以在自定义函数节点中扩展Sample Cubemap节点的功能:

// 示例:自定义立方体贴图采样函数
void SampleCubemapCustom_float(TextureCube Cubemap, SamplerState Sampler, float3 Dir, float LOD, out float4 Out)
{
    // 基本采样
    Out = SAMPLE_TEXTURECUBE_LOD(Cubemap, Sampler, Dir, LOD);

    // 自定义后处理:调整颜色
    Out.rgb = pow(Out.rgb, 1.0/2.2); // 简单的gamma校正

    // 自定义后处理:基于方向调整强度
    float intensity = saturate(dot(normalize(Dir), float3(0, 1, 0)));
    Out.rgb *= lerp(0.8, 1.2, intensity);
}

这种自定义扩展允许实现更复杂的立方体贴图效果,如方向相关的颜色调整、动态曝光补偿或特殊滤镜效果。

实际应用示例

Sample Cubemap节点在URP着色器开发中有多种实际应用。以下是一些常见的使用场景和实现方法。

环境反射

环境反射是Sample Cubemap节点最典型的应用之一。通过结合相机的反射向量和立方体贴图,可以实现高质量的实时反射效果。

实现环境反射的基本步骤:

  • 使用Reflection Vector节点计算表面点的反射方向
  • 将此方向向量连接到Sample Cubemap节点的Dir输入
  • 将环境立方体贴图连接到Cube输入
  • 将输出的颜色值与表面材质混合

高级环境反射技巧:

  • 使用Fresnel效应控制反射强度,使掠射角度的反射更强烈
  • 根据表面粗糙度调整LOD值,粗糙表面使用更高LOD产生模糊反射
  • 结合屏幕空间反射,实现更准确的局部反射效果

天空盒渲染

Sample Cubemap节点可用于创建动态天空盒效果,特别是在需要程序化生成或动态修改天空的场景中。

天空盒实现方法:

  • 使用片元的世界位置或视图方向作为采样方向
  • 连接天空盒立方体贴图到Cube输入
  • 可选:使用时间变量动态修改采样方向或LOD,实现云层移动或日夜变化效果

增强天空盒的技巧:

  • 使用多个立方体贴图和混合操作实现复杂的多层天空
  • 通过LOD控制远处天空的细节程度,优化性能
  • 结合体积云或大气散射着色,增强视觉真实感

基于图像的照明

在PBR渲染流程中,Sample Cubemap节点常用于基于图像的照明,提供高质量的环境光照信息。

IBL实现流程:

  • 使用表面法线采样辐照度图,提供漫反射环境光
  • 使用反射向量采样预过滤的环境贴图,提供镜面反射
  • 结合BRDF查找表,完成完整的PBR光照计算

优化技巧:

  • 使用不同分辨率的立方体贴图平衡质量和性能
  • 根据表面材质属性动态选择mipmap级别
  • 在低端设备上简化IBL计算,如使用球谐函数近似

折射效果

Sample Cubemap节点也可以用于实现折射效果,如玻璃、水或其他透明材质的视觉效果。

折射实现方法:

  • 使用折射向量而非反射向量作为采样方向
  • 折射向量可以通过Refract节点计算,考虑表面法线和折射率
  • 将采样结果与表面颜色混合,创建透明或半透明效果

高级折射技巧:

  • 使用多个采样层模拟复杂折射,如毛玻璃或扭曲玻璃效果
  • 结合深度信息调整折射强度,实现基于距离的效果变化
  • 使用自定义的立方体贴图,包含专门为折射优化的环境信息

性能优化建议

在使用Sample Cubemap节点时,合理的性能优化可以确保应用在各种设备上都能流畅运行。

纹理优化

  • 选择合适的立方体贴图分辨率,平衡视觉质量和内存占用
  • 使用适当的纹理压缩格式,如ASTC用于移动设备,BC系列用于PC
  • 确保立方体贴图具有完整的mipmap链,以支持LOD功能
  • 考虑使用立方体贴图阵列,批量处理多个环境贴图

采样优化

  • 仅在必要时使用高LOD值,避免不必要的模糊采样
  • 在片段着色器中谨慎使用立方体贴图采样,考虑在顶点着色器中预计算简单情况
  • 使用纹理流送系统,动态加载和卸载立方体贴图资源
  • 对于静态环境,考虑将立方体贴图烘焙到光照贴图中

平台特定优化

  • 在移动平台上,优先使用较低分辨率的立方体贴图
  • 在支持tier分级的情况下,为不同设备等级配置不同质量的立方体贴图
  • 使用Unity的Quality Settings系统,根据设备性能动态调整立方体贴图质量
  • 考虑在低端设备上完全禁用某些立方体贴图效果,使用更简单的替代方案

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

Unity —— Animator 状态机事件通知(Animator 1-2)

2026年3月16日 18:23

一. 状态机监听

  1. AnimStateHook - 状态机钩子
  • 继承自 Unity 的 StateMachineBehaviour,附加到 Animator Controller 的状态上
  • 拦截 Animator 的三个生命周期回调:OnAnimEnter、OnAnimUpdate、OnAnimExit
  • 从 Animator 所在 GameObject 获取 AnimEventDispatcher 组件并转发事件
public class AnimStateHook : StateMachineBehaviour
{
    public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex, AnimatorControllerPlayable controller)
    {
        AnimEventDispatcher call = animator.gameObject.GetComponent<AnimEventDispatcher>();
        if (call != null)
        {
            call.OnEnterChannel(animator, stateInfo, layerIndex);
        }
    }

    public override void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex, AnimatorControllerPlayable controller)
    {
        AnimEventDispatcher call = animator.gameObject.GetComponent<AnimEventDispatcher>();
        if (call != null)
        {
            call.OnUpdateChannel(animator, stateInfo, layerIndex);
        }
    }

    public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex, AnimatorControllerPlayable controller)
    {
        AnimEventDispatcher call = animator.gameObject.GetComponent<AnimEventDispatcher>();
        if (call != null)
        {
            call.OnExitChannel(animator, stateInfo, layerIndex);
        }
    }
}
  1. AnimEventDispatcher - 事件分发器
  • 作为 MonoBehaviour 组件挂载在有 Animator 的 GameObject 上, 提供外部监听方法
  • 接收 AnimStateHook 的回调并广播对应事件
public class AnimEventDispatcher : MonoBehaviour
{
    public Action<int, float> OnEnterEvent;
    public Action<int, float> OnUpdateEvent;
    public Action<int, float> OnExitEvent;


    public void OnEnterChannel(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        OnEnterEvent?.Invoke(stateInfo.shortNameHash, stateInfo.normalizedTime);
    }

    public void OnUpdateChannel(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        OnUpdateEvent?.Invoke(stateInfo.shortNameHash, stateInfo.normalizedTime);
    }

    public void OnExitChannel(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        OnExitEvent?.Invoke(stateInfo.shortNameHash, stateInfo.normalizedTime);
    }
}

  1. 总结: 如上提供了一种监听Animator状态变化的监听方法, 入状态进入(Enter), 状态Update(Update), 状态退出(Exit)注意事项
  • normalizedTime为百分比时间
  • 两个相邻状态A -> B, Unity无法保证A的退出先执行, B的进入后执行, 可能先执行B的进入,再执行A的退出也未可知

Three.js一起学-如何通过官方例子高效学习 Three.js?手把手带你“抄”出一个3D动画

2026年3月16日 18:16

前言

在之前的文章中有说过,我认为学习three.js最好的方法就是通过官网的例子去学习,在实践中练习。 因为Three.js中api很多,没有系统的学习文档,因此我觉得通过例子去学习,使用到某个概念或者api在深入学习它,这是一个不错的学习方式。 那么具体如何学习呢?

45ce1782386deb25cf01ea76ee0705b6.gif

好了言归正传,下面我就教大家如何打出一套如来神掌,啊不对,是通过官方例子学习Three.js。

0CFA5E8C.jpg

准备

阅读本文需要一点点的 WebGL 的知识点,至少这个文档的基础知识部分看完即可。

此外各位同学需要下载一下 Three.js 源码。源码里有我们需要用到的模型和官网例子的源码。

搭建一个 web 工程,本文演示创建基于 vue3 的工程。

bash

npm init vue@latest

按照自己的喜好选择要安装哪些插件即可。生成的项目结构如下,我们为此项目添加 three.js,修改 package.json 如下。

json

{
  "dependencies": {
    ...
    "three": "^0.143.0"
  }
}

如需使用 typescript,添加如下依赖:

json

{
  "devDependencies": {
    ...  
    "@types/three": "^0.143.0"
  }
}

然后执行 npm install 安装依赖即可。

至此我们前期创建项目的准备工作就做完了。

体验例子

接下来我们在 three.js 官网上找到 感兴趣的例子,打开它的源码,先大致阅读一遍。通过这个例子中我们将学到如下几个知识点:相机、场景、网格、灯光、材质、形状、动画等。下面我们跟着例子写一遍代码,带大家学会如何通过例子去学习。

j1j33-c8781.gif

写(抄)代码

源码在上面的链接已经给出来,我就不在这里凑字数了。我在这里一步一步地写一下上述例子的代码,并演示如何通过此例来学习 Three.js。那么就让我们愉快地开始吧。

首先,我们在 Vue 项目中创建一个组件(比如 ThreeExample.vue),并在 mounted 生命周期中编写 Three.js 代码。当然,你也可以用原生 HTML+JS,但这里我们以 Vue3 为例。

1. 初始化场景、相机和渲染器

打开官方例子的源码,我们会看到一开始就创建了 scenecamerarenderer 这三个基本对象。这是每一个 Three.js 应用的起点。

js

// 1. 创建场景
const scene = new THREE.Scene();

// 2. 创建透视相机(参数:视野角度、宽高比、近裁面、远裁面)
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 2000);
camera.position.set(2, 2, 5); // 设置相机位置
camera.lookAt(0, 0, 0);        // 让相机看向原点

// 3. 创建 WebGL 渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement); // 将 canvas 添加到页面

学习点:这里我们用到了 PerspectiveCamera,如果不清楚它的参数含义,就可以去 官网文档 查看。文档会详细解释每个参数的作用,比如 fov(视野角度)决定了你能看到多大的范围,aspect(宽高比)通常设为画布的宽度/高度,否则图像会被拉伸。

2. 添加光源

例子中使用了多种光源:环境光、点光源和聚光灯。光源是让物体可见并产生阴影和立体感的关键。

js

// 环境光:提供基础照明,均匀照亮所有面
const ambientLight = new THREE.AmbientLight(0xffffff, 0.2);
scene.add(ambientLight);

// 点光源:从某个点向所有方向发射光线
const pointLight = new THREE.PointLight(0xffffff, 1);
pointLight.position.set(1, 2, 3);
scene.add(pointLight);

// 还可以添加其他光源,比如聚光灯、平行光等,根据需要选择

学习点:看到 AmbientLight 和 PointLight,我们可以去文档了解每种光源的特点和适用场景。例如环境光没有方向,通常用来提亮阴影部分;点光源类似灯泡,会产生阴影(需配合阴影设置)。

3. 加载模型

例子中加载了一个带有骨骼动画和变形动画的模型(models/gltf/Soldier.glb)。我们需要使用 GLTFLoader 来加载 glTF 格式的模型。

首先,引入加载器(需要额外导入):

js

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';

然后,在代码中创建加载器并加载模型:

js

const loader = new GLTFLoader();
loader.load('models/gltf/Soldier.glb', (gltf) => {
  const model = gltf.scene;
  scene.add(model);

  // 获取模型的动画剪辑
  const animations = gltf.animations;
  if (animations && animations.length) {
    // 创建动画混合器并播放动画(后面会讲到)
    mixer = new THREE.AnimationMixer(model);
    const action = mixer.clipAction(animations[0]);
    action.play();
  }
}, undefined, (error) => {
  console.error('模型加载失败:', error);
});

注意:模型路径需要根据你存放的位置调整。官方源码中的模型路径是相对 examples/ 目录的,你可以把 models 文件夹复制到你的 public 目录下。

学习点:遇到 GLTFLoader,我们可以去文档或源码中查看它的用法。GLTF 是 Three.js 推荐的 3D 模型格式,支持动画、材质、骨骼等。通过这个例子,我们学会了如何加载外部模型并添加到场景。

4. 动画循环

例子中使用 requestAnimationFrame 实现动画循环,并在每一帧更新动画混合器。

js

let mixer = null; // 在加载模型时赋值

function animate() {
  requestAnimationFrame(animate);

  const delta = clock.getDelta(); // 获取时间差,用于平滑动画
  if (mixer) {
    mixer.update(delta); // 更新动画混合器
  }

  // 渲染场景
  renderer.render(scene, camera);
}

// 创建 Clock 对象用于计算时间差
const clock = new THREE.Clock();

// 启动动画循环
animate();

学习点AnimationMixer 和 Clock 是 Three.js 中处理动画的重要 API。通过查看文档,我们可以了解 mixer.update(delta) 如何基于时间差驱动模型动画。

5. 处理窗口大小变化

为了让画布自适应窗口,我们需要监听窗口的 resize 事件,更新相机宽高比和渲染器尺寸。

js

window.addEventListener('resize', onWindowResize, false);

function onWindowResize() {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix(); // 必须调用,使相机参数生效
  renderer.setSize(window.innerWidth, window.innerHeight);
}

学习点updateProjectionMatrix() 方法的作用是更新相机的投影矩阵,当相机参数改变时(如宽高比),需要调用此方法让 Three.js 重新计算投影。

6. 遇到不懂的就去官网查找 API

在“抄”代码的过程中,你一定会遇到很多陌生的 API,比如 PointLightGLTFLoaderAnimationMixerClockupdateProjectionMatrix 等等。这时候,最好的学习方式就是打开 Three.js 官方文档,搜索这些 API,仔细阅读其用法和参数含义。

例如,你看到 camera.lookAt(0, 0, 0),可以查阅 Object3D 的 lookAt 方法,理解它是如何让物体朝向某个点的。

文档通常包含详细说明和示例代码,非常有助于深入理解。通过这样的方式,你不仅学会了这个例子,还能举一反三,应用到其他场景。

完整的代码整合

将上述代码片段整合到一个 Vue 组件中,大概如下(省略了样式和模板部分):

vue

<template>
  <div ref="container" style="width:100%; height:100vh;"></div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';

const container = ref(null);

onMounted(() => {
  // 1. 创建场景
  const scene = new THREE.Scene();

  // 2. 创建相机
  const camera = new THREE.PerspectiveCamera(45, container.value.clientWidth / container.value.clientHeight, 1, 2000);
  camera.position.set(2, 2, 5);
  camera.lookAt(0, 0, 0);

  // 3. 创建渲染器
  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(container.value.clientWidth, container.value.clientHeight);
  renderer.setPixelRatio(window.devicePixelRatio);
  container.value.appendChild(renderer.domElement);

  // 4. 添加光源
  const ambientLight = new THREE.AmbientLight(0xffffff, 0.2);
  scene.add(ambientLight);
  const pointLight = new THREE.PointLight(0xffffff, 1);
  pointLight.position.set(1, 2, 3);
  scene.add(pointLight);

  // 5. 加载模型
  const loader = new GLTFLoader();
  let mixer = null;
  loader.load('/models/gltf/Soldier.glb', (gltf) => {
    const model = gltf.scene;
    scene.add(model);
    if (gltf.animations.length) {
      mixer = new THREE.AnimationMixer(model);
      const action = mixer.clipAction(gltf.animations[0]);
      action.play();
    }
  });

  // 6. 动画循环
  const clock = new THREE.Clock();
  function animate() {
    requestAnimationFrame(animate);

    const delta = clock.getDelta();
    if (mixer) {
      mixer.update(delta);
    }

    renderer.render(scene, camera);
  }
  animate();

  // 7. 窗口大小自适应
  const onWindowResize = () => {
    camera.aspect = container.value.clientWidth / container.value.clientHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(container.value.clientWidth, container.value.clientHeight);
  };
  window.addEventListener('resize', onWindowResize);
});
</script>

如何进一步深入学习

通过“抄”这个例子,我们已经接触到了 Three.js 的核心概念。但这仅仅是开始。下面是一些进阶学习建议:

  1. 修改参数,观察效果:尝试调整相机位置、光源颜色和强度、模型缩放比例等,实时查看变化,加深理解。
  2. 尝试添加交互:比如使用 OrbitControls 让用户可以用鼠标旋转视角。引入对应的控制器并启用,能极大地提升体验。
  3. 研究动画:例子中只播放了第一个动画剪辑。你可以尝试播放其他动画,或者混合多个动画。
  4. 阅读更多例子:Three.js 官网有上百个例子,涵盖了粒子系统、后期处理、物理效果等。按照同样的方法,一个个“抄”过去,你的 Three.js 水平会飞速提升。
  5. 参与社区:遇到问题时,可以到 Stack Overflow、GitHub Issues 或中文社区(如掘金)提问,也可以阅读他人的源码和文章。

最后

以上就是本文的全部内容了。我们通过一个官方的骨骼动画例子,逐步学习了 Three.js 的基础:场景、相机、渲染器、光源、模型加载、动画和自适应窗口。更重要的是,我们实践了“通过例子学习”的方法——遇到不懂的 API 就去查文档,然后亲手写一遍。

这种学习方法不仅适用于 Three.js,也适用于其他任何技术栈。希望各位同学能举一反三,不再畏惧陌生的框架和库,勇敢地打开源码,开始你的“抄”级学习之旅!

如果感觉阅读本文后有所收获,欢迎点赞、收藏和评论,也欢迎分享你通过例子学习 Three.js 的心得。我们下期再见!

后续会持续更新我的WebGL和Three.js学习过程和经验分享,如果感兴趣可以关注我的专栏Three.js一起来学

相关文章会同步发布到我的公众号:【编程智匠】

我花了三天用AI写了个上一代前端构建工具

作者 达拉
2026年3月16日 18:15

前端工程化实践:从复制粘贴到一键生成,xcli 解决了什么问题,又是如何设计的。

以前:2小时的痛苦

# 第1步:创建目录
mkdir my-project && cd my-project

# 第2步:初始化 package.json
npm init -y

# 第3步:安装 TypeScript
npm install -D typescript
npx tsc --init
# 然后手动改 tsconfig.json ...

# 第4步:安装 ESLint
npm install -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
# 然后创建 .eslintrc.json,配置规则 ...

# 第5步:安装 Prettier
npm install -D prettier eslint-config-prettier
# 然后创建 .prettierrc ...

# 第6步:安装 Vite
npm install -D vite @vitejs/plugin-react
# 然后创建 vite.config.ts ...

# 第7步:配置 browserslist
# 创建 .browserslistrc,写兼容配置 ...

# 第8步:配置 PostCSS
npm install -D postcss autoprefixer
# 创建 postcss.config.js ...

# ... 此处省略 20 步

# 第 N 步:终于跑起来了
npm run dev

耗时:约 2 小时

现在:3分钟的优雅

npx @jserxiao/xcli init my-project -t react -d
cd my-project
pnpm dev

耗时:约 3 分钟


xcli 是什么?

xcli 是一个可插拔的 TypeScript 项目脚手架 CLI 工具。

它的核心理念:配置标准化、可复用、开箱即用

# 全局安装
npm install -g @jserxiao/xcli

# 或者直接用 npx
npx @jserxiao/xcli init my-project

核心功能详解

1. 三种项目模板

根据实际业务场景,我设计了三种模板:

📦 Library 模板

适合开发 npm 包、工具函数库。

my-lib/
├── src/
│   └── index.ts          # 入口文件
├── dist/                 # 编译输出
├── package.json
├── tsconfig.json
└── README.md

特性

  • TypeScript 5 严格模式
  • 同时输出 ESM + CJS 格式
  • 自动生成类型声明

⚛️ React 模板

基于 pnpm monorepo 的企业级前端项目。

my-app/
├── src/                    # 主应用源码
│   ├── main.tsx
│   ├── App.tsx
│   ├── pages/              # 页面
│   ├── components/         # 组件
│   ├── router/             # 路由
│   ├── api/                # HTTP 请求
│   │   └── request.ts      # Axios/Fetch 封装
│   └── store/              # 状态管理
│       ├── index.ts
│       ├── counterSlice.ts
│       └── middleware/
├── packages/               # pnpm workspace
│   ├── shared/             # 共享工具库
│   └── ui/                 # UI 组件库
├── vite.config.ts          # Vite 配置
├── eslint.config.js        # ESLint Flat Config
├── tsconfig.json
├── postcss.config.js
├── .browserslistrc         # 浏览器兼容
└── pnpm-workspace.yaml

状态管理可选

  • Redux Toolkit(推荐)
  • MobX

HTTP 请求可选

  • Axios(带完整封装)
  • Fetch(原生 API 封装)

💚 Vue 模板

同样是 pnpm monorepo 结构,默认集成 Pinia。

my-vue-app/
├── src/
│   ├── main.ts
│   ├── App.vue
│   ├── pages/
│   ├── components/
│   ├── router/
│   ├── api/
│   └── store/              # Pinia 状态管理
├── packages/
│   ├── shared/
│   └── ui/
└── ...配置文件

2. 丰富的插件系统

插件系统
├── 代码规范
│   ├── ESLint 9 (Flat Config) ⭐ 最新格式
│   ├── Prettier
│   └── Stylelint (支持 CSS/Less/SCSS)
│
├── 构建工具
│   ├── Vite 5 ⭐ 默认推荐
│   ├── Webpack 5
│   └── Rollup
│
├── 测试工具
│   ├── Vitest ⭐ Vite 原生
│   └── Jest
│
└── Git 工具
    ├── Husky (Git Hooks)
    └── Commitlint (提交规范)

每个插件都是独立、可插拔的。你可以选择需要的,跳过不需要的。


3. 浏览器兼容性:一处配置,处处生效

这是我最想重点介绍的功能。

以前的问题

CSS 前缀和 JS Polyfill 分开配置,经常对不上:

// postcss.config.js
module.exports = {
  plugins: {
    autoprefixer: {
      overrideBrowserslist: ['last 2 versions']  // 这里
    }
  }
}

// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', {
      targets: { browsers: ['> 1%'] }  // 和这里不一致!
    }]
  ]
}

结果:CSS 兼容 Chrome 70,JS 兼容 Chrome 60,乱套了。

xcli 的解决方案

统一使用 .browserslistrc

# .browserslistrc
[production]
> 0.5%
last 2 versions
not dead
not IE 11
Chrome >= 86    # 明确指定 Chrome 86+

[development]
last 1 chrome version
last 1 firefox version
last 1 safari version

然后所有工具自动读取:

工具 作用 配置方式
Autoprefixer 添加 CSS 前缀 自动读取 .browserslistrc
Babel preset-env JS Polyfill 自动读取 .browserslistrc
Vite Legacy 旧浏览器兼容 自动读取 .browserslistrc

一处配置,处处生效。再也不用操心兼容性问题。


4. ESLint 9 Flat Config

很多脚手架还在用 ESLint 8 的 .eslintrc.json 格式,xcli 直接上了 ESLint 9+ Flat Config

旧格式(ESLint 8)

// .eslintrc.json
{
  "root": true,
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended"
  ],
  "env": {
    "node": true
  }
}

问题:

  • 多个配置文件(.eslintrc + .eslintignore
  • 配置格式不统一
  • 插件加载顺序容易出问题

新格式(ESLint 9 Flat Config)

// eslint.config.js
import js from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import prettierConfig from 'eslint-config-prettier';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';

export default tseslint.config(
  { ignores: ['dist'] },
  {
    extends: [js.configs.recommended, ...tseslint.configs.recommended],
    files: ['**/*.{ts,tsx}'],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
    },
    plugins: {
      'react-hooks': reactHooks,
      'react-refresh': reactRefresh,
    },
    rules: {
      ...reactHooks.configs.recommended.rules,
      'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
      '@typescript-eslint/no-explicit-any': 'warn',
      '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
    },
  },
  prettierConfig,
);

优势:

  • ✅ 单一配置文件
  • ✅ JavaScript 原生数组操作,可扩展性更强
  • ✅ 显式导入,没有隐式依赖
  • ✅ 性能更好

实战演示

场景 1:快速启动一个 React 项目

# 交互式创建
npx @jserxiao/xcli init my-react-app

# 然后根据提示选择:
# ? 项目类型: React
# ? 样式预处理器: Less
# ? 状态管理: Redux Toolkit
# ? HTTP 请求库: Axios
# ? 打包工具: Vite
# ? 创建 VSCode 配置: Yes

或者直接命令行一把梭:

npx @jserxiao/xcli init my-react-app \
  -t react \
  -s less \
  -m redux \
  -h axios \
  -b vite \
  -d

生成的项目结构:

my-react-app/
├── src/
│   ├── main.tsx              # React 18 入口
│   ├── App.tsx               # 根组件
│   ├── pages/
│   │   ├── Home.tsx          # 首页(带 Redux 示例)
│   │   └── About.tsx         # 关于页
│   ├── components/
│   │   └── Layout.tsx        # 布局组件
│   ├── router/
│   │   └── index.tsx         # React Router 6
│   ├── api/
│   │   └── request.ts        # Axios 封装(含拦截器)
│   ├── store/
│   │   ├── index.ts          # Store 配置
│   │   ├── counterSlice.ts   # Counter 示例
│   │   ├── apiSlice.ts       # RTK Query
│   │   └── middleware/
│   │       └── logger.ts     # 日志中间件
│   └── assets/
├── packages/
│   ├── shared/               # 共享工具函数
│   │   └── src/
│   │       └── index.ts
│   └── ui/                   # UI 组件库
│       └── src/
│           └── index.ts
├── public/
├── vite.config.ts            # Vite 5 配置
├── eslint.config.js          # ESLint 9 Flat Config
├── tsconfig.json             # TypeScript 5
├── postcss.config.js         # PostCSS + Autoprefixer
├── .browserslistrc           # 浏览器兼容
├── .prettierrc
├── pnpm-workspace.yaml
└── package.json

直接运行:

cd my-react-app
pnpm install
pnpm dev

打开浏览器,一个完整的 React 项目已经跑起来了:

  • ✅ React 18 + TypeScript 5
  • ✅ React Router 6
  • ✅ Redux Toolkit(含 RTK Query)
  • ✅ Axios 封装
  • ✅ Vite 5 + HMR
  • ✅ ESLint 9 + Prettier
  • ✅ pnpm monorepo

总耗时:3 分钟


场景 2:企业级 Vue 项目

npx @jserxiao/xcli init my-vue-app \
  -t vue \
  -s scss \
  -b webpack \
  -d

注意这里用了 Webpack 而不是 Vite。

为什么?因为有些企业项目需要:

  • 更细粒度的构建控制
  • 复杂的 loader 配置
  • 特定的优化策略

xcli 的 Webpack 配置包含:

// webpack.config.cjs 节选
module.exports = (env, argv) => {
  return {
    // ...
    module: {
      rules: [
        // Babel:自动读取 .browserslistrc
        {
          test: /\.[jt]sx?$/,
          use: {
            loader: 'babel-loader',
            options: {
              presets: [
                ['@babel/preset-env', {
                  useBuiltIns: 'usage',  // 按需 Polyfill
                  corejs: 3,
                }],
                '@babel/preset-typescript',
              ],
            },
          },
        },
        // CSS + PostCSS
        {
          test: /\.css$/,
          use: [
            MiniCssExtractPlugin.loader,
            'css-loader',
            'postcss-loader',  // Autoprefixer
          ],
        },
      ],
    },
    // 代码分割
    optimization: {
      splitChunks: {
        chunks: 'all',
        cacheGroups: {
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendors',
          },
        },
      },
    },
  };
};

场景 3:开发一个 npm 工具库

npx @jserxiao/xcli init my-utils -t library -d

生成的 Library 项目:

my-utils/
├── src/
│   └── index.ts          # 入口文件
├── dist/                 # ESM + CJS 输出
├── package.json
├── tsconfig.json
├── rollup.config.ts      # Rollup 配置
└── README.md

package.json 自动配置:

{
  "name": "my-utils",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.cjs",
  "module": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "rollup -c",
    "dev": "rollup -c -w"
  }
}

直接发布到 npm:

pnpm build
npm publish

技术细节:xcli 是如何设计的?

插件架构

每个插件都是一个独立的对象:

export const vitePlugin: Plugin = {
  name: 'vite',
  displayName: 'Vite',
  description: '下一代前端构建工具',
  category: 'bundler',
  defaultEnabled: true,
  devDependencies: {
    vite: '^5.0.0',
    '@vitejs/plugin-react': '^4.0.0',
    '@vitejs/plugin-legacy': '^5.0.0',
  },
  scripts: {
    dev: 'vite',
    build: 'vite build',
    preview: 'vite preview',
  },
  files: [
    {
      path: 'vite.config.ts',
      content: (context) => getViteConfig(context),
    },
  ],
};

好处

  • 插件之间完全解耦
  • 可以独立更新某个插件
  • 社区可以自定义插件

模板系统

模板负责生成项目结构:

export const reactTemplate = {
  type: 'react',
  displayName: 'React',
  description: 'React 前端项目 (pnpm monorepo)',

  createStructure: async (projectPath, context) => {
    // 1. 创建目录结构
    // 2. 生成配置文件
    // 3. 生成源代码
    // 4. 根据选项调整(Redux/MobX、Axios/Fetch、Vite/Webpack)
  },

  getDependencies: (styleType, stateManager, httpClient, bundler) => {
    // 根据选择返回对应的依赖
    return {
      dependencies: { ... },
      devDependencies: { ... },
    };
  },
};

版本管理

所有依赖版本统一在 versions.ts 中管理:

export const BUNDLER_VERSIONS = {
  vite: '^5.0.12',
  webpack: '^5.98.0',
  // ...
};

export const FRAMEWORK_VERSIONS = {
  react: '^18.2.0',
  vue: '^3.4.15',
  // ...
};

好处

  • 版本升级只需改一处
  • 确保所有项目使用相同版本
  • 避免版本冲突

对比:xcli vs 其他脚手架

特性 xcli create-react-app Vite 官方模板
TypeScript ✅ 原生支持 ⚠️ 需要 eject ✅ 支持
Monorepo ✅ pnpm workspace ❌ 不支持 ❌ 不支持
状态管理 ✅ 可选 Redux/MobX/Pinia ❌ 无 ❌ 无
HTTP 封装 ✅ 可选 Axios/Fetch ❌ 无 ❌ 无
ESLint 9 ✅ Flat Config ❌ 旧格式 ⚠️ 需手动配
浏览器兼容 ✅ 统一配置 ⚠️ 需手动配 ⚠️ 需手动配
构建工具 ✅ Vite/Webpack ❌ 仅 Webpack ✅ Vite
插件系统 ✅ 可插拔 ❌ 无 ❌ 无

使用建议

个人项目

# 快速启动,默认配置够用
xcli init my-app -t react -d

团队项目

# 明确指定每个选项,确保一致性
xcli init team-project \
  -t react \
  -s scss \
  -m redux \
  -h axios \
  -b vite \
  -d

建议团队制定一份 xcli 使用规范,确保所有项目配置统一。

开源库

xcli init my-lib -t library -d

写在最后

xcli 解决的是一个"小"问题——省去配置的时间。

但它带来的价值是"大"的:

  • 时间节省:从 2 小时到 3 分钟
  • 配置标准化:团队所有项目配置统一
  • 技术债减少:不再需要维护多份配置
  • 新人友好:降低项目启动门槛

如果你也受够了重复配置,不妨试试:

npx @jserxiao/xcli init my-project

附录:常用命令速查

# 创建项目
xcli init my-project
xcli init my-project -t react -d
xcli init my-project -t vue -d
xcli init my-lib -t library -d

# 插件管理
xcli plugin list
xcli plugin add vitest
xcli plugin remove jest

# 升级 CLI
xcli upgrade --check
xcli upgrade

# 查看版本
xcli version

这个工具是我用AI花了三四天写的;没错,文章也是AI写的;有问题或建议,欢迎在评论区交流 💬

【31-Ai-Agent】ai-agent的核心实现细节-bysking

作者 bysking
2026年3月16日 18:13

一、文章目的

帮助学习了解agent的核心原理

二、原理拆解

2.1 解决用户输入&输出的交互

Node.js 中,你可以使用内置的 readline 模块来实现不断读取用户命令行输入并执行不同逻辑的功能。以下是一个完整的实现示例。(当然还可以使用 commander 这个流行的库来实现,咱们就先简单实现)

const readline = require('readline');

// 创建 readline 接口
const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
  prompt: '> ' // 命令提示符
});

// 显示欢迎信息
console.log('欢迎使用命令行交互工具!');
console.log('可用命令:');
console.log('  hello - 显示问候信息');
console.log('  time - 显示当前时间');
console.log('  info - 显示系统信息');
console.log('  exit - 退出程序');
console.log('');

// 开始提示符
rl.prompt();

// 监听用户输入
rl.on('line', (input) => {
  // 去除首尾空白字符
  const command = input.trim().toLowerCase();
  
  // 根据不同命令执行不同逻辑
  switch (command) {
    case 'hello':
      console.log('你好!欢迎使用命令行工具。');
      break;
    case 'time':
      console.log(`当前时间:${new Date().toString()}`);
      break;
    case 'info':
      console.log('系统信息:');
      console.log(`  节点版本: ${process.version}`);
      console.log(`  平台: ${process.platform}`);
      console.log(`  架构: ${process.arch}`);
      break;
    case 'exit':
      console.log('再见!');
      rl.close();
      return; // 退出当前回调
    default:
      console.log(`未知命令: ${command}`);
      console.log('可用命令: hello, time, info, exit');
  }
  
  // 重新显示提示符,等待下一次输入
  console.log('');
  rl.prompt();
});

// 监听关闭事件
rl.on('close', () => {
  console.log('\n程序已退出。');
  process.exit(0);
});

2.2 实现和AI大模型的一问一答交互

这里没什么特别的,就是普通的请求调用逻辑,我们基于deepseek实现一个简单演示代码,如下:


const DEEPSEEK_API_KEY = 'xxxxxx'; // 这里需要替换成你自己的api,一般不要定义在项目里面,有泄漏风险

/**
 * 调用 DeepSeek API 获取模型回复
 * @param messages 对话消息列表,包含 system、user 和 assistant 角色的消息
 * @returns 模型生成的回复文本
 * @throws 如果 API 请求失败或返回格式不正确,将抛出错误
 */
async function callLLMs(messages) {
  const res = await fetch('https://api.deepseek.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${DEEPSEEK_API_KEY}`,
    },
    body: JSON.stringify({
      model: 'deepseek-chat',
      messages,
      temperature: 0.35,
    }),
  });

  if (!res.ok) {
    const text = await res.text();
    throw new Error(`DeepSeek API 错误: ${res.status} ${text}`);
  }

  const data = await res.json();
  const content = data.choices?.[0]?.message?.content;
  if (typeof content !== 'string') {
    throw new Error('DeepSeek 返回内容为空');
  }
  return content;
}


module.exports = { callLLMs };

我们可以编写测试代码来执行一下

const { callLLMs } = require('./agent');

// 测试代码
async function run(input) {
  const messages = [
    { role: 'system', content: '你是一个专业的助手' },
    { role: 'user', content: input },
  ];
  const response = await callLLMs(messages);
  return response;
}

// 测试代码
run('你好').then(console.log);

2.3 增加工具调用

做完上面的两步,那么将他们组合一下,你就能得到一个命令行的AI对话工具,当然,agent不止如此,我们接着往下走,思考一下,我们期望大模型在合适的时间调用我们提供的tools, 当然大模型并不知道我们有哪些工具,所以我们需要告诉大模型,我们有哪些工具

我们可以通过提示词构造来告诉大模型当前有哪些可用的工具列表:

const { callLLMs } = require('./agent');


// 通过系统提示词来告诉大模型角色定位,可用工具列表,工具参数,返回结构等。
const systemPrompt = `
你是天气查询的工具型助手,回答要简洁。

可用工具列表如下(action 的 tool 属性需与下列名称一致):
- getTime: 返回当前 time 字符串,参数为空。
- getWeather: 返回模拟天气信息字符串,参数为 JSON,如 {"city":"上海","time":"2026-02-27 10:00"}。
回复格式(严格使用 XML,小写标签): 对问题的简短思考 工具输入 等待 后再继续思考。 如果已可直接回答,则输出: 最终回答(中文,必要时引用数据来源)

规则:
每次仅调用一个工具;工具输入要尽量具体,根据用户输入,识别意图,如果需要调用工具,必须提供必要的参数,不允许使用大模型的默认工具。
如果拿到 observation 后有了答案,应输出 而不是重复调用。
未知工具时要说明,但仍用 XML 格式。
避免幻觉,不确定时请说明。请用中文回答。
`;

const question = `现在几点`;
const history = [
  { role: 'system', content: systemPrompt },
  { role: 'user', content: question },
];

async function run() {
  const response = await callLLMs(history);
  console.log(response);
}

run();


我们看一下返回的是啥

<think>
用户问现在几点,我需要获取当前时间。我可以使用 getTime 工具来获取当前时间字符串。
</think>
<action>
<tool>getTime</tool>
<input></input>
</action>

我们需要做啥:通过正则解析返回,发现需要进行工具调用,则解析出工具名,参数,然后本地调用得到结果后,再回给大模型作为上下文,继续后面的逻辑

  • 实现解析函数
const parseAssistantResponse = (content) => {
  const parsed = {
    actions: [], // 存储多个 action 的数组
    final: null,
  };

  // 1. 提取所有 <action> 标签
  const actionRegex = /<action>([\s\S]*?)<\/action>/gi;
  let actionMatch;
  while ((actionMatch = actionRegex.exec(content)) !== null) {
    const actionContent = actionMatch[1];

    // 2. 解析当前 action 中的 tool 和 input
    const toolMatch = actionContent.match(/<tool>([\s\S]*?)<\/tool>/i);
    const inputMatch = actionContent.match(/<input>([\s\S]*?)<\/input>/i);

    if (toolMatch) {
      const actionItem = {
        tool: toolMatch[1].trim(),
        input: inputMatch ? inputMatch[1].trim() : '',
      };
      parsed.actions.push(actionItem);
    }
  }

  // 3. 提取 <final> 标签
  const finalMatch = content.match(/<final>([\s\S]*?)<\/final>/i);
  if (finalMatch) {
    parsed.final = finalMatch[1].trim();
  }

  return parsed;
};

我们构造一个大模型的返回结果,进行一个工具解析测试:

  const response = `
    <think>用户问现在几点,我需要获取当前时间。我可以使用 getTime 工具来获取当前时间字符串。</think>
    <action>
      <tool>getTime</tool>
      <input></input>
    </action>

     <action>
      <tool>getTime2</tool>
      <input></input>
    </action>

    <final>当前时间是 10:00。</final>
  `;

  const parseRes = parseAssistantResponse(response);
  console.log(parseRes);

查看打印的测试结果

{
  actions: [ { tool: 'getTime', input: '' }, { tool: 'getTime2', input: '' } ],
  final: '当前时间是 10:00。'
}

那下一步,思路就清晰很多了,我们针对大模型的返回,判断是否已经得到了最终结果(判断条件是:final有值),有结果,直接返回, 如果没有最终结果,说明还是只是中间过程(final没有值),中间过程需要继续解析工具得到结果,然后继续进行大模型处理

  • 接下来,我们就在本地执行大模型解析后需要调用的工具函数,得到返回结果,然后拼接到大模型的输入上下文里面,继续进行用户的提问处理流程。

2.4 增加agent的工具处理循环

上一个步骤我们能注意到,只是处理了单次对话,单次工具调用,实际场景下,我们几乎会遇到多个工具的调用,我们需要一个循环来不断处理这些工具的调用结果。

const { callLLMs } = require('./agent');

const systemPrompt = `
你是天气查询的工具型助手,回答要简洁。

可用工具列表如下(action 的 tool 属性需与下列名称一致):
- getTime: 返回当前 time 字符串,参数为空。
- getWeather: 返回模拟天气信息字符串,参数为 JSON,如 {"city":"上海","time":"2026-02-27 10:00"}。
回复格式(严格使用 XML,小写标签): 时间获取,必须使用本地工具,对问题的简短思考 工具输入 等待 后再继续思考。 
如果已可直接回答,则输出: 最终回答,必须使用xml格式,使用final标签包裹(中文,必要时引用数据来源)

规则:
每次仅调用一个工具;工具输入要尽量具体,根据用户输入,识别意图,如果需要调用工具,必须提供必要的参数,不允许使用大模型的默认工具。
如果拿到 observation 后有了答案,应输出 而不是重复调用。
未知工具时要说明,但仍用 XML 格式。
避免幻觉,不确定时请说明。请用中文回答。
`;

const parseAssistantResponse = (content) => {
  const parsed = {
    actions: [], // 存储多个 action 的数组
    final: null,
  };

  // 1. 提取所有 <action> 标签
  const actionRegex = /<action>([\s\S]*?)<\/action>/gi;
  let actionMatch;
  while ((actionMatch = actionRegex.exec(content)) !== null) {
    const actionContent = actionMatch[1];

    // 2. 解析当前 action 中的 tool 和 input
    const toolMatch = actionContent.match(/<tool>([\s\S]*?)<\/tool>/i);
    const inputMatch = actionContent.match(/<input>([\s\S]*?)<\/input>/i);

    if (toolMatch) {
      const actionItem = {
        tool: toolMatch[1].trim(),
        input: inputMatch ? inputMatch[1].trim() : '',
      };
      parsed.actions.push(actionItem);
    }
  }

  // 3. 提取 <final> 标签
  const finalMatch = content.match(/<final>([\s\S]*?)<\/final>/gi);
  if (finalMatch) {
    parsed.final = finalMatch[0].trim();
  }

  return parsed;
};

const TOOLKIT = {
  getTime: () => '2026-03-16 15:15',
  getWeather: (input) => {
    const { city, time } = JSON.parse(input);
    return `模拟天气信息:${city} ${time} 晴转多云,温度 25°C,湿度 60%`;
  },
};

/**
 * Agent 主循环,负责与 LLM 交互、解析回复、调用工具并更新对话历史
 * @param question
 * @returns 最终回答字符串,或错误提示
 */
async function AgentLoop(question) {
  const maxStep = 10;
  const history = [
    { role: 'system', content: systemPrompt },
    { role: 'user', content: question },
  ];

  for (let step = 0; step < maxStep; step++) {
    const assistantText = await callLLMs(history);
    console.log(`\n[LLM 第 ${step + 1} 轮输出]\n${assistantText}\n`);
    
    history.push({ role: 'assistant', content: assistantText });

    const parsed = parseAssistantResponse(assistantText);

    console.log(parsed, '本轮解析转化结果');

    // 如果已经获得了处理结果,则直接返回结果
    if (parsed.final) {
      return parsed.final;
    }

    // 如果有 action,则调用工具并添加到对话历史中
    if (parsed.actions?.length) {
      const actionName = parsed.actions[0]?.tool;
      const actionParams = parsed.actions[0]?.input;
      const toolFn = TOOLKIT[actionName];
      let observation;

      observation = toolFn ? await toolFn(actionParams) : `未知工具: ${actionName}`;

      history.push({
        role: 'user',
        content: `<observation>${observation}</observation>`,
      });
      continue;
    }

    break; // 未产生 action 或 final
  }

  return '未能生成最终回答,请重试或调整问题。';
}

async function run() {
  const question = `现在几点`;
  const response = await AgentLoop(question);
  console.log(response);
}

// 启动测试
run();

三、下一步Todo

现在,组合上面的代码,监听用户输入输出,调用agent解析循环,就能实现一个迷你版本的获取时间,和天气的agent,后续我们需要进行工程化版本的生产级别的agent应用,还需要更多的封装,和支持网络能力,文件读写能力,上下文token监控和上下文压缩等等 我们还可以使用开源的框架进行项目搭建,比如:ai-sdk.dev/docs/agents… 基础原理,其实这篇文章看完也应该能理解大部分了,剩下的就交给大家自行探索了。

参考:github1s.com/minorcell/m…

里程碑5 - 完成框架 npm 包抽象封装并发布

作者 oo12138
2026年3月16日 18:12

一、目标

将之前elpis框架开发的代码抽象为sdk,将代码进行整合,区分elpis框架和具体业务;最后发布到 npm 上,实现部署

二、代码整合

1. 整合loader加载

在之前完成的代码里面,loader解析的仅是elpis框架内的相关文件;现在对这些loader进行整合,保留之前对框架的解析的同时,添加对业务逻辑的处理,样例代码如下:


    // 获取 elpis框架的 app目录
    app.appBaseDir = path.resolve(__dirname, `..${sep}app`);

    // 读取elpis框架下 app/XXXXXX/**/**.js 下所有的文件
    const XXXXXXPath = path.resolve(app.appBaseDir, `.${sep}XXXXXX`);
    const fileList = glob.sync(
        path.resolve(XXXXXXPath, `.${sep}**${sep}**.js`)
    );
    fileList.forEach(file => handleFile(file));

    let businessControllerPath;
    let businessFileList;
    if (app.businessPath !== app.appBaseDir) {
        // 读取 业务根目录下 app/XXXXXX/**/**.js 下所有的文件
        businessXXXXXXPath = path.resolve(app.businessPath, `.${sep}XXXXXX`);
        businessFileList = glob.sync(path.resolve(businessXXXXXXPath, `.${sep}**${sep}**.js`));
        businessFileList.forEach(file => handleFile(file));
    }

2. DSL整合

将由DSL模板衍生出来的 model 和 projec 挪到业务项目的 /model/文件下;让业务项目自己完成具体配置,elpis框架中只保留模板解析相关代码。

3. 自定义页面扩展

之前在elpis框架中,我们将自定义页面的router定义为 todo,留给业务进行自定义开发,elpis框架中保留共同组件。

  • 自定义页面、侧边栏 router修改

    1. 将elpis框架中 todo 相关的目录和文件删除,
    2. 在业务项目中app/pages/dashboard/XXX 目录下完成XXX页面开发。
    3. 将自定义页面路由和自定义侧边栏路由配置到app/pages/dashboard/router.js 文件中
    4. 在elpis框架的webpack配置中,确保这个配置文件是存在的,并定义路径别名
    5. 在dashboard路由配置中引入业务项目导出的模块,完成路由整合。
  • 自定义动态组件扩展

    1. 在业务项目 app/pages/dashboard/complex-view/schema-view/components 目录下写组件
    2. 配置到 app/pages/dashboard/complex-view/schema-view/components/component-config.js
    3. 在elpis框架的webpack配置中,确保这个配置文件是存在的,并定义路径别名
    4. 在elpis框架的 component-config.js 文件中,引入配置,整合后导出;配置项重复时,允许业务项目配置覆盖elpis框架配置
  • 引用业务中的自定义FormItem

    1. 在业务项目 app/pages/widgets/schema-form/complex-view 目录下写控件
    2. 配置到 app/pages/widgets/schema-form/form-item-config.js
    3. 在elpis框架的webpack配置中,确保这个配置文件是存在的,并定义路径别名
    4. 在elpis框架的 form-item-config.js 文件中,引入配置,整合后导出;配置项重复时,允许业务项目配置覆盖elpis框架配置
  • 引用业务中的自定义SearchItem

    1. 在业务项目 app/pages/widgets/schema-search-bar/complex-view 目录下写控件
    2. 配置到 app/pages/widgets/schema-search-bar/search-item-config.js
    3. 在elpis框架的webpack配置中,确保这个配置文件是存在的,并定义路径别名
    4. 在elpis框架的 search-item-config.js 文件中,引入配置,整合后导出;配置项重复时,允许业务项目配置覆盖elpis框架配置

三、实现 npm 部署

  1. 提交前确认package.json中的 name 和 version,多次提交记得修改版本号
  2. 部署前使用命令 npm config get registry,确认指向 https://registry.npmjs.org/,如果指向别的地方,输入命令 npm config set registry 清空源
  3. 执行npm login进行登录,登陆后可以使用 npm whoami 查询
  4. 第一次提交可能会需要使用命令 npm publish --access public,告知npm我们的包为共有包,后续使用 npm publish 命令就行

四、整合过程中遇到的小问题

1. 修改config-loader的时候,不管我怎么切换环境验证,返回的始终是demo项目中default.cinfog配置的值

刚开始我demo项目的package.json中设置的启动命令是"dev": "set _ENV = 'local' && nodemon ./server.js"

后来发现按照windows cmd里set的语法:= 两面是不能有空格的、且变量值不需要引号;我配置的启动命令set _ENV = 'local'实际上相当于变量名: "_ENV ",变量值: " 'local'"

我将启动命令按照规则修改为"dev": "set _ENV=local && nodemon ./server.js",发现process.env._ENV可以成功获取的值,但是返回的还是default.cinfog中的值

打印出来之后发现,package.json中启动命令set _ENV=local,在windows解析set的时候,被解析成 _ENV="local ",后面有一个空格。。。

在判断环境的地方增加trim()之后可以正常取得环境值。

2. 在prod构建和启动的时候,浏览器中提示Uncaught Error: [HMR] Hot Module Replacement is disabled.GET http://127.0.0.1:9002/__webpack_hmr net::ERR_CONNECTION_REFUSED

这个很让人感到意外,因为我们webpack的配置是只有dev环境启动的时候才会热更新。

刚开始以为是在执行完build:dev后,没有把public删除导致的,后续删了之后重新执行build:prod后,启动prod环境发现还是提示这个错误。

根据浏览器控制台中错误提示信息,发现是在vandor包里面打入了hmr相关的内容;而浏览器发现我们的bundle文件中有webpack-hot-middleware,就会自动连接,但是我们的server没有启HMR,就会发生这个错误

经过查找发现是在webpack.dev.js的代码中向 entry 配置加入 hmr 的时候,修改了 baseConfig.entry,导致 HMR client 被打进 vendor,并污染了生产环境的构建

修改方案:配置中加入hmr的时候,不修改baseConfig.entry,而是将值给到新的devEntry,完成之后在webpackConfig中配置 entry: devEntry,代码参考:

  const devEntry = {};
  // 开发环境的 entry 配置需要加入 hmr
  Object.keys(baseConfig.entry).forEach(v => {
      // 第三方包不作为hmr入口
      if (v !== "vendor") {
          devEntry[v] = [
              baseConfig.entry[v],
              `${require.resolve('webpack-hot-middleware/client')}?path=http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/${DEV_SERVER_CONFIG.HMR_PATH}&timeout=${DEV_SERVER_CONFIG.TIMEOUT}`
          ];
      } else {
          devEntry[v] = baseConfig.entry[v];
      }
  });

3. 在elpis框架的alias中配置'vue': require.resolve("vue"),而不是在新的业务代码里面npm i vue

这让人很疑惑,为什么不是在业务代码里面npm i vue下载Vue资源,而是引用elpis框架中的Vue?难道不应该是elpis框架包中不包含vue,然后在新的业务代码里面npm i vue吗?

实际上这两种方法应该都是可以的,只是我们之前使用npm link进行整合和拆分,为了避免出现多个vue实例,才在elpis框架的alias中配置'vue': require.resolve("vue"),是为了强制所有地方只使用同一个vue。

不然的话,elpis自己依赖一个vue,业务项目又进行了npm i vue,最终会导致node_modules中的层级可能会是这样的:

node_modules
 ├ vue (业务项目安装)
 └ elpis
     └ node_modules
         └ vue (elpis 自己安装)

此时项目中会存在两个vue实例,这样会导致provide/inject失效vue.component注册组件找不到Vue.use(plugin)失效;造成这些问题的原理是:

项目里面存在两个vue构造函数实例时,业务代码的Vue1 !== 内部组件的Vue2

vue.component全局注册组件时挂在vue构造函数上,如果组件是在vue1全局注册Vue1.component(...),而组件是在vue2进行创建Vue2.extend(…),vue在渲染时检查 component instanceof Vue1结果是false,vue会认为组件不存在

provide/inject的实现依赖Vue prototype chain. Vue1.prototype !== Vue2.prototype,所以inject找不到provide

插件安装Vue.use(plugin)实际上是plugin.install(Vue),如果plugin install在Vue1,但是组件运行在Vue2,插件逻辑根本不会生效

后续修改完成后可以将elpis 改为只引用、不打包,由业务项目安装Vue;实现最终运行时全局只有一个Vue,下面是具体实现方式(验证中):

  1. package.json中下载的vue要放在 peerDependencies 里,而不是dependencies里;要注意的是要写清楚版本范围,如果 elpis用的是 vue 2,项目安装的是 vue 3,可能会直接炸掉。
    {
        "peerDependencies": {
            "vue": ^3.3.4
        }
    }
  1. 在elpis的webpack配置中添加 externals,含义是当代码里面写import Vue from "vue"时,不打包,而是从运行环境获取,具体配置为:

    // 简化版
    {
        externals: {
            vue: "vue"
        }
    }
    
    // 兼容不同环境版
    {
        externals: {
            vue: {
                commonjs: "vue",
                commonjs2: "vue",
                amd: "vue",
                root: "vue",
            }
        }
    }
  1. 在业务项目中进行 npm install vue,这样配置的话目录结构会变成:

    elpis-demo
        ├ node_modules
        │   ├ vue
        │   └ @togurodi/elpis
    

「前端何去何从」(React教程)React 状态管理:从局部 State 到可扩展架构

作者 从文处安
2026年3月16日 18:05

React 状态管理:从局部 State 到可扩展应用

当 React 应用开始变复杂,真正决定代码质量的,往往不是组件写法,而是状态设计

状态管理是 React 真正开始变难的地方

在前两篇里,我们已经知道:

  • 组件会根据 props 和 state 渲染 UI
  • 用户操作会触发事件
  • 调用 setState 会让 React 重新渲染

但学到这里,很多人会开始遇到一个新的问题:代码明明能跑,为什么一复杂就开始乱?

真实项目里更常见的问题是:

  • 一个界面到底该定义哪些 state?
  • 多个组件需要同步时,状态放哪一层?
  • 为什么切换页面后,有的输入框内容还在,有的又被重置了?
  • 状态逻辑越来越复杂时,什么时候该用 useReducer
  • 跨很多层传数据时,什么时候该用 Context?

这些问题表面上看是在问 API,实际上问的是另一件事:

你的状态是不是设计清楚了。

React 官方文档把这一章叫做“状态管理”,我觉得非常准确。因为从这一章开始,重点已经不是“怎么写一个交互”,而是“怎么让交互在复杂度上升后仍然清晰、可维护”。

本文会覆盖什么

本文对应 React 官方文档“状态管理”章节及其子章节,按学习顺序讲解:

  • 用 State 响应输入
  • 选择 State 结构
  • 在组件间共享状态
  • 对 state 进行保留和重置
  • 迁移状态逻辑至 Reducer 中
  • 使用 Context 深层传递参数
  • 使用 Reducer 和 Context 拓展你的应用

学完之后你应该能做到什么

如果你认真跟着本文走完,应该能掌握这些能力:

  • 能把一个交互拆成几个明确的界面状态
  • 能判断哪些数据应该放进 state,哪些不该放
  • 能在兄弟组件之间正确共享状态
  • 能控制组件 state 什么时候保留、什么时候重置
  • 能把复杂的更新逻辑迁移到 reducer
  • 能用 context 避免层层传 props

这篇文章的目标不是让你记住几个 API 名字,而是让你建立一套更稳定的状态思维。


Part 1: 用 State 响应输入

不要一上来就写事件处理函数

React 官方文档在这一节强调的重点是:

先把界面看成一组状态,再去写组件。

很多初学者写交互时,习惯直接想:

  • 点击后把按钮禁用
  • 请求回来后显示成功提示
  • 出错时显示错误文案

这种写法的问题是,你是在“命令式地改界面”。界面稍微复杂一点,就会越来越乱。

React 更适合用另一种方式思考:

组件会处于哪些状态?每个状态应该显示什么 UI?哪些事件会让它切换到下一个状态?

这听起来有点抽象,但一旦你习惯了这种思路,很多交互代码会自然变简单。

第一步:列出组件的所有状态

以一个问答表单为例,你可以先列出这些状态:

  • 输入中
  • 提交中
  • 提交成功
  • 提交失败

这一步非常重要。很多 bug 的源头,不是代码写错,而是你一开始就漏掉了某个状态。

第二步:为状态选择合适的数据结构

来看一个典型实现:

import { useState } from 'react';

export default function QuizForm() {
  const [answer, setAnswer] = useState('');
  const [error, setError] = useState(null);
  const [status, setStatus] = useState('typing');

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('submitting');
    setError(null);

    try {
      await submitForm(answer);
      setStatus('success');
    } catch (err) {
      setError(err);
      setStatus('typing');
    }
  }

  if (status === 'success') {
    return <h1>答对了!</h1>;
  }

  return (
    <form onSubmit={handleSubmit}>
      <textarea
        value={answer}
        onChange={e => setAnswer(e.target.value)}
        disabled={status === 'submitting'}
      />
      <br />
      <button disabled={answer.length === 0 || status === 'submitting'}>
        提交
      </button>
      {error !== null && <p className="error">{error.message}</p>}
    </form>
  );
}

这里有三个 state:

  • answer:用户输入的答案
  • error:提交失败后的错误信息
  • status:当前界面所处状态

这三个值已经足够描述整个交互,而且没有多余信息。

为什么 status 比多个布尔值更好

很多人一开始会这样写:

const [isTyping, setIsTyping] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);

这样的问题是,很容易出现互相矛盾的状态,比如:

  • isSubmitting === true
  • isSuccess === true

这通常不是你想要的结果。

更好的方式是用一个 status 表示互斥状态:

const [status, setStatus] = useState('typing');

这样状态更清晰,也更不容易进入“不可能状态”。

第三步:找出触发状态变化的事件

状态变化通常来自两类来源:

  • 用户事件:输入、点击、提交
  • 异步结果:请求成功、请求失败

以上面那个表单为例:

  • onChange 会更新 answer
  • onSubmit 会把 status 改成 submitting
  • 请求成功后,把 status 改成 success
  • 请求失败后,更新 error,并把 status 改回 typing

这样一来,交互的流转路径就很清楚了。你不是在零散地改 UI,而是在管理状态切换。

这一节最应该记住什么

写交互时,先做这 3 件事:

  1. 列出所有可见状态
  2. 选出最少的 state 来表示它们
  3. 明确每个事件会把状态切换到哪里

如果你把这套顺序养成习惯,后面设计复杂界面会轻松很多。


Part 2: 选择 State 结构

State 结构设计得好,后面的代码会轻松很多;设计得差,后面每加一个功能都像在补漏洞。

React 官方文档在这一节给了几条非常重要的原则。这里我按教程方式,一条一条讲清楚。

如果你只记一句话,那就是:

state 应该尽可能少,但又足够表达当前界面。

原则 1:如果总是一起变化,就考虑合并

比如鼠标位置:

const [x, setX] = useState(0);
const [y, setY] = useState(0);

这能工作,但如果你每次更新时都要同时改 xy,更自然的写法是:

const [position, setPosition] = useState({ x: 0, y: 0 });

这样更适合表达“它们本来就是同一件事”。

不过也别为了“整洁”把所有状态都塞进一个对象。没有关系的 state,拆开反而更清楚。React 没有要求你一定用对象或一定拆开,关键看它们是不是同一个概念。

原则 2:避免矛盾的 state

看这个例子:

const [isSending, setIsSending] = useState(false);
const [isSent, setIsSent] = useState(false);

这两个 state 如果维护不好,就可能互相矛盾。

更好的写法通常是:

const [status, setStatus] = useState('typing');

用一个字段表达互斥状态,通常比多个布尔值更稳定。这是状态建模里非常常用的技巧。

原则 3:能计算出来的值,不要放进 state

这是最常见的错误之一。

// 不推荐
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');

这里的 fullName 完全可以由前两个值计算出来,不需要再单独存一份。

// 推荐
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');

const fullName = firstName + ' ' + lastName;

为什么不建议把 fullName 再存一份?

因为一旦存了两份数据,你就要保证它们一直同步。同步一旦漏掉,UI 就会出错。

这个问题在表单里尤其常见。我的建议是:能算出来的值,优先算,不要存。

原则 4:不要重复存同一份数据

假设你有一个商品列表,并且支持选中某一项:

const [items, setItems] = useState(initialItems);
const [selectedItem, setSelectedItem] = useState(items[0]);

这看起来很方便,但会产生同步问题。比如 items 更新了,selectedItem 可能还是旧对象。

更推荐的写法是只存 selectedId

const [items, setItems] = useState(initialItems);
const [selectedId, setSelectedId] = useState(0);

const selectedItem = items.find(item => item.id === selectedId);

这是一个很实用的经验,也是很多项目里常见的重构方向:

尽量存 ID,而不是存对象副本。

这样做的好处是,真正的数据源只有一份。后面列表更新、排序、过滤时,也不容易出现“选中的还是旧对象”这种问题。

原则 5:避免深度嵌套 state

如果 state 嵌套太深,更新会变得非常麻烦:

setPlan({
  ...plan,
  childPlaces: {
    ...plan.childPlaces,
    42: {
      ...plan.childPlaces[42],
      title: 'New title',
    },
  },
});

遇到这种情况时,优先考虑:

  • 扁平化数据结构
  • 用 ID 建立关系
  • 把局部状态拆到更小的组件里

一个简单的判断方法

写完一个 state 结构后,你可以问自己:

  • 这个值真的需要“记住”吗?
  • 它能不能从别的值算出来?
  • 它会不会和别的 state 打架?
  • 同一份数据是不是存了两次?

只要这里面有一两项答“是”,就说明 state 结构值得重审。

我在项目里看过很多“状态管理很乱”的组件,问题通常不在 React,而在这里一开始就没设计好。


Part 3: 在组件间共享状态

问题:兄弟组件各自有 state,但业务要求同步

这是 React 里非常经典的一类问题。

先看一个常见场景:手风琴组件。

如果每个 Panel 都自己维护是否展开:

function Panel({ title, children }) {
  const [isActive, setIsActive] = useState(false);

  return (
    <section>
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button onClick={() => setIsActive(true)}>显示</button>
      )}
    </section>
  );
}

这没问题,但它只能保证“每个面板自己能展开”。

如果需求变成:

同一时间只允许展开一个面板

那就不能让它们各自管理自己的 isActive 了。

解法:状态提升

React 的标准解法非常直接:把共享状态提升到最近的共同父组件。

import { useState } from 'react';

function Panel({ title, children, isActive, onShow }) {
  return (
    <section>
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button onClick={onShow}>显示</button>
      )}
    </section>
  );
}

export default function Accordion() {
  const [activeIndex, setActiveIndex] = useState(0);

  return (
    <>
      <Panel
        title="关于"
        isActive={activeIndex === 0}
        onShow={() => setActiveIndex(0)}
      >
        React 是一个用于构建用户界面的库。
      </Panel>
      <Panel
        title="词源"
        isActive={activeIndex === 1}
        onShow={() => setActiveIndex(1)}
      >
        “React” 这个名字强调界面对状态变化的响应。
      </Panel>
    </>
  );
}

这里发生了三件事:

  • Panel 自己不再保存 isActive
  • 父组件保存共享状态 activeIndex
  • 父组件通过 props 把值和事件处理函数传给子组件

这就是 React 文档里反复强调的单向数据流。状态放在父组件,值往下传,事件往上传。

什么时候该提升状态

当你发现两个或多个组件:

  • 需要显示同一份数据
  • 需要对同一件事达成一致
  • 一个组件更新后,另一个组件也要跟着变

那就应该考虑把状态提到它们最近的共同父级。

这个“最近”很重要。放得太低,没法共享;放得太高,顶层组件会被很多无关状态塞满。

所以“状态提升”不是把所有状态都往上丢,而是找到一个刚好能覆盖使用范围的父组件。

受控组件和非受控组件

在上面的例子里,Panel 的显示与否完全由父组件控制,这种组件叫受控组件

如果一个组件把状态完全保存在自己内部,父组件无法控制它,它就更接近非受控组件

这两个概念你不用死记定义,记住下面这个判断就够了:

  • 状态由父组件传入并控制:更偏受控
  • 状态封装在组件内部:更偏非受控

实际开发里,只要状态需要多个组件协作,通常就要往受控方向走。


Part 4: 对 State 进行保留和重置

这一节是 React 状态管理里最容易让人困惑的一节,但也非常重要。

React 什么时候会保留 state

React 会根据组件在渲染树中的位置来判断要不要保留 state。

你可以先记住一个最实用的结论:

同一种组件,出现在同一个位置,React 通常会保留它的 state。

看这个例子:

function App() {
  const [isFancy, setIsFancy] = useState(false);

  return (
    <div>
      {isFancy ? <Counter isFancy={true} /> : <Counter isFancy={false} />}
      <label>
        <input
          type="checkbox"
          checked={isFancy}
          onChange={e => setIsFancy(e.target.checked)}
        />
        使用 fancy 样式
      </label>
    </div>
  );
}

虽然 JSX 写了两个分支,但对 React 来说,这个位置渲染的始终都是 Counter。所以它会保留 Counter 的 state。

这也是为什么很多人会觉得 React 的 state “有点反直觉”。你看到的是两段 JSX,React 看到的是同一个位置上的同一种组件。

理解这点之后,很多“为什么这里没清空”“为什么这里还记着上一次输入”的问题,都会一下子变得很好解释。

React 什么时候会重置 state

如果某个位置上渲染的不再是同一种组件,React 就会重置 state。

例如:

{isPaused ? <p>See you later!</p> : <Counter />}

这里在同一个位置上,一会儿是 p,一会儿是 Counter。类型变了,所以 state 不会保留。

React 官方文档在这里讲得很清楚:state 不是“挂在 JSX 标签上”的,而是 React 根据渲染树位置保存的。位置和类型都变了,之前那份 state 自然就没了。

key 不只给列表用

很多人只知道列表渲染时要写 key,但 key 还有一个非常重要的用途:

告诉 React,这是两个不同身份的组件。

比如聊天窗口:

<Chat key={to.id} contact={to} />

如果你不给 key,切换聊天对象时,输入框里的内容可能会被保留下来。

如果你加上 key={to.id},React 会把不同联系人对应的 Chat 当成不同组件实例处理,输入框 state 就会重置。

所以,key 的本质不是“列表专用语法”,而是“身份标识”。

这点在表单、聊天窗口、切换用户视图这类场景里特别有用。

一个容易踩坑的错误:在组件内部定义组件

看下面的代码:

export default function App() {
  function MyTextField() {
    const [text, setText] = useState('');
    return <input value={text} onChange={e => setText(e.target.value)} />;
  }

  return <MyTextField />;
}

这种写法会让组件在每次渲染时都重新定义,React 可能把它当成一个新的组件,导致 state 被重置。

正确做法是把组件定义放在顶层。

这个问题前两篇其实也提到过。组件定义放在顶层,不只是为了代码风格,更是为了让 React 能稳定识别组件身份。

这一节最重要的结论

你可以把这节压缩成这 3 句:

  • state 跟组件在树中的位置有关
  • 类型相同、位置相同,state 通常会保留
  • 想强制重置,就改变组件身份,比如使用不同的 key

Part 5: 迁移状态逻辑至 Reducer 中

什么时候应该考虑 useReducer

一个组件只有一两个简单 state 时,useState 很好用。

但如果你开始遇到这些情况,就该考虑 reducer:

  • 同一个 state 会被很多事件处理函数修改
  • 更新逻辑越来越长
  • 组件里有很多 setXxx(...)
  • 你越来越难看清“某个操作到底改了什么”

这时候的问题通常不是“React 不够用”,而是“更新逻辑已经散了”。

我的经验是,只要你开始频繁在不同函数里改同一份 state,就该警觉了。因为这往往意味着后面会越来越难维护。

先看 useState 写法

以任务列表为例:

const [tasks, setTasks] = useState(initialTasks);

function handleAddTask(text) {
  setTasks([
    ...tasks,
    { id: nextId++, text, done: false },
  ]);
}

function handleChangeTask(task) {
  setTasks(tasks.map(t => (
    t.id === task.id ? task : t
  )));
}

function handleDeleteTask(taskId) {
  setTasks(tasks.filter(t => t.id !== taskId));
}

这没有错,但随着操作变多,逻辑会越来越分散。

把“怎么更新”收拢到 reducer

使用 reducer 后,事件处理函数只负责派发 action,也就是描述“发生了什么”:

function handleAddTask(text) {
  dispatch({
    type: 'added',
    id: nextId++,
    text,
  });
}

function handleChangeTask(task) {
  dispatch({
    type: 'changed',
    task,
  });
}

function handleDeleteTask(taskId) {
  dispatch({
    type: 'deleted',
    id: taskId,
  });
}

真正的更新逻辑集中写在 reducer:

function tasksReducer(tasks, action) {
  switch (action.type) {
    case 'added':
      return [
        ...tasks,
        {
          id: action.id,
          text: action.text,
          done: false,
        },
      ];
    case 'changed':
      return tasks.map(task =>
        task.id === action.task.id ? action.task : task
      );
    case 'deleted':
      return tasks.filter(task => task.id !== action.id);
    default:
      throw Error('Unknown action: ' + action.type);
  }
}

组件中这样使用:

import { useReducer } from 'react';

const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

React 官方文档把这个迁移过程拆成了三个步骤:

  1. 把设置 state 的逻辑改写成 dispatch action
  2. 编写 reducer 函数
  3. 在组件中用 useReducer

这个拆分很实用,因为它告诉你 reducer 不是“推倒重来”,而是一步一步迁移过去。

Reducer 的好处是什么

用了 reducer 之后:

  • 事件处理函数变短了
  • 更新逻辑集中到一个地方
  • 每个 action 都在描述“发生了什么”
  • reducer 是纯函数,更容易测试

我觉得 reducer 最大的价值,不是“更高级”,而是把状态更新逻辑从组件里剥出来。组件负责交互,reducer 负责状态变化,这样职责会清楚很多。

写 reducer 时的两个要求

1. reducer 必须是纯函数

不要在 reducer 里:

  • 发请求
  • 改外部变量
  • 直接修改原对象或原数组

它只应该根据 state + action 返回新的 state。

2. action 要表达业务动作

好的 action 例子:

dispatch({ type: 'deleted', id: 3 });
dispatch({ type: 'changed', task });

它们表达的是“用户做了什么”,而不是“我要调用哪个 setter”。

useStateuseReducer 怎么选

可以这样简单判断:

  • 简单组件:优先 useState
  • 更新逻辑复杂、操作很多:考虑 useReducer

不要为了“高级”而用 reducer。它的价值在于整理复杂逻辑,不在于替代所有 useState


Part 6: 使用 Context 深层传递参数

Context 解决什么问题

假设你有这样一棵组件树:

<Page>
  <Layout>
    <Sidebar />
    <Content>
      <Profile />
    </Content>
  </Layout>
</Page>

如果 Profile 需要当前登录用户,而这个用户数据在 Page 里,你可能会这样传:

<Layout user={user} />
<Content user={user} />
<Profile user={user} />

这就叫 prop drilling,也就是逐层透传 props。

如果中间组件根本不关心这个数据,只是被迫传下去,代码会越来越烦。

Context 就是为了解决这个问题。

你可以把它理解成一种“跨中间层传值”的机制。

React 官方文档这里有一句话我很认同:如果数据可以“不经过 props 直达需要它的组件”,很多中间层组件就能干净很多。

Context 的基本三步

第一步:创建 context
import { createContext } from 'react';

export const LevelContext = createContext(1);
第二步:提供 context
import { LevelContext } from './LevelContext.js';

function Section({ level, children }) {
  return (
    <LevelContext value={level}>
      {children}
    </LevelContext>
  );
}
第三步:读取 context
import { useContext } from 'react';
import { LevelContext } from './LevelContext.js';

function Heading({ children }) {
  const level = useContext(LevelContext);

  switch (level) {
    case 1:
      return <h1>{children}</h1>;
    case 2:
      return <h2>{children}</h2>;
    case 3:
      return <h3>{children}</h3>;
    default:
      return <p>{children}</p>;
  }
}

这样,Heading 不需要一级一级接收 level,它会直接从最近的 provider 读取。

这个例子看起来简单,但它很好地说明了 Context 最适合做什么:提供一个“周围环境”。像标题层级、主题、语言、当前用户,都属于这类信息。

Context 的特点

你需要记住两点:

  • useContext(SomeContext) 会读取最近的 provider 提供的值
  • 如果上层没有 provider,就使用 createContext 时传入的默认值

什么情况下该用 Context

比较适合 Context 的数据:

  • 主题
  • 当前用户
  • 语言环境
  • 路由信息
  • 某个功能域内多层组件都要读取的状态

什么情况下不要急着用 Context

如果只是传一两层,props 往往更直接。

因为 props 的依赖关系是显式的,读代码时很容易看懂。而 Context 一旦用多了,数据来源会变得不够直观。

所以一个很实用的建议是:

先问自己,这个值是真的“很多层都要用”吗?如果不是,就先用 props。

React 官方文档还提醒了另一种替代思路:有时候你不需要传某个具体 prop,而是可以把 JSX 作为 children 往下传。这样也能减少中间层的负担。


Part 7: 使用 Reducer 和 Context 拓展你的应用

到了这里,你已经学了两件事:

  • useReducer 可以整理复杂的状态更新逻辑
  • Context 可以避免层层传递 props

把它们结合起来,就是 React 原生组织复杂状态的一种常见方式。

如果说 reducer 解决的是“逻辑分散”,那 Context 解决的就是“传递太深”。

一个典型问题

任务列表状态在顶层:

const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

接下来你会发现:

  • TaskList 需要读 tasks
  • AddTask 需要用 dispatch
  • Task 也需要用 dispatch

如果继续用 props 层层往下传,组件树很快会变得臃肿。

官方文档的常见做法

statedispatch 放进两个不同的 context。

import { createContext } from 'react';

export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);

然后统一提供:

import { useReducer } from 'react';

function TaskApp() {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

  return (
    <TasksContext value={tasks}>
      <TasksDispatchContext value={dispatch}>
        <h1>任务列表</h1>
        <AddTask />
        <TaskList />
      </TasksDispatchContext>
    </TasksContext>
  );
}

深层组件直接读取:

import { useContext } from 'react';

function TaskList() {
  const tasks = useContext(TasksContext);

  return (
    <ul>
      {tasks.map(task => (
        <li key={task.id}>{task.text}</li>
      ))}
    </ul>
  );
}
function AddTask() {
  const dispatch = useContext(TasksDispatchContext);

  function handleAdd(text) {
    dispatch({
      type: 'added',
      id: nextId++,
      text,
    });
  }

  // ...
}

为什么分成两个 context

这是一个很常见的组织方式,因为它把:

  • 读取状态
  • 触发更新

分开了。

这样做的一个好处是,读代码时你会更容易分清:哪些组件只是消费数据,哪些组件还会发起更新。

React 官方文档在后面还进一步把这部分封装成 useTasksuseTasksDispatch。这个思路很好,因为它能把“从哪里取数据”也一起隐藏起来,让业务组件更干净。

后面如果你想封装自定义 Hook,也会更清楚:

function useTasks() {
  return useContext(TasksContext);
}

function useTasksDispatch() {
  return useContext(TasksDispatchContext);
}

什么时候适合这样做

Reducer + Context 比较适合这些场景:

  • 某个功能模块已经有一定复杂度
  • 多层组件都要读写同一份状态
  • 你还不想引入额外状态管理库

它不是所有项目都必须上,但它是 React 原生能力里非常值得掌握的一种模式。

如果项目规模还小,直接 props 或局部 state 完全没问题。只有当一个功能域已经明显变复杂时,Reducer + Context 才会真正体现价值。


学习路径与实践建议

到这里,你可以把本章内容串成一条完整的学习链路:

  1. 先学会把 UI 拆成几个状态
  2. 再学会只保存必要的 state
  3. 接着学会把共享状态提升到父组件
  4. 再理解 React 为什么会保留或重置 state
  5. 状态逻辑复杂后,用 reducer 收拢更新
  6. 组件层级太深后,用 context 传递状态或 dispatch

最常见的几个错误

如果你刚开始练习状态管理,最容易犯这些错:

  • 把能计算出来的值也放进 state
  • 用多个布尔值描述互斥状态
  • 兄弟组件各自维护本该共享的数据
  • 不理解 key,导致 state 该重置时没重置
  • 过早使用 Context,让数据流变复杂

你可以把这几个问题当成自己的排错清单。

如果你发现某个组件越来越难懂,通常可以反过来检查:

  • state 是不是存多了
  • 谁拥有这份 state 是不是不清楚
  • 更新逻辑是不是散在太多地方
  • 组件身份是不是不稳定

很多所谓“React 状态管理问题”,最后都能落回这几个基本点。


总结

这一章最核心的不是 API,而是状态思维。

  • 用 State 响应输入:先定义状态,再写 UI
  • 选择 State 结构:只保存必要且不冲突的数据
  • 共享状态:把共享数据放到最近的共同父级
  • 保留和重置:理解位置、类型和 key
  • Reducer:把复杂的更新逻辑集中起来
  • Context:减少层层传递 props 的成本
  • Reducer + Context:在 React 原生能力内组织更复杂的应用

如果你能把这一章真正吃透,后面再学表单、路由、全局状态管理库,都会顺很多。

如果说前两篇解决的是“React 是怎么渲染 UI 和响应交互的”,那这一篇解决的就是另一件更实际的事:

当你的应用不再只是几个按钮和输入框时,状态应该怎么组织,代码才不会失控。


相关资源


本文基于 React 官方文档 “状态管理” 章节。

React Scheduler & Lane 详解

作者 Wect
2026年3月16日 18:01

一、核心前置概念

在讲解Scheduler和Lane之前,先明确3个面试常考的基础概念,帮你快速理解二者的作用场景:

1. Lane:优先级的抽象(通俗版+专业版)

通俗解释:把React中的每一种更新优先级,想象成一条“车道”——高优先级更新走“快车道”,能插队;低优先级更新走“慢车道”,会被快车道的车辆(高优先级更新)打断,这样就能精准区分不同更新的执行顺序,避免混乱。

专业定义:Lane是React用于精细化管理更新优先级的核心机制,替代了早期的过期时间模型,本质是用31位整数的每一位(位掩码)代表一条“车道”,每一条车道对应一种更新优先级,通过高效位运算实现更新的合并、筛选与优先级判断,是React并发更新的基石。

2. 同步更新 vs 并发更新(面试高频区分)

同步更新:更新任务一旦开始,就会从头到尾执行完毕,期间会阻塞主线程(比如长时间渲染列表时,用户点击按钮没反应),React18之前默认是同步更新。

并发更新:React18引入的核心特性,允许高优先级更新打断低优先级更新,任务可以“暂停、恢复、中断”,不会阻塞主线程,能同时处理多个不同优先级的更新(比如一边渲染列表,一边响应用户输入),而Scheduler和Lane正是支撑并发更新的核心。

3. 时间切片(Time Slicing,Scheduler的核心能力)

通俗解释:浏览器每16.6ms会刷新一帧(保证页面不卡顿),时间切片就是把长任务“切”成一个个≤16.6ms的小任务,每执行一个小任务,就检查一下浏览器是否需要渲染,若需要就暂停任务、让出主线程,避免卡顿。

专业定义:React Scheduler实现的一种任务调度机制,默认切片时长约16.6ms(对应60fps),通过shouldYieldToHost方法判断是否中断当前任务,确保任务执行不阻塞浏览器的渲染和用户交互。

二、Scheduler(调度器)详解(面试核心)

1. 核心定义(必背)

React Scheduler 是 React 并发模式的核心模块,本质是“任务管理者”,负责协调 React 中所有更新任务的优先级、执行时机,调控任务队列,避免重型任务阻塞主线程(如用户交互、渲染),确保应用流畅响应,核心目标是“推进任务,且不阻塞浏览器”。

2. 核心功能(详细且简洁,面试重点背诵)

记住:Scheduler的核心就是“管优先级、管队列、管执行”,4个核心功能如下,结合通俗解释更好记:

  • 优先级调度(核心中的核心):定义5种核心优先级(从高到低),精准匹配不同场景,优先执行高优先级任务,同时避免低优先级任务“饥饿”(长期不执行)。

    通俗补充:就像医院挂号,急诊(高优先级)优先看病,普通门诊(低优先级)排队,但不会让普通门诊病人一直等不到(过期机制)。

    专业细节:5种优先级对应场景+超时时间(面试可简要提及):

    • ImmediatePriority(立即执行):对应SyncLane,超时时间0ms(比如用户点击按钮的同步更新);
    • UserBlockingPriority(用户阻塞):对应输入/滚动,超时时间250ms(比如打字、拖拽,必须快速响应);
    • NormalPriority(普通优先级):普通setState更新默认,超时时间5000ms;
    • LowPriority(低优先级):对应过渡更新,超时时间10000ms(比如useTransition包裹的非紧急更新);
    • IdlePriority(空闲优先级):空闲时执行,超时时间无限(比如后台日志、预加载)。
  • 任务队列管理:维护两个最小堆结构的队列,高效管理任务(堆结构能快速找到最高优先级任务):

    • taskQueue(立即可执行任务):按任务过期时间排序,堆顶是最先过期、优先级最高的任务;
    • timerQueue(延迟执行任务):按任务开始时间排序,比如延迟执行的后台任务,到期后移至taskQueue。
  • 时间切片与中断:实现时间切片(默认≤16.6ms),通过shouldYieldToHost方法判断是否中断任务——若任务执行超时,或浏览器需要渲染、处理用户交互,立即让出主线程,待浏览器空闲后再恢复执行,避免卡顿。 通俗补充:就像写作业,每写20分钟(对应16.6ms),就检查一下有没有人叫你(浏览器是否需要工作),有就暂停,没人叫就继续写。

  • 任务调度循环:通过workLoop(工作循环)推进任务,核心流程:① 先将timerQueue中到期的任务移至taskQueue;② 从taskQueue中取出堆顶的最高优先级任务执行;③ 若任务未执行完毕(返回回调函数),则放回taskQueue,等待下一轮调度;④ 重复以上步骤,直到队列清空。

三、Lane(车道模型)详解(面试核心)

1. 核心定义(必背)

Lane 是 React 用于精细化管理更新优先级的机制,替代了早期的过期时间模型,核心是用31位整数的每一位(位掩码)代表一条“车道”,每一条车道对应一种更新优先级,通过高效位运算(按位与、按位或)实现更新的合并、筛选与优先级判断,适配并发模式下的中断与恢复需求,是 React 并发更新的基石。

通俗补充:31位整数就像31条并行的车道,每条车道跑一种优先级的更新,位运算就是“快速判断哪条车道有车、能不能合并车道、哪条车道的车优先级最高”,比传统的队列遍历高效得多(O(1)复杂度)。

2. 核心功能(详细且简洁,面试重点背诵)

  • 优先级精细化划分:将更新划分为5类核心车道,对应Scheduler的5种优先级,分工明确(面试常考对应关系):

    • SyncLane(最高优先级):对应Scheduler的ImmediatePriority,比如用户点击、输入等同步更新,不能被中断;
    • InputContinuousLane(很高优先级):对应UserBlockingPriority,比如滚动、拖拽等连续交互,需快速响应;
    • DefaultLane(中等优先级):对应NormalPriority,普通setState更新默认走这条车道;
    • TransitionLanes(可中断优先级):对应LowPriority,有16条车道(可并行处理多个过渡更新),比如useTransition、useDeferredValue包裹的更新,可被高优先级更新打断;
    • IdleLane(最低优先级):对应IdlePriority,比如后台预加载、日志上报,只有浏览器空闲时才执行。
  • 高效位运算管理:通过位运算实现O(1)复杂度的更新操作(面试常考位运算场景):

    • 按位或(|):合并多条车道的更新,比如同时有普通更新和过渡更新,就用位或把两条车道合并,统一处理;
    • 按位与(&):检查特定车道是否有未处理的更新,比如判断是否有同步更新,就用SyncLane和pendingLanes(待处理车道)做按位与运算,结果非0则有同步更新。 通俗补充:位运算就像“快速检票”,不用一个个查,一句话就能判断所有车道的状态,效率极高。
  • 并发中断与恢复:低优先级车道(如TransitionLanes)的更新执行过程中,若有高优先级车道(如SyncLane)的更新插入,可立即中断低优先级任务,优先处理高优先级任务;待高优先级任务完成后,再恢复或重新执行低优先级任务,保障用户交互流畅性。 通俗补充:就像慢车道的车正在行驶,快车道的车来了,慢车道的车就让道,等快车道的车过去,再继续走。

  • 过期与优先级提升:为避免低优先级任务长期被高优先级任务阻塞(饥饿问题),React会为每条车道计时,若某条车道的更新待处理过久(超过对应超时时间),会将其优先级提升至SyncLane(立即执行),确保UI最终一致性,不会出现“更新一直不生效”的情况。

四、Scheduler 与 Lane 的关联(面试高频,必背)

核心总结:Lane 负责“定义更新优先级”(告诉React“这个更新该用什么优先级”),Scheduler 负责“执行优先级调度”(告诉React“这个优先级的任务什么时候执行、怎么执行”),二者协同工作,支撑React并发模式,核心关联3点:

  1. 优先级映射:Lane的每类车道,都对应Scheduler的一种优先级(一一对应,面试必背):
  • SyncLane ↔ ImmediatePriority(立即执行)
  • InputContinuousLane ↔ UserBlockingPriority(用户阻塞)
  • DefaultLane ↔ NormalPriority(普通优先级)
  • TransitionLanes ↔ LowPriority(低优先级)
  • IdleLane ↔ IdlePriority(空闲优先级)
  1. 协同工作流程(面试可完整背诵,体现逻辑):
  • 触发更新(如setState、useState更新);
  • React为该更新分配对应Lane,并将Lane加入根节点的pendingLanes(待处理车道);
  • Scheduler读取pendingLanes,将其映射为自身的优先级;
  • Scheduler从taskQueue中挑选最高优先级任务执行;
  • 执行过程中,通过Lane检查是否有更高优先级更新插入,若有则中断当前任务;
  • 高优先级任务执行完毕后,恢复或重新执行低优先级任务,形成调度闭环。
  1. 核心目标一致:二者的最终目的都是解决“主线程阻塞”问题——Lane实现优先级的精细化区分,让不同更新有明确的执行顺序;Scheduler实现任务的高效调度与中断,让任务执行不影响浏览器渲染和用户交互,共同保障React应用在复杂场景下(如大量数据渲染、高频交互)的流畅性。

五、面试常考问题(标准答案,可直接背诵)

说明:以下问题均为React面试高频题,答案简洁精准,贴合面试场景,避开复杂细节,重点突出核心考点。

1. 请说说React Scheduler的作用?

标准答案:Scheduler是React并发模式的核心模块,本质是“任务管理者”,核心作用是协调所有更新任务的优先级和执行时机,通过维护任务队列、实现时间切片和任务中断,避免重型任务阻塞主线程,确保应用的流畅响应(核心目标:推进任务,且不阻塞浏览器)。

2. Lane是什么?它的核心作用是什么?

标准答案:Lane是React用于精细化管理更新优先级的机制,核心是用31位整数的每一位(位掩码)代表一条“车道”,每一条车道对应一种更新优先级。核心作用是通过高效位运算,实现更新的合并、筛选与优先级判断,支撑React并发模式下的任务中断与恢复,解决优先级区分不精细的问题。

3. Scheduler和Lane的关系是什么?

标准答案:二者协同支撑React并发特性,核心关系是“Lane定义优先级,Scheduler执行调度”。Lane负责给更新分配优先级(对应不同车道),Scheduler负责将车道优先级映射为自身优先级,管理任务队列、执行任务,并通过Lane判断是否需要中断任务,共同解决主线程阻塞问题,保障应用流畅。

4. 什么是时间切片?它由哪个模块实现?作用是什么?

标准答案:时间切片是将长任务切分为≤16.6ms的小任务,每执行一个小任务就检查浏览器是否需要渲染,避免阻塞主线程的机制。由Scheduler实现,核心作用是防止重型任务(如大量列表渲染)阻塞主线程,保障用户交互和页面渲染的流畅性。

5. React中高优先级更新为什么能打断低优先级更新?依赖什么机制?

标准答案:依赖Lane和Scheduler的协同机制。Lane将更新划分为不同优先级的车道,高优先级更新对应高优先级车道;Scheduler在执行任务时,会通过Lane检查是否有更高优先级更新插入,若有则立即中断当前低优先级任务,优先执行高优先级任务,执行完毕后再恢复低优先级任务,从而实现高优先级打断低优先级。

6. 如何避免低优先级更新“饥饿”(长期不执行)?

标准答案:React通过Lane的“过期机制”避免低优先级更新饥饿。为每条车道设置对应超时时间,若某条低优先级车道的更新待处理过久(超过超时时间),会将其优先级提升至SyncLane(最高优先级),由Scheduler立即执行,确保UI最终一致性。

7. Lane为什么用位掩码实现?优势是什么?

标准答案:因为位运算的时间复杂度是O(1),效率极高。优势是能快速实现更新的合并(按位或)、筛选(按位与)和优先级判断,无需遍历任务队列,适配React高频更新场景(如滚动、输入),提升调度性能。

vue一次解决监听H5软键盘弹出和收起的兼容问题

2026年3月16日 17:47

H5软键盘弹出和收起在安卓和ios以及不同浏览器之间存在不同的表现形式,网上也找不到更全面的解决方案,为此自己研究出能兼容主流浏览器的解决方案。

Screenshot_2026-03-13-11-38-23-641_com.android.chrome.jpg

问题

在做手机评论功能的交互时,必须要通过监听软键盘弹出和收起来实现,比如实现“点击回复评论弹出键盘,收起键盘就取消回复操作,输入框清空输入值”。

在研究过程中发现有以下几个问题:

  1. 安卓非谷歌浏览器高度会变化,横屏时不会收起键盘
  2. 安卓最新谷歌浏览器高度不会变化(页面上推),横屏时会收起键盘,但会触发resize事件
  3. 安卓收起键盘后input可能并未失焦
  4. 有些浏览器可能会多次触发resize事件
  5. ios收起键盘页面会上滚

完整的代码放在最下面。

使用方式

当页面只有一个输入框的情况下使用

<script setup>
import keyboard from "./keyboard";
const vKeyboard = keyboard;

const keyboardFn = val => {
  console.log(val ? "弹出键盘" : "收起键盘")
};
</script>

<template>
    <input v-keyboard="keyboardFn" />
</template>

完整代码

const isIOS = /iphone|ipad|ipod/.test(navigator.userAgent.toLocaleLowerCase());
const originHeight =
  document.documentElement.clientHeight || document.body.clientHeight;
let scrollTop = 0;

const keyBoard = {
  mounted(el, binding, vnode) {
    const isFocus = ref(false);
    const isResiz = ref(0);
    const isChange = ref(false);
    const isHeight = ref(false);

    el.resizeFn = () => {
      const resizeHeight =
        document.documentElement.clientHeight || document.body.clientHeight;
      if (resizeHeight < originHeight) {
        isChange.value = true;
        isHeight.value = true;
      } else {
        isHeight.value = false;
      }
      if (isFocus.value) isResiz.value++;
    };
    
    if (!isIOS) {
      window.addEventListener("resize", el.resizeFn);
      // 第1种情况处理方式
      watch(isHeight, () => {
        if (isChange.value && isFocus.value && !isHeight.value) {
          binding.value(false);
        }
      });
      // 第2种情况处理方式
      watch(isResiz, () => {
        if (!isChange.value && isFocus.value && isResiz.value > 1) {
          binding.value(false);
        }
      });
    }

    el.handlerFocusin = () => {
      if (!isIOS) {
        isFocus.value = true;
        binding.value(true);
      } else {
        binding.value(true);
        scrollTop = document.documentElement.scrollTop;
      }
    };
    
    el.handlerFocusout = () => {
      if (!isIOS) {
        // 先失焦后后收起键盘的情况处理
        if (isFocus.value) {
          binding.value(false);
        }
        isFocus.value = false;
        isChange.value = false;
        isHeight.value = false;
        isResiz.value = 0;
      } else {
        binding.value(false);
        // 处理 iOS 收起软键盘页面会上滚问题
        setTimeout(() => window.scrollTo({ top: scrollTop }), 50);
      }
    };
    
    el.addEventListener("focusin", el.handlerFocusin);
    el.addEventListener("focusout", el.handlerFocusout);
  },
  
  unmounted(el) {
    window.removeEventListener("resize", el.resizeFn);
    el.removeEventListener("focusin", el.handlerFocusin);
    el.removeEventListener("focusout", el.handlerFocusout);
  }
};

export default keyBoard;

【JavaScript面试题-作用域与闭包】什么是闭包?闭包在实际开发中有什么应用和潜在问题(如内存泄漏)?

2026年3月16日 17:44

什么是闭包?

闭包是指一个函数能够访问并记住其词法作用域(即定义时的作用域)中的变量,即使这个函数是在其词法作用域之外被执行的。简单来说,闭包让你可以在内层函数中访问到外层函数的作用域

在JavaScript中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。但是,闭包通常特指那些引用了外部函数作用域中变量的内部函数。

一个形象的比喻

你出生在你家的老房子里,房子里有你的玩具、书桌、窗外的树(这些都是环境变量)。后来你长大了,搬到了新城市,但你心里永远记得老房子的样子——甚至你还能描述出玩具放在哪个抽屉里(这就是闭包)。

这里的“你”就是内部函数,老房子就是外层函数的作用域。虽然你离开了老房子(外层函数执行结束),但你依然能回忆起里面的细节(访问外层函数的变量)。

简单来说,闭包就是一个能“记住”并继续使用它出生时周围环境(变量)的函数,即使这个函数后来被拿到别的地方去执行,它也忘不掉那些“老家”的变量。

一个典型的闭包示例:

javascript

function outerFunction(x) {
  let y = 10;
  function innerFunction() {
    console.log(x + y); // innerFunction 访问了 outerFunction 的变量 x 和 y
  }
  return innerFunction;
}

const closureFunc = outerFunction(5);
closureFunc(); // 输出 15,即使 outerFunction 已经执行完毕,innerFunction 依然能记住 x 和 y

在上面的例子中,innerFunction 就是一个闭包。它“捕获”了 x 和 y,使得这些变量在 outerFunction 执行完毕后仍然存在于内存中,供 innerFunction 后续调用。

闭包的实际应用

闭包在前端开发中应用非常广泛,以下是一些常见场景:

  1. 数据私有化与封装
    通过闭包可以模拟私有变量,隐藏实现细节,只暴露有限的接口。

    javascript

    function createCounter() {
      let count = 0;
      return {
        increment: function() { count++; },
        decrement: function() { count--; },
        getCount: function() { return count; }
      };
    }
    const counter = createCounter();
    counter.increment();
    console.log(counter.getCount()); // 1,无法直接访问 count 变量
    
  2. 函数柯里化(Currying)
    利用闭包将多参数函数转换为一系列单参数函数,提高函数复用性。

    javascript

    function multiply(a) {
      return function(b) {
        return a * b;
      };
    }
    const double = multiply(2);
    console.log(double(5)); // 10
    
  3. 回调函数与事件处理
    在异步操作或事件监听中,闭包可以记住当时的环境变量。

    javascript

    for (var i = 0; i < 3; i++) {
      setTimeout(function() {
        console.log(i); // 如果不使用闭包,会输出 3,3,3
      }, 100);
    }
    // 利用闭包修复:
    for (var i = 0; i < 3; i++) {
      (function(j) {
        setTimeout(function() {
          console.log(j); // 输出 0,1,2
        }, 100);
      })(i);
    }
    
  4. 模块化模式(Module Pattern)
    在ES6模块之前,闭包常用于创建模块,管理私有状态和公共API。

    javascript

    var myModule = (function() {
      var privateVar = 0;
      function privateMethod() { /* ... */ }
      return {
        publicMethod: function() { /* 可以使用 privateVar 和 privateMethod */ }
      };
    })();
    
  5. 函数式编程中的高阶函数
    例如 Array.map()Array.filter() 中传入的回调函数也常常形成闭包,访问外部作用域。

闭包的潜在问题:内存泄漏

闭包虽然强大,但如果不加注意,可能会导致内存泄漏,因为闭包会一直持有对外部函数变量的引用,使得这些变量无法被垃圾回收(GC)。

常见的内存泄漏场景:

  • 无意的全局变量
    在函数内部意外创建的全局变量,由于被全局对象引用,永远不会被回收。

  • 未解除的事件监听器
    如果在DOM元素上绑定了事件回调,而回调中使用了外部变量(闭包),并且没有在元素移除前解绑,那么整个闭包作用域链都不会被释放,造成泄漏。

    javascript

    function attachEvent() {
      const largeData = new Array(1000000).fill('*');
      document.getElementById('btn').addEventListener('click', function() {
        console.log(largeData.length); // largeData 被闭包引用
      });
    }
    attachEvent(); // 即使元素被移除,由于事件监听未解绑,largeData 依然存在
    
  • 在循环中创建闭包并引用大对象
    如果循环内创建的闭包长期存在(例如存储到数组中或作为回调),且引用了外部作用域的大对象,可能导致大量内存无法释放。

  • 定时器或回调未清除
    未清除的 setInterval 或 setTimeout 回调中的闭包会持续持有外部变量。

如何避免内存泄漏

  1. 及时解除引用

    • 在不需要时,将闭包变量设置为 null,切断引用。
    • 移除DOM元素前,先移除其绑定的事件监听器(removeEventListener)。
    • 清除定时器(clearInterval / clearTimeout)。
  2. 使用弱引用(WeakMap/WeakSet)
    当需要缓存数据但不想阻止垃圾回收时,可以使用 WeakMap 或 WeakSet,它们不会增加引用计数。

  3. 避免不必要的闭包
    只在必要时创建闭包,尽量减少闭包引用的变量体积。例如,如果只需要外部函数中的一小部分数据,可以考虑只传递所需值,而不是整个作用域。

  4. 使用现代框架/工具辅助
    现代框架(如React、Vue)通常会自动处理事件监听和组件卸载时的清理工作,但在手动操作DOM时仍需小心。

总结

闭包是JavaScript语言的一大特色,它让函数变得极其灵活,支持数据私有化、函数式编程等高级用法。但同时,闭包也可能因持有外部引用而导致内存泄漏,开发者需要理解其工作原理,并在实际开发中注意及时清理不再需要的引用,从而编写出高效、可靠的应用。

如果这篇这篇文章对您有帮助?关注、点赞、收藏,三连支持一下。
有疑问或想法?评论区见

#前端#干货

性能指标与优化:从 Core Web Vitals 到实战

作者 yuki_uix
2026年3月16日 17:43

我曾遇到这样的困惑:花了很多时间优化代码,减少了回流次数,设置了合理的缓存策略,但如何证明页面"变快了"?用户说"感觉还是有点慢",但我不知道具体慢在哪里。这让我开始思考:性能到底应该如何度量?哪些指标真正影响用户体验?

这篇文章是我对 Web 性能指标的学习总结,重点关注 Google 推出的 Core Web Vitals,以及如何将这些指标应用到实际优化中。

问题的起源

为什么需要性能指标?最直接的原因是:性能影响业务

根据 Google 的研究:

  • 页面加载时间从 1 秒增加到 3 秒,跳出率增加 32%
  • 页面加载时间从 1 秒增加到 5 秒,跳出率增加 90%
  • 移动端加载时间超过 3 秒,53% 的用户会放弃访问

但"快"是一个很主观的概念。同样的页面,不同用户的感受可能完全不同。性能指标的作用,就是把主观的"快慢"转化为客观的、可度量的数字。

核心概念探索

1. Core Web Vitals:Google 的三大核心指标

2020 年,Google 推出了 Core Web Vitals,包含三个核心指标:

LCP(Largest Contentful Paint):最大内容绘制

定义:页面主要内容(最大的图片或文本块)完成渲染的时间。

// 环境:浏览器
// 场景:监测 LCP

// 使用 PerformanceObserver API
const observer = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1];
  
  console.log('LCP:', lastEntry.renderTime || lastEntry.loadTime);
  console.log('LCP element:', lastEntry.element);
});

observer.observe({ entryTypes: ['largest-contentful-paint'] });

// LCP 测量的是什么?
// - <img> 元素
// - <svg> 内的 <image> 元素
// - <video> 元素的封面图
// - 带背景图的块级元素
// - 包含文本的块级元素

评分标准

  • 优秀(Good):< 2.5 秒
  • 需要改进(Needs Improvement):2.5 - 4.0 秒
  • 差(Poor):> 4.0 秒

常见问题与优化

// 问题 1:大图片加载慢
// 解决方案:

// 1. 图片压缩
// 使用 WebP/AVIF 格式,压缩率更高
<img src="hero.webp" alt="Hero image">

// 2. 响应式图片
<img 
  srcset="hero-320w.jpg 320w,
          hero-640w.jpg 640w,
          hero-1280w.jpg 1280w"
  sizes="(max-width: 640px) 100vw, 50vw"
  src="hero-1280w.jpg"
  alt="Hero image"
>

// 3. 预加载关键图片
<link rel="preload" as="image" href="hero.jpg">

// 4. 使用 CDN
<img src="https://cdn.example.com/hero.jpg" alt="Hero image">
/* 问题 2:自定义字体阻塞文本渲染 */
/* 解决方案:使用 font-display */

@font-face {
  font-family: 'CustomFont';
  src: url('font.woff2') format('woff2');
  font-display: swap; /* 先显示系统字体,字体加载完再替换 */
}

/* font-display 选项:
   - auto: 浏览器默认行为
   - block: 阻塞渲染(不推荐)
   - swap: 立即显示后备字体,加载完替换(推荐)
   - fallback: 短暂阻塞(100ms),超时显示后备字体
   - optional: 极短阻塞,网络慢时放弃加载自定义字体
*/

FID(First Input Delay)→ INP(Interaction to Next Paint)

注意:FID 已被 INP 取代(2024 年 3 月),但很多资料仍在讨论 FID。

FID 定义:用户首次与页面交互(点击、输入)到浏览器响应的延迟。

INP 定义:整个页面生命周期中,所有交互延迟的代表值(通常是 98 分位数)。

// 环境:浏览器
// 场景:监测 INP(简化版)

let interactions = [];

// 监听所有交互事件
['pointerdown', 'click', 'keydown'].forEach(type => {
  document.addEventListener(type, (event) => {
    const startTime = performance.now();
    
    // 使用 requestAnimationFrame 测量到下一帧的时间
    requestAnimationFrame(() => {
      const delay = performance.now() - startTime;
      interactions.push(delay);
      console.log(`${type} delay:`, delay);
    });
  });
});

// INP 计算(简化版,实际更复杂)
function calculateINP() {
  if (interactions.length === 0) return 0;
  
  // 取 98 分位数
  const sorted = interactions.sort((a, b) => a - b);
  const index = Math.floor(sorted.length * 0.98);
  return sorted[index];
}

评分标准

  • 优秀(Good):< 200 毫秒
  • 需要改进(Needs Improvement):200 - 500 毫秒
  • 差(Poor):> 500 毫秒

常见问题与优化

// 问题 1:长任务阻塞主线程
// 主线程被 JavaScript 执行占用,无法响应用户交互

// ❌ 不好的做法:同步处理大量数据
function processLargeData(data) {
  const result = [];
  for (let i = 0; i < data.length; i++) {
    // 复杂计算
    result.push(expensiveOperation(data[i]));
  }
  return result;
}

// ✅ 好的做法 1:分片处理(Time Slicing)
async function processLargeDataInChunks(data, chunkSize = 100) {
  const result = [];
  
  for (let i = 0; i < data.length; i += chunkSize) {
    const chunk = data.slice(i, i + chunkSize);
    
    // 处理一个分片
    for (const item of chunk) {
      result.push(expensiveOperation(item));
    }
    
    // 让出主线程,让浏览器有机会响应用户交互
    await new Promise(resolve => setTimeout(resolve, 0));
  }
  
  return result;
}

// ✅ 好的做法 2:使用 Web Worker
// main.js
const worker = new Worker('worker.js');

worker.postMessage({ data: largeData });

worker.onmessage = (event) => {
  const result = event.data;
  console.log('Processing complete:', result);
};

// worker.js
self.onmessage = (event) => {
  const { data } = event.data;
  const result = data.map(item => expensiveOperation(item));
  self.postMessage(result);
};
// 问题 2:事件处理函数执行时间过长

// ❌ 不好的做法:在 click 事件中执行耗时操作
button.addEventListener('click', () => {
  // 耗时操作
  const result = processLargeData(data);
  updateUI(result);
});

// ✅ 好的做法 1:防抖(Debounce)
function debounce(fn, delay) {
  let timer = null;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

input.addEventListener('input', debounce((event) => {
  search(event.target.value);
}, 300));

// ✅ 好的做法 2:节流(Throttle)
function throttle(fn, interval) {
  let lastTime = 0;
  return function(...args) {
    const now = Date.now();
    if (now - lastTime >= interval) {
      lastTime = now;
      fn.apply(this, args);
    }
  };
}

window.addEventListener('scroll', throttle(() => {
  updateScrollPosition();
}, 100));

CLS(Cumulative Layout Shift):累积布局偏移

定义:页面生命周期内所有意外布局偏移的累积分数。

// 环境:浏览器
// 场景:监测 CLS

let clsScore = 0;

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // 只统计非用户交互引起的布局偏移
    if (!entry.hadRecentInput) {
      clsScore += entry.value;
      console.log('Layout shift:', entry.value);
      console.log('Cumulative CLS:', clsScore);
    }
  }
});

observer.observe({ entryTypes: ['layout-shift'] });

// 布局偏移分数计算:
// score = impact fraction × distance fraction
// - impact fraction: 移动元素占视口的比例
// - distance fraction: 移动距离占视口的比例

评分标准

  • 优秀(Good):< 0.1
  • 需要改进(Needs Improvement):0.1 - 0.25
  • 差(Poor):> 0.25

常见问题与优化

<!-- 问题 1:图片、视频没有设置尺寸 -->

<!-- ❌ 不好的做法:没有指定尺寸 -->
<img src="hero.jpg" alt="Hero">
<!-- 图片加载完成前,浏览器不知道要预留多少空间
     图片加载完成后,下方内容会被推下去,造成布局偏移 -->

<!-- ✅ 好的做法 1:显式指定宽高 -->
<img src="hero.jpg" alt="Hero" width="1200" height="800">

<!-- ✅ 好的做法 2:使用 aspect-ratio(推荐) -->
<style>
  .hero-image {
    width: 100%;
    aspect-ratio: 16 / 9; /* 浏览器会自动计算高度 */
  }
</style>
<img src="hero.jpg" alt="Hero" class="hero-image">
<!-- 问题 2:动态插入内容(广告、提示条) -->

<!-- ❌ 不好的做法:直接插入,推开现有内容 -->
<div id="banner"></div>
<script>
  // 1 秒后加载广告
  setTimeout(() => {
    document.getElementById('banner').innerHTML = '<div class="ad">广告内容</div>';
  }, 1000);
  // 广告出现时,下方内容被推下去
</script>

<!-- ✅ 好的做法:预留空间 -->
<div id="banner" style="min-height: 100px;">
  <!-- 即使内容未加载,也会预留 100px 空间 -->
</div>

<!-- ✅ 更好的做法:使用骨架屏 -->
<div id="banner">
  <div class="skeleton" style="height: 100px; background: #f0f0f0;"></div>
</div>
/* 问题 3:Web 字体加载导致文本跳动 */

/* ❌ 不好的做法:默认行为 */
@font-face {
  font-family: 'CustomFont';
  src: url('font.woff2') format('woff2');
}

body {
  font-family: 'CustomFont', sans-serif;
}

/* 字体加载前显示后备字体,加载后切换,可能导致布局偏移 */

/* ✅ 好的做法:使用 font-display: optional */
@font-face {
  font-family: 'CustomFont';
  src: url('font.woff2') format('woff2');
  font-display: optional; /* 如果字体加载慢,就不使用,避免布局偏移 */
}

/* ✅ 更好的做法:调整后备字体以匹配自定义字体 */
@font-face {
  font-family: 'CustomFont';
  src: url('font.woff2') format('woff2');
  font-display: swap;
  size-adjust: 100%; /* 调整字体大小以匹配后备字体 */
}

2. 其他重要指标

TTFB(Time to First Byte):首字节时间

服务器响应第一个字节的时间。

// 环境:浏览器
// 场景:测量 TTFB

const navigationTiming = performance.getEntriesByType('navigation')[0];
const ttfb = navigationTiming.responseStart - navigationTiming.requestStart;

console.log('TTFB:', ttfb);

// 影响 TTFB 的因素:
// 1. 服务器处理时间
// 2. 网络延迟
// 3. DNS 查询时间
// 4. TCP 连接时间
// 5. TLS 握手时间(HTTPS)

优化方向

  • 使用 CDN(减少网络延迟)
  • 服务端缓存(减少处理时间)
  • 数据库查询优化
  • 使用 HTTP/2 或 HTTP/3
  • DNS 预解析

FCP(First Contentful Paint):首次内容绘制

浏览器渲染第一个 DOM 内容的时间(文本、图片、Canvas等)。

// 环境:浏览器
// 场景:测量 FCP

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name === 'first-contentful-paint') {
      console.log('FCP:', entry.startTime);
    }
  }
});

observer.observe({ entryTypes: ['paint'] });

// FCP vs LCP:
// FCP:第一个内容(可能是小图标、文字)
// LCP:最大内容(通常是主图、标题)
// FCP < LCP

TTI(Time to Interactive):可交互时间

页面完全可交互的时间(主线程空闲,没有长任务)。

// TTI 的判定标准:
// 1. FCP 已完成
// 2. 最近 5 秒内没有长任务(执行时间 > 50ms)
// 3. 没有超过 2 个正在进行的网络请求

// 这个指标较难直接测量,通常使用工具(Lighthouse)

实际场景思考

场景 1:使用 Lighthouse 进行性能分析

Lighthouse 是 Chrome 内置的性能分析工具。

步骤:

  1. 打开 Chrome DevTools(F12)
  2. 切换到 "Lighthouse" 标签
  3. 选择 "Performance" 分析类型
  4. 选择设备(Desktop / Mobile)
  5. 点击 "Analyze page load"

Lighthouse 会给出:

  • Performance 分数(0-100)
  • Core Web Vitals 指标
  • 优化建议(按影响排序)
  • 诊断信息(资源加载瀑布图等)

解读 Lighthouse 报告

// 示例报告:

Performance: 65/100  // 需要改进

Metrics:
  FCP: 1.8s   优秀
  LCP: 4.2s   
  TBT: 350ms  需要改进
  CLS: 0.05   优秀
  SI:  3.2s   需要改进

Opportunities (优化建议):
  1. Eliminate render-blocking resources  
      节省 1.2s
      建议:使用 async/defer 加载 JS
  
  2. Properly size images
      节省 800ms
      建议:压缩图片,使用 WebP
  
  3. Reduce unused JavaScript
      节省 600ms
      建议:代码分割,移除未使用代码

Diagnostics (诊断信息):
  - Minimize main-thread work (主线程工作时间: 3.5s)
  - Reduce JavaScript execution time (JS 执行: 2.1s)

场景 2:实现性能监控

在生产环境中,我们需要持续监控真实用户的性能数据(RUM: Real User Monitoring)。

// 环境:浏览器
// 场景:收集并上报性能数据

class PerformanceMonitor {
  constructor() {
    this.metrics = {};
  }
  
  // 收集 Core Web Vitals
  collectWebVitals() {
    // LCP
    new PerformanceObserver((list) => {
      const entries = list.getEntries();
      const lastEntry = entries[entries.length - 1];
      this.metrics.lcp = lastEntry.renderTime || lastEntry.loadTime;
    }).observe({ entryTypes: ['largest-contentful-paint'] });
    
    // FID (已废弃,这里仅作示例)
    new PerformanceObserver((list) => {
      const entries = list.getEntries();
      this.metrics.fid = entries[0].processingStart - entries[0].startTime;
    }).observe({ entryTypes: ['first-input'] });
    
    // CLS
    let clsScore = 0;
    new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (!entry.hadRecentInput) {
          clsScore += entry.value;
        }
      }
      this.metrics.cls = clsScore;
    }).observe({ entryTypes: ['layout-shift'] });
  }
  
  // 收集导航时间
  collectNavigationTiming() {
    const timing = performance.getEntriesByType('navigation')[0];
    
    this.metrics.ttfb = timing.responseStart - timing.requestStart;
    this.metrics.domContentLoaded = timing.domContentLoadedEventEnd - timing.domContentLoadedEventStart;
    this.metrics.loadComplete = timing.loadEventEnd - timing.loadEventStart;
  }
  
  // 上报数据
  report() {
    // 页面即将卸载时上报
    window.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        this.sendMetrics();
      }
    });
  }
  
  sendMetrics() {
    // 使用 sendBeacon 确保数据发送成功(即使页面关闭)
    const data = JSON.stringify({
      url: location.href,
      metrics: this.metrics,
      userAgent: navigator.userAgent,
      timestamp: Date.now(),
    });
    
    navigator.sendBeacon('/api/performance', data);
  }
}

// 使用
const monitor = new PerformanceMonitor();
monitor.collectWebVitals();
monitor.collectNavigationTiming();
monitor.report();

场景 3:优化首屏 LCP

一个实际的优化案例:

// 问题:电商网站的商品详情页 LCP 达到 5.2 秒

// 排查步骤:

// 1. 运行 Lighthouse,发现 LCP 元素是商品主图(1.5MB)
// 2. 检查 Network 面板,发现:
//    - 图片从第三方 CDN 加载(500ms TTFB)
//    - 图片格式是 JPG,未压缩
//    - 图片加载优先级较低

// 优化方案:

// 方案 1:图片优化
// - 压缩:1.5MB → 300KB
// - 格式转换:JPG → WebP
// - 响应式图片:不同设备加载不同尺寸
<!-- 优化前 -->
<img src="https://cdn.example.com/product-large.jpg" alt="Product">

<!-- 优化后 -->
<picture>
  <source 
    type="image/webp"
    srcset="https://cdn.example.com/product-320.webp 320w,
            https://cdn.example.com/product-640.webp 640w,
            https://cdn.example.com/product-1280.webp 1280w"
    sizes="(max-width: 640px) 100vw, 50vw"
  >
  <img 
    src="https://cdn.example.com/product-1280.jpg"
    alt="Product"
    width="1280"
    height="960"
  >
</picture>
<!-- 方案 2:预加载关键图片 -->
<head>
  <link rel="preload" as="image" href="https://cdn.example.com/product-1280.webp" fetchpriority="high">
</head>
// 方案 3:图片懒加载(非 LCP 图片)
// 只懒加载首屏以外的图片,首屏图片立即加载

// 环境:浏览器
// 场景:原生懒加载

// LCP 图片(首屏主图):不懒加载
<img src="hero.jpg" alt="Hero" loading="eager">

// 其他图片(首屏以下):懒加载
<img src="detail-1.jpg" alt="Detail" loading="lazy">

// 或者使用 Intersection Observer 实现更精细的控制
const imageObserver = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      observer.unobserve(img);
    }
  });
});

document.querySelectorAll('img[data-src]').forEach(img => {
  imageObserver.observe(img);
});

优化结果

  • LCP:5.2s → 2.1s
  • 图片大小:1.5MB → 300KB(WebP)
  • Performance 分数:45 → 78

场景 4:优化 INP(减少交互延迟)

// 问题:数据表格的排序功能响应慢

// ❌ 原始代码:同步排序 10000 条数据
function sortTable(data, key) {
  const sorted = data.sort((a, b) => a[key] - b[key]);
  renderTable(sorted);
}

button.addEventListener('click', () => {
  sortTable(largeData, 'price');
});

// 问题:
// - 排序操作阻塞主线程(~500ms)
// - 用户点击按钮后,需要等待 500ms 才看到反馈

// ✅ 优化方案 1:立即反馈 + 异步处理
button.addEventListener('click', async () => {
  // 立即显示加载状态
  showLoading();
  
  // 让出主线程
  await new Promise(resolve => setTimeout(resolve, 0));
  
  // 执行排序
  const sorted = largeData.sort((a, b) => a.price - b.price);
  
  // 更新 UI
  renderTable(sorted);
  hideLoading();
});

// ✅ 优化方案 2:使用 Web Worker
// main.js
button.addEventListener('click', () => {
  showLoading();
  
  const worker = new Worker('sort-worker.js');
  worker.postMessage({ data: largeData, key: 'price' });
  
  worker.onmessage = (event) => {
    renderTable(event.data);
    hideLoading();
    worker.terminate();
  };
});

// sort-worker.js
self.onmessage = (event) => {
  const { data, key } = event.data;
  const sorted = data.sort((a, b) => a[key] - b[key]);
  self.postMessage(sorted);
};

// ✅ 优化方案 3:虚拟滚动(如果是长列表)
// 只渲染可见部分,减少 DOM 操作

场景 5:优化 CLS(减少布局偏移)

// 问题:新闻网站的文章页,广告加载导致内容跳动

// ❌ 原始代码
<div class="article">
  <h1>文章标题</h1>
  <div id="ad-slot"></div>  <!-- 广告位 -->
  <p>文章内容...</p>
</div>

<script>
  // 2 秒后加载广告
  setTimeout(() => {
    const ad = document.getElementById('ad-slot');
    ad.innerHTML = '<img src="ad.jpg" width="728" height="90">';
  }, 2000);
  // 问题:广告出现时,文章内容被推下去
</script>

// ✅ 优化方案 1:预留空间
<div class="article">
  <h1>文章标题</h1>
  <div id="ad-slot" style="min-height: 90px; width: 728px;">
    <!-- 预留广告位空间 -->
  </div>
  <p>文章内容...</p>
</div>

// ✅ 优化方案 2:使用 CSS aspect-ratio
<style>
  .ad-container {
    width: 100%;
    max-width: 728px;
    aspect-ratio: 728 / 90; /* 自动计算高度 */
    background: #f0f0f0; /* 占位背景色 */
  }
</style>

<div class="article">
  <h1>文章标题</h1>
  <div class="ad-container" id="ad-slot"></div>
  <p>文章内容...</p>
</div>

// ✅ 优化方案 3:使用骨架屏
<div class="article">
  <h1>文章标题</h1>
  <div class="ad-skeleton" id="ad-slot">
    <div class="skeleton-box" style="width: 728px; height: 90px;"></div>
  </div>
  <p>文章内容...</p>
</div>

<style>
  .skeleton-box {
    background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
    background-size: 200% 100%;
    animation: loading 1.5s infinite;
  }
  
  @keyframes loading {
    0% { background-position: 200% 0; }
    100% { background-position: -200% 0; }
  }
</style>

延伸与发散

1. 实验室数据 vs 真实用户数据

// 实验室数据(Lab Data):
// - 工具:Lighthouse、WebPageTest
// - 环境:固定的设备、网络条件
// - 优点:可重复、可控
// - 缺点:不代表真实用户体验

// 真实用户数据(RUM: Real User Monitoring):
// - 来源:真实用户的浏览器
// - 环境:各种设备、网络条件
// - 优点:反映真实情况
// - 缺点:数据噪音大、难以复现问题

// 最佳实践:两者结合
// 1. 开发阶段:使用 Lighthouse 发现问题
// 2. 上线后:使用 RUM 监控真实性能
// 3. 发现问题:使用 Lighthouse 重现和调试

2. 性能预算(Performance Budget)

// 性能预算是一种约束机制
// 为项目设定性能目标,超出目标时自动报警或阻止部署

// 示例:package.json 中的性能预算配置
{
  "budgets": [
    {
      "type": "bundle",
      "name": "main",
      "baseline": "500KB",
      "warning": "600KB",
      "error": "800KB"
    },
    {
      "type": "initial",
      "baseline": "1MB",
      "warning": "1.2MB",
      "error": "1.5MB"
    }
  ]
}

// Webpack 插件:
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',
      openAnalyzer: false,
      reportFilename: 'bundle-report.html',
    }),
  ],
  performance: {
    maxAssetSize: 500000, // 500KB
    maxEntrypointSize: 800000, // 800KB
    hints: 'error', // 超出预算时报错
  },
};

3. 移动端性能优化

// 移动端的特殊考虑:

// 1. 网络条件差
// - 使用 3G/4G 网络测试
// - 减少资源大小
// - 使用 HTTP/2 多路复用

// 2. CPU 性能弱
// - 减少 JavaScript 执行时间
// - 避免复杂的 CSS 选择器
// - 使用 GPU 加速(transform, opacity)

// 3. 电池续航
// - 减少网络请求
// - 避免频繁的动画
// - 使用 Intersection Observer 代替 scroll 事件

// Chrome DevTools 中模拟移动设备:
// 1. 打开 DevTools → 切换到移动设备模式(Ctrl+Shift+M)
// 2. 选择设备(iPhone, Pixel 等)
// 3. 设置网络条件(Fast 3G, Slow 3G)
// 4. 设置 CPU 限制(4x slowdown)

4. 渐进式 Web 应用(PWA)与性能

// PWA 的性能优势:

// 1. Service Worker 缓存
// - 离线可用
// - 瞬时加载

// 2. App Shell 架构
// - 缓存应用外壳
// - 动态加载内容

// 示例:缓存应用外壳
// service-worker.js
const CACHE_NAME = 'app-shell-v1';
const urlsToCache = [
  '/',
  '/styles/main.css',
  '/scripts/main.js',
  '/images/logo.png',
];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => {
      return cache.addAll(urlsToCache);
    })
  );
});

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(response => {
      return response || fetch(event.request);
    })
  );
});

知识点快速回顾

(30 秒版本)

Q: 什么是 Core Web Vitals?包含哪些指标?

A: Core Web Vitals 是 Google 推出的三个核心性能指标:

  • LCP(Largest Contentful Paint) :最大内容绘制时间,测量加载性能,目标 < 2.5s
  • INP(Interaction to Next Paint) :交互响应时间,测量交互性能,目标 < 200ms(之前是 FID)
  • CLS(Cumulative Layout Shift) :累积布局偏移,测量视觉稳定性,目标 < 0.1

Q: 如何测量网站性能?

A:

  • 实验室数据:使用 Lighthouse、Chrome DevTools Performance 面板、WebPageTest
  • 真实用户数据:使用 Performance API 收集指标,上报到后端分析
  • 两者结合:开发阶段用 Lighthouse,上线后监控真实用户数据

(2 分钟版本)

Q: 如何优化 LCP?

A: 常见优化手段:

  1. 图片优化:压缩、使用 WebP/AVIF、响应式图片、CDN
  2. 预加载关键资源<link rel="preload"> 提高优先级
  3. 减少阻塞资源:CSS 内联关键样式、JS 使用 defer/async
  4. 服务端优化:减少 TTFB、使用 SSR/SSG
  5. 字体优化font-display: swap、字体子集化

Q: 什么情况会导致 CLS?如何避免?

A: 常见原因:

  • 图片/视频没有设置尺寸
  • 动态插入内容(广告、提示条)
  • Web 字体加载导致文本重排

优化方案:

  • 为图片设置 widthheight 属性
  • 使用 aspect-ratio CSS 属性
  • 为动态内容预留空间(骨架屏)
  • 使用 font-display: optional 避免字体切换

Q: 如何优化 INP(减少交互延迟)?

A:

  1. 减少主线程阻塞:分片处理大任务、使用 Web Worker
  2. 优化事件处理:防抖(debounce)、节流(throttle)
  3. 代码分割:按需加载,减少初始 JavaScript 大小
  4. 避免长任务:单个任务不超过 50ms,使用 requestIdleCallback

Q: Lighthouse 分数如何计算?各指标权重是多少?

A: Lighthouse Performance 分数(2024 年权重):

  • LCP: 25%
  • TBT (Total Blocking Time): 30%
  • CLS: 25%
  • FCP: 10%
  • Speed Index: 10%

分数计算:每个指标有各自的评分曲线,加权平均后得到总分(0-100)。

有关性能指标与优化的高频关键概念

面试时,回答中尽量涵盖这些关键词:

  • Core Web Vitals
  • LCP / FCP / TTFB
  • INP / FID
  • CLS
  • Lighthouse / Performance API
  • 长任务(Long Task)
  • 主线程阻塞(Main Thread Blocking)
  • 预加载(Preload)
  • 懒加载(Lazy Loading)
  • 骨架屏(Skeleton Screen)
  • 性能预算(Performance Budget)
  • RUM(Real User Monitoring)

容易踩的坑

  1. 过度优化单一指标:盯着 LCP 优化,忽略了 CLS,导致用户体验变差
  2. 只关注实验室数据:Lighthouse 分数很高,但真实用户反馈慢(网络环境差异)
  3. 滥用预加载:给太多资源添加 preload,反而延迟了其他关键资源
  4. 忽略移动端:桌面端性能好,移动端很差(CPU、网络限制)
  5. 懒加载首屏图片:LCP 元素不应该懒加载,否则加载更慢

性能优化优先级

1. 影响 Core Web Vitals 的问题(优先级最高)
   ├─ LCP > 4s → 立即优化
   ├─ INP > 500ms → 立即优化
   └─ CLS > 0.25 → 立即优化

2. 其他性能问题
   ├─ TTFB > 1s → 服务端优化
   ├─ FCP > 3s → 减少阻塞资源
   └─ Bundle 过大 → 代码分割

3. 体验优化
   ├─ 添加加载状态
   ├─ 骨架屏
   └─ 渐进式渲染

小结

性能指标是连接主观体验和客观数据的桥梁。理解 Core Web Vitals(LCP、INP、CLS)的含义、测量方法和优化手段,能帮助我们系统性地提升 Web 应用性能。

这篇文章主要探讨了:

  • Core Web Vitals 三大核心指标
  • 其他重要性能指标(TTFB、FCP、TTI)
  • 使用 Lighthouse 进行性能分析
  • 实际优化案例(LCP、INP、CLS)
  • 性能监控的实现
  • 性能预算的概念

参考资料

web逆向之小红书无水印图片提取工具

2026年3月16日 17:39

web逆向之某红书无水印图片提取工具

刷小红书看到好图想保存,结果全是水印?试试这个工具,粘贴链接就能拿到高清无水印原图。

起因

前几天一个朋友找我吐槽,说她经常在小红书上保存穿搭图和壁纸,用了一个去水印的小工具,结果用了几次就弹出来要开 VIP。

就问我能不能弄?

能,必须试试!

最后简单做了一个。


这是什么

image.png

是一个小红书无水印图片提取工具。把小红书的分享链接粘贴进去,点一下就能提取出笔记里所有高清原图,没有水印、没有压缩。

简单来说:复制 → 粘贴 → 一键保存,三步搞定。


能干什么

  • 提取小红书笔记中的所有无水印高清图片
  • 支持直接链接、短链接、甚至整段分享文本直接粘贴
  • 单张保存 / 批量一键保存 / 复制全部图片链接
  • 图片预览、大图查看

怎么用

第一步: 打开小红书 App,找到你想保存图片的笔记,点击分享 → 复制链接

第二步: 打开工具,把复制的内容粘贴到输入框(不用手动提取链接,整段文字直接粘就行)

第三步: 点击「提取图片」,等几秒就能看到所有无水印原图,点保存就行

就这么简单,不需要登录、不需要注册、不用看广告。


支持哪些链接格式

不用纠结粘贴什么格式,直接复制小红书分享链接,以下几种都能识别:

https://www.xiaohongshu.com/explore/xxx
https://www.xiaohongshu.com/discovery/item/xxx
http://xhslink.com/xxx
整段分享文本,比如 "这也太好看了吧 http://xhslink.com/o/8qg6LcQg75G"

工具链接

开发者/有技术基础的朋友也可以直接调用 API 接口,返回 JSON 格式的图片列表。


常见问题

Q:需要登录吗? A:不需要,直接用。

Q:有次数限制吗? A:目前没有限制,免费使用。


背后的技术

如果你对实现原理感兴趣,评论区说一声,我后面会写一篇分享一下。


联系作者

有问题或建议可以加微信:bo998866a(备注:去水印)

【LiveStates 05】实战指南:手把手带你用 LiveStates 构建高性能生产级页面

作者 bu_xue
2026年3月16日 17:29

【LiveStates 05】实战指南:手把手带你用 LiveStates 构建高性能生产级页面

【LiveStates 01】别再手动 watch 了:开启 Flutter “自动追踪” DX 革命

【LiveStates 02】Zones 不止于异常捕获:揭秘 LiveStates 自动追踪黑科技

【LiveStates 03】拒绝无效重绘:利用 LiveCompute 实现手术刀级 UI 刷新

【LiveStates 04】不仅是状态管理:解锁 Recoverable/Refreshable 工业级特性

看了前几篇原理,你可能觉得 live_states 很玄学。其实,它的核心开发心智可以总结为一句话:“数据是唯一的真理,UI 只是数据的投影。”

今天,我们就按这个心智模型,走一遍标准的开发流程。我们将从零开始构建一个“带状态恢复和异步过滤”的生产级页面,让你彻底掌握它的底层开发逻辑。


第一步:DNA 建模——定义你的状态(State)

不要急着写 UI,先写 ViewModel。在 live_states 里,ViewModel 是独立于 UI 存在的“逻辑实体”。

原则:凡是会变的、需要计算的,全部定义为 LiveDataLiveCompute

class ProductListVM extends LiveViewModel<ProductListPage> with Recoverable {
  // 1. 定义存储 key(用于状态恢复)
  @override
  String get storageKey => 'product_list_page_cache';

  // 2. 原始状态:搜索词和数据列表
  late final searchKey = LiveData<String>('', owner);
  late final products = LiveData<List<Product>>([], owner);

  // 3. 派生状态:利用 LiveCompute 实现自动过滤
  // 它会自动追踪 searchKey 和 products,只有结果变了才会通知 UI
  late final filteredList = LiveCompute<List<Product>>(owner, () {
    final key = searchKey.value.toLowerCase();
    if (key.isEmpty) return products.value;
    return products.value.where((p) => p.name.contains(key)).toList();
  });

  // 状态恢复逻辑
  @override
  Map<String, dynamic>? storage() => {'q': searchKey.value};
  @override
  void recover(Map<String, dynamic>? s) => searchKey.value = s?['q'] ?? '';
}

第二步:神经中枢——编写业务逻辑(Actions)

在 ViewModel 里,你可以放心地处理异步请求、定时器或复杂的计算。

原则:禁止在 ViewModel 里持有任何 Widget 对象。

  // 初始化数据
  @override
  void init() {
    super.init();
    loadProducts();
  }

  Future<void> loadProducts() async {
    // 模拟网络请求
    await Future.delayed(const Duration(seconds: 1));
    products.value = [Product('Apple'), Product('Banana'), Product('Cherry')];
  }

  void onSearch(String value) {
    searchKey.value = value;
  }

第三步:投影——构建 UI 与局部刷新(View)

到了 View 层,你的任务只有一个:把数据精准地“贴”在屏幕上。

核心技巧:外科手术式局部刷新

不要直接在 build 方法里读 .value(除非你真的想整页刷新)。利用 LiveScope 来缩小刷新的范围。

class ProductListPage extends LiveWidget {
  @override
  ProductListVM createViewModel() => ProductListVM();

  @override
  Widget build(BuildContext context, ProductListVM viewModel) {
    return Scaffold(
      appBar: AppBar(title: const Text('LiveStates Demo')),
      body: Column(
        children: [
          // 搜索框:它读取状态,但它自己不需要跟随状态刷新(因为它是输入源)
          TextField(
            onChanged: viewModel.onSearch,
            controller: TextEditingController(text: viewModel.searchKey.value)
              ..selection = TextSelection.collapsed(offset: viewModel.searchKey.value.length),
          ),
          
          Expanded(
            // 局部刷新域:只有列表部分会根据过滤结果刷新
            child: LiveScope.free(
              builder: (context, _) {
                final list = viewModel.filteredList.value;
                if (list.isEmpty) return const CircularProgressIndicator();
                return ListView.builder(
                  itemCount: list.length,
                  itemBuilder: (c, i) => ListTile(title: Text(list[i].name)),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

第四步:副作用(Side Effects)——如何优雅地弹窗或跳转?

这是很多状态管理框架的软肋:数据变了,但我只想弹一个 SnackBar,而不是刷新 UI。

live_states 里,我们推荐 “侦听器模式”。你可以利用 LiveDatalisten 方法,或者在 LiveScope 里处理瞬态逻辑。

// 在 ViewModel 中定义一个事件流
late final toastMessage = LiveData<String?>(null, owner);

// 在 View 中,利用一个不返回组件的 LiveScope 来“监听”并执行副作用
LiveScope.free(
  builder: (context, _) {
    final msg = viewModel.toastMessage.value;
    if (msg != null) {
      // 这里的逻辑只在 msg 变化时运行一次
      WidgetsBinding.instance.addPostFrameCallback((_) {
        ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
        viewModel.toastMessage.onlyValue = null; // 处理完重置
      });
    }
    return const SizedBox.shrink(); // 不占据空间
  }
)

总结:live_states 的“禅意”三段论
  1. 定义数据 (DNA):想清楚哪些是原始数据,哪些是计算出来的。
  2. 封装逻辑 (Brain):在 VM 里处理业务,完全不需要考虑 context 和 UI 刷新。
  3. 精准映射 (Mapping):在 UI 层用 LiveScope 像贴补丁一样,把数据映射到屏幕。

避坑指南:

  • 别在 LiveScope 外面读 .value:除非你确定要整页刷新。
  • 多用 onlyValue:如果你只是想在方法里用一下当前值,不需要监听,记得用 onlyValue,省去不必要的订阅开销。
  • ViewModel 是单向的:它可以被 View 调用,但它永远不知道 View 到底长什么样。

写在最后: live_states 不是为了让你写更多代码,而是为了让你在写代码时更“笃定”。你定义了数据,你触碰了数据,UI 就会精准地响应。这种**“所见即所得”**的确定性,才是它能带给你的最大自由。

flex布局实现水平和垂直对齐

作者 Oneslide
2026年3月16日 17:27

1. 概述

align-items: centerjustify-content: center 是 CSS Flex 布局(弹性布局)中用于控制子元素对齐方式的核心属性,二者搭配使用可实现子元素在 flex 容器内水平+垂直双方向居中,是前端开发中最常用的居中布局方案之一。

前置条件

使用这两个属性的前提是:给父容器设置 display: flex;(开启 Flex 布局),否则属性不生效。

2. 核心概念:Flex 布局的轴

Flex 布局包含两个基础轴,理解轴的概念是掌握这两个属性的关键:

轴类型 默认方向 作用范围
主轴(Main Axis) 水平方向(左→右) justify-content 作用轴
交叉轴(Cross Axis) 垂直方向(上→下) align-items 作用轴

提示:可通过 flex-direction 修改主轴方向(如 flex-direction: column 会将主轴改为垂直方向),此时两个属性的作用方向也会同步变化。

3. 属性详细说明

3.1 justify-content: center

作用

控制 flex 容器内子元素在主轴方向上的对齐方式,center 值表示子元素在主轴上居中对齐。

默认场景(主轴水平)

当容器未修改 flex-direction 时,justify-content: center 实现子元素水平居中

语法

.container {
  display: flex;
  justify-content: center; /* 主轴居中 */
}

3.2 align-items: center

作用

控制 flex 容器内子元素在交叉轴方向上的对齐方式,center 值表示子元素在交叉轴上居中对齐。

默认场景(交叉轴垂直)

当容器未修改 flex-direction 时,align-items: center 实现子元素垂直居中

语法

.container {
  display: flex;
  align-items: center; /* 交叉轴居中 */
}

3.3 组合使用(最常用)

效果

子元素在 flex 容器内水平+垂直完全居中

完整示例代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Flex 居中示例</title>
  <style>
    /* flex容器 */
    .flex-container {
      width: 500px;       /* 容器宽度 */
      height: 300px;      /* 容器高度 */
      background-color: #f5f5f5;
      border: 1px solid #ddd;
      
      /* 核心居中样式 */
      display: flex;              /* 开启Flex布局 */
      justify-content: center;    /* 水平居中(主轴) */
      align-items: center;        /* 垂直居中(交叉轴) */
    }

    /* flex子元素 */
    .flex-item {
      font-size: 20px;
      padding: 20px 40px;
      background-color: #42b983;
      color: white;
      border-radius: 8px;
    }
  </style>
</head>
<body>
  <div class="flex-container">
    <div class="flex-item">我是居中的子元素</div>
  </div>
</body>
</html>

示例效果

  • 绿色的子元素会精准显示在灰色容器的正中心;
  • 无论子元素内容多少,居中效果始终生效。

4. 特殊场景:修改主轴方向

当设置 flex-direction: column(主轴改为垂直方向)时,两个属性的作用方向会互换:

.flex-container {
  display: flex;
  flex-direction: column; /* 主轴改为垂直方向 */
  justify-content: center;/* 垂直居中(主轴变为垂直) */
  align-items: center;    /* 水平居中(交叉轴变为水平) */
  width: 500px;
  height: 300px;
  background-color: #f5f5f5;
}

5. 常见注意事项

  1. align-itemsjustify-content给 flex 容器设置的属性,不是给子元素设置;
  2. 如果 flex 容器未设置固定高度(或高度由内容撑开),align-items: center 垂直居中效果会不明显;
  3. 多个子元素时,二者仍会让所有子元素整体在容器内居中(子元素之间默认沿主轴排列)。

6. 兼容性

  • 支持所有现代浏览器(Chrome、Firefox、Safari、Edge);
  • 兼容 IE 10+(IE 9 及以下不支持 Flex 布局)。

【LiveStates 01】别再手动 watch 了:开启 Flutter “自动追踪” DX 革命

作者 bu_xue
2026年3月16日 17:12

【LiveStates 01】别再手动 watch 了:开启 Flutter “自动追踪” DX 革命

在 Flutter 圈子里,我们似乎已经习惯了一种“显式声明”的痛苦。

不管你用的是 Provider 还是现在如日中天的 Riverpod,你一定写过这种代码:

@override
Widget build(BuildContext context, WidgetRef ref) {
  // 声明:必须手动告诉框架我要监听谁
  final name = ref.watch(nameProvider);
  final age = ref.watch(ageProvider);
  final avatar = ref.watch(userAvatarProvider);
  
  // 使用
  return Column(
    children: [
      CircleAvatar(backgroundImage: NetworkImage(avatar)),
      Text('$name ($age)'),
    ],
  );
}

这段代码看起来逻辑清晰,但如果你在深夜写一个拥有 20 个状态变量的复杂页面时,这种“先声明、再使用”的模式简直是灾难。

1. 认知负荷:为什么“显式监听”是个负担?

想象一下,你正在修改一个半年前写的页面。你需要增加一个“等级”展示,你很自然地在代码深处写下了 Text(viewModel.level.value)

然后,UI 没动。

你猛然想起:哦,我忘了在顶层调用那个该死的 ref.watch 了。

这种**“漏写必出 Bug”**的心理暗示,就是一种持续的认知负荷。开发者不得不分出精力去手动维护一张“依赖清单”。更糟糕的是,如果你为了省事直接 watch 了一个大对象,那么恭喜你,你的整个页面会在任何微小变动时全量重绘,性能优化瞬间化为泡影。

我不禁想问:我都已经在代码里读了这个值了,为什么还要再写一遍代码告诉框架我在读它?

2. “触碰即追踪”: live_states 的魔法

这就是我做 live_states 的原因。我希望实现一种“隐式追踪”的体验——只要你碰了数据,我就自动帮你订阅。

看一眼 live_states 下的写法:

@override
Widget build(BuildContext context, UserVM viewModel) {
  return Scaffold(
    body: Column(
      children: [
        const HeavyHeader(), // 静态组件,永不重绘
        LiveScope.free(
          builder: (context, _) => Text(viewModel.name.value), // 触碰 .value 的瞬间,追踪已完成
        ),
      ],
    ),
  );
}

没有 watch,没有 ref,没有冗长的依赖声明。

底层发生了什么? live_states 利用了 Dart 的 Zone 机制。你可以把 LiveScope 想象成一个带有“电磁场”的区域。当 builder 函数在里面运行时,任何对 LiveData.value 的访问都会触发一次“握手”。

框架会说:“嘿,我知道你在读这个名字,我会把你的这块 UI 登记在册。”

这种体验就像是:你不再需要手动给每一个灯泡接线,你只需要把灯泡拧进去,电流会自动感知并接通。

3. 手术刀级别的局部刷新(Surgical Rebuilds)

在传统方案中,为了实现局部刷新,我们往往要付出高昂的代价:你要么得拆分出无数个微小的 StatelessWidget,要么得忍受 ConsumerSelector 带来的多层嵌套。

在 live_states 里,局部刷新是手术刀级的,而且代码极其优雅。

live_states.gif

你会发现,你可以非常随意地在 Widget 树的任何位置贴上一个 LiveScope.free。它像是一张“性能补丁”,精准地包裹住那块会动的 UI。

4. 当“计算属性”遇上“自动拦截”

业务逻辑里最麻烦的往往是派生状态。比如:购物车里只有总额超过 100 块才显示“包邮”。

在其他框架里,totalPrice 每次变动(哪怕是从 101 变到 105),你的“包邮”图标都会跟着重绘。

live_states 的 LiveCompute 解决了这个问题:

// 只有“是否包邮”的布尔值发生变化时,UI 才会收到通知
late final isFreeShipping = LiveCompute<bool>(owner, () => totalPrice.value > 100);

它不仅会自动追踪 totalPrice,还会智能拦截。如果计算结果没变,它就会把刷新信号拦下来。这种“结果导向”的通知,才是高性能 App 该有的样子。

5. 结语:让状态管理回归本质

状态管理不应该是一门需要研读几百页文档的“玄学”,它也不应该成为样板代码的温床。

它的本质只有两件事:数据在哪,以及谁在用它。

live_states 想做的,就是利用 Dart 原生的强悍特性,把这两件事做透明。如果你也厌倦了在 ref.watch 的清单里迷失自我,不妨试试这种“所见即所得”的快感。


下一篇预告: 《【LiveStates 02】Zones 不止于异常捕获:揭秘 LiveStates 自动追踪黑科技》。

鸿蒙端 SDK 创建、单元测试、发布与依赖完整指南

2026年3月16日 17:06

鸿蒙端 SDK 创建、单元测试、发布与依赖完整指南

本文档介绍从零创建鸿蒙(OpenHarmony/HarmonyOS)SDK、编写单元测试、发布到官方三方库中心仓,并在项目中依赖使用的完整流程。涉及 C 层(Native)开发时,提供基于源码(BUILD.gn)和应用层(CMake)两种方式的完整实现示例。


一、SDK 创建

1.1 项目结构

鸿蒙 SDK 通常以 HAR(Harmony Archive) 形式发布。根据是否包含 C/C++ 原生代码、是否包含页面,结构有所不同。

1.1.1 纯 ArkTS 结构(无 C 层、无页面)
my_sdk/
├── oh-package.json5          # 包配置(必填)
├── build-profile.json5       # 构建配置
├── src/main/
│   ├── module.json5          # 模块配置
│   └── ets/
│       ├── index.ets         # 入口,导出对外 API
│       ├── utils/             # 工具函数
│       └── ...
├── README.md
├── CHANGELOG.md
└── LICENSE
1.1.2 含页面 + C/C++ 的完整结构
my_sdk/
├── oh-package.json5
├── build-profile.json5
├── src/main/
│   ├── module.json5
│   ├── ets/                   # ArkTS 源码
│   │   ├── index.ets         # 入口:import 页面 + export API
│   │   ├── pages/             # 页面(@Entry 路由页)
│   │   │   ├── MainPage.ets   # 主页面
│   │   │   ├── DetailPage.ets
│   │   │   ├── components/    # 页面内可复用组件
│   │   │   │   ├── Toolbar.ets
│   │   │   │   └── BottomPanel.ets
│   │   │   └── utils/         # 页面相关工具
│   │   │       ├── OptionsParser.ets
│   │   │       └── BoundsUtils.ets
│   │   ├── ui/                # 通用 UI(Canvas 绘制、自定义 View)
│   │   │   └── OverlayPainter.ets
│   │   ├── crop/              # 业务核心(或 domain/)
│   │   │   ├── CropOptions.ets
│   │   │   ├── CropTask.ets
│   │   │   └── ResultHandler.ets
│   │   └── utils/             # 通用工具
│   │       └── HttpUtils.ets
│   └── cpp/                   # C/C++ 原生代码(可选)
│       ├── CMakeLists.txt
│       ├── napi_init.cpp
│       └── types/             # NAPI 类型声明
│           └── libentry/
│               ├── oh-package.json5   # name: "libentry.so"
│               └── index.d.ts
├── ohosTest/                  # 单元测试
│   └── ets/test/
├── README.md
├── CHANGELOG.md
└── LICENSE
1.1.3 目录职责说明
目录 职责 示例
pages/ @Entry 的路由页面,负责页面编排与生命周期 CropEntryPageMainPage
pages/components/ 页面内可复用 UI 组件(@Component CropToolbarCropBottomPanel
pages/utils/ 页面相关解析、计算、辅助逻辑 CropOptionsParserCropBoundsUtils
ui/ 通用 UI 绘制、Canvas、自定义 View CropOverlayRulerWidget
crop/domain/ 业务核心、数据模型、任务执行 CropOptionsImageCropTask
utils/ 通用工具(网络、文件、格式等) HttpDownloadUtils
cpp/ C/C++ 原生实现,通过 NAPI 暴露给 ArkTS napi_init.cpp
1.1.4 页面相关调整

新增页面

  1. pages/ 下新建 XxxPage.ets,使用 @Entry({ routeName: 'XxxPage' }) 装饰:
    @Entry({ routeName: 'XxxPage' })
    @Component
    struct XxxPage {
      build() {
        Column() { /* ... */ }
      }
    }
    
  2. index.ets 中增加 import './pages/XxxPage'(若 index.ets 在 src/main/ets/ 下)或 import './src/main/ets/pages/XxxPage'(若 index.ets 在包根),使路由能注册该页面。
  3. 调用方通过 router.pushNamedRoute({ name: 'XxxPage' })router.pushUrl() 跳转。

修改页面布局

  • 页面根节点一般为 ColumnStackRow,按需调整子组件顺序和 layoutWeight
  • 使用 @State 控制 UI 状态,@Prop 在父子组件间传参。
  • 链式写法注意 ArkTS 的 ASI:} 后的 .width() 等需与 } 同一行或确保不被解析为独立语句。

调整页面层级

  • 将可复用部分抽到 pages/components/@Component export struct Xxx,在页面中 Xxx({ ... }) 使用。
  • 将通用绘制逻辑抽到 ui/,如 CropOverlayPainter 负责 Canvas 绘制。

路由与参数传递

  • 使用 AppStorage.setOrCreate('key', value) 存数据,页面通过 @StorageLink('key') 读取。
  • 或使用 router.pushUrl({ url: 'pages/XxxPage', params: { id: 1 } }),在目标页 router.getParams() 获取。url 格式需与 module.json5 中配置的 routes 一致。
1.1.5 C/C++ 结构说明

当 SDK 包含 Native 时,需在 src/main/ 下增加 cpp/

cpp/
├── CMakeLists.txt       # 构建配置
├── napi_init.cpp        # NAPI 注册与实现
└── types/               # 供 ArkTS 调用的类型声明
    └── libentry/        # 目录名随意,oh-package.json5 中 name 填 "libentry.so"
        ├── oh-package.json5   # name: "libentry.so", types: "./index.d.ts"
        └── index.d.ts         # 声明 C 层暴露的接口

主模块 oh-package.json5dependencies 中需添加:

"libentry.so": "file:./src/main/cpp/types/libentry"

build-profile.json5 中配置 externalNativeOptions 指向 CMakeLists.txt。详见第七章「C 层开发」。

1.2 oh-package.json5 配置

{
  "name": "my_sdk",                    // 包名,发布后用于依赖
  "version": "1.0.0",                 // 语义化版本
  "description": "SDK 功能描述",
  "main": "src/main/ets/index.ets",    // 入口文件
  "author": "your_name",
  "license": "Apache-2.0",
  "repository": "https://gitee.com/xxx/my_sdk",
  "dependencies": {}                    // 依赖的其他 ohpm 包
}

必填项nameversionmainlicense

1.3 module.json5 配置

{
  "module": {
    "name": "my_sdk",
    "type": "har",
    "deviceTypes": ["default", "tablet"]
  }
}
  • type: "har" 表示构建为 HAR 静态共享包
  • deviceTypes 指定支持的设备类型

1.4 构建 HAR

在项目根目录执行:

# 安装依赖
ohpm install

# 构建 HAR
hvigorw assembleHar

构建产物位于 build/default/outputs/default/ 目录,生成 .har 文件。


二、单元测试

2.1 测试类型

类型 目录 运行环境 适用场景
Local Test test/ 本地 JVM 纯逻辑、工具函数、不依赖设备
Instrument Test ohosTest/ 设备/模拟器 需系统 API、UI、生命周期

2.2 创建测试目录

在 DevEco Studio 中:

  1. 右键项目 → New → Directory → 输入 ohosTest(Instrument Test)或 test(Local Test)
  2. ohosTesttest 下创建 ets/test/ 子目录

或手动创建:

ohosTest/          # Instrument Test(设备/模拟器)
└── ets/
    └── test/
        └── MySdkTest.ets

test/              # Local Test(本地 JVM,可选)
└── ets/
    └── test/
        └── MyUtilTest.ets

Instrument Test 更常用,HAR 包通常使用 ohosTest

2.3 使用 Hypium 框架

oh-package.json5devDependencies 中添加:

{
  "devDependencies": {
    "@ohos/hypium": "1.0.x"
  }
}

2.4 编写测试用例

// ohosTest/ets/test/MySdkTest.ets
import { describe, it, expect } from '@ohos/hypium';
import { MyUtil } from '../../src/main/ets/MyUtil';  // 路径以实际目录层级为准

export default function test() {
  describe('MyUtil 测试', function () {
    it('add 应返回两数之和', 0, async () => {
      const result = MyUtil.add(1, 2);
      expect(result).assertEqual(3);
    });

    it('formatPath 应正确处理路径', 0, async () => {
      const path = MyUtil.formatPath('/a/b/c');
      expect(path).assertContain('a');
    });
  });
}

2.5 运行测试

  • DevEco Studio:右键测试文件 → Run 'MySdkTest'
  • 命令行
hvigorw test

2.6 常用断言

断言 说明
expect(x).assertEqual(y) 相等
expect(x).assertTrue() 为 true
expect(x).assertContain(y) 包含
expect(x).assertNotNull() 非空
expect(x).assertDeepEquals(y) 深度相等

三、发布到官方库(OpenHarmony 三方库中心仓)

3.1 注册与准备

  1. 打开 OpenHarmony 三方库中心仓
  2. 使用 Gitee 账号登录并完成实名认证
  3. 进入「个人中心」完成发布者信息

3.2 生成密钥

# 创建目录
mkdir -p ~/.ssh_ohpm

# 生成 RSA 密钥对
ssh-keygen -m PEM -t RSA -b 4096 -f ~/.ssh_ohpm/mykey -N ""

~/.ssh_ohpm/mykey.pub 公钥内容上传到中心仓个人中心的「公钥管理」。

3.3 配置 ohpm

# 设置私钥路径
ohpm config set key_path ~/.ssh_ohpm/mykey

# 设置发布 ID(个人中心获取)
ohpm config set publish_id <your_publish_id>

# 设置发布仓库地址
ohpm config set publish_registry https://ohpm.openharmony.cn/ohpm

3.4 准备发布文件

确保项目根目录包含:

文件 说明
oh-package.json5 包配置
README.md 使用说明、API 介绍、示例
CHANGELOG.md 版本变更记录
LICENSE 开源协议(如 Apache-2.0)

3.5 发布命令

# 进入 HAR 所在目录(或包含 oh-package.json5 的目录)
cd /path/to/my_sdk

# 发布
ohpm publish .

3.6 常见问题

问题 处理
Missing file "oh-package.json5" 确保在包含 oh-package.json5 的包根目录执行 ohpm publish .
签名失败 检查 key_path、公钥是否已上传
版本冲突 修改 oh-package.json5 中的 version 后重新发布
Node 版本 建议使用 Node 18+,执行 node -v 检查

四、在项目中依赖使用

4.1 配置依赖

在项目根目录的 oh-package.json5dependencies 中声明:

{
  "dependencies": {
    "my_sdk": "1.0.0"
  }
}

依赖版本号支持语义化范围,如 "1.0.x""^1.0.0"

4.2 安装依赖

ohpm install

4.3 导入使用

// 按包名导入
import { MyUtil, MyClass } from 'my_sdk';

// 使用
const result = MyUtil.doSomething();
const obj = new MyClass();

4.4 依赖私有仓或特定源

在项目根目录创建或编辑 oh-package.json5

{
  "dependencies": {
    "my_sdk": "1.0.0"
  },
  "devDependencies": {},
  "registry": "https://ohpm.openharmony.cn/ohpm"
}

或通过环境变量:

export OHPM_REGISTRY=https://ohpm.openharmony.cn/ohpm
ohpm install

五、Flutter 插件中的鸿蒙 SDK 结构示例

image_cropper 为例,其鸿蒙端结构:

image_cropper/ohos/
├── oh-package.json5           # 主包配置
├── build-profile.json5
├── src/
│   ├── main/
│   │   ├── module.json5
│   │   └── ets/
│   │       └── components/
│   │           └── plugin/
│   │               └── ImageCropperPlugin.ets
│   └── lib/
│       └── image_crop_ohos/    # 子库(HAR)
│           ├── oh-package.json5
│           ├── build-profile.json5
│           ├── index.ets       # 导出入口
│           └── src/main/ets/
│               ├── crop/
│               ├── pages/
│               └── ui/
└── ...

主包通过 dependencies: { "image_crop_ohos": "file:./src/lib/image_crop_ohos" } 引用本地子库。若将 image_crop_ohos 单独发布到中心仓,其他项目可直接依赖:

"dependencies": {
  "image_crop_ohos": "1.0.0"
}

六、流程总览

┌─────────────────┐
│ 1. 创建 SDK 项目  │
│ oh-package.json5 │
│ module.json5     │
└────────┬────────┘
         │
         ├──────────────────────────────────┐
         │ 若需 C 层能力                      │
         ▼                                  │
┌─────────────────┐                         │
│ 1.5 添加 Native  │  CMake / BUILD.gn       │
│ NAPI 模块        │  → libxxx.so            │
└────────┬────────┘                         │
         │                                  │
         ▼                                  ▼
┌─────────────────┐
│ 2. 编写单元测试   │
│ ohosTest/       │
│ @ohos/hypium    │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│ 3. 构建 HAR     │
│ hvigorw         │
│ assembleHar     │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│ 4. 发布到中心仓  │
│ ohpm publish    │
│ 密钥 + 文档      │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│ 5. 项目依赖使用  │
│ ohpm install    │
│ import 'my_sdk' │
└─────────────────┘

七、C 层(Native)开发

当 SDK 需要调用 C/C++ 代码(如高性能计算、移植三方库、系统底层能力)时,需使用 NAPI(Native API) 作为 ArkTS 与 C/C++ 的桥梁,相当于 Android 的 JNI。

7.1 两种开发方式对比

方式 适用场景 构建系统 是否需要 OpenHarmony 源码
方式一:源码 + BUILD.gn 系统级、预装应用、设备厂商 BUILD.gn ✅ 需要
方式二:应用层 + CMake 普通应用、HAR 包、发布到中心仓 CMake ❌ 不需要

7.2 方式一:基于 OpenHarmony 源码(BUILD.gn)

适用于有完整 OpenHarmony 源码、需将 NAPI 编译进系统镜像的场景。

7.2.1 目录结构
mysubsys/                    # 子系统
├── ohos.build
└── hello/                   # 组件
    └── hellonapi/          # 模块
        ├── BUILD.gn
        └── hellonapi.cpp
7.2.2 添加子系统 ohos.build

mysubsys/ohos.build

{
  "subsystem": "mysubsys",
  "parts": {
    "hello": {
      "module_list": [
        "//mysubsys/hello/hellonapi:hellonapi"
      ],
      "inner_kits": [],
      "system_kits": [],
      "test_list": []
    }
  }
}
7.2.3 注册到 build/subsystem_config.json
"mysubsys": {
  "project": "hmf/mysubsys",
  "path": "mysubsys",
  "name": "mysubsys",
  "dir": ""
}
7.2.4 C++ 源码实现 hellonapi.cpp
#include <string>
#include "napi/native_api.h"
#include "napi/native_node_api.h"

// 1. 业务接口实现
static napi_value getHelloString(napi_env env, napi_callback_info info) {
  napi_value result;
  std::string words = "Hello OpenHarmony NAPI";
  NAPI_CALL(env, napi_create_string_utf8(env, words.c_str(), words.length(), &result));
  return result;
}

// 2. 注册对外接口
static napi_value registerFunc(napi_env env, napi_value exports) {
  static napi_property_descriptor desc[] = {
    DECLARE_NAPI_FUNCTION("getHelloString", getHelloString),
  };
  NAPI_CALL(env, napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc));
  return exports;
}

// 3. 定义并注册 NAPI 模块
static napi_module hellonapiModule = {
  .nm_version = 1,
  .nm_flags = 0,
  .nm_filename = nullptr,
  .nm_register_func = registerFunc,
  .nm_modname = "hellonapi",
  .nm_priv = nullptr,
  .reserved = { 0 },
};

extern "C" __attribute__((constructor)) void hellonapiModuleRegister() {
  napi_module_register(&hellonapiModule);
}
7.2.5 BUILD.gn 构建脚本
import("//build/ohos.gni")

ohos_shared_library("hellonapi") {
  include_dirs = [
    "//foundation/arkui/napi/interfaces/kits",
    "//foundation/arkui/napi/interfaces/inner_api",
  ]
  cflags_cc = [ "-Wno-error", "-Wno-unused-function" ]
  sources = [ "hellonapi.cpp" ]
  deps = [ "//foundation/arkui/napi:ace_napi" ]
  relative_install_dir = "module"
  subsystem_name = "mysubsys"
  part_name = "hello"
}
7.2.6 编译与烧录
# 增量编译
./build.sh --product-name rk3568 --ccache --build-target=hellonapi --target-cpu arm64

# 全量编译后烧录镜像
7.2.7 ETS 调用
import hellonapi from '@ohos.hellonapi';

let str = hellonapi.getHelloString();

需在 SDK 目录下提供 @ohos.hellonapi.d.ts 声明:

declare namespace hellonapi {
  function getHelloString(): string;
}
export default hellonapi;

7.3 方式二:基于 DevEco Studio + CMake(应用层)

适用于普通应用开发者,无需 OpenHarmony 源码,在 DevEco Studio 中创建 Native C++ 模块。

7.3.1 项目结构
entry/
├── src/
│   └── main/
│       ├── cpp/                    # C++ 源码
│       │   ├── CMakeLists.txt
│       │   ├── napi_init.cpp
│       │   └── types/              # 类型声明(可选)
│       │       └── libentry/
│       │           ├── oh-package.json5
│       │           └── index.d.ts
│       └── ets/
│           └── ...
├── build-profile.json5
└── oh-package.json5
7.3.2 CMakeLists.txt
cmake_minimum_required(VERSION 3.4.1)
project(entry)

set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})
include_directories(${NATIVERENDER_ROOT_PATH})

# 生成 libentry.so
add_library(entry SHARED napi_init.cpp)

# 链接 NAPI 库
target_link_libraries(entry PUBLIC libace_napi.z.so)
7.3.3 napi_init.cpp 实现
#include "napi/native_api.h"
#include "napi/native_node_api.h"

static napi_value add(napi_env env, napi_callback_info info) {
  size_t argc = 2;
  napi_value args[2];
  napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

  int32_t a, b;
  napi_get_value_int32(env, args[0], &a);
  napi_get_value_int32(env, args[1], &b);

  napi_value result;
  napi_create_int32(env, a + b, &result);
  return result;
}

static napi_value registerFunc(napi_env env, napi_value exports) {
  napi_property_descriptor desc[] = {
    { "add", nullptr, add, nullptr, nullptr, nullptr, napi_default, nullptr }
  };
  napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
  return exports;
}

static napi_module entryModule = {
  .nm_version = 1,
  .nm_flags = 0,
  .nm_filename = nullptr,
  .nm_register_func = registerFunc,
  .nm_modname = "libentry",
  .nm_priv = nullptr,
  .reserved = { 0 },
};

extern "C" __attribute__((constructor)) void entryModuleRegister() {
  napi_module_register(&entryModule);
}
7.3.4 build-profile.json5 配置

在模块的 build-profile.json5 中配置 Native 编译:

{
  "apiType": "stageMode",
  "buildOption": {
    "externalNativeOptions": {
      "path": "./src/main/cpp/CMakeLists.txt",
      "arguments": "-DOHOS_STL=c++_shared",
      "abiFilters": ["armeabi-v7a", "arm64-v8a"]
    }
  }
}
7.3.5 类型声明与 SO 关联

src/main/cpp/types/libentry/ 下创建:

oh-package.json5

{
  "name": "libentry.so",
  "types": "./index.d.ts",
  "version": "1.0.0",
  "description": "Native NAPI module"
}

index.d.ts

export const add: (a: number, b: number) => number;
7.3.6 主模块 oh-package.json5 引用

entry/oh-package.json5dependencies 中添加:

{
  "dependencies": {
    "libentry.so": "file:./src/main/cpp/types/libentry"
  }
}
7.3.7 ETS 调用
import libentry from 'libentry.so';

let sum = libentry.add(1, 2);  // 3

7.4 调用已有三方 SO

若已有预编译的 .so 文件,无需编写 C++ 源码,只需:

  1. .so 放入 src/main/libs/<abi>/ 目录(如 src/main/libs/arm64-v8a/libmylib.so
  2. 创建类型声明包,在 oh-package.json5name 填 SO 名(如 libmylib.so),types 指向 index.d.ts
  3. 主模块 oh-package.json5dependencies 中添加:"libmylib.so": "file:./src/main/cpp/types/libmylib"
  4. build-profile.json5externalNativeOptions 中配置 abiFilters,或通过 CMake 的 add_library(IMPORTED) 引入预编译 so(具体以 DevEco 文档为准)

目录示例

src/main/
├── libs/
│   ├── arm64-v8a/
│   │   └── libmylib.so
│   └── armeabi-v7a/
│       └── libmylib.so
└── cpp/types/libmylib/
    ├── oh-package.json5
    └── index.d.ts

types/libmylib/oh-package.json5

{
  "name": "libmylib.so",
  "types": "./index.d.ts",
  "version": "1.0.0"
}

index.d.ts

export const nativeMethod: (param: string) => number;

主模块 oh-package.json5

{
  "dependencies": {
    "libmylib.so": "file:./src/main/cpp/types/libmylib"
  }
}

7.5 NAPI 常用类型转换

ETS 类型 C/C++ 获取 C/C++ 返回
number napi_get_value_int32 / napi_get_value_double napi_create_int32 / napi_create_double
string napi_get_value_string_utf8 napi_create_string_utf8
boolean napi_get_value_bool napi_get_boolean(创建 true/false 的 napi_value)
ArrayBuffer napi_get_arraybuffer_info napi_create_arraybuffer
对象 napi_get_property napi_create_object

7.6 C 层流程总览

┌─────────────────────────────────────────────────────────────┐
│ C++ 实现 (napi_init.cpp)                                      │
│  - 实现业务逻辑 napi_value xxx(napi_env, napi_callback_info) │
│  - registerFunc 注册接口                                     │
│  - napi_module_register 注册模块                             │
└────────────────────────┬────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────────────┐
│ 构建 (CMake / BUILD.gn)                                      │
│  - add_library(entry SHARED ...)                              │
│  - target_link_libraries(entry libace_napi.z.so)             │
└────────────────────────┬────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────────────┐
│ 产物 libentry.so + index.d.ts                                │
└────────────────────────┬────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────────────┐
│ ETS: import libentry from 'libentry.so'                       │
│      libentry.add(1, 2)                                       │
└─────────────────────────────────────────────────────────────┘

八、参考链接

Pinia 状态管理实战 | 从 0 到 1 搭建 Vue3 项目状态层(附模块化 / 持久化)

作者 代码煮茶
2026年3月16日 17:02

Pinia 状态管理实战 | 从 0 到 1 搭建 Vue3 项目状态层(附模块化 / 持久化)

一、为什么是 Pinia?

还记得 Vuex 吗?那个陪伴我们多年的状态管理库,有着严格的 mutations、actions 分工,写起来像在写 Java——虽然严谨,但也繁琐。

// Vuex 时代的痛
mutations: {
  SET_USER(state, user) {
    state.user = user
  }
},
actions: {
  async fetchUser({ commit }) {
    const user = await api.getUser()
    commit('SET_USER', user) // 绕了一大圈
  }
}

而 Pinia 来了,它说:「简单点,写代码的方式简单点」

// Pinia 的快乐
export const useUserStore = defineStore('user', {
  state: () => ({ user: null }),
  actions: {
    async fetchUser() {
      this.user = await api.getUser() // 直接赋值,爽!
    }
  }
})

1.1 Pinia 的核心优势

特性 Vuex Pinia
mutations ✅ 必须写 ❌ 没了
TypeScript 支持 😖 痛苦 😎 原生支持
代码量 少 30%
学习曲线 陡峭 平缓
DevTools ✅ 更好

二、项目初始化:从 0 开始搭建状态层

承接上一节的 Vite 项目,我们来深度拆解状态管理。

2.1 安装 Pinia

npm install pinia
npm install pinia-plugin-persistedstate # 持久化插件(后面会讲)

2.2 在 main.ts 中注册

// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'

// 创建 Pinia 实例
const pinia = createPinia()

const app = createApp(App)

// 注册插件(顺序很重要:先 Pinia,后路由)
app.use(pinia)
app.use(router)

app.mount('#app')

三、Store 的两种写法:你pick哪一种?

Pinia 支持两种 Store 定义方式,就像 Vue 有 Options API 和 Composition API 一样。

3.1 Options Store(类似 Vuex 风格)

// stores/counter.ts
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // state:数据源
  state: () => ({
    count: 0,
    name: '计数器'
  }),
  
  // getters:计算属性
  getters: {
    doubleCount: (state) => state.count * 2,
    // 使用 this 访问其他 getter
    displayText(): string {
      return `${this.name}: ${this.count} (翻倍后: ${this.doubleCount})`
    }
  },
  
  // actions:方法(支持同步异步)
  actions: {
    increment(amount = 1) {
      this.count += amount
    },
    async fetchAndSetCount() {
      // 模拟异步请求
      const res = await fetch('/api/count')
      const data = await res.json()
      this.count = data.count
    }
  }
})

3.2 Setup Store(Composition API 风格)⭐推荐

// stores/counter.ts (Setup Store)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // state:用 ref/reactive
  const count = ref(0)
  const name = ref('计数器')
  
  // getters:用 computed
  const doubleCount = computed(() => count.value * 2)
  const displayText = computed(() => {
    return `${name.value}: ${count.value} (翻倍后: ${doubleCount.value})`
  })
  
  // actions:普通函数
  function increment(amount = 1) {
    count.value += amount
  }
  
  async function fetchAndSetCount() {
    const res = await fetch('/api/count')
    const data = await res.json()
    count.value = data.count
  }
  
  // 必须返回所有暴露的内容
  return {
    count,
    name,
    doubleCount,
    displayText,
    increment,
    fetchAndSetCount
  }
})

为什么推荐 Setup Store?

  • 更灵活,可以组合复用逻辑
  • TypeScript 类型推导更好
  • 符合 Vue3 Composition API 的心智模型

四、模块化设计:把大象装进冰箱分几步?

企业级项目最忌讳「一个大 Store 管所有」。正确的姿势是:按业务模块拆分

4.1 推荐的项目结构

src/stores/
├── index.ts              # 统一导出
├── modules/
│   ├── user.ts           # 用户模块
│   ├── cart.ts           # 购物车模块
│   ├── product.ts        # 商品模块
│   └── app.ts            # 应用配置(主题/语言等)
├── composables/          # 可复用的组合逻辑
│   ├── useAuth.ts
│   └── useCache.ts
└── plugins/              # Pinia 插件
    └── logger.ts

4.2 用户模块(完整示例)

// stores/modules/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { UserInfo, LoginParams } from '@/types/user'
import { loginApi, getUserInfoApi } from '@/api/user'
import { ElMessage } from 'element-plus'

export const useUserStore = defineStore('user', () => {
  // --- State ---
  const token = ref<string | null>(localStorage.getItem('token'))
  const userInfo = ref<UserInfo | null>(null)
  const permissions = ref<string[]>([])
  
  // --- Getters ---
  const isLoggedIn = computed(() => !!token.value)
  const userName = computed(() => userInfo.value?.name || '游客')
  const userRole = computed(() => userInfo.value?.role || 'guest')
  const hasPermission = computed(() => (perm: string) => {
    return permissions.value.includes(perm) || userRole.value === 'admin'
  })
  
  // --- Actions ---
  // 登录
  async function login(params: LoginParams) {
    try {
      const res = await loginApi(params)
      token.value = res.token
      userInfo.value = res.userInfo
      permissions.value = res.permissions || []
      
      // 同步到 localStorage
      localStorage.setItem('token', res.token)
      
      ElMessage.success('登录成功')
      return true
    } catch (error) {
      ElMessage.error('登录失败:' + (error as Error).message)
      return false
    }
  }
  
  // 登出
  function logout() {
    token.value = null
    userInfo.value = null
    permissions.value = []
    localStorage.removeItem('token')
    ElMessage.success('已退出登录')
  }
  
  // 获取用户信息
  async function fetchUserInfo() {
    if (!token.value) return
    
    try {
      const res = await getUserInfoApi()
      userInfo.value = res.userInfo
      permissions.value = res.permissions
    } catch (error) {
      console.error('获取用户信息失败:', error)
      // token 无效,自动登出
      if ((error as any).response?.status === 401) {
        logout()
      }
    }
  }
  
  // 更新用户信息
  function updateUserInfo(data: Partial<UserInfo>) {
    if (userInfo.value) {
      userInfo.value = { ...userInfo.value, ...data }
    }
  }
  
  return {
    // state
    token,
    userInfo,
    permissions,
    // getters
    isLoggedIn,
    userName,
    userRole,
    hasPermission,
    // actions
    login,
    logout,
    fetchUserInfo,
    updateUserInfo
  }
})

4.3 应用配置模块(主题/语言)

// stores/modules/app.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

type Theme = 'light' | 'dark'
type Language = 'zh' | 'en'

export const useAppStore = defineStore('app', () => {
  // 从 localStorage 读取初始值
  const getInitialTheme = (): Theme => {
    const saved = localStorage.getItem('theme') as Theme
    return saved || 'light'
  }
  
  const getInitialLanguage = (): Language => {
    const saved = localStorage.getItem('language') as Language
    return saved || 'zh'
  }
  
  // State
  const theme = ref<Theme>(getInitialTheme())
  const language = ref<Language>(getInitialLanguage())
  const sidebarCollapsed = ref(false)
  
  // Getters
  const isDark = computed(() => theme.value === 'dark')
  const currentLanguage = computed(() => language.value)
  
  // Actions
  function setTheme(newTheme: Theme) {
    theme.value = newTheme
    localStorage.setItem('theme', newTheme)
    
    // 更新 HTML 的 data-theme 属性(用于 CSS 变量)
    document.documentElement.setAttribute('data-theme', newTheme)
  }
  
  function toggleTheme() {
    setTheme(theme.value === 'light' ? 'dark' : 'light')
  }
  
  function setLanguage(lang: Language) {
    language.value = lang
    localStorage.setItem('language', lang)
  }
  
  function toggleSidebar() {
    sidebarCollapsed.value = !sidebarCollapsed.value
  }
  
  return {
    theme,
    language,
    sidebarCollapsed,
    isDark,
    currentLanguage,
    setTheme,
    toggleTheme,
    setLanguage,
    toggleSidebar
  }
})

4.4 统一导出(方便使用)

// stores/index.ts
export { useUserStore } from './modules/user'
export { useAppStore } from './modules/app'
export { useCartStore } from './modules/cart'
export { useProductStore } from './modules/product'

// 如果需要,可以创建一个组合多个 store 的 hook
import { useUserStore } from './modules/user'
import { useAppStore } from './modules/app'

export const useStore = () => ({
  user: useUserStore(),
  app: useAppStore()
})

五、持久化:让状态「记住」自己

5.1 问题场景

用户登录后刷新页面,状态丢了——这是初学者最常见的困惑。

// 刷新后,token 没了,又要重新登录
// 用户体验:???

5.2 解决方案:pinia-plugin-persistedstate

npm install pinia-plugin-persistedstate
// src/main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate) // 注册插件

5.3 基本用法

// stores/modules/user.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    token: null,
    userInfo: null
  }),
  persist: true // 一键开启持久化
})

就这么简单!默认会:

  • 使用 localStorage
  • key 为 store名(这里是 'user')
  • 自动同步整个 state

5.4 高级配置:按需持久化

有时候我们不想存所有东西(比如敏感信息、临时数据):

// stores/modules/user.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    token: null,
    userInfo: null,
    tempSearchKeyword: '', // 这个不想持久化
    loginTime: null
  }),
  persist: {
    key: 'user-storage', // 自定义存储 key
    storage: localStorage, // 可选 sessionStorage
    paths: ['token', 'userInfo'], // 只持久化这两个字段
    beforeRestore: (context) => {
      console.log('即将恢复状态', context)
    },
    afterRestore: (context) => {
      console.log('状态恢复完成', context)
    }
  }
})

5.5 Setup Store 的持久化写法

// stores/modules/app.ts
export const useAppStore = defineStore('app', () => {
  const theme = ref('light')
  const language = ref('zh')
  
  // ... 其他逻辑
  
  return {
    theme,
    language
  }
}, {
  persist: {
    key: 'app-settings',
    paths: ['theme', 'language'] // 只持久化主题和语言
  }
})

5.6 多标签页同步

如果你想让多个标签页的状态保持同步,可以这样配置:

// stores/modules/user.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    token: null
  }),
  persist: {
    storage: localStorage,
    // 监听 storage 事件,实现多标签页同步
    beforeRestore: (context) => {
      window.addEventListener('storage', (e) => {
        if (e.key === 'user-storage') {
          // 重新恢复状态
          context.store.$hydrate()
        }
      })
    }
  }
})

六、Store 组合与复用(类似 Composables)

这是 Pinia 最强大的特性之一:Store 可以像组合式函数一样复用-5

6.1 场景:多个模块需要认证逻辑

假设你的应用有多个模块都需要用到用户认证状态,不想在每个 Store 里重复写一遍登录/登出逻辑。

// stores/composables/useAuth.ts
import { ref, computed } from 'vue'

export function useAuth() {
  const isLoggedIn = ref(false)
  const username = ref('')
  
  function login(name: string) {
    isLoggedIn.value = true
    username.value = name
  }
  
  function logout() {
    isLoggedIn.value = false
    username.value = ''
  }
  
  return {
    isLoggedIn,
    username,
    login,
    logout
  }
}

6.2 在 Store 中复用

// stores/modules/user.ts
import { defineStore } from 'pinia'
import { useAuth } from '../composables/useAuth'

export const useUserStore = defineStore('user', () => {
  // 复用认证逻辑
  const { isLoggedIn, username, login, logout } = useAuth()
  
  // 扩展用户专属状态
  const userId = ref<number | null>(null)
  const avatar = ref('')
  
  // 扩展登录方法
  const loginWithId = (name: string, id: number) => {
    login(name) // 调用复用的 login
    userId.value = id
  }
  
  return {
    isLoggedIn,
    username,
    userId,
    avatar,
    login: loginWithId,
    logout
  }
})

// stores/modules/admin.ts
import { defineStore } from 'pinia'
import { useAuth } from '../composables/useAuth'

export const useAdminStore = defineStore('admin', () => {
  // 同样复用认证逻辑
  const { isLoggedIn, username, login, logout } = useAuth()
  
  // 管理员特有的状态
  const adminLevel = ref(1)
  
  return {
    isLoggedIn,
    username,
    adminLevel,
    login,
    logout
  }
})

6.3 场景:数据缓存逻辑复用

多个模块都需要缓存数据(比如商品列表、订单列表),可以封装一个通用的缓存逻辑-5

// stores/composables/useCache.ts
import { ref } from 'vue'

export function useCache<T>(key: string, fetchFn: () => Promise<T>, expireTime = 5 * 60 * 1000) {
  const cachedData = ref<T | null>(null)
  const lastFetchTime = ref<number | null>(null)
  
  const getData = async () => {
    const now = Date.now()
    
    // 如果有缓存且未过期,直接返回缓存
    if (cachedData.value && lastFetchTime.value && (now - lastFetchTime.value) < expireTime) {
      console.log(`[缓存命中] ${key}`)
      return cachedData.value
    }
    
    // 否则重新获取
    console.log(`[缓存失效] ${key},重新获取`)
    const freshData = await fetchFn()
    cachedData.value = freshData
    lastFetchTime.value = now
    return freshData
  }
  
  const clearCache = () => {
    cachedData.value = null
    lastFetchTime.value = null
  }
  
  return {
    getData,
    clearCache,
    cachedData
  }
}
// stores/modules/product.ts
import { defineStore } from 'pinia'
import { useCache } from '../composables/useCache'
import { fetchProductList } from '@/api/product'

export const useProductStore = defineStore('product', () => {
  const { getData, clearCache, cachedData } = useCache(
    'products',
    fetchProductList,
    10 * 60 * 1000 // 10分钟缓存
  )
  
  const loadProducts = async () => {
    return await getData()
  }
  
  return {
    products: cachedData,
    loadProducts,
    clearCache
  }
})

七、在组件中使用:三种姿势

7.1 基础用法(最常用)

<!-- views/Profile.vue -->
<template>
  <div class="profile">
    <h2>个人中心</h2>
    
    <div v-if="userStore.isLoggedIn">
      <el-avatar :src="userStore.userInfo?.avatar" />
      <p>用户名:{{ userStore.userName }}</p>
      <p>角色:{{ userStore.userRole }}</p>
      
      <el-button @click="handleLogout">退出登录</el-button>
    </div>
    
    <div v-else>
      <p>请先登录</p>
      <el-button @click="goToLogin">去登录</el-button>
    </div>
    
    <!-- 测试权限指令 -->
    <button v-if="userStore.hasPermission('product:edit')">
      编辑商品
    </button>
  </div>
</template>

<script setup lang="ts">
import { useUserStore } from '@/stores/modules/user'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'

const userStore = useUserStore()
const router = useRouter()

const handleLogout = () => {
  ElMessageBox.confirm('确认退出登录吗?', '提示', {
    type: 'info'
  }).then(() => {
    userStore.logout()
    router.push('/login')
  })
}

const goToLogin = () => {
  router.push('/login')
}
</script>

7.2 解构赋值(小心丢失响应性)

<script setup>
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia' // 重要!

const userStore = useUserStore()

// ❌ 错误:直接解构会丢失响应性
const { userName, isLoggedIn } = userStore

// ✅ 正确:使用 storeToRefs
const { userName, isLoggedIn, userInfo } = storeToRefs(userStore)

// actions 可以直接解构(不会丢失)
const { login, logout } = userStore
</script>

7.3 在路由守卫中使用

// src/router/index.ts
import { useUserStore } from '@/stores/modules/user'

router.beforeEach((to, from, next) => {
  // 需要手动获取 store 实例
  const userStore = useUserStore()
  
  if (to.meta.requiresAuth && !userStore.isLoggedIn) {
    next({ path: '/login', query: { redirect: to.fullPath } })
  } else {
    next()
  }
})

7.4 在 axios 拦截器中使用

// src/utils/request.ts
import { useUserStore } from '@/stores/modules/user'

request.interceptors.request.use((config) => {
  const userStore = useUserStore()
  
  if (userStore.token) {
    config.headers.Authorization = `Bearer ${userStore.token}`
  }
  
  return config
})

request.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      const userStore = useUserStore()
      userStore.logout() // 自动清除状态
      router.push('/login')
    }
    return Promise.reject(error)
  }
)

八、Pinia 插件开发:定制你的专属功能

8.1 日志插件:记录所有状态变化

// stores/plugins/logger.ts
import type { PiniaPluginContext } from 'pinia'

export function loggerPlugin({ store, options }: PiniaPluginContext) {
  // 订阅 state 变化
  store.$subscribe((mutation, state) => {
    console.group(`📝 [${store.$id}] 状态变化`)
    console.log('类型:', mutation.type)
    console.log('载荷:', mutation.payload)
    console.log('新状态:', state)
    console.groupEnd()
  })
  
  // 订阅 action 调用
  store.$onAction(({
    name,       // action 名称
    store,      // store 实例
    args,       // 参数
    after,      // 成功后回调
    onError     // 失败后回调
  }) => {
    console.log(`🚀 [${store.$id}] 调用 action: ${name}`, args)
    
    after(result => {
      console.log(`✅ [${store.$id}] action 成功: ${name}`, result)
    })
    
    onError(error => {
      console.error(`❌ [${store.$id}] action 失败: ${name}`, error)
    })
  })
}

8.2 注册插件

// src/main.ts
import { loggerPlugin } from './stores/plugins/logger'

const pinia = createPinia()
pinia.use(loggerPlugin) // 全局生效

8.3 自定义持久化插件

// stores/plugins/customPersist.ts
export function customPersist({ store }: PiniaPluginContext) {
  // 从 localStorage 恢复状态
  const savedState = localStorage.getItem(`pinia:${store.$id}`)
  if (savedState) {
    store.$patch(JSON.parse(savedState))
  }
  
  // 订阅变化并保存
  store.$subscribe((mutation, state) => {
    localStorage.setItem(`pinia:${store.$id}`, JSON.stringify(state))
  })
}

九、性能优化与最佳实践

9.1 避免在 getter 中返回新对象

// ❌ 错误:每次访问都返回新对象,破坏缓存
getters: {
  filteredList: (state) => {
    return state.list.filter(item => item.active) // 每次都是新数组
  }
}

// ✅ 正确:getter 本身会缓存计算结果
getters: {
  activeCount: (state) => state.list.filter(item => item.active).length
}

9.2 按需加载 Store

// 在组件中动态导入(适用于大型应用)
const useUserStore = () => import('@/stores/user').then(m => m.useUserStore)

// 或者在路由懒加载时使用
const UserModule = () => import('@/views/User.vue')

9.3 使用 shallowRef 优化大对象

import { shallowRef } from 'vue'

// 对于大型对象,不需要深度响应式
const bigData = shallowRef(null)

// 只有整体替换时才触发更新
bigData.value = await fetchLargeDataset()

9.4 重置 Store 状态

// 添加重置方法
export const useUserStore = defineStore('user', () => {
  const initialState = {
    token: null,
    userInfo: null,
    permissions: []
  }
  
  const token = ref(initialState.token)
  const userInfo = ref(initialState.userInfo)
  const permissions = ref(initialState.permissions)
  
  function $reset() {
    token.value = initialState.token
    userInfo.value = initialState.userInfo
    permissions.value = initialState.permissions
    localStorage.removeItem('token')
  }
  
  return {
    token,
    userInfo,
    permissions,
    $reset,
    // ... 其他 actions
  }
})

十、TypeScript 类型增强

10.1 为 store 添加类型

// stores/modules/user.ts
import type { UserInfo } from '@/types/user'

export interface UserState {
  token: string | null
  userInfo: UserInfo | null
  permissions: string[]
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    token: null,
    userInfo: null,
    permissions: []
  })
})

10.2 扩展 Pinia 类型(为所有 store 添加通用方法)

// types/pinia.d.ts
import 'pinia'

declare module 'pinia' {
  export interface PiniaCustomProperties {
    // 给所有 store 添加 $reset 方法
    $reset(): void
    
    // 添加自定义属性
    readonly $id: string
  }
  
  export interface PiniaCustomStateProperties<S> {
    // 给所有 state 添加 toJSON 方法
    toJSON(): S
  }
}

10.3 为插件添加类型

// stores/plugins/logger.ts
import type { PiniaPluginContext } from 'pinia'

export interface LoggerPluginOptions {
  enabled?: boolean
  filter?: (storeId: string) => boolean
}

export function loggerPlugin(options: LoggerPluginOptions = {}) {
  return (context: PiniaPluginContext) => {
    // 插件逻辑
  }
}

十一、实战演练:完整的购物车模块

让我们把学到的知识串起来,实现一个完整的购物车模块。

11.1 购物车 Store

// stores/modules/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { CartItem, Product } from '@/types'
import { ElMessage } from 'element-plus'

export const useCartStore = defineStore('cart', () => {
  // --- State ---
  const items = ref<CartItem[]>([])
  const loading = ref(false)
  const lastUpdated = ref<Date | null>(null)
  
  // --- Getters ---
  const totalCount = computed(() => {
    return items.value.reduce((sum, item) => sum + item.quantity, 0)
  })
  
  const totalPrice = computed(() => {
    return items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  })
  
  const isEmpty = computed(() => items.value.length === 0)
  
  const formattedTotal = computed(() => {
    return ${totalPrice.value.toFixed(2)}`
  })
  
  // --- Actions ---
  function addItem(product: Product, quantity = 1) {
    const existing = items.value.find(item => item.id === product.id)
    
    if (existing) {
      existing.quantity += quantity
    } else {
      items.value.push({
        id: product.id,
        name: product.name,
        price: product.price,
        image: product.image,
        quantity
      })
    }
    
    lastUpdated.value = new Date()
    ElMessage.success(`已添加 ${product.name} 到购物车`)
  }
  
  function removeItem(productId: number) {
    const index = items.value.findIndex(item => item.id === productId)
    if (index > -1) {
      const removed = items.value[index]
      items.value.splice(index, 1)
      ElMessage.success(`已移除 ${removed.name}`)
    }
  }
  
  function updateQuantity(productId: number, quantity: number) {
    const item = items.value.find(item => item.id === productId)
    if (item) {
      if (quantity <= 0) {
        removeItem(productId)
      } else {
        item.quantity = quantity
      }
    }
  }
  
  function clearCart() {
    items.value = []
    ElMessage.success('购物车已清空')
  }
  
  async function checkout() {
    if (isEmpty.value) {
      ElMessage.warning('购物车是空的')
      return false
    }
    
    loading.value = true
    try {
      // 模拟提交订单
      await new Promise(resolve => setTimeout(resolve, 1500))
      
      // 提交成功后清空购物车
      clearCart()
      ElMessage.success('下单成功!')
      return true
    } catch (error) {
      ElMessage.error('下单失败,请重试')
      return false
    } finally {
      loading.value = false
    }
  }
  
  return {
    // state
    items,
    loading,
    lastUpdated,
    // getters
    totalCount,
    totalPrice,
    isEmpty,
    formattedTotal,
    // actions
    addItem,
    removeItem,
    updateQuantity,
    clearCart,
    checkout
  }
}, {
  persist: {
    key: 'shopping-cart',
    paths: ['items'], // 只持久化商品列表
    storage: localStorage
  }
})

11.2 在组件中使用

<!-- components/CartIcon.vue -->
<template>
  <el-badge :value="cartStore.totalCount" :hidden="cartStore.isEmpty">
    <el-button :icon="ShoppingCart" @click="showCartDrawer = true">
      购物车
    </el-button>
  </el-badge>
  
  <el-drawer v-model="showCartDrawer" title="购物车" size="400px">
    <div v-loading="cartStore.loading" class="cart-content">
      <template v-if="!cartStore.isEmpty">
        <div v-for="item in cartStore.items" :key="item.id" class="cart-item">
          <img :src="item.image" :alt="item.name" class="item-image">
          <div class="item-info">
            <h4>{{ item.name }}</h4>
            <p class="item-price">¥{{ item.price }}</p>
          </div>
          <div class="item-actions">
            <el-input-number
              v-model="item.quantity"
              :min="1"
              :max="99"
              size="small"
              @change="handleQuantityChange(item.id, $event)"
            />
            <el-button
              type="danger"
              :icon="Delete"
              link
              @click="cartStore.removeItem(item.id)"
            />
          </div>
        </div>
        
        <div class="cart-footer">
          <div class="total">
            <span>总计:</span>
            <span class="total-price">{{ cartStore.formattedTotal }}</span>
          </div>
          <el-button
            type="primary"
            :loading="cartStore.loading"
            @click="handleCheckout"
          >
            结算
          </el-button>
        </div>
      </template>
      
      <el-empty v-else description="购物车空空如也" />
    </div>
  </el-drawer>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { ShoppingCart, Delete } from '@element-plus/icons-vue'
import { useCartStore } from '@/stores/modules/cart'
import { ElMessageBox } from 'element-plus'

const cartStore = useCartStore()
const showCartDrawer = ref(false)

const handleQuantityChange = (productId: number, quantity: number) => {
  cartStore.updateQuantity(productId, quantity)
}

const handleCheckout = async () => {
  ElMessageBox.confirm('确认提交订单吗?', '提示', {
    type: 'info'
  }).then(async () => {
    const success = await cartStore.checkout()
    if (success) {
      showCartDrawer.value = false
    }
  })
}
</script>

<style scoped lang="scss">
.cart-content {
  padding: 20px;
  height: 100%;
  display: flex;
  flex-direction: column;
}

.cart-item {
  display: flex;
  align-items: center;
  padding: 12px 0;
  border-bottom: 1px solid #eee;
  
  .item-image {
    width: 60px;
    height: 60px;
    object-fit: cover;
    border-radius: 4px;
    margin-right: 12px;
  }
  
  .item-info {
    flex: 1;
    
    h4 {
      margin: 0 0 4px;
      font-size: 14px;
    }
    
    .item-price {
      margin: 0;
      color: #f56c6c;
      font-weight: bold;
    }
  }
  
  .item-actions {
    display: flex;
    align-items: center;
    gap: 8px;
  }
}

.cart-footer {
  margin-top: auto;
  padding-top: 20px;
  border-top: 2px solid #eee;
  
  .total {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 16px;
    font-size: 16px;
    
    .total-price {
      color: #f56c6c;
      font-size: 20px;
      font-weight: bold;
    }
  }
}
</style>

十二、总结与进阶

12.1 Pinia 核心要点回顾

概念 作用 类比
State 存储数据 组件的 data
Getter 计算派生状态 组件的 computed
Action 修改状态的方法 组件的 methods
Plugin 扩展功能 全局混入
Store 上述内容的容器 一个模块

12.2 什么时候用 Pinia?

  • ✅ 多个组件共享同一份数据
  • ✅ 数据需要跨路由持久化
  • ✅ 有复杂的业务逻辑需要复用
  • ✅ 需要 DevTools 调试状态变化
  • ❌ 简单的父子组件通信(用 props/emit 就够了)

12.3 下一步学习方向

  1. Pinia + Vue Query:服务端状态管理
  2. Pinia + WebSocket:实时数据同步
  3. Pinia 源码阅读:理解响应式原理
  4. 自定义插件开发:根据项目需求定制

12.4 写在最后

从 Vuex 到 Pinia,不仅仅是 API 的简化,更是对「状态管理应该简单」这一理念的回归。就像 Evan You 说的:

"Pinia 成功地在保持清晰的设计分离的同时,提供了简单、小巧且易于上手的 API。"

掌握 Pinia,不是为了炫技,而是为了让代码更清晰、维护更简单。现在,去重构你项目里的状态管理吧!🚀

工作五年前端,终于靠OpenClaw拥有了专属个人网站

作者 Ferries
2026年3月16日 17:01

作为一名工作了五年的前端开发工程师,我一直都没有一个专属的个人网站。openclaw出现之后 我终于弄好了 大家可以点进去看一下http://119.23.54.57:3000/

image.png

其实之前并不是没有想过自己开发一个,也确实尝试着手去弄,但开发一个完整的个人网站太花费时间和精力——既要设计页面、编写代码,还要处理部署和兼容问题,往往弄到一半,就被繁杂的工作挤占了所有时间,最后只能不了了之。

最近OpenClaw大火,铺天盖地的新闻让我再次关注到它。其实早在OpenClaw刚出来的时候,我就看到了相关消息,只是当时没太放在心上,也没想着用它来搭建个人网站。

直到最近,看着身边越来越多人用它高效完成各种需求,我突然萌生一个想法:我的个人网站,是不是也可以让它帮我实现?

没想到,仅仅用了几天时间,OpenClaw就把我对个人网站的所有想法都落地了。虽然成品还有很多细节问题需要优化,但这种不用自己写一行代码、只需要清晰表达需求、自己当“产品经理” 的感觉,实在太爽了。

更关键的是,它可以24小时无休工作,不用像我一样被工作打断节奏,省去了大量重复繁琐的操作。今天,我就把自己的这份经验分享出来,希望能帮到和我一样,想拥有个人网站却没时间亲手开发的朋友,让每个人都能轻松拥有自己的专属个人网站。

一、服务器选择

国内服务器厂商有很多,比如腾讯云、阿里云、百度云等。我自己之前就买过阿里云的服务器,其实最基础的配置就完全够用,而且新人还有体验福利和优惠,所以下面我就以阿里云为例,给大家讲解配置方法。

值得一提的是,阿里云自带OpenClaw专属镜像,部署起来很便捷;当然,如果你想挑战手动配置,也完全可以,接下来我们就重点说说手动配置的具体步骤。

image.png

二、OpenClaw手动安装(多系统适配)

手动安装OpenClaw很简单,无需复杂操作,直接执行对应系统的命令,就能一键完成安装,不同系统的安装命令如下,大家按需复制即可:

MacOS/Linux 系统(终端执行):

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

Windows 系统(需用PowerShell执行):

iwr -useb https://openclaw.ai/install.ps1 | iex

执行命令后,等待几分钟即可完成安装,中途可能会遇到3个常见问题,大家对应解决即可,非常简单:

  1. 提示“没有node环境”:直接访问Node.js官网(nodejs.org/en/download),下载对应系统版本,一键安装即可,无需额外配置。

  2. 提示“没有git”:访问Git官网(git-scm.com/),下载安装,安装完成后重启终端/ PowerShell即可生效。

  3. Windows PowerShell提示“不支持脚本”:在PowerShell中直接输入以下命令,回车确认即可解决:

Set-ExecutionPolicy RemoteSigned

三、OpenClaw配置步骤

当我们完成OpenClaw安装后,无需复杂操作,只需执行一条命令,就能进入配置流程,全程跟着提示走即可,具体步骤如下:

  1. 打开终端(MacOS/Linux)或PowerShell(Windows),输入以下命令,回车启动OpenClaw配置向导:
openclaw onboard

2. 启动配置后,会出现引导提示,除了配置模型 和 其他的我们选择默认配置就ok

image.png

当看到这个页面的时候 我们选择Minimax 经过本人测试 大概有一周的免费试用时间

image.png

然后我们选择 Minimax OAuth 会给你一个url 打开这个url注册一下就可以了 下一步就是配置聊天的模型 按照你的需求和提示配置就ok image.png

四: 总结

当全部配置完成你就可以用大白话和他对话了 以下就是我和它的一些对话内容 可以参考

有任何问题欢迎留言 我会积极回复大家

1f1f7d5cc69e99b78e6b49b72e503c12.jpg

5eb4ab9f0746d367135d217741b4e583.jpg

00d92a0eaf85b9d788672d5b25632fcd.jpg

❌
❌