×
¥
查看详情
🔥 会员专享 文生文 其它

JavaScript事件监听器生成器

👁️ 65 次查看
📅 Dec 8, 2025
💡 核心价值: 本提示词专为前端开发场景设计,能够根据用户指定的HTML元素、事件类型和回调需求,生成符合JavaScript最佳实践的事件监听代码。通过结构化参数输入,自动处理事件绑定、事件对象处理和错误捕获等核心功能,支持多种常见事件类型和元素选择方式,帮助开发者快速实现网页交互功能,提升开发效率和代码质量。输出结果包含完整代码实现、技术原理说明和实际应用指导。

🎯 可自定义参数(3个)

元素选择器
需要绑定事件的HTML元素选择器
事件类型
要监听的事件类型
回调描述
事件触发后需要执行的功能描述

🎨 效果示例

代码实现

"use strict";

/**
 * 事件委托方式绑定“加入购物车”点击事件。
 * - 目标元素:.product-card .buy-btn
 * - 事件类型:click
 * - 回调逻辑:
 *   1) 将当前商品加入购物车
 *   2) 更新右上角购物车数量徽标
 *   3) 按钮进入短暂禁用与加载态防止重复点击
 *   4) 记录一次埋点事件
 *   5) 异常时轻提示且恢复按钮状态
 *
 * 返回值:一个用于解绑事件的函数。
 */
function attachBuyButtonListener(options = {}) {
  const cfg = normalizeConfig(options);

  // 用 WeakSet 记录处于“加载态”的按钮,避免重复点击并防止内存泄漏
  const busyButtons = new WeakSet();

  function clickHandler(event) {
    // 事件委托:只处理 .buy-btn 上的点击
    const btn = event.target.closest(cfg.selectors.button);
    if (!btn) return;

    const card = btn.closest(cfg.selectors.card);
    if (!card) return;

    // 如果是 <a>,阻止默认跳转
    if (btn.tagName === "A") {
      event.preventDefault();
    }

    // 已处于忙碌态或被禁用时直接返回
    if (busyButtons.has(btn) || btn.disabled) {
      return;
    }

    // 获取商品数据
    let product;
    try {
      product = cfg.getProductFromCard(card);
    } catch (ex) {
      console.error("[buy-btn] getProductFromCard error:", ex);
      cfg.toast("无法获取商品信息,请稍后重试");
      return;
    }

    if (!product || !product.id) {
      cfg.toast("商品信息缺失,无法加入购物车");
      return;
    }

    // 设置按钮加载态
    busyButtons.add(btn);
    setButtonLoading(btn, true, cfg.loadingText, cfg.loadingClass);

    // 预埋点(不阻塞主流程)
    try {
      cfg.services.track("buy_click", {
        productId: product.id,
        productName: product.name,
        price: product.price,
      });
    } catch (trackErr) {
      // 埋点失败不影响主流程
      console.warn("[buy-btn] track buy_click error:", trackErr);
    }

    // 加入购物车
    (async () => {
      try {
        const result = await cfg.services.addItem(product);

        // 更新徽标
        const cartCount =
          result && typeof result.cartCount !== "undefined"
            ? Number(result.cartCount)
            : null;
        updateCartBadge(cfg.root, cfg.selectors.badge, cartCount);

        // 成功埋点
        try {
          cfg.services.track("add_to_cart_success", {
            productId: product.id,
            productName: product.name,
            price: product.price,
            cartCount,
          });
        } catch (trackErr) {
          console.warn("[buy-btn] track add_to_cart_success error:", trackErr);
        }
      } catch (err) {
        console.error("[buy-btn] addItem error:", err);
        cfg.toast("加入购物车失败,请稍后重试");

        // 失败埋点
        try {
          cfg.services.track("add_to_cart_error", {
            productId: product.id,
            message: err && err.message,
          });
        } catch (trackErr) {
          console.warn("[buy-btn] track add_to_cart_error error:", trackErr);
        }
      } finally {
        // 恢复按钮状态
        setButtonLoading(btn, false, cfg.loadingText, cfg.loadingClass);
        busyButtons.delete(btn);
      }
    })();
  }

  // 绑定事件(冒泡阶段,非被动监听,因为需要调用 preventDefault)
  cfg.root.addEventListener("click", clickHandler, { capture: false });

  // 返回解绑函数,避免内存泄漏
  return function detachBuyButtonListener() {
    cfg.root.removeEventListener("click", clickHandler, { capture: false });
  };
}

/* ---------- 工具函数与默认配置 ---------- */

function normalizeConfig(options) {
  const cfg = {
    root:
      options.root && isNodeLike(options.root)
        ? options.root
        : document, // 默认在 document 上委托
    selectors: {
      card: options.selectors?.card || ".product-card",
      button: options.selectors?.button || ".buy-btn",
      badge: options.selectors?.badge || ".cart-badge",
    },
    services: {
      addItem:
        typeof options.services?.addItem === "function"
          ? options.services.addItem
          : async () => {
              throw new Error("services.addItem 未提供");
            },
      track:
        typeof options.services?.track === "function"
          ? options.services.track
          : () => {},
    },
    toast:
      typeof options.toast === "function"
        ? options.toast
        : (msg) => console.warn("[toast]", msg),
    getProductFromCard:
      typeof options.getProductFromCard === "function"
        ? options.getProductFromCard
        : defaultGetProductFromCard,
    loadingText: options.loadingText || "处理中...",
    loadingClass: options.loadingClass || "is-loading",
  };

  return cfg;
}

function isNodeLike(node) {
  return (
    node &&
    (node === document ||
      node === window ||
      (typeof node.nodeType === "number" && (node.nodeType === 1 || node.nodeType === 9)))
  );
}

