¥
立即购买

JavaScript函数声明生成器

9 浏览
1 试用
0 购买
Dec 6, 2025更新

本提示词专为JavaScript开发场景设计,能够根据用户需求生成符合最佳实践的JavaScript函数声明代码。通过精确的参数配置,可指定函数名称、功能描述、参数列表和返回值类型,确保生成的代码结构清晰、语法规范且具备完整的注释说明。适用于Web开发、客户端脚本编写、交互式应用构建等多种前端开发场景,帮助开发者快速创建高质量的JavaScript函数代码。

函数声明

/**
 * 将数值金额格式化为本地化的货币或千分位数字字符串。
 * - 使用 Intl.NumberFormat 实现本地化格式化与千分位分隔。
 * - 支持自定义货币代码、小数最小/最大小数位数。
 * - 严格处理边界:NaN/Infinity/非法参数会抛出错误;规范化 -0 为 0。
 *
 * 适用场景:
 * - 价格展示(页面 UI)
 * - 报表导出(稳定的小数位与千分位格式)
 *
 * @param {number|string} amount - 要格式化的金额;支持 number 或可被 Number() 正确解析的字符串。
 * @param {string|string[]} [locale] - BCP-47 语言标记(如 "zh-CN"、"en-US"),或其数组;省略时使用运行环境默认区域。
 * @param {string} [currency] - 3 位 ISO 4217 货币代码(如 "CNY"、"USD")。省略则输出为普通带千分位的十进制数字。
 * @param {number} [minFraction] - 最小小数位(0-20)。未传时使用该区域/货币的默认值。
 * @param {number} [maxFraction] - 最大小数位(0-20),需不小于 minFraction。未传时使用该区域/货币的默认值。
 * @returns {string} 已格式化的字符串。
 *
 * @throws {TypeError} 当 amount 非有限数值(NaN/Infinity)或类型不合法时抛出。
 * @throws {RangeError} 当 locale/currency 非法,或小数位参数越界/相互矛盾时抛出。
 */
function formatCurrency(amount, locale, currency, minFraction, maxFraction) {
  // 将输入转换为有限数值
  const toFiniteNumber = (val) => {
    if (typeof val === 'number') {
      return val;
    }
    if (typeof val === 'string') {
      const n = Number(val.trim());
      return n;
    }
    throw new TypeError('formatCurrency: "amount" must be a number or a numeric string.');
  };

  let num = toFiniteNumber(amount);
  if (!Number.isFinite(num)) {
    throw new TypeError('formatCurrency: "amount" must be a finite number (not NaN/Infinity).');
  }

  // 规范化 -0 为 0,避免显示 "-0.00" 等
  if (Object.is(num, -0)) {
    num = 0;
  }

  // 处理货币代码:允许未提供;若提供则必须是 3 位字母
  let upperCurrency;
  if (currency != null) {
    if (typeof currency !== 'string') {
      throw new TypeError('formatCurrency: "currency" must be a string ISO 4217 code, e.g., "USD".');
    }
    upperCurrency = currency.trim().toUpperCase();
    if (!/^[A-Z]{3}$/.test(upperCurrency)) {
      throw new RangeError('formatCurrency: "currency" must be a 3-letter ISO 4217 code (e.g., "CNY", "USD").');
    }
  }

  // 先用默认分组与样式创建一个格式化器,以获取该区域/货币的默认小数位
  const style = upperCurrency ? 'currency' : 'decimal';
  let defaultsNf;
  try {
    defaultsNf = new Intl.NumberFormat(locale, {
      style,
      currency: upperCurrency,
    });
  } catch (err) {
    throw new RangeError(`formatCurrency: Invalid locale or currency. ${err.message}`);
  }

  const { minimumFractionDigits: defMin, maximumFractionDigits: defMax } =
    defaultsNf.resolvedOptions();

  // 归一化小数位参数
  const normalizeDigits = (name, value) => {
    if (value === undefined || value === null) return undefined;
    const n = Number(value);
    if (!Number.isFinite(n) || !Number.isInteger(n)) {
      throw new RangeError(`formatCurrency: "${name}" must be an integer between 0 and 20.`);
    }
    if (n < 0 || n > 20) {
      throw new RangeError(`formatCurrency: "${name}" must be between 0 and 20.`);
    }
    return n;
  };

  const min = normalizeDigits('minFraction', minFraction);
  const max = normalizeDigits('maxFraction', maxFraction);

  let finalMin;
  let finalMax;

  if (min === undefined && max === undefined) {
    finalMin = defMin;
    finalMax = defMax;
  } else if (min !== undefined && max === undefined) {
    finalMin = min;
    finalMax = Math.max(min, defMax);
  } else if (min === undefined && max !== undefined) {
    finalMax = max;
    finalMin = Math.min(defMin, max);
  } else {
    if (min > max) {
      throw new RangeError('formatCurrency: "minFraction" cannot be greater than "maxFraction".');
    }
    finalMin = min;
    finalMax = max;
  }

  // 创建最终格式化器
  let formatter;
  try {
    formatter = new Intl.NumberFormat(locale, {
      style,
      currency: upperCurrency,
      useGrouping: true,
      minimumFractionDigits: finalMin,
      maximumFractionDigits: finalMax,
      // currencyDisplay 默认 "symbol";如报表需要 "code",可在注意事项中参考调整
    });
  } catch (err) {
    throw new RangeError(`formatCurrency: Failed to create formatter. ${err.message}`);
  }

  return formatter.format(num);
}

