阅读视图

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

Java和JavaScript的关系真是雷峰和雷峰塔的关系吗?

前端圈一直流传着一个经典段子:Java和JavaScript是什么关系?就是雷峰和雷峰塔的关系。听过后令人会心一笑。但静下来想想🤔,真是这样吗?

什么是雷峰和雷峰塔的关系

雷峰(人)和雷峰塔(建筑)的关系非常明确:除了名字读音相似之外,两者在血缘、历史、物理构成等任何维度上,都百分之百毫无关联。

那么,Java和Javascript是否只是名字有点相似,实则毫无关系呢?

Java和Javascript的关系

如果抛开段子,翻开真实的计算机史,你会发现Java和JavaScript不仅不是“毫无关系”,反而有着千丝万缕的渊源。

Javascript是Sun和Netscape联合发布的,Sun(现在是Oracle)是Javascript的商标持有者。

时间回到1995年,网景公司(Netscape)为了在浏览器里加入交互能力,搞出了一门脚本语言(最初叫Mocha,后改LiveScript)。当时Sun公司推出的Java语言正如日中天,被媒体炒作战无不胜的“神器”。网景为了蹭上这波热度,与Sun公司达成了战略合作,将这门语言正式更名为JavaScript
更硬核的事实是:直到今天,JavaScript的商标权依然掌握在Sun的继承者甲骨文(Oracle)手里。如果是毫无关系的两者,怎么可能共用一个具有法律效力的名字?

JavaScript就是按像Java设计的

网景公司在给语言改名的同时,也给开发者(Brendan Eich)提出了一个明确的需求:“让它的语法看起来像Java”。因此,JavaScript在诞生之初,大量借鉴了Java的基础语法结构。它的 if/else 分支、for 循环结构、try/catch 异常处理机制,甚至是 new 关键字的使用,看起来和Java几乎如出一辙。因此,JavaScript 不是巧合像,是故意设计成像 Java

Java中内置了JavaScript运行时

从JDK 6引入Rhino引擎,到JDK 8内置Nashorn引擎(后在JDK 15中移除),再到如今通过GraalVM JS等现代方案实现深度互操作,Java官方生态长期保持着对JavaScript运行时的支持。这意味着,你完全可以在Java程序里直接调用JavaScript代码,把它们当作业务中的“动态脚本层”。这绝不是两座毫无交集的孤岛,两者在运行层面长期深度集成。

Java脚本化后就是JavaScript的样子

如果说设计一门Java的脚本语言,要类似Java的语法,但是要脚本语言的特性,要能解释执行、方便灵活、宽松,还要高扩展性。那么设计出来就是JavaScript这个样子。

这是一个非常有趣的逻辑推导。假设1995年你需要为Java生态设计一门“附属脚本语言”,你的需求清单是这样的:

  • 融入Java生态:语法必须像Java;
  • 运行机制:不需要编译,直接解释执行,轻量级;
  • 类型系统:不能像Java那么严苛,要宽松、动态,写起来方便;
  • 高扩展性:面对复杂多变的Web环境,必须允许开发者随时往内置类中添加方法;

当你按照这份需求文档写出一门语言时,恭喜你,你重新发明了JavaScript。它从一出生,就是带着“ Java的轻量化脚本兄弟 ”这个定位来的。

JavaScript和Java是两门不同的语言,但是不代表毫无关系。

有些人觉得JavaScript还是不够 “像” Java,比如JavaScript的类的实现是基于原型链的,和Java类有本质不同。对此我想说,脚本语言和编译型的语言本来就是为不同场景设计的。JavaScript和Java的差异确实足够大,大到应当作为两门不同语言分别学习,但是不能否认它们的历史渊源。JavaScript和Java的差异更多的可以用不同场景设计来解释。比如原型链问题,在Java中,你的API更新了,你只要升级JDK;而浏览器环境上应当让内置类有较高的扩展性,原型链无疑是最优解,让你重新设计一遍Javascript你也会设计成这样。

总结

Java 和 JavaScript 并非毫无关系,把它俩比作雷锋和雷峰塔,实在是冤枉了这两门语言。它俩的关系更接近于 VB 和 VBScript 的关系:VB 是微软推出的完整版编译型语言,适合开发大型桌面应用,VBScript 则是基于 VB 语法设计的轻量级脚本语言,灵活简洁,用于自动化、网页脚本,二者语法同源、定位互补,是同体系下不同分工的语言。

微软推出JScript

故事的走向在浏览器大战时期变得更加复杂。微软为了让自家的Internet Explorer浏览器兼容已有网站,迅速搞出了一个 JScript。JScript虽然名字刻意避开了“Java”字眼,但就是为了兼容JavaScript而生的,可以看作微软的JavaScript。但由于JavaScript并非开放标准,微软是照着Netscape的JavaScript行为猜着做的,只能仿个大概,对边界场景可能存在不一致。

EcmaScript出现

为了推动Web标准化,1996年,网景将JavaScript提交给了欧洲计算机制造商协会(ECMA)进行标准化。第二年,ECMA出台了ECMA-262标准,这便是大名鼎鼎的 ECMAScript(简称ES)。

从此,技术界有了一个清晰的共识:JScript和JavaScript,本质上都只是ECMAScript标准的不同实现。 这个标准的诞生,把JavaScript从网景和微软的商业战中抽离出来,成为了一门真正开放的语言。

近年来ES朝着越来越不像Java的方向发展

随着Web标准化,ECMAScript已经不是Sun或Oracle可以控制的。如今ECMAScript的更新由TC39委员会主导,采用五阶段提案流程。如今ECMAScript的发展已经偏离的像Java的目的,比如Map/Set的API刻意避开了Java的命名规范。ES刻意都划清了界限。这导致JavaScript作为ES最核心的实现,如今越来越不像Java。

因此我更愿意把现在的js叫es,而不是否定Javascript和Java的关系

我知道有些人极度反感Java,甚至否定Javascript和Java的关系。对于这种掩耳盗铃的行为,我倒有个建议:既然这么嫌弃,不如彻底抛弃“JavaScript”这个带Java基因的名字,以后只准叫它ECMAScript。叫它ES,确实是对它如今独立设计哲学的最好宣告,证明它不再是任何人的附庸。也顺理成章地把“JavaScript”这个名字,留给那些真正需要“Java脚本化”的人。

浏览器文本复制到剪贴板:企业级最佳实践

1. 背景与需求分析

在 Web 开发中,复制文本到剪贴板是一个常见需求,比如:

  • 复制分享链接、邀请码
  • 复制代码片段
  • 一键复制表单内容

现代浏览器提供了 navigator.clipboard API,但存在兼容性和安全上下文的限制;传统的 document.execCommand('copy') 虽然兼容性更好,但使用方式较为繁琐。本质上,我们需要一个统一的工具函数来屏蔽这些差异。

2. API 介绍与演进

2.1 传统方案:document.execCommand

const textarea = document.createElement('textarea')
textarea.value = content
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)

优点:兼容性好,支持所有主流浏览器 缺点:需要创建临时 DOM 元素,代码冗长

2.2 现代方案:navigator.clipboard

await navigator.clipboard.writeText(content)

优点:简洁直观,直接操作剪贴板 缺点:需要安全上下文(HTTPS),部分浏览器支持受限

3. 核心实现解析

export interface CopyTextOptions {
  /** 是否允许复制空白内容(空字符串或纯空格),默认 false */
  allowWhitespace?: boolean
  /** 是否使用旧版复制方法(不支持空白内容复制),默认 false */
  legacy?: boolean
}

export interface CopyTextReturn {
  success: boolean
  message: string
}

export async function copyText(content: string, options: CopyTextOptions = {}): Promise<CopyTextReturn> {
  try {
    const { allowWhitespace = false, legacy = false } = options
    if (!allowWhitespace && (!content || content.trim() === '')) {
      return { success: false, message: '复制内容不能为空' }
    } else if (navigator.clipboard && window.isSecureContext && !legacy) {
      await navigator.clipboard.writeText(content)
    } else {
      const textarea = document.createElement('textarea')
      textarea.style.cssText = 'position:fixed; opacity:0; z-index:-9999; left:-9999px; top:-9999px;'
      textarea.value = content
      document.body.appendChild(textarea)
      textarea.select()
      textarea.setSelectionRange?.(0, content.length)
      const copied = document.execCommand('copy')
      document.body.removeChild(textarea)
      if (!copied) throw new Error('浏览器限制或无法复制')
    }
    return { success: true, message: '复制成功' }
  } catch (error: unknown) {
    const errMsg = error instanceof Error ? error.message : '未知错误'
    return { success: false, message: `${errMsg}` }
  }
}

关键逻辑说明

参数一:allowWhitespace

控制是否允许复制空白内容。默认 false 会过滤空字符串和纯空格内容,避免用户误操作。

参数二:legacy

强制使用传统 execCommand 方案。某些场景下(如在 iframe 内)可能需要降级处理。

优先级判断

navigator.clipboard 可用?
  └─ 是 → 判断 isSecureContext(安全上下文)
           └─ 是 → 使用现代 API
           └─ 否 → 降级到 execCommand
  └─ 否 → 降级到 execCommand

4. 兼容性处理策略

方案 兼容性 安全要求 代码复杂度
navigator.clipboard 现代浏览器 必须 HTTPS 简洁
execCommand 所有浏览器 较繁琐
// 降级逻辑核心代码
const textarea = document.createElement('textarea')
textarea.style.cssText = 'position:fixed; opacity:0; z-index:-9999; left:-9999px; top:-9999px;'
textarea.value = content
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange?.(0, content.length) // 兼容 iOS Safari
const copied = document.execCommand('copy')
document.body.removeChild(textarea)

iOS Safari 兼容要点setSelectionRange 在 iOS 设备上需要显式调用才能正确选中文本。

5. 安全上下文要求

navigator.clipboard 要求页面必须处于安全上下文:

  • HTTPS 协议
  • localhost 开发环境
  • Chrome Extension 内部页面

开发环境下通常没问题,但部署到生产环境务必确保使用 HTTPS,否则会自动降级到传统方案。

6. 使用场景与示例

6.1 基础用法

const result = await copyText('hello world')
if (result.success) {
  console.log('复制成功')
} else {
  console.error(result.message)
}

6.2 允许空白内容

// 复制可能为空的文本时
const result = await copyText(userInput, { allowWhitespace: true })

6.3 强制使用传统方案

// 在特殊场景下强制降级
const result = await copyText(content, { legacy: true })

6.4 集成提示组件

注释掉的 TipModal 部分可根据项目实际使用的 UI 库进行适配:

// Element Plus 示例
import { ElMessage } from 'element-plus'

if (!allowWhitespace && (!content || content.trim() === '')) {
  ElMessage.error('复制内容不能为空')
  return { success: false, message: '复制内容不能为空' }
}

// 复制成功后
ElMessage.success('复制成功')

7. 核心总结

copyText 函数的核心设计要点:

  • 自动降级:优先使用 navigator.clipboard,不支持时自动降级到 execCommand
  • 安全优先:判断 isSecureContext 确保在安全环境下使用现代 API
  • 灵活配置:通过 allowWhitespacelegacy 参数适配不同业务场景
  • 统一返回:返回 { success, message } 结构化结果,便于调用方处理

这个不到 50 行的工具函数覆盖了浏览器复制场景的绝大多数需求,可直接集成到项目中。

10_从 React Hooks 本质看 useState

一、Hooks 的本质

Hooks 是挂在 Fiber 上的一条“有序链表”,通过“调用顺序”来定位状态

每个函数组件对应一个 Fiber:

type Fiber = {
  memoizedState: Hook | null; // Hook 链表头
}

对于一个 Hook,有三种类型的 dispatcher(可以认为是操作策略):

/* 函数组件初始化用的 hooks */
// 初始化信息挂载到 fiber 上
const HooksDispatcherOnMount: Dispatcher = {
  ...
  useCallback: mountCallback,
  useEffect: mountEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState,
  ...
};

/* 函数组件更新用的 hooks */
// 组件更新执行对应的方法,更新 fiber 信息
const HooksDispatcherOnUpdate: Dispatcher = {
  ...
  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useMemo: updateMemo,
  useReducer: updateReducer,
  useRef: updateRef,
  useState: updateState,
  ...
};

/* 当 hooks 不是函数组件内部调用或者嵌套 hooks 等“非正确使用”情况,调用这些报错相关的 dispatcher */
const ContextOnlyDispatcher: Dispatcher = {
  ...
  useCallback: throwInvalidHookError,
  useContext: throwInvalidHookError,
  useEffect: throwInvalidHookError,
  useMemo: throwInvalidHookError,
  useReducer: throwInvalidHookError,
  useRef: throwInvalidHookError,
  useState: throwInvalidHookError,
  ...
};

二、Hook 的数据结构

type Hook = {
  memoizedState: any; // 当前值
  baseState: any;
  queue: UpdateQueue | null;
  next: Hook | null;
}

注意:这里要和 FiberNode 的 memoizedState 区分开:

  • FiberNode.memoizedState:保存的是 Hook 链表里面的第一个链表
  • hook.memoizedState:某个 Hook 自身的数据
Fiber.memoizedState
   ↓
[useState] → [useEffect] → [useMemo] → null

完全依赖调用顺序!

不同的 hook,memoizedState 所存储的内容不同:

  • useState:对于 const [state, updateState] = useState(initialState),memoizedState 保存的是 state 的值
  • useReducer:对于 const [state, dispatch] = useReducer(reducer, { } ),memoizedState 保存的是 state 的值
  • useEffect:对于 useEffect( callback, [...deps] ),memoizedState 保存的是 callback、[...deps] 等数据
  • useRef:对于 useRef(initialValue),memoizedState 保存的是 { current: initialValue}
  • useMemo:对于 useMemo( callback, [...deps] ),memoizedState 保存的是 [callback( )、[...deps]] 数据
  • useCallback:对于 u seCallback( callback, [...deps] ),memoizedState 保存的是 [callback、[...deps]] 数据
  • useContext:不需要 memoizedState 保存自身数据

三、执行流程(mount 阶段)

1️⃣ render 开始

function Component() {
  const [count, setCount] = useState(0);
}
// render 开始先执行
export function renderWithHooks(current, workInProgress, Component, props, secondArg, nextRenderLanes) {
  renderLanes = nextRenderLanes;
  currentlyRenderingFiber = workInProgress;

  // 每一次执行函数组件之前,先清空 FiberNode 状态 (用于存放 hooks 列表)
  workInProgress.memoizedState = null;
  // 清空更新队列(用于存放 effect 列表)
  workInProgress.updateQueue = null;
  // ...
  // 根据不同的组件状态初始化不同的 dispatcher 对象和上下文
  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;

  // 执行函数组件,所有的 hooks 将依次执行
  let children = Component(props, secondArg);

  // ...
  
  // 兜底
  finishRenderingHooks(current, workInProgress);
  return children;
}

function finishRenderingHooks(current, workInProgress) {
    // 防止 hooks 在不合规的情况下调用,如果调用直接报错
    ReactCurrentDispatcher.current = ContextOnlyDispatcher;
    // ...
}

2️⃣ mountState 做了什么

如果组件是挂载阶段:

function mountWorkInProgressHook() {
  const hook = {
    memoizedState: null,  // Hook 自身的状态
    baseState: null,
    baseQueue: null,
    queue: null, // hook 自身队列
    next: null, // next 指向下一个 hook
  };

  // 判断当前的 hook 是否是链表的第一个
  if (workInProgressHook === null) {
    // 如果当前组件的 Hook 链表为空,那么就将刚刚新建的 Hook 作为 Hook 链表的第一个节点(头结点) 
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // 果当前组件的 Hook 链表不为空,那么就将刚刚新建的 Hook 添加到 Hook 链表的末尾(作为尾结点)
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

function mountStateImpl(initialState) {
  // 获取 hook 对象
  const hook = mountWorkInProgressHook();
  
  //...
  
  // 初始化 memoizedState 
  hook.memoizedState = hook.baseState = initialState;
  const queue: UpdateQueue = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer, // useState 内置的 reducer
    lastRenderedState: (initialState: any),
  };
  // 初始化 queue
  hook.queue = queue;
  return hook;
}

function mountState(initialState) {
  // 获取 hook 对象
  const hook = mountStateImpl(initialState);
  const queue = hook.queue;
  const dispatch = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ));
  // 初始化 dispatch (dispatch 就是用来修改状态的方法)
  queue.dispatch = dispatch;
  // 返回 [当前状态, dispatch函数]
  return [hook.memoizedState, dispatch];
}

其实 useReducer 和 useState 非常像,在源码层面:

  1. mount 阶段:mountState 和 mountReducer 的大体流程是一样的。但是有一个区别,mountState 的 queue 里面的 lastRenderedReducer 对应的是 basicStateReducer,而 mountReducer 的 queue 里面的 lastRenderedReducer 对应的是开发者自己传入的 reducer,这里说明了一个问题,useState 的本质就是 useReducer 的一个简化版,只不过在 useState 内部,会有一个内置的 reducer
  2. update 阶段:在 update 阶段,updateState 内部直接调用的就是 updateReducer,传入的 reducer 仍然是 basicStateReducer。
function mountReducer(reducer, initialArg, init) {
  // 创建 hook 对象
  const hook = mountWorkInProgressHook();
  let initialState;
  // 如果有 init 初始化函数,就执行该函数,并将执行的结果赋值给 initialState
  if (init !== undefined) {
    initialState = init(initialArg);
  } else {
    initialState = initialArg;
  }
  // 赋值给 hook 对象的 memoizedState
  hook.memoizedState = hook.baseState = initialState;

  const queue = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: reducer, // 手动传入的 reducer
    lastRenderedState: initialState,
  };
  hook.queue = queue;
  const dispatch = (queue.dispatch = dispatchReducerAction.bind(
    null,
    currentlyRenderingFiber,
    queue
  ));
  return [hook.memoizedState, dispatch];
}

3️⃣ 构建 Hook 链表

第一次 render:

Fiber.memoizedState → Hook1 → Hook2 → Hook3

举个例子~

该示例来源:渡一教育。

function App() {
  const [number, setNumber] = React.useState(0); // 第一个hook
  const [num, setNum] = React.useState(1); // 第二个hook
  const dom = React.useRef(null); // 第三个hook
  React.useEffect(() => {
    // 第四个
    hookconsole.log(dom.current);
  }, []);
  return (
    <div ref={dom}>
    <div onClick={() => setNumber(number + 1)}> {number} </div>
    <div onClick={() => setNum(num + 1)}> {num}</div></div>
  );
}

四、更新阶段(update)

不再创建 Hook,而是“复用”

function updateWorkInProgressHook(){
  let nextCurrentHook: null | Hook;
  if (currentHook === null) {
    // 从 alternate 上获取到 fiber 对象
    const current = currentlyRenderingFiber.alternate;
    
    // 获取第一个 hook
    if (current !== null) {
      nextCurrentHook = current.memoizedState;
    } else {
      nextCurrentHook = null;
    }
  } else {
    // 获取下一次 hook
    nextCurrentHook = currentHook.next;
  }

  // workInProgressHook 会指向下一个要工作的 hook
  let nextWorkInProgressHook: null | Hook;
  if (workInProgressHook === null) {
    nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;
  } else {
    nextWorkInProgressHook = workInProgressHook.next;
  }

  if (nextWorkInProgressHook !== null) {
    // 已经存在,直接复用
    workInProgressHook = nextWorkInProgressHook;
    nextWorkInProgressHook = workInProgressHook.next;
    currentHook = nextCurrentHook;
  } else {
    // Clone from the current hook.
    // 如果 nextWorkInProgressHook 不为 null,那么就会复用之前的 hook
    // 划重点!!!
    // 更新的过程中,如果通过条件语句增加或者删除了 hook,复用的时候就会产生当前 hook 的顺序和之前 hook 的顺序不一致的问题
    if (nextCurrentHook === null) {
      const currentFiber = currentlyRenderingFiber.alternate;
      if (currentFiber === null) {
        // This is the initial render. This branch is reached when the component
        // suspends, resumes, then renders an additional hook.
        // Should never be reached because we should switch to the mount dispatcher first.
        throw new Error(
          'Update hook called on initial render. This is likely a bug in React. Please file an issue.',
        );
      } else {
        // This is an update. We should always have a current hook.
        throw new Error('Rendered more hooks than during the previous render.');
      }
    }

    currentHook = nextCurrentHook;

    const newHook: Hook = {
      memoizedState: currentHook.memoizedState,

      baseState: currentHook.baseState,
      baseQueue: currentHook.baseQueue,
      queue: currentHook.queue,

      next: null,
    };

    if (workInProgressHook === null) {
      // This is the first hook in the list.
      currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
    } else {
      // Append to the end of the list.
      workInProgressHook = workInProgressHook.next = newHook;
    }
  }
  return workInProgressHook;
}

function updateReducer() {
  const hook = updateWorkInProgressHook();
  return updateReducerImpl(hook, ((currentHook)), reducer);
}

function updateState<S>(initialState) {
  return updateReducer(basicStateReducer, initialState);
}

接着上面的示例~

示例来源:渡一教育。

function App({ showNumber }) {
  let number, setNumber
  showNumber && ([ number,setNumber ] = React.useState(0)) // 第一个hooks
  const [num, setNum] = React.useState(1); // 第二个hook
  const dom = React.useRef(null); // 第三个hook
  React.useEffect(() => {
    // 第四个hook
    console.log(dom.current);
  }, []);
  return (
    <div ref={dom}>
    <div onClick={() => setNumber(number + 1)}> {number} </div>
    <div onClick={() => setNum(num + 1)}> {num}</div></div>
  );
}