function defaultGetProductFromCard(cardEl) {
  // 读取常见的数据来源:data-* 或子元素
  const id =
    cardEl.dataset.productId ||
    cardEl.querySelector("[data-product-id]")?.dataset.productId;
  const name =
    cardEl.dataset.productName ||
    cardEl.querySelector("[data-product-name]")?.dataset.productName ||
    cardEl.querySelector(".title")?.textContent?.trim() ||
    "";
  const priceStr =
    cardEl.dataset.productPrice ||
    cardEl.querySelector("[data-product-price]")?.dataset.productPrice;
  const price =
    priceStr !== undefined && priceStr !== null && priceStr !== ""
      ? Number(priceStr)
      : undefined;

  return { id, name, price };
}

function setButtonLoading(btn, loading, loadingText, loadingClass) {
  if (loading) {
    if (!btn.dataset.originalText) {
      btn.dataset.originalText = (btn.textContent || "").trim();
    }
    btn.disabled = true;
    btn.setAttribute("aria-disabled", "true");
    btn.setAttribute("aria-busy", "true");
    if (loadingClass) btn.classList.add(loadingClass);
    // 简单替换文案。若按钮内有图标/自定义内容,可用更细粒度的处理。
    btn.textContent = loadingText;
  } else {
    btn.disabled = false;
    btn.removeAttribute("aria-disabled");
    btn.removeAttribute("aria-busy");
    if (loadingClass) btn.classList.remove(loadingClass);
    if (btn.dataset.originalText) {
      btn.textContent = btn.dataset.originalText;
      delete btn.dataset.originalText;
    }
  }
}

function updateCartBadge(root, badgeSelector, cartCount) {
  const badge = root.querySelector(badgeSelector);
  if (!badge) return;

  // 有明确数量则直接设置,否则尝试 +1
  if (cartCount !== null && !Number.isNaN(Number(cartCount))) {
    badge.textContent = String(cartCount);
  } else {
    const current = Number(badge.textContent) || 0;
    badge.textContent = String(current + 1);
  }

  // 轻微动效提示更新(依赖 .pulse 的 CSS,可选)
  badge.classList.add("pulse");
  setTimeout(() => badge.classList.remove("pulse"), 400);

  // 辅助无障碍:动态区域可设置为 polite
  if (!badge.hasAttribute("aria-live")) {
    badge.setAttribute("aria-live", "polite");
  }
}

/* ---------- 可选:一个简易的 Toast 实现(生产环境可替换为成熟组件) ---------- */
function createToast(message, duration = 2000) {
  const containerId = "toast-container";
  let container = document.getElementById(containerId);
  if (!container) {
    container = document.createElement("div");
    container.id = containerId;
    container.style.position = "fixed";
    container.style.top = "16px";
    container.style.right = "16px";
    container.style.zIndex = "10000";
    container.style.display = "flex";
    container.style.flexDirection = "column";
    container.style.gap = "8px";
    container.setAttribute("role", "status");
    container.setAttribute("aria-live", "polite");
    document.body.appendChild(container);
  }

  const toast = document.createElement("div");
  toast.textContent = message;
  toast.style.background = "rgba(0,0,0,0.8)";
  toast.style.color = "#fff";
  toast.style.padding = "8px 12px";
  toast.style.borderRadius = "6px";
  toast.style.fontSize = "14px";
  toast.style.boxShadow = "0 2px 8px rgba(0,0,0,0.25)";
  container.appendChild(toast);

  setTimeout(() => {
    toast.remove();
    if (container.childElementCount === 0) {
      container.remove();
    }
  }, duration);
}

技术说明

  • 事件委托:在父级 root(默认为 document)上绑定一次 click 监听,通过事件冒泡捕获目标 .buy-btn。优点:
    • 支持动态渲染的商品卡片,避免对每个按钮重复绑定。
    • 降低内存占用与绑定开销。
  • 事件传播:使用冒泡阶段(capture: false),并在点击的是链接时调用 preventDefault,避免跳转干扰逻辑。
  • 并发与防抖:使用 WeakSet 记录“忙碌中的按钮”,按钮禁用 + aria-busy 提升可访问性,避免多次触发导致重复加入。
  • 错误处理:对商品数据缺失、加入购物车失败进行提示,并确保按钮状态在 finally 中恢复。
  • 埋点:在点击时和成功/失败后分别记录事件,不阻塞主流程;埋点错误不会中断业务逻辑。
  • 徽标更新:优先使用后端返回的 cartCount,缺省则在现有基础上 +1,保证用户感知。
  • 清理机制:返回解除绑定的函数,便于在 SPA 路由切换或组件卸载时解绑,避免泄漏与冲突。
  • 兼容性:核心 API 使用现代标准(addEventListener、closest、dataset)。如需兼容非常老旧浏览器,可为 Element.closest 提供 polyfill。

参数解析

  • options.root:事件委托的挂载节点,默认 document。传入容器元素可限定作用范围。
  • options.selectors:
    • card:商品卡片选择器,默认 ".product-card"
    • button:购买按钮选择器,默认 ".buy-btn"
    • badge:购物车徽标选择器,默认 ".cart-badge"
  • options.services:
    • addItem(product):异步函数,执行加入购物车,建议返回 { cartCount } 用于徽标更新。
    • track(eventName, payload):埋点记录函数,失败不影响主流程。
  • options.toast(message):轻提示函数,默认使用 console 退化;示例中提供 createToast 可替换。
  • options.getProductFromCard(cardEl):从卡片中提取商品数据的函数。默认从 data-product-id/name/price 或子元素读取。
  • options.loadingText:按钮加载中文案,默认 “处理中...”
  • options.loadingClass:按钮加载样式类名,默认 “is-loading”

使用示例

<!-- 示例 HTML -->
<div class="header">
  <span class="cart-badge" aria-live="polite">0</span>
</div>

<div class="product-list">
  <div class="product-card" data-product-id="sku-1001" data-product-name="示例商品A" data-product-price="199">
    <h3 class="title">示例商品A</h3>
    <button class="buy-btn">加入购物车</button>
  </div>

  <div class="product-card" data-product-id="sku-1002" data-product-name="示例商品B" data-product-price="299">
    <h3 class="title">示例商品B</h3>
    <a class="buy-btn" href="/buy/sku-1002">加入购物车</a>
  </div>
