阅读视图

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

用 AudioContext.suspend()/resume() 作为流式音视频的同步门控

在"边收帧边播放"的场景中,传统做法是缓冲 N 帧后再启动——本质上是在猜网络速度,猜错了音画就不同步。更可靠的方案:把 AudioContext.suspend() 当作同步门,帧没到就冻结音频时钟,帧到了再放行。这个做法成立的原因是 suspend() 同时冻结音频输出 currentTime 时钟,天然是一个同步原语。

本文假设你了解 Web Audio API 基础和 requestAnimationFrame


问题:帧和音频跑在两条不同的轨道上

考虑一个流式口型同步播放器——后端通过 SSE 逐帧推送视频帧,客户端边收边解码边渲染,同时播放对应的音频。

朴素实现:音频立即开始播放,requestAnimationFrame 根据 audioContext.currentTime 计算当前应显示哪一帧:

// ❌ 朴素实现:音频驱动帧
audioSource.start(0);
const startTime = audioContext.currentTime;

function renderLoop() {
  const elapsed = audioContext.currentTime - startTime;
  const targetFrame = Math.floor(elapsed * fps);

  if (frames[targetFrame]) {
    drawFrame(frames[targetFrame]);
  }
  // 若 frames[targetFrame] 为 null,保持上一帧
  requestAnimationFrame(renderLoop);
}

这在网络良好时能工作。一旦后端帧到达速度跟不上播放速度:

音频时钟:  0s ────────── 2s ────────── 4s ────────────
已到达帧:  ████████████░░░░░░░░░░░░░░░░░░░░░░░░ (帧停在第 60 帧)
实际显示:  [正常]        [嘴型冻结]    [嘴型冻结,音频继续]

嘴型冻结,但语音继续——这是用户最容易感知到的 A/V 失同步。


天真的修复:缓冲阈值

最常见的修复是在开始播放前先缓冲足够的帧:

// ❌ 缓冲阈值方案
const BUFFER_THRESHOLD = 60; // 缓冲 60 帧再开始

onFrameDecoded(() => {
  decodedCount++;
  if (!started && decodedCount >= BUFFER_THRESHOLD) {
    started = true;
    audioSource.start(0);
    renderLoop();
  }
});

这把问题延后了,但没有解决:

  • 阈值是拍脑袋定的:60 帧在慢网络下可能还不够,在快网络下只是增加延迟
  • 播放开始后依然会失同步:后端如果在中途卡顿,帧再次追不上音频
  • 首帧延迟增加:用户要等 ~60 帧(约 1 秒)才看到第一个画面

根本问题是:音频时钟在独立运行,不关心帧有没有到


关键洞察:suspend() 同时冻结两样东西

AudioContext 有一个经常被忽视的性质:

audioContext.suspend() 不仅暂停音频输出,还暂停 currentTime 时钟本身。

const ctx = new AudioContext();
ctx.currentTime; // 0.0

// ... 播放一段时间后 ...
ctx.currentTime; // 2.341

await ctx.suspend();
// 此时 currentTime 冻结在 2.341,不再增加
await sleep(3000);

ctx.currentTime; // 仍然是 2.341(不是 5.341)

await ctx.resume();
// currentTime 从 2.341 继续,而非跳到 5.341

这意味着 suspend() 可以暂停整个时间轴,而不仅仅是静音。

这个性质让它成为一个天然的同步原语——用帧的到达来控制时间轴的开闸:

帧 N+1 已就绪?
  → 是: resume() → 时钟流动 → 绘制帧 → rAF 调度下一次检查
  → 否: suspend() → 时钟冻结 → 等待帧到达事件 → 重新触发检查

实现

音频解码和帧流接收是并行进行的——帧 0 可能在音频解码完成之前就到达,也可能更晚。实现中用 audioReady flag 跟踪音频是否就绪,调度器在两个事件上都能被触发:帧到达时,或音频就绪时。

1. AudioContext 创建后立即 suspend

const AudioCtx = window.AudioContext;
const audioCtx = new AudioCtx();

// 创建后立即挂起——由帧驱动何时 resume
await audioCtx.suspend();

const buffer = await audioCtx.decodeAudioData(rawAudioBuffer);

