阅读视图

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

Tailwind CSS v4 — 当框架猜不透你的心思

你在项目里写下 text-(--brand-color),满心期待文字变成品牌色,刷新页面——字号变了。

颜色没变,字号倒是歪了。你盯着屏幕,开始怀疑人生。

别急,这不是 bug,是 Tailwind 在"猜"你的意图——而且猜错了。

这篇文章会带你走一遍真实的开发场景。从最基础的任意值用法开始,一步步遇到更复杂的情况,直到你理解 Tailwind 为什么会猜错,以及如何优雅地纠正它。


场景一:设计稿给了个非标准值

设计师甩过来一张稿子,标注写着:top: 117px、背景色 #bada55

你翻了一遍 Tailwind 的间距和颜色系统——没有。top-28112pxtop-32128px,不上不下。

这时候就需要任意值(Arbitrary Values)了。用方括号 [] 把具体的 CSS 值包起来:

<div class="top-[117px]">精确定位</div>

<button class="bg-[#bada55]">这个颜色名字挺快乐</button>

<div class="left-[calc(50%-4rem)]">居中偏移</div>

方括号里可以放任何合法的 CSS 值——像素、百分比、calc() 表达式,甚至 var()。Tailwind 会原封不动地把它编译成对应的 CSS。

CSS 变量怎么写?

如果你的值存在 CSS 变量里,v4 提供了一个更简洁的语法——用圆括号 () 代替方括号:

<!-- v4 新语法:圆括号 + 裸变量名 -->
<div class="bg-(--brand-color)">用 CSS 变量设背景色</div>

<!-- 当然,显式写 var() 依然有效 -->
<div class="bg-[var(--brand-color)]">效果一样</div>

这是 v4 相对 v3 的一个重要变化。v3 里 CSS 变量简写用的是方括号 bg-[--brand-color],v4 改成了圆括号 bg-(--brand-color)。这个改动不是为了好看——而是为了解决歧义问题,后面会详细说。


场景二:Tailwind 没有的 CSS 属性

项目里需要用 mask-type 控制 SVG 遮罩行为。你搜了一圈文档,Tailwind 没有提供这个工具类。

任意属性(Arbitrary Properties)登场。用方括号把完整的 属性:值 对写进去:

<div class="[mask-type:luminance]">
  SVG 遮罩使用亮度模式
</div>

它和修饰符(modifier)配合也没问题:

<div class="[mask-type:luminance] hover:[mask-type:alpha]">
  hover 时切换为 alpha 模式
</div>

用任意属性设置 CSS 变量

这个语法还有一个很实用的场景——在 HTML 里直接设置 CSS 变量的值:

<div class="[--scroll-offset:56px] lg:[--scroll-offset:44px]">
  不同断点下设置不同的滚动偏移量
</div>

配合响应式前缀,你可以把 CSS 变量当作"响应式参数"来用,而不用写额外的媒体查询。


场景三:选择器玩不转了

产品经理说:"列表前三项要加下划线,hover 的时候。"

:nth-child(-n+3):hover —— 这选择器 Tailwind 的内置修饰符肯定不够用。

任意变体(Arbitrary Variants)可以搞定:

<ul>
  <li class="[&:nth-child(-n+3)]:hover:underline">第 1 项</li>
  <li class="[&:nth-child(-n+3)]:hover:underline">第 2 项</li>
  <li class="[&:nth-child(-n+3)]:hover:underline">第 3 项</li>
  <li>第 4 项(不受影响)</li>
</ul>

方括号里的 & 代表当前元素。Tailwind 会把 & 替换成生成的类名,编译出你需要的选择器。

再来几个例子:

<!-- 所有子 p 元素加上 margin-top -->
<div class="[&_p]:mt-4">
  <p>我有 margin-top</p>
  <p>我也有</p>
</div>

<!-- 当元素有 .is-dragging 类时 -->
<li class="[&.is-dragging]:cursor-grabbing">
  拖拽中换光标
</li>

<!-- @supports 查询 -->
<div class="flex [@supports(display:grid)]:grid">
  支持 grid 就用 grid,否则用 flex
</div>

v4 变体堆叠顺序变了:从左往右读,和 CSS 选择器一致。v3 是从右往左。


场景四:值里面有空格怎么办?

你在写 Grid 布局,需要 grid-template-columns: 1fr 500px 2fr

直接写 grid-cols-[1fr 500px 2fr]?Tailwind 会把空格当作类名分隔符,直接报错。

解决方案:用下划线代替空格。

<div class="grid grid-cols-[1fr_500px_2fr]">
  <!-- 编译后:grid-template-columns: 1fr 500px 2fr -->
</div>

Tailwind 在编译时会自动把下划线转成空格。

但是 URL 里的下划线怎么办?

放心,Tailwind 足够聪明,会保留 URL 里的下划线:

<div class="bg-[url('/what_a_rush.png')]">
  <!-- 不会被转成空格,保持原样 -->
</div>

真的需要下划线呢?

用反斜杠转义:

<div class="before:content-['hello_world']">
  <!-- 编译后:content: 'hello_world' -->
</div>

JSX 里反斜杠被吃了?

JSX 的字符串会把 `` 当转义字符处理。用 String.raw 模板标签:

<div className={String.raw`before:content-['hello_world']`}>
  在 JSX 中安全地使用下划线
</div>

核心场景:Tailwind 猜错了

好,前面都是热身。现在进入本文的重头戏。

问题复现

回到开头的例子。你在 CSS 里定义了一个品牌色变量:

:root {
  --brand-color: #e63946;
}

然后你写下:

<p class="text-(--brand-color)">品牌色文字</p>

你期望的是文字变成红色。但实际效果是——字号变了,颜色没变。

为什么?

因为 text-* 在 Tailwind 里是一个多义命名空间。它同时映射了两种不同的 CSS 属性:

  • text-lgtext-smfont-size(字号)
  • text-red-500text-blackcolor(颜色)

当你写字面值的时候,Tailwind 能从值本身推断出类型:

<!-- Tailwind 看到 22px,推断为 length → font-size -->
<div class="text-[22px]">这是字号</div>

<!-- Tailwind 看到 #bada55,推断为 color → color -->
<div class="text-[#bada55]">这是颜色</div>

22px 明显是长度,#bada55 明显是颜色——推断没问题。

但 CSS 变量是个黑盒

当你写 text-(--brand-color) 的时候,Tailwind 看不到变量里存的是什么。它不知道 --brand-color 是颜色还是尺寸还是别的什么。

这时候 Tailwind 只能猜。而默认的猜测策略可能不符合你的预期——它可能把变量当成了 font-size 而不是 color

于是你的文字不是变红了,而是字号变成了 var(--brand-color),浏览器无法解析为有效字号,表现就很诡异。

解决方案:CSS 数据类型提示

在圆括号里,变量名前面加上类型提示

<!-- 明确告诉 Tailwind:这是颜色 -->
<p class="text-(color:--brand-color)">品牌色文字 ✓</p>

<!-- 明确告诉 Tailwind:这是字号 -->
<p class="text-(length:--font-size)">自定义字号 ✓</p>

语法格式:工具类-(类型:--变量名)

Tailwind 看到 color: 前缀,就知道应该把这个变量编译成 color 属性而不是 font-size。歧义消除。

方括号里的写法

如果你用 var() 的显式写法,类型提示放在方括号开头:

<p class="text-[color:var(--brand-color)]">同样有效</p>

不止 text-*

text-* 是最经典的歧义案例,但不是唯一一个。以下工具类都存在类似的命名空间冲突:

bg-* — 背景相关

<!-- 背景色 -->
<div class="bg-(color:--my-var)">背景颜色</div>

<!-- 背景图 -->
<div class="bg-(image:--my-var)">背景图片</div>

<!-- 背景位置 -->
<div class="bg-(position:--my-var)">背景位置</div>

bg-* 的歧义更多——它可以是颜色、图片、尺寸、位置,不加类型提示几乎必出问题。

border-* — 边框相关

<!-- 边框颜色 -->
<div class="border-(color:--my-var)">边框颜色</div>

<!-- 边框宽度 -->
<div class="border-(length:--my-var)">边框宽度</div>

shadow-* — 阴影相关

<div class="shadow-(color:--my-var)">阴影颜色</div>

decoration-* — 文本装饰

<!-- 装饰线颜色 -->
<div class="decoration-(color:--my-var)">装饰色</div>

<!-- 装饰线粗细 -->
<div class="decoration-(length:--my-var)">装饰粗细</div>

规律总结:只要一个工具类前缀同时对应多种 CSS 属性(颜色 + 尺寸最常见),用 CSS 变量时就需要类型提示。用字面值(如 #fff2px)时不需要,因为 Tailwind 能自动推断。


可用的类型提示一览

Tailwind v4 支持的 CSS 数据类型提示:

类型关键词 匹配什么 示例值
color CSS 颜色 #fffrgb(...)oklch(...)
length 长度 16px1rem2em
percentage 百分比 50%
number 数值 1.50
integer 整数 14
angle 角度 45deg0.25turn
url URL url(...)
image CSS 图片类型 url(...)linear-gradient(...)
position 位置 centertop left
ratio 比例 16/9
line-width 线宽 边框宽度值
bg-size 背景尺寸 covercontain
family-name 字体族名 字体名称

速查表

把全文涉及的语法整理在一起,方便随时翻阅:

场景 语法 示例
字面任意值 工具类-[值] top-[117px]bg-[#bada55]
CSS 变量简写 工具类-(--变量) bg-(--brand-color)
CSS 变量 + var() 工具类-[var(--变量)] bg-[var(--brand-color)]
类型提示(圆括号) 工具类-(类型:--变量) text-(color:--brand-color)
类型提示(方括号) 工具类-[类型:var(--变量)] text-[color:var(--brand-color)]
任意属性 [属性:值] [mask-type:luminance]
设置 CSS 变量 [--变量:值] [--scroll-offset:56px]
任意变体 [选择器]:工具类 [&:nth-child(3)]:underline
空格用下划线 _ 代替空格 grid-cols-[1fr_500px_2fr]
真正的下划线 _ 转义 content-['hello_world']
JSX 中的转义 String.raw`...` String.raw`content-['a_b']`

手写无限画布4 —— 从视觉图元到元数据对象

画布上的每一个像素都是稍纵即逝的,真正永恒的,是背后那套被精心设计的元数据(Metadata)规范。

尽管在前面的篇章中,我们一路披荆斩棘,搞定了坐标系、渲染层和基本交互,让演示工程初具雏形。但 Canvas 本质上只是一块没有记忆的像素面板。

要想从理论走向工程落地,实现支持持久化与多人协同的业务,最核心的架构法则在于:必须将画布上的任意元素,都抽象并定义为可传输、可持久化的元数据对象(Metadata Object)。

元数据定义 (Metadata Definition)

我们要彻底抛弃"直接在画布里 new Konva.Rect()" 的思维惯性。

在一个成熟的白板应用架构中,画布引擎只是一个"读报机器",它读的报纸,就是我们定义的元数据规范(Metadata Schema)

为了达到我们最终建立一个类似 Excalidraw 的既定目标,我们在规范数据结构时,绝不能只停留在纯粹的"几何图形"定义上。我们必须在其之上,附加明确的预制业务概念。我们不仅要描述它是一个 Rect(矩形),更要描述它在业务里是一张 StickyNote(便利贴),还是一根 Connector(连接线)。

如下代码,这就是我们实际落地的元数据规范:

// src/schema/types.ts

// 所有图元共享的基因——它们必须遵守的基础契约
export interface BaseElementData {
  id: string; // 唯一宇宙编号,协同与更新的基石
  type: ElementType; // 业务大类
  x: number;
  y: number;
  width: number;
  height: number;
  hitColor: string; // 上一篇的命中测试色值,也要元数据化
  strokeColor: string;
  backgroundColor: string;
  opacity: number;
  zIndex: number; // 层级控制,决定覆盖关系
  isLocked: boolean; // 业务属性:用户是否锁定了该元素
  // ...
}

// 业务派生:形状、文字、线条各有自己的专属字段
export interface ShapeElementData extends BaseElementData {
  type: "rectangle" | "ellipse" | "diamond";
}

export interface LinearElementData extends BaseElementData {
  type: "arrow" | "line";
  points: number[][]; // 途经的折点
  startArrowhead: "arrow" | "triangle" | "none";
  endArrowhead: "arrow" | "triangle" | "none";
  startBindingId: string | null; // 线头绑定的元素 ID
  endBindingId: string | null;
}

// 终极联合类型:无限画布的唯一真理对象
export type CanvasElementData =
  | ShapeElementData
  | TextElementData
  | LinearElementData;

注意一个关键细节:上一篇讲到的命中测试色值 hitColor,也被我们收编进了元数据定义。从此刻起,一个图形的一切——它在哪、它多大、它长什么样、它怎么被点中——全部由这颗 JSON 树的一个节点来描述。再也没有游离在数据结构之外的"野状态"了。

纯元数据驱动带来的红利

当你把屏幕上所有花里胡哨的图形,都严格浓缩成上述哪怕只有几百 KB 大小的纯 JSON 文本时,奇迹发生了:

  1. 绝对纯净的持久化与协同:现在保存用户作品,不过就是做一次 JSON.stringify。而做多人协同,也不过是当某个 Node 的 x 发生改变时,通过 WebSocket 向房间里的其他人广播一个极小的 Diff 补丁 {"id": "node_1", "x": 250}
  2. 极其廉价的时间机器:撤销(Undo)与重做(Redo)再也不是什么黑科技。因为数据被极度抽象了,你只需要使用类似 Immer.js 等不可变数据结构工具,把每一步操作的 JSON 快照(或者 Delta 片段)保存在数组里,指针前后移动,就是时间倒流。
  3. 彻底的跨端解耦:这套 Metadata 甚至都不知道 Canvas 的存在。你可以把同一团 JSON 丢给 Web 端用 Konva 渲染,扔给 iOS 用 CoreGraphics 渲染,或者丢给后端 Node 帮你无头渲染出一张 PDF。

接入状态管理:Zustand

有了元数据定义,接下来的问题是:这颗 JSON 树放在哪?谁来读它、写它、通知别人它变了?

绝不能让 Konva 本身(View 层)既当爹又当妈地去存储这些业务数据,这会导致视图状态和业务逻辑严重耦合。我们引入现代轻量级状态管理库 zustand 作为单一事实来源(Single Source of Truth),对整个工程做一次严格的分层。

打开 src/store.ts,这是整个工程的心脏:

// src/store.ts

export const canvasStore = createStore<CanvasState>((set) => ({
  // 全部元素的 Record 字典,key 为 id
  elements: initialElements,
  // 应用运行时状态(当前工具、缩放、视口偏移、选中态...)
  appState: defaultAppState,

  // ——— 以下全是纯函数式的 Actions ———
  updateElementProps: (id, props) =>
    set((state) => ({
      elements: {
        ...state.elements,
        [id]: { ...state.elements[id], ...props },
      },
    })),

  addElement: (el) =>
    set((state) => ({
      elements: { ...state.elements, [el.id]: el },
    })),

  selectElement: (id) =>
    set((state) => ({
      appState: { ...state.appState, selectedElementIds: id ? [id] : [] },
    })),
  // ...
}));

值得反复品味的是:无论是创建元素、更新坐标、还是切换选中态,Store 里执行的全部都是浅拷贝替换{ ...state.elements, [id]: ... })。没有任何副作用,没有任何直接 DOM 操作。这意味着前面说的 Undo/Redo "时间机器",你只需要把这些 Immutable 快照存进一个栈里就好了——就是这么廉价。


引擎订阅:一个极致的"哑巴渲染器"

Store 管数据,那谁管画面?答案是 src/engine/index.ts——我们的引擎总控 EngineFacade。它做的事情极其克制:只读数据,只画画面

// src/engine/index.ts — 订阅逻辑

this.unsubscribe = canvasStore.subscribe((state) => {
  // 图元变更 → 重新渲染
  if (state.elements !== prevState.elements) {
    this.shapeRenderer.render(state.elements);
  }
  // 选中态变更 → 同步 Transformer 控制框
  if (state.appState.selectedElementIds !== prevState.appState.selectedElementIds) {
    this.selectionManager.syncSelection(state.appState.selectedElementIds);
  }
  // 视口变更 → 同步 Stage 缩放/平移
  if (state.appState.zoom !== prevState.appState.zoom || ...) {
    this.viewportManager.syncViewport(zoom, scrollX, scrollY);
  }
});

请注意这里的引用相等性比较(!==)。Zustand 的不可变数据范式保证了:只有当数据真正改变时,引用才会不同。所以引擎的每一次重绘都是精确触发的——不多画一帧,不少画一帧。

整个数据流形成了一个干净的单向环路

用户操作 → Store 更新元数据 → Engine 监听到变更 → Konva 重绘画面
                ↑                                      │
                └──────── 用户拖拽,Engine 回写坐标 ────┘

Konva 永远不私自修改任何数据。当用户拖拽一个图形时,Engine 层拦截 Konva 的 dragmove 事件,取得新坐标,然后调用 store.updateElementProps(id, { x, y }) 把新位置"汇报"回 Store。Store 更新后触发订阅回调,Engine 再根据新数据重绘——一切都是单向、可追溯的。

而浮在画布之上的 React UI(工具栏、属性面板)也是同一个 Store 的消费者:

// src/App.tsx — 属性面板(精简)
const PropertiesPanel = () => {
  const selectedIds = useCanvasStore(
    (state) => state.appState.selectedElementIds,
  );
  const elements = useCanvasStore((state) => state.elements);
  const updateElementProp = useCanvasStore((state) => state.updateElementProp);

  const el = elements[selectedIds[0]];
  // 从 store 读数据,渲染颜色选择器、描边样式按钮...
  // 用户点击后,直接调用 updateElementProp() 回写 store
};

我们常说,前端框架 React 的核心公式是 UI = f(State)。 而无限白板的架构真谛就是:Canvas = Konva(Metadata)


回望:四层地基已就位

至此,我们用四篇文章,自底向上地垒完了无限画布系统的四层地基:

层级 解决的核心问题 关键技术
坐标系 "无限"与"缩放"的数学本质 世界坐标 ↔ 屏幕坐标变换
渲染层 高性能绘制大量图形 Konva Scene Graph, 局部重绘
交互层 重建事件感知 离屏 Color Picking, Hit Testing
对象层 让画布拥有序列化的组织 元数据 Schema, Zustand 单向数据流

历经四篇文章的打磨,我们从最底层的数学坐标系起步,最终构筑起这套‘可协同、可撤销、可跨端’的数据驱动画布架构。这段工程演进之路的破局关键,其实就是两个字:克制。清晰划定架构的分层边界,想透每一层该做什么,并坚决杜绝越界。

本系列 实例项目已上传GitHub github.com/Seanshi2025… 项目上有完整的架构组织文档。

React 底层原理 & 新特性

React 底层原理 & 新特性

本文深入探讨 React 的底层架构演进、核心原理以及最新版本带来的突破性特性。


原文地址

墨渊书肆/React 底层原理 & 新特性


React 版本变动历史

React 自发布以来经历了多个版本的更新,每个主要版本的变动都带来了新的特性和改进,同时也对旧有的API进行了调整或废弃。以下是React几个重要版本的主要变动概述:

React 15 (2016年)

  • 引入Fiber架构:在 React 15后期版本中引入了 Fiber, 提供了更灵活的渲染调度和更换的错误恢复机制。
  • 改进了服务器端渲染:提升了SSR(Server-Side Rendering)的性能 and 稳定性。
  • SVG和MathML的支持增强:更好地支持SVG和MathML元素,使其渲染更加一致和准确。

React 16 (2017年)

  • 全面实施Fiber:Fiber成为了React核心的更新算法,提供了更细粒度的任务调度和更强大的并发模式,使得React应用的性能和响应性有了显著提升。
  • Error Boundaries:引入了错误边界的概念,允许组件捕获其子组件树中的JavaScript错误,并优雅地降级,而不是让整个应用崩溃。
  • Portals:允许将子节点渲染到DOM树的其他位置,为模态框、弹出层等场景提供了更好的解决方案。
  • 支持返回数组的render方法:可以直接从组件的render方法返回多个元素,而不需要额外的包装元素。

React 17 (2020年)

  • 自动批处理更新:默认开启了自动批处理更新,即使开发者没有手动使用 React.startTransitionunstable_batchedUpdates,React也会尝试批处理状态更新,以减少渲染次数。
  • 事件委托改进:改变了事件处理的方式,将事件监听器绑定到 document 上,减少了委托层级,简化了第三方库的继成。
  • 更严格的 JSX 类型检查:增强了对JSX类型的检查,帮助开发者提前发现潜在的类型错误。
  • 无-breaking-change 版本:React 17被设计为一个过渡版本,尽量减少对现有代码的破坏,为未来更大的更新铺路。

React 18 (2022年)

  • 并发模式:进一步深化了Fiber架构的并发特性,通过新的 SuspenseUseTransition API,允许开发者更好地控制组件的加载和更新策略。
  • 自动 hydration:React 18引入了新的渲染模式,包括 Server ComponentsAutomatic Hydration,旨在减少初次加载时间和提高用户体验。
  • 改进的错误处理:增强了错误边界和错误报告的能力,使得调试和问题定位更加容易。
  • StartTransition API:允许开发者标记某些状态更新为低优先级,从而优化UI的响应性和流畅性。

React 19 新特性深度解析 (2024年)

React 19 是一个重大的里程碑,它将许多在 React 18 中处于 Canary/Experimental 阶段的特性正式稳定化,并引入了全新的开发范式。

1. Actions 与异步状态管理

React 19 引入了 Actions 的概念,用于简化异步操作(如表单提交)及其状态管理。

  • useActionState: 自动处理异步函数的 pending 状态和结果。

    function UpdateName({ name, updateName }) {
      const [error, submitAction, isPending] = useActionState(
        async (previousState, formData) => {
          const error = await updateName(formData.get("name"));
          if (error) return error;
          return null;
        },
        null
      );
    
      return (
        <form action={submitAction}>
          <input type="text" name="name" disabled={isPending} />
          <button type="submit" disabled={isPending}>Update</button>
          {error && <p>{error}</p>}
        </form>
      );
    }
    
  • useFormStatus: 子组件无需通过 Props 即可感知父表单的提交状态。

  • useOptimistic: 极致的乐观更新体验。在请求发出时立即更新 UI,请求失败后自动回滚。

2. Server Actions:打通前后端的“虫洞”

Server Actions 允许你在客户端直接调用服务器上的异步函数,是 React 19 的核心特性之一。

  • 指令: 使用 'use server' 标记函数或整个文件。
  • 全链路流程:
    1. 定义: 在服务端定义异步函数。
    2. 序列化: React 自动处理参数的序列化(支持复杂对象、FormData)。
    3. 传输: 客户端调用时,React 发起一个特殊的 POST 请求,将参数序列化后传输。
    4. 执行: 服务器接收请求,反序列化参数,执行逻辑(如操作数据库)。
    5. 响应: 服务器返回执行结果,React 自动刷新相关的客户端数据(通过 Revalidation 机制)。
  • 核心优势:
    • 安全性: 自动包含 CSRF 防护,防止跨站请求伪造。
    • 简化代码: 无需手动编写 API 路由、处理 fetch 和状态更新逻辑。
    • 渐进增强: 在 JS 未加载完成时,表单提交依然可以通过原生的 form action 工作。

3. use API:统一的资源读取

use 是一个全新的运行时 API,可以在渲染时读取 Promises 或 Context。

  • 条件调用: 不同于普通的 Hooks,use 可以在 iffor 循环中调用。
  • 自动 Suspense: 当 use(promise) 还在等待时,React 会自动挂起当前组件并显示最近的 Suspense 占位符。

4. Hook 进阶:useEffectEvent (React 19.2+)

为了解决 useEffect 依赖项过多的问题,React 19.2 引入了 useEffectEvent

  • 设计初衷: 在 useEffect 中,有些逻辑需要读取最新的 propsstate,但不希望这些值的变化触发 Effect 重新运行。

  • 示例:

    function ChatRoom({ roomId, theme }) {
      // 将“纯逻辑事件”抽离
      const onConnected = useEffectEvent(() => {
        showNotification('已连接!', theme); // 始终能拿到最新的 theme
      });
    
      useEffect(() => {
        const connection = createConnection(roomId);
        connection.on('connected', () => {
          onConnected(); // 调用事件
        });
        connection.connect();
        return () => connection.disconnect();
      }, [roomId]); // ✅ theme 变化不再导致重新连接
    }
    
  • 核心逻辑: useEffectEvent 定义的函数具有“反应性”,但它不是“依赖项”。它能捕获最新的闭包值,却不会触发渲染。


5. React Server Components (RSC) 进阶

RSC 不仅仅是服务端渲染,它是一种新的组件架构。

  • 零包体积: 服务端组件的代码不会下载到浏览器,减少了 JS Bundle 大小。
  • 直接访问数据: 可以直接在组件内写 sql 查询或读取文件系统。
  • 混合模式: 通过 'use client' 指令,开发者可以精确定义客户端交互边界。

6. Web Components 原生支持

React 19 终于完美支持了 Web Components,解决了长期以来的“痛点”。

  • 属性与特性的智能映射:
    • 以前: React 总是将属性作为 Attribute 处理,导致无法传递对象或布尔值给 Web Components。
    • 现在: React 会自动检测自定义元素。如果该元素上定义了对应的 Property(属性),React 会优先使用属性赋值;否则使用 setAttribute
  • 原生事件支持:
    • 以前: 开发者需要通过 ref 手动调用 addEventListener
    • 现在: 可以像原生 DOM 一样直接使用 onMyEvent={handleEvent},React 会自动处理事件委托和解绑。
  • 跨团队协作: 这意味着大型企业可以在同一个页面中混合使用 React 组件和基于 Lit、Stencil 开发的 Web Components,而不会产生任何兼容性壁垒。

7. 开发者体验 (DX) 的全面进化

React 19 移除了许多历史包袱,让 API 变得更加直观。

  • 简化 ref 传递:

    • 以前: 必须使用 forwardRef 才能将 ref 传递给子组件。
    • 现在: ref 现在作为一个普通的 prop 传递。你可以直接在函数组件的参数中解构它:
    function MyInput({ placeholder, ref }) {
      return <input placeholder={placeholder} ref={ref} />;
    }
    
  • 文档元数据 (Metadata) 支持:

    • 开发者现在可以直接在组件中渲染 <title>, <meta>, <link>。React 会自动将它们提升(Hoist)到文档的 <head> 部分,并处理去重。
  • 静态资源加载优化:

    • React 19 引入了资源预加载 API,如 preload, preinit
    • 样式表与脚本: 支持在组件中直接声明样式表,React 会确保在组件渲染前样式已加载完成,避免闪烁(FOUC)。

底层原理深度解析

React 的底层设计旨在解决大规模应用中的 UI 响应速度和开发效率问题。其核心逻辑遵循从 “数据描述 (JSX) -> 内存模型 (Fiber) -> 任务调度 (Scheduler) -> 真实渲染 (Commit)” 的流水线。

1. JSX 的本质:声明式描述 UI

JSX(JavaScript XML)是 JavaScript 的语法扩展,本质是 React.createElement 的语法糖。

  • 源码转换JSX 通过 Babel 编译为 _jsx 调用,生成描述 UI 的普通对象(React Element)。
  • 设计初衷
    • 声明式编程:开发者只需关注 UI 的“最终状态”,而非如何操作 DOM。
    • 跨平台一致性React Element 是纯 JSON 对象,不仅能渲染为 DOM,还能渲染为原生应用(React Native)或 Canvas。

2. Fiber 架构:最小工作单元与增量渲染

Fiber 是 React 16 的核心重构,它将渲染过程从不可中断的“递归”变为了可控制的“迭代”。

  • Fiber 节点源码结构

    function FiberNode(tag, pendingProps, key) {
      // 1. 实例属性
      this.tag = tag;                 // 组件类型(Function, Class, Host...)
      this.stateNode = null;          // 对应真实 DOM 或组件实例
      
      // 2. 树结构属性 (单向链表)
      this.return = null;             // 指向父节点
      this.child = null;              // 指向第一个子节点
      this.sibling = null;            // 指向右侧兄弟节点
      
      // 3. 状态属性
      this.memoizedState = null;      // 存储 Hooks 链表
      this.updateQueue = null;        // 存储更新任务 (UpdateQueue)
      
      // 4. 并发与优先级
      this.alternate = null;          // 双缓存指向 (WIP vs Current)
      this.lanes = NoLanes;           // 当前任务优先级
      this.childLanes = NoLanes;      // 子树优先级
    }
    
  • UpdateQueue 内部结构: 每一个 Fiber 节点都有一个 updateQueue,用于存放状态更新。

    const updateQueue = {
      baseState: fiber.memoizedState,
      firstBaseUpdate: null,          // 基础更新链表头
      lastBaseUpdate: null,           // 基础更新链表尾
      shared: {
        pending: null,                // 待处理的循环链表
      },
      effects: null,                  // 存放副作用的数组
    };
    
  • Effect 链表 (副作用清理)

    Commit 阶段,React 会遍历 Effect 链表来执行 DOM 操作、生命周期或 Hooks 的 cleanup

    const effect = {
      tag: tag,                       // Hook 类型 (HookHasEffect | HookPassive)
      create: create,                 // useEffect 的第一个参数
      destroy: destroy,               // useEffect 的返回值 (cleanup)
      deps: deps,                     // 依赖项
      next: null,                     // 指向下一个 Effect
    };
    
  • 核心优势

    • 可中断性:将巨大的更新拆分为细小的 Fiber 任务,主线程可以在任务间隔处理更高优先级的用户输入。
    • 状态持久化:由于 Fiber 节点存储在内存中,即使渲染中断,之前的状态也能被保留,下次继续。

3. Fiber 树的遍历逻辑:深度优先遍历

React 采用“深度优先遍历”算法来处理 Fiber 树,这是一个典型的“递归”转“迭代”的过程。

  • beginWork 阶段:从上往下。

    • 核心逻辑:根据 React Element 的变化,决定是复用现有 Fiber 还是新建。
    • 任务:计算新的 props、计算新的 state、调用生命周期或 Hooks、打上副作用标记(Flags)。
  • completeWork 阶段:从下往上。

    • 核心逻辑
    function completeWork(current, workInProgress, renderLanes) {
      const newProps = workInProgress.pendingProps;
      switch (workInProgress.tag) {
        case HostComponent: // 真实 DOM 节点
          if (current !== null && workInProgress.stateNode != null) {
            // 更新模式:对比 props,记录差异
            updateHostComponent(current, workInProgress, tag, newProps);
          } else {
            // 创建模式:生成真实 DOM,并插入子节点
            const instance = createInstance(type, newProps, ...);
            appendAllChildren(instance, workInProgress);
            workInProgress.stateNode = instance;
          }
          break;
        // ... 其他类型处理
      }
    }
    
    • 任务
      • 构建离屏 DOM 树:在内存中完成 DOM 节点的创建和属性绑定。
      • 副作用冒泡 (Bubble up):将子树的所有 Flags 收集到父节点,这样 Commit 阶段只需遍历根节点的 Flags 链表。
  • 带来的性能体验: 这种双向遍历确保了 React 可以在中途暂停,并在恢复时准确知道当前处理到的位置。通过“副作用冒泡”,Commit 阶段的执行速度得到了极大的提升。

4. 双缓存 (Double Buffering) 机制

React 在内存中维护两棵 Fiber 树:current 树(屏幕显示)和 workInProgress 树(正在构建)。

  • 设计初衷
    • 避免 UI 破碎:如果直接在 current 树上修改,用户可能会看到渲染到一半的页面。
    • 极致性能:构建完成后,只需切换 FiberRoot 指针即可完成整棵树的更新,这种“内存交换”比逐个修改 DOM 节点快得多。

5. Scheduler 与时间切片

Scheduler 是 React 的心脏,负责任务的全局调度。

  • 时间切片 (Time Slicing):React 默认每 5ms 会让出一次主线程。它通过 MessageChannel 模拟宏任务。
  • 设计初衷:即使在执行极其复杂的渲染任务(如万级列表),页面依然能响应用户的点击和输入,彻底解决了 JavaScript 阻塞主线程导致的“卡死”感。

6. Lanes 优先级模型

React 17 引入了基于 31 位位掩码的 Lanes 模型。

  • 设计优势
    • 多任务并行:相比旧的 ExpirationTimeLanes 可以表示“一组”任务优先级。
    • 任务插队:React 可以准确识别出最高优先级任务,优先处理它,并将正在进行的低优先级任务挂起或废弃。

7. 合成事件 (Synthetic Events) 与批处理 (Batching)

React 并不直接使用浏览器的原生事件,而是实现了一套全平台的合成事件机制。

  • 合成事件原理:
    • 事件委派: React 17+ 将事件绑定在 root 容器上,而不是 document
    • 对象池化: (注:React 17 之后已移除池化,改为直接传递)。
    • 跨平台映射: 将不同浏览器的差异(如 transitionend, animationend)封装为统一的 API。
  • 自动批处理 (Automatic Batching):
    • 原理: React 会将多个状态更新合并为一次渲染。
    • React 18/19 的突破: 以前只有在 React 事件处理函数中才有批处理。现在,无论是在 PromisesetTimeout 还是原生事件中,所有的更新都是自动批处理的。
    • 底层实现: 通过 ExecutionContext(执行上下文)标记。当 React 发现处于“更新流程”中时,它不会立即触发渲染,而是将更新放入 UpdateQueue,等待主任务结束后一次性处理。

8. 协调 (Reconciliation) 过程深度拆解

协调是 React 区分“计算”与“渲染”的核心。

  • 阶段拆分:
    1. Render 阶段 (异步/可中断): 生成 Fiber 树,计算差异。
    2. Commit 阶段 (同步/不可中断):
      • BeforeMutation: 处理 DOM 渲染前的逻辑(如 getSnapshotBeforeUpdate)。
      • Mutation: 真正操作 DOM(增删改)。
      • Layout: 渲染后的逻辑(如 useLayoutEffect)。
  • 事务机制 (Transaction):
    • 虽然 React 源码中没有直接命名为 Transaction 的类,但其更新流程遵循典型的事务模式:performSyncWorkOnRoot 开启事务 -> 执行更新 -> commitRoot 结束事务并清理环境。

并发渲染 (Concurrent Rendering) 深度解析

并发渲染是 React 18+ 的核心能力,它改变了 React 处理更新的基础方式。

1. 传统渲染 vs 并发渲染

  • 传统渲染 (Stack Reconciler):渲染过程是同步且不可中断的。如果一个组件树很大,浏览器会一直忙于计算,无法响应用户操作。
  • 并发渲染:React 可以在渲染过程中暂停。如果用户点击了按钮,React 会暂停当前的渲染,处理点击事件,然后再恢复之前的渲染。

2. 并发特性的核心:Transitions

通过 startTransition,开发者可以告诉 React 哪些更新是“不紧急”的。

  • 应用场景:输入框打字是紧急的,下方的搜索结果列表更新是不紧急的。
  • 底层实现startTransition 会将更新标记为低优先级的 Lane,使得紧急更新(输入)可以打断它。

流式 SSR 与 Suspense 架构

React 18+ 彻底重塑了服务端渲染 (SSR) 的工作流程。

1. 传统的 SSR 瓶颈

在 React 18 之前,SSR 必须经历:

  1. 服务器拉取所有数据
  2. 生成整个 HTML
  3. 客户端下载整个 JS
  4. 整个页面进行 Hydration

任何一个环节慢了,用户都会看到白屏或无法交互。

2. 流式 SSR (Streaming SSR)

React 现在支持通过 renderToPipeableStream 将 HTML 分块发送给浏览器。

  • 结合 Suspense: 页面可以先显示外壳,耗时较长的组件(如评论列表)在服务器端准备好后再“流”向客户端,并自动插入到正确位置。
  • 选择性注水 (Selective Hydration): 用户点击了还没注水的组件时,React 会优先为该组件进行注水,提升了交互的实时性。

隐藏的宝藏:Offscreen (Activity) 模式

React 19 引入了 <Activity> 组件(实验性名称为 Offscreen API),开启了“智能预渲染”的大门。

  • 核心原理: 允许 React 在后台渲染组件树,而不将其挂载到真实的 DOM 上。
  • 运行模式:
    • hidden 模式:
      • DOM 隐藏: 组件的 DOM 节点被隐藏或不创建。
      • Effect 卸载: 所有的 useEffect 会执行 cleanup,避免后台任务占用过多资源。
      • 状态保留: 组件内部的 useStateuseReducer 状态会被完整保留。
      • 低优先级更新: 当 React 处理完所有可见任务后,会利用空闲时间悄悄更新 hidden 的树。
    • visible 模式: 组件瞬间恢复可见,useEffect 重新挂载,UI 立即同步到最新状态。
  • 优势与场景:
    • 瞬间回退 (Back Navigation): 用户点击“返回”按钮时,之前的页面可以瞬间重现,无需重新加载数据。
    • 标签页切换 (Tabs): 预先渲染非活跃的 Tab 页面,切换时零延迟。
    • 列表预加载: 当用户滚动列表时,提前渲染屏幕下方的几个节点。

性能优化进阶:Transition Tracing & Profiler

为了帮助开发者量身定制性能方案,React 19 提供了更强大的追踪工具和底层的调度观察能力。

1. Transition Tracing (过渡追踪)

允许开发者监听特定的“过渡任务”的生命周期。

  • onTransitionStart / onTransitionProgress: 可以精准监控 startTransition 开启的任务。

    import { unstable_useTransitionTracing } from 'react';
    
    function SearchPage() {
      unstable_useTransitionTracing('search-results', {
        onTransitionStart: (startTime) => {
          console.log('搜索开始', startTime);
        },
        onTransitionComplete: (endTime) => {
          console.log('搜索渲染完成', endTime);
        }
      });
      // ...
    }
    
  • 核心价值: 帮助开发者识别哪些复杂的渲染导致了 UI 的延迟,从而决定是否需要拆分组件或优化数据结构。

2. DevTools Profiler 增强

现在的 Profiler 可以清晰展示每个任务所属的 Lane(优先级级别)。

  • 优先级可视化: 开发者可以看到哪些任务是 User Blocking(用户阻塞,高优先级),哪些是 Transition(过渡,低优先级)。
  • 任务插队分析: Profiler 会标注出哪些任务是因为被更高优先级的任务“插队”而暂停的,这对于调试复杂的并发逻辑至关重要。

协调过程与 Diff 算法深度解析

协调是计算“变了什么”的过程,而 Diff 是其中的核心算法。

1. 核心策略:O(n) 复杂度

React 的 Diff 算法基于三个预设限制:

  • 同层比较:只比较同级节点,跨层级移动会被视为删除和重新创建。
  • 类型判断:如果节点类型变了,直接销毁旧树,创建新树。
  • Key 标识:通过 key 属性,开发者可以告知 React 哪些元素在不同渲染中是稳定的。

2. 多节点 Diff 的“两次遍历”

  1. 第一轮遍历:从左往右对比新旧节点。如果 keytype 都匹配,复用;否则跳出。
  2. 第二轮遍历:将剩余的旧节点放入 Map。遍历新节点时,尝试从 Map 中通过 key 寻找可复用的节点,从而高效处理位移。

Hooks 底层:基于链表的状态管理

Hooks 的状态存储在 Fiber 节点的 memoizedState 单向链表中。

1. Hook 对象结构

const hook = {
  memoizedState: null, // 存储 useState 的值、useEffect 的 effect 等
  baseState: null,
  baseQueue: null,
  queue: null,         // 状态更新队列
  next: null,          // 下一个 Hook
};

2. 闭包陷阱的本质

当 Hook 在渲染过程中被调用时,它会读取当前 Fiber 的状态。如果异步操作引用了旧的变量,而组件已经重新渲染,就会产生闭包陷阱。这正是 useEffect 依赖项数组存在的原因。


未来展望:React Compiler (React Forget)

为了进一步提升性能,Meta 正在开发 React Compiler

  • 自动记忆化: 目前开发者需要手动使用 useMemouseCallback。编译器将通过静态分析,自动插入这些优化代码。
  • 性能体验: 彻底告别手动性能优化,让 React 应用在默认情况下就拥有极致的运行效率。

总结:Meta 为什么这样设计?

Meta(原 Facebook)之所以设计这套极其复杂的源码结构,其核心目标只有一个:在保证开发者体验(DX)的同时,提供极致的用户体验(UX)。

  1. 响应性优先:通过 FiberScheduler,确保用户操作永远拥有最高优先级。
  2. 内存换速度:通过“虚拟 DOM”和“双缓存”,用内存中的对象运算来换取昂贵的真实 DOM 操作。
  3. 架构的生命力LanesConcurrent Mode 的引入,让 React 从一个简单的 UI 库进化成了一个能处理复杂调度任务的“前端操作系统”。
  4. 全栈融合:React 19 的 Server ActionsRSC 标志着 React 正在从“UI 库”向“全栈框架”迈进,试图统一前后端的开发模型

VUE3响应式原理——从零解析

基本概念

在开始讲解响应式原理之前,我们需要知道两个基本概念:

什么是副作用函数?

即该函数的执行影响到其他函数的执行结果,则称该函数为副作用函数。例如:

const obj = { text: 'test' };

function effect() {
    obj.text = ‘hello’;
}

effect()执行后,其他使用到obj.text的函数中,读取到的值将是hello,而不是text,产生了副作用,故称effect()为副作用函数。

什么是响应式数据?

即当某个数据发生变化时,所有使用该数据的地方都发生了变化,则称该数据为响应式数据。例如:

const obj = { text: 'test' };

function effect() {
    ducoment.body.innerText = obj.text;
}

effect();
obj.text = 'hello';

obj.text的值设置为hello后,若body显示的内容由test变为hello,则称obj是一个响应式数据。


如何实现响应式?

通过上述基本概念的举例说明可以看出,响应式数据涉及到了数据的读取(get)和设置(set)操作——副作用函数执行时,进行了读取操作;数据值改变时,进行了设置操作,同时副作用函数被执行。

那怎么样才能保证对数据进行设置操作时,副作用函数被执行呢?可以在读取操作时使用一个容器将副作用函数保存起来,在设置操作时取出副作用函数执行,就实现了最简单的响应式。

Snipaste_2026-02-27_14-43-27.png

Snipaste_2026-02-27_14-43-34.png 在ES2015+以后,Proxy可以实现拦截数据的getset操作,并进行一些特殊处理。

// 副作用函数
function effect() {
    document.getElementById("result").innerHTML = obj.text;
}

const data = { text: "test" };

// 收集副作用函数的容器
const bucket = new Set();

// 响应式数据
const obj = new Proxy(data, {
    get(target, key) {
        // 读取时将副作用函数存入容器
        bucket.add(effect);
        return target[key];
    },
    set(target, key, newVal) {
        target[key] = newVal;
        // 设置后将容器中的副作用函数取出逐一执行
        bucket.forEach((fn) => fn());
        return true;
    },
});

然而,在实际应用过程中,副作用函数名称并不都是effect,可能是其他名称,也可能是一个匿名函数。因此,需要改造一下原有的effect函数,允许其接收一个真正的副作用函数,并存到一个变量中,解决副作用函数名称被硬编码的问题。

// 当前激活的副作用函数
let activeEffect;

// 改造原有的effect函数
function effect(fn){
    activeEffect = fn;
    fn();
}

const data = { text: "test" };

// 收集副作用函数的容器
const bucket = new Set();

// 响应式数据
const obj = new Proxy(data, {
    get(target, key) {
        if (activeEffect) {
            bucket.add(activeEffect);
        }

        return target[key];
    },
    set(target, key, newVal) {
        target[key] = newVal;
        bucket.forEach((fn) => fn());
        return true;
    },
});


如何仅触发特定的副作用函数?

上一节中,已经实现了基本的响应式数据。但如果给obj中原本不存在的属性设置数据后,会发现副作用函数被执行了两次,例如下面这段代码:

effect(() => {
    console.log('执行了副作用函数');
})

function exec() {
    obj.text = 'hello';
    obj.name = '张三';
}

exec();

这和预期不一致——原始数据没有name属性,且副作用函数中未读取该属性,exec()执行到最后一行时,不应触发副作用函数的执行。

通过观察可以发现,objtexteffect呈现一种树状结构: Snipaste_2026-02-26_17-28-35.png

拓展可以得到以下情况: Snipaste_2026-02-27_14-27-09.png

targetkeyeffect是一对多的关系,因此单单使用Set是不满足的,需要调整收集副作用函数的容器的数据结构。

// 当前激活的副作用函数
let activeEffect;

// 改造原有的effect函数
export function effect(fn) {
    activeEffect = fn;
    fn();
}

const data = { text: "test" };

// 收集副作用函数的容器
const bucket = new WeakMap();

// 响应式数据
export const obj = new Proxy(data, {

    get(target, key) {
        if (!activeEffect) {
            return target\[key];
        }

        let depsMap = bucket.get(target);
        if (!depsMap) {
            // 如果不存在,则创建一个新的Map
            bucket.set(target, (depsMap = new Map()));
        }

        let effectsSet = depsMap.get(key);
        if (!effectsSet) {
            // 如果不存在,则创建一个新的Set
            depsMap.set(key, (effectsSet = new Set()));
        }

        effectsSet.add(activeEffect);
        return target[key];
    },

    set(target, key, newVal) {
        target[key] = newVal;

        const depsMap = bucket.get(target);
        // 没有收集到有副作用函数的属性,直接返回
        if (!depsMap) {
            return;
        }

        // 取出与属性绑定的所有副作用函数逐一执行
        const effectsSet = depsMap.get(key);
        effectsSet && effectsSet.forEach((fn) => fn());
        return true;
    },
});

基于Rokid CXR-M SDK的AI饮食健康助手开发实战

引言:当AR眼镜遇上年夜饭

每年春节,面对满桌的鸡鸭鱼肉、煎炸炖煮,无数人陷入“吃还是不吃”的两难——红烧肉到底多少卡?油炸春卷热量有多高?糖醋鱼会不会让血糖飙升?传统的解决方案要么掏出手机打开App手动搜索,要么靠记忆力估算,不仅繁琐,还常常因为“懒得查”而放弃。

基于Rokid CXR-M SDK,可以开发一款手机端识别分析 + 眼镜端AR显示的协同应用:用户只需用眼镜扫过餐桌,系统即可实时识别菜品并投射热量数据至视野中,让健康管理从“刻意为之”变成“自然发生”。

楼主将完整记录从SDK集成、设备连接、图像采集与识别,到AR投射与语音交互的全过程,希望能为其他开发者提供一份可复用的实操指南。

一、系统架构与核心能力

整体架构设计

本系统采用手机为控制中枢、眼镜为显示终端的端云协同架构。CXR-M SDK作为桥梁,封装了底层通信细节,让我们能专注于业务逻辑。

  • 感知层:Rokid Glasses摄像头实时捕获餐桌画面
  • 分析层:手机端运行AI模型进行菜品识别与热量计算
  • 交互层:眼镜端通过AR叠加显示识别结果,支持语音控制

二、项目初始化与设备连接

2.1 环境搭建

首先在项目中配置Rokid Maven仓库和SDK依赖:

// settings.gradle.kts
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        // 添加Rokid官方Maven仓库
        maven { url = uri("https://maven.rokid.com/repository/maven-public/") }
    }
}