</div>

<script>
  // 模拟服务实现(实际项目中调用后端 API)
  const services = {
    addItem: async (product) => {
      // 这里可替换为真实接口调用:
      // const resp = await fetch('/api/cart', { method: 'POST', body: JSON.stringify(product) });
      // const data = await resp.json();
      // return { cartCount: data.count };
      await new Promise((r) => setTimeout(r, 600)); // 模拟网络延迟
      window.__cartCount = (window.__cartCount || 0) + 1;
      // 随机制造一次失败演示
      if (Math.random() < 0.1) throw new Error("网络异常");
      return { cartCount: window.__cartCount };
    },
    track: (eventName, payload) => {
      // 这里可替换为真实埋点 SDK
      console.log("[track]", eventName, payload);
    },
  };

  // 使用自定义 Toast
  const toast = (msg) => createToast(msg);

  // 绑定事件
  const detach = attachBuyButtonListener({
    root: document,
    selectors: {
      card: ".product-card",
      button: ".buy-btn",
      badge: ".cart-badge",
    },
    services,
    toast,
    loadingText: "处理中...",
    loadingClass: "is-loading",
  });

  // 测试:3秒后可选择解除绑定
  // setTimeout(() => {
  //   detach();
  //   console.log("已解除买按钮事件绑定");
  // }, 3000);
</script>

<style>
  /* 可选的徽标动效与按钮加载样式 */
  .cart-badge.pulse { animation: pulse 0.4s ease; }
  @keyframes pulse {
    0% { transform: scale(1); }
    50% { transform: scale(1.2); }
    100% { transform: scale(1); }
  }
  .buy-btn.is-loading { opacity: 0.7; cursor: not-allowed; }
</style>

测试建议:

  • 连续快速点击同一按钮,确保不会重复加入(按钮被禁用且 WeakSet 记录忙碌态)。
  • 模拟网络失败时出现轻提示且按钮恢复可点击。
  • 动态插入新的 .product-card 后,无需重新绑定,事件委托仍有效。

注意事项

  • 事件绑定方式:使用 addEventListener,避免已废弃的内联或旧式绑定(如 element.onclick)。
  • 事件委托优先:性能更好,避免在列表中对每个按钮单独绑定。
  • 防止内存泄漏:提供解绑函数;在 SPA 或组件卸载时调用 detach。
  • 可访问性:
    • 按钮加载态设置 aria-busy 与 aria-disabled。
    • 徽标设置 aria-live="polite",让读屏器感知数量变化。
  • 异常与边界:
    • 商品信息缺失时立即提示并阻止后续流程。
    • 埋点失败不影响业务逻辑。
    • 如果后端未返回 cartCount,采用本地 +1 退化更新。
  • 兼容性:
    • 使用 Element.closest 与 dataset 属性,现代浏览器原生支持。如需兼容 IE 等老旧环境,可添加 polyfill。
  • 安全性:
    • 未进行任何危险的 DOM 注入;提示内容仅作为文本展示。
    • 网络请求示例中不包含非授权或恶意操作。
  • 样式与文案:
    • 按钮加载文案与样式 class 可自定义。
    • 若按钮包含复杂子元素(图标/计数器),可在 setButtonLoading 中改为只添加 class,不直接覆盖 textContent。

代码实现

/**
 * 专业事件监听器:表单提交(submit)
 * 需求:
 * - 阻止默认提交
 * - 校验邮箱格式与密码强度
 * - 逐项高亮错误并显示提示
 * - 提交时禁用表单与按钮
 * - 使用 fetch 异步提交
 * - 成功后清空并显示成功条
 * - 失败则聚焦首个错误
 */