const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(audioCtx.destination);
source.start(0); // start(0) 在 suspended 状态下不会实际播放
const startTime = audioCtx.currentTime;

注意 start(0) 在 suspended 状态下是合法的——它把播放位置固定在 0,但不产生声音。

2. suspend/resume 门控函数

let audioCtx: AudioContext | null = null;
let audioRunning = false;

// 不检查 audioCtx.state——resume()/suspend() 是异步的,
// state 属性的切换有延迟,检查它会导致 flag 与真实状态分裂。
// 只用本地 flag 做幂等守卫。
const ensureRunning = () => {
  if (!audioRunning && audioCtx) {
    audioRunning = true;
    audioCtx.resume().catch(() => {});
  }
};

const ensureSuspended = () => {
  if (audioRunning && audioCtx) {
    audioRunning = false;
    audioCtx.suspend().catch(() => {});
  }
};

为什么不检查 audioCtx.state

resume()suspend() 都是异步的,state 属性要等微任务队列清空后才切换。如果在同一个 rAF 周期内先调 resume() 再调 ensureSuspended()state 可能还停留在 'suspended'(resume 尚未完成),导致 ensureSuspended 认为"不需要 suspend"而跳过——但 resume 已经在途中,随后完成后音频就失控地开始播放。本地 flag 的翻转是同步的,不存在这个窗口。

3. 核心调度器

let audioReady = false; // 音频解码完成后置 true
let lastDrawnFrame = -1;

const tryAdvance = () => {
  const nextNeeded = lastDrawnFrame + 1;

  // 结束条件
  if (streamEnded && nextNeeded >= totalFrames) {
    ensureSuspended();
    onPlayEnd();
    return;
  }

  if (frames[nextNeeded] !== null) {
    if (!audioReady) {
      // 音频尚未就绪,rAF 轮询等待
      // 此时 audioCtx 还不存在,不能调 ensureRunning
      requestAnimationFrame(tryAdvance);
      return;
    }

    // 有帧且音频就绪 → 打开闸门
    ensureRunning();

    // 检查时钟是否已到该帧时间(后端快于实时时限速)
    const elapsed = audioCtx.currentTime - startTime;
    const frameTime = nextNeeded / fps;

    if (elapsed >= frameTime) {
      draw(frames[nextNeeded]);
      lastDrawnFrame = nextNeeded;
      requestAnimationFrame(tryAdvance); // 立即尝试下一帧
    } else {
      requestAnimationFrame(tryAdvance); // 等时钟追上
    }
  } else {
    // 帧未到 → 关闭闸门,等帧到达事件唤醒
    ensureSuspended();
    // 不调度 rAF——由 onFrameArrived 触发
  }
};

// 每次有新帧解码完成时调用
const onFrameArrived = (index: number) => {
  frames[index] = decodedBitmap;

  // 只有下一顺序帧到达才触发调度(乱序帧不触发)
  if (index === lastDrawnFrame + 1) {
    tryAdvance();
  }
};

4. 乱序帧的处理

后端可能乱序推送帧(帧 N+1 比帧 N 先到):

帧到达顺序: 0, 1, 3, 2, 4, 5 ...
              ↑ 帧 2 迟到

onFrameArrived(3) 时,lastDrawnFrame = 1index !== lastDrawnFrame + 1,不触发调度。 onFrameArrived(2) 时,index === lastDrawnFrame + 1,触发 tryAdvance(),绘制帧 2 后继续绘制已等待的帧 3、4、5。

这个模式保证严格顺序播放,无需额外排序逻辑。


边界情况

后端快于实时:所有帧在音频解码前到达。tryAdvance 看到 frames[nextNeeded] !== nullaudioReady = false,进入 rAF 轮询等待音频就绪。音频就绪后 elapsed >= frameTime 判断阻止帧提前绘制。

0 帧响应streamEnded = truenextNeeded >= totalFrames(0 >= 0),立即 resolve。

播放中断:外部调用 stop() → isPlaying = falsetryAdvance 首行 early return → rAF 循环自然终止。


取舍