// app/build.gradle.kts
android {
    defaultConfig {
        minSdk = 28 // 必须 ≥28
    }
}

dependencies {
    implementation("com.rokid.cxr:client-m:1.0.1-20250812.080117-2")
    implementation("org.tensorflow:tensorflow-lite:2.9.0") // 端侧AI推理
    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1") // MVVM架构
}

2.2 权限声明与动态申请

AndroidManifest.xml中声明必要权限:

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />

Android 12+需动态申请蓝牙权限:

class PermissionHelper(private val activity: AppCompatActivity) {
    fun requestRequiredPermissions(onGranted: () -> Unit) {
        val permissions = mutableListOf(
            Manifest.permission.ACCESS_FINE_LOCATION,
            Manifest.permission.CAMERA
        )
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            permissions += Manifest.permission.BLUETOOTH_SCAN
            permissions += Manifest.permission.BLUETOOTH_CONNECT
        }

        if (permissions.all { 
            activity.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED 
        }) {
            onGranted()
        } else {
            activity.requestPermissions(permissions.toTypedArray(), REQUEST_CODE)
        }
    }
}

2.3 双通道设备连接

Rokid CXR-M SDK支持蓝牙+Wi-Fi双通道连接:蓝牙通道用于控制指令传输,Wi-Fi通道用于大容量图像数据传输。

class ConnectionManager(private val context: Context) {
    private val tag = "ConnectionManager"
    private var isBluetoothConnected = false
    private var isWifiConnected = false
    private var deviceAuthenticated = false

    fun initDeviceConnection(device: BluetoothDevice) {
        // 1. 初始化蓝牙连接
        CxrApi.getInstance().initBluetooth(context, device, object : BluetoothStatusCallback {
            override fun onConnectionInfo(socketUuid: String?, macAddress: String?, 
                                          rokidAccount: String?, glassesType: Int) {
                socketUuid?.let { uuid ->
                    macAddress?.let { address ->
                        connectBluetooth(uuid, address)
                    }
                }
            }

            override fun onConnected() {
                Log.d(tag, "蓝牙连接成功")
                isBluetoothConnected = true
                initWifiConnection()
            }

            override fun onDisconnected() {
                Log.w(tag, "蓝牙连接断开")
                isBluetoothConnected = false
                deviceAuthenticated = false
                // 尝试重连
            }

            override fun onFailed(errorCode: ValueUtil.CxrBluetoothErrorCode?) {
                Log.e(tag, "蓝牙连接失败: ${errorCode?.name}")
            }
        })
    }

    private fun connectBluetooth(uuid: String, mac: String) {
        CxrApi.getInstance().connectBluetooth(context, uuid, mac, bluetoothCallback)
    }

    // 2. 初始化Wi-Fi P2P
    private fun initWifiConnection() {
        if (!isBluetoothConnected) return
        
        val status = CxrApi.getInstance().initWifiP2P(object : WifiP2PStatusCallback {
            override fun onConnected() {
                isWifiConnected = true
                Log.d(tag, "Wi-Fi P2P连接成功")
                // 等待设备认证
            }

            override fun onDisconnected() {
                isWifiConnected = false
                Log.e(tag, "Wi-Fi P2P断开")
            }

            override fun onFailed(errorCode: ValueUtil.CxrWifiErrorCode?) {
                Log.e(tag, "Wi-Fi P2P连接失败: ${errorCode?.name}")
            }
        })
    }

    // 3. 关键:等待设备认证完成
    fun setAuthenticationListener() {
        CxrApi.getInstance().setDeviceAuthListener { token ->
            if (token.isNotEmpty()) {
                deviceAuthenticated = true
                Log.d(tag, "设备认证成功,token: $token")
                // 此时才可进行UI渲染等操作
                onDeviceReady()
            }
        }
    }
}

⚠️ 关键经验onConnected()只代表物理链路通了,但设备认证信息尚未同步。必须等待onDeviceAuthenticated()回调后,才能安全调用openCustomView()等UI接口。

三、核心功能实现

3.1 AI场景触发与图像采集

利用眼镜侧面的AI按键,用户只需长按即可唤醒识别功能:

class FoodRecognitionManager {
    private val tag = "FoodRecognitionManager"
    
    // 设置AI按键监听
    fun setupAiKeyListener() {
        CxrApi.getInstance().setAiEventListener(object : AiEventListener {
            override fun onAiKeyDown() {
                Log.d(tag, "AI按键长按,启动识别流程")
                captureAndRecognize()
            }
            
            override fun onAiKeyUp() {
                // 短按可预留其他功能
            }
            
            override fun onAiKeyCancel() {
                Log.d(tag, "AI按键取消")
            }
        })
    }
    
    // 采集图像并识别
    private fun captureAndRecognize() {
        // 1. 打开相机预览(如果未打开)
        CxrApi.getInstance().openGlassCamera(
            width = 1920,
            height = 1080,
            quality = 85,
            object : CameraStatusCallback {
                override fun onCameraOpened(status: ValueUtil.CxrStatus?) {
                    if (status == ValueUtil.CxrStatus.RESPONSE_SUCCEED) {
                        Log.d(tag, "相机已开启")
                        takePhoto()
                    } else {
                        Log.e(tag, "相机开启失败: ${status?.name}")
                    }
                }
            }
        )
    }
    
    // 2. 拍照获取高清图像
    private fun takePhoto() {
        CxrApi.getInstance().takeGlassPhoto(object : PhotoResultCallback {
            override fun onPhotoTaken(data: ByteArray?) {
                data?.let { imageData ->
                    Log.d(tag, "拍照成功,图片大小: ${imageData.size} bytes")
                    // 发送到AI识别服务
                    recognizeFood(imageData)
                } ?: run {
                    Log.e(tag, "拍照失败: data为空")
                }
            }
            
            override fun onFailed(errorCode: ValueUtil.CxrCameraErrorCode?) {
                Log.e(tag, "拍照失败: ${errorCode?.name}")
            }
        })
    }
}

3.2 手机端AI菜品识别

拍照获得的图像数据通过ByteArray形式返回,我们可以在手机端运行TensorFlow Lite模型进行识别:

class FoodRecognitionService {
    private var tflite: Interpreter? = null
    private val foodDatabase = mapOf(
        "hongshao_rou" to FoodInfo("红烧肉", 480, "高脂肪", "#FF4444"),
        "qingzheng_yu" to FoodInfo("清蒸鱼", 120, "优质蛋白", "#44FF44"),
        "chun_juan" to FoodInfo("春卷", 350, "油炸食品", "#FFAA00"),
        "liangban_huanggua" to FoodInfo("凉拌黄瓜", 45, "低卡推荐", "#44FF44"),
        "niangao" to FoodInfo("年糕", 210, "主食", "#FFAA00")
    )
    
    data class FoodInfo(
        val name: String,
        val calories: Int,      // 千卡/100g
        val tag: String,
        val color: String       // 用于AR显示的颜色
    )
    
    fun recognizeFood(imageData: ByteArray, callback: (FoodInfo?) -> Unit) {
        // 1. 图像预处理
        val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size)
        val resized = Bitmap.createScaledBitmap(bitmap, 224, 224, true)
        
        // 2. 转换为模型输入格式
        val input = convertBitmapToByteBuffer(resized)
        
        // 3. 执行推理
        val output = Array(1) { FloatArray(5) } // 假设5个分类
        tflite?.run(input, output)
        
        // 4. 解析结果
        val maxIndex = output[0].indices.maxByOrNull { output[0][it] } ?: 0
        val confidence = output[0][maxIndex]
        
        if (confidence > 0.7) {
            val foodKey = when (maxIndex) {
                0 -> "hongshao_rou"
                1 -> "qingzheng_yu"
                2 -> "chun_juan"
                3 -> "liangban_huanggua"
                4 -> "niangao"
                else -> null
            }
            callback(foodDatabase[foodKey])
        } else {
            callback(null) // 识别失败
        }
    }
    
    private fun convertBitmapToByteBuffer(bitmap: Bitmap): ByteBuffer {
        // TensorFlow Lite输入格式转换
        // 具体实现略
    }
}

3.3 AR界面渲染:JSON动态构建

识别完成后,需要在眼镜端显示热量信息。Rokid CXR-M SDK支持通过JSON动态构建自定义界面。