(function initSignupForm(selector = 'form#signup') {
  const form = document.querySelector(selector);
  if (!form) {
    console.warn(`[signup] 未找到表单元素:${selector}`);
    return;
  }

  // 防止重复绑定同一个表单(避免内存泄漏/事件冲突)
  if (form.dataset.bound === 'true') return;
  form.dataset.bound = 'true';

  // 可配置:服务端接口(默认使用 form.action / form.method)
  const submitURL = form.action || '/api/signup';
  const submitMethod = (form.method || 'POST').toUpperCase();

  // 提升可访问性:禁用原生HTML5校验,由脚本接管
  form.noValidate = true;

  // 常用字段引用(可按需扩展)
  const fields = {
    email: form.querySelector('input[name="email"]'),
    password: form.querySelector('input[name="password"]'),
  };

  // 提交过程的中断控制(防止重复请求、支持取消)
  let currentAbortController = null;

  // 安全的 CSS 选择器转义(兼容性处理)
  const cssEscape = (name) =>
    window.CSS && typeof window.CSS.escape === 'function'
      ? window.CSS.escape(name)
      : String(name).replace(/([^\w-])/g, '\\$1');

  // 状态栏:用于展示成功/错误信息(无侵入,自动创建)
  function getStatusBar() {
    let bar = form.querySelector('.form-status');
    if (!bar) {
      bar = document.createElement('div');
      bar.className = 'form-status';
      bar.setAttribute('role', 'status');
      bar.setAttribute('aria-live', 'polite');
      bar.hidden = true;
      form.prepend(bar);
    }
    return bar;
  }

  function showStatus(type, message) {
    const bar = getStatusBar();
    bar.textContent = message; // 使用 textContent 防止 XSS
    bar.dataset.type = type;   // 可用于样式区分:success/error/info
    bar.hidden = false;
  }

  // 错误消息容器:优先使用同一字段附近的 .error-msg,没有则自动创建
  function getErrorContainer(input) {
    const inField = input.closest('.field');
    let msg = inField?.querySelector('.error-msg');
    if (!msg) {
      const next = input.nextElementSibling;
      if (next && next.classList && next.classList.contains('error-msg')) {
        msg = next;
      }
    }
    if (!msg) {
      msg = document.createElement('div');
      msg.className = 'error-msg';
      msg.hidden = true;
      input.after(msg);
    }
    return msg;
  }

  function setFieldError(input, message) {
    if (!input) return;
    const msg = getErrorContainer(input);
    input.classList.add('is-invalid');
    input.setAttribute('aria-invalid', 'true');

    // 确保错误消息有可被关联的ID
    if (!msg.id) {
      msg.id = `${input.name || input.id || 'field'}-error`;
    }
    input.setAttribute('aria-describedby', msg.id);

    // 显示错误文本
    msg.textContent = message;
    msg.hidden = false;
    msg.setAttribute('role', 'alert');
  }

  function clearFieldError(input) {
    if (!input) return;
    input.classList.remove('is-invalid');
    input.removeAttribute('aria-invalid');
    input.removeAttribute('aria-describedby');

    const msg = getErrorContainer(input);
    msg.textContent = '';
    msg.hidden = true;
    msg.removeAttribute('role');
  }

  // 基础校验:邮箱与密码
  function isValidEmail(email) {
    // 简洁实用的邮箱校验(避免过度严格导致合法邮箱无法通过)
    const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return re.test(email);
  }

  function assessPasswordStrength(pw) {
    const categories = [
      /[A-Z]/.test(pw),        // 大写
      /[a-z]/.test(pw),        // 小写
      /[0-9]/.test(pw),        // 数字
      /[^A-Za-z0-9]/.test(pw), // 特殊字符
    ];
    const count = categories.filter(Boolean).length;
    const strong = pw.length >= 8 && count >= 3; // 至少8位,四类中满足任意三类
    return { strong, count, length: pw.length };
  }

  function validateFields() {
    const errors = [];

    if (fields.email) {
      clearFieldError(fields.email);
      const val = fields.email.value.trim();
      if (!val) {
        setFieldError(fields.email, '邮箱不能为空');
        errors.push(fields.email);
      } else if (!isValidEmail(val)) {
        setFieldError(fields.email, '邮箱格式不正确');
        errors.push(fields.email);
      }
    }

    if (fields.password) {
      clearFieldError(fields.password);
      const val = fields.password.value;
      const res = assessPasswordStrength(val);
      if (!val) {
        setFieldError(fields.password, '密码不能为空');
        errors.push(fields.password);
      } else if (!res.strong) {
        setFieldError(
          fields.password,
          '密码需至少 8 位,并包含大小写、数字或特殊字符中的任意 3 类'
        );
        errors.push(fields.password);
      }
    }

    return errors;
  }

  // 禁用/启用表单控件(提交阶段防止重复操作)
  function setFormDisabled(disabled) {
    const controls = form.querySelectorAll('input, select, textarea, button');
    controls.forEach((el) => {
      // 不改变隐性不可用状态的元素(例如有 data-skip-disable 时)
      if (el.dataset.skipDisable === 'true') return;
      el.disabled = disabled;
    });
    form.setAttribute('aria-busy', String(disabled));
    form.dataset.disabled = String(disabled);
  }

  // 序列化表单为对象(FormData -> Object)
  function serializeFormToObject() {
    const fd = new FormData(form);
    const obj = Object.create(null);
    for (const [key, value] of fd.entries()) {
      // 处理同名字段为数组
      if (obj[key] !== undefined) {
        Array.isArray(obj[key]) ? obj[key].push(value) : (obj[key] = [obj[key], value]);
      } else {
        obj[key] = value;
      }
    }
    return obj;
  }

  // 提交到服务端(fetch)
  async function postJSON(url, data, signal) {
    const resp = await fetch(url, {
      method: submitMethod,
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
      },
      body: JSON.stringify(data),
      signal,
      credentials: 'same-origin',
    });

    const contentType = resp.headers.get('content-type') || '';
    let payload = null;
    if (contentType.includes('application/json')) {
      payload = await resp.json();
    } else {
      // 非JSON情况也尽量解析(并避免使用 innerHTML)
      payload = await resp.text();
    }

    if (!resp.ok) {
      const err = new Error('请求失败');
      err.status = resp.status;
      err.payload = payload;
      throw err;
    }
    return payload;
  }

  function focusFirstError(inputs) {
    const target = inputs.find((el) => el && typeof el.focus === 'function');
    if (target) target.focus();
  }

  function clearFormAndErrors() {
    form.reset();
    Object.values(fields).forEach(clearFieldError);
  }

  // 提交事件处理函数
  async function onSubmit(e) {
    e.preventDefault();

    // 若当前正在提交,忽略重复触发
    if (form.dataset.disabled === 'true') {
      return;
    }

    // 客户端校验
    const invalids = validateFields();
    if (invalids.length > 0) {
      showStatus('error', '请修正标注的错误后再提交');
      focusFirstError(invalids);
      return;
    }

    // 开始提交:禁用表单,显示状态
    setFormDisabled(true);
    showStatus('info', '正在提交...');

    // 确保只存在一个进行中的请求
    if (currentAbortController) currentAbortController.abort();
    currentAbortController = new AbortController();

    try {
      const data = serializeFormToObject();
      const result = await postJSON(submitURL, data, currentAbortController.signal);

      // 成功:清空表单,清理错误,显示成功条
      clearFormAndErrors();
      showStatus('success', '注册成功');
    } catch (err) {
      // 服务端返回字段级错误:尝试逐项显示
      let focused = false;
      if (err.payload && typeof err.payload === 'object' && err.payload.errors) {
        const serverErrors = err.payload.errors;
        const erroredInputs = [];
        Object.keys(serverErrors).forEach((name) => {
          const input = form.querySelector(`[name="${cssEscape(name)}"]`);
          if (input) {
            setFieldError(input, String(serverErrors[name]));
            erroredInputs.push(input);
          }
        });
        showStatus('error', err.payload.message || '提交失败,请检查表单内容');
        if (erroredInputs.length) {
          focusFirstError(erroredInputs);
          focused = true;
        }
      }

      // 其他错误:网络问题/非结构化错误
      if (!focused) {
        showStatus('error', '网络或服务器错误,请稍后重试');
        focusFirstError([fields.email, fields.password].filter(Boolean));
      }
    } finally {
      setFormDisabled(false);
    }
  }

  // 输入时即时反馈(事件委托,提升性能与可维护性)
  function onInput(e) {
    const t = e.target;
    if (!t || !t.name) return;

    // 只对表单控件处理
    if (!(t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement || t instanceof HTMLSelectElement)) {
      return;
    }

    // 即时清理错误并给出轻量校验提示
    if (t.name === 'email') {
      clearFieldError(t);
      const val = t.value.trim();
      if (val && !isValidEmail(val)) {
        setFieldError(t, '邮箱格式不正确');
      }
    } else if (t.name === 'password') {
      clearFieldError(t);
      const val = t.value;
      const res = assessPasswordStrength(val);
      if (val && !res.strong) {
        setFieldError(t, '密码强度不足(至少8位,含大小写/数字/特殊字符中的任意3类)');
      }
    }
  }

  // 绑定事件监听器(遵循现代标准,避免废弃API)
  form.addEventListener('submit', onSubmit, { capture: false });
  form.addEventListener('input', onInput, { capture: false });
})('form#signup');