优点 缺点
严格音画同步,无论网络如何抖动 网络卡顿时音频会有可感知的停顿
首帧延迟极低(~1 帧,而非缓冲 N 帧) 停顿期间无音频,用户可能误以为播放器崩了
不需要 AudioWorklet 或 ScriptProcessorNode 需要处理 resume() 的异步性(见上文 flag 而非 state)
与现有 close() 清理逻辑完全兼容 Safari 较旧版本在 suspend 过渡中 close() 有内存泄漏风险

何时不适用:如果视频帧来源稳定(本地文件、已缓冲的 HLS),直接用音频时钟驱动帧渲染更简单,不需要这套门控。这个模式的价值在于网络不稳定的流式推送场景。


这个方案把"同步"的责任从业务代码转移到了 AudioContext 本身——不需要手动计算音频和帧之间的时间差,不需要调整缓冲策略,状态也只有 audioRunning 这一个 flag。代价是引入了对 suspend/resume 异步性的理解门槛,以及停顿时无声音的 UX 取舍。


完整代码

audiocontext-sync-gate.ts


延伸阅读

React 事件订阅的稳定引用问题:从 useEffect 到 useEffectEvent

在 React 里订阅 WebSocket / EventEmitter 时,把 handler 直接放进 effect 依赖会导致反复 subscribe/unsubscribe。用 useRef 代理最新 handler 可以解决——但渲染阶段直接赋值 ref 在 Strict Mode 下有副作用风险。本文拆解这个模式的三个演进版本,以及 React 19 的终极解法。

本文假设你理解 React useEffect 的依赖数组机制和闭包基础。


问题:handler 是新的,订阅也是新的

写 WebSocket 消息监听的第一版,大多数人会这样写:

// ❌ 版本 1:handler 变化 = 重新订阅
function ChatPanel({ conversationId }: { conversationId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);

  useEffect(() => {
    socket.on('message:new', (msg) => {
      setMessages((prev) => [...prev, msg]);
    });
    return () => socket.off('message:new', /* 哪个函数? */);
  }, [conversationId]);
}

第一个问题显而易见:socket.off 需要传入与 socket.on 完全相同的函数引用,但内联箭头函数每次渲染都是新对象,off 根本移除不掉正确的监听器,导致监听器堆积。

修复方式是把 handler 提出来,加入依赖数组:

// ❌ 版本 2:监听器能正确移除了,但每次渲染都重订阅
useEffect(() => {
  const handler = (msg: Message) => {
    setMessages((prev) => [...prev, msg]);
  };
  socket.on('message:new', handler);
  return () => socket.off('message:new', handler);
}, [conversationId, setMessages]); // handler 是函数,引用每次都变

更典型的场景是 handler 来自 props:

// ❌ 每次父组件重渲染,onMessage 是新函数 → 重新订阅
function useSocketEvent(event: string, onMessage: (msg: Message) => void) {
  useEffect(() => {
    socket.on(event, onMessage);
    return () => socket.off(event, onMessage);
  }, [event, onMessage]); // onMessage 每次都是新引用
}

父组件只要重渲染(比如 state 更新),onMessage 就是新函数,effect 就重跑,WebSocket 就重新订阅一次。在高频更新的组件里,这意味着每秒可能订阅/取消订阅数十次。


心理模型:代理人

解法的核心思路是引入一个稳定的代理人

想象有个翻译:客户(WebSocket)只认识这个翻译(stableHandler),不管雇主(handler)换了几茬,客户永远对着同一个翻译说话。翻译内部维护一个指针,永远转发给最新的雇主。

WebSocket → stableHandler(稳定,不变)→ handlerRef.current(总是最新的 handler)

用代码表示:

const handlerRef = useRef(handler);
// handlerRef.current 永远是最新 handler

const stableHandler = (payload: T) => handlerRef.current(payload);
// stableHandler 是稳定函数引用,只在组件挂载时创建一次

socket.on('message:new', stableHandler); // 只订阅一次

三个版本的演进

版本 1:渲染阶段赋值(常见但有隐患)

export function useStableHandler<T>(
  event: string,
  handler: (payload: T) => void,
) {
  const handlerRef = useRef(handler);
  handlerRef.current = handler; // ← 直接在渲染阶段赋值

  useEffect(() => {
    const stableHandler = (payload: T) => handlerRef.current(payload);
    socket.on(event, stableHandler);
    return () => socket.off(event, stableHandler);
  }, [event]);
}