功能说明

  • 函数用途:

    • 将金额数值格式化为符合本地化规则的货币或十进制字符串,包含千分位分隔与可控小数位数。
    • 支持指定 locale 与货币代码,适用于 UI 价格展示与对小数位有严格要求的报表导出。
  • 参数说明:

    • amount (number|string):要格式化的金额;支持 number 或可被 Number() 正确解析的字符串。必须为有限数值。
    • locale (string|string[],可选):BCP-47 语言标记或数组,例如 "zh-CN"、"en-US";省略时使用运行环境默认区域。
    • currency (string,可选):3 位 ISO 4217 货币代码,如 "CNY"、"USD"、"JPY"。未提供时按十进制数字格式化(不显示货币符号)。
    • minFraction (number,可选):最小小数位(0-20),未提供时使用该区域/货币默认值。
    • maxFraction (number,可选):最大小数位(0-20),未提供时使用该区域/货币默认值;若与 minFraction 同时提供,则必须 >= minFraction。
  • 返回值:

    • string:格式化后的字符串。
  • 使用示例:

    • 基本货币格式化(UI 展示)
      • formatCurrency(1234567.89, 'zh-CN', 'CNY') => "¥1,234,567.89"
      • formatCurrency(1234567.89, 'en-US', 'USD') => "$1,234,567.89"
    • 报表导出(固定两位小数)
      • formatCurrency(1234.5, 'zh-CN', 'CNY', 2, 2) => "¥1,234.50"
      • formatCurrency('9876', 'en-GB', 'GBP', 2, 2) => "£9,876.00"
    • 无货币代码时作为十进制数字输出(仍含千分位)
      • formatCurrency(1234567.89, 'de-DE') => "1.234.567,89"
    • 处理日元(默认 0 位,强制两位)
      • formatCurrency(1234, 'ja-JP', 'JPY') => "¥1,234"
      • formatCurrency(1234, 'ja-JP', 'JPY', 2, 2) => "¥1,234.00"

注意事项

  • 代码兼容性:
    • 依赖 Intl.NumberFormat。现代浏览器与 Node.js 均已支持;在老旧环境或 Node 打包裁剪 ICU 数据的场景,请确保完整的 ICU 支持,否则部分 locale/currency 可能不可用。
  • 特殊使用场景提示:
    • 报表导出通常需要稳定的小数位,建议将 minFraction 与 maxFraction 设为相同值(例如 2)。
    • 若需要在报表中显示货币代码而非符号(如 "USD 1,234.56"),可将 formatter 的 currencyDisplay 由默认 "symbol" 改为 "code";或在函数内根据需求扩展一个参数。
    • 若需要在高频循环中大量格式化,考虑在业务层缓存 Intl.NumberFormat 实例(按 locale/currency/小数位作为缓存键)以减少实例化开销。
  • 可能的错误处理建议:
    • 函数对非法 amount(NaN/Infinity)或参数越界会抛出错误;在 UI 中建议使用 try/catch 包裹并提供降级回退(例如显示 "--" 或原始数值)。
    • 过大数值(超过 Number 安全整数范围)可能存在精度风险;如需处理超大金额,建议在数据层使用十进制库(如 decimal.js)先做精度运算,再传入本函数进行展示格式化。

函数声明

/**
 * 封装基于 fetch 的 JSON 请求,支持重试(指数退避+抖动)、超时控制与中止信号、状态码分类处理与错误包装。
 * 调用端建议统一捕获并上报日志(参见使用示例)。
 *
 * @template T
 * @param {string | URL} url - 请求地址
 * @param {RequestInit} [options] - fetch 的原生请求配置(headers、method、body 等)
 * @param {number} [retries=2] - 最大重试次数(不含首次请求),例如 2 表示最多发起 3 次请求
 * @param {number} [backoffMs=300] - 指数退避的初始等待时长(毫秒)
 * @param {number} [timeoutMs=10000] - 每次请求的超时时长(毫秒)
 * @param {AbortSignal} [signal] - 外部中止信号(优先级高于内部超时)
 * @returns {Promise<T>} - 解析后的 JSON 数据
 *
 * @example
 * // 推荐:在调用端统一日志上报(示例使用控制台代替)
 * (async () => {
 *   try {
 *     const data = await fetchJsonWithRetry('https://api.example.com/data', {
 *       method: 'GET',
 *       headers: { 'Accept': 'application/json' }
 *     }, 3, 400, 8000);
 *     console.log('SUCCESS', data);
 *   } catch (err) {
 *     // 统一错误上报入口(可接入监控/埋点系统)
 *     console.error('REQUEST_FAILED', {
 *       message: err?.message,
 *       name: err?.name,
 *       code: err?.code,
 *       status: err?.status,
 *       url: err?.url,
 *       method: err?.method,
 *       category: err?.category,
 *       isRetryable: err?.isRetryable,
 *     });
 *   }
 * })();
 */