技术说明

  • 事件类型:submit
    • 在表单提交时触发,默认行为会刷新页面或跳转。通过 e.preventDefault() 阻止默认提交,改为自定义异步处理。
    • submit 事件会冒泡,可绑定在 form 元素上进行统一处理。
  • 事件传播与委托
    • 对 input 事件采用委托,统一绑定到 form 上,减少对每个字段单独绑定,降低内存占用与维护成本。
  • addEventListener 参数
    • 第三个参数使用对象形式 { capture: false },明确在冒泡阶段监听,便于与其他监听器协作。
  • 校验策略
    • 邮箱:使用实用型正则,避免过度限制。
    • 密码强度:长度至少 8,并在大写/小写/数字/特殊字符中满足任意 3 类。
  • 可访问性
    • 使用 aria-invalid、aria-describedby、role="alert" 与 role="status"/aria-live 提升辅助技术支持。
  • 提交安全与健壮性
    • 使用 fetch + JSON,避免不安全的 innerHTML,所有消息通过 textContent 设置,防止 XSS。
    • 使用 AbortController 防止重复提交和中断前一次请求。
    • 支持服务端返回字段级错误并逐项展示。

参数解析

  • 元素选择器:'form#signup'
    • 用于定位目标表单。代码中支持传入自定义选择器。
  • 事件类型:'submit'
    • 绑定在 form 上,统一处理提交逻辑。
  • 回调逻辑:
    • 阻止默认提交:e.preventDefault()
    • 校验邮箱与密码:isValidEmail、assessPasswordStrength、validateFields
    • 高亮错误与提示:setFieldError/clearFieldError,对每个字段单独处理
    • 提交时禁用表单与按钮:setFormDisabled(true/false)
    • 异步提交:postJSON(fetch),使用 JSON 格式
    • 成功:clearFormAndErrors() + showStatus('success', ...)
    • 失败:服务端字段错误映射 + 聚焦首个错误;网络错误展示通用提示并聚焦首字段

使用示例

  • HTML 示例(可根据你的页面结构调整类名与样式)
<form id="signup" action="/api/signup" method="POST">
  <div class="field">
    <label for="email">邮箱</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    <div class="error-msg" hidden></div>
  </div>

  <div class="field">
    <label for="password">密码</label>
    <input id="password" name="password" type="password" autocomplete="new-password" required />
    <div class="error-msg" hidden></div>
  </div>

  <button type="submit">注册</button>

  <!-- 状态条会自动插入为 .form-status(如果不存在) -->
</form>

<script src="signup-listener.js"></script>
  • 测试用例
    1. 留空邮箱/密码后点击“注册”:应在对应字段下显示错误提示,并聚焦到第一个错误。
    2. 填入不合法邮箱,例如 "abc@x": 显示“邮箱格式不正确”。
    3. 填入弱密码,例如 "abcd1234": 显示“密码强度不足”。
    4. 填入合法邮箱和强密码,提交:显示“正在提交...”,随后“注册成功”,并清空表单。
    5. 服务端返回字段错误(例如 { errors: { email: '该邮箱已注册' } }):应在邮箱处显示服务端错误,并聚焦邮箱。

注意事项

  • 兼容性
    • fetch 与 AbortController 在现代浏览器中支持良好;若需支持旧版浏览器(如 IE),请引入相应 polyfill。
    • CSS.escape 在部分旧环境不存在,代码已内置回退处理。
  • 安全性
    • 所有文本展示使用 textContent,避免 XSS。
    • 建议在服务端开启 CSRF 防护;本示例使用 credentials: 'same-origin'。
  • 性能与维护
    • 使用事件委托减少监听器数量。
    • 使用 dataset.bound 防止重复绑定导致的内存泄漏或事件冲突。
  • 可访问性与用户体验
    • 对错误消息设置 aria-attributes,提升读屏器体验。
    • 在提交过程设置 aria-busy,并禁用控件防止重复操作。
  • 样式建议
    • 为 .is-invalid、.error-msg、.form-status 配置适当样式,例如错误红色提示、状态条成功绿色/错误红色等。
  • 服务端协议约定
    • 建议返回 JSON:
      • 成功:{ ok: true, ... }
      • 失败:{ ok: false, message: '...', errors: { fieldName: '错误文案' } }
    • 非 JSON 返回将以文本形式处理并给出通用错误提示。

代码实现

/**
 * 搜索输入框联想下拉(可取消请求 + 300ms 防抖 + ARIA 无障碍)
 * - 目标元素:#searchInput
 * - 事件类型:input
 * - 需求:
 *   1) 300ms 防抖
 *   2) 输入长度 >= 2 才发请求
 *   3) 调用候选接口获取结果
 *   4) 渲染下拉列表并使用 ARIA 属性
 *   5) 请求中显示 loading
 *   6) 支持取消上次请求
 *   7) 清空输入时移除结果
 */