这个版本能运行,也是网上最常见的写法。但 handlerRef.current = handler 写在渲染函数体里,是渲染阶段的副作用。

React Strict Mode 在开发环境下会故意执行两次渲染函数体(不含 effects),目的是暴露副作用。在并发模式(Concurrent Mode)下,React 可以中断、暂停、重播渲染——如果渲染阶段有副作用,可能在预期之外的时机被多次执行。

对于 ref 赋值,实践中通常没有问题(ref 赋值是幂等的),但这是 React 文档明确标注为"不推荐"的模式。

版本 2:独立 effect 同步(正确且 Strict Mode 安全)

export function useStableHandler<T>(
  subscribe: (handler: (payload: T) => void) => () => void,
  handler: (payload: T) => void,
): void {
  const handlerRef = useRef(handler);

  // Effect 1:同步最新 handler 到 ref(Strict Mode 安全)
  useEffect(() => {
    handlerRef.current = handler;
  }, [handler]);

  // Effect 2:订阅,只在 subscribe 变化时重跑
  useEffect(() => {
    const stableHandler = (payload: T) => handlerRef.current(payload);
    const unsubscribe = subscribe(stableHandler);
    return unsubscribe;
  }, [subscribe]);
}

两个 effect 分工明确:

Effect 职责 依赖 重跑频率
Effect 1 保持 ref 最新 [handler] handler 变化时(可能很频繁)
Effect 2 管理订阅生命周期 [subscribe] subscribe 变化时(应该很少)

关键点:Effect 1 频繁重跑没有性能问题,因为它只做一次 ref 赋值,没有 I/O。Effect 2 重跑才是代价高的(涉及 socket.on/off),而它的依赖 subscribe 应该是稳定的。

版本 3:useEffectEvent(React 19+,最简洁)

import { useEffectEvent } from 'react';

export function useStableHandler<T>(
  subscribe: (handler: (payload: T) => void) => () => void,
  handler: (payload: T) => void,
): void {
  // useEffectEvent 返回一个稳定函数,内部始终能访问最新 handler
  const stableHandler = useEffectEvent(handler);

  useEffect(() => {
    return subscribe(stableHandler);
  }, [subscribe]); // stableHandler 不需要放进依赖
}

useEffectEvent 是 React 官方对这个模式的标准答案。它做的事和版本 2 完全一样,只是封装成了语言原语。被 useEffectEvent 包裹的函数:

  • 稳定引用:不会触发 effect 重跑
  • 始终最新:调用时看到的是最新的 props/state
  • 不可在 effect 外调用(React 会报错,因为语义不同)

最容易踩的坑:subscribe 必须稳定

这个 hook 把订阅稳定性的责任转移到了 subscribe 参数上。如果调用时传入内联函数:

// ❌ 每次渲染 subscribe 都是新函数 → Effect 2 每次都重订阅
useStableHandler(
  (handler) => {
    socket.on('message:new', handler);
    return () => socket.off('message:new', handler);
  },
  (msg) => setMessages((prev) => [...prev, msg]),
);

修复:用 useCallback 稳定 subscribe

// ✅ subscribe 只在 socket 变化时重新创建
const subscribe = useCallback((handler: (msg: Message) => void) => {
  socket.on('message:new', handler);
  return () => socket.off('message:new', handler);
}, [socket]);

useStableHandler(subscribe, (msg) => setMessages((prev) => [...prev, msg]));

handler 参数则没有这个限制——内联函数完全可以,这正是 hook 的价值所在。


实际用例对比

Socket.IO 消息监听

function ChatPanel({ conversationId }: { conversationId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);

  const subscribe = useCallback((handler: (msg: Message) => void) => {
    socket.on('message:new', handler);
    return () => socket.off('message:new', handler);
  }, []); // socket 是模块级单例,依赖为空

  useStableHandler(subscribe, (msg) => {
    if (msg.conversation_id === conversationId) {
      setMessages((prev) => [...prev, msg]);
    }
  });

  // handler 每次渲染都是新函数(因为依赖 conversationId),
  // 但订阅不会重建 ✅
}