async function fetchJsonWithRetry(
  url,
  options = {},
  retries = 2,
  backoffMs = 300,
  timeoutMs = 10_000,
  signal
) {
  // ---- 常量配置 ----
  const MAX_BACKOFF_MS = 30_000;
  const ERROR_BODY_LIMIT = 8_192; // 错误体读取大小上限(字节)
  const DEFAULT_ACCEPT = 'application/json';

  // ---- 参数校验 ----
  if (typeof retries !== 'number' || retries < 0 || !Number.isFinite(retries)) {
    throw new TypeError('retries must be a non-negative finite number');
  }
  if (typeof backoffMs !== 'number' || backoffMs < 0 || !Number.isFinite(backoffMs)) {
    throw new TypeError('backoffMs must be a non-negative finite number');
  }
  if (typeof timeoutMs !== 'number' || timeoutMs <= 0 || !Number.isFinite(timeoutMs)) {
    throw new TypeError('timeoutMs must be a positive finite number');
  }

  // ---- 工具函数与错误类型 ----
  class RequestError extends Error {
    /**
     * @param {string} message
     * @param {object} meta
     * @param {string} [meta.code] - 错误码,如 ETIMEDOUT/EFETCH/HTTP_5XX/HTTP_4XX/ABORTED 等
     * @param {boolean} [meta.isRetryable=false] - 是否建议重试
     * @param {string | URL} [meta.url]
     * @param {string} [meta.method]
     * @param {number} [meta.status]
     * @param {string} [meta.statusText]
     * @param {string} [meta.category] - 'network' | 'timeout' | 'abort' | 'client' | 'server'
     * @param {Record<string,string>} [meta.responseHeaders]
     * @param {unknown} [meta.responseBodySnippet]
     * @param {Error} [meta.cause]
     */
    constructor(message, meta = {}) {
      super(message);
      this.name = 'RequestError';
      this.code = meta.code;
      this.isRetryable = Boolean(meta.isRetryable);
      this.url = String(meta.url ?? '');
      this.method = meta.method;
      this.status = meta.status;
      this.statusText = meta.statusText;
      this.category = meta.category;
      this.responseHeaders = meta.responseHeaders;
      this.responseBodySnippet = meta.responseBodySnippet;
      if (meta.cause) this.cause = meta.cause;
    }
  }

  /** 解析 Retry-After 头为等待毫秒数(返回 null 表示不可用) */
  function parseRetryAfterMs(hv) {
    if (!hv) return null;
    const seconds = Number(hv);
    if (Number.isFinite(seconds)) {
      return seconds > 0 ? seconds * 1000 : 0;
    }
    // 日期格式
    const dateMs = Date.parse(hv);
    if (Number.isFinite(dateMs)) {
      const delay = dateMs - Date.now();
      return delay > 0 ? delay : 0;
    }
    return null;
  }

  /** 指数退避 + 抖动(full jitter),并进行上限裁剪 */
  function computeBackoffMs(base, attemptIndex) {
    const exp = Math.pow(2, attemptIndex); // attemptIndex: 0,1,2,...
    const raw = Math.min(base * exp, MAX_BACKOFF_MS);
    return Math.floor(Math.random() * raw); // full jitter
  }

  /** 判断状态码是否可重试 */
  function isRetryableStatus(status) {
    if (status === 408) return true; // Request Timeout
    if (status === 429) return true; // Too Many Requests
    if (status >= 500 && status < 600) return true; // 5xx
    return false;
  }

  /** 将 Headers 转为简单对象便于记录 */
  function headersToObject(headers) {
    /** @type {Record<string,string>} */
    const obj = {};
    headers.forEach((v, k) => { obj[k] = v; });
    return obj;
  }

  /** 创建带超时和外部 signal 的联合信号 */
  function createTimeoutLinkedSignal(timeout, externalSignal) {
    const controller = new AbortController();
    let timeoutId = null;
    let timedOut = false;

    const onExternalAbort = () => {
      if (!controller.signal.aborted) {
        controller.abort(externalSignal.reason ?? new DOMException('Aborted', 'AbortError'));
      }
    };

    if (externalSignal) {
      if (externalSignal.aborted) {
        controller.abort(externalSignal.reason ?? new DOMException('Aborted', 'AbortError'));
      } else {
        externalSignal.addEventListener('abort', onExternalAbort, { once: true });
      }
    }

    timeoutId = setTimeout(() => {
      timedOut = true;
      if (!controller.signal.aborted) {
        // 兼容性:部分环境没有 DOMException,这里回退为 Error 并设置 name
        const err = typeof DOMException !== 'undefined'
          ? new DOMException('TimeoutError', 'TimeoutError')
          : Object.assign(new Error('TimeoutError'), { name: 'TimeoutError' });
        controller.abort(err);
      }
    }, timeout);

    const cleanup = () => {
      if (timeoutId) clearTimeout(timeoutId);
      if (externalSignal) externalSignal.removeEventListener('abort', onExternalAbort);
    };

    return { signal: controller.signal, cleanup, isTimedOut: () => timedOut };
  }

  /** 支持 signal 的 sleep,abort 时抛出 AbortError */
  function sleep(ms, abortSignal) {
    return new Promise((resolve, reject) => {
      if (ms <= 0) return resolve();
      let t = null;
      const onAbort = () => {
        if (t) clearTimeout(t);
        const reason = abortSignal?.reason;
        if (reason instanceof Error) return reject(reason);
        const err = typeof DOMException !== 'undefined'
          ? new DOMException('Aborted', 'AbortError')
          : Object.assign(new Error('Aborted'), { name: 'AbortError' });
        reject(reason ?? err);
      };
      if (abortSignal?.aborted) return onAbort();

      t = setTimeout(() => {
        abortSignal?.removeEventListener('abort', onAbort);
        resolve();
      }, ms);

      abortSignal?.addEventListener('abort', onAbort, { once: true });
    });
  }

  /** 读取错误体(受大小上限约束),返回文本片段 */
  async function readErrorBodySnippet(response) {
    try {
      const lenHeader = response.headers.get('content-length');
      const len = lenHeader ? Number(lenHeader) : NaN;
      if (Number.isFinite(len) && len > ERROR_BODY_LIMIT) {
        return `[body omitted: content-length ${len} > limit ${ERROR_BODY_LIMIT}]`;
      }
      // 如果没有明确的 content-length,尽量读取,但截断
      const text = await response.text();
      return text.length > ERROR_BODY_LIMIT
        ? text.slice(0, ERROR_BODY_LIMIT) + '…[truncated]'
        : text;
    } catch {
      return '[unreadable error body]';
    }
  }

  /** 解析响应为 JSON(空体/204 返回 null) */
  async function parseJsonSafely(response) {
    if (response.status === 204) return null;
    const ct = response.headers.get('content-type') || '';
    // 某些接口返回 "" 或 text/plain 但实际是 JSON,尝试兼容
    const isLikelyJson = /\bjson\b/i.test(ct) || ct === '';
    if (!isLikelyJson) {
      // 不是 JSON 类型也尝试 JSON.parse(若失败交给调用方感知)
      const text = await response.text();
      if (text === '') return null;
      try {
        return JSON.parse(text);
      } catch (e) {
        const err = new RequestError('Unexpected content-type, failed to parse JSON', {
          code: 'EJSONPARSE',
          isRetryable: false,
          url,
          method: options?.method || 'GET',
          status: response.status,
          statusText: response.statusText,
          category: response.status >= 500 ? 'server' : 'client',
          responseHeaders: headersToObject(response.headers),
          responseBodySnippet: text.slice(0, ERROR_BODY_LIMIT),
          cause: e instanceof Error ? e : undefined,
        });
        throw err;
      }
    }
    // 标准 JSON 流程
    if (response.headers.get('content-length') === '0') return null;
    // 有些服务会返回空体但未设置 content-length=0
    const text = await response.text();
    if (text === '') return null;
    return JSON.parse(text);
  }

  // ---- 准备请求头(不覆盖调用方显式传入) ----
  const headers = new Headers(options.headers || {});
  if (!headers.has('Accept')) headers.set('Accept', DEFAULT_ACCEPT);

  // ---- 逐次尝试 ----
  /** @type {Error | null} */
  let lastError = null;

  for (let attempt = 0; attempt <= retries; attempt++) {
    const { signal: linkedSignal, cleanup, isTimedOut } = createTimeoutLinkedSignal(timeoutMs, signal);
    try {
      const res = await fetch(url, {
        ...options,
        headers,
        signal: linkedSignal,
      });

      if (res.ok) {
        const data = await parseJsonSafely(res);
        cleanup();
        return /** @type {any} */ (data);
      }

      // 非 2xx,构造错误并判断是否重试
      const status = res.status;
      const category = status >= 500 ? 'server' : 'client';
      const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after'));
      const retryable = isRetryableStatus(status);

      const bodySnippet = await readErrorBodySnippet(res);
      const err = new RequestError(`HTTP ${status} ${res.statusText}`, {
        code: status >= 500 ? 'HTTP_5XX' : status === 429 ? 'HTTP_429' : 'HTTP_4XX',
        isRetryable: retryable,
        url,
        method: options?.method || 'GET',
        status,
        statusText: res.statusText,
        category,
        responseHeaders: headersToObject(res.headers),
        responseBodySnippet: bodySnippet,
      });

      if (retryable && attempt < retries) {
        const baseWait = retryAfterMs ?? computeBackoffMs(backoffMs, attempt);
        // 避免负值/NaN
        const waitMs = Math.max(0, Number.isFinite(baseWait) ? baseWait : backoffMs);
        cleanup();
        await sleep(waitMs, signal); // 等待期间尊重外部中止
        lastError = err;
        continue;
      }

      cleanup();
      throw err;
    } catch (e) {
      cleanup();

      // 外部中止:直接抛出
      if (signal?.aborted) {
        const err = new RequestError('Aborted by external signal', {
          code: 'ABORTED',
          isRetryable: false,
          url,
          method: options?.method || 'GET',
          category: 'abort',
          cause: e instanceof Error ? e : undefined,
        });
        throw err;
      }

      // 超时中止
      if (e && (e.name === 'TimeoutError' || (e.name === 'AbortError' && isTimedOut()))) {
        const timeoutErr = new RequestError('Request timed out', {
          code: 'ETIMEDOUT',
          isRetryable: attempt < retries, // 超时通常可重试
          url,
          method: options?.method || 'GET',
          category: 'timeout',
          cause: e instanceof Error ? e : undefined,
        });
        if (attempt < retries) {
          const waitMs = computeBackoffMs(backoffMs, attempt);
          await sleep(waitMs, signal);
          lastError = timeoutErr;
          continue;
        }
        throw timeoutErr;
      }

      // 其他网络类错误(如 DNS、连接失败等),视为可重试
      const netErr = new RequestError('Network error during fetch', {
        code: 'EFETCH',
        isRetryable: attempt < retries,
        url,
        method: options?.method || 'GET',
        category: 'network',
        cause: e instanceof Error ? e : undefined,
      });
      if (attempt < retries) {
        const waitMs = computeBackoffMs(backoffMs, attempt);
        await sleep(waitMs, signal);
        lastError = netErr;
        continue;
      }
      throw netErr;
    }
  }

  // 理论上不会到达此处
  if (lastError) throw lastError;
  throw new RequestError('Unknown error', { code: 'EUNKNOWN', url, method: options?.method || 'GET' });
}