(function initSearchAutocomplete() {
  const config = {
    selector: '#searchInput',
    minLength: 2,
    debounceMs: 300,
    // 建议替换为你的真实接口地址
    endpoint: '/api/suggest?q=',
    // 将接口返回的结果映射为 { id, label } 结构
    mapResultItem: (item) => ({
      id: item.id ?? String(item.value ?? item.id ?? item),
      label: item.label ?? item.name ?? String(item.label ?? item),
      raw: item
    }),
    // 可选:最多显示条数(前端裁剪)
    maxItems: 10
  };

  const input = document.querySelector(config.selector);
  if (!input) {
    console.error(`[autocomplete] element not found for selector: ${config.selector}`);
    return;
  }

  // ARIA & DOM 结构
  const listboxId = `${input.id || 'searchInput'}-listbox`;
  const listbox = document.createElement('ul');
  listbox.id = listboxId;
  listbox.role = 'listbox';
  listbox.className = 'autocomplete-listbox';
  listbox.hidden = true;
  // 让容器紧随输入框,便于定位
  input.insertAdjacentElement('afterend', listbox);

  // 输入框 ARIA 属性(简化 combobox 模式)
  input.setAttribute('role', 'combobox');
  input.setAttribute('aria-autocomplete', 'list');
  input.setAttribute('aria-expanded', 'false');
  input.setAttribute('aria-controls', listboxId);
  input.setAttribute('aria-haspopup', 'listbox');

  // SR(屏幕阅读器)状态区域,用于播报加载与结果数
  const srStatus = document.createElement('div');
  srStatus.setAttribute('aria-live', 'polite');
  srStatus.setAttribute('aria-atomic', 'true');
  srStatus.className = 'sr-only'; // 你可在 CSS 中设置 .sr-only 进行隐藏但可读
  input.insertAdjacentElement('afterend', srStatus);

  // 状态管理
  let debounceTimer = null;
  let abortController = null;
  let requestSeq = 0; // 防竞态
  let isComposing = false; // 输入法合成中(中文/日文等)

  // 工具函数
  const setExpanded = (expanded) => {
    input.setAttribute('aria-expanded', String(expanded));
    listbox.hidden = !expanded;
  };

  const clearList = () => {
    listbox.innerHTML = '';
    listbox.hidden = true;
    input.setAttribute('aria-expanded', 'false');
    input.setAttribute('aria-busy', 'false');
    srStatus.textContent = '';
  };

  const renderLoading = () => {
    listbox.innerHTML = '';
    const li = document.createElement('li');
    li.className = 'autocomplete-loading';
    li.setAttribute('role', 'presentation');
    li.textContent = 'Loading...';
    listbox.appendChild(li);
    input.setAttribute('aria-busy', 'true');
    setExpanded(true);
    srStatus.textContent = 'Loading';
  };

  const renderError = (message = 'Network error') => {
    listbox.innerHTML = '';
    const li = document.createElement('li');
    li.className = 'autocomplete-error';
    li.setAttribute('role', 'presentation');
    li.textContent = message;
    listbox.appendChild(li);
    input.setAttribute('aria-busy', 'false');
    setExpanded(true);
    srStatus.textContent = message;
  };

  const renderResults = (items) => {
    listbox.innerHTML = '';
    input.setAttribute('aria-busy', 'false');

    if (!items || items.length === 0) {
      setExpanded(false);
      srStatus.textContent = 'No results';
      return;
    }

    const frag = document.createDocumentFragment();
    items.slice(0, config.maxItems).forEach((item, idx) => {
      const li = document.createElement('li');
      li.role = 'option';
      li.tabIndex = -1;
      li.dataset.id = item.id;
      // 使用 textContent 防止 XSS
      li.textContent = item.label;
      // 唯一 id 便于 aria-activedescendant(如后续扩展键盘导航)
      li.id = `${listboxId}-opt-${idx}`;
      frag.appendChild(li);
    });
    listbox.appendChild(frag);
    setExpanded(true);

    const countText = `${items.length} result${items.length > 1 ? 's' : ''}`;
    srStatus.textContent = countText;
  };

  const cancelOngoing = () => {
    if (abortController) {
      abortController.abort();
      abortController = null;
    }
  };

  // 具体请求函数(支持取消)
  const fetchSuggestions = async (query, seq, signal) => {
    const url = `${config.endpoint}${encodeURIComponent(query)}`;
    const res = await fetch(url, { signal, headers: { 'Accept': 'application/json' } });
    if (!res.ok) {
      throw new Error(`HTTP ${res.status}`);
    }
    const data = await res.json();
    // 容错:允许 Array<string> 或 Array<object>
    const items = Array.isArray(data) ? data : (data.items || data.results || []);
    return items.map(config.mapResultItem);
  };

  // 输入处理(防抖调度)
  const scheduleSearch = (query) => {
    if (debounceTimer) clearTimeout(debounceTimer);
    debounceTimer = setTimeout(async () => {
      debounceTimer = null;

      const trimmed = query.trim();
      if (trimmed.length === 0) {
        cancelOngoing();
        clearList();
        return;
      }
      if (trimmed.length < config.minLength) {
        cancelOngoing();
        clearList();
        return;
      }

      // 取消上一次请求
      cancelOngoing();
      abortController = new AbortController();
      const signal = abortController.signal;
      const seq = ++requestSeq;

      renderLoading();

      try {
        const items = await fetchSuggestions(trimmed, seq, signal);
        // 如果期间又发起了新请求,丢弃旧结果
        if (seq !== requestSeq) return;
        renderResults(items);
      } catch (err) {
        if (err.name === 'AbortError') {
          // 被主动取消,忽略
          return;
        }
        renderError('Failed to load results');
        console.error('[autocomplete] request error:', err);
      }
    }, config.debounceMs);
  };

  // 事件处理
  const onInput = (e) => {
    if (isComposing) return; // 输入法合成中,不触发检索
    const value = e.target.value || '';
    scheduleSearch(value);
  };

  const onCompositionStart = () => { isComposing = true; };
  const onCompositionEnd = (e) => {
    isComposing = false;
    // 合成结束后再触发一次
    scheduleSearch(e.target.value || '');
  };

  const onListClick = (e) => {
    const option = e.target.closest('[role="option"]');
    if (!option) return;
    const label = option.textContent || '';
    const id = option.dataset.id;
    input.value = label;
    clearList();

    // 向外派发事件,携带选中项数据
    const detail = { id, label };
    input.dispatchEvent(new CustomEvent('autocomplete:select', { detail, bubbles: true }));
  };

  const onEscape = (e) => {
    if (e.key === 'Escape') {
      clearList();
    }
  };

  const onDocumentClick = (e) => {
    if (e.target === input) return;
    if (listbox.contains(e.target)) return;
    clearList();
  };

  // 绑定监听
  input.addEventListener('input', onInput, { passive: true });
  input.addEventListener('compositionstart', onCompositionStart);
  input.addEventListener('compositionend', onCompositionEnd);
  input.addEventListener('keydown', onEscape);
  listbox.addEventListener('click', onListClick);
  document.addEventListener('click', onDocumentClick);

  // 可选:暴露销毁函数(防止内存泄漏,若需要动态卸载)
  input.autocompleteDestroy = () => {
    cancelOngoing();
    if (debounceTimer) clearTimeout(debounceTimer);
    input.removeEventListener('input', onInput);
    input.removeEventListener('compositionstart', onCompositionStart);
    input.removeEventListener('compositionend', onCompositionEnd);
    input.removeEventListener('keydown', onEscape);
    listbox.removeEventListener('click', onListClick);
    document.removeEventListener('click', onDocumentClick);
    listbox.remove();
    srStatus.remove();
    input.removeAttribute('role');
    input.removeAttribute('aria-autocomplete');
    input.removeAttribute('aria-expanded');
    input.removeAttribute('aria-controls');
    input.removeAttribute('aria-haspopup');
    input.removeAttribute('aria-busy');
  };
})();