原生 resize 监听

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  const subscribe = useCallback((handler: () => void) => {
    window.addEventListener('resize', handler);
    return () => window.removeEventListener('resize', handler);
  }, []);

  useStableHandler(subscribe, () => setWidth(window.innerWidth));

  return width;
}

取舍

优点 缺点
handler 无需 useCallback,调用处更干净 subscribe 必须稳定,调用处需要 useCallback
订阅/取消订阅次数最小化 两个 effect 之间存在一帧的 handler 不同步窗口(极罕见)
适用于任何 subscribe/unsubscribe 接口 版本 2 写法对团队有一定理解门槛

一帧不同步窗口是指:Effect 1(同步 handler)和 Effect 2(使用 handler)在同一个 commit 里按顺序执行,正常情况下没有问题。但如果 subscribe 变化的同时 handler 也变化,理论上可能先执行 Effect 2 再执行 Effect 1,导致新订阅在一帧内用了旧 handler。实践中这种场景几乎不会出现,且影响仅限一次事件处理。

React 19 的 useEffectEvent 从根本上消除了这个窗口,是该模式的最终形态。


完整代码

react/use-stable-handler.ts


延伸阅读

并发 401 下的 Token 刷新竞态:一个被低估的 Bug

当多个请求同时遇到 401 时,朴素实现会触发多次 token 刷新,导致 race condition。用一个 isRefreshing 标志 + 订阅者队列可以彻底解决——但大多数实现里存在一个隐藏的 Promise 泄漏问题。

本文假设你熟悉 async/await、HTTP 拦截器(axios/fetch)和 JWT 认证基础。


问题:并发 401 不止一个

实现过 token 刷新的人,第一版代码大概长这样:

// ❌ 朴素实现
axios.interceptors.response.use(null, async (error) => {
  if (error.response?.status === 401) {
    const newToken = await refreshToken();
    error.config.headers.Authorization = `Bearer ${newToken}`;
    return axios(error.config);
  }
  return Promise.reject(error);
});

单个请求失效时,这完全够用。但在真实应用里,你的页面同时发出 5 个请求是常态——Dashboard 加载时并行请求用户信息、通知数量、最新数据……

当 token 在这 5 个请求飞行途中过期:

Request A401refreshToken() ─┐
Request B401refreshToken()  │← 同时触发 5 次刷新
Request C → 401refreshToken()  │
Request D → 401refreshToken() ─┘
Request E → 401refreshToken()

每次刷新都会使上一次发出的 refresh_token 失效(轮换机制)。结果是:第一个刷新成功,其余四个用过期的 refresh_token 刷新——全部失败,用户被踢回登录页。


心理模型:收银台排队

把并发请求想象成超市收银台:

  • 朴素实现:每个顾客(请求)都跑去叫店长(刷新 token)。店长同时被 5 个人拉着,什么都做不了。
  • 正确实现:第一个顾客去叫店长,其他人在收银台前排队等候。店长回来后,所有人一起结账(用新 token 重试)。

实现这个逻辑只需要两个变量:

let isRefreshing = false;          // 店长是否在处理中
let subscribers: Subscriber[] = []; // 排队等待的顾客

实现:带队列的刷新机制

完整实现分四个部分:

1. 订阅者类型

// newToken 为字符串时表示刷新成功,为 null 时表示刷新失败
type Subscriber = (newToken: string | null) => void;

let isRefreshing = false;
let subscribers: Subscriber[] = [];

注意 string | null 的设计——这是避免 Promise 泄漏的关键,后面详述。

2. 队列管理

function addSubscriber(callback: Subscriber) {
  subscribers.push(callback);
}

function notifySubscribers(newToken: string | null) {
  subscribers.forEach((cb) => cb(newToken));
  subscribers = [];
}

3. 核心调度逻辑