⚠️ 重要限制:Rokid Glasses的光学显示模组对特定波长敏感,所有显示元素必须使用绿色通道(#00FF00) 才能被用户看到。

class ARDisplayManager {
    
    // 构建热量显示界面
    fun showCalorieInfo(foodInfo: FoodRecognitionService.FoodInfo) {
        // 根据热量等级决定提示颜色(但最终只能渲染绿色,颜色用于语义区分)
        val indicatorColor = when (foodInfo.color) {
            "#FF4444" -> "#00FF00" // 警告(高热量)→ 亮绿闪烁
            "#FFAA00" -> "#00AA00" // 中等 → 中绿
            "#44FF44" -> "#008800" // 低卡 → 暗绿
            else -> "#00FF00"
        }
        
        val caloriesJson = """
        {
          "type": "ConstraintLayout",
          "props": {
            "layout_width": "match_parent",
            "layout_height": "match_parent",
            "backgroundColor": "#00000000"
          },
          "children": [
            {
              "type": "LinearLayout",
              "props": {
                "id": "card_background",
                "layout_width": "wrap_content",
                "layout_height": "wrap_content",
                "orientation": "vertical",
                "gravity": "center",
                "backgroundColor": "#BB000000",
                "padding": "16dp",
                "layout_alignParentBottom": "true",
                "layout_centerHorizontal": "true",
                "marginBottom": "40dp"
              },
              "children": [
                {
                  "type": "TextView",
                  "props": {
                    "id": "food_name",
                    "layout_width": "wrap_content",
                    "layout_height": "wrap_content",
                    "text": "${foodInfo.name}",
                    "textSize": "22sp",
                    "textColor": "$indicatorColor",
                    "textStyle": "bold",
                    "gravity": "center"
                  }
                },
                {
                  "type": "TextView",
                  "props": {
                    "id": "calorie_info",
                    "layout_width": "wrap_content",
                    "layout_height": "wrap_content",
                    "text": "${foodInfo.calories} kcal/100g",
                    "textSize": "28sp",
                    "textColor": "$indicatorColor",
                    "textStyle": "bold",
                    "marginTop": "4dp",
                    "gravity": "center"
                  }
                },
                {
                  "type": "TextView",
                  "props": {
                    "id": "food_tag",
                    "layout_width": "wrap_content",
                    "layout_height": "wrap_content",
                    "text": "${foodInfo.tag}",
                    "textSize": "16sp",
                    "textColor": "#AAAAAA",
                    "marginTop": "8dp",
                    "gravity": "center"
                  }
                }
              ]
            }
          ]
        }
        """.trimIndent()
        
        // 打开自定义界面
        val status = CxrApi.getInstance().openCustomView(caloriesJson)
        if (status != ValueUtil.CxrStatus.REQUEST_SUCCEED) {
            Log.e("ARDisplay", "打开自定义界面失败: $status")
        }
    }
    
    // 更新已有界面(用于连续识别)
    fun updateCalorieInfo(foodInfo: FoodRecognitionService.FoodInfo) {
        val updateJson = """
        [
          {
            "action": "update",
            "id": "food_name",
            "props": {
              "text": "${foodInfo.name}"
            }
          },
          {
            "action": "update",
            "id": "calorie_info",
            "props": {
              "text": "${foodInfo.calories} kcal/100g"
            }
          },
          {
            "action": "update",
            "id": "food_tag",
            "props": {
              "text": "${foodInfo.tag}"
            }
          }
        ]
        """.trimIndent()
        
        CxrApi.getInstance().updateCustomView(updateJson)
    }
    
    // 关闭界面
    fun hideCalorieInfo() {
        CxrApi.getInstance().closeCustomView()
    }
}

四、总结与展望

6.1 技术亮点回顾

本文基于Rokid CXR-M SDK,完整实现了春节饮食助手的核心功能:

  1. 端云协同架构:手机端负责AI计算,眼镜端专注AR显示,兼顾性能与功耗
  2. 双通道通信:蓝牙传指令、Wi-Fi传图像,保障流畅体验
  3. JSON动态UI:通过绿色通道渲染,实现轻量级AR界面

6.2 未来可扩展性

基于同样的技术架构,可以扩展更多场景:

  • 糖尿病管理:识别菜品并显示碳水化合物含量
  • 健身指导:根据用户目标推荐蛋白质摄入量
  • 饮食日志:自动记录每餐摄入,生成周报
  • 社交分享:将营养报告分享给家人或营养师

当科技真正“隐于无形”,服务于生活时,它便拥有了最温暖的意义。这个春节,让Rokid眼镜成为你的“热量透视镜”,吃明白,过个健康年。

React 基础理论 & API 使用

React 基础理论 & API 使用

本文主要记录一些关于 React 的基础理论、核心概念以及常用 API 的使用方法,供查漏补缺。


原文地址

墨渊书肆/React 基础理论 & API 使用


React 简介

React 是一个由 Facebook(现在称为 Meta)开发的开源 JavaScript 库,主要用于构建用户界面,特别是单页应用程序(SPA)的开发。React 不仅限于 Web 开发,通过React Native,开发者还可以使用几乎相同的组件化开发方式来构建原生移动应用程序,实现了跨平台的代码复用。由于其灵活性和高效性,React 已成为现代 Web 开发中最受欢迎的前端库之一。

核心特点

  • 组件化编程:React 将页面和功能分解为可复用的组件,每个组件可以管理自己的状态 and 渲染逻辑,大大提高了代码的可维护性和可重用性。
  • Virtual DOM:引入虚拟 DOM 的概念,它是一个树形数据结构,用来表示真实 DOM 的抽象。当状态发生改变时,在Render阶段会先计算VDOM的最小更新,然后在Commit阶段生成真实 DOM,减少了浏览器的重排和重绘,提高了性能。
  • 声明式编程:React 使用声明式的方式定义页面的 UI 和状态逻辑,让代码更容易理解。
  • JSX:允许开发者在 JS 中混写 HTML-like 的语法,这种语法糖被称为JSX,可以更直观和简洁的描述组件的结构。
  • 单向数据流:React 应用遵循单向数据流的原则,父组件向子组件传递状态(props)和回调函数,子组件通过调用这些回调来通知父组件的状态变更,这有助于保持数据流的清晰和可预测性。这点有别于 Vue 的双向绑定。

安装

在搭建 React 框架时,我们现在通常使用目前更主流、构建速度更快的 Vite,它是现代前端开发的优选脚手架。

# 使用 Vite 创建项目
npm create vite@latest my-react-app -- --template react

如果你选择其他框架或工具链,也有对应的安装方式:

  • Next.jsnpx create-next-app@latest
  • UmiJS:使用 create-umi

组件通讯方式

在 React 中,组件间的通信主要有以下几种方式:

  1. 通过 props 向子组件传递数据:父组件通过属性将数据传递给子组件。
  2. 通过回调函数向父组件传递数据:父组件向子组件传递一个函数,子组件调用该函数并传入数据。
  3. 使用 Refs 调用子组件暴露的方法:通过 forwardRefuseImperativeHandle 钩子,父组件可以访问子组件内部定义的方法。
  4. 通过 Context 进行跨组件通信:使用 createContextuseContext 实现跨层级的状态共享。
  5. 使用状态管理库:如 ReduxMobXZustand 等进行全局状态管理。

生命周期

经典生命周期

在React 16.3之后的生命周期可以分为三个阶段:

挂载阶段(Mounting):

  • constructor: 组件实例化时调用,初始化 state 和绑定 this
  • getDerivedStateFromProps: (React 16.3新增)在组件实例被创建后续更新时被调用,用于根据 props 来计算 state
  • render: 根据 state 和 props 渲染UI到虚拟 DOM。
  • componentDidMount: 组件已经被渲染到 DOM 后调用,常用于发起网络请求、设置定时器等。

更新阶段(Updating):

  • getDerivedStateFromProps: 同挂载阶段。
  • shouldComponentUpdate: 判断是否需要更新 DOM,返回 true/false。
  • render: 状态或props改变时再次渲染 UI。
  • getSnapshotBeforeUpdate: (React 16.3新增)在 DOM 更新前调用,可以获取一些信息用于在 componentDidUpdate 中使用。
  • componentDidUpdate: 组件更新后立即调用,可以进行 DOM 操作或网络请求。

卸载阶段(Unmounting):

  • componentWillUnmount: 组件将要卸载时调用,清理工作如取消网络请求、清除定时器等。

从React 16.3开始,componentWillMount, componentWillReceiveProps, 和 componentWillUpdate 被标记为不安全的,并最终在React 17中被废弃。React推荐使用getDerivedStateFromProps和useState、useEffect等Hooks来替代。

Hooks 生命周期模拟

对于 React 函数组件,现在的实践更倾向于使用如下的 Hooks 生命周期:

  • useState: 用于组件内部状态管理。

  • useEffect: 用于处理副作用,可模拟以下生命周期:

    • 模拟挂载阶段 (componentDidMount): 依赖数组传空 []
    useEffect(() => { /* 只在挂载后执行 */ }, []);
    
    • 模拟更新阶段 (componentDidUpdate): 不传依赖数组或传入特定依赖。
    useEffect(() => { /* 每次渲染后都执行 */ });
    useEffect(() => { /* count 变化后执行 */ }, [count]);
    
    • 模拟卸载阶段 (componentWillUnmount): 在 useEffect 中返回一个清理函数。
    useEffect(() => {
      return () => { /* 组件卸载前执行 */ };
    }, []);
    
  • useContext: 用于从上下文中消费值。

  • useRef: 用于持久化一个可变的引用对象,不会引起组件重新渲染。

  • useReducer: 用于有复杂状态逻辑的组件,替代某些 useState 的使用场景。

  • useCallbackuseMemo: 用于优化性能,避免不必要的函数或计算的重新创建。

父子组件生命周期调用顺序

在函数组件中,挂载阶段的执行顺序如下:

  1. 父组件执行函数体(首次渲染)。
  2. 子组件执行函数体(首次渲染)。
  3. 子组件执行 useEffect(挂载完成)。
  4. 父组件执行 useEffect(挂载完成)。

更新阶段:

  1. 父组件重新渲染。
  2. 子组件重新渲染。
  3. 子组件 useEffect 清理函数执行。
  4. 父组件 useEffect 清理函数执行。
  5. 子组件 useEffect 执行。
  6. 父组件 useEffect 执行。

组件类 API

PureComponent

PureComponentComponent 的子类,是基于 shouldComponentUpdate 的一种优化方式。使用 PureComponent 的主要优点在于它自动执行了浅比较来检查 props 和 state 是否有变化,没有变化的时候不会重新渲染,从而提高了性能,减少了不必要的计算和 DOM 操作。

import React, { PureComponent } from 'react';

class MyComponent extends PureComponent {
  render() {
    return (
      <div>
        {this.props.text}
      </div>
    );
  }
}

export default MyComponent;

memo

React.memo 是 React 中用于函数组件的性能优化手段,它是一个高阶函数,用来包装一个函数组件,并利用引用地址比较(浅比较)来决定是否重新渲染该组件。当组件的 props 没有发生变化时(基于浅比较),则跳过重新渲染,从而提高性能。

import React, { memo } from 'react';

const MyComponent = memo((props) => {
  // 组件逻辑...
  return <div>{props.text}</div>;
});

// 自定义比较函数:可以通过传递第二个参数给memo来自定义比较逻辑,这允许你实现深度比较或其他定制化的比较策略。
const MyComponent = memo((props) => {...}, (prevProps, nextProps) => {
  // 自定义比较逻辑
  // 返回true如果 props 没有变化,无需重新渲染
  // 返回false如果 props 有变化,需要重新渲染
  return prevProps.text === nextProps.text;
});

createRef

createRef 是 React 中管理 DOM 元素或组件实例引用的一个现代、灵活的方法,有助于处理表单、动画交互、原生DOM操作等场景。

class MyComponent extends React.Component {
  myInputRef = React.createRef();

  componentDidMount() {
    // 在组件挂载后访问DOM元素
    this.myInputRef?.current?.focus();
  }

  render() {
    return <input type="text" ref={this.myInputRef} />;
  }
}

forwardRef

forwardRef 是 React 中的一个高阶组件(HOC),它允许我们将 React 的 refs 转发到被包裹的组件中,即使这个组件是一个函数组件。这在需要访问子组件的 DOM 节点或者想要从父组件传递一些引用到子组件的场景下非常有用。极大地增强了函数组件的能力,使得它们在处理需要直接操作DOM或传递引用的场景下更加灵活和强大。

import React, { forwardRef } from 'react';

// 第一个参数是React.forwardRef接收的render函数,它接收两个参数:props和ref
const MyForwardedComponent = forwardRef((props, ref) => {
  // 现在你可以在这个函数组件内部使用ref了
  return <input type="text" ref={ref} {...props} />;
});

// 使用forwardRef的组件时,可以像普通组件那样使用ref
class ParentComponent extends React.Component {
  myInputRef = React.createRef();

  focusInput = () => {
    this.myInputRef?.current?.focus();
  };

  render() {
    return (
      <>
        <MyForwardedComponent ref={this.myInputRef} />
        <button onClick={this.focusInput}>Focus Input</button>
      </>
    );
  }
}

createContext

createContext 是 React 中的一个API,用于创建一个“context”对象。Context 提供了一种在组件树中传递数据的方式,而不必显式地通过每一个层级手动传递 props。这使得在不同层级的组件中共享数据变得简单且高效,特别适合管理如主题语言设置认证信息等全局状态。

基本用法:

  • 创建 Context: createContext(defaultValue)
import React from 'react';
// 创建一个context
const MyContext = React.createContext('light');
  • Provider组件: 注入值上下文
class App extends React.Component {
  state = {
    theme: 'light',
  };

  render() {
    return (
      // 通过Provider组件向上下文中注入值
      <MyContext.Provider value={this.state.theme}>
        <ComponentThatNeedsTheContext />
      </MyContext.Provider>
    );
  }
}
  • Consumer组件: 读取上下文的方法
function ComponentThatNeedsTheContext() {
  return (
    <MyContext.Consumer>
      {theme => /* 使用theme值 */}
    </MyContext.Consumer>
  );
}

或者使用 useContext Hook:

import React, { useContext } from 'react';

function ComponentThatNeedsTheContext() {
  const theme = useContext(MyContext);
  // 现在可以使用theme值
  return <div>{theme}</div>;
}

注意事项:

Context 会随着组件树的遍历而传递,无论组件是否使用了这个 Context。因此,应当谨慎使用,避免创建过多的 Context,尤其是嵌套使用时。

createElement

createElement 在我们的平时使用中较少,它用于创建 React 元素,是构成用户界面的基本单位,我们常写的 JSX 就是 createElement 的语法糖,所以还是很有必要了解这个 API 的。

基本语法:

React.createElement(
  type, // 通常是一个字符串(对应HTML标签名)或一个React组件(函数组件或类组件的构造函数)。
  [props], // 一个对象,用于传递给组件的属性。它可以包含事件处理器、样式等。
  [...children] // 代表组件的子元素,可以是一个React元素、字符串或数字,也可以是这些类型的数组。
)

示例:

const element = React.createElement(
  'div',
  { id: 'example', className: 'box' },
  'Hello, world!'
);

这段代码等同于下面的JSX写法:

<div id="example" className="box">
  Hello, world!
</div>

cloneElement

cloneElement 是 React 提供的一个方法,用于克隆并返回一个新的 React 元素,同时可以修改传入元素的 props,甚至可以添加或替换子元素。这个方法常用于在高阶组件中,或者任何需要基于现有元素创建一个具有额外 props 或不同子元素的新元素的场景。

基本语法:

React.cloneElement(
  element, // 要克隆的React元素
  [props], // 一个对象,包含了要添加或覆盖到原始元素props上的新属性
  [...children] // 可选的,用于替换或追加子元素到克隆后的元素中
)

自定义 HOC

高阶组件(Higher-Order Components, HOC)是 React 中用于重用组件逻辑的一种高级技术。HOC 本身不是 React API 的一部分,而是一种从函数式编程原则中借来的模式。一个 HOC 是一个接受组件作为参数并返回一个新的增强组件的函数。

function withEnhancement(WrappedComponent) {
  return function EnhancedComponent(props) {
    // 添加额外的props或逻辑
    const newProps = { ...props, enhancedProp: "Enhanced Value" };
    
    // 渲染被包装的组件,并传递新的props
    return <WrappedComponent {...newProps} />;
  };
}

注意事项:

  • 不要修改传入组件的props: 最好是通过组合新的 props 而不是修改原有的 props来保持纯净性。
  • 命名约定: 通常 HOC 函数名以 with 开头,以表明它是一个 HOC。
  • 文档和测试: 编写清晰的文档说明 HOC 的功能和用法,并确保充分测试,以防止引入bug。

Hooks

React Hooks 是React 16.8版本引入的一个新特性,在不编写类的情况下使用 React 的状态和其他生命周期特性。Hooks 使函数组件的功能更加丰富,使得函数组件逻辑更易于理解和重用。

useState

允许在函数组件中添加状态(state)。它返回一个状态变量和一个用来更新这个状态的函数。

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

useEffect

useEffect 是 React Hooks 系统中的一个重要成员,它主要用于执行副作用操作,比如数据获取、订阅或者手动修改 DOM 等。此 Hook 允许你同步副作用与 React 组件的生命周期,替代了类组件中的一些生命周期方法,如 componentDidMountcomponentDidUpdatecomponentWillUnmount

// useEffect 接收两个参数:一个包含副作用操作的函数,和一个依赖项数组
useEffect(() => {
  // 副作用操作:订阅或数据获取等
  document.title = `You clicked ${count} times`;

  // 可选的清理函数,用于在下次effect执行前或组件卸载时清理副作用
  return () => {
    // 清理操作,例如取消网络请求或移除事件监听器
  };
}, [count]); // 依赖项数组,当这些值变化时触发effect重新执行

useContext

useContext 是 React Hooks 系统中的一个 API,它使你能够在组件树中无需通过 props 逐层传递,就能访问到全局状态或其他组件上下文中的值。这对于管理如主题、语言、认证信息等跨多个组件共享的数据尤为有用。

import React, { useContext } from 'react';

function ComponentThatNeedsTheContext() {
  const theme = useContext(MyContext);
  // 现在可以使用theme值
  return <div>{theme}</div>;
}

useRef

useRef 是 React Hooks 系统中的一个API,它用于创建一个可变的引用对象(ref),这个对象的.current属性被初始化为传递的参数(initialValue)。useRef的主要用途是在渲染之间持久化一个可变的值,并且可以用来直接访问 DOM 元素或在函数组件之间保持一些状态。

import React, { useRef } from 'react';

function TextInputWithFocusButton() {
  // 初始化一个ref,用来存放input元素的引用
  const inputEl = useRef(null);

  const onButtonClick = () => {
    // 当按钮被点击时,让input元素获取焦点
    inputEl?.current?.focus();
  };

  return (
    <>
      {/* 将input元素的引用赋给useRef返回的对象 */}
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

useReducer

useReducer 是React中的一个Hook,它用于管理组件中的状态,特别适用于状态更新逻辑较复杂的场景。

基本用法:

import React, { useReducer } from 'react';

// 定义reducer函数
const reducer = (state, action) => {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
};

// 初始化状态
const initialState = { count: 0 };

function Counter() {
  // 使用useReducer,传入reducer函数和初始状态
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
    </div>
  );
}

注意事项:

  • 确保reducer函数是纯函数,即给定相同输入始终产生相同输出,不产生副作用。
  • 选择合适的状态管理方式,对于简单的状态管理,useState可能更直观易用。
  • 利用useCallback来记忆化dispatch函数,避免在每个渲染周期都创建新的函数引用,进而减少不必要的子组件重渲染。

useMemo

useMemo 是React中的一个Hook,用于优化性能,避免在每次渲染时都进行复杂的计算。它让你能够 memoize(记忆化)一个值,这个值是基于某些依赖项计算出来的,只有当这些依赖项改变时,才会重新计算这个值。

基本用法:

import React, { useMemo } from 'react';

function MyComponent({ list }) {
  // 使用useMemo进行性能优化
  const sortedList = useMemo(() => {
    console.log('Sorting list');
    return list.sort((a, b) => a - b);
  }, [list]); // 依赖项数组,当list变化时才重新计算sortedList

  return (
    <div>
      {sortedList.map(item => (
        <div key={item}>{item}</div>
      ))}
    </div>
  );
}

注意事项:

  • 不要过度使用: 虽然useMemo可以帮助优化性能,但是不必要的使用反而可能导致额外的性能开销,特别是在计算简单或频繁变化的值时。
  • 理解其限制: useMemo不会阻止其依赖项内的对象或数组的内部变化触发重渲染。只有当依赖项的引用本身发生变化时,才会触发重计算。
  • 与React.memo区别: React.memo是一个高阶组件,用于记忆化整个组件,防止不必要的渲染,而useMemo是记忆化组件内部的某个值或计算结果。

useCallback

useCallback 是 React中 的另一个性能优化 Hook,它用于记忆化函数。与 useMemo 相似,useCallback 也用于避免在每次渲染时都进行新的函数引用,但它的主要应用场景是当这些函数作为 props 传递给子组件时,帮助子组件避免不必要的重新渲染。

import React, { useCallback, useState } from 'react';

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

  // 使用useCallback记忆化increment函数
  const increment = useCallback(() => {
    setCount(count + 1);
  }, [count, setCount]); // 依赖项数组,当这些值变化时,才会生成新的increment函数

  return <ChildComponent onClick={increment} />;
}

function ChildComponent({ onClick }) {
  // ...
}

注意事项:

  • 与useMemo的区别: useMemo 适用于记忆化计算值或对象,而 useCallback 专门用于记忆化函数。
  • 避免闭包陷阱: 在使用 useCallback 时,需要注意函数内部引用的外部变量也应包含在依赖项数组中,以确保正确的重渲染逻辑。

别再死记CSS属性了!真正能让你少走半年弯路的,是这套思维

做前端这么多年,我发现一个特别普遍的现象:很多人学CSS,真的特别努力。属性背了一堆,教程刷了一个又一个,可一到真实项目里,页面照样乱、兼容照样崩、写起来照样费劲。

更有意思的是,现在很多人开始用AI写CSS,看似省时间,最后反而踩了更多坑。

不是你笨,也不是你不练,更不是AI没用,而是你没搞懂——AI写CSS和真正的CSS专家写CSS,差的根本不是“代码熟练度”,是实战思维和坑感。

今天这篇我不扯那些花里胡哨的动画特效,就聊点实在的——AI写CSS和专家写CSS的核心差异,以及真正能提升你CSS水平的东西。看完你可能会突然明白:原来CSS根本不是靠死记硬背,也不是靠AI抄作业就能学好的。

先说一句很扎心的:你之所以写CSS痛苦,甚至用AI也写不好,是因为你一直在用“拼属性”的思路做页面,而专家早就跳出了这个误区。

很多人学CSS,今天学个flex,明天学个grid,后天看到别人的炫酷效果就抄一段;用AI的话,就是丢一句“帮我写一个XX布局”,然后直接复制粘贴。结果就是:看得懂,写不出,改不动,一上线就出各种诡异问题——AI写的代码,要么冗余到没法维护,要么兼容性拉胯,要么稍微改个需求就全盘崩掉。

CSS本质从来不是“堆属性”,而是布局规则 + 渲染逻辑 + 工程化思维。你缺的永远不是技巧,也不是AI能给你的现成代码,是一套能直接扔进项目里用的、能避坑的稳定方法论——这也是AI和CSS专家最核心的区别。

真正实用的CSS,专家和AI的差距,就这4点(真人实战总结)

1. 专家先定结构,AI只堆属性

我平时写项目,第一步永远是拆页面结构,哪怕用注释画个简单的骨架,先理清“容器→层级→流→对齐”,再动笔写CSS。结构定好了,后面写样式就是填空,再复杂的页面也不会乱。

但AI不一样,你让它写一个布局,它只会根据你的描述,堆砌一堆flex、position属性,根本不会考虑结构合理性。比如你要一个自适应导航栏,AI可能会写一堆固定宽度的代码,换个屏幕尺寸就崩;而专家会先考虑响应式逻辑,先搭好可扩展的结构,再写样式,后期改需求也不用全盘返工。

这就是差距:AI只做“表面功夫”,专家做“底层逻辑”。

2. 专家追求“少写代码”,AI追求“完成任务”

我见过太多新手(包括依赖AI的人)写的样式表:越写越长,越改越坑,全是!important,嵌套乱得像毛线,后期想改一个按钮样式,要改十几处地方。

AI写CSS也是这样,为了“完成你要的效果”,会写大量冗余代码,甚至用最粗暴的方式实现——比如用margin-left硬调居中,用固定像素写间距,完全不考虑可维护性和复用性。

但CSS专家写代码,追求的是“可维护、可复用、好定位”。比如会抽离公共样式,统一类名规范,用变量管理颜色和间距,哪怕多花2分钟整理,后期能省几小时的修改时间。这不是“多此一举”,是实战里踩过无数坑才总结出来的经验——AI没有这种“坑感”,它只知道“实现效果”,不知道“规避问题”。

3. 专家懂原理,AI懂“模仿”

为什么很多人用AI写的CSS,看似能用,一上线就出bug?比如居中突然失效、样式冲突、移动端适配错乱?

因为AI不懂CSS的底层原理,它只是模仿网上的代码片段,不知道“层叠、优先级、BFC、重绘重排”这些核心逻辑。比如你让AI写一个浮动布局,它可能会忽略清除浮动,导致后面的元素错位;而专家一出手,就会考虑清除浮动的多种方案,甚至会避开浮动的坑,用flex更稳妥地实现。

90%的CSS诡异bug,都是原理没通导致的。AI不会帮你搞懂原理,它只会给你“现成代码”;而专家写CSS,每一行代码都有逻辑,知道为什么这么写,知道可能会出什么坑,提前规避——这是AI永远替代不了的,也是新手最该学的东西。

4. 专家适配实战,AI适配“理想场景”

AI写CSS,永远是“理想状态”——默认你用最新浏览器,默认页面结构简单,默认没有兼容需求。但真实项目里,哪有这么多“理想”?

比如你要兼容IE11,AI写的grid布局可能直接失效;比如你要做移动端适配,AI写的固定像素样式,在小屏手机上会溢出;比如你要和后端配合,AI写的样式可能和接口返回的数据不匹配,导致布局错乱。

而CSS专家写代码,会提前考虑这些实战场景:兼容方案怎么写、响应式怎么适配不同屏幕、如何配合后端数据调整布局、如何应对页面加载时的样式闪烁——这些都是实战里磨出来的经验,AI学不会,也写不出来。

给你一个我平时写项目的真实思路(AI写不出来的逻辑)

  1. 先拆页面结构,用注释画骨架,明确每个容器的作用,避免后期结构混乱; 2. 统一类名风格(比如BEM规范),不想到哪写到哪,方便后期查找和修改; 3. 先用flex/grid搭好布局骨架,优先考虑响应式,再调细节样式; 4. 处理细节时,优先用相对单位(rem、vw),避免固定像素,适配不同屏幕; 5. 抽离公共样式(比如按钮、标题、间距),用变量管理颜色和尺寸,提高复用性; 6. 最后检查兼容问题,针对不同浏览器做适配,避开常见坑。

就按这个顺序写,你的CSS至少会比现在干净三倍、好改五倍,也不会再被AI写的“坑代码”拖累。

但说实话,这篇只是皮毛

真正项目里你会遇到的问题远不止这些:复杂布局(比如多列自适应、嵌套布局)怎么写最稳?移动端横屏、异形屏适配怎么处理?怎么写出团队能共同维护的CSS?面试里常考的CSS原理题,怎么结合实战理解?

这些东西,AI给不了你,免费文章也真的讲不完、讲不透——因为里面全是实战踩坑的经验,是需要结合具体案例一点点拆解的。

我把自己多年工作里总结的整套CSS体系、实战思路、避坑清单、项目模板,全都整理成了一个知乎专栏。从基础原理→布局思维→工程化实战→面试重点,一步步带你把CSS彻底学通,教你像专家一样写CSS,而不是靠AI抄作业、靠死记硬背碰运气。

如果你是:

  1. 刚入门的前端,想把CSS学扎实,避开新手坑;

  2. 会写CSS,但写得乱、经常出bug,依赖AI还解决不了问题;

  3. 准备面试,想搞懂CSS底层原理,提升竞争力;

  4. 想提高开发效率,少加班,写出可维护的高质量CSS。

可以去我主页看看这套专栏,至少能帮你少走半年弯路,摆脱“AI依赖”,真正学会写CSS。

最后说一句:CSS真不是玄学,也不是靠AI就能搞定的。AI可以当工具,但不能当老师;死记硬背可以应付一时,但不能应付实战。真正能让你站稳脚跟的,是专家级的思维和实战经验。

觉得有用点个赞、收个藏,后面我继续更前端实战里的真东西,教你避开更多CSS坑,摆脱AI依赖。

你也可以阅读我写的这方面小册:

低代码平台表单设计系统技术分析(实战三)

第三篇:拖拽功能与布局系统

前两篇我们分析了低代码平台表单设计系统的整体架构和组件体系,这一篇将深入探讨拖拽功能与布局系统的实现

1. 拖拽功能实现

该低代码平台使用 vuedraggable 库实现组件的拖拽功能,主要包括两个场景:

1.1 从左侧组件库拖拽到画布

<draggable
  :list="formDefine"
  :group="{ name: 'widget', pull: pullFuction, put: false }"
  item-key="id"
  :sort="false"
  @start="dragStart"
>
  <template #item="{ element }">
    <div class="item" @click="addComponent(element)" fill="currentColor">
      <span v-html="icons[element.type]" class="item-icon"></span>
      <span>{{ element.title }}</span>
    </div>
  </template>
</draggable>

核心实现:

  • 使用 vuedraggable 组件包装左侧组件列表
  • 配置 group 属性,设置拖拽组名为 "widget"
  • pull 函数控制拖拽行为,支持克隆模式 
  • dragStart 事件处理拖拽开始时的逻辑 
  • addComponent 方法处理点击添加组件的逻辑

1.2 画布内组件的拖拽排序

<draggable
  :list="props.formData.list"
  group="widget"
  item-key="id"
  @add="handleAdd"
>
  <template #item="{ element }">
    <FormDesignView
      @on-widget-select="widgetSelect(element)"
      :item="element"
      :chosenItem="currentItem"
      :formData="formData"
    ></FormDesignView>
  </template>
</draggable>

核心实现:

  • 使用 vuedraggable 包装画布内的组件列表 
  • 配置 group 属性为 "widget",与左侧组件库保持一致
  • @add 事件处理组件添加到画布的逻辑 
  • item-key 使用组件的 id 确保正确的DOM更新

1.3 拖拽逻辑处理

// 拖拽开始处理
const dragStart = (e) => {
  currentDragItem.value = formDefine[e.oldDraggableIndex]
  const currentType = currentDragItem.value.type
  currentTemplate(currentType)
  triggerScroll()
}

// 处理添加组件
const handleAdd = ({ newIndex }) => {
  const itemId = props.formData.list[newIndex].type + '_' + new Date().getTime()
  props.formData.list[newIndex] = {
    ...JSON.parse(JSON.stringify(props.formData.list[newIndex])),
    itemId,
    grid: props.formData.config?.colSpan || 24
  }
  currentItem.value = props.formData.list[newIndex]
}

拖拽处理特点:

  • 拖拽时克隆组件,而非移动原始组件 
  • 为新添加的组件生成唯一的 id 
  • 应用当前表单的布局配置
  • 自动选中新添加的组件
  • 触发右侧配置面板的更新

2. 布局系统设计

2.1 布局配置选项

表单布局通过 FormConfig 组件进行配置,支持多种布局方式:

<el-form-item label="表单布局" :label-position="itemLabelPosition">
  <el-select v-model="config.colSpan">
    <el-option
      v-for="item in layOutOptions"
      :key="item.key"
      :label="item.name"
      :value="item.colSpan"
    />
  </el-select>
</el-form-item>

布局选项:

const layOutOptions = [
  {
    key: 'single',
    name: '单列',
    colSpan: 24
  },
  {
    key: 'double',
    name: '双列',
    colSpan: 12
  },
  {
   ....省略
  }
]

2.2 标签对齐方式

支持三种标签对齐方式:

<el-form-item label="标签对齐方式" :label-position="itemLabelPosition">
  <el-radio-group
    v-model="config.labelPosition"
    aria-label="label position"
    @change="handleLabelPositionChange"
  >
    <el-radio-button value="left">左侧</el-radio-button>
    <el-radio-button value="right">右侧</el-radio-button>
    <el-radio-button value="top">顶部</el-radio-button>
  </el-radio-group>
</el-form-item>

2.3 标签宽度配置

<el-form-item label="标签宽度" :label-position="itemLabelPosition">
  <el-input-number
    v-model="config.labelWidth"
    :min="60"
    :max="500"
  >
    <template #suffix>
      <span>px</span>
    </template>
  </el-input-number>
</el-form-item>

2.4 组件级布局控制

每个组件可以单独设置宽度:

<el-form-item label="字段宽度" v-if="specialShow">
  <el-radio-group v-model="item.grid" class="field-width-wrapper">
    <el-radio-button :value="6" label="1/4" />
 
    <-- 省略-->

    <el-radio-button :value="24" label="整行" />
  </el-radio-group>
</el-form-item>

3. 布局渲染实现

3.1 响应式布局

使用 Element Plus 的栅格系统实现响应式布局:

<el-col :span="fixGridOptions.includes(item.type) ? 24 : finalGrid">
  <!-- 组件内容 -->
</el-col>

布局计算逻辑:

const { finalGrid, fixGridOptions } = useFormData()

// 监听组件属性和表单属性的布局变化
watch(
  () => props.formData.config?.colSpan,
  (newVal) => {
    finalGrid.value = newVal
    props.item.grid = newVal
  }
)

3.2 固定宽度组件

某些组件(如多标签页、分割线等)需要固定宽度:

const fixGridOptions = [
  FORM_TYPE.MULTI_TAB,
  FORM_TYPE.SEPARATOR,
  // 其他需要固定宽度的组件
]

4. 拖拽与布局的交互

4.1 拖拽时的布局应用

当组件被拖拽到画布时,会自动应用当前表单的布局设置:

const handleAdd = ({ newIndex }) => {
  // ...
  props.formData.list[newIndex] = {
    ...JSON.parse(JSON.stringify(props.formData.list[newIndex])),
    itemId,
    // 应用当前表单配置的布局
    grid: props.formData.config?.colSpan || 24
  }
  // ...
}

4.2 布局变更的实时响应

当表单布局发生变化时,所有组件会自动更新:

watch(
  () => props.formData.config?.colSpan,
  (newVal) => {
    finalGrid.value = newVal
    props.item.grid = newVal
  }
)

4.3 组件宽度的独立控制

组件可以覆盖表单的默认布局,设置自己的宽度:

watch(
  () => props.item.grid,
  (newVal) => {
    finalGrid.value = newVal
  }
)

5. 技术亮点 

  • 流畅的拖拽体验 :使用 vuedraggable 实现平滑的拖拽效果 
  • 智能的布局应用 :拖拽时自动应用表单布局设置
  • 灵活的布局选项 :支持多种布局方式和标签对齐方式
  • 组件级布局控制 :每个组件可以单独设置宽度 
  • 响应式设计 :基于 Element Plus 的栅格系统
  • 实时布局更新 :布局变更实时反映到所有组件
  • 固定宽度组件 :某些组件自动使用固定宽度

这种拖拽与布局系统的设计,大大简化了表单设计过程。用户可以通过直观的拖拽操作和灵活的布局配置,快速创建出表单。并且还有预览功能,直接在预览界面就可实时看到表单布局和试用数据填报。

下一篇预告 :《组件属性配置系统》,将详细分析组件属性配置的实现机制和设计思路。

ts随笔:面向对象与高级类型

ts随笔:面向对象与高级类型

本篇主要聚焦在类、模块、高级类型以及在常见前端框架中的实践,同时结合生态中新出现的一些特性,如何自然地用上这些新能力。


原文地址

墨渊书肆/ts随笔:面向对象与高级类型


类(Class)

类是面向对象编程的基础,用于创建具有属性(数据成员)和方法(成员函数)的对象的蓝图。TypeScript 中的类支持 继承封装多态 等面向对象特性。

基本语法

class Person {
  name: string;
  age: number;

  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }

  greet() {
    console.log(`Hello, my name is ${this.name} and I am ${this.age} years old.`);
  }
}