可选的最简样式建议(非必需):

/* 建议样式(示意) */
.autocomplete-listbox {
  position: absolute;
  z-index: 1000;
  margin-top: 4px;
  width: 100%;
  max-height: 240px;
  overflow: auto;
  background: #fff;
  border: 1px solid #ddd;
  border-radius: 6px;
  box-shadow: 0 6px 18px rgba(0,0,0,0.08);
  padding: 4px 0;
}
.autocomplete-listbox li {
  padding: 8px 12px;
  cursor: pointer;
}
.autocomplete-listbox li:hover {
  background: #f5f5f5;
}
.autocomplete-loading,
.autocomplete-error {
  color: #666;
  cursor: default;
}
.sr-only {
  position: absolute !important;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; overflow: hidden;
  clip: rect(0 0 0 0); border: 0;
}

技术说明

  • 事件类型 input
    • 在用户每次输入(含粘贴、删除)都会触发,适合实时联想。
    • 配合 composition 事件,避免中文等输入法在合成中频繁请求。
  • 防抖机制
    • 通过 setTimeout 实现 300ms 防抖,减少高频请求,提升性能与配额利用率。
  • 取消请求
    • 使用 AbortController/AbortSignal,输入变化时取消上一次 fetch,避免无用的网络与竞态渲染。
  • 竞态防护
    • 使用自增的 requestSeq 标记请求顺序,仅渲染最后一次有效请求的结果,消除响应乱序问题。
  • ARIA 无障碍
    • 输入框设置 role=combobox、aria-controls、aria-expanded、aria-autocomplete、aria-haspopup。
    • 下拉列表使用 role=listbox,选项使用 role=option。
    • 使用 aria-live polite 播报“Loading”和结果数量。
  • 事件委托
    • 仅在 listbox 容器绑定一次 click 事件,通过事件冒泡处理每个选项,减少监听器数量。