功能说明

  • 函数用途:基于 fetch 发起 JSON 请求,支持以下能力:
    • 重试机制:对 408、429、5xx、网络错误、超时进行最多 N 次重试,使用指数退避 + 抖动(full jitter),并尊重服务端 Retry-After 头。
    • 超时控制:为每次请求单独设置超时;超时以 AbortSignal 触发,错误码为 ETIMEDOUT。
    • 中止信号:支持外部 AbortSignal;一旦外部中止,立即停止请求与等待流程并抛出 ABORTED。
    • 状态码分类:将错误分类为 client/server/network/timeout/abort;包装为 RequestError,附带元信息(status、headers 片段、错误体片段等)。
    • JSON 解析:自动处理 204/空体返回为 null;容错处理非标准 JSON 响应并在解析失败时抛出 EJSONPARSE。
  • 参数说明:
    • url (string | URL):请求地址。
    • options (RequestInit):原生 fetch 配置,如 method、headers、body 等。默认补充 Accept: application/json(不覆盖显式传入)。
    • retries (number):最大重试次数(不含首次),默认 2。
    • backoffMs (number):退避初始基准毫秒数,默认 300,内置最大退避上限 30s。
    • timeoutMs (number):单次请求超时毫秒数,默认 10000。
    • signal (AbortSignal):外部中止信号,优先级高于内部超时。
  • 返回值:
    • Promise:解析得到的 JSON 对象;当响应为空或 204 时返回 null。
  • 使用示例:
    • 见函数注释中的示例。建议统一在调用端捕获 RequestError 并对关键信息(code、status、isRetryable、url、method、category)进行日志上报或监控告警。

