陌上人如玉
公子世无双

2.18 useDebugValue 与 Hook 可调试性

2.18 useDebugValue 与 Hook 可调试性

一句话:useDebugValue 让自定义 Hook 在 React DevTools 的 Hooks 面板里显示「有意义的值」而不是一串 undefined——它是开发专用的标注工具,配合「命名规范 + DevTools + 渲染审计工具」构成 Hook 的完整可观测体系。

一、问题:自定义 Hook 在 DevTools 里长什么样

// 一个普通的自定义 Hook
function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);
  useEffect(() => {
    const on = () => setIsOnline(true);
    const off = () => setIsOnline(false);
    window.addEventListener('online', on);
    window.addEventListener('offline', off);
    return () => {
      window.removeEventListener('online', on);
      window.removeEventListener('offline', off);
    };
  }, []);
  return isOnline;
}
⭐ 在 React DevTools 的 Hooks 面板里,你看到的是:

  ▾ OnlineStatus()
      ▾ Effect            ← 只有类型名,看不出「这个 Effect 管什么」
      ▾ State: true       ← 有值,但不知道它代表「在线状态」
      ▾ Effect

⭐ 问题:
   ① 组件里用了 8 个自定义 Hook、20 个内置 Hook 时,
      面板是一长串「State / Effect / Memo / Callback」,无法快速定位
   ② 想知道「某个 Hook 内部的中间值」时,只能回去加 console.log
   ③ 排查「为什么这个 Hook 重新执行了」时,缺少上下文
// ✅ 加上 useDebugValue 之后
function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);
  // ...
  useDebugValue(isOnline ? '在线' : '离线');       // ⭐ DevTools 里显示「在线 / 离线」
  return isOnline;
}
DevTools 面板现在显示:

  ▾ OnlineStatus()
      ▾ State: true
      ▾ OnlineStatus: "在线"        ← ⭐ 一眼看出这个 Hook 的状态
      ▾ Effect

二、两种用法:直接值 vs 格式化函数

// ① 传值(当「格式化」很便宜时)
useDebugValue(isOnline ? '在线' : '离线');
useDebugValue(user?.name ?? '未登录');
useDebugValue(`${items.length} 项`);
// ② 传「格式化函数」(当「格式化」很昂贵时,⭐ 推荐)
function useUserStats(userId: string) {
  const stats = useExpensiveStats(userId);       // 昂贵计算

  // ⭐ 格式化函数只在「DevTools 打开 + 这个 Hook 被检查」时才执行
  useDebugValue(stats, (s) => `活跃度 ${s.score}(${s.level})`);

  return stats;
}
⭐⭐ 两种写法的关键差异:

· useDebugValue(value)
  → React 会在**每次渲染**都「记录」这个值(便宜,但值本身会被求值)

· useDebugValue(value, format)
  → React 只在「**DevTools 正在检查这个 Hook**」时才调用 format
  → 所以「拼字符串、遍历数组、JSON.stringify」这类昂贵操作应该放进 format

⭐ 结论:**只要格式化不再是「取一个字段」,就用第二种**。
// ✅ 更完整的实践:把「多个字段」用格式化函数组装成一行可读信息
function useFetchState(url: string) {
  const [state, setState] = useState>({ status: 'idle' });

  useDebugValue(state, (s) => {
    switch (s.status) {
      case 'idle': return `${url} · 未开始`;
      case 'loading': return `${url} · 加载中…`;
      case 'success': return `${url} · 成功(${Array.isArray(s.data) ? `${s.data.length} 条` : '已就绪'})`;
      case 'error': return `${url} · 失败:${s.error.message}`;
    }
  });

  // ...
  return state;
}

三、什么时候执行?—— 三个必须知道的细节

⭐⭐ 细节一:它【只在开发环境】有意义
   · 生产构建里,useDebugValue 的调用会被「编译掉」(或者变成空操作)
   · 所以你不需要为「生产性能」担心它

⭐⭐ 细节二:格式化函数「惰性执行」
   · 只有当 DevTools「正在检查这个 Hook」时才调用
   · 所以它可以做相对昂贵的操作(但仍不要在里面发请求/改状态)

⭐⭐ 细节三:它【不是给内置 Hook 用的】
   · 你不能给 useState 加 debug value
   · 它只对「自定义 Hook」有意义(因为内置 Hook 的类型名已经显示了)