参数解析

  • selector: 目标输入框选择器(本题为 #searchInput)。
  • minLength: 触发请求的最小字符长度(默认 2)。
  • debounceMs: 防抖等待时间,默认 300ms。
  • endpoint: 候选接口基础地址,自动拼接 ?q=encodeURIComponent(query)。
  • mapResultItem(item): 将接口原始数据统一映射为 { id, label, raw },便于渲染和选择回传。
  • maxItems: 前端显示的最大条数限制,避免一次渲染过多 DOM。

内部状态与函数:

  • abortController: 管理可取消的 fetch。
  • requestSeq: 防止竞态条件,确保只渲染最新请求。
  • renderLoading/renderResults/renderError/clearList: 负责 UI 状态与 ARIA 标记。
  • scheduleSearch: 结合防抖与最小长度校验进行请求调度。

使用示例

  • 直接使用(替换为你的接口地址):
<input id="searchInput" type="text" placeholder="Search..." autocomplete="off" />
<script src="/path/to/autocomplete.js"></script>
<script>
  // 如需监听选中事件
  document.getElementById('searchInput').addEventListener('autocomplete:select', (e) => {
    console.log('Selected:', e.detail); // { id, label }
  });
</script>
  • 模拟接口(开发联调用):
<input id="searchInput" type="text" placeholder="Search..." autocomplete="off" />
<script>
  // 替换示例中的 endpoint 和 fetch 函数(简单模拟)
  // 方案1:本地服务返回 /api/suggest?q=foo -> ["foo1","foo2",...]
  // 方案2:在 Service Worker 或 dev server 做 mock
</script>
  • 自定义映射:
// 若接口返回 { items: [{id: 1, name: 'Apple'}, ...] }
 // 上面的 mapResultItem 已默认兼容 name/label。若字段更复杂可重写:
 // config.mapResultItem = item => ({ id: item.id, label: `${item.name} (${item.category})`, raw: item });

注意事项

  • 兼容性
    • AbortController 在现代浏览器中已普遍支持。若需兼容旧浏览器,可在不支持时跳过取消逻辑(请求仍可完成)。
  • 性能与可维护性
    • 使用事件委托避免对每个建议项单独绑定事件。
    • 使用 textContent 而非 innerHTML 防止 XSS 注入。
    • 尽量限制渲染数量(maxItems),大量结果应采用分页或虚拟列表。
  • 无障碍与键盘交互
    • 当前实现包含基本 ARIA 标注与状态播报;若需要完整键盘导航(上下选中、回车确认、aria-activedescendant 同步),可在此基础上扩展 keydown 逻辑。
  • 交互细节
    • 输入清空(长度为 0)时自动取消请求并清空列表。
    • Escape 键可关闭列表。
    • 点击列表项会派发 autocomplete:select 事件,便于上层逻辑处理(例如跳转或再次请求详情)。
  • 错误处理
    • 网络错误或非 2xx 响应会显示通用错误消息并写入控制台,便于排查。
  • 销毁与内存
    • 若组件需要在 SPA 中卸载,可调用 input.autocompleteDestroy() 解除所有监听并清理 DOM,避免内存泄漏。
  • 安全
    • 严禁拼接 HTML 字符串插入,始终使用 DOM API 与 textContent 设置文本内容。

示例详情

📖 如何使用

30秒出活:复制 → 粘贴 → 搞定
与其花几十分钟和AI聊天、试错,不如直接复制这些经过千人验证的模板,修改几个 {{变量}} 就能立刻获得专业级输出。省下来的时间,足够你轻松享受两杯咖啡!
加载中...
💬 不会填参数?让 AI 反过来问你
不确定变量该填什么?一键转为对话模式,AI 会像资深顾问一样逐步引导你,问几个问题就能自动生成完美匹配你需求的定制结果。零门槛,开口就行。
转为对话模式
🚀 告别复制粘贴,Chat 里直接调用
无需切换,输入 / 唤醒 8000+ 专家级提示词。 插件将全站提示词库深度集成于 Chat 输入框。基于当前对话语境,系统智能推荐最契合的 Prompt 并自动完成参数化,让海量资源触手可及,从此彻底告别"手动搬运"。
即将推出
🔌 接口一调,提示词自己会进化
手动跑一次还行,跑一百次呢?通过 API 接口动态注入变量,接入批量评价引擎,让程序自动迭代出更高质量的提示词方案。Prompt 会自己进化,你只管收结果。
发布 API
🤖 一键变成你的专属 Agent 应用
不想每次都配参数?把这条提示词直接发布成独立 Agent,内嵌图片生成、参数优化等工具,分享链接就能用。给团队或客户一个"开箱即用"的完整方案。
创建 Agent

✅ 特性总结

一键生成可直接粘贴的事件监听代码,覆盖点击、输入、提交等常见交互场景。
支持多种元素选择方式,输入选择器即可定位目标节点,省去繁琐查找与绑定步骤。
自动生成健壮的回调逻辑与错误提示,轻松应对元素缺失、无效事件等边界情况。
内置事件委托与性能优化思路,大量列表与动态内容场景下,也能保持顺滑交互体验。
提供清晰注释与原理讲解,新手也能看懂思路,团队协作与代码评审更高效。
一键切换不同事件类型与回调需求,快速试错迭代,缩短开发到上线的整体周期。
输出真实可运行的使用示例与测试方法,复制即可验证效果,减少沟通与排查时间。
默认采用现代语法与安全规范,避免旧式写法带来的隐患,让代码更易维护与扩展。
支持参数化模板与个性化配置,按业务场景一键复用,统一风格同时保留灵活性。

🎯 解决的问题

让任何从事前端相关工作的同学,基于少量自然语言与结构化输入,快速生成可直接投入项目的 JavaScript 事件监听方案:- 一次填写,自动产出高质量事件监听代码、详尽注释、使用示例与注意事项,复制即用;- 内置健壮性与可维护性思路(元素校验、合理绑定、边界处理、冲突预防),显著降低返工与线上隐患;- 用统一模板把“需求-代码-说明-示例”打包输出,缩短评审与协作成本;- 覆盖点击、表单、键鼠、滚动、可视化交互等高频场景,既适合新人提效,也能让资深同学把重复工作自动化;- 以可读、可扩展为目标,帮助团队建立一致的编码风格与最佳实践沉淀,提升交付速度与质量。

🕒 版本历史

当前版本
v2.1 2024-01-15
优化输出结构,增强情节连贯性
  • ✨ 新增章节节奏控制参数
  • 🔧 优化人物关系描述逻辑
  • 📝 改进主题深化引导语
  • 🎯 增强情节转折点设计
v2.0 2023-12-20
重构提示词架构,提升生成质量
  • 🚀 全新的提示词结构设计
  • 📊 增加输出格式化选项
  • 💡 优化角色塑造引导
v1.5 2023-11-10
修复已知问题,提升稳定性
  • 🐛 修复长文本处理bug
  • ⚡ 提升响应速度
v1.0 2023-10-01
首次发布
  • 🎉 初始版本上线
COMING SOON
版本历史追踪,即将启航
记录每一次提示词的进化与升级,敬请期待。

💬 用户评价

4.8
⭐⭐⭐⭐⭐
基于 28 条评价
5星
85%
4星
12%
3星
3%
👤
电商运营 - 张先生
⭐⭐⭐⭐⭐ 2025-01-15
双十一用这个提示词生成了20多张海报,效果非常好!点击率提升了35%,节省了大量设计时间。参数调整很灵活,能快速适配不同节日。
效果好 节省时间
👤
品牌设计师 - 李女士
⭐⭐⭐⭐⭐ 2025-01-10
作为设计师,这个提示词帮我快速生成创意方向,大大提升了工作效率。生成的海报氛围感很强,稍作调整就能直接使用。
创意好 专业
COMING SOON
用户评价与反馈系统,即将上线
倾听真实反馈,在这里留下您的使用心得,敬请期待。
加载中...
📋
提示词复制
在当前页面填写参数后直接复制: