我构建了 6 个零依赖的 JavaScript 小部件——以下是我从每个中学到的经验

发布: (2026年3月1日 GMT+8 16:14)
11 分钟阅读
原文: Dev.to

Source: Dev.to

1️⃣ WhatsApp 聊天按钮

Widget: 一个浮动按钮,打开预填信息的 WhatsApp 聊天。
可选弹出卡片,显示客服姓名、头像和在线指示器。

我的收获:脉冲动画的争夺

我的第一个版本使用 setInterval 来切换脉冲环的 CSS 类。
这导致 布局抖动 —— 动画在低端手机上卡顿,因为 JavaScript 与浏览器的渲染管线相互竞争。

解决方案: 将所有内容迁移到纯 CSS @keyframes 动画中,仅使用一次 JS 来添加/移除类 一次

// Bad – JS fighting the renderer
setInterval(() => {
  pulse.classList.toggle('active');
}, 1000);

// Good – CSS handles the animation entirely
// JS only adds the class once at init
pulse.classList.add('pulse-active');
@keyframes fc-pulse {
  0%   { transform: scale(1);   opacity: 0.7; }
  100% { transform: scale(1.8); opacity: 0;   }
}
.pulse-active {
  animation: fc-pulse 2.2s ease-out infinite;
}

WhatsApp URL 小技巧

const url = `https://wa.me/${phone}?text=${encodeURIComponent(message)}`;

encodeURIComponent不可或缺 的——如果不使用它,任何包含 &、表情符号等的消息都会悄悄导致 URL 失效。


2️⃣ “Sarah from London just purchased” 弹窗

Widget: 循环显示一系列通知,支持可配置的切换时间。

我的收获:暂停正在进行的 CSS 过渡是一条无底洞

该组件在鼠标悬停时会暂停倒计时进度条,离开后继续从暂停的位置恢复。
如果在 CSS 过渡进行到一半时将其移除,元素会立即跳到最终值。

解决方案: 在暂停的瞬间获取计算后的宽度并冻结,然后使用剩余时间重新应用过渡。

element.addEventListener('mouseenter', () => {
  // 在移除过渡之前获取当前渲染的宽度
  const computed = window.getComputedStyle(progressBar).width;
  progressBar.style.transition = 'none';
  progressBar.style.width = computed; // 在此冻结

  // 记录暂停的时间点
  this._pausedAt = Date.now();
});

element.addEventListener('mouseleave', () => {
  const elapsed = Date.now() - this._pausedAt;
  this._remaining -= elapsed;

  // 使用剩余时间恢复
  progressBar.style.transition = `width ${this._remaining}ms linear`;
  progressBar.style.width = '0%';

  this._pausedAt = null;
});

模式:

  1. 捕获计算后的样式。
  2. 设置 transition: none
  3. 设置显式值(冻结)。
  4. 重新添加过渡并使用剩余的持续时间。

3️⃣ 同意模态框(Accept All / Reject / Manage Preferences)

Widget: 带有每个类别切换开关的模态框,存储同意信息。

我的收获:在 两个 位置存储同意

  • 最初只在 localStorage 中存储同意。
  • 问题:
    • 服务端渲染(SSR)框架无法在服务器上读取 localStorage
    • 注重隐私的浏览器会积极清除 localStorage

解决方案: 同时在 localStorage Cookie 中存储。先从 localStorage 读取(更快),若没有再回退到 Cookie。

function saveConsent(key, data, days) {
  // Cookie(服务器端可访问,能够在 localStorage 被清除后仍然存在)
  const expires = new Date(Date.now() + days * 864e5).toUTCString();
  document.cookie = `${key}=${encodeURIComponent(JSON.stringify(data))};expires=${expires};path=/;SameSite=Lax`;

  // localStorage(客户端读取更快)
  localStorage.setItem(key, JSON.stringify(data));
}

function loadConsent(key) {
  // 优先尝试 localStorage
  const ls = localStorage.getItem(key);
  if (ls) return JSON.parse(ls);

  // 回退到 Cookie
  const match = document.cookie.match(
    new RegExp('(?:^|; )' + key + '=([^;]*)')
  );
  if (match) return JSON.parse(decodeURIComponent(match[1]));

  return null; // 未存储同意信息
}

重要提示: Cookie 上的 SameSite=Lax 对 GDPR 合规至关重要;如果缺少该属性,一些浏览器会在跨域情境下阻止 Cookie。

4️⃣ 粘性顶部/底部栏

Widget: 用于促销、倒计时促销、运费提示的固定栏;可轮播多条信息。

我的收获:为固定栏留出 Body 内边距

在出现固定栏时给 “ 添加 padding-top(或 padding-bottom)听起来很简单——其实并不容易。会导致问题的三点:

  1. 粘性导航 – 粘性页眉的 top 值会被错误覆盖。
  2. 滚动恢复 – 在后退导航时,浏览器会在栏出现之前恢复滚动位置,导致页面跳动。
  3. 窗口尺寸变化 – 栏的高度可能会变化(例如在移动端文字换行),因此必须同步更新内边距。

解决方案:offsetBody 设为 可选,并在文档中说明这些边缘情况,而不是尝试兼容所有布局场景。

if (cfg.position === 'top' && cfg.sticky && cfg.offsetBody) {
  document.body.style.paddingTop = cfg.height + 'px';
}

// Clean up on dismiss
dismiss() {
  if (cfg.position === 'top' && cfg.offsetBody) {
    document.body.style.paddingTop = '';
  }
}

倒计时计时器辅助函数

const diff = new Date(targetDate) - Date.now();
const h = Math.floor(diff / 36e5);
const m = Math.floor((diff % 36e5) / 6e4);
const s = Math.floor((diff % 6e4) / 1e3);
const pad = n => String(n).padStart(2, '0');
// → "02:44:17"

5️⃣ ToastKit

Widget: ToastKit.success("Saved!") – 六种类型,六个位置,浅色/深色/自动主题,promise API

我学到的:Promise 模式是关键

在添加 promise 辅助函数之前,toast 系统感觉只是另一个普通的 toast 系统。添加之后,整个概念一下子清晰了。

ToastKit.promise = function(promise, messages) {
  const t = ToastKit.loading(messages.loading, { duration: 0 });

  promise
    .then(() => t.update(messages.success, 'success'))
    .catch(() => t.update(messages.error, 'error'));

  return promise; // passthrough so you can still await it
};

现在你可以这样使用:

ToastKit.promise(
  fetch('/api/save'),
  {
    loading: 'Saving…',
    success: 'Saved!',
    error:   'Failed to save.'
  }
);

toast 会自动反映异步状态,无需额外的样板代码。

TL;DR

WidgetKey Takeaway
WhatsApp button使用纯 CSS @keyframes 实现脉冲效果;JS 只在一次时添加类。
Purchase pop‑ups获取计算后的样式,使用 transition:none 冻结,然后恢复。
Consent modallocalStorage Cookie(SameSite=Lax)中存储同意。
Sticky baroffsetBody 应为可选项;处理粘性导航、滚动恢复和窗口大小变化。
ToastKit使用 ToastKit.promise 包装 Promise,实现自动加载/成功/错误 toast。

这些模式让你能够交付 小巧无依赖 的 UI 小部件,在各种设备、浏览器和渲染环境中都能良好运行。祝开发愉快!

ToastKit – 基于 Promise 的 Toast

// Usage
ToastKit.promise(
  fetch('/api/save', { method: 'POST' }),
  {
    loading: 'Saving...',
    success: 'Saved!',
    error:   'Failed. Try again.',
  }
);

关键洞察:返回原始 Promise 让调用者仍然可以在其上链 .then()(或 await)。Toast 只是一个副作用,而不是一个阻断。

自动暗模式

let theme = opts.theme;
if (theme === 'auto') {
  theme = window.matchMedia('(prefers-color-scheme: dark)').matches
    ? 'dark'
    : 'light';
}

两行代码。本来可以花几个小时在这上面,却不该这么做。


阅读进度小部件

在页面顶部/底部显示细长的进度条。它可以跟踪 整个页面 特定元素(例如文章)。

整页进度(简单)

const docH   = document.documentElement.scrollHeight - window.innerHeight;
const percent = (window.scrollY / docH) * 100;

仅元素进度(复杂)

function getElementProgress(selector) {
  const el   = document.querySelector(selector);
  const rect = el.getBoundingClientRect();

  // rect.top 是相对于视口的 → 转换为绝对位置
  const elTop    = rect.top + window.scrollY;
  const elHeight = el.offsetHeight;
  const winH     = window.innerHeight;

  // 我们已经滚动了元素的多少部分?
  const scrolled = window.scrollY + winH - elTop;

  return Math.min(100, Math.max(0, (scrolled / elHeight) * 100));
}

window.scrollY + winH 将“视口已滚动的距离”转换为“视口 底部 已移动的距离”——这才是用户实际看到的量。

滚动至顶部按钮(平滑显示/隐藏)

不使用 JavaScript 切换 display;只切换一个类,由 CSS 完成动画。

#scroll-btn {
  opacity: 0;
  transform: translateY(12px) scale(0.9);
  pointer-events: none;
  transition: opacity 0.28s ease,
              transform 0.28s cubic-bezier(0.34,1.2,0.64,1);
}
#scroll-btn.visible {
  opacity: 1;
  transform: none;
  pointer-events: all;
}

从 JS 中切换 visible 类;CSS 负责动画。 与脉冲环效果的原理相同。

Reflections

  • 重复性 – 最初构建这些小部件时感觉很重复;它们都是小的、独立的,并且结构相似。
  • 模式CSS 应负责动画,JS 应管理状态。
    • 在 JavaScript 中进行动画会让浏览器与你作对。
    • 将动画移到 CSS,并仅使用 JS 添加/移除类或设置 CSS 变量,可获得流畅的效果。
  • 边缘情况 – 专业感小部件与半成品之间的差别几乎完全在于对边缘情况的处理:
    • 动画期间的悬停
    • 缺失的元素
    • 移动端行为

这就是大部分时间花费的地方。

可用小部件

所有 6 个小部件均在 Gumroad 销售:

  • 链接: rajabdev.gumroad.com
  • 价格: 每个 9 美元
  • 技术: Vanilla JS,单个 script 标签
  • 演示: 查看演示是了解它们的最佳方式。

欢迎在评论中提出任何实现细节方面的问题!

0 浏览
Back to Blog

相关文章

阅读更多 »

JavaScript:开始

JavaScript 在1995年,一位名叫Brendan Eich的程序员在Netscape工作。当时,网站大多是静态的——它们可以显示信息,但……

三层响应式电子商务页眉

封面图片(Triple-Tier Responsive E-commerce Header) https://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2...

‘skill-check’ JS 测验

问题 1:类型强制转换 以下代码在控制台会输出什么? javascript console.log0 == '0'; console.log0 === '0'; 答案:true,然后 false Ex...