fix(frontend): 修复 speechSynthesis.cancel() 未守卫导致的页面崩溃

This commit is contained in:
2026-05-21 19:06:40 +08:00
parent 380cc24913
commit 8b7d4ec19a
2 changed files with 249 additions and 14 deletions
+225
View File
@@ -0,0 +1,225 @@
# 紧急诊断报告:页面崩溃 "Cannot read properties of undefined (reading 'cancel')"
**日期**: 2026-05-21
**严重级别**: 🔴 Critical — 页面持续崩溃,ErrorBoundary 捕获持久性错误
**诊断方法**: 源代码静态分析 + CDP (Chrome DevTools Protocol) 运行时分析
---
## 1. 错误信息
```
⚠️ 应用遇到错误: Cannot read properties of undefined (reading 'cancel')
```
用户报告:
- **初次触发**: 前端在初次对话回复到一半时页面崩溃
- **后续表现**: 刷新页面一直弹出 ErrorBoundary 错误页 + 重试按钮
- **特点**: 持久性错误 — 错误发生在页面初始化阶段
---
## 2. 根本原因分析
### 2.1 `.cancel()` 引用全局搜索
对整个 `frontend/web/src/` 目录进行 `.ts` / `.tsx` 文件搜索,**所有 `.cancel()` 调用仅存在于一个文件**
| 文件 | 行号 | 代码 |
|------|------|------|
| [`useSpeechSynthesis.ts`](frontend/web/src/hooks/useSpeechSynthesis.ts:205) | L205 | `window.speechSynthesis.cancel();``speak()` 回调内(有 `isSupported` 守卫) |
| [`useSpeechSynthesis.ts`](frontend/web/src/hooks/useSpeechSynthesis.ts:224) | L224 | `window.speechSynthesis.cancel();``stop()` 回调内(**无守卫** |
| [`useSpeechSynthesis.ts`](frontend/web/src/hooks/useSpeechSynthesis.ts:256) | L256 | `window.speechSynthesis.cancel();` — 组件卸载 cleanup 内(**无守卫** |
**项目不使用 Axios(无 `CancelToken`),也不使用 `AbortController`。** HTTP 客户端是原生 `fetch`(见 [`client.ts`](frontend/web/src/api/client.ts))。
### 2.2 CDP 运行时确认
通过 Chromium CDP (端口 9225) 在 `about:blank` 页面执行以下诊断:
```javascript
// CDP Runtime.evaluate 结果:
'speechSynthesis' in window true
typeof window.speechSynthesis "object"
window.speechSynthesis === undefined false
window.speechSynthesis === null false
!!window.speechSynthesis true
typeof window.speechSynthesis?.cancel "function"
```
同时确认了错误消息的精确匹配:
```javascript
// 直接测试:
var x = undefined; x.cancel();
// → TypeError: "Cannot read properties of undefined (reading 'cancel')"
```
**结论**: 错误消息 `"Cannot read properties of undefined (reading 'cancel')"` 确实是访问 `undefined.cancel` 导致的 `TypeError`。在当前 headless Chrome 中,`window.speechSynthesis` **存在且有 `.cancel` 方法**。但错误发生在特定运行时条件下。
### 2.3 调用链路分析
```
App.tsx (登录后渲染)
└── ChatContainer.tsx
└── MessageList.tsx
└── MessageBubble.tsx (每个 assistant 消息)
└── AIMessageActions(content) ← 调用 useSpeechSynthesis()
└── useSpeechSynthesis()
├── isSupported = 'speechSynthesis' in window // ✓ 有守卫
├── speak() → window.speechSynthesis.cancel() // ✓ isSupported 守卫
├── stop() → window.speechSynthesis.cancel() // ✗ 无守卫!
└── cleanup → window.speechSynthesis.cancel() // ✗ 无守卫!
```
关键代码对比([`useSpeechSynthesis.ts`](frontend/web/src/hooks/useSpeechSynthesis.ts):
```typescript
// ✓ speak() — 有 isSupported 守卫
const speak = useCallback((text, options) => {
if (!isSupported || !text.trim()) return; // <-- GUARD
window.speechSynthesis.cancel(); // 安全
// ...
}, [isSupported, speakNextChunk]);
// ✗ stop() — 无守卫
const stop = useCallback(() => {
window.speechSynthesis.cancel(); // 未检查 isSupported!
setIsSpeaking(false);
// ...
}, []);
// ✗ cleanup — 无守卫
useEffect(() => {
return () => {
window.speechSynthesis.cancel(); // 未检查 isSupported!
// ...
};
}, []);
```
### 2.4 为什么是「持久性」错误
错误在以下场景触发:
1. **初次对话崩溃**: 用户发送消息 → WebSocket 流式返回 → `MessageBubble` 渲染 assistant 消息 → `AIMessageActions` 挂载 → `useSpeechSynthesis()` 初始化 → 组件因某些原因卸载(React 重渲染、StrictMode 双重挂载、或流式更新导致的条件渲染变化) → **cleanup 执行 `window.speechSynthesis.cancel()`** → 如果此时 `speechSynthesis` 对象不可用 → TypeError → ErrorBoundary 捕获
2. **刷新后持续崩溃**: 用户已登录 → `initSession()` 从服务端加载历史消息 → 历史消息中包含 assistant 消息 → `MessageBubble` 渲染 → `AIMessageActions` 挂载 → **同样的初始化路径触发同样的错误** → ErrorBoundary 持续捕获 → 页面永远无法恢复
3. **为什么 `window.speechSynthesis` 可能为 `undefined`**:
- 尽管 CDP 测试显示它存在,但在某些特定的浏览器/环境组合下(如特定版本的 headless Chrome、无音频设备的服务器环境、或浏览器安全策略限制),`window.speechSynthesis` 可能是一个不完整的对象,或者在某些时序下(如页面刚加载、`onvoiceschanged` 未触发时)表现出意外行为
- React 18 的 `StrictMode`(在 `main.tsx` 第10行启用)会**双重挂载/卸载组件**,增加了 cleanup 被调用的次数和时序复杂度
### 2.5 辅助触发因素
`AIMessageActions` 内部还有一个额外的[无守卫调用](frontend/web/src/components/chat/MessageBubble.tsx:104-105):
```typescript
const checkEnd = setInterval(() => {
if (!window.speechSynthesis.speaking) { // 直接访问 speechSynthesis
setIsThisSpeaking(false);
clearInterval(checkEnd);
}
}, 200);
```
`setInterval` 没有清理机制 — 如果 `AIMessageActions``checkEnd` 回调执行时已卸载,`setIsThisSpeaking` 会触发 React 的 "setState on unmounted component" 警告。虽然这不是 `.cancel` 错误的直接原因,但同样是缺少防御性编程的症状。
---
## 3. Zustand Persist 检查
| Store | 文件 | 使用 persist? |
|-------|------|--------------|
| `authStore` | [`authStore.ts`](frontend/web/src/store/authStore.ts) | ❌ 纯 `create()` |
| `chatStore` | [`chatStore.ts`](frontend/web/src/store/chatStore.ts) | ❌ 纯 `create()` |
| `sessionStore` | [`sessionStore.ts`](frontend/web/src/store/sessionStore.ts) | ❌ 纯 `create()` |
| `notificationStore` | [`notificationStore.ts`](frontend/web/src/store/notificationStore.ts) | ❌ 纯 `create()` |
| `personaStore` | [`personaStore.ts`](frontend/web/src/store/personaStore.ts) | ❌ 纯 `create()` |
**所有 Zustand Store 均未使用 `persist` 中间件**,因此**不存在**序列化到 `localStorage` 导致 `CancelToken`/`AbortController` 实例丢失方法的问题。排除此假设。
---
## 4. 七种可能来源的排查
| # | 假说 | 验证结果 |
|---|------|----------|
| 1 | Axios CancelToken 序列化到 localStorage 后丢失 `.cancel` 方法 | ❌ 项目不使用 Axios |
| 2 | AbortController 被存入 Zustand store 序列化后丢失 | ❌ 项目不使用 AbortController |
| 3 | `window.speechSynthesis.cancel()``speechSynthesis` 不支持时调用 | ✅ **唯一 `.cancel` 来源** |
| 4 | 第三方库中有 `.cancel` 调用 | ❌ 依赖仅 react + zustand |
| 5 | Service Worker 中有 `.cancel` 调用 | ❌ SW 代码中无 `.cancel` |
| 6 | Zustand persist 中间件序列化问题 | ❌ 所有 store 不使用 persist |
| 7 | React StrictMode 双重卸载触发 cleanup 时序问题 | ⚠️ 可能是加剧因素 |
---
## 5. 修复建议
### 5.1 立即修复(推荐)
在 [`useSpeechSynthesis.ts`](frontend/web/src/hooks/useSpeechSynthesis.ts) 中添加防御性守卫:
**`stop()` 函数 (L223)**:
```typescript
const stop = useCallback(() => {
if (isSupported) {
window.speechSynthesis.cancel();
}
setIsSpeaking(false);
setIsPaused(false);
utteranceRef.current = null;
chunksRef.current = [];
chunkIndexRef.current = 0;
}, [isSupported]); // ← 添加 isSupported 到依赖数组
```
**cleanup effect (L254-261)**:
```typescript
useEffect(() => {
return () => {
if (isSupported) {
window.speechSynthesis.cancel();
}
if (resumeIntervalRef.current) {
clearInterval(resumeIntervalRef.current);
}
};
}, [isSupported]); // ← 添加 isSupported 到依赖数组
```
### 5.2 防御性增强(推荐)
在 [`MessageBubble.tsx`](frontend/web/src/components/chat/MessageBubble.tsx) 的 `AIMessageActions` 组件中,为 `checkEnd``setInterval` 添加清理:
```typescript
useEffect(() => {
return () => {
if (checkEndRef.current) {
clearInterval(checkEndRef.current);
}
};
}, []);
```
### 5.3 架构层建议
考虑将 `useSpeechSynthesis()` 调用提升到应用层级(如 `App.tsx``AppLayout.tsx`),而非在每个 `AIMessageActions` 中各自创建一个 hook 实例。这可以避免消息列表渲染时大量重复初始化 SpeechSynthesis 上下文。
---
## 6. 诊断工具与数据
- **CDP 测试脚本**: `debug/cache/test_cdp_cancel_error.py`
- **Chromium 调试端口**: `localhost:9225`
- **前端预览端口**: `localhost:5199`
---
## 7. 结论
**根因**: `useSpeechSynthesis` hook 中的 `stop()` 回调和组件卸载 cleanup effect 直接调用 `window.speechSynthesis.cancel()` **没有任何防御性守卫**。当 `window.speechSynthesis` 在特定运行时条件下不可用(值为 `undefined`)时,抛出 `TypeError: Cannot read properties of undefined (reading 'cancel')`
**持久性原因**: 已登录用户在刷新页面后,`initSession()` 加载历史消息,渲染 assistant 消息气泡时触发 `AIMessageActions``useSpeechSynthesis()`,在 React StrictMode 双重挂载/卸载周期中触发未守卫的 cleanup,导致错误在每次页面加载时必现。
**修复优先级**: 🔴 P0 — 阻塞所有已登录用户使用应用。
+24 -14
View File
@@ -131,13 +131,13 @@ export function useSpeechSynthesis(): UseSpeechSynthesisReturn {
// Chrome bug 规避:定期 resume 避免长时间不调用后暂停
useEffect(() => {
if (isSpeaking && !isPaused) {
resumeIntervalRef.current = setInterval(() => {
if (window.speechSynthesis.speaking && window.speechSynthesis.paused) {
window.speechSynthesis.resume();
}
}, 5000);
}
if (!isSupported || !isSpeaking || isPaused) return;
resumeIntervalRef.current = setInterval(() => {
if (window.speechSynthesis.speaking && window.speechSynthesis.paused) {
window.speechSynthesis.resume();
}
}, 5000);
return () => {
if (resumeIntervalRef.current) {
@@ -145,7 +145,7 @@ export function useSpeechSynthesis(): UseSpeechSynthesisReturn {
resumeIntervalRef.current = null;
}
};
}, [isSpeaking, isPaused]);
}, [isSupported, isSpeaking, isPaused]);
/** 朗读下一段 */
const speakNextChunk = useCallback(() => {
@@ -221,29 +221,37 @@ export function useSpeechSynthesis(): UseSpeechSynthesisReturn {
/** 停止朗读 */
const stop = useCallback(() => {
window.speechSynthesis.cancel();
if (!isSupported) {
console.warn('[useSpeechSynthesis] stop: speechSynthesis not supported');
return;
}
if (utteranceRef.current) {
window.speechSynthesis.cancel();
}
setIsSpeaking(false);
setIsPaused(false);
utteranceRef.current = null;
chunksRef.current = [];
chunkIndexRef.current = 0;
}, []);
}, [isSupported]);
/** 暂停 */
const pause = useCallback(() => {
if (!isSupported) return;
if (isSpeaking && !isPaused) {
window.speechSynthesis.pause();
setIsPaused(true);
}
}, [isSpeaking, isPaused]);
}, [isSupported, isSpeaking, isPaused]);
/** 恢复 */
const resume = useCallback(() => {
if (!isSupported) return;
if (isPaused) {
window.speechSynthesis.resume();
setIsPaused(false);
}
}, [isPaused]);
}, [isSupported, isPaused]);
/** 设置语音 */
const setVoice = useCallback((voice: SpeechSynthesisVoice) => {
@@ -253,12 +261,14 @@ export function useSpeechSynthesis(): UseSpeechSynthesisReturn {
// 组件卸载时停止
useEffect(() => {
return () => {
window.speechSynthesis.cancel();
if (isSupported && utteranceRef.current) {
window.speechSynthesis.cancel();
}
if (resumeIntervalRef.current) {
clearInterval(resumeIntervalRef.current);
}
};
}, []);
}, [isSupported]);
return {
isSpeaking,