注意事项

  • 代码兼容性:
    • 需要全局 fetch、Headers、AbortController 支持。Node.js 环境请使用 Node 18+ 或引入 fetch/undici polyfill。
    • DOMException 在部分非浏览器环境可能不存在,已做降级处理。
  • 特殊使用场景提示:
    • 重试建议仅用于幂等/可安全重试的请求(如 GET/HEAD)。对 POST/PUT 等非幂等操作请谨慎开启或让服务端支持幂等键。
    • 当服务端返回 Retry-After 时会优先采用该等待时长,可能导致等待时间较长。
    • 错误体仅截取片段用于日志,避免内存开销;大响应将被省略或截断。
  • 可能的错误处理建议:
    • 建议调用端统一封装日志上报,基于 err.code、err.status、err.category、err.isRetryable 做分级处理。
    • 对超时(ETIMEDOUT)与网络错误(EFETCH)通常可尝试再次调用或降级处理;对于 4xx 应检查请求参数与鉴权。
    • 若需要全局超时(而非单次请求超时),可在外层创建独立 AbortController 并将其 signal 传入本函数。

函数声明

/**
 * @callback ScrollSpyOnChange
 * @param {Object} detail - 变化详情
 * @param {string|null} detail.activeId - 当前活动标题的 id(无匹配时为 null)
 * @param {string|null} detail.previousId - 上一个活动标题的 id(首次计算或无匹配时为 null)
 * @param {HTMLElement|null} detail.activeElement - 当前活动标题元素
 * @param {number} detail.index - 当前活动标题在 sections 中的索引(无匹配时为 -1)
 * @param {Array<{id: string, el: HTMLElement, top: number}>} detail.sections - 标题清单及其计算位置(按 top 升序)
 * @param {Window|HTMLElement} detail.container - 滚动容器
 */