const person = new Person("Alice", 30);
person.greet();

在较新的 TypeScript 版本中,你也可以结合 ECMAScript 的 私有字段 语法(以 # 开头),在保持类型安全的同时实现更彻底的封装:

class Counter {
  #value = 0;

  increment() {
    this.#value++;
  }

  get value(): number {
    return this.#value;
  }
}

继承

class Student extends Person {
  studentId: string;

  constructor(name: string, age: number, studentId: string) {
    super(name, age);
    this.studentId = studentId;
  }

  study() {
    console.log(`${this.name} is studying.`);
  }
}

const student = new Student("Bob", 20, "S123");
student.greet();
student.study();

借助 TypeScript 的严格类型系统,继承关系中的属性和方法都会得到完整的类型检查支持,在重写方法时也能获得参数和返回值的约束。

模块(Module)

模块是用于组织代码的容器,它允许你将相关联的类、接口、函数等封装在一个单独的文件中,并可以控制它们的可见性(导出/导入)。模块有助于避免命名冲突和促进代码的复用。

导入与导出

// moduleA.ts
export class MyClass {
  // ...
}

// 在其他文件中使用导出的元素
import { MyClass } from "./moduleA";

const myInstance = new MyClass();

默认导出命名导出 可以混合使用,但在一个模块中只能有一个默认导出;命名导出则可以有多个。

命名空间与模块的异同

在早期版本的 TypeScript 中,命名空间(Namespace)是另一种组织代码的方式,它类似于 C# 或 Java 中的包,提供了一种分层次的方式来组织代码。虽然模块现在是推荐的做法,但命名空间仍然可用,特别是在需要合并多个文件定义的命名空间时。

面向未来的模块特性:JSON 模块import defer

从 ES2025 开始,JSON 模块 等特性有望在主流环境中稳定可用,你可以直接以模块的方式导入 JSON 文件,并配合 TypeScript 的类型系统进行约束:

// config.json
// {
//   "apiBaseUrl": "https://api.example.com",
//   "featureFlags": {
//     "newUI": true
//   }
// }

interface FeatureFlags {
  newUI: boolean;
}

interface AppConfig {
  apiBaseUrl: string;
  featureFlags: FeatureFlags;
}

// 在支持 JSON 模块的环境下
import configJson from "./config.json" with { type: "json" };

const config = configJson as AppConfig;

在 ES2026 及之后,import defer 等语法提案逐步成熟时,可以在保持语义清晰的前提下延迟加载非关键模块,而 TypeScript 依然会对导入的符号进行完整的类型检查:

// 伪代码示意:具体语法以最终标准为准
// import defer "./heavy-analytics.js";

// TypeScript 关注的是导出的类型本身,只要声明文件同步更新,
// 即使底层加载时机发生变化,类型系统仍然保持稳定。

和上一篇中提到的声明文件一样,这些新的模块特性最终都会通过 .d.ts 的方式落地到 TypeScript 生态中。

高级类型探索

泛型 Generics

泛型(Generics)是 TypeScript 中一个强大的特性,它允许你在定义函数、接口或类的时候不预先指定具体的类型,而是将类型作为参数传递。

基本概念

泛型的核心在于使用类型变量(通常用大写字母表示,如 T、U 等)来代表一些未知的类型。当使用这个组件时,你再指定这些类型变量的具体类型。

泛型函数
function identity<T>(arg: T): T {
  return arg;
}

let output = identity<string>("hello");
console.log(output);

let numberOutput = identity<number>(123);
console.log(numberOutput);
泛型接口
interface Pair<T> {
  first: T;
  second: T;
}

let pairStr: Pair<string> = { first: "hello", second: "world" };
let pairNum: Pair<number> = { first: 1, second: 2 };
泛型类
class Box<T> {
  private containedValue: T;

  set(value: T) {
    this.containedValue = value;
  }

  get(): T {
    return this.containedValue;
  }
}

let boxStr = new Box<string>();
boxStr.set("hello");
console.log(boxStr.get());

let boxNum = new Box<number>();
boxNum.set(123);
console.log(boxNum.get());
泛型约束

有时候,你可能需要限制可以作为类型参数的具体类型,这时候可以使用泛型约束。泛型约束通过接口来定义,要求传入的类型必须满足该接口定义的条件。

interface Lengthwise {
  length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
  console.log(arg.length);
  return arg;
}

loggingIdentity({ length: 10, value: "test" });
// loggingIdentity(123); // 错误:number 没有 length 属性

联合类型 Union Types

联合类型 允许一个变量可能是多种类型之一。例如,你可以定义一个变量既可能是字符串也可能是数字:

let myValue: string | number;
myValue = "Hello";
myValue = 42;

类型守卫 Type Guards

当你在操作联合类型的变量时,TypeScript 可能无法确定变量的具体类型,这会影响到你能够调用的方法或访问的属性。类型守卫 就是用来缩小类型范围,确保在运行时变量属于某种特定类型。

typeof 类型守卫
if (typeof myValue === "string") {
  console.log(myValue.toUpperCase());
} else {
  console.log(myValue.toFixed(2));
}
instanceof 类型守卫
class Animal {}

class Dog extends Animal {
  bark() {
    console.log("Woof!");
  }
}

function isDog(animal: Animal): animal is Dog {
  return animal instanceof Dog;
}

let pet = new Dog();
if (isDog(pet)) {
  pet.bark();
}
in 操作符
interface Cat {
  meow: () => void;
}

function makeSound(animal: Animal | Cat) {
  if ("meow" in animal) {
    animal.meow();
  } else {
    console.log(animal.toString());
  }
}

Iterator Helpers 与 Set 扩展下的类型推断

在 ES2025、ES2026 相关提案中,Iterator HelpersSet 扩展 是非常值得关注的一类特性:它们让各种可迭代对象(包括数组、Set、Map 的键值迭代器等)拥有类似链式操作的能力。

当对应的类型定义进入 TypeScript 之后,可以配合泛型和类型守卫写出既简洁又安全的代码。例如,以 Set 扩展为例:

// 假设运行时与 TypeScript lib 均已支持 Set 的扩展方法
const ids = new Set([1, 2, 3, 4, 5]);

// filter 返回的仍然是 Set<number>,类型信息由泛型推断而来
// const evenIds = ids.filter((id) => id % 2 === 0);

// map 等其他 Iterator Helpers 也同理可以得到明确的类型
// const idStrings = ids.map((id) => `id-${id}`);

虽然上面的代码在当前某些环境中还处于“提案阶段”,但可以预期的是,未来在 TypeScript 中使用这些 API 时,你同样能获得完整的泛型推断和类型守卫支持。

日期时间与本地化:TemporalIntl.Locale

时间与本地化一直是前端开发中的老大难问题。Temporal 和 Intl.Locale 等提案正是为了解决 Date 语义不清、Intl 配置复杂等问题。

Temporal 定稿并进入主流运行时时,你可以在 TypeScript 中这样书写代码:

// 假设 lib 已经包含 Temporal 与最新的 Intl 声明
// const now: Temporal.ZonedDateTime = Temporal.Now.zonedDateTimeISO();
// const locale = new Intl.Locale("zh-CN", { calendar: "gregory" });

// console.log(now.toLocaleString(locale.toString()));

这些 API 本身是 JavaScript 语言层面的特性,但它们的类型声明会第一时间进入 TypeScript 官方声明文件,从而让我们在使用它们时也能享受完整的类型推断、自动补全和错误检查。

ts 在 React 中的使用

新项目使用 create-react-app 接入

npx create-react-app my-app --template typescript

React 老项目接入

首先安装 @types/react@types/react-dom 这些 React 的类型定义文件:

npm install --save-dev @types/react @types/react-dom

然后将 .js 文件逐步转换为 .tsx(TypeScript 支持 JSX 的文件扩展名)并添加类型注释。

React 代码编写

import React, { useState } from "react";

interface Props {
  name: string;
}

const Hello: React.FC<Props> = ({ name }) => {
  const [message, setMessage] = useState<string>("Hello");

  return (
    <div>
      <h1>{`${message}, ${name}!`}</h1>
      <button onClick={() => setMessage("Welcome")}>Change Message</button>
    </div>
  );
};

export default Hello;

在较新的 TypeScript 与 React 生态中,配合前面提到的 JSON 模块Iterator HelpersTemporal 等能力,你可以更放心地在组件中使用这些新特性——只要升级依赖并确保声明文件同步更新,编辑器就会用类型系统帮你“兜住”大部分错误。

ts 在 Vue 3 中的使用

新项目使用 Vue CLI 接入

vue create my-vue3-project --preset typescript

Vue 老项目接入

vue add typescript

Vue 代码编写

<script lang="ts">
import { defineComponent, ref, reactive } from "vue";

interface Props {
  msg: string;
}

export default defineComponent({
  props: {
    msg: String,
  },
  setup(props: Props) {
    const count = ref(0);
    const state = reactive({ status: "active" });

    // 在这里同样可以安心地使用前文提到的高级类型、
    // Iterator Helpers 或 Temporal 等能力,TypeScript
    // 会在编译阶段帮你把控类型安全。

    return {
      count,
      state,
    };
  },
});
</script>

无论是 React 还是 Vue,TypeScript 都会继续扮演“粘合剂”的角色:只要按需升级依赖、合理配置 tsconfig,就能够在习惯的写法下自然享受到这些新特性带来的收益。

SPA 首屏加载速度慢怎么解决?

一、问题根源拆解:为什么 SPA 首屏会这么慢?

在动手优化前,先明确核心瓶颈,确保优化方向精准:

  1. 资源体积过大:打包后 app.js 体积臃肿,包含大量未使用的代码(冗余代码);
  2. 脚本阻塞渲染:SPA 需加载完完整 JS 才能渲染首屏,JS 解析 / 执行时间过长导致白屏;
  3. 网络传输低效:未启用 CDN、未开启 Gzip,资源传输耗时久;
  4. 缓存未命中:静态资源未设置合理缓存策略,每次请求都重新下载;
  5. 重复请求 / 资源:多入口重复加载相同 JS/CSS/ 图片资源。

二、核心解决方案:分 5 大维度落地优化

维度 1:减小入口文件体积(最立竿见影)

入口文件(如 app.js)是首屏加载的核心瓶颈,需通过「代码分割、剔除冗余、按需加载」大幅减小体积。

1.1 路由懒加载(必做)

将路由按模块分割,实现「首屏只加载当前页面需要的代码」,Vue2/Vue3 通用配置:

Vue3 配置(src/router/index.js
import { createRouter, createWebHashHistory } from 'vue-router'

// 路由懒加载:每个路由对应一个独立 chunk
const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import(/* webpackChunkName: "home" */ '@/views/Home.vue')
  },
  {
    path: '/about',
    name: 'About',
    component: () => import(/* webpackChunkName: "about" */ '@/views/About.vue')
  }
]

const router = createRouter({
  history: createWebHashHistory(),
  routes
})

export default router
Vue2 配置(src/router/index.js
import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/',
      name: 'Home',
      component: () => import(/* webpackChunkName: "home" */ '@/views/Home.vue')
    },
    {
      path: '/about',
      name: 'About',
      component: () => import(/* webpackChunkName: "about" */ '@/views/About.vue')
    }
  ]
})

1.2 UI 框架按需加载(必做)

避免一次性引入完整 UI 框架(如 ElementUI、Ant Design Vue),仅引入使用的组件:

Vue3 (Element Plus 按需加载)
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
// 按需引入 Element Plus 组件
import { ElButton, ElInput } from 'element-plus'
import 'element-plus/dist/index.css'

const app = createApp(App)
// 注册需要的组件
app.use(ElButton)
app.use(ElInput)
app.mount('#app')
Vue2 (Element UI 按需加载)
// src/main.js
import Vue from 'vue'
import App from './App.vue'
// 按需引入 Element UI 组件
import { Button, Input } from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'

Vue.use(Button)
Vue.use(Input)
Vue.config.productionTip = false
new Vue({ render: h => h(App) }).$mount('#app')

1.3 移除冗余代码(Webpack 配置)

vue.config.js 中添加配置,自动剔除未使用的代码(Tree Shaking):

// vue.config.js
module.exports = {
  configureWebpack: {
    optimization: {
      usedExports: true, // 开启 Tree Shaking
      splitChunks: { // 代码分割:提取公共代码
        chunks: 'all',
        cacheGroups: {
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'chunk-vendors',
            priority: -10
          },
          common: {
            name: 'chunk-common',
            minChunks: 2,
            priority: -20,
            reuseExistingChunk: true
          }
        }
      }
    }
  }
}

维度 2:静态资源本地缓存(提升二次加载速度)

通过设置浏览器缓存,让用户二次访问时直接读取本地资源,大幅提升加载速度。

2.1 配置 Webpack 输出哈希(必做)

打包时为静态文件添加内容哈希,确保文件更新后浏览器能识别新文件:

// vue.config.js
module.exports = {
  filenameHashing: true, // 开启文件哈希
  outputDir: 'dist',
  assetsDir: 'static'
}

2.2 Nginx 缓存配置(服务端落地)

若使用 Nginx 部署,添加以下配置,设置缓存过期时间:

# 配置静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
    expires 30d; # 缓存 30 天
    add_header Cache-Control "public, max-age=2592000";
    add_header ETag ""; # 禁用 ETag(可选)
}

维度 3:图片资源压缩与优化(减少网络请求耗时)

图片是首屏资源体积的主要贡献者,需通过压缩、懒加载、CDN 优化。

3.1 图片压缩(构建时自动压缩)

使用 image-webpack-loader 压缩图片:

npm install image-webpack-loader --save-dev

配置 vue.config.js

module.exports = {
  chainWebpack: config => {
    config.module
      .rule('images')
      .use('image-webpack-loader')
      .loader('image-webpack-loader')
      .options({
        mozjpeg: { progressive: true, quality: 65 }, // 压缩 JPG
        optipng: { enabled: false }, // 压缩 PNG
        pngquant: { quality: [0.6, 0.8] } // 压缩 PNG
      })
  }
}

3.2 图片懒加载(仅加载可视区域图片)

使用 Vue 官方插件 vue-lazyload

npm install vue-lazyload --save
// src/main.js
import Vue from 'vue'
import VueLazyload from 'vue-lazyload'

Vue.use(VueLazyload, {
  loading: require('@/assets/images/loading.png'), // 加载中占位图
  error: require('@/assets/images/error.png') // 加载失败占位图
})

组件中使用

<template>
  <img v-lazy="imageUrl" alt="懒加载图片" />
</template>

维度 4:开启 Gzip 压缩(大幅减小传输体积)

Gzip 压缩可将 JS/CSS 体积压缩 60%-80%,是提升首屏速度的关键服务端配置。

4.1 Nginx 开启 Gzip(必做)

# 开启 Gzip
gzip on;
# 压缩文件类型
gzip_types text/plain text/css application/javascript application/json application/xml application/rss+xml text/xml text/javascript image/svg+xml;
# 压缩级别(1-9,数值越高压缩率越高,消耗 CPU 越多)
gzip_comp_level 6;
# 仅压缩大于 1k 的文件
gzip_min_length 1024;
# 压缩响应头
gzip_vary on;

4.2 前端构建时生成 Gzip 文件

// vue.config.js
const CompressionWebpackPlugin = require('compression-webpack-plugin')

module.exports = {
  configureWebpack: {
    plugins: [
      new CompressionWebpackPlugin({
        algorithm: 'gzip', // 压缩算法
        test: /\.(js|css|json|svg)$/, // 压缩哪些文件
        threshold: 10240, // 仅压缩大于 10k 的文件
        minRatio: 0.8 // 压缩率小于 0.8 才压缩
      })
    ]
  }
}

维度 5:解决脚本阻塞渲染(让首屏快速显示)

PA 中 JS 加载 / 解析 / 执行会阻塞 DOM 渲染,需通过「预加载、 defer、异步加载」解决。

5.1 关键 CSS 内联(首屏样式直接写入 HTML)

将首屏核心 CSS 内联到 index.html,避免外部 CSS 阻塞渲染:

<!-- public/index.html -->
<head>
  <!-- 内联首屏核心样式 -->
  <style>
    #app { height: 100%; }
    .loading { display: flex; justify-content: center; align-items: center; height: 100%; }
  </style>
</head>

5.2 非关键脚本异步加载

main.js 中,将非核心初始化逻辑(如埋点、第三方统计)异步加载:

// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

// 核心初始化逻辑
const app = createApp(App)
app.use(router)
app.mount('#app')

// 非核心逻辑:异步加载(使用 setTimeout 或 import())
setTimeout(() => {
  import('./utils/analytics') // 埋点统计
  import('./utils/third-party') // 第三方 SDK
}, 1000)

总结

SPA 首屏加载优化是工程化 + 服务端 + 前端的协同工作,核心落地步骤如下:

  1. 体积优化:路由懒加载 + UI 按需加载 + 代码分割;
  2. 网络优化:Gzip 压缩 + CDN 加速 + 图片压缩;
  3. 渲染优化:关键 CSS 内联 + 非核心脚本异步;
  4. 缓存优化:文件哈希 + 浏览器缓存;

本文提供的方案完全可落地,从 Webpack 配置到 Nginx 再到前端代码,每一步都有具体可复制的代码,适配 Vue2/Vue3 生态,已在多个生产项目中验证,能彻底解决 SPA 首屏加载慢的问题。

Everything Claude Code 完全长篇指南

image.png


在上一篇Everything Claude Code 速查指南中,我涵盖了基础配置:技能(skills)和命令(commands)、钩子(hooks)、子代理(subagents)、MCPs、插件(plugins),以及构成高效 Claude Code 工作流骨干的配置模式。那是一份配置指南和基础设施。

这篇长篇指南深入探讨了将高效会话与浪费性会话区分开来的技术。如果你还没有读过简明指南,请先回去配置好你的设置。接下来的内容假设你已经配置好了技能、代理、钩子和 MCPs 并且它们已正常运行。

这里的主题包括:token 经济学、记忆持久化、验证模式、并行化策略,以及构建可复用工作流的复合效应。这些是我在超过10个月的日常使用中精炼出的模式,它们决定了你是在第一个小时内就被上下文腐烂(context rot)所困扰,还是能够维持数小时的高效会话。

简明和长篇文章中涵盖的所有内容都可在 GitHub 上获取:everything-claude-code


Context & Memory Management

上下文与记忆管理

对于跨会话共享记忆,最好的方法是使用一个技能或命令来总结并检查进度,然后保存到你的 .claude 文件夹中的 .tmp 文件,并在会话结束前持续追加内容。第二天它可以将其作为上下文来使用,从你上次中断的地方继续,为每个会话创建一个新文件,这样你就不会把旧的上下文污染到新的工作中。最终你会有一个很大的会话日志文件夹——只需将其备份到有意义的地方,或者修剪你不需要的会话对话。Claude 会创建一个总结当前状态的文件。查看它,如果需要可以要求修改,然后开始新的对话。对于新对话,只需提供文件路径。当你达到上下文限制并需要继续复杂工作时,这特别有用。这些文件应该包含——哪些方法有效(可验证且有证据)、哪些尝试过的方法没有效果、哪些方法尚未尝试以及还剩什么要做。

image.png 会话存储示例 -> github.com/affaan-m/ev…

策略性清除上下文:

一旦你的计划设定好并且上下文已清除(现在是 Claude Code 计划模式中的默认选项),你就可以从计划开始工作。当你积累了大量不再与执行相关的探索性上下文时,这很有用。对于策略性压缩,禁用自动压缩。在逻辑间隔处手动压缩,或创建一个技能为你执行此操作,或在某些定义的标准下提出建议。


Strategic Compact Skill(直接链接):

(嵌入以供快速参考)

#!/bin/bash
# Strategic Compact Suggester
# Runs on PreToolUse to suggest manual compaction at logical intervals
#
# Why manual over auto-compact:
# - Auto-compact happens at arbitrary points, often mid-task
# - Strategic compacting preserves context through logical phases
# - Compact after exploration, before execution
# - Compact after completing a milestone, before starting next

COUNTER_FILE="/tmp/claude-tool-count-$$"
THRESHOLD=${COMPACT_THRESHOLD:-50}

# Initialize or increment counter
if [ -f "$COUNTER_FILE" ]; then
  count=$(cat "$COUNTER_FILE")
  count=$((count + 1))
  echo "$count" > "$COUNTER_FILE"
else
  echo "1" > "$COUNTER_FILE"
  count=1
fi

# Suggest compact after threshold tool calls
if [ "$count" -eq "$THRESHOLD" ]; then
  echo "[StrategicCompact] $THRESHOLD tool calls reached - consider /compact if transitioning phases" >&2
fi

将它挂钩到 Edit/Write 操作的 PreToolUse 上——当你积累了足够多的上下文、压缩可能有帮助时,它会提醒你。


进阶:动态系统提示注入(Advanced: Dynamic System Prompt Injection)

我学到并正在试运行的一种模式是:不要只把所有东西放在 CLAUDE.md(用户范围)或 .claude/rules/(项目范围)中(这些每次会话都会加载),而是使用 CLI 标志来动态注入上下文。

claude --system-prompt "$(cat memory.md)"

这让你可以更精确地控制何时加载什么上下文。你可以根据当前工作内容在每个会话中注入不同的上下文。

为什么这比 @ 文件引用更重要: 当你使用 @memory.md 或将内容放在 .claude/rules/ 中时,Claude 通过 Read 工具在对话过程中读取它——它作为工具输出传入。当你使用 --system-prompt 时,内容在对话开始前被注入到实际的系统提示中。