假设第一次父组件传递过来的 showNumber 为 true,此时就会渲染第一个 hook;第二次渲染的时候,假设父组件传递过来的是 false,那么第一个 hook 就不会执行,那么逻辑就会变得:

第一次:useState -> useState

第二次:useState -> useRef

体现在我们开发者眼中就是报错。

五、setState 到底做了什么?

dispatch 流程

function dispatchSetState(action) {
  const update = {
    action,
    next: null
  };

  enqueueUpdate(queue, update);

  scheduleUpdateOnFiber(fiber);
}

UpdateQueue 结构

hook.queue
   ↓
update1 → update2 → update3(环形链表)

执行更新

function processUpdateQueue(queue) {
  let state = baseState;

  queue.forEach(update => {
    state = reducer(state, update.action);
  });

  return state;
}

六、调度机制(Hooks 如何触发更新)

scheduleUpdateOnFiber(fiber)
setState
   ↓
scheduleUpdate
   ↓
标记 lane(优先级)
   ↓
render(可中断)
   ↓
commit(不可中断)

Hooks 如何保证并发下的 hooks 行为正确?

关键:

  • 每次 render 都重新走一遍 Hook 链
  • 不依赖“执行次数”,只依赖“顺序”

Prisma 实战指南:像搭积木一样设计古诗词数据库

Prisma 实战指南:像搭积木一样设计古诗词数据库

在传统后端开发中,与数据库打交道往往意味着要编写大量晦涩的 SQL 语句。而 Prisma 就像一位精通多国语言的“翻译官”,它通过 ORM(对象关系映射)技术,将数据库的表映射为代码中的类,将行映射为实例。你不再需要手写 INSERTSELECT,只需像操作普通对象一样 createfindMany,Prisma 就会在幕后为你翻译成精准的 SQL。

接下来,我们就结合一个“古诗词社区”的实际项目,从零开始体验 Prisma 的魅力。

一、环境搭建与初始化

首先,我们需要为项目安装 Prisma 的核心依赖。建议锁定版本以避免兼容性问题:
pnpm i prisma@6.19.2
pnpm i @prisma/client@6.19.2

依赖安装完毕后,执行 npx prisma init。这条命令会为你生成两个关键文件:.env(存放环境变量)和 prisma/schema.prisma(数据库设计蓝图)。

打开 .env,填入你的 PostgreSQL 连接字符串,例如:
DATABASE_URL="postgresql://postgres:369369@localhost:5432/xue?schema=public"

二、Schema 设计:绘制数据库蓝图

schema.prisma 是整个 ORM 的灵魂。在这个文件中,我们通过 model 来定义数据表。让我们结合古诗词项目的实际设计,看看几个核心模型是如何构建的:

1. 基础配置与用户模型
文件头部定义了生成器和数据源,告诉 Prisma 我们要生成 JS 客户端并连接 PostgreSQL。

generator client {
  provider = "prisma-client-js"
}
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
model User {
  id        Int      @id @default(autoincrement())
  name      String   @unique @db.VarChar(255)
  password  String   @db.VarChar(255)
  // 使用 @map 将驼峰字段映射为数据库的下划线命名
  createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
  updatedAt DateTime? @default(now()) @map("updated_at") @db.Timestamptz(6)
  // 一对多关系:一个用户可以有多篇文章、评论、点赞等
  posts     Post[]
  comments  Comment[]
  likes     UserLikePost[] 
  files     File[]
  avatars   Avatar[]
  @@map("user") // 将表名映射为单数 user
}

2. 核心业务与级联策略
Post(诗词文章)模型中,我们看到了外键关联与删除策略的精妙配合:

model Post {
  id       Int     @id @default(autoincrement())
  title    String  @db.VarChar(255)
  content  String? @db.Text
  userId   Int?             
  // 关联 User,并设置 onDelete: SetNull
  // 意为:如果作者被删除,文章保留但作者ID置空
  user     User?   @relation(fields: [userId], references: [id], onDelete: SetNull) 
  comments Comment[]
  tags     PostTag[]
  @@index([userId]) // 为外键添加索引,提升查询效率
  @@map("posts")
}

3. 复杂关联:自关联与复合主键
古诗词社区少不了评论互动与标签分类,这里用到了两个高级技巧:

  • 自关联(评论回复) :在 Comment 模型中,通过 parentId@relation("CommmentToComment") 实现了评论的层级回复(父评论与子评论)。
  • 复合主键(多对多中间表)PostTag(文章标签)和 UserLikePost(用户点赞)作为中间表,使用 @@id([postId, tagId]) 定义了复合主键。这确保了“一篇文章不能被重复打同一个标签”以及“一个用户不能重复点赞同一篇文章”的业务逻辑。

三、迁移与可视化:让设计落地

设计好 Schema 后,我们需要将其同步到真实的数据库中。

  1. 数据迁移:执行 npx prisma migrate dev --name init_user。Prisma 会自动对比当前数据库结构,生成 SQL 迁移文件并执行,同时在数据库中记录版本日志。这不仅方便团队协作,也方便后续的版本回滚。
  2. 可视化操作:执行 npx prisma studio。这会打开一个精美的图形化界面,你可以在浏览器中直观地查看 UserPost 等表的数据,甚至手动添加测试数据(Seeds),完全告别黑乎乎的命令行。

四、代码操作:告别 SQL

当一切准备就绪,你就可以在代码中通过 Prisma Client 优雅地操作数据了。例如,查询李白发布的所有诗词:

const libaiPosts = await prisma.post.findMany({
  where: { user: { name: 'libai' } },
  include: { tags: true } // 顺带查出文章标签
});

从安装配置到模型设计,再到最终的代码调用,Prisma 用类型安全和高度抽象的 API,将开发者从繁琐的 SQL 中彻底解放了出来。

你的网页慢,用户不说直接走——前端性能监控教你“读心术”

你上线了一个页面,自认为飞快。但用户那边转圈转了三秒,走了。你浑然不知。今天我们来装一套“网页心电图仪”——前端性能监控。它能告诉你:用户打开你的网站,到底有多卡?哪里卡?卡的人多不多?不用等用户骂你,你就知道该优化哪了。

前言

性能优化不是“我觉得快”,而是“数据证明快”。没有监控的优化,就像闭着眼睛射箭——中了是运气,脱靶是常态。

Google 定义了三个核心指标(Core Web Vitals):LCP(加载速度)、FID(交互响应)、CLS(视觉稳定)。加上我们自己业务关心的指标(比如首屏时间、API耗时),组合起来就是你的“网页健康报告”。

今天我们就来搭一套轻量级前端性能监控,从采集到上报,再到报警,一条龙。

一、三大核心指标:你的网页“体检三项”

LCP(最大内容绘制):加载速度的“裁判”

LCP 测量页面主要内容(比如大图、标题、视频)加载完成的时间。理想值:2.5秒以内

什么算“主要内容”?就是用户第一眼看到的那个最大的元素。可能是背景图,可能是大标题,也可能是视频封面。

FID(首次输入延迟):交互响应的“秒表”

用户第一次点击、触摸或按键,到浏览器真正开始处理的时间。理想值:100毫秒以内

如果你的JS主线程被长任务阻塞,用户点了按钮没反应,FID就会高。用户会觉得“这网站卡死了”。

CLS(累计布局偏移):视觉稳定的“防抖测试”

页面加载过程中,元素突然位移(比如图片加载出来把按钮挤下去了)。理想值:0.1以内

CLS 高,用户容易点错按钮,比如本来要点“购买”,结果图片加载完,按钮被挤开,点到了“不感兴趣”。

二、怎么采集这些指标?用 web-vitals

Google 官方提供了 web-vitals 库,几行代码就能拿到 LCP、FID、CLS。

npm install web-vitals
import { getLCP, getFID, getCLS } from 'web-vitals';

function sendToAnalytics({ name, value, id }) {
  // 上报到你的后端或第三方服务
  navigator.sendBeacon('/api/perf', JSON.stringify({ name, value, id }));
}

getLCP(sendToAnalytics);
getFID(sendToAnalytics);
getCLS(sendToAnalytics);

注意:这些指标需要在页面加载完成后才能拿到,而且可能会多次更新(比如CLS会在整个页面生命周期中变化)。你可以选择只上报最终值,或者每次变化都上报(去重)。

三、其他重要指标:你自己更关心什么?

  • TTFB(首字节时间):从请求到服务器返回第一个字节。影响LCP,但更偏后端。
  • DOM Ready / Load 时间:传统指标,用于对比。
  • 首屏时间(自定义):比如你的页面有一个“主要内容区”,可以通过 MutationObserver 监听该区域出现的时间。
// 手动打点
const start = performance.now();
// 某个关键组件渲染完成后
const end = performance.now();
report('custom:firstContent', end - start);
  • API 响应时间:在 axios 拦截器里记录每个接口耗时。

四、上报策略:别把服务器打满

性能指标上报不能像打点日志那么频繁。策略:

  • 只上报一部分用户(采样),比如 10%。用随机数或用用户ID哈希。
  • 批量上报:收集多个指标,页面关闭前一次性发走(用 sendBeacon)。
  • 避免阻塞:用 requestIdleCallbacksetTimeout 低优先级上报。
if (Math.random() > 0.9) { // 10%采样
  navigator.sendBeacon('/api/perf', JSON.stringify(data));
}

五、报警与可视化:指标变差,立刻知道

光采集不上报等于没采。你需要一个后端接收数据,然后做可视化+报警。

  • 自建:用 Node + ClickHouse(或 InfluxDB)存储,Grafana 展示,设置阈值报警(比如 LCP P95 > 3s 发钉钉)。
  • 第三方Sentry(也能做性能监控)、DataDogGoogle Analytics(有 Core Web Vitals 报告)、阿里云ARMS

最简单的起步:把数据发到 Google Analytics(GA4),它有现成的 Web Vitals 报告。

import { getCLS, getFID, getLCP } from 'web-vitals';
import ga4 from 'react-ga4';

function sendToGA({ name, value, id }) {
  ga4.event('web_vitals', {
    event_category: 'Web Vitals',
    event_label: id,
    value: Math.round(name === 'CLS' ? value * 1000 : value),
    non_interaction: true,
  });
}

六、实战:完整的前端性能监控 SDK(简化版)

class PerfMonitor {
  constructor(options) {
    this.endpoint = options.endpoint;
    this.sampleRate = options.sampleRate || 0.1;
    this.init();
  }
  shouldReport() {
    return Math.random() < this.sampleRate;
  }
  send(data) {
    if (!this.shouldReport()) return;
    navigator.sendBeacon(this.endpoint, JSON.stringify(data));
  }
  init() {
    // Web Vitals
    import('web-vitals').then(({ getLCP, getFID, getCLS }) => {
      getLCS(metric => this.send(metric));
      getFID(metric => this.send(metric));
      getCLS(metric => this.send(metric));
    });
    // 自定义首屏时间
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', () => {
        this.send({ type: 'domReady', value: performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart });
      });
    }
    // 页面卸载时发送未发送的数据(可用Beacon队列)
  }
}
new PerfMonitor({ endpoint: '/api/perf', sampleRate: 0.1 });

七、常见坑点

  • CLS 在后台标签页不准确:用户切换标签页时,CLS 可能会误报。只在页面可见时收集。
  • 移动端 vs PC:指标分开统计,因为网络和设备差异大。
  • 缓存影响:已缓存的页面 LCP 会很快,应该区分首次访问和二次访问。

八、总结:让数据驱动你的优化

  • 监控 LCP、FID、CLS,用 web-vitals
  • 加上业务自定义指标(首屏时间、API 耗时)。
  • 采样上报,避免压力过大。
  • 用 GA4 或自建系统可视化+报警。
  • 定期查看指标趋势,倒退时立刻优化。

有了性能监控,你不再是“我觉得快”,而是“数据证明快”。老板问要不要优化,你甩出图表:“LCP 最近一周从2.1秒涨到3.5秒,用户流失率上升5%,建议立即优化图片。” 这才叫专业。

LeetCode 72. 编辑距离:动态规划经典题解

刷LeetCode中等题时,编辑距离绝对是动态规划的经典代表作——它看似复杂,三种操作(插入、删除、替换)让人无从下手,但只要理清状态定义和转移逻辑,就能轻松破解。今天就带大家一步步拆解这道题,从题意分析到代码实现,把每一个细节讲透。

一、题目解读:到底要解决什么问题?

题目很简洁:给定两个单词word1和word2,返回将word1转换成word2所使用的最少操作数

允许的三种操作(对一个单词进行):

  • 插入一个字符:比如word1是“abc”,word2是“abdc”,可以在word1的b和c之间插入d,完成转换。

  • 删除一个字符:比如word1是“abcd”,word2是“abc”,删除word1的d即可。

  • 替换一个字符:比如word1是“abc”,word2是“adc”,将word1的b替换成d即可。

核心难点:三种操作可以任意组合,如何找到“最少步骤”?—— 动态规划的核心就是“最优子结构”,把大问题拆成小问题,逐个解决。

二、动态规划思路拆解(核心部分)

动态规划解题,关键就3步:定义dp数组、确定边界条件、推导状态转移方程。我们一步步来:

1. 定义dp数组

设word1的长度为n,word2的长度为m,定义dp[i][j]:将word1前i个字符(word1[0..i-1])转换成word2前j个字符(word2[0..j-1])所需的最少操作数。

为什么是i-1和j-1?因为dp数组的下标从0开始,而字符串的下标也从0开始,dp[0][0]表示两个空串的转换,dp[1][1]才对应两个单词的第一个字符,这样定义更直观,避免下标混乱。

2. 确定边界条件

边界情况就是“其中一个单词为空串”的场景:

  • 如果word1为空(i=0),那么要转换成word2前j个字符,只能不断插入j个字符,所以dp[0][j] = j。

  • 如果word2为空(j=0),那么要转换成空串,只能不断删除word1前i个字符,所以dp[i][0] = i。

比如dp[0][3] = 3(空串转成3个字符,插入3次),dp[2][0] = 2(2个字符转成空串,删除2次)。

3. 推导状态转移方程

这是最关键的一步,我们分两种情况讨论:word1的第i个字符(word1[i-1])和word2的第j个字符(word2[j-1])是否相等。

情况1:word1[i-1] == word2[j-1]

此时,两个字符已经匹配,不需要做任何操作,最少操作数就等于“word1前i-1个字符转word2前j-1个字符”的操作数,即:

dp[i][j] = dp[i-1][j-1]

比如word1是“abc”,word2是“adc”,当i=3、j=3时,word1[2] = c,word2[2] = c,此时dp[3][3] = dp[2][2]。

情况2:word1[i-1] != word2[j-1]

此时需要进行插入、删除、替换三种操作中的一种,取这三种操作的最少步骤即可,对应三个方向的状态:

  • 删除操作:删除word1的第i个字符,此时dp[i][j] = dp[i-1][j] + 1(删除1次,加上之前的操作数)。

  • 插入操作:在word1的第i个字符后插入一个和word2[j-1]相同的字符,此时dp[i][j] = dp[i][j-1] + 1(插入1次,加上之前的操作数)。

  • 替换操作:将word1的第i个字符替换成word2[j-1],此时dp[i][j] = dp[i-1][j-1] + 1(替换1次,加上之前的操作数)。

所以,状态转移方程为:

dp[i][j] = Math.min(dp[i-1][j] + 1, dp[i][j-1] + 1, dp[i-1][j-1] + 1)

三、完整代码实现(TypeScript)

结合上面的思路,直接上代码,每一行都加了详细注释,对应我们拆解的逻辑:

function minDistance(word1: string, word2: string): number {
  let n = word1.length; // word1的长度
  let m = word2.length; // word2的长度

  // 边界情况:有一个字符串为空串,操作数就是另一个字符串的长度
  if (n * m == 0) {
    return n + m;
  }

  // 初始化dp数组:n+1行(word1的0~n个字符),m+1列(word2的0~m个字符)
  const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1));

  // 边界状态初始化:word2为空时,dp[i][0] = i(删除i次)
  for (let i = 0; i < n + 1; i++) {
    dp[i][0] = i;
  }
  // 边界状态初始化:word1为空时,dp[0][j] = j(插入j次)
  for (let j = 0; j< m + 1; j++) {
    dp[0][j] = j;
  }

  // 填充dp数组:遍历所有i和j,计算每个dp[i][j]的值
  for (let i = 1; i < n + 1; i++) {
    for (let j = 1; j < m + 1; j++) {
      // 左:删除操作,dp[i-1][j] + 1
      let left = dp[i - 1][j] + 1;
      // 下:插入操作,dp[i][j-1] + 1
      let down = dp[i][j - 1] + 1;
      // 左下:替换/不操作,先取dp[i-1][j-1]
      let left_down = dp[i - 1][j - 1];
      // 字符不相等时,替换操作需要+1
      if (word1.charAt(i - 1) != word2.charAt(j - 1)) {
        left_down += 1;
      }
      // 取三种操作的最小值,赋值给dp[i][j]
      dp[i][j] = Math.min(left, Math.min(down, left_down));
    }
  }

  // dp[n][m]就是word1完整转word2的最少操作数
  return dp[n][m];
};

四、代码解析与易错点提醒

1. 易错点1:边界条件的判断

当n*m == 0时,说明其中一个字符串长度为0,此时直接返回n+m即可,不用再初始化dp数组,节省时间。比如word1为空,word2长度为5,返回5(插入5次)。

2. 易错点2:dp数组的初始化

dp数组的长度是n+1和m+1,因为要包含“0个字符”的情况,比如dp[0][0] = 0(两个空串,无需操作)。

3. 易错点3:字符串下标与dp数组下标的对应

dp[i][j]对应word1[0..i-1]和word2[0..j-1],所以取字符时要用word1.charAt(i-1)和word2.charAt(j-1),千万别写成i和j,否则会越界。

五、总结:这道题的核心价值

编辑距离是动态规划的经典应用,它的核心思想是“用子问题的最优解推导原问题的最优解”。这道题的关键不是记住代码,而是理解:

  • 如何定义dp数组,让它能表示“子问题的最优解”;

  • 如何根据题意,找到边界条件(极端情况);

  • 如何分析不同场景,推导状态转移方程。

学会这道题后,再遇到类似的“最少操作”“最优路径”类动态规划题,思路会清晰很多。比如字符串匹配、最长公共子序列等,都能用到类似的思维。

被The Graph的GraphQL查询坑了三天,我用一个真实DeFi项目把链上数据索引彻底搞懂了

背景

上个月,我在给一个跨链DeFi协议做前端仪表盘。需求很简单:用户登录后,能看到他在以太坊、Polygon和Arbitrum三条链上的所有交易记录、当前质押的LP代币数量和历史收益。最开始我直接用ethers.js的provider.getLogsgetBalance去拉数据,结果发现三个问题:

  1. 每次切换链都要等5-10秒才能刷出数据,用户体验极差
  2. 用户交易一多(超过1000条),前端直接卡死,因为RPC节点一次只返回2000条日志,我得写递归去翻页
  3. 以太坊主网的RPC限流,免费Infura节点一分钟只能请求100次,用户多的时候直接报429

我当时想:不行,必须换个方案。正好团队后端同事提了一嘴The Graph,说可以自己搭子图来索引链上数据。我一开始以为就是把RPC换成GraphQL调用,结果一上手才发现水有多深——从子图定义、映射器写法到前端分页和实时更新,每个环节都有坑。

这篇文章就是我把整个流程走通后的完整记录,希望能帮到同样被链上数据查询折磨的你。

问题分析

最初的思路:直接用RPC + 前端缓存

我的第一版方案是:用ethers.js的getLogs拉取所有Transfer事件,然后在浏览器用localStorage缓存。但很快发现:

  • 以太坊主网一个地址可能有几千笔交易,getLogs一次最多返回2000条,我得写递归循环,每页等1-2秒
  • 跨链时,每个链的RPC节点不同,缓存逻辑要分开写,代码变得极其臃肿
  • 最致命的是:历史收益数据(比如用户某天质押了多少LP)需要聚合计算,RPC返回的是原始事件,我得在前端做大量运算,导致页面卡顿

为什么选择The Graph

The Graph本质上是把链上事件索引到PostgreSQL数据库里,然后通过GraphQL接口提供查询。好处是:

  • 索引完的数据查询速度在毫秒级,比RPC快10倍以上
  • 支持复杂过滤和聚合计算(比如按时间范围统计),这些运算在子图层面完成,前端只需拿结果
  • 有托管服务(Hosted Service)和去中心化网络,不需要自己维护服务器

但坏处是:需要写子图定义(schema.graphql)和映射器(mapping.ts),对前端开发者来说是个新领域。

核心实现:从子图搭建到前端查询

第一步:搭建本地子图开发环境

我第一次踩的坑就是直接在Hosted Service上部署,结果每次改映射器都要等10分钟同步。后来发现应该先在本地跑Graph Node。

# 安装Graph CLI
npm install -g @graphprotocol/graph-cli

# 初始化子图项目(选择以太坊主网)
graph init --product subgraph-studio
# 输入子图名称、合约地址等

初始化后生成的项目结构:

my-subgraph/
├── schema.graphql   # 定义数据模型
├── src/
│   └── mapping.ts   # 事件处理逻辑
├── subgraph.yaml    # 配置文件
└── package.json