// ⚠️ 常见误解:以为它能「在控制台输出」
function useBadDebug() {
  const [x] = useState(0);
  useDebugValue(x);          // ⚠️ 控制台里什么都没有(它是给 DevTools 面板用的)
  return x;
}
// ✅ 想输出到控制台就用 console.log(但要清理)

四、完整实现:一个「可调试」的数据请求 Hook

// hooks/use-request-debug.ts —— 80 行,演示 useDebugValue 的完整用法
import { useCallback, useEffect, useRef, useState, useDebugValue } from 'react';

type Status = 'idle' | 'loading' | 'success' | 'error';

interface AsyncState {
  status: Status;
  data: T | null;
  error: Error | null;
  /** 本次请求的序号(调试用:能看到「第几次请求」) */
  requestId: number;
}

/**
 * 一个「善用 useDebugValue」的请求 Hook:
 * · DevTools 里能直接看到:状态、请求序号、URL、数据量、耗时
 * · 不需要 console.log,也不需要打开 Network 面板
 */
export function useRequest(url: string, deps: unknown[] = []) {
  const [state, setState] = useState>({
    status: 'idle', data: null, error: null, requestId: 0,
  });
  const startedAtRef = useRef(0);
  const [duration, setDuration] = useState(null);
  const seqRef = useRef(0);

  useEffect(() => {
    const controller = new AbortController();
    const seq = ++seqRef.current;
    startedAtRef.current = performance.now();

    setState((prev) => ({ status: 'loading', data: prev.data, error: null, requestId: seq }));

    fetch(url, { signal: controller.signal })
      .then(async (res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json() as Promise;
      })
      .then((data) => {
        if (controller.signal.aborted) return;
        setDuration(Math.round(performance.now() - startedAtRef.current));
        setState({ status: 'success', data, error: null, requestId: seq });
      })
      .catch((err: Error) => {
        if (err.name === 'AbortError') return;
        setDuration(Math.round(performance.now() - startedAtRef.current));
        setState({ status: 'error', data: null, error: err, requestId: seq });
      });

    return () => controller.abort();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);

  const refetch = useCallback(() => {
    setState((prev) => ({ ...prev, status: 'idle' }));
  }, []);

  // ⭐⭐ 核心:把「排查时最想知道的信息」一行展示出来
  useDebugValue(state, (s) => {
    const size = Array.isArray(s.data) ? `${s.data.length} 条` : s.data ? '1 项' : '无数据';
    const ms = duration === null ? '-' : `${duration}ms`;
    switch (s.status) {
      case 'idle': return `[${s.requestId}] 未开始 · ${url}`;
      case 'loading': return `[${s.requestId}] 加载中… · ${url}`;
      case 'success': return `[${s.requestId}] ✅ ${ms} · ${size} · ${url}`;
      case 'error': return `[${s.requestId}] ❌ ${ms} · ${s.error.message} · ${url}`;
    }
  });

  return { ...state, refetch };
}
⭐ 在 DevTools 里现在能看到(假设组件用了 useRequest('/api/users')):

  ▾ Request("/api/users")
      ▾ State: {status: 'success', ...}
      ▾ Request: "[2] ✅ 128ms · 20 条 · /api/users"      ← ⭐ 一行看全
      ▾ Effect
      ▾ State: 128
      ▾ Ref
      ▾ Callback

⭐ 排查效率的变化:
   · 「为什么列表是空的?」→ 一看就知道是「loading 卡住」还是「成功但 0 条」
   · 「请求发了几次?」→ 看 requestId([2] 说明发了 2 次)
   · 「接口慢吗?」→ 看 128ms
   · 「切换筛选后请求了吗?」→ 看 requestId 有没有变
   → 这些信息以前要靠 console.log + Network 面板来回切

五、Hook 可调试性的完整工具链

⭐ 六层工具,从「最轻」到「最重」:

① 命名规范(零成本,收益最大)
   · 自定义 Hook 用 use 开头 + 语义化命名(useUserList 而不是 useData)
   · DevTools 的 Hooks 面板里会显示「组件名 / Hook 名」

② useDebugValue(标注自定义 Hook 的内部状态)
   · 适合「有内部状态、且状态对排查有意义」的 Hook

③ React DevTools 的 Hooks 面板 + 组件高亮
   · "Highlight updates when components render":看渲染范围(见 5.1)
   · 组件的 "Why did this render?":看渲染原因

④ 渲染审计工具(why-did-you-render / React Scan)
   · 自动报告「无谓重渲染」,并给出「为什么」(哪个 prop 变了)

⑤ Profiler(DevTools + ``)
   · 看耗时与次数(见 5.1、5.12)

⑥ StrictMode + 自定义断言
   · StrictMode 暴露「渲染不纯 / 清理缺失」(见 8.5)
   · 自定义开发期断言(如「渲染中不应该调用某个函数」)
手段 成本 能看到什么 适用
命名规范 零 DevTools 里的组件/Hook 名 所有 Hook
useDebugValue 极低 自定义 Hook 的内部状态摘要 有内部状态的 Hook
DevTools Hooks 面板 零 每个 Hook 的类型与值 所有组件
Highlight updates 零 哪些组件在渲染 排查渲染范围
why-did-you-render 低(引依赖) 「无谓重渲染」+ 原因 排查性能
React Scan 低 页面级渲染热点(无需改代码) 快速定位
<Profiler onRender> 中 渲染耗时(可上报) 线上性能数据
StrictMode 零 不纯渲染 / 清理缺失 开发期正确性
自定义断言 中 业务级不变量 复杂 Hook
// ✅ ② 命名规范的实战价值(不是「格式问题」)
function useData() { /* ... */ }              // ❌ DevTools 里叫 Data,看不出用途
function useUserList() { /* ... */ }          // ✅ DevTools 里叫 UserList,一眼定位

// ✅ ③ Highlight updates 的用法(最快的「渲染范围」诊断)
// DevTools → Components → ⚙ → "Highlight updates when components render"
// 然后在输入框里打一个字:闪的区域越大,说明状态放得越高(见 5.4)
// ✅ ⑥ 自定义断言的写法(把「隐含约定」变成「开发期报错」)
function useImmutableInput(value: T, name: string): T {
  const ref = useRef(value);

  if (process.env.NODE_ENV !== 'production') {
    // ⭐ 检测「调用方是否直接改了对象」(破坏不可变约定)
    if (ref.current === value && isPlainObject(value)) {
      // 引用没变但内容可能被改了 —— 用浅快照比对来发现
      const snapshot = JSON.stringify(value);
      if (refSnapshot.current !== snapshot) {
        console.error(
          `[useImmutableInput] "${name}" 在引用不变的情况下内容变了 —— ` +
          `请使用不可变更新(如 { ...prev, k: v })而不是直接修改。`
        );
      }
      refSnapshot.current = snapshot;
    }
    ref.current = value;
  }

  return value;
}
// ✅ 自定义「渲染计数」断言(开发期发现「渲染爆炸」)
function useRenderGuard(name: string, maxPerSecond = 30) {
  const countRef = useRef({ count: 0, since: performance.now() });

  if (process.env.NODE_ENV !== 'production') {
    const now = performance.now();
    const state = countRef.current;
    if (now - state.since > 1000) {
      if (state.count > maxPerSecond) {
        console.warn(`[useRenderGuard] ${name} 每秒渲染 ${state.count} 次,可能陷入了「更新风暴」`);
      }
      countRef.current = { count: 1, since: now };
    } else {
      state.count++;
    }
  }
}

六、useDebugValue 与「生产代码」的关系

// ⭐ 生产构建里它会被编译掉(不会增加产物与运行时开销)
process.env.NODE_ENV === 'production'
  ? () => {}                                   // 生产:空操作
  : function useDebugValue(value, format) { /* 记录到 DevTools */ }
⭐ 三个实践结论:

① 不需要用 `if (__DEV__)` 包裹(React 已经处理了)
② 不需要担心「格式化函数的性能」(生产不执行,开发只在你查看时执行)
③ 但**不要**在格式化函数里做「有副作用的事」(它在「渲染期间被调用」,
   虽然不参与渲染结果,但仍不应该改状态或发请求)
// ❌ 反例:在格式化函数里做副作用
useDebugValue(state, (s) => {
  track('debug', s);              // ❌ 改外部状态(而且会在「渲染期」执行)
  return s.status;
});

// ✅ 只用它「算出可读字符串」
useDebugValue(state, (s) => `${s.status} · ${s.data?.length ?? 0} 项`);

七、什么时候不该用 useDebugValue

⭐ 四种「别用」的情况:

① 内置 Hook 的返回值 → 面板里本来就有(如 State 的值)
② 值是「原始值且自解释」→ 加一行反而增加噪音
   ❌ useDebugValue(count)                  // State: 3 已经显示了
② 格式化会「泄漏敏感信息」→ 别把 token / 用户隐私放进 debug value
   ❌ useDebugValue(session, (s) => s.token)
③ 依赖它「做断言」→ 它不是断言工具(用 console.error / throw 更合适)
// ✅ 它真正有价值的场景:状态是「复合结构」且「摘要比原值更可读」
interface FormState {
  values: Record;
  errors: Record;
  touched: Record;
  dirty: boolean;
  submitting: boolean;
}

function useForm(initial: Record) {
  const [form, setForm] = useState({
    values: initial, errors: {}, touched: {}, dirty: false, submitting: false,
  });

  // ⭐ 原值是一大坨对象(DevTools 里要展开好几层)→ 摘要成一行
  useDebugValue(form, (f) => {
    const errs = Object.keys(f.errors).length;
    return `${f.submitting ? '提交中' : '空闲'} · ` +
           `已填 ${Object.keys(f.values).length} 项 · ` +
           `${errs ? `${errs} 个错误` : '无错误'} · ` +
           `${f.dirty ? '已修改' : '未修改'}`;
  });

  return { form, setForm };
}

八、本篇特有的坑

// ① 以为它会在控制台输出
useDebugValue(x);                    // ⚠️ 控制台没有输出(它只影响 DevTools 面板)

// ② 给内置 Hook 的返回值加它(无意义且增加噪音)
useDebugValue(count);                // ⚠️ State 里已经显示了

// ③ 把「昂贵格式化」放在第一个参数(每次渲染都执行)
useDebugValue(items.map((i) => i.name).join(', '));       // ❌ 每次渲染都算
useDebugValue(items, (list) => list.map((i) => i.name).join(', '));   // ✅ 惰性

// ④ 在格式化函数里做副作用
useDebugValue(state, (s) => { track(s); return 'x'; });   // ❌

// ⑤ 在格式化函数里「发请求 / 改状态」
useDebugValue(id, (v) => { setX(v); return v; });         // ❌ 会触发渲染循环

// ⑥ 把敏感信息放进 debug value
useDebugValue(session, (s) => `token=${s.token}`);        // ❌ 屏幕共享/录屏会泄漏

// ⑦ 依赖它「检测 bug」(它不是断言工具)
// ⑧ 在「生产环境」期待它显示(DevTools 的 Hooks 面板在生产构建里信息有限)
// ⑨ 为「每一个自定义 Hook」都加(噪音过大,只给「有内部状态且不易读」的加)
// ⑩ 把「大对象」直接作为第一个参数(DevTools 每次展开都会序列化它)
useDebugValue(hugeTree);             // ⚠️ 传大对象会让 DevTools 变慢
// ⑪ 忘记「自定义 Hook 的命名」也影响 DevTools 显示(命名不规范时定位困难)
// ⑫ 在「服务端渲染」中期待它生效(服务端没有 DevTools)
// ⑬ 用它对「第三方库的 Hook」做标注(改不了人家的实现)
// ⑭ 以为「useDebugValue 能替代 Profiler」(它只显示值,不显示耗时)
// ⑮ 在格式化函数里「读闭包里的旧值」——它接收的 value 才是当前的,别混用
useDebugValue(state, () => `${count}`);   // ⚠️ 用的是外部 count(可能不是这个 Hook 的状态)
useDebugValue(state, (s) => `${s.count}`); // ✅ 用参数

九、面试延伸

  1. 「useDebugValue 是做什么的?」
    它让自定义 Hook 在 React DevTools 的 Hooks 面板里显示「有意义的标注值」,而不是只有一串「State / Effect / Memo」的类型名。它是开发专用的(生产构建里会被编译掉),并且提供了「格式化函数」的重载——把「昂贵/可读化」的工作延后到「DevTools 正在检查这个 Hook 时」才执行。

  2. 「两种写法(值 / 格式化函数)有什么区别?该用哪个?」
    useDebugValue(value) 会在每次渲染就把这个值记录下来;useDebugValue(value, format) 只在「DevTools 正在检查这个 Hook」时才调用 format。所以判断标准是「格式化是否比「直接取值」更昂贵」:取一个字段(user.name)→ 第一种就够;拼字符串、遍历数组、JSON.stringify → 必须用第二种(否则每次渲染都做这活儿)。

  3. 「它会影响生产性能吗?」
    不会。它在生产构建里会被编译掉(变成空操作),所以:① 不需要用 if (__DEV__) 包裹;② 不需要为「格式化函数的开销」担心(生产不执行,开发只在你查看时执行);③ 但仍然不要在格式化函数里做有副作用的事(它在「渲染期间」被调用,改状态或发请求会造成混乱)。

  4. 「什么时候该用它?」
    当「自定义 Hook 的内部状态是复合结构、且摘要比原值更可读」时。典型例子:① 请求 Hook 的「状态机 + 请求序号 + 耗时 + 数据量」;② 表单 Hook 的「已填项数 + 错误数 + 是否修改 + 是否提交中」;③ 复杂交互 Hook 的「当前阶段」。反之,原始值且自解释的(count、isOpen)不要加(State 里已经显示了,加了只是噪音)。

  5. 「除了 useDebugValue,还有哪些 Hook 调试手段?」
    六层:① 命名规范(useUserList 而不是 useData)——零成本、收益最大(DevTools 里直接显示名字);② useDebugValue(标注内部状态);③ DevTools 的 Hooks 面板 + “Highlight updates”(看渲染范围与 Hook 值);④ 渲染审计工具(why-did-you-render、React Scan)——自动报告「无谓重渲染 + 原因」;⑤ Profiler(DevTools 面板与 <Profiler onRender> 编程式采集耗时);⑥ StrictMode + 自定义断言(暴露不纯渲染、清理缺失、以及业务级不变量)。

  6. 「怎么用 DevTools 快速判断『渲染范围过大』?」
    打开 Components 面板 → ⚙ 设置 → “Highlight updates when components render”,然后在页面里做一个操作(如输入一个字符):闪烁的区域越大,说明状态放得越高。理想情况是「只有输入框附近闪」;如果「整页都在闪」,说明状态被提升到了过高的层级(见 5.4「状态下移」)。这是最快、最直观的诊断方式。

  7. 「why-did-you-render 和 React Scan 有什么区别?」
    ① why-did-you-render——需要在代码里引入并 patch React,它会在控制台报告「本可以不重渲染但重渲染了」并给出原因(哪个 prop 的引用变了)——信息最详细,但要改代码(通常只在开发环境引入);② React Scan——是一个无需改代码的扫描工具(浏览器扩展 / 脚本),直接在页面上高亮出「哪些组件在渲染」并给出渲染耗时——启动成本更低,适合「先快速定位热点」。实践中常组合使用:React Scan 找热点 → why-did-you-render 找原因 → Profiler 确认耗时。

  8. 「useDebugValue 能用来做『断言』吗?」
    不能(也不应该)。它是「展示工具」而不是「校验工具」——它不会报错、不影响渲染结果、也无法阻止错误行为。需要断言时应该用:① 开发期 console.error + 条件判断(如「检测到引用不变但内容变了 → 提示用不可变更新」);② 直接 throw(把不变量违反变成崩溃,交给 Error Boundary);③ StrictMode(暴露不纯渲染与清理缺失)。useDebugValue 的定位是「看得见」,断言的定位是「拦得住」。

一句话速记

useDebugValue 让自定义 Hook 在 React DevTools 的 Hooks 面板里显示「有意义的标注」,只在开发环境生效(生产被编译掉);两种用法:「useDebugValue(value)(每次渲染记录)」与「useDebugValue(value, format)(只在 DevTools 查看时才算)」,只要格式化比「取值」复杂就用第二种;别给它传大对象、别放敏感信息、别在里面做副作用、别用它当断言工具;它只解决「看得见」——完整的 Hook 可观测体系是六层:「命名规范(零成本)→ useDebugValue → DevTools 面板 + Highlight updates → why-did-you-render / React Scan → Profiler → StrictMode + 自定义断言」。

赞(0) 打赏
未经允许不得转载:陌上寒 » 2.18 useDebugValue 与 Hook 可调试性

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