区别在于指令层级(instruction hierarchy)。系统提示内容的权威性高于用户消息,用户消息的权威性高于工具结果。对于大多数日常工作来说,这种差异是微不足道的。但对于诸如严格的行为规则、项目特定的约束,或你绝对需要 Claude 优先考虑的上下文——系统提示注入确保它得到适当的权重。

实际配置:

一种有效的方法是将 .claude/rules/ 用于你的基线项目规则,然后为场景特定的上下文设置 CLI 别名,可以在它们之间切换:

# Daily development
alias claude-dev='claude --system-prompt "$(cat ~/.claude/contexts/dev.md)"'

# PR review mode
alias claude-review='claude --system-prompt "$(cat ~/.claude/contexts/review.md)"'

# Research/exploration mode
alias claude-research='claude --system-prompt "$(cat ~/.claude/contexts/research.md)"'

System Prompt Context Example Files(直接链接):

  • dev.md 专注于实现
  • review.md 专注于代码质量/安全
  • research.md 专注于行动前的探索

同样,对于大多数情况,使用 .claude/rules/context1.md 和直接将 context1.md 附加到系统提示之间的区别是微乎其微的。CLI 方法更快(无需工具调用)、更可靠(系统级权威)且稍微更节省 token。但这是一个小优化,对许多人来说开销大于收益。


进阶:记忆持久化钩子(Advanced: Memory Persistence Hooks)

有一些钩子(hooks)是大多数人不知道的,或者知道但没有真正利用的,它们有助于记忆管理:

SESSION 1                    SESSION 2
─────────                    ─────────
[Start]                      [Start]
   │                            │
   ▼                            ▼
┌──────────────┐          ┌──────────────┐
│ SessionStart │ ◄─── reads ─────── │ SessionStart │◄── loads previous
│    Hook      │    nothing yet     │    Hook      │    context
└──────┬───────┘          └──────┬───────┘
       │                         │
       ▼                         ▼
   [Working]                 [Working]
       │                    (informed)
       ▼                         │
┌──────────────┐                 ▼
│  PreCompact  │──► saves state [Continue...]
│    Hook      │    before summary
└──────┬───────┘
       │
       ▼
   [Compacted]
       │
       ▼
┌──────────────┐
│  Stop Hook   │──► persists to ──────────►
│ (session-end)│    ~/.claude/sessions/
└──────────────┘
  • PreCompact Hook: 在上下文压缩发生之前,将重要状态保存到文件中
  • SessionComplete Hook: 在会话结束时,将学习成果持久化到文件中
  • SessionStart Hook: 在新会话开始时,自动加载之前的上下文

Memory Persistent Hooks(直接链接):

(嵌入以供快速参考)

{
  "hooks": {
    "PreCompact": [{
      "matcher": "*",
      "hooks": [{
        "type": "command",
        "command": "~/.claude/hooks/memory-persistence/pre-compact.sh"
      }]
    }],
    "SessionStart": [{
      "matcher": "*",
      "hooks": [{
        "type": "command",
        "command": "~/.claude/hooks/memory-persistence/session-start.sh"
      }]
    }],
    "Stop": [{
      "matcher": "*",
      "hooks": [{
        "type": "command",
        "command": "~/.claude/hooks/memory-persistence/session-end.sh"
      }]
    }]
  }
}

这些的作用:

  • pre-compact.sh:记录压缩事件,用压缩时间戳更新活动会话文件
  • session-start.sh:检查最近的会话文件(最近7天),通知可用的上下文和已学习的技能
  • session-end.sh:创建/更新每日会话文件模板,跟踪开始/结束时间

将这些串联起来,即可实现跨会话的持续记忆,无需手动干预。这建立在第一篇文章中的钩子类型(PreToolUse、PostToolUse、Stop)之上,但专门针对会话生命周期。


Continuous Learning / Memory

持续学习 / 记忆

我们之前讨论了以更新代码地图(codemaps)的形式进行持续记忆更新,但这也适用于其他方面,比如从错误中学习。如果你不得不多次重复提示,而 Claude 遇到了相同的问题或给了你以前听过的回答,那这部分就适用于你。很可能你需要发送第二个提示来"重新引导(resteer)"并校准 Claude 的方向。这适用于任何此类场景——这些模式必须被追加到技能中。

现在你可以通过简单地告诉 Claude 记住它或将其添加到你的规则中来自动完成此操作,或者你可以有一个技能来专门做这件事。

问题: 浪费 token,浪费上下文,浪费时间,你的皮质醇飙升,因为你沮丧地对 Claude 大喊不要做你在之前会话中已经告诉它不要做的事情。

解决方案: 当 Claude Code 发现一些非平凡的东西——调试技术、变通方法、某些项目特定的模式——它会将该知识保存为新技能。下次出现类似问题时,技能会自动加载。


Continuous Learning Skill(直接链接)

为什么我使用 Stop hook 而不是 UserPromptSubmit?UserPromptSubmit 在你发送的每条消息上都会运行——这会带来很大的开销,给每个提示增加延迟,坦率地说对这个目的来说是大材小用。Stop 在会话结束时只运行一次——轻量级,不会在会话期间拖慢你的速度,而且评估的是完整会话而非碎片化的内容。

安装:

# Clone to skills folder
git clone https://github.com/affaan-m/everything-claude-code.git ~/.claude/skills/everything-claude-code

# Or just grab the continuous-learning skill
mkdir -p ~/.claude/skills/continuous-learning
curl -sL https://raw.githubusercontent.com/affaan-m/everything-claude-code/main/skills/continuous-learning/evaluate-session.sh > ~/.claude/skills/continuous-learning/evaluate-session.sh
chmod +x ~/.claude/skills/continuous-learning/evaluate-session.sh

Hook Configuration(直接链接):

{
  "hooks": {
    "Stop": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/skills/continuous-learning/evaluate-session.sh"
          }
        ]
      }
    ]
  }
}

这使用 Stop hook 在每个提示上运行一个激活脚本,评估会话中值得提取的知识。该技能也可以通过语义匹配激活,但钩子确保了一致的评估。

Stop hook 在你的会话结束时触发——脚本分析会话中值得提取的模式(错误解决方案、调试技术、变通方法、项目特定模式等),并将它们保存为 ~/.claude/skills/learned/ 中的可复用技能。

手动提取 - /learn:

你不必等到会话结束。仓库中还包含一个 /learn 命令,你可以在会话中途刚解决了一些非平凡的问题时运行它。它会提示你立即提取模式,起草一个技能文件,并在保存前征求确认。参见这里

会话日志模式:

该技能期望会话日志保存在 .tmp 文件中。模式为:~/.claude/sessions/YYYY-MM-DD-topic.tmp——每个会话一个文件,包含当前状态、已完成项目、阻碍因素、关键决定和下次会话的上下文。示例会话文件在仓库的 examples/sessions/ 中。


其他自我改进的记忆模式:

来自 @RLanceMartin 的一种方法涉及反思会话日志以提炼用户偏好——本质上是建立一个记录什么有效、什么无效的"日记"。每次会话后,一个反思代理(reflection agent)提取哪些做得好、哪些失败了、你做了哪些修正。这些学习成果会更新一个记忆文件,在后续会话中加载。

来自 @alexhillman 的另一种方法是让系统每15分钟主动建议改进,而不是等你注意到模式。代理审查最近的交互,提出记忆更新建议,你批准或拒绝。随着时间推移,它会从你的批准模式中学习。


Token Optimization

Token 优化

我从价格敏感的消费者那里收到了很多问题,或者那些作为重度用户经常遇到限制问题的人。在 token 优化方面,有一些技巧可以使用。

主要策略:子代理架构

主要是优化你使用的工具和子代理架构,旨在将最便宜的、足以完成任务的模型委派出去以减少浪费。你有几个选项——可以尝试试错法并随着进展调整。一旦你了解了什么是什么,你就可以区分哪些委派给 Haiku、哪些委派给 Sonnet、哪些委派给 Opus。

基准测试方法(更深入):

另一种更深入的方法是,你可以让 Claude 设置一个基准测试,在一个有明确目标、任务和明确计划的仓库中。在每个 git worktree 中,让所有子代理都使用同一个模型。在任务完成时记录日志——理想情况下在你的计划和任务中记录。你必须至少使用每个子代理一次。

一旦完成一轮完整测试并且任务已从你的 Claude 计划中勾选完毕,停下来审核进度。你可以通过比较差异(diffs)、创建在所有 worktree 中统一的单元测试、集成测试和端到端测试来做到这一点。这将给你一个基于通过/失败案例的数值基准。如果所有 worktree 都全部通过,你需要添加更多的边界测试用例或增加测试的复杂度。这可能值得也可能不值得做,取决于这对你来说到底有多重要。

模型选择快速参考:

image.png

各种常见任务上子代理的假设配置及选择背后的推理

90% 的编码任务默认使用 Sonnet。当首次尝试失败、任务跨越5个以上文件、架构决策或安全关键代码时升级到 Opus。当任务是重复性的、指令非常清晰,或在多代理设置中作为"工人(worker)"使用时降级到 Haiku。坦率地说,Sonnet 4.5 目前处于一个尴尬的位置,每百万输入 token 3,每百万输出token3,每百万输出 token 15,与 Opus 相比成本节省约66.7%,绝对值来说这是不错的节省,但相对而言对大多数人来说微不足道。Haiku 和 Opus 组合最有意义,因为 Haiku 与 Opus 有5倍的成本差异,而与 Sonnet 只有1.67倍的价格差异。

image.png

来源:platform.claude.com/docs/en/abo…

在你的代理定义中,指定模型:

---
name: quick-search
description: Fast file search
tools: Glob, Grep
model: haiku  # Cheap and fast
---

工具特定优化:

想想 Claude 最频繁调用的工具。例如,用 mgrep 替换 grep——在各种任务上,与传统 grep 或 ripgrep(Claude 默认使用的)相比,平均有效 token 减少约一半。

image.png

来源:github.com/mixedbread-…

后台进程:

适用时,如果你不需要 Claude 处理整个输出并实时流式传输,请在 Claude 之外运行后台进程。这可以通过 tmux 轻松实现(参见简明指南Tmux Commands Reference(直接链接))。获取终端输出后,要么总结它,要么只复制你需要的部分。这将节省大量输入 token,这是大部分成本的来源——Opus 4.5 每百万 token 5,输出每百万token5,输出每百万 token 25。

模块化代码库的好处:

拥有更模块化的代码库,包含可复用的工具函数、函数、钩子等——主文件在数百行而非数千行——既有助于 token 优化成本,也有助于第一次就正确完成任务,两者是相关的。如果你必须多次提示 Claude,你就在大量消耗 token,尤其是当它反复读取非常长的文件时。你会注意到它需要进行大量工具调用才能读完文件。中间过程中,它会告诉你文件非常长,它将继续读取。在这个过程中的某个地方,Claude 可能会丢失一些信息。而且,停止和重新读取会消耗额外的 token。这可以通过拥有更模块化的代码库来避免。示例如下 ->

root/
├── docs/              # Global documentation
├── scripts/           # CI/CD and build scripts
├── src/
│   ├── apps/          # Entry points (API, CLI, Workers)
│   │   ├── api-gateway/    # Routes requests to modules
│   │   └── cron-jobs/
│   │
│   ├── modules/       # The core of the system
│   │   ├── ordering/       # Self-contained "Ordering" module
│   │   │   ├── api/             # Public interface for other modules
│   │   │   ├── domain/          # Business logic & Entities (Pure)
│   │   │   ├── infrastructure/  # DB, External Clients, Repositories
│   │   │   ├── use-cases/       # Application logic (Orchestration)
│   │   │   └── tests/           # Unit and integration tests
│   │   │
│   │   ├── catalog/        # Self-contained "Catalog" module
│   │   │   ├── domain/
│   │   │   └── ...
│   │   │
│   │   └── identity/       # Self-contained "Auth/User" module
│   │       ├── domain/
│   │       └── ...
│   │
│   ├── shared/        # Code used by EVERY module
│   │   ├── kernel/         # Base classes (Entity, ValueObject)
│   │   ├── events/         # Global Event Bus definitions
│   │   └── utils/          # Deeply generic helpers
│   │
│   └── main.ts        # Application bootstrap
├── tests/             # End-to-End (E2E) global tests
├── package.json
└── README.md

精简代码库 = 更便宜的 Token:

这可能很明显,但你的代码库越精简,token 成本就越低。关键是要通过使用技能来持续清理代码库,利用技能和命令进行重构来识别死代码。另外在某些时候,我喜欢通读整个代码库,寻找那些让我觉得突出或看起来重复的东西,手动拼凑这些上下文,然后将其与重构技能和死代码技能一起输入给 Claude。

系统提示精简(进阶):

对于真正注重成本的人:Claude Code 的系统提示占用约18k token(200k 上下文的约9%)。这可以通过补丁减少到约10k token,节省约7,300 token(静态开销的41%)。如果你想走这条路,参见 YK 的 system-prompt-patches,我个人不这样做。


Verification Loops and Evals

验证循环与评估

评估和框架调优——取决于项目,你需要使用某种形式的可观测性和标准化。

可观测性方法:

一种方法是让 tmux 进程挂钩到追踪思维流和输出上,每当技能被触发时。另一种方法是使用 PostToolUse 钩子来记录 Claude 具体执行了什么以及确切的变更和输出是什么。

基准测试工作流:

将其与不使用技能地要求相同的事情进行比较,检查输出差异以进行相对性能基准测试:

           [Same Task]
                │
    ┌────────────┴────────────┐
    ▼                         ▼
┌───────────────┐     ┌───────────────┐
│  Worktree A   │     │  Worktree B   │
│  WITH skill   │     │ WITHOUT skill │
└───────┬───────┘     └───────┬───────┘
        │                     │
        ▼                     ▼
   [Output A]            [Output B]
        │                     │
        └──────────┬──────────┘
                   ▼
              [git diff]
                   │
                   ▼
          ┌────────────────┐
          │ Compare logs,  │
          │ token usage,   │
          │ output quality │
          └────────────────┘

分叉对话,在其中一个中启动一个没有技能的新 worktree,最后查看差异,看看日志记录了什么。这与持续学习和记忆部分相关联。

评估模式类型:

更高级的评估和循环协议在这里登场。分为基于检查点的评估和基于强化学习任务的持续评估。

CHECKPOINT-BASED              CONTINUOUS
─────────────────             ──────────
[Task 1]                      [Work]
    │                            │
    ▼                            ▼
┌─────────┐                  ┌─────────┐
│Checkpoint│◄── verify       │ Timer/  │
│   #1     │    criteria     │ Change  │
└────┬────┘                  └────┬────┘
     │ pass?                      │
  ┌───┴───┐                       ▼
  │       │                  ┌──────────┐
 yes   no ──► fix ──┐       │Run Tests │
  │               │  │       │ + Lint   │
  ▼           └────┘ │       └────┬─────┘
[Task 2]              │            │
    │                 │       ┌────┴────┐
    ▼                 │       │         │
┌─────────┐           │      pass     fail
│Checkpoint│          │       │         │
│   #2     │          │       ▼         ▼
└────┬────┘           │   [Continue]  [Stop & Fix]
     │                │
    ...            └────┘

Best for: Linear workflows    Best for: Long sessions
with clear milestones         exploratory refactoring

基于检查点的评估:

  • 在工作流中设置明确的检查点
  • 在每个检查点根据定义的标准进行验证
  • 如果验证失败,Claude 必须在继续之前修复
  • 适合具有明确里程碑的线性工作流

持续评估:

  • 每 N 分钟或在重大变更后运行
  • 完整测试套件、构建状态、lint 检查
  • 立即报告回归
  • 在继续之前停下来修复
  • 适合长时间运行的会话

决定因素是你工作的性质。基于检查点适用于具有明确阶段的功能实现。持续评估适用于探索性重构或维护,因为你没有明确的里程碑。

我会说,通过一些干预,验证方法足以避免大部分技术债务。让 Claude 在完成任务后通过运行技能和 PostToolUse 钩子来验证有助于此。持续更新代码地图也有帮助,因为它保持了变更日志以及代码地图如何随时间演变的记录,作为仓库本身之外的事实来源。通过严格的规则,Claude 会避免创建杂乱的随机 .md 文件、为相似代码创建重复文件,以及留下一片死代码的荒原。

评分器类型(来自 Anthropic - 直接链接)

基于代码的评分器(Code-Based Graders): 字符串匹配、二元测试、静态分析、结果验证。快速、便宜、客观,但对有效变体很脆弱。

基于模型的评分器(Model-Based Graders): 评分标准打分、自然语言断言、成对比较。灵活且能处理微妙之处,但非确定性且更昂贵。

人工评分器(Human Graders): 专家审查、众包判断、抽样检查。金标准质量,但昂贵且缓慢。

关键指标:

pass@k: At least ONE of k attempts succeeds
┌─────────────────────────────────────┐
│ k=1: 70%    k=3: 91%    k=5: 97%   │
│ Higher k = higher odds of success   │
└─────────────────────────────────────┘

pass^k: ALL k attempts must succeed
┌─────────────────────────────────────┐
│ k=1: 70%    k=3: 34%    k=5: 17%   │
│ Higher k = harder (consistency)     │
└─────────────────────────────────────┘

当你只需要它能工作且任何验证反馈就足够时,使用 pass@k。当一致性至关重要且你需要接近确定性的输出一致性(在结果/质量/风格方面)时,使用 pass^k

构建评估路线图(来自同一 Anthropic 指南):

  • 尽早开始——从真实失败中提取20-50个简单任务
  • 将用户报告的失败转化为测试用例
  • 编写明确的任务——两个专家应达成相同结论
  • 构建平衡的问题集——测试行为应该和不应该出现的情况
  • 构建健壮的测试框架——每次试验从干净环境开始
  • 评分代理产生的结果,而非它走过的路径
  • 阅读许多试验的记录
  • 监控饱和度——100%通过率意味着需要添加更多测试

Parallelization

并行化

在多 Claude 终端设置中分叉对话时,确保分叉中的操作范围和原始对话的操作范围都有明确定义。在代码变更方面尽量减少重叠。选择彼此正交的任务以防止干扰的可能性。

我的首选模式:

个人而言,我更喜欢主聊天用于处理代码变更,而我做的分叉是用于我对代码库及其当前状态的问题,或者做外部服务的研究,比如拉取文档、在 GitHub 上搜索适用的开源仓库来帮助任务,或其他有用的一般性研究。

关于任意终端数量:

Boris @bcherny(创建 Claude Code 的传奇人物)有一些关于并行化的建议,我对此有赞同也有不赞同的地方。他建议过类似在本地运行5个 Claude 实例和5个上游实例这样的事情。我建议不要设置这样的任意终端数量。增加一个终端和增加一个实例应该出于真正的必要性和目的。如果你可以用脚本来处理该任务,就用脚本。如果你可以留在主聊天中让 Claude 在 tmux 中启动一个实例并在单独的终端中流式传输,那就那样做。

引用推文:Boris Cherny @bcherny · 1月3日 回复 @bcherny 1/ 我在终端中并行运行5个 Claude。我给我的标签页编号1-5,并使用系统通知来知道何时 Claude 需要输入 code.claude.com/docs/en/ter…

image.png

你的目标真的应该是:用最小可行的并行化量完成尽可能多的工作。

对于大多数新手,我甚至建议在你熟练掌握单实例运行和管理一切之前远离并行化。我不是主张限制自己——我是说要小心。大多数时候,即使是我也只使用大约4个终端。我发现通常只需打开2或3个 Claude 实例就能完成大部分事情。

扩展实例时:

如果你要开始扩展你的实例,并且有多个 Claude 实例在相互重叠的代码上工作,使用 git worktrees 并为每个实例制定非常明确的计划是必不可少的。此外,为了在恢复会话时不会混淆或迷失哪个 git worktree 是做什么的(除了树的名称之外),使用 /rename <name here> 来命名你所有的聊天。

Git Worktrees 用于并行实例:

# Create worktrees for parallel work
git worktree add ../project-feature-a feature-a
git worktree add ../project-feature-b feature-b
git worktree add ../project-refactor refactor-branch

# Each worktree gets its own Claude instance
cd ../project-feature-a && claude

好处:

  • 实例之间没有 git 冲突
  • 每个都有干净的工作目录
  • 容易比较输出
  • 可以在不同方法之间对同一任务进行基准测试

级联方法(The Cascade Method):

当运行多个 Claude Code 实例时,使用"级联"模式来组织:

  • 在右侧的新标签页中打开新任务
  • 从左到右扫描,从最旧到最新
  • 保持一致的方向流
  • 根据需要检查特定任务
  • 一次最多关注3-4个任务——超过这个数量,心理开销的增长速度会快于生产力

Groundwork

基础工作

从零开始时,实际的基础非常重要。这应该是显而易见的,但随着代码库的复杂性和规模增加,技术债务也会增加。管理它非常重要,如果你遵循一些规则,其实并不困难。除了为当前项目有效配置你的 Claude 之外(参见简明指南)。

双实例启动模式(The Two-Instance Kickoff Pattern):

对于我自己的工作流管理(不是必须的但很有帮助),我喜欢用2个打开的 Claude 实例来启动一个空仓库。

实例 1:脚手架代理(Scaffolding Agent)

  • 负责搭建脚手架和基础工作
  • 创建项目结构
  • 配置设置(CLAUDE.md、规则、代理——简明指南中的所有内容)
  • 建立约定
  • 把骨架搭好

实例 2:深度研究代理(Deep Research Agent)

  • 连接到你所有的服务、网络搜索等
  • 创建详细的 PRD(产品需求文档)
  • 创建架构 mermaid 图
  • 用实际文档中的实际片段编译参考资料

image.png

启动配置:左侧终端用于编码,右侧终端用于提问——使用 /rename 和 /fork。

最小化启动所需的内容就够了——这比每次都用 Context7 或喂链接让它抓取或使用 Firecrawl MCP 站点要快。当你已经深入某件事情且 Claude 明显语法出错或使用过时的函数或端点时,那些方法才派上用场。

llms.txt 模式:

如果可用的话,你可以在许多文档参考上通过在到达其文档页面后访问 /llms.txt 来找到一个 llms.txt 文件。这是一个示例:www.helius.dev/docs/llms.t…

这给你一个干净的、为 LLM 优化的文档版本,你可以直接提供给 Claude。

理念:构建可复用模式(Philosophy: Build Reusable Patterns)

来自 @omarsar0 的一个我完全认同的见解:"早期,我花时间构建可复用的工作流/模式。构建起来很繁琐,但随着模型和代理框架的改进,这产生了疯狂的复合效应。"

值得投资的方面:

  • 子代理(简明指南)
  • 技能(简明指南)
  • 命令(简明指南)
  • 计划模式
  • MCP 工具(简明指南)
  • 上下文工程模式

为什么会产生复合效应(@omarsar0):"最棒的部分是所有这些工作流都可以转移到其他代理,如 Codex。"一旦构建完成,它们可以跨模型升级工作。投资于模式 > 投资于特定模型的技巧。


Best Practices for Agents & Sub-Agents

代理和子代理的最佳实践

在简明指南中,我列出了子代理结构——planner、architect、tdd-guide、code-reviewer 等。在这一部分,我们关注编排和执行层。

子代理上下文问题(The Sub-Agent Context Problem):

子代理的存在是为了通过返回摘要而非倾倒所有内容来节省上下文。但编排器(orchestrator)拥有子代理所缺乏的语义上下文。子代理只知道字面上的查询,不知道请求背后的目的/推理。摘要通常会遗漏关键细节。

来自 @PerceptualPeak 的类比:"你的老板让你去参加一个会议并要求你做个总结。你回来给他汇报情况。十次有九次,他会有后续问题。你的总结不会包含他需要的所有内容,因为你没有他拥有的隐含上下文。"

迭代检索模式(Iterative Retrieval Pattern):

┌─────────────────┐
│  ORCHESTRATOR   │
│  (has context)  │
└────────┬────────┘
         │ dispatch with query + objective
         ▼
┌─────────────────┐
│   SUB-AGENT     │
│ (lacks context) │
└────────┬────────┘
         │ returns summary
         ▼
┌─────────────────┐    ┌─────────────┐
│    EVALUATE     │─no──►│  FOLLOW-UP  │
│   Sufficient?   │    │  QUESTIONS  │
└────────┬────────┘    └──────┬──────┘
         │ yes                │ sub-agent
         ▼                    │ fetches answers
      [ACCEPT]                │
         ◄──────────────────────┘
                        (max 3 cycles)

要修复这个问题,让编排器:

  • 评估每个子代理的返回结果
  • 在接受之前询问后续问题
  • 子代理回到源头,获取答案,返回
  • 循环直到充分(最多3个循环以防止无限循环)

传递目标上下文,而不仅仅是查询。 当派遣子代理时,同时包含具体查询和更广泛的目标。这有助于子代理确定在其摘要中优先包含什么内容。

模式:带有顺序阶段的编排器(Pattern: Orchestrator with Sequential Phases)

Phase 1: RESEARCH (use Explore agent)
- Gather context
- Identify patterns
- Output: research-summary.md

Phase 2: PLAN (use planner agent)
- Read research-summary.md
- Create implementation plan
- Output: plan.md

Phase 3: IMPLEMENT (use tdd-guide agent)
- Read plan.md
- Write tests first
- Implement code
- Output: code changes

Phase 4: REVIEW (use code-reviewer agent)
- Review all changes
- Output: review-comments.md

Phase 5: VERIFY (use build-error-resolver if needed)
- Run tests
- Fix issues
- Output: done or loop back

关键规则:

  • 每个代理获得一个明确的输入并产生一个明确的输出
  • 输出成为下一阶段的输入
  • 永远不要跳过阶段——每个阶段都有价值
  • 在代理之间使用 /clear 保持上下文新鲜
  • 将中间输出存储在文件中(不仅仅是记忆中)

代理抽象层级列表(Agent Abstraction Tierlist)(来自 @menhguin):

第1层:直接增益(容易使用)

  • 子代理(Subagents) ——防止上下文腐烂和临时专业化的直接增益。只有多代理一半的用处,但复杂度低得多
  • 元提示(Metaprompting) ——"我花3分钟来提示一个20分钟的任务。"直接增益——提高稳定性并对假设进行健全性检查
  • 在开始时多问用户(Asking user more at the beginning) ——通常是增益,尽管你必须在计划模式中回答问题

第2层:高技能门槛(较难用好)

  • 长时间运行的代理(Long-running agents) ——需要理解15分钟任务 vs 1.5小时 vs 4小时任务的形态和权衡。需要一些调整,而且显然是非常长的试错过程
  • 并行多代理(Parallel multi-agent) ——方差非常高,仅在高度复杂或分段良好的任务上有用。"如果2个任务需要10分钟,而你花了任意时间在提示上,或者更糟糕的是合并变更,那就适得其反了"
  • 基于角色的多代理(Role-based multi-agent) ——"模型演进太快,硬编码的启发式规则除非套利非常高否则没意义。"难以测试
  • 计算机使用代理(Computer use agents) ——非常早期的范式,需要大量调教。"你在让模型做一年前它们绝对不应该做的事情"

要点:从第1层模式开始。只有在掌握了基础知识并有真正的需求时才升级到第2层。


Tips and Tricks

提示与技巧

一些 MCP 是可替代的,将释放你的上下文窗口

方法如下。

对于版本控制(GitHub)、数据库(Supabase)、部署(Vercel、Railway)等 MCP——这些平台中的大多数已经有健壮的 CLI,MCP 本质上只是对它们的包装。MCP 是一个不错的包装器,但它有成本。

要让 CLI 更像 MCP 一样工作,而实际上不使用 MCP(以及随之而来的上下文窗口缩减),考虑将功能捆绑到技能和命令中。剥离 MCP 暴露的使事情变得简单的工具,并将它们转化为命令。

示例:不要始终加载 GitHub MCP,而是创建一个 /gh-pr 命令来包装 gh pr create 并使用你偏好的选项。不要让 Supabase MCP 消耗上下文,而是创建直接使用 Supabase CLI 的技能。功能是一样的,便利性相似,但你的上下文窗口被释放出来用于实际工作。

这与我收到的一些其他问题相关。自从我发布原始文章以来的过去几天里,Boris 和 Claude Code 团队在记忆管理和优化方面取得了很大进展,主要是 MCP 的懒加载,使它们不再从一开始就消耗你的窗口。以前我会建议在可能的地方将 MCP 转换为技能,以两种方式之一卸载执行 MCP 的功能:在当时启用它(不太理想,因为你需要离开并恢复会话)或者拥有使用 MCP 的 CLI 类似物的技能(如果它们存在的话),让技能成为它的包装器——本质上让它充当伪 MCP。

通过懒加载,上下文窗口问题基本上已经解决。但 token 使用和成本并没有以同样的方式解决。CLI + 技能方法仍然是一种 token 优化方法,其效果可能与使用 MCP 相当或接近。此外,你可以通过 CLI 而不是在上下文中运行 MCP 操作,这显著减少了 token 使用,对于数据库查询或部署等繁重的 MCP 操作特别有用。

用 React + Remotion 做视频:入门与 AI 驱动生成

用 React + Remotion 做视频:入门与 AI 驱动生成

科普向:Remotion 是什么、能做什么、官方在哪看;从零创建项目、做一个可导出的 demo;并介绍 AI(Remotion Skills)结合 React 做视频的思路与周边生态。

一、这东西是什么

Remotion 是一个用 React 组件 来「写」视频的框架。你可以把它理解成:视频 = 按时间轴一帧一帧渲染出来的 React 页面

  • 和传统视频的区别:传统用 After Effects、Premiere 做剪辑,改文案要重新做一版;用 FFmpeg 写脚本又很难和业务数据、前端技术栈打通。Remotion 把每一帧都交给 React 渲染:你用组件描述「这一帧长什么样」,用 当前帧号useCurrentFrame())算动画进度,最后由 Remotion 用 Chromium 逐帧截图并合成 MP4。
  • 核心抽象
    • Composition:一个「视频画布」,规定宽、高、帧率(fps)、总帧数(durationInFrames)。
    • Sequence:时间轴上的一个片段,在指定帧区间内渲染某个组件,用来排先后顺序。
    • useCurrentFrame() / useVideoConfig():在组件里拿到当前帧号和画布配置,用来算透明度、位移、缩放等,实现关键帧动画。

所以:会写 React,就能用同一套技能做可编程、数据驱动的视频;改数据或文案后重新渲染即可,适合模板化、批量和 CI 集成。


二、这东西有什么用

  • 数据驱动视频:接口、配置、文案都从 props 或 API 来,改数据即改视频,无需手剪。适合:产品介绍、数据可视化、个性化贺卡、批量短视频。
  • 复用前端能力:CSS、Canvas、SVG、Three.js、Lottie 等都能用,和写页面一致,学习成本低。
  • 可编程与自动化:和 Node 脚本、CI 结合,批量生成不同分辨率、不同文案的视频;也可和 AI(如 Remotion Skills 或自建 LLM)结合,用自然语言描述「做一个 15 秒产品介绍」,由 AI 生成脚本,Remotion 负责渲染,实现「描述 → 视频」。

适用人群:前端/全栈、需要做产品动画或批量视频的团队、想用代码替代部分剪辑工作的开发者。


三、官方链接

  • 官网www.remotion.dev
  • 文档www.remotion.dev/docs
  • GitHubgithub.com/remotion-de…
  • 模板/示例npx create-video@latest 会拉取官方脚手架;文档中有多语言、多分辨率等示例。
  • Remotion Skills(AI 生成视频):见官网或仓库关于 Skills 的说明,用自然语言驱动 Remotion 渲染。

写文时 API 与用法以当前官方文档为准。


四、从零跑起来一个项目

环境要求

  • Node.js:建议 18+(以 Remotion 当前文档为准)。
  • 系统:macOS / Windows / Linux 均可;渲染依赖 Chromium,首次可能需下载。

创建与运行

npx create-video@latest my-video
cd my-video
npm run dev

执行后会在浏览器打开本地预览:左侧是时间轴,右侧是当前帧的 React 渲染结果,改代码会热更新。默认带一个示例 Composition,可直接改着玩。

目录结构(常见)

  • src/Root.tsx:入口,里面对应多个 <Composition>,每个代表一个可选的「视频」。
  • src/Compositions/ 或类似:各个视频的组件,例如 HelloWorld.tsx
  • remotion.config.ts:Remotion 配置(如 Chromium 路径、并发数等)。
  • package.json 里会有 remotion@remotion/cli@remotion/bundler 等依赖。

首次跑通后,你会看到一个几秒的示例动画;接下来就是在组件里用 useCurrentFrame() 做自己的内容。


五、如何做一个 demo 出来

下面是一个最小可运行 demo:一个 5 秒的标题渐显 + 一段副标题在 2 秒后出现。

1. 在 Root 里注册一个 Composition

src/Root.tsx(或你项目中的根组件)里增加:

import { Composition } from "remotion";
import { MyFirstVideo } from "./Compositions/MyFirstVideo";

export const RemotionRoot: React.FC = () => {
    return (
        <>
            <Composition
                id="MyFirstVideo"
                component={MyFirstVideo}
                durationInFrames={150}
                fps={30}
                width={1920}
                height={1080}
                defaultProps={{ title: "Remotion + React", subtitle: "用代码做视频" }}
            />
        </>
    );
};
  • durationInFrames={150}fps={30} → 5 秒。
  • defaultProps 会传给下面的 MyFirstVideo 组件。

2. 写 MyFirstVideo 组件(用帧号做动画)

新建 src/Compositions/MyFirstVideo.tsx

import React from "react";
import { useCurrentFrame, useVideoConfig } from "remotion";

export const MyFirstVideo: React.FC<{ title: string; subtitle: string }> = ({ title, subtitle }) => {
    const frame = useCurrentFrame();
    const { fps } = useVideoConfig();

    const titleOpacity = Math.min(1, frame / (fps * 0.8));
    const subtitleStart = fps * 2;
    const subtitleOpacity = frame < subtitleStart ? 0 : Math.min(1, (frame - subtitleStart) / (fps * 0.5));

    return (
        <div style={{ flex: 1, justifyContent: "center", alignItems: "center", display: "flex", flexDirection: "column", background: "#0a0a0a", color: "#fff" }}>
            <div style={{ fontSize: 72, opacity: titleOpacity }}>{title}</div>
            <div style={{ fontSize: 36, marginTop: 24, opacity: subtitleOpacity }}>{subtitle}</div>
        </div>
    );
};
  • 前 0.8 秒标题渐显;2 秒后副标题渐显。
  • titlesubtitle 或时长,保存即可在预览里看到效果。

3. 导出为 MP4

在项目根目录执行:

npx remotion render MyFirstVideo out/my-first-video.mp4

会在 out/ 下得到 my-first-video.mp4
服务端批量渲染可用 @remotion/renderer 在 Node 里调同样逻辑(见官方文档)。


六、AI + React 做视频:Remotion Skills 思路

Remotion Skills 是 Remotion 官方推出的、用自然语言驱动视频生成的方案:你描述「做一个 15 秒的产品介绍,突出三个卖点」,AI 解析意图并生成 Remotion 可用的脚本/结构,再交给 Remotion 渲染。

  • 典型流程:用户输入描述 → LLM 输出分镜或组件配置(如每段文案、时长)→ 映射成 Sequence 和组件 props → Remotion 渲染。
  • 自建方式:不用官方 Skills 时,可用任意 LLM API 根据描述生成 JSON(每段文案、时长、图片 URL 等),再在 Node 里转成 Remotion 的 Composition/Sequence 与 props,用 @remotion/renderer 渲染。这样「AI 生成脚本 + React 组件渲染」就打通了,适合产品动画、批量短视频、数据可视化讲解。

七、周边生态推荐

  • 模板create-video 自带示例;GitHub / 社区有「字幕模板」「数据图表视频」等,可搜 remotion template
  • 相关库:Remotion 官方包如 @remotion/lottie@remotion/media-utils 等,文档中有列表;与 Three.js、Framer Motion 等结合可做更复杂动效。
  • 社区:GitHub Discussions、Discord(见官网),有问题可查 issue 或提问。
  • 进阶:服务端渲染(Lambda / 自建 Node)、多分辨率输出、与 CI 集成自动生成发布视频,文档里都有示例。

八、注意点与总结

  • 性能:复杂动画或大画布会拉长渲染时间,可先用低分辨率预览再全分辨率导出。
  • 字体与资源:服务端渲染时注意字体路径、图片/视频 URL 在渲染环境可访问。
  • 版本:Remotion 更新较快,API 以当前官方文档为准。

总结:Remotion 用 React 组件 + 帧号 描述视频,数据驱动、易和前端与 CI 集成;配合 AI(Remotion Skills 或自建 LLM)可实现「描述 → 视频」的自动化。科普部分你只要记住:是什么、有什么用、官方链接、从零跑起来、做一个 demo、周边生态,就能把 Remotion 讲清楚、带读者上手。

如果这篇对你有帮助,欢迎点赞 / 收藏;想深入可看 Remotion 文档 与 Remotion Skills 相关介绍。


标签ReactRemotion前端人工智能视频

Everything Claude Code 速查指南

这是黑客松冠军日常使用 10 个月后的完整配置:技能(skills)、钩子(hooks)、子代理(subagents)、MCP、插件(plugins),以及真正好用的部分。

自从 2 月份实验性发布以来,我一直是 Claude Code 的深度用户,并且在 Anthropic x Forum Ventures 黑客松上与 @DRodriguezFX 一起完全使用 Claude Code 构建了 Zenith 并获胜。

cogsec @affaanmustafa · 2025年9月16日

在纽约的 @AnthropicAI x @forumventures 黑客松上拿下了冠军,感谢主办方举办这次精彩的活动(还有 15k 的 Anthropic Credits)

@DRodriguezFX 和我构建了 PMFProbe,帮助创始人从 0 到 1,在 MVP 阶段前验证你的想法,后续还有更多内容

图片1图片2图片3图片4


Skills and Commands

技能与命令

技能类似于规则,被限定在特定的作用域和工作流中。它们是执行特定工作流时的快捷提示词。

在用 Opus 4.5 长时间编码后,想清理无用代码和零散的 .md 文件?运行 /refactor-clean。需要测试?/tdd/e2e/test-coverage。技能和命令可以在一条提示词中链式调用。

链式调用命令链式调用命令

我可以创建一个在检查点更新代码地图(codemap)的技能——让 Claude 快速导航你的代码库,而不必在探索上消耗上下文。

~/.claude/skills/codemap-updater.md

命令是通过斜杠命令执行的技能。它们有重叠,但存储方式不同:

  • Skills: ~/.claude/skills - 更广泛的工作流定义
  • Commands: ~/.claude/commands - 快速可执行的提示词
# Example skill structure
~/.claude/skills/
  pmx-guidelines.md           # Project-specific patterns
  coding-standards.md          # Language best practices
  tdd-workflow/                # Multi-file skill with README.md
  security-review/             # Checklist-based skill

Hooks

钩子

钩子是基于触发器的自动化,在特定事件上触发。与技能不同,它们被限定在工具调用和生命周期事件中。

钩子类型

  • PreToolUse - 工具执行前(验证、提醒)
  • PostToolUse - 工具完成后(格式化、反馈循环)
  • UserPromptSubmit - 当你发送消息时
  • Stop - 当 Claude 完成响应时
  • PreCompact - 上下文压缩前
  • Notification - 权限请求

示例:在运行长时间命令前提醒使用 tmux

{
  "PreToolUse": [
    {
      "matcher": "tool == \"Bash\" && tool_input.command matches \"(npm|pnpm|yarn|cargo|pytest)\"",
      "hooks": [
        {
          "type": "command",
          "command": "if [ -z \"$TMUX\" ]; then echo '[Hook] Consider tmux for session persistence' >&2; fi"
        }
      ]
    }
  ]
}

image.png

在 Claude Code 中运行 PostToolUse 钩子时获得的反馈示例

专业提示: 使用 hookify 插件通过对话方式创建钩子,而不是手动编写 JSON。运行 /hookify 并描述你想要的功能。


Subagents

子代理

子代理是主编排器(主 Claude)可以委派任务的进程,具有有限的作用域。它们可以在后台或前台运行,为主代理释放上下文。

子代理与技能配合很好——一个能够执行你部分技能的子代理可以被委派任务并自主使用这些技能。它们还可以通过特定的工具权限进行沙盒化。

# Example subagent structure
~/.claude/agents/
  planner.md                   # Feature implementation planning
  architect.md                 # System design decisions
  tdd-guide.md                 # Test-driven development
  code-reviewer.md             # Quality/security review
  security-reviewer.md         # Vulnerability analysis
  build-error-resolver.md
  e2e-runner.md
  refactor-cleaner.md

为每个子代理配置允许的工具、MCP 和权限,以实现合理的作用域划分。


Rules and Memory

规则与记忆

你的 .rules 文件夹存放 Claude 应该始终遵循的最佳实践 .md 文件。两种方式:

  • 单一 CLAUDE.md - 所有内容放在一个文件中(用户级或项目级)
  • Rules 文件夹 - 按关注点分组的模块化 .md 文件
~/.claude/rules/
  security.md                  # No hardcoded secrets, validate inputs
  coding-style.md              # Immutability, file organization
  testing.md                   # TDD workflow, 80% coverage
  git-workflow.md              # Commit format, PR process
  agents.md                    # When to delegate to subagents
  performance.md               # Model selection, context management

示例规则:

  • 代码库中不使用 emoji
  • 前端避免紫色调
  • 部署前始终测试代码
  • 优先使用模块化代码而非超大文件
  • 永远不要提交 console.log

MCPs (Model Context Protocol)

MCP(模型上下文协议)

MCP 将 Claude 直接连接到外部服务。它不是 API 的替代品——而是围绕 API 的提示驱动封装,在信息导航方面提供更多灵活性。

示例: Supabase MCP 让 Claude 可以拉取特定数据、直接在上游运行 SQL,无需复制粘贴。数据库、部署平台等同理。

Supabase MCP 列出公共 schema 中的表的示例Supabase MCP 列出公共 schema 中的表的示例

Chrome in Claude: 是一个内置的插件 MCP,让 Claude 可以自主控制你的浏览器——点击查看各种功能。

关键:上下文窗口管理

对 MCP 要精挑细选。我把所有 MCP 放在用户配置中,但禁用所有不用的。导航到 /plugins 并向下滚动或运行 /mcp

你 200k 的上下文窗口在压缩前可能只有 70k,如果启用了太多工具的话。性能会显著下降。

使用 /plugins 导航到 MCP 查看当前已安装的及其状态使用 /plugins 导航到 MCP 查看当前已安装的及其状态

经验法则: 配置中放 20-30 个 MCP,但保持启用的不超过 10 个 / 活跃工具不超过 80 个。


Plugins

插件

插件将工具打包以便于安装,而不需要繁琐的手动设置。一个插件可以是技能 + MCP 的组合,或者钩子/工具的捆绑。

安装插件:

# Add a marketplace
claude plugin marketplace add https://github.com/mixedbread-ai/mgrep
# Open Claude, run /plugins, find new marketplace, install from there

显示新安装的 Mixedbread-Grep 市场显示新安装的 Mixedbread-Grep 市场

LSP 插件: 如果你经常在编辑器外运行 Claude Code,这特别有用。语言服务协议(Language Server Protocol)为 Claude 提供实时类型检查、跳转到定义和智能补全,无需打开 IDE。

# Enabled plugins example
typescript-lsp@claude-plugins-official     # TypeScript intelligence
pyright-lsp@claude-plugins-official        # Python type checking
hookify@claude-plugins-official            # Create hooks conversationally
mgrep@Mixedbread-Grep                      # Better search than ripgrep

与 MCP 同样的警告——注意你的上下文窗口。


Tips and Tricks

技巧和窍门

快捷键

  • Ctrl+U - 删除整行(比疯狂按退格快)
  • ! - 快速 bash 命令前缀
  • @ - 搜索文件
  • / - 启动斜杠命令
  • Shift+Enter - 多行输入
  • Tab - 切换思考模式显示
  • Esc Esc - 中断 Claude / 恢复代码

并行工作流

/fork - 分叉对话,在不重叠的任务上并行工作,而不是排队发送消息

Git Worktrees - 用于有重叠的并行 Claude,避免冲突。每个 worktree 是独立的检出

git worktree add ../feature-branch feature-branch
# Now run separate Claude instances in each worktree

tmux 用于长时间运行的命令: 流式查看和监控 Claude 运行的日志/bash 进程。

让 Claude Code 启动前端和后端服务器,并通过 tmux 附加会话监控日志让 Claude Code 启动前端和后端服务器,并通过 tmux 附加会话监控日志(视频)

tmux new -s dev
# Claude runs commands here, you can detach and reattach
tmux attach -t dev

mgrep > grep: mgrep 比 ripgrep/grep 有显著提升。通过插件市场安装,然后使用 /mgrep 技能。支持本地搜索和网络搜索。

mgrep "function handleSubmit"                    # Local search
mgrep --web "Next.js 15 app router changes"      # Web search

其他有用命令

  • /rewind - 回到之前的状态
  • /statusline - 自定义显示分支、上下文百分比、待办事项
  • /checkpoints - 文件级撤销点
  • /compact - 手动触发上下文压缩

GitHub Actions CI/CD

在你的 PR 上用 GitHub Actions 设置代码审查。配置后 Claude 可以自动审查 PR。

Claude 批准一个 bug 修复 PRClaude 批准一个 bug 修复 PR

沙盒化

对有风险的操作使用沙盒模式——Claude 在受限环境中运行,不影响你的实际系统。(使用 --dangerously-skip-permissions 则相反,让 Claude 自由操作,如果不小心的话可能会造成破坏。)


On Editors

关于编辑器

虽然不需要编辑器,但它可能正面或负面地影响你的 Claude Code 工作流。虽然 Claude Code 可以在任何终端上工作,但配合一个强大的编辑器可以解锁实时文件追踪、快速导航和集成命令执行。

Zed(我的首选)

我使用 Zed——一个基于 Rust 的编辑器,轻量、快速、高度可定制。

为什么 Zed 与 Claude Code 配合良好:

  • Agent Panel Integration(代理面板集成) - Zed 的 Claude 集成让你在 Claude 编辑时实时追踪文件变更。在 Claude 引用的文件间跳转,无需离开编辑器
  • Performance(性能) - 用 Rust 编写,即时打开,处理大型代码库无延迟
  • CMD+Shift+R Command Palette(命令面板) - 在可搜索的 UI 中快速访问所有自定义斜杠命令、调试器和工具。即使你只想运行一个快速命令而不切换到终端
  • Minimal Resource Usage(最小资源占用) - 在繁重操作期间不会与 Claude 争夺系统资源
  • Vim Mode(Vim 模式) - 完整的 vim 键绑定

Zed 编辑器使用 CMD+Shift+R 的自定义命令下拉菜单。右下角靶心图标显示跟随模式。Zed 编辑器使用 CMD+Shift+R 的自定义命令下拉菜单。右下角靶心图标显示跟随模式。

  • Split your screen(分屏) - 一边终端运行 Claude Code,一边编辑器
  • Ctrl + G - 在 Zed 中快速打开 Claude 正在编辑的文件
  • Auto-save(自动保存) - 启用自动保存,确保 Claude 的文件读取始终是最新的
  • Git integration(Git 集成) - 使用编辑器的 Git 功能在提交前审查 Claude 的更改
  • File watchers(文件监视器) - 大多数编辑器自动重新加载已更改的文件,确认已启用

VSCode / Cursor

这也是一个可行的选择,与 Claude Code 配合良好。你可以以终端格式使用,通过 \ide 启用 LSP 功能实现与编辑器的自动同步(现在与插件有些冗余)。或者你可以选择扩展版本,它与编辑器集成度更高,有匹配的 UI。

来自文档来自文档,地址:code.claude.com/docs/en/vs-…


My Setup

我的配置

插件

已安装:(我通常同时只启用其中 4-5 个)

ralph-wiggum@claude-code-plugins         # Loop automation
frontend-design@claude-code-plugins      # UI/UX patterns
commit-commands@claude-code-plugins      # Git workflow
security-guidance@claude-code-plugins    # Security checks
pr-review-toolkit@claude-code-plugins    # PR automation
typescript-lsp@claude-plugins-official   # TS intelligence
hookify@claude-plugins-official          # Hook creation
code-simplifier@claude-plugins-official
feature-dev@claude-code-plugins
explanatory-output-style@claude-code-plugins
code-review@claude-code-plugins
context7@claude-plugins-official         # Live documentation
pyright-lsp@claude-plugins-official      # Python types
mgrep@Mixedbread-Grep                    # Better search

MCP 服务器

已配置(用户级):

{
  "github": {
    "command": "npx",
    "args": ["-y", "@modelcontextprotocol/server-github"]
  },
  "firecrawl": {
    "command": "npx",
    "args": ["-y", "firecrawl-mcp"]
  },
  "supabase": {
    "command": "npx",
    "args": ["-y", "@supabase/mcp-server-supabase@latest", "--project-ref=YOUR_REF"]
  },
  "memory": {
    "command": "npx",
    "args": ["-y", "@modelcontextprotocol/server-memory"]
  },
  "sequential-thinking": {
    "command": "npx",
    "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
  },
  "vercel": {
    "type": "http",
    "url": "https://mcp.vercel.com"
  },
  "railway": {
    "command": "npx",
    "args": ["-y", "@railway/mcp-server"]
  },
  "cloudflare-docs": {
    "type": "http",
    "url": "https://docs.mcp.cloudflare.com/mcp"
  },
  "cloudflare-workers-bindings": {
    "type": "http",
    "url": "https://bindings.mcp.cloudflare.com/mcp"
  },
  "cloudflare-workers-builds": {
    "type": "http",
    "url": "https://builds.mcp.cloudflare.com/mcp"
  },
  "cloudflare-observability": {
    "type": "http",
    "url": "https://observability.mcp.cloudflare.com/mcp"
  },
  "clickhouse": {
    "type": "http",
    "url": "https://mcp.clickhouse.cloud/mcp"
  },
  "AbletonMCP": {
    "command": "uvx",
    "args": ["ableton-mcp"]
  },
  "magic": {
    "command": "npx",
    "args": ["-y", "@magicuidesign/mcp@latest"]
  }
}

按项目禁用(上下文窗口管理):

# In ~/.claude.json under projects.[path].disabledMcpServers
disabledMcpServers: [
  "playwright",
  "cloudflare-workers-builds",
  "cloudflare-workers-bindings",
  "cloudflare-observability",
  "cloudflare-docs",
  "clickhouse",
  "AbletonMCP",
  "context7",
  "magic"
]

这是关键——我配置了 14 个 MCP,但每个项目只启用约 5-6 个。保持上下文窗口健康。

关键钩子

{
  "PreToolUse": [
    // tmux reminder for long-running commands
    {
      "matcher": "npm|pnpm|yarn|cargo|pytest",
      "hooks": ["tmux reminder"]
    },
    // Block unnecessary .md file creation
    {
      "matcher": "Write && .md file",
      "hooks": ["block unless README/CLAUDE"]
    },
    // Review before git push
    {
      "matcher": "git push",
      "hooks": ["open editor for review"]
    }
  ],
  "PostToolUse": [
    // Auto-format JS/TS with Prettier
    {
      "matcher": "Edit && .ts/.tsx/.js/.jsx",
      "hooks": ["prettier --write"]
    },
    // TypeScript check after edits
    {
      "matcher": "Edit && .ts/.tsx",
      "hooks": ["tsc --noEmit"]
    },
    // Warn about console.log
    {
      "matcher": "Edit",
      "hooks": ["grep console.log warning"]
    }
  ],
  "Stop": [
    // Audit for console.logs before session ends
    {
      "matcher": "*",
      "hooks": ["check modified files for console.log"]
    }
  ]
}

自定义状态栏

显示用户、目录、git 分支(带脏标记)、剩余上下文百分比、模型、时间和待办事项数量:

Mac 根目录中的示例状态栏Mac 根目录中的示例状态栏

规则结构

~/.claude/rules/
  security.md          # Mandatory security checks
  coding-style.md      # Immutability, file size limits
  testing.md           # TDD, 80% coverage
  git-workflow.md      # Conventional commits
  agents.md            # Subagent delegation rules
  patterns.md          # API response formats
  performance.md       # Model selection (Haiku vs Sonnet vs Opus)
  hooks.md             # Hook documentation

子代理

~/.claude/agents/
  planner.md             # Break down features
  architect.md           # System design
  tdd-guide.md           # Write tests first
  code-reviewer.md       # Quality review
  security-reviewer.md   # Vulnerability scan
  build-error-resolver.md
  e2e-runner.md          # Playwright tests
  refactor-cleaner.md    # Dead code removal
  doc-updater.md         # Keep docs synced

Key Takeaways

关键要点

  • 不要过度复杂化 - 把配置当作微调,而非架构设计
  • 上下文窗口很宝贵 - 禁用未使用的 MCP 和插件
  • 并行执行 - 分叉对话,使用 git worktrees
  • 自动化重复工作 - 用钩子处理格式化、代码检查、提醒
  • 限定子代理的作用域 - 有限的工具 = 专注的执行

References

参考链接


注: 这只是部分细节。如果大家感兴趣,我可能会发更多关于具体内容的帖子。

前端嵌入Grafana 报表的自定义方案:隐藏导航栏保留筛选工具

在实际业务开发中,将 Grafana 报表嵌入到自研应用系统是非常常见的需求。官方提供了几种嵌入模式,但灵活性有限,无法满足所有场景。本文将介绍如何突破官方限制,实现"隐藏导航栏 + 保留筛选工具"的定制化嵌入效果。

一、官方支持的嵌入方式

Grafana 官方通过 URL 参数 kiosk 提供了三种嵌入模式:

1. 无参数模式

直接访问 Grafana 地址,不添加任何参数:

http://your-grafana/d/dashboard-id/dashboard-name

效果:导航栏和筛选工具都正常显示,适用场景:需要完整 Grafana 功能的独立访问。 image.png