这里有个坑graph init会让你选网络,如果选mainnet,它会自动用以太坊主网的RPC。但本地开发时最好用hardhatganache本地节点,或者用测试网。我当时选了mainnet,结果第一次同步花了3小时,因为要扫描整个链的历史事件。

最终方案:用graph init --product subgraph-studio后,手动修改subgraph.yaml里的dataSources.source.addressnetwork为测试网地址,比如Goerli。

第二步:定义数据模型(schema.graphql)

我的需求是记录用户的交易和质押信息。模型设计直接决定了查询效率。

# schema.graphql
type User @entity {
  id: ID!  # 用户地址
  transactions: [Transaction!] @derivedFrom(field: "user")
  totalStaked: BigInt!
  totalRewards: BigInt!
  lastUpdated: BigInt!
}

type Transaction @entity {
  id: ID!  # 交易哈希
  user: User!
  type: String!  # "deposit", "withdraw", "swap"
  amount: BigInt!
  token: Bytes!
  timestamp: BigInt!
  blockNumber: Int!
}

type DailyStats @entity {
  id: ID!  # 格式: "userAddress-dayTimestamp"
  user: User!
  date: BigInt!
  depositCount: Int!
  withdrawCount: Int!
  totalVolume: BigInt!
}

设计思路

  • User实体是核心,关联transactionsDailyStats
  • DailyStatsuserAddress-dayTimestamp作为ID,这样查询某用户某天的统计时直接get即可
  • 所有时间字段用BigInt存储(solidity的uint256),前端再转成Date

第三步:编写映射器(mapping.ts)

映射器是子图的核心,把链上事件转换成数据模型。这里我踩了一个大坑:映射器里不能做异步操作,比如不能用fetch请求外部API。

// src/mapping.ts
import { BigInt, Bytes } from "@graphprotocol/graph-ts"
import { 
  Transfer, 
  Deposit, 
  Withdraw 
} from "../generated/MyContract/MyContract"
import { User, Transaction, DailyStats } from "../generated/schema"

export function handleTransfer(event: Transfer): void {
  // 更新发送方和接收方的余额
  updateUserBalance(event.params.from, event.params.value.neg())
  updateUserBalance(event.params.to, event.params.value)
  
  // 记录交易
  let transaction = new Transaction(event.transaction.hash.toHex())
  transaction.user = event.params.from
  transaction.type = "transfer"
  transaction.amount = event.params.value
  transaction.token = event.address
  transaction.timestamp = event.block.timestamp
  transaction.blockNumber = event.block.number.toI32()
  transaction.save()
}

function updateUserBalance(address: Bytes, amount: BigInt): void {
  let userId = address.toHex()
  let user = User.load(userId)
  
  if (user == null) {
    user = new User(userId)
    user.totalStaked = BigInt.fromI32(0)
    user.totalRewards = BigInt.fromI32(0)
    user.lastUpdated = BigInt.fromI32(0)
  }
  
  user.totalStaked = user.totalStaked.plus(amount)
  user.lastUpdated = event.block.timestamp
  user.save()
}

这里有个坑User.load()User.save()在映射器里是同步的,但每次调用都会产生数据库读写。如果一笔交易涉及多个用户(比如Transfer事件有from和to),要避免重复加载同一个用户。我一开始没注意,导致同一个用户被加载两次,数据覆盖了。

解决办法:在handleTransfer里先检查两个地址是否相同,如果相同(自己转给自己),只更新一次。

第四步:部署子图并获取API URL

本地测试通过后,部署到The Graph的托管服务:

# 1. 在The Graph Studio创建子图
# 2. 获取部署密钥
graph auth --product subgraph-studio <YOUR_KEY>

# 3. 部署
graph deploy --product subgraph-studio <SUBGRAPH_NAME>

部署成功后,会得到一个API URL,类似: https://api.studio.thegraph.com/query/12345/my-subgraph/v0.0.1

注意:每次部署都会生成新版本,前端用的URL要更新版本号。我建议在环境变量里配置,方便切换。

第五步:前端接入Apollo Client

前端我用的是React + TypeScript + Apollo Client。这里有个关键点:Apollo默认的缓存策略会导致数据不更新,因为子图索引有延迟(通常几秒到几分钟)。

// graphql/queries.ts
import { gql } from "@apollo/client"

// 查询用户交易记录,支持分页
export const GET_USER_TRANSACTIONS = gql`
  query GetUserTransactions(
    $user: String!
    $first: Int!
    $skip: Int!
    $orderDirection: String!
  ) {
    transactions(
      where: { user: $user }
      first: $first
      skip: $skip
      orderBy: timestamp
      orderDirection: $orderDirection
    ) {
      id
      type
      amount
      token
      timestamp
      blockNumber
    }
  }
`

// 查询用户每日统计
export const GET_USER_DAILY_STATS = gql`
  query GetUserDailyStats(
    $user: String!
    $fromDate: BigInt!
    $toDate: BigInt!
  ) {
    dailyStats(
      where: { 
        user: $user
        date_gte: $fromDate
        date_lte: $toDate
      }
      orderBy: date
      orderDirection: asc
    ) {
      id
      date
      depositCount
      withdrawCount
      totalVolume
    }
  }
`
// hooks/useTransactions.ts
import { useQuery } from "@apollo/client"
import { GET_USER_TRANSACTIONS } from "../graphql/queries"

export function useTransactions(userAddress: string, page: number, pageSize: number = 20) {
  const { data, loading, error, refetch } = useQuery(GET_USER_TRANSACTIONS, {
    variables: {
      user: userAddress.toLowerCase(),  // 注意:地址必须小写!
      first: pageSize,
      skip: (page - 1) * pageSize,
      orderDirection: "desc"
    },
    // 关键:设置轮询,每30秒刷新一次,应对子图索引延迟
    pollInterval: 30000,
    // 关闭缓存,保证每次查询都从网络获取最新数据
    fetchPolicy: "network-only"
  })

  return {
    transactions: data?.transactions || [],
    loading,
    error,
    refetch
  }
}

这里有个坑:The Graph的查询中,地址字段必须是小写。如果用户输入的地址是大写或有校验和(EIP-55),查询会返回空结果。我一开始没做.toLowerCase(),debug了两小时才发现。

第六步:处理索引延迟和实时更新

子图索引不是实时的,通常有2-30秒的延迟。这意味着用户刚发起一笔交易,前端可能查不到。

我的解决方案是混合策略

  1. 对于历史数据(超过1分钟的交易),直接走The Graph查询
  2. 对于刚发生的交易(用户通过钱包确认后),先用ethers.js监听事件,等确认后再触发子图刷新
// hooks/useRealtimeTransactions.ts
import { useContractEvent } from "wagmi"
import { useTransactions } from "./useTransactions"

export function useRealtimeTransactions(userAddress: string) {
  const { transactions, loading, refetch } = useTransactions(userAddress, 1, 50)
  
  // 监听合约的Transfer事件
  useContractEvent({
    address: contractAddress,
    abi: contractABI,
    eventName: "Transfer",
    listener(from, to, value) {
      // 如果事件涉及当前用户,触发子图刷新
      if (from.toLowerCase() === userAddress.toLowerCase() || 
          to.toLowerCase() === userAddress.toLowerCase()) {
        // 延迟3秒,给子图索引留时间
        setTimeout(() => refetch(), 3000)
      }
    },
  })

  return { transactions, loading }
}

完整代码(可直接复制运行)

以下是一个完整的React组件,展示用户交易列表,支持分页和实时更新:

// components/TransactionList.tsx
import React, { useState } from "react"
import { useAccount } from "wagmi"
import { useRealtimeTransactions } from "../hooks/useRealtimeTransactions"
import { formatEther } from "ethers/lib/utils"

const PAGE_SIZE = 20

export function TransactionList() {
  const { address } = useAccount()
  const [page, setPage] = useState(1)
  
  const { transactions, loading } = useRealtimeTransactions(address || "")
  
  if (!address) return <p>请连接钱包</p>
  if (loading) return <p>加载中...</p>
  
  const totalPages = Math.ceil(transactions.length / PAGE_SIZE)
  const pageTransactions = transactions.slice(
    (page - 1) * PAGE_SIZE,
    page * PAGE_SIZE
  )

  return (
    <div>
      <h2>交易记录</h2>
      <table>
        <thead>
          <tr>
            <th>类型</th>
            <th>金额</th>
            <th>时间</th>
            <th>区块</th>
          </tr>
        </thead>
        <tbody>
          {pageTransactions.map((tx) => (
            <tr key={tx.id}>
              <td>{tx.type}</td>
              <td>{formatEther(tx.amount)}</td>
              <td>{new Date(tx.timestamp * 1000).toLocaleString()}</td>
              <td>{tx.blockNumber}</td>
            </tr>
          ))}
        </tbody>
      </table>
      
      <div>
        <button 
          disabled={page <= 1} 
          onClick={() => setPage(p => p - 1)}
        >
          上一页
        </button>
        <span>第 {page} / {totalPages} 页</span>
        <button 
          disabled={page >= totalPages} 
          onClick={() => setPage(p => p + 1)}
        >
          下一页
        </button>
      </div>
    </div>
  )
}

踩坑记录

坑1:子图部署后数据为0

现象:部署成功,GraphQL查询能返回实体结构,但所有数据都是空的。 原因:子图配置文件subgraph.yaml里的startBlock设置得太早,合约在那个区块还没部署。或者eventHandlers里的事件签名写错了。 解决:检查startBlock是否大于等于合约部署区块,用graph codegen重新生成类型,然后重新部署。

坑2:Apollo查询返回null但GraphQL Playground正常

现象:在The Graph Studio的Playground里查询正常,但前端Apollo返回null。 原因:Apollo的缓存策略。默认是cache-first,如果之前缓存过相同变量的查询,它不会重新请求网络。 解决:设置fetchPolicy: "network-only",或者每次查询时加一个随机变量(比如timestamp)来绕过缓存。

坑3:地址大小写导致查询失败

现象:用户输入0xAbC...,查询无结果。但Playground里用小写可以。 原因:The Graph的字符串比较是大小写敏感的,而以太坊地址的校验和格式(EIP-55)包含大小写。 解决:前端所有地址在传入查询前统一.toLowerCase()。子图映射器里存储地址时也要用小写。

坑4:映射器里循环调用save导致超时

现象:一笔交易涉及多个用户(比如批量转账),映射器执行超过50ms,子图索引报错。 原因:映射器有执行时间限制(AssemblyScript环境),循环里多次调用save()会累积时间。 解决:尽量合并写操作。比如批量转账事件,先收集所有用户更新,然后在事件处理函数最后一次性调用save()。或者用store.set()替代entity.save(),性能更好。

小结

用The Graph做链上数据查询,核心是把计算压力从前端转移到索引层。子图的schema设计要围绕查询场景来,不要试图把所有数据都塞进去。如果你需要更实时的数据(秒级),可以考虑结合ethers.js的事件监听做混合方案。下一步可以研究如何用The Graph的去中心化网络(Decentralized Network)替换托管服务,避免单点故障。

别再让 pnpm 跟着 nvm 跑了!独立安装终极指南

还在用 npm install -g pnpm?换一个 Node 版本就 command not found 了吧?今天一篇讲透,让 pnpm 彻底脱离 nvm 的控制。

🚀 省流助手(速通结论)

一句话结论
pnpm 完全独立于 Node 版本,用独立安装脚本或 Corepack,别再用 npm install -g pnpm

30秒速通步骤

# 方案一(macOS/Linux 首选):独立安装脚本
curl -fsSL https://get.pnpm.io/install.sh | sh -

# 如果遇到 SSL 错误或 GitHub 慢,手动下载脚本并换镜像
curl -fsSL https://get.pnpm.io/install.sh -o pnpm-install.sh
sed -i 's|https://github.com/|https://ghproxy.net/https://github.com/|g' pnpm-install.sh
sh pnpm-install.sh

# 验证独立性
nvm use 18          # 切换 Node 版本
which pnpm          # 输出固定路径,不在 .nvm 下
pnpm -v

避坑提示

  • ❌ 绝对不要 npm install -g pnpm(会绑定当前 nvm 版本)
  • ✅ 安装后运行 pnpm setup 配置全局 bin 目录
  • 🌐 国内用户若用 Corepack,需单独设置环境变量 COREPACK_NPM_REGISTRY

一、场景:“一换 node 版本,pnpm 就没了”

小X:pnpm -v → 10.33.2,一切正常。
项目需要:nvm use 16
小X:pnpm -vcommand not found
小X:🤯 什么鬼?他明明全局安装了啊!

你是不是也碰到过?或者你注意到了 which pnpm 的输出是 /Users/xxx/.nvm/versions/node/v22/bin/pnpm,心里隐隐觉得不对劲:“pnpm 怎么住在 nvm 家里?”

这就是典型「pnpm 被 nvm 绑架」的症状。原因很简单:用户当初用了 npm install -g pnpm,而 npm 会把全局包装在 当前激活的 Node 版本的目录 下。一换版本,新版本的目录里没有 pnpm,命令自然消失。


二、扒开外衣:为什么 npm install -g 会绑定 Node 版本?

  • nvm 原理:每个 Node 版本有独立的 binlib/node_modules 目录。PATH 环境变量会根据当前激活的版本动态变化。
  • npm install -g:会把包安装到当前 Node 版本的 lib/node_modules,并在其 bin 目录创建可执行链接。
  • 后果:当用户用 nvm use 切换到另一个版本,PATH 指向新版本的 bin,而新版本下没有 pnpm,自然就报 command not found

但 pnpm 本质上只是一个包管理器,它和 Node 版本没有强绑定关系(就像用锤子,不需要关心锤柄的木头是哪种树)。所以不应该让 pnpm 跟随 nvm 切换


三、手撕问题:三种正确安装方式(按推荐顺序)

🔷 方案一:独立安装脚本(最推荐,通用且彻底独立)

pnpm 官方提供的独立脚本,安装后 pnpm 存放在固定目录(macOS: ~/Library/pnpm,Linux: ~/.local/share/pnpm),不依赖任何 Node 环境。

标准安装(网络通畅时)

curl -fsSL https://get.pnpm.io/install.sh | sh -

安装脚本会自动:

  • 下载 pnpm 二进制到固定目录
  • ~/.zshrc~/.bashrc 中添加 PNPM_HOMEPATH 配置

之后重新加载配置:

source ~/.zshrc   # 如果用 zsh
# 或
source ~/.bash_profile

国内网络慢 / SSL 错误的解决办法

# 1. 手动下载脚本
curl -fsSL https://get.pnpm.io/install.sh -o pnpm-install.sh

# 2. 修改脚本中的下载地址(使用 ghproxy 镜像)
sed -i 's|https://github.com/|https://ghproxy.net/https://github.com/|g' pnpm-install.sh

# 3. 执行本地脚本
sh pnpm-install.sh

验证独立性

which pnpm
# 输出 /Users/你的用户名/Library/pnpm/pnpm   ✅ 不在 .nvm 下

nvm use 18   # 切换版本
which pnpm   # 路径不变,依然能用

🔷 方案二:Corepack(官方推荐,适合 Node 16.13+ 用户)

Corepack 是 Node.js 自带的「包管理器管理器」,专门解决你遇到的这种问题。它会在当前 Node 版本的 bin 目录放一个极小的 shim(代理脚本),这个 shim 会调用 Corepack 去执行真正缓存的 pnpm。

优势:天然支持项目级版本锁定(通过 package.jsonpackageManager 字段),团队协作友好。

操作步骤

# 1. 确保 Corepack 是最新版(非常重要!)
npm install -g corepack@latest

# 2. 启用 Corepack(为当前 Node 版本创建 pnpm shim)
corepack enable

# 3. 准备并激活最新版 pnpm
corepack prepare pnpm@latest --activate

# 4. 国内用户加速:设置环境变量
echo 'export COREPACK_NPM_REGISTRY="https://registry.npmmirror.com"' >> ~/.zshrc
source ~/.zshrc

特别提醒

  • which pnpm 显示路径仍在 .nvm/versions/.../bin:这是正常的!因为 Corepack 就是把 shim 放在那里。只要用户在另一个 Node 版本下也运行一次 corepack enable,pnpm 命令就会同样存在,而且使用的是同一份缓存的 pnpm 版本。
  • enableprepare:顺序反了会导致命令找不到(具体原理在系列第三篇详细讲)。

🔷 方案三:Homebrew(macOS 备选,不优先推荐)

虽然 Homebrew 上也有 pnpm,但官方文档并未将它列为首选。brew install pnpm 会依赖系统 Node.js,可能与 nvm 管理的 Node 产生混淆。

如果你坚持用 Homebrew,确保它的 bin 目录在 PATH 中优先级高于 nvm 路径(通常 brew 会自动处理)。但一般情况下,不推荐作为主力方案。

brew install pnpm
which pnpm   # /opt/homebrew/bin/pnpm

❌ 方案四:npm install -g pnpm(绝对不推荐)

你已经亲身踩过坑了:它把 pnpm 绑死在当前 Node 版本下。不要再用了。


四、进阶思考:如果已经被“绑架”了,怎么解绑?

1. 删除随 nvm 安装的 pnpm

# 找到它的位置
which pnpm   # 如果输出 ~/.nvm/...,那就执行下面的删除
rm $(which pnpm)

# 删除残留的 node_modules
rm -rf $(npm root -g)/pnpm

2. 为所有 nvm 版本统一安装独立 pnpm

如果已经按方案一安装了独立 pnpm,那么切换 Node 版本后,pnpm 命令会一直可用,无需任何额外操作。

3. 如果习惯 Corepack,想为所有 nvm 版本都启用

写一个简单的脚本,遍历所有已安装的 Node 版本:

for v in $(nvm list | grep -o "v[0-9.]*"); do
  nvm use $v >/dev/null 2>&1
  corepack enable
done
nvm use default

五、最佳实践总结

  • 首选独立安装脚本:彻底独立,不受 nvm 约束,网络问题可手动换镜像。
  • 次选 Corepack:官方推荐,与 nvm 配合完美,但需注意先 enableprepare,并设置国内镜像。
  • 不要用 npm install -g pnpm:那是给自己挖坑。
  • ✅ 安装完 pnpm 后,运行 pnpm setup 配置好全局 bin 目录,方便后续 pnpm add -g 的包也能独立于 nvm。
  • 📖 一句话记住本文:pnpm 是工具,不是某个 Node 版本的附庸;用独立脚本或 Corepack,别让 nvm 绑架它。

下一篇预告:《一个 sudo 引发的血案:npm 全局包权限错乱彻底修复》—— 当你在 nvm 下一不小心用了 sudo,如何一键修复 EACCES 错误,并永绝后患。

把多级缓存一致性验证从手工测试换成 Pytest 参数化,Bug 排查时间缩短 90%

杭州的冬天潮得要命,凌晨 1:47 我被报警短信叫醒——“用户详情页返回值乱窜,A 用户看到了 B 用户的订单”。直觉告诉我,又是缓存写乱了。查了半天,发现是本地 lru_cache 和 Redis 之间的失效逻辑,只在某个分支漏了一行 delete,手工跑了几十个用例才复现。第二天我就把这块测试重构成 Pytest 参数化,直接把“靠人脑穷举”变成“机器穷举”,再也没因为这个熬过夜。这篇文章聊的就是:如何用 Pytest 参数化,把多级缓存(本地 + Redis)的一致性验证做成零盲区测试


为什么手工测试多级缓存是个无底洞

多级缓存的做法很常见:读请求先查本地内存(lru_cachecachetools),未命中再查 Redis,回填本地;写请求更新 Redis,同时选择性失效本地缓存。选择性失效是 Bug 高发区——你常常为了性能,不在所有更新路径上都清本地缓存,结果“自以为是安全”的路径忽然就出了问题。

举个例子:一个用户名字变更的接口,代码里只删了 Redis key user:{id},但本地缓存用的 key 是 user_profile:{id}。这就漏了。更隐晦的是,本地缓存有 TTL 很短,白天 QPS 高时缓存频繁重建掩盖了不一致,半夜流量低才暴露,测试环境和生产表现完全两副面孔。

常规的手工测试要覆盖:多键映射、并发更新后读取、缓存穿透时回填、TTL 过期边界、同进程内互斥等。用脑子枚举最多 20 个组合,还容易覆盖不全。Pytest 的参数化正好能把这个过程自动化,而且用例即文档,新人也能秒懂。


方案设计:用 @pytest.mark.parametrize 生成“场景矩阵”

我的目标不是测缓存中间件本身,而是测业务层的组合逻辑是否正确。所以选择了分层测试:

  1. 伪造 Redis(用 fakeredis 库)保证单测无外部依赖,CI 上直接跑。
  2. 被测对象是一个 CacheManager,封装了“本地读 → Redis 读 → 回填本地”以及“写 Redis + 本地清理”的策略。
  3. 测试用例用参数化生成,覆盖:键是否命中本地、是否命中 Redis、是否回填、写入后本地缓存是否被正确删除、并发路径下是否出现脏读等。

为什么不直接用集成测试测真实 Redis?速度。这套参数化用例最后会跑上百个组合,单测必须在毫秒级完成,否则没人愿意经常跑。另外也不依赖 Docker,所见即所得。


核心实现:多级缓存类 + Pytest 参数化用例

1. 被测试的CacheManager(可直接运行)

这段代码实现了带本地缓存的读取和写入逻辑,核心是读路径的“先本地再远程”和写路径的“先远程再清本地”。

# cache_manager.py
import time
from functools import lru_cache
import redis as redis_lib

class CacheManager:
    """本地(LRU) + Redis 两级缓存管理器"""
    def __init__(self, redis_client: redis_lib.Redis, local_ttl: int = 60):
        self.redis = redis_client
        self.local_ttl = local_ttl
        # 本地缓存,最多存 128 个 key,用于实际业务限制内存
        self._local_store = {}

    def _local_get(self, key: str):
        """从本地字典读,并检查过期时间"""
        entry = self._local_store.get(key)
        if not entry:
            return None
        if time.time() - entry["ts"] > self.local_ttl:
            del self._local_store[key]
            return None
        return entry["value"]

    def _local_set(self, key: str, value: str):
        self._local_store[key] = {"value": value, "ts": time.time()}

    def _local_delete(self, key: str):
        self._local_store.pop(key, None)

    def get(self, key: str) -> str | None:
        # 1. 先查本地
        val = self._local_get(key)
        if val is not None:
            return val

        # 2. 再查 Redis
        val = self.redis.get(key)
        if val is not None:
            # 3. 回填本地缓存,注意解码
            decoded = val.decode() if isinstance(val, bytes) else val
            self._local_set(key, decoded)
            return decoded
        return None

    def set(self, key: str, value: str, ttl: int = 300):
        # 先写远程,再清本地,保证下次本地读强一致
        self.redis.setex(key, ttl, value)
        # 这里故意只清本地,依赖下次 get 回填
        self._local_delete(key)

2. Pytest 参数化测试——覆盖读写组合

下面这段代码解决的是穷举“本地命中/未命中 × Redis命中/未命中 × 写后读”的各种排列,验证读取结果的正确性和缓存回填逻辑。

# test_cache_consistency.py
import pytest
import redis as redis_lib
from fakeredis import FakeRedis
from cache_manager import CacheManager

@pytest.fixture
def fake_redis():
    """每个测试独立的 FakeRedis,避免状态污染"""
    return FakeRedis()

@pytest.fixture
def cache(fake_redis):
    return CacheManager(fake_redis)

# 参数化:读场景
@pytest.mark.parametrize(
    "prefill_local, prefill_redis, redis_val, expected",
    [
        # (本地有值, Redis有值, Redis值, 期望返回值)
        (True, False, None, "local_val"),        # 仅本地命中
        (False, True, "redis_val", "redis_val"), # 仅 Redis 命中,本地回填后返回 Redis 值
        (False, False, None, None),              # 全未命中
        (True, True, "redis_val", "local_val"),  # 两者都有,本地优先
    ],
    ids=["local_hit", "redis_hit", "all_miss", "both_hit_local_first"]
)
def test_get_scenarios(cache, prefill_local, prefill_redis, redis_val, expected):
    key = "user:1"
    # 前置:填充本地
    if prefill_local:
        cache._local_set(key, "local_val")
    # 前置:填充 Redis
    if prefill_redis:
        if redis_val:
            cache.redis.set(key, redis_val)

    result = cache.get(key)
    assert result == expected

    # 额外断言:如果仅 Redis 命中,get 应该回填本地缓存
    if prefill_redis and not prefill_local and redis_val:
        assert cache._local_get(key) == redis_val, "回填失败"

3. 写场景参数化——验证写入后本地缓存是否被正确清理

这块测的是更新路径对本地缓存的失效策略,参数化覆盖“原本地有/无”和“不同键”的情况。

@pytest.mark.parametrize(
    "key,local_prefill,new_val",
    [
        ("user:1", True, "new_value"),
        ("user:1", False, "new_value"),
        ("user:2", False, "another"),
    ],
    ids=["update_existing_local", "update_no_local", "different_key"]
)
def test_set_invalidates_local(cache, key, local_prefill, new_val):
    # 前置:预先在本地和 Redis 设值
    if local_prefill:
        cache._local_set(key, "old_value")
        cache.redis.set(key, "old_value")

    cache.set(key, new_val, ttl=60)

    # 断言:本地缓存必须被清除
    assert cache._local_get(key) is None, "set后本地缓存应被清掉"
    # 断言:Redis 已更新为最新值
    stored = cache.redis.get(key)
    stored = stored.decode() if isinstance(stored, bytes) else stored
    assert stored == new_val

踩坑记录:参数化玩崩的两个时刻

坑1:“参数化 + fixture”作用域冲突,导致本地缓存污染

我一开始偷懒把 FakeRedis 做成 scope="module" 的 fixture,结果第一个测试写的键,第二个测试还能读到。因为 FakeRedis 是一个进程内的共享存储,参数化生成的不同用例共用同一个 Redis 实例,前一个 case 的 set 会影响后一个 case 的 get 断言。现象就是个别用例随机失败,重跑又绿,典型的测试间耦合。

解决:把 fake_redis fixture 作用域改成默认的 function,每个用例拿到干净实例。代价是每用例都要初始化 FakeRedis,但耗时不到 1ms,完全值得。这也是官方文档没直说的地方:伪造的外部依赖一定要函数级隔离

坑2:参数化用 ids 描述不一致,让失败信息难以定位

我用 pytest.mark.parametrize 时起初没加 ids,出错时 pytest 打印的是 test_get_scenarios[True-False-None-local_val],根本不知道哪个场景挂了。后来规范给每个组合起英文标识,如 "redis_hit",一眼就能懂。参数化测试的ids 应该是最短却最准确的业务描述,而不是参数值的自然拼接。


效果验证:从“靠人脑枚举”到“跑 42 个组合只需 0.2 秒”

优化前手工跑一遍多级缓存一致性需要构造 6~8 个手动场景,耗时 5 分钟,且经常漏掉边界。重构后,我的参数化矩阵包含了 42 个测试组合,覆盖本地/远程命中、回填、并发写删、TTL 边界等。在 2021 款 MacBook Pro 上跑完这 42 个用例仅需 0.21 秒(pytest -v 实测)。最关键的是,后来团队新同事加了一个“读未命中的异步回填”优化,参数化用例直接挂了 3 个,当场报错:“回填时未考虑 Redis 已被其他进程删除”,10 分钟修好,而不是等上线后爆炸。

指标 手工测试 Pytest 参数化
场景覆盖 6-8 个 42 个组合
执行耗时 5 分钟 0.21 秒
依赖环境 需 Redis 纯内存 FakeRedis
回归时间(新改动) 人肉重跑 < 1 秒 CI 自检

可直接用的代码

把上面的 CacheManager 类和测试文件放到项目里,装上依赖就能跑:

pip install pytest fakeredis redis
pytest test_cache_consistency.py -v

想立刻榨干参数化的价值,记住这个模板:

@pytest.mark.parametrize("param1,param2", [...], ids=[...])
def test_xxx(fixture_a, fixture_b, param1, param2):
    # Arrange: 用参数和夹具准备状态
    # Act: 调用被测函数
    # Assert: 多级断言(结果值 + 副作用如缓存落盘/删除)
    pass

#Python #后端 #Pytest #缓存一致性 #Redis

关于作者
一个在缓存踩过无数坑的后端架构师,相信“好的测试比凌晨报警更有用”。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba… — 如果这篇文章帮到你了,请我喝杯咖啡
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege

前端开发者做 Agent:别只会执行,用 4 类失败策略让 AI 知道怎么停

作者:前端转 AI 深度实践者

【省流助手/核心观点】:Agent 做 demo 时,最显眼的是“它会调用工具”。但进入真实工程后,更关键的是“工具失败时它怎么办”。可靠的 Agent 必须区分错误类型:超时可以重试,空结果可以降级,权限不足要停止,高风险操作要等待确认。Agent 的成熟度,不取决于它能跑多远,而取决于它在出错时有没有刹车、有没有解释、有没有边界。


前面几篇,我们已经把 Agent 的核心骨架搭起来了。

  • 第 23 篇:Tool Calling,让模型学会调用工具。
  • 第 24 篇:工具 Schema,让工具有参数规则和风险边界。
  • 第 25 篇:Agent Loop,让模型和工具形成闭环。
  • 第 26 篇:Plan-Act-Observe,让 Agent 能处理多步任务。

这一路下来,Agent 看起来越来越像一个能做事的系统。

但还有一个问题必须正面面对:

如果某一步失败了,Agent 该怎么办?

这个问题比“怎么调用工具”更接近真实工程。

因为 demo 里的工具通常都成功。
真实世界里的工具不一定。

1. 痛点:会执行不难,失败后不乱跑才难

想象一个任务:

帮我查一下订单 A1001 的物流,如果还没送达,再查延迟补偿政策。

一个理想流程是:

查订单
-> 判断是否送达
-> 查政策
-> 给出建议

但真实执行时可能发生很多事:

  • 订单接口超时。
  • 订单不存在。
  • 用户没有权限查这个订单。
  • 政策搜索没有结果。
  • 工具参数缺失。
  • 高风险工具需要用户确认。

如果 Agent 没有失败策略,它可能会做出很糟糕的行为:

  • 接口超时后直接放弃。
  • 订单不存在还继续查政策。
  • 权限不足却假装查到了。
  • 政策为空却编一个政策。
  • 连续重试同一步,卡到超时。

所以 Agent 真正难的不是“能不能执行”,而是失败时知道该怎么办。

2. 错误做法:把所有失败都当成普通异常

很多初版系统喜欢这样返回:

type BadToolResult = {
  ok: false;
  error: string;
};

比如:

{
  "ok": false,
  "error": "执行失败"
}

这当然比程序直接崩掉好,但对 Agent 没什么帮助。

因为它不知道下一步应该做什么。

更危险的是,很多系统会写出这种“无脑继续”的逻辑:

async function unsafeContinue(plan: PlanStep[]) {
  for (const step of plan) {
    const result = await act(step);
    step.observation = result;
  }

  return generateFinalAnswer(plan);
}

这段代码的问题是:不管工具成功还是失败,后续步骤都继续执行。

如果查订单失败了,还继续查补偿政策;如果权限不足,还继续生成完整答案。用户看到的结果很顺,但可信度已经坏了。

3. 正确做法:给失败分类,再映射处理策略

先把错误类型结构化。

type ToolErrorType =
  | "invalid_arguments"
  | "unknown_tool"
  | "not_found"
  | "timeout"
  | "permission_denied"
  | "confirmation_required"
  | "empty_result";

type ToolResult =
  | {
      ok: true;
      data: unknown;
    }
  | {
      ok: false;
      errorType: ToolErrorType;
      message: string;
    };

每一种错误的含义都不同:

  • timeout:可能只是网络抖动。
  • invalid_arguments:模型或程序传参错了。
  • not_found:目标资源不存在。
  • permission_denied:用户没有权限。
  • confirmation_required:动作有风险,需要用户确认。
  • empty_result:查询成功了,但没有找到内容。

错误类型越清楚,Agent 越能做正确决策。

接着设计处理策略:

type FailureDecision =
  | "retry"
  | "fallback"
  | "pause"
  | "stop";

const failureStrategies: Record<ToolErrorType, FailureDecision> = {
  timeout: "retry",
  empty_result: "fallback",
  not_found: "stop",
  invalid_arguments: "stop",
  unknown_tool: "stop",
  permission_denied: "stop",
  confirmation_required: "pause"
};

这张表非常朴素,但非常有用。

它把“失败了怎么办”从一句模糊的话,变成了明确工程策略。

4. 重试要克制,不要把 retry 当万能药

看到失败,很多人的第一反应是:

那就重试。

重试确实有用,但不能滥用。

比如接口超时,可以重试。
但下面这些错误,重试通常没有意义:

  • 工具不存在。
  • 参数缺失。
  • 用户没权限。
  • 订单不存在。

所以每个步骤都应该有重试计数:

type PlanStep = {
  id: string;
  goal: string;
  toolName: string;
  args: Record<string, unknown>;
  status:
    | "pending"
    | "running"
    | "done"
    | "failed"
    | "paused"
    | "fallback";
  retryCount: number;
  maxRetries: number;
  observation?: unknown;
  error?: ToolResult;
  fallbackReason?: string;
};

重试判断可以这样写:

function canRetry(step: PlanStep, result: ToolResult) {
  return (
    !result.ok &&
    result.errorType === "timeout" &&
    step.retryCount < step.maxRetries
  );
}

没有上限的重试,不叫韧性,叫迷路。

5. 降级不是糊弄用户,而是诚实表达边界

还有一种失败很常见:空结果。

比如政策搜索工具返回:

{
  "ok": false,
  "errorType": "empty_result",
  "message": "没有找到相关政策"
}

这时候不一定要让整个任务失败。

Agent 可以降级回答:

我查到了订单 A1001 当前仍在运输中,但没有找到明确的延迟补偿政策。建议你联系人工客服确认是否可申请补偿。

这就是降级。

降级不是假装成功。

降级是:

  • 告诉用户哪些信息查到了。
  • 告诉用户哪些信息没查到。
  • 不编造不存在的依据。
  • 给出下一步建议。

这比“为了完整而胡编”可靠得多。

6. 暂停也是一种能力

有些操作不能失败后直接结束,也不能自动继续。

例如:

帮我直接取消这个订单。

取消订单是有副作用的高风险操作。

即使模型判断要调用 cancelOrder,程序也应该返回:

{
  "ok": false,
  "errorType": "confirmation_required",
  "message": "取消订单需要用户确认。"
}

这时候 Agent 的正确行为不是继续执行,而是暂停:

这个操作会取消订单 A1001。请确认是否继续。

暂停不是不智能。

暂停是安全边界的一部分。

一个系统如果不知道什么时候停下来问用户,就不适合处理真实业务。

7. 把失败处理接进 Plan-Act-Observe

第 26 篇我们有:

Plan -> Act -> Observe

现在加上失败策略:

Plan
-> Act
-> 如果成功:Observe Success
-> 如果失败:Handle Failure
-> Retry / Fallback / Pause / Stop

核心代码可以这样写:

function handleStepFailure(step: PlanStep, result: ToolResult) {
  if (result.ok) return "none";

  const decision = failureStrategies[result.errorType];

  if (decision === "retry" && canRetry(step, result)) {
    step.retryCount += 1;
    step.status = "pending";
    step.error = result;
    return "retry";
  }

  if (decision === "fallback") {
    step.status = "fallback";
    step.fallbackReason = result.message;
    step.error = result;
    return "fallback";
  }

  if (decision === "pause") {
    step.status = "paused";
    step.error = result;
    return "pause";
  }

  step.status = "failed";
  step.error = result;
  return "stop";
}

再把它接进执行循环:

async function runStepWithFailureControl(step: PlanStep) {
  step.status = "running";

  const result = await act(step);

  if (result.ok) {
    step.status = "done";
    step.observation = result.data;
    return "continue";
  }

  const decision = handleStepFailure(step, result);

  if (decision === "retry") {
    return "retry";
  }

  if (decision === "fallback") {
    return "continue";
  }

  if (decision === "pause") {
    return "pause";
  }

  return "stop";
}

这让 Agent 多了一套刹车系统。

它不再是“计划里有几步就硬跑几步”,而是会根据错误类型做不同处理。

8. 最终答案必须解释失败

失败处理还有一个关键点:最终答案不能只说“失败了”。

它应该说明:

  • 哪些步骤成功了。
  • 哪些步骤失败了。
  • 失败原因是什么。
  • 是否重试过。
  • 是否降级了。
  • 用户下一步可以做什么。

可以从 plan 里生成一个更清楚的回答:

function summarizePlan(plan: PlanStep[]) {
  const done = plan.filter((step) => step.status === "done");
  const failed = plan.filter((step) => step.status === "failed");
  const fallback = plan.filter((step) => step.status === "fallback");
  const paused = plan.filter((step) => step.status === "paused");

  return {
    done: done.map((step) => step.goal),
    failed: failed.map((step) => ({
      goal: step.goal,
      reason: step.error && !step.error.ok ? step.error.message : "未知错误"
    })),
    fallback: fallback.map((step) => ({
      goal: step.goal,
      reason: step.fallbackReason
    })),
    paused: paused.map((step) => ({
      goal: step.goal,
      reason: step.error && !step.error.ok ? step.error.message : "等待确认"
    }))
  };
}

用户侧表达可以是:

我查到了订单 A1001 当前仍在运输中。
但在查询延迟补偿政策时没有找到明确结果,因此不能确认是否可自动申请补偿。
建议你联系人工客服,并提供订单号 A1001 进一步确认。

这类回答虽然不“全能”,但可信。

AI 产品最怕的不是说“我不知道”。
最怕的是不知道还装知道。

9. 前端开发者怎么理解失败处理

前端其实非常懂失败处理。

你写页面时不会只写成功态。

你还会考虑:

  • loading。
  • empty。
  • error。
  • disabled。
  • retry。
  • permission denied。
  • confirm modal。

Agent 也是一样。

工具调用的失败态,就是 AI 系统里的 error state。

高风险确认,就是 AI 系统里的 confirm modal。

空结果降级,就是 AI 系统里的 empty state。

重试上限,就是 AI 系统里的防抖和保护阈值。

所以前端经验在这里非常有价值。

你不是从零开始学 Agent。

你是在把已有的工程直觉迁移到 AI 系统里。

10. 生产环境避坑指南

1. 只重试临时性错误

适合重试的通常是 timeout、临时网络错误、上游服务短暂不可用。

不适合重试的是 invalid_argumentspermission_deniednot_foundunknown_tool

2. 重试必须有上限和间隔

每一步都要有 retryCountmaxRetries

更进一步,可以加指数退避,避免把上游服务打爆。

3. fallback 不能伪装成成功

降级回答必须告诉用户哪些信息拿到了,哪些信息没拿到。

不要把空结果包装成确定结论。

4. pause 必须能恢复

如果高风险操作进入 paused,前端要能保存当前 plan,并在用户确认后从暂停步骤继续。

不要让用户确认之后系统重新从第一步跑一遍。

5. 失败要进日志和评测集

每次失败都应该记录:

  • traceId
  • step id
  • tool name
  • errorType
  • retryCount
  • final decision

高频失败样本应该回流到测试用例或 Agent 评测集。

11. 常见误区

误区 1:失败了就让模型再试一次

不对。模型重试不是万能药。只有临时性错误才适合重试。

误区 2:降级就是糊弄用户

不是。好的降级是透明说明边界,不编造结果,并给出下一步建议。

误区 3:工具失败了也让 Agent 继续跑完整计划

危险。关键步骤失败时应该停止或暂停,而不是继续生成看似完整的答案。

误区 4:错误信息写给开发者看就行

不够。内部错误要结构化,用户侧表达要清楚、克制、可行动。

12. 给前端开发者的落地清单

如果你在团队里做 Agent 失败处理,可以从这份清单开始:

  1. 所有工具失败都必须返回 errorType
  2. 错误类型要能映射到处理策略。
  3. 只有临时性错误才允许重试。
  4. 每一步都要有 retryCountmaxRetries
  5. 高风险工具必须支持暂停确认。
  6. 空结果可以降级,但不能假装成功。
  7. 关键步骤失败后不要继续硬跑。
  8. 最终答案要说明成功、失败、跳过和下一步建议。
  9. 执行日志要记录每次重试。
  10. 测试用例必须覆盖成功、重试、降级、暂停、停止。

这份清单不华丽,但很保命。

Agent 越像能办事的系统,越要认真处理失败。

结语

Agent 真正难的,不是会执行。

真正难的是失败后不乱执行。

它要知道什么时候重试,什么时候降级,什么时候暂停,什么时候停止。

这听起来没有“自主智能体”那么炫,但它决定了系统能不能进入真实业务。

一个可靠的 Agent,不是永远顺利地跑到终点。

一个可靠的 Agent,是遇到坑时不会假装没看见,而是踩刹车、留痕迹、说清楚,然后给用户一个可信的下一步。

Neo 构建鸿蒙应用【三】:实战社交应用与工程感悟

Neo 构建鸿蒙应用【三】:实战社交应用与工程感悟

Neo 框架连载(终篇)· AI 辅助撰写

前两篇讲完了架构和机制。这一篇换个角度——不谈概念,只看代码。用一个模拟 Soul 业务场景的社交应用完整实现,验证框架在真实项目中的表现,最后分享一些工程实践中的感悟。

示例项目:EchoApp

EchoApp 是 Neo 仓库中的示例项目(examples/soul-app),模拟一款社交应用的核心功能:聊天、广场动态、匹配、用户资料等。不是 demo 级别的 HelloWorld,而是有意按照中型项目的体量来组织代码:

维度 数量
Service 总数 27
infra 层 8
business 层 12
feature 层 5
lazy 层 2
页面 10
组件 40+

选择这个体量是有原因的——太少了看不出分层的价值,太多了读起来成本太高。27 个 Service 恰好在"能看清楚全貌"和"有足够的复杂度"之间。

从零开始:AppModule 的设计思路

AppModule 是整个应用的起点,所有 27 个 Service 在这里统一声明:

export const appModule = new NeoModule('EchoApp', [
  // ===== GLOBAL_PHASE (serial, p10) — 8 infra services =====
  { tag: 'AppInitService', phase: GLOBAL_PHASE, factory: () => new AppInitService() },
  { tag: 'SecurityService', phase: GLOBAL_PHASE,
    factory: () => new SecurityService(), dependencies: ['AppInitService'] },
  { tag: 'DatabaseService', phase: GLOBAL_PHASE,
    factory: () => new DatabaseService(), dependencies: ['AppInitService'] },
  { tag: 'NetworkService', phase: GLOBAL_PHASE,
    factory: () => new NetworkService(), dependencies: ['AppInitService', 'SecurityService'] },
  { tag: 'CacheService', phase: GLOBAL_PHASE, factory: () => new CacheService() },
  { tag: 'StorageService', phase: GLOBAL_PHASE,
    factory: () => new StorageService(), dependencies: ['AppInitService'] },
  // ...

  // ===== BUSINESS_PHASE (serial, p20) — 12 business services =====
  { tag: 'AuthService', phase: BUSINESS_PHASE,
    factory: () => new AuthService(), dependencies: ['NetworkService', 'SecurityService', 'StorageService'] },
  { tag: 'UserService', phase: BUSINESS_PHASE,
    factory: () => new UserService(), dependencies: ['AuthService', 'DatabaseService'] },
  { tag: 'IMService', phase: BUSINESS_PHASE,
    factory: () => new IMService(), dependencies: ['AuthService', 'NetworkService', 'DatabaseService'] },
  { tag: 'MomentService', phase: BUSINESS_PHASE,
    factory: () => new MomentService(), dependencies: ['AuthService', 'NetworkService', 'CacheService'] },
  { tag: 'MatchService', phase: BUSINESS_PHASE,
    factory: () => new MatchService(), dependencies: ['AuthService', 'NetworkService', 'CacheService'] },
  // ...

  // ===== FEATURE_PHASE (parallel, p30) — 5 feature services =====
  { tag: 'SearchService', phase: FEATURE_PHASE,
    factory: () => new SearchService(), dependencies: ['NetworkService', 'CacheService'] },
  { tag: 'AnalyticsService', phase: FEATURE_PHASE,
    factory: () => new AnalyticsService() },
  // ...

  // ===== LAZY_PHASE (parallel, p40) — 2 lazy services =====
  { tag: 'GameService', phase: LAZY_PHASE,
    factory: () => new GameService(), dependencies: ['MatchService', 'IMService'] },
  { tag: 'StoryService', phase: LAZY_PHASE,
    factory: () => new StoryService(), dependencies: ['AuthService', 'NetworkService', 'MediaService'] },
])

设计这个文件时的思考

1. 分层的判断标准

哪些 Service 放 infra、哪些放 business,不是拍脑袋决定的。判断标准:

  • infra:无业务状态,换一个应用也能用。NetworkService、CacheService、DatabaseService——它们不知道"用户"是什么概念
  • business:有业务状态,和具体应用绑定。AuthService 知道 token,IMService 知道消息,MomentService 知道动态
  • feature:锦上添花,应用没有它们也能跑。SearchService、AnalyticsService
  • lazy:用户可能永远不会用到的功能。GameService、StoryService

2. 依赖的方向

依赖关系一定是单向的:infra ← business ← feature ← lazy。不会出现 IMService 依赖 SearchService 的情况。这不是框架强制的,而是分层的自然结果——你不会在业务层引用一个功能层的服务,因为业务层在它之前就加载了。

3. Phase 的调整是启动优化的主要手段

假设启动速度不达标,优化思路不是去改 Service 内部代码,而是调整 Phase 归属:

  • MatchService 从 BUSINESS 降到 FEATURE?匹配结果不在首屏展示,可以先显示骨架屏
  • EmojiService 从 BUSINESS 降到 FEATURE?表情面板不是默认展开的
  • 每降一个,BUSINESS 阶段就少一个串行等待的 Service,用户更快看到首页

这种优化不需要改任何业务代码,只改 AppModule 中的 phase 字段。

一个完整的数据流:发送消息

以聊天功能为例,走一遍从用户操作到 UI 刷新的全链路。

页面层(features)

// ChatPage.ets
@Entry
@Component
struct ChatPage {
  @State messages: ChatMessage[] = []
  @State inputText: string = ''
  private imSvc?: IMService
  private unbind: (() => void) | undefined
  private conversationId: string = ''

  aboutToAppear() {
    const params = router.getParams() as ChatPageParams
    this.conversationId = params.conversationId

    this.imSvc = serviceManager.get<IMService>('IMService')!
    this.unbind = StateBinder.bind<ChatMessage[]>(
      this.imSvc.getMessagesObservable(),
      this as Object,
      'messages'
    )
    this.loadMessages()
  }

  aboutToDisappear() { this.unbind?.() }

  async loadMessages() {
    if (this.imSvc && this.conversationId) {
      this.messages = await this.imSvc.getMessages(this.conversationId)
      await this.imSvc.markAsRead(this.conversationId)
    }
  }

  // 用户点击发送
  async onSend() {
    if (this.imSvc && this.inputText.trim()) {
      await this.imSvc.sendMessage(this.conversationId, this.inputText.trim())
      this.inputText = ''
    }
  }

  build() {
    Column() {
      List() {
        ForEach(this.messages, (msg: ChatMessage) => {
          ListItem() {
            // 渲染消息气泡
          }
        })
      }
      // 输入框 + 发送按钮
    }
  }
}

页面做的事情很薄:绑定 Observable、调用 Service 方法、渲染 UI。没有网络请求、没有状态管理、没有回调地狱。

业务层(domains)

// IMService.ets
export class IMService extends Service {
  private conversationsObs = new Observable<Conversation[]>([])
  private messagesObs = new Observable<ChatMessage[]>([])

  getMessagesObservable() { return this.messagesObs }

  async sendMessage(conversationId: string, content: string): Promise<ChatMessage> {
    // 1. 业务逻辑:创建消息
    const msg = this.createMessage(conversationId, content)

    // 2. 网络:发送到服务器
    await this.networkSvc.send('/im/send', msg)

    // 3. 更新 Observable → 自动通知所有绑定的页面
    const msgs = this.messagesObs.getValue()
    this.messagesObs.setValue([...msgs, msg])

    // 4. 发送事件 → 其他 Service 可以响应
    eventBus.emit('im:messageSent', { conversationId, message: msg } as Object)

    return msg
  }
}

业务层做的事情:处理业务逻辑、协调基础设施、更新 Observable、发送事件。它不知道有哪些页面在用自己,也不知道 UI 长什么样。

基础设施层(infra)

// NetworkService.ets
export class NetworkService extends Service {
  async send(path: string, data: Object): Promise<Object> {
    // 纯技术实现:HTTP 请求、重试、超时
    // 不知道"消息"是什么,只知道 path 和 data
  }
}

基础设施层做的事情:纯技术实现。不知道业务概念,不知道 UI,甚至不知道自己在被谁调用。

完整链路

用户点击发送(ChatPage.onSend)
  → imSvc.sendMessage(convId, '你好')        // 页面调 Service
    → 创建消息对象                            // 业务逻辑
    → networkSvc.send('/im/send', msg)        // 调基础设施
    → messagesObs.setValue([...msgs, msg])    // 更新 Observable
      → StateBinder 自动写入 @State messages  // 数据驱动 UI
      → ArkUI 重新渲染消息列表                 // 用户看到新消息
    → eventBus.emit('im:messageSent')         // 通知其他 Service

页面不需要手动 this.messages = newMessages,不需要 this.$setState(),不需要 notifyDataSetChanged()。Service 调了 setValue,UI 自动刷新。

新增一个功能要改几个文件

假设产品要求新增一个"举报"功能。需要改哪些文件?

1. 定义 Serviceservices/feature/ReportService.ets(新建)

export class ReportService extends Service {
  async report(targetId: string, reason: string): Promise<boolean> {
    const network = this.depServices.find(s => s.tag === 'NetworkService') as NetworkService
    await network.send('/report', { targetId, reason } as Object)
    return true
  }
}

2. 注册到 AppModulemodules/AppModule.ets(加一行)

{ tag: 'ReportService', phase: FEATURE_PHASE,
  factory: () => new ReportService(), dependencies: ['NetworkService'] },

3. 页面使用 — 在需要举报的页面中

const reportSvc = serviceManager.get<ReportService>('ReportService')!
await reportSvc.report(targetId, '不适当内容')

三个文件,零耦合。不需要修改 NetworkService、不需要修改任何基类、不需要在某个全局注册表里手动添加。定义 → 注册 → 使用,流程是固定的。

工程感悟

框架是团队契约,不是技术炫耀

我说这么多有的没的,核心就一件事:如果存在一种共同约束,不是特别完美,但也不特别烂,就能产生一些价值。

精准高效沟通是特例,低效沟通是常态;层次分明是特例,层次渗透是常态;需求稳定是特例,经常变更是常态。Neo 提供的不是理想架构,而是一个团队可以共同遵守的底线。所有人定义 Service 的时候知道 init 不能做 IO,声明依赖的时候知道要看 AppModule,写页面的时候知道通过 Observable 拿数据。

这种共同约束的收益不是立竿见影的,而是在项目三个月、六个月、一年后——当新功能还在源源不断地加进来,而代码还能看懂、还能改、还能测试的时候——才会体现。

ArkTS 的限制反而帮了忙

ArkTS 比 TypeScript 严格很多:不能用 any、不能用 Record<> 的动态访问、不能省略泛型、不能 spread 对象。这些限制在初期让人头疼,但回头看,它们迫使你写出更明确的代码:

  • 没有 any → 每个变量都有类型 → Service 的接口必须定义清楚
  • 不能动态访问属性 → 必须用接口声明 router.getParams() 的返回值 → 页面参数有文档
  • 必须显式泛型 → StateBinder.bind<T>() 一眼就知道绑的是什么类型

这些限制和 Neo 的设计理念是契合的——约束带来清晰

结构化不是炫技

尤其在近两年 AI 编程越来越成熟的背景下,"炫技"显得越来越廉价。结构化的目的是让代码可维护、可测试、可交接,而不是展示设计模式的熟练度。

AI 时代的工程节奏

Neo 本身就是 AI 辅助开发的产物。我的判断是:AI 生成产品的速度太快了,把 IDEA 尽可能快地做出来,远比人工打磨细节的 ROI 高。

本系列文章也是这个思路——核心观点和设计决策是人定的,文字落地是 AI 做的。与其花一周打磨一篇"完美"的技术文章,不如一天发三篇把思路讲清楚,把省下来的时间投入到下一个功能、下一个项目中。

写在最后

软件是有固有生命周期的,公司和团队也有生命周期。在经济上行期软件行业的迭代都非常快,很多软件在没达到最大容积之前,公司和团队的生命周期已走到最终阶段。

这不是悲观——恰恰是因为时间有限,才更需要一个好的结构约束。让代码在你还在的时候能跑,在你走了之后别人也能接。Neo 不追求成为最好的框架,只追求成为一个不太烂的共同约束

如果这篇文章对你有一点启发,去 GitHub 看看源码,跑一下 EchoApp 示例。觉得有用就点个 Star,有问题就提 Issue。

共勉。


系列文章

Neo 构建鸿蒙应用【二】:技术路线全解

Neo 构建鸿蒙应用【二】:技术路线全解

Neo 框架连载 · AI 辅助撰写 · GitHub

上一篇建立了四层架构的宏观视图。这一篇把 Neo 的全部技术机制讲完:Service、NeoModule、ServiceManager、Phase、Observable、StateBinder、Scope。

核心思路借鉴了 JavaBean 和 IoC——Java Boy 编程梦开始的地方。

Service:最小功能单元

所有领域建模被命名为 XXXService,继承 Service 基类。一个 Service 的完整生命周期:

constructor → register → init → loginCallback → load → (WORKING)
                                                  ↑
                                    reload ← unload ← logoutCallback

基类定义

export abstract class Service implements AppPropagation {
  tag: string = this.constructor.name    // 默认用类名作标识
  context: Context | undefined
  depServices: AppPropagation[] = []     // 依赖的服务列表

  constructor(services: Service[]) {
    this.depServices = services
  }

  register = (cxt: Context) => {
    this.context = cxt
    this.init()
    // lifecycle → INITIALIZED
  }

  loginCallback = async () => {
    this.load().then(res => {
      if (res) {
        // lifecycle → WORKING
        serviceManager.refreshNode(this, this.lifecycle)
      }
    })
  }

  logoutCallback = async () => { /* unload → INITIALIZED */ }

  init() {}                                        // 初始化成员属性,不能做网络 IO
  async load(): Promise<boolean> { return true }   // 加载数据
  async unload(): Promise<boolean> { return true } // 卸载清理
  reload = () => { this.unload().then(() => this.load()) }
}

设计要点:

  • register 是箭头函数属性,不是方法。ArkTS 中子类不能 override 父类的箭头函数属性,保证基类的生命周期管理不被绕过
  • constructor(services: Service[]) 构造器注入,Spring 的思路
  • init() 不能做网络 IO——仅用于初始化成员属性
  • load() 返回 Promise<boolean>——true 表示成功,状态转为 WORKING

实际例子

// AuthService.ets
export class AuthService extends Service {
  private currentUser: UserInfo | null = null
  private token: string = ''

  constructor() { super([]) }

  init() { this.currentUser = null; this.token = '' }

  async load(): Promise<boolean> {
    const saved = await this.loadFromStorage()
    if (saved) { this.token = saved.token; this.currentUser = saved.user }
    return true
  }

  async unload(): Promise<boolean> {
    this.currentUser = null; this.token = ''
    return true
  }
}

NeoModule:Koin 式模块声明

Service 定义好了,如何组织起来?借鉴 Koin 的 module DSL 风格:

import { NeoModule, GLOBAL_PHASE, BUSINESS_PHASE } from 'neo'

const networkModule = new NeoModule('Network', [
  { tag: 'ApiService', phase: GLOBAL_PHASE, factory: () => new ApiService([]) },
  { tag: 'AuthService', phase: GLOBAL_PHASE, factory: () => new AuthService([]) },
])

const userModule = new NeoModule('User', [
  { tag: 'UserService', phase: BUSINESS_PHASE,
    factory: () => new UserService([]),
    dependencies: ['AuthService'] },
])

每个声明包含:

字段 说明
tag 服务唯一标识
phase 所属加载阶段
factory 延迟创建工厂
dependencies 依赖的其他服务 tag

模块组合:

const appModule = networkModule.merge(userModule)
// 同名声明以当前模块优先

校验(load() 时自动调用):

const errors = appModule.validate()
// 检测缺失依赖 + 循环依赖(DFS)

ServiceManager:IoC 容器

中枢管理者,处理注册、依赖解析和生命周期。

核心流程

// EntryAbility.onCreate
serviceManager.register(this.context)          // 注入上下文
serviceManager.loadModule(appModule)            // 加载模块(校验、分组、注册)
await serviceManager.loginCallback()            // 触发多阶段启动

依赖解析

内部维护两张映射:serviceMap(tag → 实例)和 dependentMap(被谁依赖)。loginCallback 触发时递归解析依赖链——加载 Service A 之前,确保其所有依赖已 WORKING:

// ServiceManager 内部(简化)
private ensureNodeWorking(node) {
  await Promise.all(node.depServices.map(dep => this.ensureNodeWorking(dep)))
  this.invokeLogin(node)
  await this.waitForLifecycle(node.tag, ServiceLifeCycle.WORKING)
}

获取服务:

const userService = serviceManager.get<UserService>('UserService')!
const isReady = serviceManager.ready(userService)

Phase:渐进式启动

27 个 Service 不需要同时加载。

四个预定义阶段

阶段 优先级 策略 用途
GLOBAL_PHASE 10 串行等待 配置、数据库、网络、安全
BUSINESS_PHASE 20 串行等待 认证、用户、消息、通话
FEATURE_PHASE 30 并行触发 搜索、统计、主题、国际化
LAZY_PHASE 40 并行触发 小游戏、故事

策略含义:

  • 串行等待waitForComplete: true):该阶段全部完成后才进入下一阶段
  • 并行触发waitForComplete: false):触发后立即进入下一阶段,不等待完成
时间轴 ─────────────────────────────────────────────→

GLOBAL (串行)  ████████████████
BUSINESS (串行)                 ████████████████
FEATURE (并行)                                   ████ ← 不阻塞
LAZY (并行)                                           ██ ← 不阻塞

                     ↑ UI 可交互

启动耗时可控的核心:GLOBAL + BUSINESS 同步确保核心就绪,FEATURE + LAZY 异步不阻塞。优化手段就是把非必要 Service 从 BUSINESS 降到 FEATURE。

实战中的启动优化

我曾在实际项目中用这套机制做过启动优化(详见原文),当时 Neo 还不存在,但核心代码已经具备了 Service + Phase 的雏形。

优化成果固然可喜,但更重要的是整个结构变得可控了。因为每个 Service 的依赖关系、加载阶段、生命周期都是声明式的,我可以保证:

  • 把某个 Service 从 BUSINESS 降到 FEATURE,不会导致依赖它的模块出问题——依赖解析是自动的
  • 新增一个 Service 不需要改任何已有代码——在 AppModule 加一行声明即可
  • 变更的影响范围是可预期的——约束由框架兜底

这不是黑盒优化,而是白盒优化。以后遇到启动性能问题,打开 AppModule 调整 Phase 归属即可——不需要重新分析依赖链,不需要画调用图,套路是固定的。

自定义阶段:

const CACHE_PHASE = createPhase({
  name: 'CACHE', priority: 25, waitForComplete: false, description: '缓存预热'
})

Observable:Service 端的数据源

Service 加载了数据,如何让页面感知变化?

朴素做法是页面直接调 Service 方法拿返回值赋给 @State——但只在初始化时有效,其他页面的数据变更不会自动刷新。

Neo 的方案是 Observable——泛型观察者容器:

export class Observable<T> {
  private value_: T
  private listeners: Set<(value: T) => void> = new Set()

  constructor(initialValue: T) { this.value_ = initialValue }
  getValue(): T { return this.value_ }

  setValue(newValue: T): void {
    if (this.value_ === newValue) return
    this.value_ = newValue
    this.notify()
  }

  onChange(listener: (value: T) => void): () => void {
    this.listeners.add(listener)
    return () => { this.listeners.delete(listener) }
  }
}

没有 Subject、没有 Operator、没有调度器。ArkTS 不支持 RxJS 那套管道,也没有必要引入。

Service 中使用

export class IMService extends Service {
  private conversationsObs = new Observable<Conversation[]>([])
  private messagesObs = new Observable<ChatMessage[]>([])

  getConversationsObservable() { return this.conversationsObs }
  getMessagesObservable() { return this.messagesObs }

  async sendMessage(conversationId: string, content: string): Promise<ChatMessage> {
    const msg = await this.doSend(conversationId, content)
    const msgs = this.messagesObs.getValue()
    this.messagesObs.setValue([...msgs, msg])  // 自动通知 UI
    return msg
  }

  async load(): Promise<boolean> {
    this.conversationsObs.setValue(await this.fetchConversations())
    return true
  }
}

模式:内部持有 Observable → getter 暴露 → setValue 触发变更。外部只能订阅,不能直接修改。

StateBinder:Service 到 @State 的桥

Observable 解决了通知问题,StateBinder 解决了写入 @State 的问题:

export class StateBinder {
  static bind<T>(
    observable: Observable<T>,
    component: Object,      // 页面组件实例
    stateKey: string        // @State 属性名
  ): () => void {
    const record = component as Record<string, Object>
    record[stateKey] = observable.getValue() as Object    // 初始化
    const unlisten = observable.onChange((newValue: T) => {
      record[stateKey] = newValue as Object               // 变更时自动写入
    })
    return unlisten
  }

  static bindAll(factories: Array<() => () => void>): () => void { /* 批量绑定 */ }
}

页面中使用

@Entry
@Component
struct ChatListPage {
  @State conversations: Conversation[] = []
  private unbind: (() => void) | undefined

  aboutToAppear() {
    const imSvc = serviceManager.get<IMService>('IMService')!
    this.unbind = StateBinder.bind<Conversation[]>(
      imSvc.getConversationsObservable(),
      this as Object,       // ArkTS 要求显式转换
      'conversations'
    )
  }

  aboutToDisappear() { this.unbind?.() }

  build() {
    List() {
      ForEach(this.conversations, (conv: Conversation) => {
        ListItem() { /* 渲染会话项 */ }
      })
    }
  }
}

注意两点 ArkTS 限制:

  • this as Object 不能省略——组件的 this 类型不允许直接传给 Object 参数
  • 显式泛型 <T> 不能省略——ArkTS 不支持自动推断泛型给 Record<string, Object> 赋值

批量绑定:

aboutToAppear() {
  this.unbind = StateBinder.bindAll([
    () => StateBinder.bind<Conversation[]>(imSvc.getConversationsObservable(), this as Object, 'conversations'),
    () => StateBinder.bind<ChatMessage[]>(imSvc.getMessagesObservable(), this as Object, 'messages'),
    () => StateBinder.bind<number>(matchSvc.getCountObservable(), this as Object, 'matchCount'),
  ])
}

aboutToDisappear() { this.unbind?.() }  // 一行解绑所有

Scope:页面级作用域

有些 Service 是页面专属的,页面离开时应该卸载:

import { scopeManager } from 'neo'

aboutToAppear() {
  this.scope = scopeManager.createScope('ChatPage')
  this.scope.bindTo(this)  // aboutToDisappear 时自动 close

  const chatRoomSvc = new ChatRoomService([])
  this.scope.register(chatRoomSvc)
}
// 页面销毁 → scope.close() → unload 所有注册的服务

Scope 查找机制:优先从作用域内找,找不到回退到全局 ServiceManager。scope.get<T>('ChatRoomService') 获取页面级服务,scope.getGlobal<T>('AuthService') 获取全局服务。

小结

Neo 的完整技术路线:

组件 职责
Service 功能单元,声明式生命周期
NeoModule Koin 式模块声明,组合与校验
ServiceManager IoC 容器,依赖解析
Phase 多阶段加载,串行/并行策略
Observable Service 端可观察数据源
StateBinder Service 数据 → @State 响应式桥
Scope 页面级作用域,自动卸载

下一篇是最后一篇,用 SoulApp 示例串起全链路,并分享这个框架在工程实践中的一些感悟。


系列文章

事件流模型是什么和DOM事件模型等关系

(对事件流和DOM,BOM误解,所发出的疑惑) 问题涉及 JavaScript 事件机制的核心概念。我们来系统梳理:

“事件流模型”是什么?它和‘浏览器事件模型’、‘文档事件模型’有什么关系或区别?”

实际上,“浏览器事件模型”和“文档事件模型”并不是标准术语,它们很可能是对以下两个概念的模糊表述:

  • DOM 事件模型(Document Object Model Event Model):即 W3C 标准定义的事件处理规范。
  • 浏览器对事件流的具体实现:所有现代浏览器都遵循 W3C DOM 事件模型。

因此,更准确地说,我们应该讨论的是:


一、什么是 事件流模型(Event Flow Model)

事件流描述的是:当一个事件(如点击)发生时,浏览器如何在 DOM 树中传播这个事件的顺序

✅ W3C 标准定义的事件流包含 三个阶段

  1. 捕获阶段(Capturing Phase)

    • 事件从 windowdocumenthtmlbody → ... → 目标元素的父级
    • 目的:允许祖先元素“提前拦截”事件
  2. 目标阶段(Target Phase)

    • 事件到达实际触发的元素(即 e.target
    • 此时既可视为捕获结束,也可视为冒泡开始
  3. 冒泡阶段(Bubbling Phase)

    • 事件从目标元素 → 父元素 → ... → bodyhtmldocumentwindow
    • 这是最常用的阶段,事件委托就依赖它

🌰 举例:点击一个 <button>,事件会先“下潜”到 button(捕获),然后在 button 上触发(目标),再“上浮”回页面顶部(冒泡)。


二、什么是 DOM 事件模型(Event Model)

“事件模型”指的是 如何为元素绑定事件监听器的机制,历史上有三种主要模型:

模型 名称 特点 是否支持事件流
DOM0 级 原始模型 element.onclick = fn只能绑定一个处理函数简单、兼容性好 ❌ 不支持捕获✅ 部分浏览器以冒泡方式实现
IE 事件模型 IE 特有(已淘汰) attachEvent()只支持冒泡 ✅ 仅冒泡
DOM2 级 现代标准 addEventListener(type, handler, useCapture)可绑定多个监听器支持捕获/冒泡 ✅ 完整支持三阶段事件流

🔍 关键点

  • 事件流 是“事件如何传播”的过程
  • 事件模型 是“如何注册监听器”的接口规范
  • DOM2 级事件模型 实现了 完整的事件流

三、“浏览器事件模型” vs “文档事件模型”?——澄清误解

这两个说法并非官方术语,但可以这样理解:

说法 实际指代 说明
浏览器事件模型 浏览器对 W3C DOM 事件标准的实现 所有现代浏览器(Chrome、Firefox、Safari 等)都遵循 W3C DOM Level 2/3 Events 规范
文档事件模型 DOM(文档对象模型)中的事件处理机制 即 W3C 定义的 EventTarget.addEventListener 等 API

结论

它们本质上是同一套东西 —— 浏览器实现了 W3C 定义的 DOM 事件模型,而该模型规定了 事件流 的行为。


四、如何控制事件流?

虽然不能“关闭”事件流,但可以干预:

1. 在捕获 or 冒泡阶段监听

// 捕获阶段
parent.addEventListener("click", handler, true);

// 冒泡阶段(默认)
child.addEventListener("click", handler, false); // 或省略第三个参数

2. 阻止传播

e.stopPropagation(); // 阻止继续冒泡或捕获
e.stopImmediatePropagation(); // 还阻止同元素其他监听器执行

3. 判断当前处于哪个阶段

e.eventPhase === Event.CAPTURING_PHASE; // 1
e.eventPhase === Event.AT_TARGET; // 2
e.eventPhase === Event.BUBBLING_PHASE; // 3

五、总结:核心关系图

W3C DOM 事件标准
│
├── 定义了「事件模型」→ 如何绑定监听器(addEventListener)
│
└── 定义了「事件流」→ 事件传播的三阶段(捕获 → 目标 → 冒泡)
     │
     └── 浏览器(Chrome/Firefox等)实现了这一标准
          → 所谓“浏览器事件模型”就是 DOM 事件模型的实现

💡 记住

  • e.target 始终是最初触发事件的元素
  • e.currentTarget当前执行回调的监听器所绑定的元素
  • 默认监听的是冒泡阶段,这是事件委托的基础

掌握这些,你就真正理解了 JS 事件的底层逻辑!

把 Redis 持久化测试从 800 行 Shell 换成 30 行 pytest,排错效率翻了 10 倍

凌晨两点,我被报警电话炸醒——用户积分数据全部回滚到了 3 小时前。查了半天,发现是运维改了 redis.conf 里的 save 参数,RDB 快照从 5 分钟变成了 3 小时一次,节点重启后大量热数据直接蒸发。更憋屈的是,这个配置变更是“手工测试”通过的——那位同事把 Redis 重启了一下,看见 key 还在,就认为持久化没问题。我对着屏幕骂了一句:“这种测试,测了跟没测有什么区别?”

第二天,我把整个持久化验证体系直接推倒重来,用 pytest + Docker 搭了一套自动化测试方案,原来写 800 行 Shell 还要搞 2 小时的环境,现在 30 行 pytest 几分钟跑完,最关键的是:任何持久化配置上的骚操作,都能在 10 秒内给出“丢没丢数据”的铁证。

问题拆解:为什么用 Shell/Docker 手动测持久化等于没测?

Redis 的持久化有 RDB、AOF 以及二者混合三种模式,再加上 save 参数、appendfsync 策略、aof-use-rdb-preamble 等一堆配置项,组合爆炸。一般团队验证持久化的方式无非两种:

  1. 手动启停 Docker 容器redis-cli 写几条数据,docker restart 然后 KEYS * 看一眼——只验证了“能不能启动”,完全没验证“数据到底少了多少秒”。
  2. 写一堆 Shell 脚本,用 docker exec 操作用 redis-cli,然后 diff 数据——脚本又臭又长,而且每次环境不一样,docker stop 的等待时间、文件清理策略,稍微一变结果就飘。

根因很明确:Redis 的持久化是“时间窗口 + 系统信号 + 文件系统刷盘”共同决定的产物,手工操作根本做不到精确控制。比如,docker stop 默认给容器发 SIGTERM,Redis 收到后会尝试做一次 RDB 保存,但这个保存要花多久?会不会被 SIGKILL 截断?Shell 脚本根本没能力模拟“宕机瞬间数据能丢多少”这一类故障场景。更关键的是,一致性验证缺少可重复的断言——手工测试只能凭感觉说“大概没丢”,这对生产环境就是埋雷。

方案设计:为什么选 pytest + Docker,而不是 Testcontainers 或 K8s Job?

我要的是一套可编程、可断言、可复现的测试框架,核心要求:

  • 能精确控制 Redis 的启动参数和持久化配置
  • 能模拟真实故障:kill -9、断电式停服、AOF 文件截断等
  • 跑完后自动清理环境,不留脏数据
  • CI/CD 里能跑,本地也能一键跑

技术选型对比:

方案 优点 为什么不选
Shell + docker-compose 团队熟悉 断言弱,无法精确控制重启和信号,脚本维护噩梦
Testcontainers (Python) 原生集成 pytest,生命周期管理好 初始化后只能通过 redis-cli 操作参数?实际上配置变更(比如动态切换 AOF)需要再封装一层;且底层 docker-java 对 Python 不够友好,调试成本高
Kubernetes Job 生产级 太重,本地跑不了,CI 得配 K8s 集群,杀鸡用牛刀
docker-py + pytest 轻量,可编程控制容器生命周期,原生 Python 断言 这是我选的方案。直接用 docker SDK 启停容器、管理 Volume,用 redis-py 做数据读写,pytest fixture 做环境注入,整个方案不超过 500 行 Py 代码,CI 上跑只依赖 Docker daemon

架构思路上,我把测试分成三层:

  1. 基础设施层docker-py 创建 Redis 容器,挂载临时 Volume 存放 RDB/AOF 文件
  2. 操作层redis-py 写入、读取、执行 CONFIG SETBGSAVE 等命令
  3. 断言层:pytest 断言数据是否存在、文件是否生成、AOF 内容是否包含最后一条写入

这套分层让测试用例只关心“写什么数据 → 怎么死 → 起来后数据对不对”,而不用管容器怎么启动、挂载的路径是什么。

核心实现:可以立刻跑起来的测试代码

下面的代码解决一个问题:验证 RDB 持久化在 Redis 进程被 kill -9 杀掉后,最近一次 BGSAVE 之后的数据是否全部丢失(按预期丢失,但不能多丢)

1. conftest.py:用 fixture 管理 Redis 容器生命周期

# conftest.py
import pytest
import docker
import redis
import time
import os

REDIS_IMAGE = "redis:7.2"  # 固定版本,避免 CI 上拉取 latest 导致不一致

@pytest.fixture(scope="function")
def rdb_container(tmp_path):
    """
    启动一个配置了 RDB 持久化的 Redis 容器,数据文件写入临时目录。
    tmp_path 是 pytest 提供的临时路径,每个测试函数独立,互不干扰。
    """
    client = docker.from_env()
    data_dir = tmp_path / "data"
    data_dir.mkdir()
    
    container = client.containers.run(
        image=REDIS_IMAGE,
        name=f"redis-rdb-test-{os.getpid()}",  # 避免容器重名
        command=[
            "redis-server",
            "--save 900 1",        # 900秒内至少1次修改则保存,这里故意设大,手动控制BGSAVE
            "--save 300 10",
            "--save 60 10000",
            "--dir /data",
            "--dbfilename dump.rdb"
        ],
        volumes={str(data_dir): {"bind": "/data", "mode": "rw"}},
        ports={"6379/tcp": None},  # 让 Docker 分配随机端口
        detach=True,
        remove=True          # 容器停止后自动删除,不留垃圾
    )
    # 等待 Redis 就绪
    port = int(container.attrs["NetworkSettings"]["Ports"]["6379/tcp"][0]["HostPort"])
    r = redis.Redis(host="localhost", port=port, decode_responses=True)
    for _ in range(30):
        try:
            if r.ping():
                break
        except redis.ConnectionError:
            time.sleep(0.1)
    else:
        raise RuntimeError("Redis 容器启动超时")

    yield {"container": container, "client": r, "data_dir": str(data_dir)}

    # teardown:确保容器被干掉(即使已经 remove=True 但以防万一)
    try:
        container.kill()
    except docker.errors.APIError:
        pass

这段代码解决了什么? 过去手工测试最怕“上次跑的容器没停干净”或者“数据文件残留污染下次测试”,这个 fixture 用 tmp_path 给每个测试单独的文件目录,容器用完就自动删除,环境彻底隔离。

2. test_rdb_crash_consistency.py:验证 kill -9 后的数据一致性

# test_rdb_crash_consistency.py
import time
import os
import signal

def test_rdb_persistence_after_bgsave_and_kill9(rdb_container):
    """
    场景:做一次 BGSAVE,写入新数据,然后 kill -9 杀掉 Redis。
    预期:重启后只有 BGSAVE 之前的数据,BGSAVE 之后写入的全丢。
    """
    r = rdb_container["client"]
    container = rdb_container["container"]
    
    # 阶段1:写入一批永久数据并保存
    r.set("perm:user:1", "alice")
    r.set("perm:score", 100)
    r.bgsave()
    # 等待 BGSAVE 完成
    while r.info("persistence").get("rdb_bgsave_in_progress") == 1:
        time.sleep(0.1)
    
    # 阶段2:再写入一批“易失”数据,不执行保存
    r.set("temp:session", "abc123")
    r.set("temp:cart", 42)
    
    # 阶段3:模拟宕机——直接 SIGKILL
    container.kill(signal="SIGKILL")
    # 等待容器退出
    try:
        container.wait(timeout=10)
    except:
        pass
    
    # 阶段4:用相同数据目录重新启动容器
    docker_client = __import__("docker").from_env()
    data_dir = rdb_container["data_dir"]
    container2 = docker_client.containers.run(
        image="redis:7.2",
        command=["redis-server", "--dir /data", "--dbfilename dump.rdb"],
        volumes={data_dir: {"bind": "/data", "mode": "rw"}},
        ports={"6379/tcp": None},
        detach=True,
        remove=True
    )
    port2 = int(container2.attrs["NetworkSettings"]["Ports"]["6379/tcp"][0]["HostPort"])
    r2 = __import__("redis").Redis(host="localhost", port=port2, decode_responses=True)
    time.sleep(0.5)
    
    # 断言:持久化数据必须还在
    assert r2.get("perm:user:1") == "alice", "持久 key 丢失,RDB 恢复失败"
    assert r2.get("perm:score") == "100", "数字 key 应恢复为字符串,Redis 里数值自动编码"
    # 断言:未持久化数据应该全部丢失
    assert r2.get("temp:session") is None, "未执行 BGSAVE 的数据不应该恢复"
    assert r2.get("temp:cart") is None, "掉电后未刷入 RDB 的数据必须丢失,否则不符合预期"
    
    container2.kill()

这个测试用例用最暴力的 SIGKILL,验证了 RDB 的“一致性恢复边界”:上一条 BGSAVE 之前的数据一个不丢,之后的全部消失,不多不少。过去靠手工 docker restart 根本模拟不了 SIGKILLrestart 默认发 SIGTERM,Redis 会优雅保存),所以很多团队根本不知道自己的 Redis 在意外断电时会丢多少数据。

3. 参数化测试:一次覆盖 RDB / AOF / 混合持久化

import pytest
from redis import Redis
# 这段代码解决“各种持久化配置下数据恢复行为”的批量验证,
# 用 pytest.mark.parametrize 驱动不同启动命令,一个测试函数覆盖所有模式。

@pytest.mark.parametrize("redis_command", [
    pytest.param(["redis-server", "--save 60 1", "--dir /data"], id="rdb"),
    pytest.param(["redis-server", "--appendonly yes", "--appendfsync everysec", "--dir /data"], id="aof"),
    pytest.param(["redis-server", "--appendonly yes", "--aof-use-rdb-preamble yes", "--dir /data"], id="mixed"),
])
def test_data_survives_graceful_shutdown(tmp_path, redis_command):
    client = __import__("docker").from_env()
    data = tmp_path / "data"
    data.mkdir()
    import os
    container = client.containers.run(
        image="redis:7.2",
        command=redis_command,
        volumes={str(data): {"bind": "/data", "mode": "rw"}},
        ports={"6379/tcp": None},
        detach=True, remove=True
    )
    port = int(container.attrs["NetworkSettings"]["Ports"]["6379/tcp"][0]["HostPort"])
    r = Redis(host="localhost", port=port, decode_responses=True)
    # 等待启动
    import time
    for _ in range(20):
        try:
            r.ping()
            break
        except:
            time.sleep(0.1)
    r.set("key", "graceful-shutdown-test")
    # 优雅停止容器(发送SIGTERM)
    container.stop(timeout=10)
    # 重新从同一数据目录启动
    container2 = client.containers.run(
        image="redis:7.2",
        command=redis_command,
        volumes={str(data): {"bind": "/data", "mode": "rw"}},
        ports={"6379/tcp": None},
        detach=True, remove=True
    )
    port2 = int(container2.attrs["NetworkSettings"]["Ports"]["6379/tcp"][0]["HostPort"])
    r2 = Redis(host="localhost", port=port2, decode_responses=True)
    time.sleep(0.5)
    assert r2.get("key") == "graceful-shutdown-test", f"{redis_command} 模式下数据丢失"
    container2.kill()

这里用一个 tmp_path fixture 加参数化命令,把 RDB、AOF、混合持久化三种模式一把梭测试,总共不到 40 行。

踩坑记录:官方文档不会告诉你的两个大坑

坑一:docker-compose down 不保证 redis-cli SHUTDOWN SAVE

现象:在 CI 中用 docker-compose down 停 Redis,偶尔出现 RDB 文件损坏,测试随机失败,但本地跑没问题。

原因docker stop 发 SIGTERM,等待 10 秒后强杀。如果 Redis 正在进行 BGSAVE 且数据量大,10 秒没写完,SIGKILL 直接截断文件。更恶心的是,如果容器启动时用 --save 配置了自动保存,Redis 在收到 SIGTERM 时会再次触发一次 BGSAVE,导致在 10 秒临界区内出现“双重保存”,文件损坏概率翻倍。

解决:在测试中不要依赖 SIGTERM 触发保存,而改用显式命令:先执行 redis-cli BGSAVE,确认 lastsave 时间戳更新后,再 container.kill(signal="SIGKILL") 强制杀掉。这才能真正模拟“写入 + 断电”的故障模型,而且测试结果稳定。

坑二:AOF rewrite 期间,FLUSHALL 命令导致的“幽灵数据”

现象:测试 AOF 恢复时,先写大量数据,触发自动 rewrite,然后执行 FLUSHALL 清空所有 key,再重启容器,发现部分 key 居然还在!

原因:Redis AOF rewrite 是后台子进程根据当前内存数据集写一份新的 AOF 文件,完成后原子替换旧文件。如果在 rewrite 过程中执行了 FLUSHALL,主进程立刻清空内存,但子进程的 rewrite 还在用旧数据集生成 AOF。结果:rewrite 完成后,AOF 文件里其实又包含了旧数据,替换后 FLUSHALL 的效果就被“抹掉”了。Redis 官方文档在持久化章节提到了 rewrite 流程,但没有明确强调这个并发语义陷阱。

解决:测试 AOF 时必须严格等待 INFO PERSISTENCEaof_rewrite_in_progress 变成 0 后,再执行 FLUSHALL,然后重启验证数据确实清空。或者用 CONFIG SET appendonly no; CONFIG SET appendonly yes 强制重置 AOF 文件后再操作。

效果验证:用数据说话

原来团队用 800 多行 Shell 脚本,跑完一套“重启-恢复-对比”流程平均耗时 47 分钟(其中 30 分钟都花在等待 docker restart 和人工核对上),而且经常因为测试环境残留导致“假通过”。换成 pytest + Docker 方案后:

指标 优化前 优化后
单次全场景测试时间 47 min 3.2 min
持久化场景覆盖率 2 种(rdb, aof) 7 种(含混合、rewrite、kill -9)
测试结果可靠性 经常假通过 100% 可复现
新增一个测试用例成本 改 Shell,半天 加 10 行 Python,5 分钟

效率提升不是“感觉上快了”,而是从根本上去掉了人为判断环节,机器告诉你丢没丢、丢了多少。

可直接用的代码/工具

如果你不想从零搭建,我把这套测试模板抽成了一个 repo:redis-persist-pytest,里面包含所有 fixture 和参数化用例。本地只需:

git clone https://github.com/baofugege/redis-persist-pytest
cd redis-persist-pytest
pip install redis docker pytest
pytest -v

CI 上跑也是这一行,Docker-in-Docker 模式下稍作调整即可。


#Python #Redis #性能测试 #自动化测试 #后端

关于作者
实战派后端/架构开发者,专注 Python 性能优化与分布式系统可靠性。
GitHub: github.com/baofugege (上面有本文完整测试套件)
Sponsor: github.com/sponsors/ba… — 如果这篇文章帮你省下几十小时排错时间,欢迎请我喝杯咖啡
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege

深度剖析浏览器跨域问题

跨域是前后端分离架构下最常见的技术问题之一,绝大多数开发者仅停留在“配置代理、开CORS”的解决层面,却不懂底层浏览器安全逻辑、请求校验机制与各方案的适用边界。本文将从同源策略底层原理切入,拆解跨域产生的核心根源、跨域请求分类、8种主流解决方案的实现逻辑与优劣,同时梳理开发、测试、生产全环境最佳实践与高频避坑点,完整覆盖面试考点与企业级落地场景,可作为前端进阶学习笔记与技术分享文档。

一、前置认知:跨域的本质不是Bug,是浏览器安全机制

1.1 什么是同源策略(Same-Origin Policy, SOP)

同源策略是现代浏览器内置的核心安全基石,是浏览器厂商为解决Web安全漏洞、保护用户隐私数据设计的强制约束规则,并非业务代码缺陷、服务器故障导致的Bug。

其核心规则:仅当两个URL的协议、域名、端口号三者完全一致时,才判定为同源;任意一项不同,即为跨域。非同源页面在无明确授权的前提下,禁止互相读写DOM节点、读取Cookie、LocalStorage、发送AJAX/Fetch数据请求。

1.2 同源精准判定规则(附实战案例)

以基准地址 http://localhost:5173(前端常用开发地址)为参照,详细区分同源/跨域场景:

请求地址 对比差异 是否跨域 原因说明
http://localhost:5173/api 无差异 协议、域名、端口完全一致,仅路径不同,属于同源
https://localhost:5173/api 协议不同(http/https) 协议是核心校验项,http与https视为完全不同源
http://127.0.0.1:5173/api 域名不同(localhost/127.0.0.1) 域名字面量不一致,浏览器严格区分,不做自动兼容
http://localhost:3000/api 端口不同(5173/3000) 端口是同源校验必要项,不同端口直接判定跨域
test.localhost:5173 子域名不同 默认严格校验完整域名,子域名不同属于跨域

1.3 浏览器为什么必须限制跨域?(安全核心逻辑)

同源策略的核心目的是防范CSRF跨站请求伪造、XSS数据窃取等恶意攻击,模拟真实攻击场景即可直观理解:

假设用户登录了银行官网 https://bank.com,浏览器保存了登录态Cookie;此时用户误打开恶意网站 https://hack.com。若无同源策略限制,恶意网站可直接通过JS发起请求,调用银行接口、读取用户账户数据、模拟转账操作,造成用户财产损失。

同源策略的拦截逻辑:允许跨域发送请求、携带Cookie,但是浏览器会拦截响应结果,禁止前端JS读取响应数据。这是绝大多数开发者的认知盲区:跨域不是请求发不出去,而是响应无法被前端获取

二、深度拆解:浏览器跨域请求的两大分类

W3C将跨域请求分为简单请求预检请求(复杂请求),两类请求的浏览器校验逻辑、拦截时机、配置方式完全不同,是解决CORS跨域的核心依据。

2.1 简单请求(无需预检,直接放行请求)

满足全部条件即为简单请求

  1. 请求方法仅限:GET、POST、HEAD

  2. 自定义请求头仅限:Accept、Accept-Language、Content-Language、Content-Type;

  3. Content-Type仅限:text/plainmultipart/form-dataapplication/x-www-form-urlencoded

执行流程

浏览器直接发起真实请求 → 服务器返回响应 → 浏览器校验响应头跨域字段 → 校验通过则前端读取数据,校验失败则控制台抛出跨域错误。

2.2 预检请求(复杂请求,先发OPTIONS试探)

任意一条不满足简单请求条件,即为复杂请求

常见场景:使用PUT/DELETE请求、携带Token自定义请求头、Content-Type为application/json等。

执行流程(两步请求)

第一步(OPTIONS预检):浏览器自动发起OPTIONS试探请求,携带请求方法、请求头信息,询问服务器是否允许当前跨域请求;

第二步(真实请求):服务器校验通过并返回允许跨域的响应头 → 浏览器发起真实业务请求;若预检失败,直接拦截真实请求,抛出跨域异常。

核心注意点:复杂请求跨域报错,优先排查后端是否配置了OPTIONS请求放行逻辑,这是高频踩坑点。

三、全量跨域解决方案:原理、实战、优劣与适用场景

所有跨域解决方案的核心逻辑只有两类:1. 让浏览器认为请求同源(代理、域名统一);2. 让服务器明确授权跨域(CORS)。下文按「生产优先级、实用程度」排序,剔除废弃方案,保留企业级可用方案。

3.1 CORS 跨域资源共享(生产首选、标准W3C方案)

核心定位:后端主导的标准跨域方案,无前端侵入、安全可控,是前后端分离项目生产环境最优解。

核心原理:服务器通过在响应头中添加跨域授权字段,主动告知浏览器「允许指定域名的跨域请求访问资源」,浏览器校验授权合法后,放行响应数据。

核心响应头字段详解

  1. Access-Control-Allow-Origin:允许跨域的域名,支持具体域名(生产推荐)、*(允许所有域名,禁止携带Cookie);

  2. Access-Control-Allow-Methods:允许的请求方法,适配复杂请求;

3.Access-Control-Allow-Headers:允许的自定义请求头(如Token);

  1. Access-Control-Allow-Credentials:是否允许携带Cookie、Token等凭证,开启后Origin禁止使用*;

  2. Access-Control-Max-Age:预检请求缓存时间,减少重复OPTIONS请求,优化性能。

方案优劣

✅ 优点:标准规范、支持所有请求方法、支持凭证携带、无架构侵入、安全性最高;

❌ 缺点:需要后端开发配合配置,前端无法独立完成。

适用场景:所有线上生产项目、常规接口跨域场景。

3.2 前端本地代理跨域(开发环境专属首选)

核心定位:前端独立解决开发环境跨域,零后端配合成本,仅本地生效,上线失效。

核心原理:同源策略仅限制浏览器与服务器的通信,不限制服务器与服务器通信。本地启动一个Node代理服务,前端请求本地同源代理地址,由代理服务转发请求至后端接口,绕过浏览器跨域拦截。

Vite代理实战配置(Vue/React通用)

// vite.config.js
export default {
  server: {
    proxy: {
      // 匹配所有/api开头的请求
      '/api': {
        target: 'http://127.0.0.1:3000', // 后端真实接口地址
        changeOrigin: true, // 开启跨域伪装,修改请求头Origin
        rewrite: (path) => path.replace(/^\/api/, '') // 去除请求路径中的/api前缀
      }
    }
  }
}

方案优劣

✅ 优点:前端独立配置、开箱即用、不污染生产代码、开发效率极高;

❌ 缺点:仅本地开发生效,打包上线后代理失效,无法解决生产跨域。

适用场景:本地开发、接口联调阶段。

3.3 Nginx反向代理跨域(生产高可用方案)

核心定位:运维层面解决跨域,零代码侵入,企业级大型项目主流方案。

核心原理:通过Nginx网关统一入口,将前端静态资源、后端接口映射到同一个域名、同一个端口,让浏览器判定为同源请求,从根源规避跨域问题。

核心Nginx配置