/**
 * 为单页文档实现目录滚动高亮(Scroll Spy)。
 * - 监听滚动并计算活动区块
 * - 支持偏移(用于固定头部等布局)
 * - 支持节流,降低滚动处理负载
 * - 提供 onChange 回调,以便外部更新目录高亮和 ARIA 属性
 *
 * 无返回值。函数会为相应容器绑定滚动和尺寸变化监听。
 *
 * 可接收自定义事件:document.dispatchEvent(new Event('scrollspy:refresh')) 以手动刷新位置缓存。
 *
 * @param {string} containerSelector - 滚动容器选择器;使用 'window'(或传空/无效)则监听窗口滚动
 * @param {string} headingsSelector - 标题选择器(建议限定到带 id 的标题,如 'main h2[id], main h3[id]')
 * @param {number} [offset=0] - 计算活动区块时的像素偏移(正值等效于向下移动参考线)
 * @param {number} [throttleMs=100] - 滚动与尺寸变更处理的节流间隔(毫秒,推荐 50-150)
 * @param {ScrollSpyOnChange} [onChange] - 活动区块变化时触发的回调
 * @returns {void}
 */
export function initScrollSpy(
  containerSelector,
  headingsSelector,
  offset = 0,
  throttleMs = 100,
  onChange
) {
  // SSR/非浏览器环境保护
  if (typeof window === 'undefined' || typeof document === 'undefined') {
    return;
  }

  // 参数校验
  const isString = (v) => typeof v === 'string';
  if (containerSelector != null && !isString(containerSelector)) {
    throw new TypeError('initScrollSpy: containerSelector 必须为字符串。');
  }
  if (!isString(headingsSelector) || !headingsSelector.trim()) {
    throw new TypeError('initScrollSpy: headingsSelector 必须为非空字符串。');
  }
  if (typeof offset !== 'number' || !Number.isFinite(offset)) {
    throw new TypeError('initScrollSpy: offset 必须为有限数字。');
  }
  if (typeof throttleMs !== 'number' || !Number.isFinite(throttleMs) || throttleMs < 0) {
    throw new TypeError('initScrollSpy: throttleMs 必须为大于等于 0 的有限数字。');
  }
  if (onChange != null && typeof onChange !== 'function') {
    throw new TypeError('initScrollSpy: onChange 必须为函数(或省略)。');
  }

  const safeThrottleMs = Math.max(16, Math.floor(throttleMs) || 0); // 保底 ~1 帧

  // 选择滚动容器
  let container;
  const wantWindow =
    !containerSelector ||
    containerSelector === 'window' ||
    containerSelector === 'document' ||
    !document.querySelector(containerSelector);

  if (wantWindow) {
    container = window;
  } else {
    container = document.querySelector(containerSelector);
  }

  // 在容器范围内查询标题(window 使用 document 范围)
  const scope = container === window ? document : container;
  let headings = Array.from(scope.querySelectorAll(headingsSelector)).filter(
    (el) => el instanceof HTMLElement
  );

  // 过滤无 id 的标题,并提示
  const noIdCount = headings.filter((h) => !h.id).length;
  if (noIdCount > 0) {
    // 仅提示一次,避免刷屏
    console.warn(
      `initScrollSpy: 检测到 ${noIdCount} 个未设置 id 的标题,这些标题将不会参与滚动高亮。`
    );
    headings = headings.filter((h) => !!h.id);
  }

  if (headings.length === 0) {
    console.warn('initScrollSpy: 未找到任何可用标题,函数将不执行。');
    return;
  }

  // 工具函数
  const safeCssEscape = (s) => {
    if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
      return CSS.escape(s);
    }
    // 简易转义:为特殊字符加反斜杠
    return String(s).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
  };

  const getScrollTop = () => {
    if (container === window) {
      return (
        window.pageYOffset ||
        document.documentElement.scrollTop ||
        document.body.scrollTop ||
        0
      );
    }
    return container.scrollTop;
  };

  const getTopWithinContainer = (el) => {
    if (container === window) {
      const rect = el.getBoundingClientRect();
      const pageY =
        window.pageYOffset ||
        document.documentElement.scrollTop ||
        document.body.scrollTop ||
        0;
      return rect.top + pageY;
    }
    const elRect = el.getBoundingClientRect();
    const cRect = container.getBoundingClientRect();
    return elRect.top - cRect.top + container.scrollTop;
  };

  const throttle = (fn, wait) => {
    let last = 0;
    let timer = null;
    let lastArgs;
    return function throttled(...args) {
      const now = Date.now();
      const remain = wait - (now - last);
      lastArgs = args;
      if (remain <= 0) {
        if (timer) {
          clearTimeout(timer);
          timer = null;
        }
        last = now;
        fn.apply(this, args);
      } else if (!timer) {
        timer = setTimeout(() => {
          last = Date.now();
          timer = null;
          fn.apply(this, lastArgs);
        }, remain);
      }
    };
  };

  // 数据缓存
  let sections = [];
  let lastActiveId = null;

  const refreshSections = () => {
    sections = headings
      .map((el) => ({
        id: el.id,
        el,
        top: Math.floor(getTopWithinContainer(el)),
      }))
      .sort((a, b) => a.top - b.top);
  };

  const pickActive = () => {
    if (!sections.length) return { active: null, index: -1 };
    const y = getScrollTop() + offset + 1; // +1 避免边界抖动
    let idx = -1;
    for (let i = 0; i < sections.length; i += 1) {
      if (sections[i].top <= y) idx = i;
      else break;
    }
    if (idx === -1) idx = 0; // 顶部区域默认选中第一个标题
    return { active: sections[idx], index: idx };
  };

  const defaultOnChange = ({ activeId, previousId }) => {
    // 默认行为:为匹配 a[href="#id"] 的目录链接设置 aria-current
    // 你可以在自定义 onChange 中执行更复杂的 class 切换与滚动联动
    if (!activeId && !previousId) return;

    const qa = (sel) => Array.from(document.querySelectorAll(sel));

    if (previousId) {
      const prevSel = `a[href="#${safeCssEscape(previousId)}"]`;
      qa(prevSel).forEach((a) => a.removeAttribute('aria-current'));
    }
    if (activeId) {
      const nextSel = `a[href="#${safeCssEscape(activeId)}"]`;
      qa(nextSel).forEach((a) => a.setAttribute('aria-current', 'true'));
    }
  };

  const emitChange = ({ active, index }) => {
    const activeId = active ? active.id : null;
    const previousId = lastActiveId;

    if (activeId === lastActiveId) return;

    lastActiveId = activeId;

    const detail = {
      activeId,
      previousId,
      activeElement: active ? active.el : null,
      index: active ? index : -1,
      sections,
      container,
    };

    try {
      if (typeof onChange === 'function') {
        onChange(detail);
      } else {
        defaultOnChange(detail);
      }
    } catch (err) {
      // 防御性:避免回调抛错影响滚动
      console.error('initScrollSpy: onChange 执行出错:', err);
    }
  };

  const update = () => {
    if (!sections.length) return;
    const { active, index } = pickActive();
    emitChange({ active, index });
  };

  // 组合:刷新 + 更新
  const refreshAndUpdate = () => {
    refreshSections();
    update();
  };

  // 事件绑定(被动监听以提升滚动流畅度)
  const onScroll = throttle(update, safeThrottleMs);
  const onResize = throttle(refreshAndUpdate, Math.max(safeThrottleMs, 100));

  const addListeners = () => {
    const opts = { passive: true };
    if (container === window) {
      window.addEventListener('scroll', onScroll, opts);
    } else {
      container.addEventListener('scroll', onScroll, opts);
    }
    window.addEventListener('resize', onResize, opts);
    window.addEventListener('orientationchange', onResize, opts);
    window.addEventListener('load', refreshAndUpdate, opts);

    // 提供手动刷新钩子(适合动态内容变更后)
    document.addEventListener('scrollspy:refresh', refreshAndUpdate, opts);
  };

  // 初始化
  refreshAndUpdate();
  addListeners();

  // 注意:根据需求可返回 teardown 以解除监听,但本实现遵循返回 void 的约束。
}