2. Kiosk TV 模式(kiosk=tv

http://your-grafana/d/dashboard-id/dashboard-name?kiosk=tv

效果:隐藏筛选工具,保留导航栏,适用场景:大屏展示、TV 模式轮播。

image.png

3. Kiosk 全屏模式(kiosk=1

http://your-grafana/d/dashboard-id/dashboard-name?kiosk=1

效果:同时隐藏导航栏和筛选工具,适用场景:纯展示型嵌入、无交互需求。 image.png

官方方案的局限性

从上述三种模式可以看出,官方不支持「隐藏导航栏 + 保留筛选工具」的组合,而这恰恰是许多业务系统需要的效果——既想让报表与应用融为一体,又希望用户能使用筛选功能进行交互。 image.png

二、自定义方案概述

核心思路

通过 Nginx 反向代理 Grafana 服务,前端使用 iframe 嵌入,并利用 JavaScript 动态修改 iframe 内的 DOM 元素,实现 UI 的精准控制。

技术栈

  • Grafana 版本:v10.2.3 (1e84fede54)
  • Nginx:反向代理
  • 前端:iframe + MutationObserver API

三、实施方案详解

3.1 Grafana 配置

修改 Grafana 配置文件 grafana.ini,关键配置如下:

[server]
domain = ''
root_url = %(protocol)s://%(domain)s/grafana/
[security]
allow_embedding = true
[auth.anonymous]
enabled = true
org_name = Main Org.
org_role = Viewer

配置要点:

  • root_url:设置 Grafana 访问路径前缀,与 Nginx 代理路径对应
  • allow_embedding:允许 iframe 嵌入(必须开启)
  • auth.anonymous:根据业务需求配置匿名访问权限 完整配置示例:
{
  "grafana.ini": "[analytics]
    check_for_updates = true
    [grafana_net]
    url = https://grafana.net
    [log]
    mode = console
    [paths]
    data = /var/lib/grafana/
    logs = /var/log/grafana
    plugins = /var/lib/grafana/plugins
    provisioning = /etc/grafana/provisioning
    [server]
    domain = ''
    root_url = %(protocol)s://%(domain)s/grafana/
    [security]
    allow_embedding = true
    [auth.anonymous]
    enabled = true
    org_name = Main Org.
    org_role = Viewer
  "
}

3.2 Nginx 反向代理配置

Nginx 配置分为三个部分:Grafana 代理、前端页面路由、静态资源服务。

server {
    listen 30609;
    server_name localhost;
    # Grafana 服务代理
    location /grafana/ {
        proxy_pass http://grafana-ip:3000/;
        
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Uri $request_uri;
        
        # WebSocket 支持
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        # 静态资源映射(可选)
        location ~ ^/grafana/(.*) {
            alias /var/www/grafana/$1;
            try_files $uri $uri/ =404;
        }
    }
    # 前端监控页面
    location /monitor {
        alias D:/temp/monitor.html;
        internal;
        default_type text/html;
        add_header Cache-Control "no-cache, no-store, must-revalidate";
        add_header Pragma "no-cache";
        add_header Expires "0";
    }
    # 静态资源服务
    location /monitor/static/ {
        alias D:/temp/static/;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

配置说明:

  1. Grafana 代理:将 /grafana/ 路径代理到 Grafana 服务,处理 WebSocket 连接(实时刷新功能需要)
  2. 前端页面:提供嵌入页面的访问入口,建议使用 internal 防止直接暴露
  3. 静态资源:支持加载本地 JS/CSS 资源

3.3 前端实现:动态 DOM 操作

核心思路是使用 MutationObserver 监听 iframe 内容变化,找到目标元素后进行样式修改。

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Grafana 嵌入 - 自定义布局</title>
    <style>
        iframe {
            width: 100%;
            height: 1600px;
            border: 1px solid #ccc;
        }
    </style>
</head>
<body>
    <iframe id="myIframe" 
            src="http://127.0.0.1/grafana/d/200ac8fdbfbb74b39aff88118e4d1c2c/kubernetes-compute-resources-node-pods?orgId=1&refresh=10s" 
            frameborder="0">
    </iframe>
    <script>
        const iframe = document.getElementById('myIframe');
        
        iframe.onload = function () {
            const iframeDoc = iframe.contentDocument;
            // 使用 MutationObserver 监听 DOM 变化
            const observer = new MutationObserver(function (mutations) {
                // 隐藏顶部导航栏(Grafana v10.2.3 的导航栏类名)
                const toolbarElement = iframeDoc.querySelector('.css-srjygq');
                if (toolbarElement) {
                    toolbarElement.style.display = 'none';
                }
                // 移除导航栏占位空间
                const paddingElement = iframeDoc.querySelector('.css-60onds');
                if (paddingElement) {
                    paddingElement.style.paddingTop = '0';
                }
                // 找到目标元素后停止监听
                if (toolbarElement && paddingElement) {
                    observer.disconnect();
                    console.log('✅ 已隐藏导航栏,保留筛选工具');
                }
            });
            // 开始监听
            observer.observe(iframeDoc.body, {
                childList: true,
                subtree: true
            });
            // 超时保护:10秒后自动停止
            setTimeout(() => {
                observer.disconnect();
                console.warn('⏰ 超时:未找到目标元素');
            }, 10000);
        };
    </script>
</body>
</html>

实现要点:

  1. MutationObserver:动态监听 DOM 变化,避免使用 setInterval 轮询
  2. 元素选择器:.css-srjygq 是 Grafana v10.2.3 的导航栏类名(不同版本可能不同)
  3. 超时保护:防止目标元素不存在时无限监听

四、版本兼容性说明

关于 CSS 类名

Grafana 的 CSS 类名(如 .css-srjygq)是通过 CSS-in-JS 动态生成的,不同版本可能不同。如果你的 Grafana 版本不是 v10.2.3,需要自行定位目标元素: 定位方法:

  1. 在浏览器中打开 Grafana
  2. 按 F12 打开开发者工具
  3. 使用元素选择器定位导航栏
  4. 找到对应的 CSS 类名或 data-testid 属性 更稳健的选择器示例:
// 方案1:使用 data-testid(更稳定)
const toolbarElement = iframeDoc.querySelector('[data-testid="data-testid Navbar"]');
// 方案2:使用语义化选择器
const toolbarElement = iframeDoc.querySelector('nav[aria-label="Navbar"]');
// 方案3:组合选择器
const toolbarElement = iframeDoc.querySelector('header nav');

五、常见问题与解决方案

问题 1:跨域访问错误

原因:iframe 加载的页面与父页面不同源 解决方案:

  1. 确保 Nginx 代理配置正确
  2. 配置 Grafana 的 allow_embedding = true
  3. 如需跨域访问,考虑使用 postMessage 通信

问题 2:WebSocket 连接失败

现象:实时刷新功能不工作 解决方案: 确保 Nginx 配置了 WebSocket 支持:

proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

问题 3:iframe 高度自适应

解决方案:

iframe.onload = function() {
    const iframeDoc = iframe.contentDocument;
    const height = iframeDoc.body.scrollHeight;
    iframe.style.height = height + 'px';
};
// 监听内容变化动态调整
const resizeObserver = new ResizeObserver(entries => {
    const height = iframe.contentDocument.body.scrollHeight;
    iframe.style.height = height + 'px';
});
resizeObserver.observe(iframe.contentDocument.body);

问题 4:筛选功能失效

原因:如果隐藏了错误的元素,可能影响交互 解决方案: 只隐藏导航栏容器,不要隐藏整个顶部区域。筛选工具通常在 .dashboard-container 的子元素中。

六、方案对比总结

方案 导航栏 筛选工具 实现难度 维护成本
无参数 ✅ 显示 ✅ 显示 简单
kiosk=tv ✅ 显示 ❌ 隐藏 简单
kiosk=1 ❌ 隐藏 ❌ 隐藏 简单
自定义方案 ❌ 隐藏 ✅ 显示 中等 中等

七、写在最后

这个方案通过 Nginx 代理 + DOM 操作的方式,填补了官方 kiosk 模式的空白,为业务系统集成 Grafana 提供了更灵活的选择。 注意事项:

  1. 版本兼容性:Grafana 升级时需要重新验证元素选择器
  2. 同源策略:确保应用与 Grafana 代理后的域名一致
  3. 性能优化:MutationObserver 找到元素后立即 disconnect
  4. 权限控制:根据业务需求配置 Grafana 的认证和授权 如果你的项目也遇到了类似的嵌入需求,希望这个方案能给你提供一些思路。在实际落地过程中,建议结合自身业务场景和 Grafana 版本进行适当调整。

参考资源:

vue3:组件中,v-model的区别(新版)

一、v-model 传值的核心本质

v-model语法糖,本质是「父组件传值 + 子组件触发更新事件」的组合:

  • 对原生表单元素:v-model="xxx":value="xxx" @input="xxx = $event.target.value"
  • 对自定义组件:v-model="xxx":modelValue="xxx" @update:modelValue="xxx = $event""update:modelValue"仅仅是个事件名而已

这个规则是所有用法的基础,记住它就能灵活应对各种场景。

二、场景:父子组件的 v-model 传值(核心重点)

1、Vue的单向数据流,禁止子组件直接修改 props(包括用 v-model 绑定)因为props是只读的;

2、子组件不能直接给 props.modelValue 绑 v-model,必须通过「内部变量(ref) / 计算属性(computed)」中转,因为v-model 是 语法糖

默认传值(modelValue)

<!-- 父组件 Father.vue -->
<template>
  <input type="number" v-model="msg">
  <Child v-model="msg"></Child>
</template>
<script setup>
import { ref } from 'vue'
import Child from './components/test.vue'
const msg = ref(100)
// 仿照接口的异步数据
setTimeout(() => {
  msg.value = 2000
}, 1000)
</script>
<!-- 子组件 Child.vue -->
<template>
    <input 
    v-model="innerValue"
  />
</template>
<script setup>
import { ref, watch } from 'vue';

const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const innerValue = ref(props.modelValue)

// 监听外部变量父组件props变化 → 同步到本地变量(父 → 子)
// immediate: true 确保初始化时也能触发(如果是异步数据必须加,不然初始化数据一直是100,同步数据可不加)
watch(() => props.modelValue, (newVal) => {
  innerValue.value = newVal
}, { immediate: true })

//  监听本地变量子组件的数据变化 → 触发emit通知父组件(子 → 父)
watch(innerValue, (newVal) => {
  emit('update:modelValue', newVal)
})
</script>

Tips: 因为仿照接口的异步数据,所以16-18行代码必须加),通过v-model和watch的结合,父子组件的值,双向绑定效果就出来了。

<!-- 父组件 Father.vue -->
<template>
  <div>
      <Test v-model="message"></Test>
      <span>父传子:<input type="number" v-model="message"></span>
  </div>
</template>

<script setup >
import { ref } from 'vue';
import Test from './components/test.vue'
const message = ref(100);
setTimeout(() => {
  message.value = 20000
},1000);
</script>
<!-- 子组件 Child.vue -->
<template>
  <div>
    子传父:<input type="number" v-model="message">
  </div>
</template>

<script setup>
import { computed, ref, watch } from 'vue';
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
  modelValue: Number
})
const emits = defineEmits(['update:modelValue']) 
// 不能直接修改computed的值,会报错:[Vue warn] Write operation failed: computed value is readonly
// const message = computed(() => props.modelValue) // 报错
const message = computed({
  get(){
    return props.modelValue
  },
  set(val){
    emits('update:modelValue', val)
  }
})
</script>

Tips: computedget 方法本身就会监听依赖的响应式变化(包括异步数据),只要依赖变了,computed 的值就会自动更新,所以不用像watch那么复杂监听2次。

<!-- 父组件 -->
<template>
  <el-button type="primary" @click="openInnerDialog">
    Open the inner Dialog
  </el-button>
  <InnerDialog v-model="innerVisible" />
</template>

<script lang="ts" setup>
import { ref } from 'vue'
import InnerDialog from './components/test.vue'

const innerVisible = ref(false)
const openInnerDialog = () => {
  innerVisible.value = true
}

</script>
<!-- 子组件 -->
<template>
  <el-dialog
    v-model="visible"
    width="500"
    title="Inner Dialog"
    append-to-body
  >
    <span>This is the inner Dialog</span>
  </el-dialog>
</template>

<script lang="ts" setup>
import { computed } from 'vue'
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const visible = computed({
  get: () => props.modelValue, 
  set: (val) => emit('update:modelValue', val)
})

</script>

Tips:弹框组件的常见用法:props和emit(缺点:父组件,每个入口触发 emit)、ref(缺点:父组件直接操作子组件实例,耦合性高,不符合 “单向数据流”、Vue3 中需手动 defineExpose 暴露方法很麻烦)等。

从 Vue2 到 Vue3:语法差异与迁移时最容易懵的点

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、为什么要写这篇文章?

Vue3 已经是官方默认推荐版本,但很多团队的存量项目仍然在 Vue2 上跑。即便你已经开始用 Vue3 了,也很可能是"Options API 的写法 + <script setup> 的壳"——形式换了,思维没换。

这篇文章不讲玄学的底层原理,只讲一个核心问题

日常写代码到底该怎么选、为什么这么选、踩坑会踩在哪?

我们会把 Vue2 的 data / props / computed / methods / watch / 生命周期 和 Vue3 的 Composition API 做一次逐项对照,每一项都给出完整的代码示例和踩坑说明。

二、先建立一个全局视角:Options API vs Composition API

在动手对比之前,先花 30 秒看一张对照表,心里有个全貌:

关注点 Vue2(Options API) Vue3(Composition API / <script setup>
响应式数据 data() ref() / reactive()
接收外部参数 props 选项 defineProps()
计算属性 computed 选项 computed() 函数
方法 methods 选项 普通函数声明
侦听器 watch 选项 watch() / watchEffect()
生命周期 created / mounted … onMounted / onUnmounted …
模板访问 this.xxx 直接用变量名(<script setup> 自动暴露)

一句话总结:Vue2 按"选项类型"组织代码(数据放一块、方法放一块);Vue3 按"逻辑关注点"组织代码(一个功能的数据+方法+侦听可以放在一起)。

三、逐项对比 + 完整示例 + 踩坑点

3.1 响应式数据:data()ref() / reactive()

Vue2 写法

<template>
  <div>
    <p>{{ count }}</p>
    <p>{{ user.name }} - {{ user.age }}</p>
    <button @click="add">+1</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      count: 0,
      user: {
        name: '张三',
        age: 25
      }
    }
  },
  methods: {
    add() {
      this.count++
      this.user.age++
    }
  }
}
</script>

Vue2 里一切都挂在 this 上,data() 返回的对象会被 Vue 内部用 Object.defineProperty 做递归劫持,所以你只要 this.count++,视图就会更新。简单粗暴,上手友好。

Vue3 写法(<script setup>

<template>
  <div>
    <p>{{ count }}</p>
    <p>{{ user.name }} - {{ user.age }}</p>
    <button @click="add">+1</button>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'

// 基本类型 → 用 ref
const count = ref(0)

// 对象类型 → 用 reactive
const user = reactive({
  name: '张三',
  age: 25
})

function add() {
  count.value++   // ← 注意:ref 在 JS 里要 .value
  user.age++      // ← reactive 对象不需要 .value
}
</script>

踩坑重灾区

坑 1:ref.value 到底什么时候要加?

这是从 Vue2 转过来最高频的困惑,记住一个口诀:

模板里不加,JS 里要加。

<template>
  <!-- 模板中直接用,Vue 会自动解包 -->
  <p>{{ count }}</p>
</template>

<script setup>
import { ref } from 'vue'
const count = ref(0)

// JS 中必须 .value
console.log(count.value) // 0
count.value++
</script>

为什么模板里不用加?因为 Vue 的模板编译器遇到 ref 时会自动帮你插入 .value,这是编译期的语法糖。但在 <script> 里你是在写原生 JS,Vue 管不到,所以必须手动 .value

坑 2:refreactive 到底选哪个?

这是社区吵了很久的问题。我的实战建议(也是 Vue 官方文档推荐的倾向):

场景 推荐 原因
基本类型(number / string / boolean) ref() reactive() 不支持基本类型
对象/数组,且不会被整体替换 reactive() 不用到处写 .value,更清爽
对象/数组,但可能被整体替换 ref() reactive() 整体替换会丢失响应性
拿不准的时候 ref() 全部用 ref 不会出错,reactive 有限制

坑 3:reactive 的解构陷阱 —— 这个真的会坑到你

<script setup>
import { reactive } from 'vue'

const user = reactive({ name: '张三', age: 25 })

// ❌ 错误:解构后变量失去响应性!
let { name, age } = user
age++  // 视图不会更新,因为 age 现在只是一个普通的数字 25

// ✅ 正确做法1:不解构,直接用
user.age++

// ✅ 正确做法2:用 toRefs 解构
import { toRefs } from 'vue'
const { name: nameRef, age: ageRef } = toRefs(user)
ageRef.value++  // 视图会更新(注意变成了 ref,需要 .value)
</script>

为什么会这样?因为 reactive 的响应性是挂在对象的属性访问上的(基于 Proxy),一旦你把属性值解构出来赋给一个新变量,那个新变量只是一个普通的 JS 值,和原来的 Proxy 对象已经没有关系了。

坑 4:reactive 整体替换会丢失响应性

<script setup>
import { reactive, ref } from 'vue'

let state = reactive({ list: [1, 2, 3] })

// ❌ 错误:整体替换,模板拿到的还是旧的那个对象
state = reactive({ list: [4, 5, 6] })  
// 此时模板绑定的引用还指向旧对象,视图不会更新

// ✅ 正确做法1:修改属性而不是替换对象
state.list = [4, 5, 6]  // 这样是OK的

// ✅ 正确做法2:需要整体替换的场景,改用 ref
const state2 = ref({ list: [1, 2, 3] })
state2.value = { list: [4, 5, 6] }  // 没问题,视图正常更新
</script>

这也是我建议"拿不准就用 ref"的原因——ref 不存在这个问题,因为你永远是通过 .value 赋值,Vue 能追踪到。


3.2 Props:props 选项defineProps()

Vue2 写法

<!-- 子组件 UserCard.vue -->
<template>
  <div class="card">
    <h3>{{ name }}</h3>
    <p>年龄:{{ age }}</p>
    <p>是否VIP:{{ isVip ? '是' : '否' }}</p>
  </div>
</template>

<script>
export default {
  props: {
    name: {
      type: String,
      required: true
    },
    age: {
      type: Number,
      default: 18
    },
    isVip: {
      type: Boolean,
      default: false
    }
  },
  mounted() {
    // 通过 this 访问
    console.log(this.name, this.age)
  }
}
</script>
<!-- 父组件中使用 -->
<UserCard name="李四" :age="30" is-vip />

Vue3 写法(<script setup>

<!-- 子组件 UserCard.vue -->
<template>
  <div class="card">
    <h3>{{ name }}</h3>
    <p>年龄:{{ age }}</p>
    <p>是否VIP:{{ isVip ? '是' : '否' }}</p>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'

// defineProps 是编译器宏,不需要 import
const props = defineProps({
  name: {
    type: String,
    required: true
  },
  age: {
    type: Number,
    default: 18
  },
  isVip: {
    type: Boolean,
    default: false
  }
})

onMounted(() => {
  // 不再有 this,直接用 props 对象
  console.log(props.name, props.age)
})
</script>

如果你用 TypeScript,还可以用纯类型声明的写法,更加简洁:

<script setup lang="ts">
interface Props {
  name: string
  age?: number
  isVip?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  age: 18,
  isVip: false
})
</script>

踩坑重灾区

坑 1:defineProps 不需要 import,但 IDE 可能会报红

definePropsdefineEmitsdefineExpose 这些都是编译器宏(compiler macro),在编译阶段就被处理掉了,运行时并不存在。所以不需要 import

如果你的 ESLint 报 'defineProps' is not defined,那是 ESLint 配置问题,需要在 .eslintrc 里配置:

// .eslintrc.js
module.exports = {
  env: {
    'vue/setup-compiler-macros': true
  }
}

或者升级到较新版本的 eslint-plugin-vue(v9+),它默认已经支持了。

坑 2:Props 解构也会丢失响应性(Vue 3.2 及以前)

<script setup>
const props = defineProps({ count: Number })

// ❌ Vue 3.2及以前:解构会丢失响应性
const { count } = props  // count 变成普通值,父组件更新后这里不会变

// ✅ 保持响应性的做法
import { toRefs } from 'vue'
const { count: countRef } = toRefs(props)
// 或者直接用 props.count
</script>

好消息:Vue 3.5+ 引入了响应式 Props 解构(Reactive Props Destructure),如果你的项目版本够新,可以直接解构:

<script setup>
// Vue 3.5+ 可以直接解构,自动保持响应性
const { count = 0 } = defineProps({ count: Number })
// count 是响应式的,可以直接在模板中用
</script>

但如果你的项目还在 3.4 或更早版本上,老老实实用 props.counttoRefs 是最稳的。


3.3 Computed:computed 选项computed() 函数

Vue2 写法

<template>
  <div>
    <p>原价:{{ price }} 元</p>
    <p>折后价:{{ discountedPrice }} 元</p>
    <input v-model="fullName" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      price: 100,
      discount: 0.8,
      firstName: '张',
      lastName: '三'
    }
  },
  computed: {
    // 只读计算属性
    discountedPrice() {
      return (this.price * this.discount).toFixed(2)
    },
    // 可读可写计算属性
    fullName: {
      get() {
        return this.firstName + this.lastName
      },
      set(val) {
        // 假设第一个字是姓,后面是名
        this.firstName = val.charAt(0)
        this.lastName = val.slice(1)
      }
    }
  }
}
</script>

Vue3 写法

<template>
  <div>
    <p>原价:{{ price }} 元</p>
    <p>折后价:{{ discountedPrice }} 元</p>
    <input v-model="fullName" />
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const price = ref(100)
const discount = ref(0.8)
const firstName = ref('张')
const lastName = ref('三')

// 只读计算属性 —— 传一个 getter 函数
const discountedPrice = computed(() => {
  return (price.value * discount.value).toFixed(2)
})

// 可读可写计算属性 —— 传一个对象
const fullName = computed({
  get: () => firstName.value + lastName.value,
  set: (val) => {
    firstName.value = val.charAt(0)
    lastName.value = val.slice(1)
  }
})
</script>

踩坑重灾区

坑 1:computed 里千万别做"副作用"操作

这条 Vue2 和 Vue3 都一样,但很多人还是会犯:

// ❌ 错误示范:在 computed 里修改别的状态、发请求、操作 DOM
const total = computed(() => {
  otherState.value = 'changed'  // 副作用!
  fetch('/api/log')             // 副作用!
  return items.value.reduce((sum, item) => sum + item.price, 0)
})

// ✅ computed 应该是纯函数,只根据依赖算出一个值
const total = computed(() => {
  return items.value.reduce((sum, item) => sum + item.price, 0)
})

computed 的设计初衷就是"根据已有状态派生出新状态",它有缓存机制——只有依赖变了才重新计算。如果你往里面塞副作用,会导致不可预测的执行时机和执行次数。

坑 2:别把 computed 和 methods 搞混了

Vue2 老手可能觉得"computed 和 method 返回的值不是一样吗",但核心区别是缓存

<script setup>
import { ref, computed } from 'vue'

const list = ref([1, 2, 3, 4, 5])

// computed:有缓存,list 不变就不会重新执行
const total = computed(() => {
  console.log('computed 执行了')
  return list.value.reduce((a, b) => a + b, 0)
})

// 普通函数:每次模板渲染都会重新执行
function getTotal() {
  console.log('function 执行了')
  return list.value.reduce((a, b) => a + b, 0)
}
</script>

<template>
  <!-- 假设模板里用了3次 -->
  <p>{{ total }} {{ total }} {{ total }}</p>
  <!-- computed 只会打印1次 log,函数会打印3次 -->
  <p>{{ getTotal() }} {{ getTotal() }} {{ getTotal() }}</p>
</template>

结论:需要缓存、依赖响应式数据派生值的用 computed;需要执行某个动作(点击事件等)的用普通函数。


3.4 Methods:methods 选项 → 普通函数

Vue2 写法

<template>
  <div>
    <p>{{ count }}</p>
    <button @click="increment">+1</button>
    <button @click="incrementBy(5)">+5</button>
    <button @click="reset">重置</button>
  </div>
</template>

<script>
export default {
  data() {
    return { count: 0 }
  },
  methods: {
    increment() {
      this.count++
    },
    incrementBy(n) {
      this.count += n
    },
    reset() {
      this.count = 0
      this.logAction('reset')  // 方法之间互相调用
    },
    logAction(action) {
      console.log(`[${new Date().toLocaleTimeString()}] 执行了: ${action}`)
    }
  }
}
</script>

Vue2 的 methods 是一个选项对象,所有方法平铺在里面,互相调用要通过 this

Vue3 写法

<template>
  <div>
    <p>{{ count }}</p>
    <button @click="increment">+1</button>
    <button @click="incrementBy(5)">+5</button>
    <button @click="reset">重置</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}

function incrementBy(n) {
  count.value += n
}

function logAction(action) {
  console.log(`[${new Date().toLocaleTimeString()}] 执行了: ${action}`)
}

function reset() {
  count.value = 0
  logAction('reset')  // 直接调用,不需要 this
}
</script>

关键差异说明

Vue3 里没有 methods 这个概念了——就是普通的 JavaScript 函数。在 <script setup> 中声明的函数会自动暴露给模板,不需要额外 return。

这带来几个实质性的好处:

  1. 不再需要 this:函数直接闭包引用变量,没有 this 指向问题
  2. 可以用箭头函数:Vue2 的 methods 里不建议用箭头函数(会导致 this 指向错误),Vue3 随意用
  3. 方法可以和相关数据放在一起:不用再在 datamethods 之间跳来跳去
<script setup>
import { ref } from 'vue'

// ———— 计数器相关逻辑 ————
const count = ref(0)
const increment = () => count.value++  // 箭头函数完全OK
const reset = () => (count.value = 0)

// ———— 用户信息相关逻辑 ————
const username = ref('')
const updateUsername = (name) => (username.value = name)
</script>

看到没?数据和操作数据的方法紧挨在一起,按"功能"而不是按"类型"组织。这就是 Composition API 的核心思想——当组件逻辑复杂的时候,不用在 datacomputedmethodswatch 之间反复横跳。


3.5 Watch:watch 选项watch() / watchEffect()

Vue2 写法

<script>
export default {
  data() {
    return {
      keyword: '',
      user: { name: '张三', age: 25 }
    }
  },
  watch: {
    // 基础用法
    keyword(newVal, oldVal) {
      console.log(`搜索词变了:${oldVal} → ${newVal}`)
      this.doSearch(newVal)
    },
    // 深度侦听
    user: {
      handler(newVal) {
        console.log('user 变了', newVal)
      },
      deep: true,
      immediate: true  // 创建时立即执行一次
    }
  },
  methods: {
    doSearch(kw) { /* ... */ }
  }
}
</script>

Vue3 写法

<script setup>
import { ref, reactive, watch, watchEffect } from 'vue'

const keyword = ref('')
const user = reactive({ name: '张三', age: 25 })

// ——— watch:和 Vue2 类似,显式指定侦听源 ———

// 侦听 ref
watch(keyword, (newVal, oldVal) => {
  console.log(`搜索词变了:${oldVal} → ${newVal}`)
  doSearch(newVal)
})

// 侦听 reactive 对象的某个属性(注意:要用 getter 函数)
watch(
  () => user.age,
  (newAge, oldAge) => {
    console.log(`年龄变了:${oldAge} → ${newAge}`)
  }
)

// 侦听整个 reactive 对象(自动深度侦听)
watch(user, (newVal) => {
  console.log('user 变了', newVal)
})

// 加选项:立即执行
watch(keyword, (newVal) => {
  doSearch(newVal)
}, { immediate: true })

// ——— watchEffect:自动收集依赖,不用指定侦听源 ———
watchEffect(() => {
  // 回调里用到了哪些响应式数据,就自动侦听哪些
  console.log(`当前搜索词:${keyword.value},用户:${user.name}`)
})

function doSearch(kw) { /* ... */ }
</script>

watch vs watchEffect 怎么选?