export async function handleUnauthorized<T>(
  doRefresh: () => Promise<string | null>,
  doRetry: (newToken: string) => Promise<T>,
  onFailure: () => void,
): Promise<T> {
  // 已有刷新进行中 → 排队等待
  if (isRefreshing) {
    return new Promise<T>((resolve, reject) => {
      addSubscriber((newToken) => {
        if (newToken) {
          doRetry(newToken).then(resolve).catch(reject);
        } else {
          reject(new Error('Token refresh failed'));
        }
      });
    });
  }

  // 发起刷新
  isRefreshing = true;
  const newToken = await doRefresh();

  if (newToken) {
    notifySubscribers(newToken); // 通知队列重试
    isRefreshing = false;
    return doRetry(newToken);
  }

  // 刷新失败:通知队列(传 null),然后执行失败处理
  notifySubscribers(null);
  isRefreshing = false;
  onFailure();
  return Promise.reject(new Error('Token refresh failed'));
}

4. 接入 Axios 拦截器

axios.interceptors.response.use(null, (error) => {
  const { response, config } = error;

  // 只处理 401,跳过登录和刷新接口本身
  if (response?.status !== 401) return Promise.reject(error);
  if (config?.url?.includes('/auth/login')) return Promise.reject(error);
  if (config?.url?.includes('/auth/refresh')) {
    clearStorage();
    window.location.href = '/login';
    return Promise.reject(error);
  }

  return handleUnauthorized(
    () => fetchNewToken(),
    (newToken) => {
      config.headers.Authorization = `Bearer ${newToken}`;
      return axios(config);
    },
    () => {
      clearStorage();
      window.location.href = '/login';
    },
  );
});

现在同样的并发场景:

Request A → 401 → isRefreshing=false → 发起刷新 → isRefreshing=true
Request B → 401 → isRefreshing=true  → 加入队列
Request C → 401 → isRefreshing=true  → 加入队列
Request D → 401 → isRefreshing=true  → 加入队列

刷新成功 → notifySubscribers(newToken) → B、C、D 用新 token 重试 ✅

隐藏的 Bug:Promise 泄漏

这是大多数网上教程里存在的问题,包括一些知名库的早期版本。

当刷新失败时,朴素实现通常这样写:

// ❌ 有 Bug 的版本
isRefreshing = false;
subscribers = []; // ← 直接清空!
onFailure();

问题在于:subscribers 数组里存的是 Promise 的 resolve/reject 回调。直接清空等于把这些 Promise 永远挂起——它们既不 resolve 也不 reject,永远 pending

JavaScript 引擎不会回收仍在等待的 Promise(因为理论上它们还能被 resolve)。在 SPA 里,这意味着用户每次遇到刷新失败,都会积累一批无法被 GC 的 Promise 和闭包。

修复方式:通知订阅者失败,让它们主动 reject:

// ✅ 正确版本
notifySubscribers(null); // 传 null → 订阅者收到后调用 reject()
isRefreshing = false;
onFailure();

这就是为什么 Subscriber 的类型是 (newToken: string | null) => void 而不是 (newToken: string) => void


需要注意的边界情况

并发刷新之间的时序

isRefreshing 是模块级变量,在整个应用生命周期内共享。如果两个页面同时初始化(如 iframe 或多标签页共享 localStorage),队列不会跨页面同步——这是该模式的设计边界。多标签页场景需要用 BroadcastChannelSharedWorker

刷新接口本身的 401

必须跳过对刷新接口的重试,否则会死循环:

refreshToken() → 401handleUnauthorized() → refreshToken() → ...

代码里的这一判断不能省:

if (config?.url?.includes('/auth/refresh')) {
  clearStorage();
  window.location.href = '/login';
  return Promise.reject(error);
}

状态重置时机

isRefreshing = false 必须在 notifySubscribers() 之后设置,不能之前。否则队列通知过程中如果又进来新的 401,会再次触发刷新。


取舍与局限

优点 缺点
无额外依赖,纯逻辑 模块级状态,无法跨 iframe/标签页
O(1) 判断,O(n) 通知,性能无影响 刷新超时无内建处理(需自行包装)
与具体 HTTP 客户端解耦 队列顺序不保证(取决于 Promise 执行顺序)

如果你的应用有严格的刷新超时需求,可以在 doRefresh 里用 Promise.race 包一层 timeout:

const doRefresh = () => Promise.race([
  fetchNewToken(),
  new Promise<null>((resolve) => setTimeout(() => resolve(null), 10_000)),
]);

完整代码

token-refresh-queue.ts


延伸阅读

❌