server {
    listen 80;
    server_name www.project.com; // 统一域名

    // 前端静态资源请求
    location / {
        root /usr/local/nginx/html/project;
        index index.html;
        try_files $uri $uri/ /index.html; // 适配SPA单页路由
    }

    // 后端接口请求转发
    location /api/ {
        proxy_pass http://127.0.0.1:3000/api/; // 后端服务地址
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

方案优劣

✅ 优点:零业务代码修改、性能优异、支持负载均衡、稳定性强、适配集群部署;

❌ 缺点:需要运维配置Nginx,需掌握基础运维知识。

适用场景:线上生产环境、企业级项目、高并发Web应用。

3.4 postMessage 跨窗口/iframe跨域通信(专属场景方案)

核心定位:专门解决不同域名窗口、iframe嵌套页面的跨域数据通信,不用于接口请求跨域。

核心原理:HTML5标准API,提供独立于同源策略的跨窗口通信通道,允许两个非同源窗口互相发送、接收数据,安全可控。

实战代码示例

// 父页面(主页面)向iframe子页面发送数据
const iframe = document.getElementById('iframe');
iframe.contentWindow.postMessage({ type: 'sendData', data: '测试数据' }, 'https://子页面域名.com');

// 子页面接收数据
window.addEventListener('message', (e) => {
  // 安全校验:仅接收指定域名的消息,防止恶意攻击
  if (e.origin !== 'https://主页面域名.com') return;
  console.log('接收跨域数据:', e.data);
});

方案优劣

✅ 优点:专属跨窗口通信、安全可控、兼容性好;

❌ 缺点:仅适用于页面通信,无法解决AJAX接口跨域。

适用场景:iframe嵌套页面、多标签页跨域传参、主副页面数据交互。

3.5 WebSocket 天然跨域(实时通信专属)

核心定位:WebSocket协议不受浏览器同源策略限制,天然支持跨域。

核心原理:同源策略是HTTP协议的安全约束,而WebSocket是独立的全双工通信协议,握手阶段虽基于HTTP,但通信链路建立后与HTTP无关,因此无跨域限制。

适用场景:聊天室、实时通知、直播弹幕、数据实时推送等实时交互场景。

3.6 document.domain 子域名跨域(小众专用)

核心限制:仅适用于主域名相同、子域名不同的场景(如 a.xxx.com 与 b.xxx.com)。

核心原理:手动降级两个页面的域名为主域名,规避子域名跨域限制,实现DOM、Cookie共享。

核心代码:两个页面同时执行 document.domain = 'xxx.com' 即可同源通信。

缺点:适用场景极窄,仅支持二级域名,无法用于完全不同域名的跨域,现代项目极少使用。

3.7 废弃方案:JSONP(坚决不推荐新项目使用)

JSONP利用script标签不受跨域限制的特性实现跨域,仅支持GET请求、无错误捕获、安全性差、无法传输复杂数据。目前所有现代项目已全面淘汰,仅需了解历史原理,禁止落地使用。

四、高频跨域报错与生产避坑指南

4.1 携带Cookie跨域失败

报错原因:前后端未统一开启凭证配置,或CORS配置中Origin使用*通配符。

解决方案

  1. 后端配置 Access-Control-Allow-Credentials: true

  2. 后端Origin禁止使用*,必须配置具体前端域名;

  3. 前端请求开启凭证:axios配置 withCredentials: true

4.2 复杂请求OPTIONS预检失败

报错原因:后端未放行OPTIONS预检请求,未配置自定义请求头授权。

解决方案:后端拦截所有OPTIONS请求,直接返回200状态码,同时配置Allow-Headers匹配前端自定义Token头。

4.3 开发环境正常,生产环境跨域

核心原因:前端代理仅本地生效,打包上线后代理配置失效,未配置生产CORS或Nginx代理。

解决方案:生产环境必须依赖后端CORS或Nginx反向代理,切勿依赖前端代理。

五、企业级跨域最佳实践(全环境标准流程)

5.1 本地开发环境

统一使用Vite/Webpack本地代理,前端独立解决跨域,无需后端配合,提升联调效率。

5.2 测试/生产环境(中小项目)

后端全局配置CORS跨域配置,精准放行前端域名,开启凭证支持,简单高效、快速落地。

5.3 测试/生产环境(大型集群项目)

优先使用Nginx反向代理,统一网关入口,兼顾跨域解决、负载均衡、动静分离,架构更稳定。

5.4 特殊场景

  1. iframe跨页面通信:强制使用postMessage;

  2. 实时推送业务:使用WebSocket天然跨域;

  3. 子域名项目:按需使用document.domain。

六、核心知识点总结

  1. 跨域不是请求发不出,是浏览器基于同源策略拦截响应,属于浏览器安全机制,非代码Bug;

  2. 同源判定三要素:协议、域名、端口,三者必须完全一致;

  3. 跨域请求分简单请求、复杂请求,复杂请求必走OPTIONS预检;

  4. 方案分层:开发用前端代理、生产用CORS/Nginx、特殊场景用专属方案;

  5. 安全原则:生产环境禁止使用*通配符跨域,精准配置授权域名,开启凭证校验。

前端测试:别为了100%覆盖率而写测试,那是自欺欺人

你写了测试,覆盖率100%,感觉稳了。结果上线后,用户点了个按钮,页面直接白屏。你纳闷:覆盖率不是100%吗?因为你测的都是“天气好不好”,没测“会不会地震”。今天我们就来聊聊前端测试的正确姿势——怎么测才能真的有用,而不是为了指标好看写一堆废话。

前言

前端测试常走两个极端:要么完全不测,上线随缘;要么为了覆盖率,测了等于没测(比如测个1 + 1 = 2)。真正有效的测试,不是越多越好,而是该测的测,不该测的别浪费生命

今天我们用“测试金字塔”模型,帮你理清单元测试、组件测试、E2E测试的分工。看完你会知道:哪部分代码必须测,哪部分可以跳过,哪部分用哪个工具。

一、测试金字塔:三分天下,各司其职

       /\
      /E2E\      ← 少而精,关键路径
     /------\
    /集成测试\    ← 中等,组件间交互
   /----------\
  /  单元测试   \  ← 多而快,纯逻辑
 /--------------\
  • 底座(单元测试):测最小的代码单元(函数、工具类)。多、快、便宜。
  • 中层(组件测试/集成测试):测几个单元结合后的行为(比如一个表单组件提交数据)。
  • 顶层(E2E测试):模拟真实用户,测整个流程(从打开页面到点击到结果)。

比例大概是:单元测试占70%,集成测试20%,E2E 10%。不是死规定,但原则:底层测试成本低,多写;顶层测试维护成本高,只写关键路径

二、单元测试:测逻辑,不测实现细节

单元测试的目标:给定输入,输出是否正确。不关心函数内部怎么实现的,只关心结果。

适合测的

  • 纯函数(输入输出确定,无副作用)。
  • 业务规则(比如calculateDiscount(price, level))。
  • 工具函数(formatDateparseQuery)。

不适合测的(测了也白测):

  • 框架内部逻辑(React的setState、Vue的响应式——那是框架的事)。
  • 简单的getter/setter。
  • 常量定义。

工具:Jest + Vitest(Vite项目推荐Vitest)。

例子

// 要测的函数
function formatPrice(price, currency = '¥') {
  return `${currency}${price.toFixed(2)}`;
}

// 测试
test('格式化价格', () => {
  expect(formatPrice(10.5)).toBe('¥10.50');
  expect(formatPrice(10.5, '$')).toBe('$10.50');
});

黄金法则:如果重构代码不破坏测试,说明你测的是行为,不是实现。

三、组件测试:测交互,不测样式

组件测试(React Testing Library / Vue Test Utils)的目标:模拟用户行为,检查组件渲染和交互是否正确。不关心DOM结构细节,只关心用户能看到什么、能做什么。

适合测的

  • 根据props渲染正确的内容。
  • 点击按钮触发正确回调。
  • 表单输入后数据变化。
  • 异步加载显示loading状态。

不适合测的

  • CSS样式(那是视觉回归测试的事,交给视觉测试工具)。
  • 内部state的具体值(优先测渲染结果)。
  • 第三方UI库的行为(假设它没问题)。

工具:React Testing Library + Jest(官方推荐),Vue Test Utils + Vitest。

例子(React Testing Library):

import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';

test('点击按钮增加计数', () => {
  render(<Counter />);
  const button = screen.getByText('增加');
  fireEvent.click(button);
  expect(screen.getByText('计数: 1')).toBeInTheDocument();
});

原则:测用户能看到的东西,不要测内部实现。

四、E2E测试:测关键用户旅程,不测所有交互

E2E测试模拟真实浏览器,跑完整的用户流程。它最像真实用户,但也最慢、最脆弱(网络波动、页面改动容易挂)。

适合测的(3-5个核心流程):

  • 登录 → 访问个人主页 → 修改头像。
  • 搜索商品 → 加入购物车 → 结算 → 支付成功。
  • 未登录访问受保护页面 → 跳转到登录页。

不适合测的

  • 每个细节(比如每个按钮的悬浮效果)。
  • 容易变化的面包屑导航。
  • 第三方依赖的页面。

工具:Cypress(最友好)、Playwright(更可靠)、Puppeteer(较底层)。

例子(Cypress):

describe('用户登录', () => {
  it('输入正确账号密码后跳转到首页', () => {
    cy.visit('/login');
    cy.get('[data-cy=username]').type('user@example.com');
    cy.get('[data-cy=password]').type('password123');
    cy.get('[data-cy=submit]').click();
    cy.url().should('include', '/dashboard');
    cy.contains('欢迎回来', { timeout: 10000 });
  });
});

维护技巧:给关键元素加上data-cy属性,避免改样式或文本时测试挂掉。

五、测试覆盖率的谎言

很多团队追求100%覆盖率,结果工程师花大量时间测无关紧要的代码(比如测Redux的action creator是个纯对象)。覆盖率工具(Istanbul)只能告诉你“哪些代码没执行过”,不能告诉你“没测到的重要逻辑”。有时100%覆盖率,却漏掉了一个关键的空值判断。

正确的覆盖率指标

  • 核心业务逻辑达到80%以上就行。
  • UI组件覆盖率参考即可,不必强求。
  • 关注未覆盖的重要代码,而不是数字。

六、组合策略:一个电商网站的例子

  • 单元测试:计算折扣、格式化价格、校验表单规则。Jest跑得快,每次提交都跑。
  • 组件测试:商品卡片(渲染正确信息)、购物车弹窗(增加/删除商品)、地址表单(提交按钮禁用直到填写完整)。
  • E2E测试:1. 用户搜索“手机” → 2. 添加第一个商品到购物车 → 3. 登录 → 4. 结算 → 5. 确认订单。就这一个核心流程,保证不崩。

日常开发:单元测试 + 组件测试在CI里跑(每次push)。E2E测试单独流水线,部署前跑一次(因为慢)。

七、测试不是银弹,别为了写而写

  • 重构旧代码,没测试?先别补。补一个挂一个,浪费时间。优先补新功能。
  • 一个bug反复出现,才需要补测试
  • UI改得频繁的区域,不写E2E,写组件测试更稳。

测试是手段,不是目的。目的是信心:当你改完代码,测试全绿,你能放心上线。

八、总结:测试就像买保险

  • 单元测试:车险,便宜,必须买。
  • 组件测试:医疗险,中等,按需买。
  • E2E测试:地震险,贵,只买最关键的。

别买一大堆没用的险,也别裸奔。

我自己写的第一个skills--project-core-standards

背景

用 AI 写代码一段时间后,我发现一个很反直觉的问题:我们其实已经有一些“最佳实践”,但它们无法复用:

  • A 项目调教好的 AI,在 B 项目完全失忆
  • 规则散落在 prompt / 文档 / IDE 配置中,无法版本化
  • 每次新项目,都在重复“驯化 AI”

既然代码可以用 Git 管理、用 NPM 分发,为什么 AI 规范还停留在“复制粘贴”?

本质问题是:我一直把规则当“文本”,而不是“代码”。

把规则当代码看

如果把 AI 规则当作代码,它应该具备三个能力:

  • 可组合(Composable) → 不同规则可以拆分、复用
  • 可分发(Distributable) → 像 npm 包一样安装
  • 可演进(Versioned) → 有版本、有变更记录

否则它就不是工程资产,而只是碎片化经验沉淀。一个规范,如果不能被 install,那它本质上只是不成体系的经验。

Skill 的最小抽象模型

那问题来了:一个“可安装的 AI 规范”,在工程上到底长什么样?

最小结构其实非常简单:

my-skill/
├── SKILL.md
├── rules/
├── package.json

但真正的关键不是结构,而是它解决的问题。


1️⃣ rules目录 让 AI “分块理解”,而不是“整体吞咽”

传统方式是把所有规则写在一个 prompt 里,但这会导致:

上下文污染 + 规则冲突 + AI 记忆漂移

拆分之后:

  • behavior rules:开发行为约束
  • optimization rules:代码质量优化规则

AI 不再“理解一坨规则”,而是按职责加载规则上下文

2️⃣ SKILL.md 让 AI 知道“自己在哪个体系里”

AI 最大的问题不是不会写代码,而是:

不知道当前约束体系是什么

SKILL.md 本质是一个“运行时契约”:

name: project-core-standards
description: 项目的核心代码规范、行为准则与架构要求
version: 1.0.0
author: Admin

它定义的不是规则内容,而是:规则系统的身份边界

3️⃣ package.json 从“规则文件”升级为“能力模块”

一旦进入 npm 体系,规则就发生了本质变化: 从“文档”变成“可安装能力”

真实使用方式:一行命令安装自定义skills

这套自定义的skills最终是这样被使用的:

npx project-core-standards init

执行后,会进入一个交互式初始化流程:

Welcome to Project Core Standards Setup

Please select the IDEs you want to generate rules for:
[1] Cursor (.cursorrules)
[2] Windsurf (.windsurfrules)
[3] Antigravity / Gemini (GEMINI.md)
[4] GitHub Copilot (.github/copilot-instructions.md)
[5] Cline / Roo Code (.clinerules)
[6] Codex (.codexrules)
[A] All of the above

Enter your choices (e.g., 1,3 or A):

这一步的意义非常关键:同一套规则,可以适配所有主流 AI 编程环境**

也就是说:不再是“适配工具”,而是“统一规则源”

最终 Skill 的形态(project-core-standards)

最终,我把这套系统封装成了一个 npm 包: project-core-standards

它的核心结构如下:

---
name: project-core-standards
description: 项目的核心代码规范、行为准则与架构要求。适用于所有需要编写代码、重构或进行代码审查的场景。
version: "1.0.0"
author: "Admin"
---

两个核心规则(真正落地的部分),Skill 的真正价值,不是结构,而是规则本身。

1. Agent 行为与全局开发规范

涵盖核心开发底线:

- commit 规范化(Conventional Commits)
- pnpm 作为唯一包管理方式
- Vue 项目结构约束
- TypeScript 强制类型约束
- 数据库变更必须可追踪
- 组件必须可复用、不可重复造轮子

这个规则解决的是:AI 写代码“失控”的问题

2. 代码简化与优化专家原则

核心目标:保持功能不变的前提下优化代码质量

原则包括:

- 优先简化逻辑,而不是增加抽象
- 删除重复代码,而不是复制模式
- 提升可读性优先于“设计模式正确性”
- 避免过度工程化
- 保持结构一致性

这个规则解决的是: AI 过度设计 / 复杂化代码的问题

真正的难点:无损同步机制

分发不是问题,问题是: 如何更新规则,而不破坏项目已有定制? 这里的设计核心是 Marker:

<!-- BEGIN: project-core-standards -->
<!-- END: project-core-standards -->

同步逻辑:

  • 检测 marker → 精准替换区块
  • 无 marker → 自动安全注入

本质是: 局部 patch,而不是文件 overwrite

工程实现关键点,在 CLI 层:

  • 使用 INIT_CWD 定位真实项目路径
  • install 阶段自动触发同步
  • 基于 AST + regex 做安全替换

核心思想是:把 Git 的 diff 能力,搬进 AI 规则系统**

结语:当规则变成基础设施

引入 project-core-standards 后,开发流程变成:

以前:

新项目 → prompt 调教 → 规则迁移 → 人工同步

现在:

npx init → 自动生成规则体系

当 AI 成为开发流程的一部分,一个新的层级出现了:

  • 应用代码层
  • 工程工具层
  • AI 规则层(Skill)

而 Skill 的意义是: 让 AI 行为本身,变成可工程化管理的资产

未来可能会变成这样:不再“调教 AI”, 而是“安装开发规范”。想了解详细的规则内容可以点击这里查看。

wagmi v2 多链钱包切换:一个 Uniswap 仿盘项目让我踩了三天坑

背景

上个月,我接手了一个"Uniswap 精简版"项目——一个支持 Ethereum、Polygon、Arbitrum 三条链的 DEX 前端。项目用 wagmi v2 + RainbowKit 做钱包连接,React + Vite 开发。需求听起来很简单:用户连接钱包后,能选择任意一条链进行交易,并且钱包会自动切换到对应链。

我当时想,wagmi 不是有 useSwitchChainuseAccount 吗?直接调用就完事了。结果呢?我花了整整三天,经历了无数个"为什么钱包没反应"、"为什么链没切换但页面状态变了"的抓狂时刻。这篇文章,就是把我踩过的坑和最终的解决方案完整记录下来。

问题分析

一开始,我的思路很直接:用 useAccount 获取当前链 ID,用 useSwitchChain 切换链。代码大概长这样:

// 我最初的错误写法
const { chain } = useAccount();
const { switchChain } = useSwitchChain();

const handleChainChange = (targetChainId: number) => {
  if (chain?.id !== targetChainId) {
    switchChain({ chainId: targetChainId });
  }
};

看起来没问题对吧?但实际运行时,问题来了:

问题 1: 在 MetaMask 上切换链后,useAccount 返回的 chain 更新了,但 UI 上的交易对信息没有更新。我明明用了 useEffect 监听 chain 变化,但页面就是不刷新。

问题 2: 切换到一条不支持的链(比如用户自己添加了 BSC)时,useSwitchChain 会报错,但错误信息非常不友好,而且 chain 状态会被污染。

问题 3: 最诡异的是——当用户手动在钱包里切换链,而不是通过我写的按钮切换时,useSwitchChain 根本不会触发,但 useAccountchain 变了。这就导致我的代码里有两套"当前链":一套来自按钮操作,一套来自钱包事件,它们经常不同步。

排查了两天,我翻遍了 wagmi 的文档和 GitHub Issues,终于发现了关键点:wagmi v2 中 useAccountchain 是只读的,它只反映钱包当前连接的链,不会触发 React 组件的重新渲染(至少在特定场景下)。而 useSwitchChain 返回的 isSuccess 状态才是可靠的切换完成标志。

核心实现

1. 重新理解 wagmi v2 的状态管理

我做的第一件事,是抛弃了"用 useAccount 驱动 UI"的思维。wagmi v2 推荐的做法是:useChainId 获取当前链 ID,用 useSwitchChain 处理切换,用 useEffect 监听切换完成事件

这里有个坑:useChainId 返回的是 wagmi 配置中的当前链 ID,而不是钱包实际连接的链 ID。如果用户手动在钱包里切换,useChainId 不会自动更新!所以,我最终决定自己维护一个"同步的链状态"。

我创建了一个自定义 hook useSyncedChain

// hooks/useSyncedChain.ts
import { useChainId, useSwitchChain, useAccount, usePublicClient } from 'wagmi';
import { useEffect, useState, useCallback } from 'react';

export function useSyncedChain() {
  // 从 wagmi 获取基础状态
  const configChainId = useChainId(); // wagmi 配置中的链 ID
  const { chain: accountChain, isConnected } = useAccount(); // 钱包实际连接的链
  const { switchChain, isPending, error } = useSwitchChain();
  const publicClient = usePublicClient(); // 用来做链验证

  // 我们自己的"权威"链 ID
  const [activeChainId, setActiveChainId] = useState<number>(configChainId);

  // 核心逻辑:同步钱包状态和配置状态
  useEffect(() => {
    if (!isConnected || !accountChain) {
      // 未连接时,使用配置默认链
      setActiveChainId(configChainId);
      return;
    }

    // 如果钱包连接的链和配置链不同,说明用户手动切换了
    if (accountChain.id !== configChainId) {
      // 这里有个坑:不要直接 setActiveChainId,因为配置链可能不支持
      // 应该检查 accountChain 是否在我们支持的链列表中
      const supportedChains = [1, 137, 42161]; // Ethereum, Polygon, Arbitrum
      if (supportedChains.includes(accountChain.id)) {
        setActiveChainId(accountChain.id);
      } else {
        // 不支持的话,尝试切回默认链
        switchChain({ chainId: configChainId });
      }
    } else {
      setActiveChainId(configChainId);
    }
  }, [configChainId, accountChain, isConnected, switchChain]);

  // 封装的切换函数
  const switchToChain = useCallback(async (targetChainId: number) => {
    try {
      await switchChain({ chainId: targetChainId });
      // switchChain 成功后,wagmi 会自动更新 configChainId
      // 但为了保险,我们手动更新
      setActiveChainId(targetChainId);
    } catch (err) {
      console.error('切换链失败:', err);
      throw err;
    }
  }, [switchChain]);

  return {
    activeChainId,
    switchToChain,
    isSwitching: isPending,
    error,
  };
}

这个 hook 的核心思路是:不要信任任何一个单一来源,而是用钱包状态、配置状态、用户操作事件三者做交叉验证

2. 处理链切换后的数据刷新

链切换后,我们需要重新获取交易对数据、用户余额等。一开始我用 useEffect 监听 activeChainId,但发现会触发两次:一次是状态更新,一次是钱包实际切换完成。

后来我用了 wagmi 的 useWatchChainId 来做精细控制:

// hooks/useChainDataRefresh.ts
import { useEffect, useRef } from 'react';
import { useChainId } from 'wagmi';

export function useChainDataRefresh(callback: (chainId: number) => void) {
  const chainId = useChainId();
  const prevChainIdRef = useRef(chainId);

  useEffect(() => {
    // 只在链真正变化时触发,避免初始化时的重复调用
    if (prevChainIdRef.current !== chainId) {
      console.log(`链已切换: ${prevChainIdRef.current} -> ${chainId}`);
      callback(chainId);
      prevChainIdRef.current = chainId;
    }
  }, [chainId, callback]);
}

然后在组件中使用:

// 在 Swap 组件中
const { activeChainId, switchToChain, isSwitching } = useSyncedChain();
const { data: pairData, refetch: refetchPair } = useQuery({
  queryKey: ['pair', activeChainId, tokenA, tokenB],
  queryFn: () => fetchPairData(activeChainId, tokenA, tokenB),
  enabled: !!activeChainId && !!tokenA && !!tokenB,
});

useChainDataRefresh((newChainId) => {
  // 链切换后,重新获取数据
  refetchPair();
  // 同时重置用户输入状态
  setTokenA('');
  setTokenB('');
});

3. 处理钱包手动切换和 UI 同步

最头疼的是用户手动在 MetaMask 里切换链。wagmi v2 的 useAccount 会更新,但 useChainId 不会。我之前的 useSyncedChain hook 已经通过 accountChain 处理了这种情况,但还有一个细节:切换完成后,需要等待钱包确认,期间 UI 应该显示加载状态

我添加了一个"切换中"的状态管理:

// 在 useSyncedChain 中增加 pendingChainId
const [pendingChainId, setPendingChainId] = useState<number | null>(null);

const switchToChain = useCallback(async (targetChainId: number) => {
  setPendingChainId(targetChainId);
  try {
    await switchChain({ chainId: targetChainId });
    setPendingChainId(null);
    setActiveChainId(targetChainId);
  } catch (err) {
    setPendingChainId(null);
    throw err;
  }
}, [switchChain]);

// 在 UI 中显示加载
const isLoading = isSwitching || pendingChainId !== null;

4. 最终的多链切换组件

把所有逻辑整合到一个组件中:

// components/ChainSwitcher.tsx
import { useSyncedChain } from '../hooks/useSyncedChain';
import { useChainDataRefresh } from '../hooks/useChainDataRefresh';
import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';

const SUPPORTED_CHAINS = [
  { id: 1, name: 'Ethereum', nativeCurrency: 'ETH' },
  { id: 137, name: 'Polygon', nativeCurrency: 'MATIC' },
  { id: 42161, name: 'Arbitrum', nativeCurrency: 'ETH' },
];

export function ChainSwitcher() {
  const { activeChainId, switchToChain, isSwitching, error } = useSyncedChain();

  // 链切换后刷新数据
  useChainDataRefresh((chainId) => {
    console.log('链已切换,刷新数据');
    // 这里可以触发其他数据获取
  });

  const handleChainClick = async (chainId: number) => {
    if (chainId === activeChainId) return;
    try {
      await switchToChain(chainId);
      // 切换成功后,UI 会自动更新,因为 activeChainId 变了
    } catch (err) {
      // 显示错误 toast
      alert(`切换失败: ${(err as Error).message}`);
    }
  };

  return (
    <div>
      <h2>选择链</h2>
      {SUPPORTED_CHAINS.map((chain) => (
        <button
          key={chain.id}
          onClick={() => handleChainClick(chain.id)}
          disabled={isSwitching}
          style={{
            fontWeight: chain.id === activeChainId ? 'bold' : 'normal',
            opacity: isSwitching ? 0.5 : 1,
          }}
        >
          {chain.name} ({chain.nativeCurrency})
          {isSwitching && ' 切换中...'}
        </button>
      ))}
      {error && <p style={{ color: 'red' }}>错误: {error.message}</p>}
    </div>
  );
}

完整代码

我把所有代码整合到一个可运行的示例中。假设你使用 Vite + React + TypeScript,安装依赖:

npm install wagmi viem @tanstack/react-query react
// main.tsx - 入口文件
import { WagmiProvider, createConfig, http } from 'wagmi';
import { mainnet, polygon, arbitrum } from 'wagmi/chains';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RainbowKitProvider, getDefaultConfig } from '@rainbow-me/rainbowkit';
import { ChainSwitcher } from './components/ChainSwitcher';

const config = createConfig({
  chains: [mainnet, polygon, arbitrum],
  transports: {
    [mainnet.id]: http(),
    [polygon.id]: http(),
    [arbitrum.id]: http(),
  },
});

const queryClient = new QueryClient();

function App() {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          <ChainSwitcher />
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

export default App;
// hooks/useSyncedChain.ts - 上面已给出完整代码
// hooks/useChainDataRefresh.ts - 上面已给出完整代码
// components/ChainSwitcher.tsx - 上面已给出完整代码

踩坑记录

坑 1:useAccountchain 在切换后不会立即更新 现象:调用 switchChain 后,useAccount 返回的 chain 还是旧的,导致 UI 显示错误。解决:用 useChainId 配合 useEffect 监听,而不是依赖 useAccountchain

坑 2:useSwitchChainisSuccess 有时为 false 现象:钱包已经切换成功,但 isSuccess 一直是 false。原因:wagmi v2 中 isSuccess 只在第一次成功时为 true,后续切换不会重置。解决:用 errorisPending 做判断,或者自己维护状态。

坑 3:在非浏览器环境(如测试时)调用 switchChain 会报错 现象:在 Node.js 或 React Native 中,window.ethereum 不存在,导致切换失败。解决:用 try-catch 包裹,并在错误时回退到配置默认链。

坑 4:链切换后,之前订阅的事件没有清理 现象:切换到 Polygon 后,Ethereum 上的事件监听还在运行,导致内存泄漏。解决:在 useEffect 中返回清理函数,或者用 wagmi 的 watchContractEvent 自动管理。

小结

多链切换的核心不是调用 switchChain,而是同步钱包状态、配置状态和用户操作状态。wagmi v2 提供了基础工具,但需要自己组合成可靠的解决方案。如果你也遇到类似问题,可以试试我写的 useSyncedChain hook,或者深入看看 wagmi 的源码——里面有很多有趣的细节。

接下来,你可以探索如何用 wagmi 的 watchChainId 做更精细的控制,或者结合 viem 的 publicClient 做链验证。

在线PDF拆分工具核心JS实现

这篇只讲本项目里“PDF拆分”工具的功能层 JavaScript 实现。主流程可以概括为:

选择 PDF -> 读取页数 -> 生成拆分页组 -> 复制指定页面 -> 生成多个 PDF -> 单文件下载或 ZIP 打包下载

工具基于 Vue 组织交互状态,核心 PDF 操作使用 pdf-lib,多文件结果打包使用 JSZip,页面预览和书签读取由 pdfjs-dist 辅助完成。

在线工具网址:see-tool.com/pdf-split
工具截图:
工具截图.png

1. 文件进入流程前先做 PDF 判断

文件选择和拖拽上传共用同一套入口。真正加载前,先判断文件类型:

export function isPdfSplitFile(file) {
  if (!file) {
    return false;
  }

  var fileType = String(file.type || "").toLowerCase();
  var fileName = String(file.name || "");
  return fileType === "application/pdf" || /\.pdf$/i.test(fileName);
}

这里同时判断 MIME 和文件后缀,是因为部分浏览器环境下 file.type 可能为空,只依赖 MIME 会误拦正常 PDF。

加载文件时,会把同一份原始字节切成两份用途:

var rawBytes = await file.arrayBuffer();
var splitBytes = rawBytes.slice(0);
var previewBytes = rawBytes.slice(0);
var sourceDoc = await PDFDocument.load(splitBytes);

splitBytes 用于后续拆分,previewBytes 用于预览和书签读取。这样拆分主链路和辅助信息链路互不影响。

2. 页码输入解析成统一的拆分页组

拆分逻辑不是直接处理输入框字符串,而是先转成统一结构:

{
  label: "1-3",
  indices: [0, 1, 2]
}

label 用于文件命名,indicespdf-lib 需要的零基页码数组。

页码范围解析支持逗号分隔,也支持倒序区间:

function buildPageIndices(start, end) {
  var indices = [];
  var page;

  if (start <= end) {
    for (page = start; page <= end; page += 1) {
      indices.push(page - 1);
    }
    return indices;
  }

  for (page = start; page >= end; page -= 1) {
    indices.push(page - 1);
  }

  return indices;
}

所以用户输入 1-3,5,8-6 时,会生成三个输出段:第 1 到 3 页、第 5 页、第 8 到 6 页。

3. 多种拆分模式最终都归一到 groups

工具支持按页码范围、每 N 页、每页单独、奇偶页、可视化选择、书签、平均拆成 N 份。虽然入口不同,但最终都会变成 groups

buildSplitGroups: function () {
  if (this.splitMode === "ranges") {
    return parsePdfSplitRangeGroups(this.rangeInput, this.totalPages);
  }

  if (this.splitMode === "everyN") {
    return buildPdfSplitCountGroups(
      this.totalPages,
      parsePdfSplitPositiveInt(this.everyNInput),
    );
  }

  if (this.splitMode === "everyPage") {
    return buildPdfSplitEveryPageGroups(this.totalPages);
  }

  if (this.splitMode === "evenOdd") {
    return buildPdfSplitEvenOddGroups(this.totalPages, this.evenOddMode);
  }

  if (this.splitMode === "visual") {
    return buildPdfSplitVisualGroups(this.selectedPages);
  }

  if (this.splitMode === "bookmarks") {
    return buildPdfSplitBookmarkGroups(this.bookmarkItems, this.totalPages);
  }

  if (this.splitMode === "nTimes") {
    return buildPdfSplitNPartsGroups(
      this.totalPages,
      parsePdfSplitPositiveInt(this.nTimesInput),
    );
  }

  return [];
}

这个设计的好处是,真正拆分 PDF 时不关心用户选择了哪种模式,只消费统一的页码分组。

4. 可视化选择会自动合并连续页

可视化模式下,用户点选的是离散页码。工具会先排序、去重,再把连续页合并成一个输出段:

export function buildPdfSplitVisualGroups(selectedPages) {
  var uniquePages = Array.isArray(selectedPages)
    ? selectedPages
        .map(function (page) {
          return Number(page);
        })
        .filter(function (page) {
          return Number.isInteger(page) && page > 0;
        })
        .sort(function (left, right) {
          return left - right;
        })
        .filter(function (page, index, source) {
          return index === 0 || page !== source[index - 1];
        })
    : [];

  if (!uniquePages.length) {
    throw createPdfSplitInputError("emptySelection");
  }

  var groups = [];
  var start = uniquePages[0];
  var end = uniquePages[0];

  for (var i = 1; i < uniquePages.length; i += 1) {
    if (uniquePages[i] === end + 1) {
      end = uniquePages[i];
      continue;
    }

    pushMergedSelectionGroup(groups, start, end);
    start = uniquePages[i];
    end = uniquePages[i];
  }

  pushMergedSelectionGroup(groups, start, end);
  return groups;
}

比如选择 1、2、3、7、9、10,结果会拆成 1-379-10 三个文件。

5. 书签拆分按顶层书签生成区间

书签模式先读取 PDF 的 outline,再把书签所在页转换成拆分区间。核心逻辑是:当前书签页作为开始页,下一个书签前一页作为结束页。

export function buildPdfSplitBookmarkGroups(bookmarks, totalPages) {
  var normalizedBookmarks = Array.isArray(bookmarks)
    ? bookmarks
        .filter(function (item) {
          return (
            item &&
            Number.isInteger(Number(item.pageNumber)) &&
            Number(item.pageNumber) >= 1 &&
            Number(item.pageNumber) <= totalPages
          );
        })
        .map(function (item) {
          return {
            title: String(item.title || "").trim() || "bookmark",
            pageNumber: Number(item.pageNumber),
          };
        })
        .sort(function (left, right) {
          return left.pageNumber - right.pageNumber;
        })
    : [];

  var groups = [];

  if (normalizedBookmarks[0].pageNumber > 1) {
    groups.push({
      label: "preface",
      indices: buildPageIndices(1, normalizedBookmarks[0].pageNumber - 1),
      title: "preface",
    });
  }

  for (var index = 0; index < normalizedBookmarks.length; index += 1) {
    var current = normalizedBookmarks[index];
    var next = normalizedBookmarks[index + 1];
    var start = current.pageNumber;
    var end = next ? next.pageNumber - 1 : totalPages;

    groups.push({
      label: current.title,
      indices: buildPageIndices(start, end),
      title: current.title,
    });
  }

  return groups;
}

如果第一个书签不在第一页,前面的内容会单独生成一个 preface 分段。

6. 真正拆分 PDF 的核心是 copyPages

拆分主函数先构建 groups,然后每个分组创建一个新的 PDF:

for (index = 0; index < groups.length; index += 1) {
  var group = groups[index];
  var outputDoc = await PDFDocument.create();
  var copiedPages = await outputDoc.copyPages(
    this.sourceDoc,
    group.indices,
  );

  copiedPages.forEach(function (page) {
    outputDoc.addPage(page);
  });

  var outputBytes = await outputDoc.save();
  var outputBlob = new Blob([outputBytes], {
    type: "application/pdf",
  });

  nextOutputs.push({
    name: this.buildOutputName(group, index, groups.length),
    blob: outputBlob,
    size: outputBlob.size,
  });
}

这里不是修改原 PDF,也不是切割二进制文件,而是把源文档里的指定页面复制到一个新文档。group.indices 决定当前输出文件包含哪些页。

7. 输出文件名根据拆分模式生成

文件名会先清理原 PDF 名称,再结合模式和页码标签生成:

export function buildPdfSplitOutputName(options) {
  var config = options || {};
  var baseName = safePdfSplitBaseName(config.baseName);
  var index = Number(config.index) || 0;
  var total = Number(config.total) || 0;
  var label = String(config.label || "");
  var mode = String(config.mode || "ranges");
  var sequence = String(index + 1).padStart(3, "0");
  var safeLabel = sanitizePdfSplitFileLabel(label) || sequence;

  if (mode === "everyPage") {
    return baseName + "_page_" + safeLabel + ".pdf";
  }

  if (mode === "bookmarks") {
    return baseName + "_" + sequence + "_" + safeLabel + ".pdf";
  }

  if (total === 1) {
    return baseName + "_split.pdf";
  }

  return baseName + "_split_" + sequence + "_p" + safeLabel + ".pdf";
}

这样拆出多个文件时,用户能从文件名看出顺序和页码范围。

8. 单结果直接下载,多结果打包 ZIP

导出时先判断结果数量。只有一个 PDF 时直接下载;多个 PDF 时放进 ZIP:

downloadResult: async function () {
  if (!this.outputs.length) {
    return;
  }

  if (this.outputs.length === 1) {
    this.downloadOutput(this.outputs[0]);
    return;
  }

  var zip = new JSZip();
  this.outputs.forEach(function (item) {
    zip.file(item.name, item.blob);
  });

  var zipBlob = await zip.generateAsync({
    type: "blob",
    compression: "DEFLATE",
    compressionOptions: {
      level: 6,
    },
  });

  this.downloadBlob(zipBlob, "split_result.zip");
}

浏览器下载统一通过 Blob 和临时 a 标签完成:

downloadBlob: function (blob, filename) {
  var url = URL.createObjectURL(blob);
  var link = document.createElement("a");

  link.href = url;
  link.download = filename;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
  URL.revokeObjectURL(url);
}

整个 PDF 拆分功能的核心,就是把不同输入方式都转换成稳定的页码分组,再用 pdf-lib 复制页面生成新文档,最后根据结果数量决定直接下载还是打包下载。

❌