特性 watch watchEffect
需要指定侦听源 否(自动收集依赖)
能拿到 oldValue 不能
默认是否立即执行 否(可设 immediate: true 是(创建时立即执行一次)
适合场景 需要精确控制"侦听谁"、需要新旧值对比 "用到啥就侦听啥",简化写法

我的实战建议:大多数场景用 watch,因为它意图更明确——看代码就知道你在侦听什么。watchEffect 适合那种"把几个数据凑一起做点事、不关心谁变了"的简单场景。

踩坑重灾区

坑 1:侦听 reactive 对象的属性,必须用 getter 函数

const user = reactive({ name: '张三', age: 25 })

// ❌ 错误:直接写 user.age,这只是传了个数字 25 进去
watch(user.age, (val) => { /* 永远不会触发 */ })

// ✅ 正确:传一个 getter 函数
watch(() => user.age, (val) => { console.log(val) })

原因很简单:user.age 在传参时就已经求值了,得到数字 25——一个普通的数字不是响应式的,Vue 没法侦听它。用 () => user.age 则是传了一个函数,Vue 每次执行这个函数时都会触发 Proxy 的 get 拦截,从而建立依赖追踪。

坑 2:watch 的清理——组件卸载后还在跑?

// 在 <script setup> 顶层调用的 watch 会自动与组件绑定
// 组件卸载时自动停止,不用手动处理
watch(keyword, (val) => { /* ... */ })

// 但如果你在异步回调或条件语句里创建 watch,就需要手动停止
let stop
setTimeout(() => {
  stop = watch(keyword, (val) => { /* ... */ })
}, 1000)

// 需要停止时调用
// stop()
</script>

3.6 生命周期:选项式 → 组合式

对照表

Vue2(Options API) Vue3(Composition API) 说明
beforeCreate 不需要(setup 本身就是) <script setup> 的代码就运行在这个时机
created 不需要(setup 本身就是) 同上
beforeMount onBeforeMount() DOM 挂载前
mounted onMounted() DOM 挂载后
beforeUpdate onBeforeUpdate() 数据变了、DOM 更新前
updated onUpdated() DOM 更新后
beforeDestroy onBeforeUnmount() 卸载前(注意改名了!)
destroyed onUnmounted() 卸载后(注意改名了!)

完整示例

<!-- Vue2 -->
<script>
export default {
  data() {
    return { timer: null }
  },
  created() {
    console.log('created: 可以访问数据了')
    this.fetchData()
  },
  mounted() {
    console.log('mounted: DOM 准备好了')
    this.timer = setInterval(() => {
      console.log('tick')
    }, 1000)
  },
  beforeDestroy() {
    clearInterval(this.timer)
    console.log('beforeDestroy: 清理定时器')
  }
}
</script>
<!-- Vue3 -->
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'

const timer = ref(null)

// <script setup> 中的顶层代码 ≈ created
console.log('setup: 可以访问数据了')
fetchData()

onMounted(() => {
  console.log('onMounted: DOM 准备好了')
  timer.value = setInterval(() => {
    console.log('tick')
  }, 1000)
})

onBeforeUnmount(() => {
  clearInterval(timer.value)
  console.log('onBeforeUnmount: 清理定时器')
})

async function fetchData() { /* ... */ }
</script>

踩坑重灾区

坑 1:beforeDestroyonBeforeUnmount,名字改了!

Vue3 把 destroy 相关的钩子全部改名为 unmount

  • beforeDestroyonBeforeUnmount
  • destroyedonUnmounted

如果你用 Options API 写 Vue3 组件(是的,Vue3 也支持 Options API),那对应的选项名也变了:beforeUnmountunmounted

坑 2:不要在 setup 顶层做 DOM 操作

<script setup>
// ❌ 这里 DOM 还没挂载!
document.querySelector('.my-el')  // null

// ✅ DOM 操作要放到 onMounted 里
import { onMounted } from 'vue'
onMounted(() => {
  document.querySelector('.my-el')  // OK
})
</script>

<script setup> 的顶层代码执行时机等同于 beforeCreate + created,这时候 DOM 还不存在。


3.7 Emits:this.$emit()defineEmits()

Vue2 写法

<!-- 子组件 -->
<script>
export default {
  methods: {
    handleClick() {
      this.$emit('update', { id: 1, name: '新名称' })
      this.$emit('close')
    }
  }
}
</script>

<!-- 父组件 -->
<ChildComponent @update="onUpdate" @close="onClose" />

Vue3 写法

<!-- 子组件 -->
<script setup>
const emit = defineEmits(['update', 'close'])

// 或者带类型校验(TypeScript)
// const emit = defineEmits<{
//   (e: 'update', payload: { id: number; name: string }): void
//   (e: 'close'): void
// }>()

function handleClick() {
  emit('update', { id: 1, name: '新名称' })
  emit('close')
}
</script>

<!-- 父组件(用法不变) -->
<ChildComponent @update="onUpdate" @close="onClose" />

Vue3 要求显式声明组件会触发哪些事件。这不仅仅是规范,还有一个实际好处:Vue3 会把未声明的事件名当作原生 DOM 事件处理。如果你不声明 emits,给组件绑定 @click,这个 click 会直接穿透到子组件的根元素上。

四、一个完整的实战对比:Todo List

最后,用一个麻雀虽小五脏俱全的 Todo List,把上面所有知识点串起来。

Vue2 版本

<template>
  <div class="todo-app">
    <h2>待办清单(共 {{ activeCount }} 项未完成)</h2>
    <div class="input-bar">
      <input
        v-model="newTodo"
        @keyup.enter="addTodo"
        placeholder="输入待办事项..."
      />
      <button @click="addTodo" :disabled="!canAdd">添加</button>
    </div>
    <ul>
      <li v-for="todo in filteredTodos" :key="todo.id">
        <input type="checkbox" v-model="todo.done" />
        <span :class="{ done: todo.done }">{{ todo.text }}</span>
        <button @click="removeTodo(todo.id)">删除</button>
      </li>
    </ul>
    <div class="filters">
      <button @click="filter = 'all'">全部</button>
      <button @click="filter = 'active'">未完成</button>
      <button @click="filter = 'completed'">已完成</button>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      newTodo: '',
      nextId: 1,
      filter: 'all',
      todos: []
    }
  },
  computed: {
    canAdd() {
      return this.newTodo.trim().length > 0
    },
    activeCount() {
      return this.todos.filter(t => !t.done).length
    },
    filteredTodos() {
      if (this.filter === 'active') return this.todos.filter(t => !t.done)
      if (this.filter === 'completed') return this.todos.filter(t => t.done)
      return this.todos
    }
  },
  watch: {
    todos: {
      handler(newTodos) {
        localStorage.setItem('todos', JSON.stringify(newTodos))
      },
      deep: true
    }
  },
  created() {
    const saved = localStorage.getItem('todos')
    if (saved) {
      this.todos = JSON.parse(saved)
      this.nextId = this.todos.length
        ? Math.max(...this.todos.map(t => t.id)) + 1
        : 1
    }
  },
  methods: {
    addTodo() {
      if (!this.canAdd) return
      this.todos.push({
        id: this.nextId++,
        text: this.newTodo.trim(),
        done: false
      })
      this.newTodo = ''
    },
    removeTodo(id) {
      this.todos = this.todos.filter(t => t.id !== id)
    }
  }
}
</script>

Vue3 版本

<template>
  <div class="todo-app">
    <h2>待办清单(共 {{ activeCount }} 项未完成)</h2>
    <div class="input-bar">
      <input
        v-model="newTodo"
        @keyup.enter="addTodo"
        placeholder="输入待办事项..."
      />
      <button @click="addTodo" :disabled="!canAdd">添加</button>
    </div>
    <ul>
      <li v-for="todo in filteredTodos" :key="todo.id">
        <input type="checkbox" v-model="todo.done" />
        <span :class="{ done: todo.done }">{{ todo.text }}</span>
        <button @click="removeTodo(todo.id)">删除</button>
      </li>
    </ul>
    <div class="filters">
      <button @click="filter = 'all'">全部</button>
      <button @click="filter = 'active'">未完成</button>
      <button @click="filter = 'completed'">已完成</button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue'

// ———— 状态 ————
const newTodo = ref('')
const filter = ref('all')
const todos = ref([])
let nextId = 1

// ———— 初始化(等同于 created) ————
const saved = localStorage.getItem('todos')
if (saved) {
  todos.value = JSON.parse(saved)
  nextId = todos.value.length
    ? Math.max(...todos.value.map(t => t.id)) + 1
    : 1
}

// ———— 计算属性 ————
const canAdd = computed(() => newTodo.value.trim().length > 0)

const activeCount = computed(() => {
  return todos.value.filter(t => !t.done).length
})

const filteredTodos = computed(() => {
  if (filter.value === 'active') return todos.value.filter(t => !t.done)
  if (filter.value === 'completed') return todos.value.filter(t => t.done)
  return todos.value
})

// ———— 侦听器 ————
watch(todos, (newTodos) => {
  localStorage.setItem('todos', JSON.stringify(newTodos))
}, { deep: true })

// ———— 方法 ————
function addTodo() {
  if (!canAdd.value) return
  todos.value.push({
    id: nextId++,
    text: newTodo.value.trim(),
    done: false
  })
  newTodo.value = ''
}

function removeTodo(id) {
  todos.value = todos.value.filter(t => t.id !== id)
}
</script>

对比两个版本你会发现:模板部分完全一样,变化全在 <script> 里。这也是 Vue3 设计的一个巧妙之处——模板语法几乎没有 breaking change,迁移成本主要在 JS 逻辑层。

五、迁移时的高频"懵圈"清单

最后汇总一下,从 Vue2 迁到 Vue3,最容易懵的点:

序号 懵圈点 一句话解惑
1 ref.value 什么时候加? 模板里不加,JS 里加
2 ref 还是 reactive 拿不准就全用 ref,不会出错
3 reactive 解构丢失响应性 toRefs() 解构,或者不解构
4 this 去哪了? 没有了,<script setup> 里直接用变量和函数
5 defineProps / defineEmits 要 import 吗? 不用,它们是编译器宏
6 beforeDestroy 不生效了? 改名了,叫 onBeforeUnmount
7 created 里的逻辑放哪? 直接写在 <script setup> 顶层
8 watch 侦听 reactive 属性无效? 要用 getter 函数 () => obj.prop
9 watchwatchEffect 选哪个? 大多数场景用 watch,意图更清晰
10 组件暴露方法给父组件怎么办? defineExpose({ methodName })

六、结语

Vue3 的 Composition API 不是为了"炫技"而存在的,它解决的是一个非常现实的问题:当组件逻辑变复杂后,Options API 的代码会像面条一样——数据在上面,方法在下面,watch 在中间,改一个功能要上下反复跳。

Composition API 让你可以按逻辑关注点把代码组织在一起,甚至抽成可复用的 composables(组合式函数),这才是它真正的威力所在。

但说实话,不需要一步到位。Vue3 完全兼容 Options API,你可以:

  1. 新组件用 <script setup> + Composition API
  2. 老组件维护时逐步迁移
  3. 复杂逻辑才抽 composables,简单组件怎么顺手怎么来

技术服务于业务,够用、好维护,就是最好的选择。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

js中,什么是线性查找?

在 JavaScript 中,线性查找(Linear Search),也叫顺序查找,是一种最简单、最直接的查找算法。它的基本思想是:从数组(或列表)的第一个元素开始,逐个检查每个元素,直到找到目标值或遍历完整个数组为止

特点:

  • 时间复杂度
    • 最好情况:O(1)(第一个元素就是要找的)
    • 最坏情况:O(n)(目标在最后一个位置,或者不存在)
    • 平均情况:O(n)
  • 空间复杂度:O(1)(不需要额外空间)
  • 不要求数组有序,适用于任何类型的数组

示例代码(JavaScript):

function linearSearch(arr, target) {
  for (let i = 0; i < arr.length; i++) {
    if (arr[i] === target) {
      return i; // 返回目标元素的索引
    }
  }
  return -1; // 未找到,返回 -1
}

// 使用示例
const numbers = [10, 25, 3, 47, 15];
console.log(linearSearch(numbers, 3));  // 输出: 2
console.log(linearSearch(numbers, 100)); // 输出: -1

适用场景:

  • 数据量较小
  • 数组未排序
  • 只需要查找一次(如果多次查找,建议先排序后用二分查找等更高效方法)

总结:

线性查找虽然效率不高,但实现简单、通用性强,是理解查找算法的基础。在 JavaScript 中,原生方法如 Array.prototype.indexOf()Array.prototype.find()Array.prototype.findIndex() 内部本质上也是线性查找。

新手引导 intro.js 的使用

1 依赖引入

npm install --save intro.js

2 intro.js的使用

vue3 为例

<template>
  <div id="step1">...</div>
  <div id="step2">...</div>
  <div id="step3">...</div>
</template>

<script setup>
import { onBeforeUnmount, onMounted } from 'vue'
// 引入intro.js相关依赖
import introJs from 'intro.js'
import 'intro.js/introjs.css'

const intro = introJs() // 申明引导

onMounted(() => {
  // 注册引导
  intro.setOptions({
    nextLabel: '下一步',
    prevLabel: '上一步',
    doneLabel: '完成',
    steps: [
      {
        element: document.querySelector('#step1'),
        intro: "这是第一步的描述",
        position: 'bottom'
      },
      {
        element: document.querySelector('#step2'),
        intro: "这是第二步的描述",
        position: 'bottom'
      },
      {
        element: document.querySelector('#step3'),
        intro: "这是第二步的描述",
        position: 'left'
      }
    ]
  })
  intro.start() // 开启引导
})

onBeforeUnmount(() => {
  intro?.exit() // 销毁监听
})
</script>

3 再次唤起引导

引导关闭后发现无法通过 intro.start() 方法再次唤起,因此需要销毁重建。

function openIntro() { // 打开引导方法,可绑定在 “新手引导” 按钮上重复触发
  intro.onExit(() => { // 引导关闭钩子,每次关闭都重新创建引导
    setTimeout(() => { // 手动异步
      intro?.exit() // 销毁
      intro = introJs() // 重构
    }, 10)
  })
  // 注册引导
  intro.setOptions({
    nextLabel: '下一步',
    prevLabel: '上一步',
    doneLabel: '完成',
    steps: [
      {
        element: document.querySelector('#step1'),
        intro: "这是第一步的描述",
        position: 'bottom'
      },
      {
        element: document.querySelector('#step2'),
        intro: "这是第二步的描述",
        position: 'bottom'
      },
      {
        element: document.querySelector('#step3'),
        intro: "这是第二步的描述",
        position: 'left'
      }
    ]
  })
  intro.start() // 开启引导
}

4 集成为公共组件使用

4.1 在 Vue3 作为 hook 使用

import { ref, onBeforeUnmount } from 'vue'
import introJs from 'intro.js'
import 'intro.js/introjs.css'

export function useIntro() {
  const intro = ref(introJs())

  function openIntroWithOptions(options = { steps: [] }) {
      intro.value.onExit(() => { // 每次关闭都重新创建引导器
        setTimeout(() => {
          intro.value?.exit() // 销毁
          intro.value = introJs() // 重构
        }, 10)
      })
      // 注册引导器
      intro.value.setOptions({
        nextLabel: '下一步',
        prevLabel: '上一步',
        doneLabel: '完成',
        ...options
      })
      intro.value.start()
  }

  onBeforeUnmount(() => {
    intro.value?.exit()
  })

  return {
    intro,
    openIntroWithOptions
  }
}

/** 
 * 在页面中使用示例:
 * 1. 引入 useIntro
 * 2. 声明方法
 * 3. 编写引导打开方法
 * 3.1 其中至少配置 steps,由于element要实时获取,所以必须在页面中的方法里实时配置
 * 3.2 如果是页面加载完立即启动引导,可直接在 onMounted 中执行 openIntro 方法内容 */
// import { useIntro } from '@/hooks/intro' // 1.引入
// const { openIntroWithOptions } = useIntro() // 2.声明 
// function openIntro() { // 3.引导打开方法
//   openIntroWithOptions({ // 配置引导options
//     steps: [
//       {
//         element: document.querySelector('#step1'),
//         intro: "这里是待办事项总览",
//         position: 'bottom'
//       },
//       {
//         element: document.querySelector('#step2'),
//         intro: "点击可查看此类目待办事项",
//         position: 'bottom'
//       },
//       {
//         element: document.querySelector('#step3'),
//         intro: "这是待办事项列表",
//         position: 'top'
//       },
//       {
//         element: document.querySelector('#step4'),
//         intro: "点击可前往处理",
//         position: 'left'
//       }
//     ]
//   })
// }

4.2 在 Vue2 作为 mixin 使用

// 引导器mixins
import introJs from 'intro.js'
import 'intro.js/introjs.css'

let intro = introJs()

export default {
  beforeDestroy() {
    intro?.exit() // 销毁监听
  },
  methods: {
    openIntroWithOptions(options = { steps: [] }) { // 打开引导
      intro.onExit(() => { // 每次关闭都重新创建引导器
        setTimeout(() => {
          intro?.exit() // 销毁
          intro = introJs() // 重构
        }, 10)
      })
      // 注册引导器
      intro.setOptions({
        nextLabel: '下一步',
        prevLabel: '上一步',
        doneLabel: '完成',
        ...options
      })
      intro.start()
    }
  }
}

/** 
 * 在页面中使用示例:
 * 1. 引入
 * 2. 申明mixins
 * 3. 在 methods 中写入以下方法
 * 3.1 其中至少配置 steps,由于element要实时获取,所以必须在页面中的方法里实时配置
 * 3.2 如果是页面加载完立即启动引导,可直接在 mounted 中执行 openIntro 方法内容 */
// import intro from '@/mixins/intro' // 1. 引入
// mixins: [intro] // 2. 申明
// openIntro() { // 3. 调用方法
//   this.openIntroWithOptions({ // 配置引导options
//     steps: [
//       {
//         element: document.querySelector('#step1'),
//         intro: "这里是待办事项总览",
//         position: 'bottom'
//       },
//       {
//         element: document.querySelector('#step2'),
//         intro: "点击可查看此类目待办事项",
//         position: 'bottom'
//       },
//       {
//         element: document.querySelector('#step3'),
//         intro: "这是待办事项列表",
//         position: 'top'
//       },
//       {
//         element: document.querySelector('#step4'),
//         intro: "点击可前往处理",
//         position: 'left'
//       }
//     ]
//   })
// }

笔记主要为自用,欢迎友好交流!

【AI 编程实战】第 12 篇:从 0 到 1 的回顾 - 项目总结与 AI 协作心得

12 篇文章、一个完整的小程序、从需求分析到性能优化,这是我和 AI 协作开发心动恋聊的全过程。这篇文章作为系列收官,分享项目的完整回顾、AI 协作的心得体会、以及对未来开发模式的思考。

系列专栏【AI 编程实战】专栏目录

本篇主题:项目总结与 AI 协作心得

实战项目:心动恋聊 - AI 恋爱聊天助手

一、项目回顾:从 0 到 1 的历程

1.1 项目概览

心动恋聊是一个 AI 恋爱聊天助手小程序,帮助用户在社交场景中获得更好的聊天回复建议。

📊 项目规模:

技术栈:
- 前端:UniApp + Vue 3 + TypeScript + Pinia
- 后端:Next.js + Prisma + AI API
- UI:UnoCSS + UView Pro

代码量:
- 前端代码:~15,000 行
- 组件数量:30+ 个
- 页面数量:15+ 个
- Hooks:10+ 个

开发周期:
- 总耗时:约 4 周
- 迭代次数:3 个大版本

1.2 系列文章脉络

📚 12 篇文章的完整脉络:

【基础搭建】(第 1-5 篇)
├── 第 1 篇:项目启动 - 需求分析、技术选型、AI 配置
├── 第 2 篇:创建项目 - UniApp 项目初始化与配置
├── 第 3 篇:页面结构 - 首页布局与 TabBar 配置
├── 第 4 篇:样式系统 - UnoCSS 原子化 CSS 实战
└── 第 5 篇:状态管理 - Pinia 持久化与用户状态

【核心功能】(第 6-9 篇)
├── 第 6 篇:网络请求 - HTTP 封装与拦截器设计
├── 第 7 篇:登录流程 - 微信授权与多步骤弹窗
├── 第 8 篇:组件封装 - 可复用组件设计方法
└── 第 9 篇:Hooks 封装 - 逻辑复用的最佳实践

【质量保障】(第 10-12 篇)
├── 第 10 篇:错误处理 - 防御性编程与边界情况
├── 第 11 篇:性能优化 - 分包、懒加载、缓存策略
└── 第 12 篇:项目总结 - 回顾与 AI 协作心得(本篇)

1.3 每篇文章的核心产出

篇章 主题 核心产出
第 1 篇 项目启动 需求文档、技术架构图
第 2 篇 创建项目 项目脚手架、目录结构
第 3 篇 页面结构 首页布局、TabBar 配置
第 4 篇 样式系统 UnoCSS 配置、主题色方案
第 5 篇 状态管理 userStore、持久化方案
第 6 篇 网络请求 HTTP 封装、拦截器
第 7 篇 登录流程 LoginModal、多步骤流程
第 8 篇 组件封装 XButton、Modal、VipCard
第 9 篇 Hooks useRequest、useUpload
第 10 篇 错误处理 Toast 封装、防御性编程
第 11 篇 性能优化 分包、懒加载、缓存
第 12 篇 项目总结 方法论、心得体会

二、AI 协作的正确姿势

2.1 什么样的对话最有效

通过 12 篇文章的实践,我总结出和 AI 对话的几个关键点:

❌ 低效的对话方式:

我:帮我写一个登录功能

这样的提问太宽泛,AI 不知道:

  • 是什么平台?小程序/H5/App?
  • 用什么登录方式?微信/手机号/密码?
  • 登录后跳哪里?需要什么回调?
  • UI 是弹窗还是页面?

✅ 高效的对话方式:

我:需要设计一套登录系统,要求:
    1. 微信小程序环境
    2. 支持微信登录 + 手机号授权
    3. 新用户要引导填性别和年龄
    4. 任意页面都能触发登录弹窗
    5. 登录成功后能执行回调

这样 AI 能给出精准的方案,因为:

  • 明确了环境(小程序)
  • 明确了功能(微信登录 + 手机号)
  • 明确了流程(新用户引导)
  • 明确了交互(弹窗 + 回调)

2.2 对话的层次感

我发现最有效的对话是分层推进的:

第一轮:说清楚要做什么
我:需要封装一个通用的请求 Hook

第二轮:AI 询问细节,我补充
AI:需要支持哪些功能?immediate?初始数据?
我:需要立即执行选项,需要初始数据

第三轮:AI 给出设计,我确认
AI:我来设计接口结构...
我:开始吧

第四轮:AI 生成代码,我追问
AI:(生成代码)
我:为什么用 Promise 链式而不是 try-catch?

第五轮:AI 解释原理,我学到了
AI:两种写法对比...

这种层层递进的对话,比一次性给出"完美 Prompt"更有效,因为:

  1. 渐进明确:需求是在对话中逐步清晰的
  2. 双向确认:AI 的设计决策需要你确认
  3. 深度学习:追问"为什么"让你真正理解

2.3 AI 不是银弹

在实践中,我也发现了 AI 的局限:

📊 AI 擅长的事:

✅ 生成样板代码(CRUD、配置)
✅ 解释技术概念
✅ 分析代码问题
✅ 提供多种方案对比
✅ 重构和优化建议

📊 AI 不擅长的事:

❌ 理解业务上下文(需要你提供)
❌ 做产品决策(需要你判断)
❌ 处理边界情况(需要你验证)
❌ 了解项目历史(需要你说明)

核心认知:AI 是工具,不是替代品。你需要:

  • 清晰地描述需求
  • 评估 AI 给出的方案
  • 验证生成的代码
  • 理解背后的原理

三、效率提升的真实数据

3.1 开发效率对比

📊 单项任务耗时对比:

| 任务 | 传统方式 | AI 辅助 | 提升倍数 |
|------|---------|---------|----------|
| HTTP 封装 | 4 小时 | 1 小时 | 4x |
| 登录弹窗 | 8 小时 | 3 小时 | 2.7x |
| 组件封装 | 6 小时 | 2 小时 | 3x |
| Hooks 设计 | 4 小时 | 1.5 小时 | 2.7x |
| 错误处理 | 3 小时 | 1 小时 | 3x |
| 性能优化 | 6 小时 | 2 小时 | 3x |

3.2 效率提升的来源

📊 效率提升分析:

1. 减少"从零开始"的时间
   - 传统:Google 搜索 → 看文档 → 试错
   - AI:描述需求 → 获得可用代码 → 微调

2. 减少"踩坑"的时间
   - 传统:遇到问题 → Stack Overflow → 找答案
   - AI:描述问题 → 获得解决方案 → 理解原因

3. 减少"重复劳动"
   - 传统:复制粘贴 → 手动修改
   - AI:描述模式 → 批量生成

4. 加速"学习理解"
   - 传统:看源码 → 猜测用法
   - AI:问"为什么" → 获得解释

3.3 质量提升的体现

📊 代码质量对比:

【代码规范性】
- 一致的命名风格
- 完整的类型定义
- 合理的代码注释

【架构合理性】
- 清晰的分层设计
- 合理的职责划分
- 可扩展的接口设计

【可维护性】
- 抽象复用的组件
- 封装良好的 Hooks
- 统一的错误处理

四、最佳实践总结

4.1 项目结构最佳实践

📂 推荐的项目结构:

src/
├── api/              # API 接口定义
│   ├── user.ts
│   └── chat.ts
├── components/       # 通用组件
│   ├── XButton.vue
│   ├── Modal.vue
│   └── LoadingIndicator.vue
├── composables/      # 组合式函数
│   ├── useLoginFlow.ts
│   └── useSystemInfo.ts
├── hooks/            # 通用 Hooks
│   ├── useRequest.ts
│   └── useUpload.ts
├── http/             # HTTP 封装
│   ├── http.ts
│   ├── interceptor.ts
│   └── types.ts
├── pages/            # 页面
│   ├── index/
│   └── my/
├── store/            # 状态管理
│   ├── user.ts
│   └── loginModal.ts
├── subPackages/      # 分包
│   ├── vip/
│   └── agreement/
└── utils/            # 工具函数
    ├── toast.ts
    └── platform.ts

4.2 代码规范最佳实践

// ✅ 推荐的代码风格

// 1. 类型定义清晰
interface Props {
  text?: string;
  loading?: boolean;
  disabled?: boolean;
}

// 2. 默认值合理
const props = withDefaults(defineProps<Props>(), {
  text: '',
  loading: false,
  disabled: false,
});

// 3. 事件定义明确
const emit = defineEmits<{
  click: [];
  success: [data: any];
  error: [error: Error];
}>();

// 4. 计算属性缓存
const formattedData = computed(() =>
  rawData.value.map(item => ({
    ...item,
    displayName: formatName(item.name),
  }))
);

// 5. 错误处理完整
const handleSubmit = async () => {
  if (loading.value) return;

  loading.value = true;
  try {
    const res = await doSubmit();
    emit('success', res.data);
  } catch (error) {
    console.error('提交失败:', error);
    toast.error('提交失败,请重试');
  } finally {
    loading.value = false;
  }
};

4.3 AI 协作最佳实践

📋 AI 协作清单:

【开始前】
□ 明确功能需求
□ 了解技术约束
□ 准备上下文信息

【对话中】
□ 分层描述需求
□ 确认设计方案
□ 追问实现原理
□ 要求代码解释

【生成后】
□ 阅读理解代码
□ 验证功能正确
□ 检查边界情况
□ 优化代码细节

五、踩过的坑与解决方案

5.1 常见问题汇总

📊 开发中遇到的典型问题:

1. Token 获取位置
   ❌ 从 Store 获取(拦截器执行时 Store 未初始化)
   ✅ 从 Storage 获取

2. 响应式数据依赖
   ❌ 静态对象引用 store 数据
   ✅ 使用 computed 保持响应式

3. 枚举类型存储
   ❌ 字符串存储('男'/'女')
   ✅ 数字枚举(1/2)

4. 条件编译位置
   ❌ 运行时判断平台
   ✅ 使用 #ifdef 编译时判断

5. 组件职责边界
   ❌ 组件内处理业务逻辑
   ✅ 组件只负责 UI,业务逻辑在 Store/Service

5.2 避坑指南

// 1. Token 获取
// ❌ 错误
const { token } = userStore.userInfo;

// ✅ 正确
const token = uni.getStorageSync('token');

// 2. 响应式依赖
// ❌ 错误
const menuItems = {
  label: userStore.genderDisplay
};

// ✅ 正确
const menuItems = computed(() => ({
  label: userStore.genderDisplay
}));

// 3. 平台判断
// ❌ 错误
if (process.env.UNI_PLATFORM === 'mp-weixin') {
  // ...
}

// ✅ 正确
// #ifdef MP-WEIXIN
// 小程序专用代码
// #endif

六、对未来的思考

6.1 AI 辅助开发的趋势

📊 我的观察:

【现在】
- AI 生成代码片段
- 人工整合和调试
- 需要理解才能用

【未来可能】
- AI 理解整个项目上下文
- 自动化测试和修复
- 更智能的代码审查

【不变的是】
- 需求分析能力
- 架构设计能力
- 问题诊断能力

6.2 开发者的核心竞争力

📊 AI 时代的开发者能力:

1. 问题定义能力
   - 把模糊需求转化为清晰描述
   - 识别真正要解决的问题

2. 方案评估能力
   - 评估 AI 给出的多种方案
   - 选择最适合当前场景的

3. 架构设计能力
   - 理解系统整体结构
   - 做出合理的技术决策

4. 持续学习能力
   - 通过 AI 加速学习新技术
   - 保持技术敏感度

七、系列总结

7.1 本系列的价值

📚 这个系列想传达的:

1. AI 辅助开发是可行的
   - 不是概念,是实践
   - 有真实的效率提升

2. 对话比 Prompt 更重要
   - 不是"写好 Prompt 就行"
   - 而是"多轮对话逐步明确"

3. 理解比复制更重要
   - 不是"复制代码就完了"
   - 而是"理解原理才能用好"

4. 人的判断不可替代
   - AI 是工具,不是替代
   - 技术决策需要人来做

7.2 给读者的建议

📋 如果你想开始 AI 辅助开发:

1. 从小项目开始
   - 不要一开始就用于生产项目
   - 先在 Side Project 中积累经验

2. 保持学习心态
   - 每次对话都是学习机会
   - 追问"为什么"比"给我代码"更重要

3. 建立验证习惯
   - AI 生成的代码要验证
   - 边界情况要自己考虑

4. 积累对话模式
   - 总结有效的对话方式
   - 建立自己的"提问模板"

7.3 最后的话

心动恋聊从一个想法,到一个完整的小程序,
再到这 12 篇文章,是我和 AI 协作的一次深度实践。

我最大的感受是:
AI 没有让编程变得"不需要思考",
反而让我更清晰地思考"该怎么做"。

因为你需要:
- 清晰地描述需求
- 评估多种方案
- 理解生成的代码
- 验证实际效果

这些,都需要思考。

希望这个系列对你有帮助。
如果有问题,欢迎评论区交流!

系列完结!

12 篇文章,完整记录了心动恋聊小程序从 0 到 1 的开发过程。

这不是教你"如何写 Prompt",而是展示如何和 AI 协作解决实际问题

如果这个系列对你有帮助,请点赞、收藏、转发!

❌