功能说明

  • 函数用途:在单页文档中,根据滚动位置自动计算并标记当前活动标题(Scroll Spy)。支持偏移量(用于处理固定头部等布局差异),并通过节流降低滚动与尺寸变更的处理频率。提供 onChange 回调以便外部更新目录高亮与可访问性状态(如 aria-current)。
  • 参数说明:
    • containerSelector (string):滚动容器选择器。传入 'window' 或无效选择器时使用窗口滚动。
    • headingsSelector (string):需要监听的标题元素选择器,建议限定到带 id 的标题,如 'main h2[id], main h3[id]'。
    • offset (number, 默认 0):在计算活动标题时使用的像素偏移。正值相当于向下移动参考线(通常用于抵消固定头部高度)。
    • throttleMs (number, 默认 100ms):滚动与尺寸变更处理的节流间隔。建议 50-150ms 之间权衡流畅性与性能。
    • onChange (function, 可选):活动标题变化时触发的回调,入参包含 activeId、previousId、activeElement、index、sections 和 container。
  • 返回值:void(无返回值)。函数内部会绑定必要的事件监听。
  • 使用示例:
    // 固定头部高度 64px,监听主内容区的二、三级标题
    initScrollSpy(
      'main',
      'h2[id], h3[id]',
      64, // offset: 顶部固定导航高度
      100, // throttleMs: 节流间隔
      ({ activeId, previousId, activeElement }) => {
        // 自定义:更新目录高亮与可访问性
        const toc = document.querySelector('.toc');
        if (!toc) return;
    
        // 清除旧状态
        toc.querySelectorAll('[aria-current="true"]').forEach((a) => {
          a.removeAttribute('aria-current');
          a.classList.remove('is-active');
        });
    
        // 设置新状态
        if (activeId) {
          // 注意使用 CSS.escape 以避免特殊字符选择器问题
          const esc = (s) =>
            (window.CSS && CSS.escape) ? CSS.escape(s) : s.replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
          const links = toc.querySelectorAll(`a[href="#${esc(activeId)}"]`);
          links.forEach((a) => {
            a.setAttribute('aria-current', 'true');
            a.classList.add('is-active');
          });
        }
    
        // 可选(辅助键盘焦点可达性):确保标题可聚焦
        if (activeElement && activeElement.tabIndex < 0) {
          activeElement.tabIndex = -1; // 仅通过脚本聚焦时可用,不影响顺序
        }
      }
    );
    
    // 动态内容插入图片/异步渲染后(标题位置发生改变)手动刷新:
    document.dispatchEvent(new Event('scrollspy:refresh'));
    

注意事项

  • 代码兼容性说明:
    • 依赖现代浏览器的 passive 事件与 ES6+ 语法;如需在旧环境运行,请使用 Babel 转译并按需填充(polyfill)。
    • CSS.escape 在部分旧浏览器中不可用,代码已内置降级处理;如目录链接 href 含特殊字符,建议自行处理转义。
  • 特殊使用场景提示:
    • 固定头部:请传入与之相等的 offset(如 64)以获得更准确的激活区域判定。
    • 自定义滚动容器:当 containerSelector 为某个可滚动元素时,函数会在该元素上监听滚动,并计算标题相对该容器的偏移。
    • 动态内容(图片懒加载、异步渲染):可在内容稳定后触发 document.dispatchEvent(new Event('scrollspy:refresh')) 进行位置重算。
    • 可访问性建议(ARIA 与键盘):
      • 在目录链接上使用 aria-current="true" 标记当前活动项。
      • 目录(nav)应具有 role="navigation" 和 aria-label,链接文字清晰描述对应章节。
      • 对于非可聚焦标题,可在激活时临时设置 tabIndex = -1,以便脚本可聚焦并与 Skip Link 兼容。
      • 可为目录列表添加键盘导航(ArrowUp/ArrowDown/Home/End),辅助无鼠标用户快速跳转。
  • 可能的错误处理建议:
    • 若 headingsSelector 无法匹配到任何带 id 的标题,函数将输出警告并中止执行。
    • onChange 回调内部若发生异常不会中断滚动处理,但会在控制台输出错误日志,建议在回调内做好异常防护。
    • 避免对同一容器与同一批标题重复初始化,否则可能产生重复事件监听与多次回调。可在业务侧管理初始化流程或封装单例。

示例详情

解决的问题

面向前端工程师、全栈开发者与技术团队,帮助在最短时间内产出结构清晰、风格统一、可直接合并的 JavaScript 函数声明。通过一次配置函数名称、意图、参数与返回值,自动生成含完整注释、错误处理建议与示例用法的高质量代码,显著缩短开发与评审周期,降低遗漏与返工;适用于通用工具、数据处理、页面交互与网络封装等高频场景,助力个人高效交付、团队标准落地与知识沉淀,并以可复用的提示词方案拉动转化与复购。

适用用户

前端工程师

新建模块时一键生成规范函数与注释,快速落地交互逻辑;重构旧代码为清晰结构,缩短评审与联调时间。

全栈开发者

为数据处理、工具库与浏览器逻辑快速产出稳健函数,统一命名与风格,减少后端对接与前端适配摩擦。

技术负责人

用统一模板落地团队编码约定,规范错误处理与注释标准;帮助新人快速上手,降低线上缺陷率。

特征总结

一键生成符合规范的函数声明,按需匹配命名、参数与返回,落地即用,省去手写模板时间。
自动补全注释与使用示例,清晰说明参数作用与返回意义,方便团队协作与交接。
内置最佳实践与性能考量,自动避免常见坑并给出错误处理建议,降低线上风险。
支持多场景预设,覆盖Web开发、交互脚本、数据处理等,用同一套流程快速开工。
可自定义参数校验与边界处理,按业务要求生成更稳健的函数骨架,减少后期返工。
根据输入描述自动优化函数结构与命名,提升可读性与维护性,遵循团队编码约定。
提供兼容性与注意事项提示,帮助在不同浏览器与构建环境中稳定运行。
模板化输出与可复用片段,常用函数一键复刻,搭建个人与团队的代码资产库。
轻松集成到日常开发流程,配合代码评审与文档编写,缩短需求到上线的周期。
支持多次迭代生成与微调,快速对齐需求变化,保持代码与产品节奏一致。

如何使用购买的提示词模板

1. 直接在外部 Chat 应用中使用

将模板生成的提示词复制粘贴到您常用的 Chat 应用(如 ChatGPT、Claude 等),即可直接对话使用,无需额外开发。适合个人快速体验和轻量使用场景。

2. 发布为 API 接口调用

把提示词模板转化为 API,您的程序可任意修改模板参数,通过接口直接调用,轻松实现自动化与批量处理。适合开发者集成与业务系统嵌入。

3. 在 MCP Client 中配置使用

在 MCP client 中配置对应的 server 地址,让您的 AI 应用自动调用提示词模板。适合高级用户和团队协作,让提示词在不同 AI 工具间无缝衔接。

AI 提示词价格
¥20.00元
先用后买,用好了再付款,超安全!

您购买后可以获得什么

获得完整提示词模板
- 共 611 tokens
- 4 个可调节参数
{ 函数名称 } { 功能描述 } { 参数列表 } { 返回值类型 }
获得社区贡献内容的使用权
- 精选社区优质案例,助您快速上手提示词
使用提示词兑换券,低至 ¥ 9.9
了解兑换券 →
限时半价

不要错过!

半价获取高级提示词-优惠即将到期

17
:
23
小时
:
59
分钟
:
59