我构建了 6 个零依赖的 JavaScript 小部件——以下是我从每个中学到的经验
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;
});
模式:
- 捕获计算后的样式。
- 设置
transition: none。- 设置显式值(冻结)。
- 重新添加过渡并使用剩余的持续时间。
3️⃣ 同意模态框(Accept All / Reject / Manage Preferences)
Widget: 带有每个类别切换开关的模态框,存储同意信息。
我的收获:在 两个 位置存储同意
- 最初只在
localStorage中存储同意。 - 问题:
- 服务端渲染(SSR)框架无法在服务器上读取
localStorage。 - 注重隐私的浏览器会积极清除
localStorage。
- 服务端渲染(SSR)框架无法在服务器上读取
解决方案: 同时在 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)听起来很简单——其实并不容易。会导致问题的三点:
- 粘性导航 – 粘性页眉的
top值会被错误覆盖。 - 滚动恢复 – 在后退导航时,浏览器会在栏出现之前恢复滚动位置,导致页面跳动。
- 窗口尺寸变化 – 栏的高度可能会变化(例如在移动端文字换行),因此必须同步更新内边距。
解决方案: 将 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
| Widget | Key Takeaway |
|---|---|
| WhatsApp button | 使用纯 CSS @keyframes 实现脉冲效果;JS 只在一次时添加类。 |
| Purchase pop‑ups | 获取计算后的样式,使用 transition:none 冻结,然后恢复。 |
| Consent modal | 在 localStorage 和 Cookie(SameSite=Lax)中都存储同意。 |
| Sticky bar | offsetBody 应为可选项;处理粘性导航、滚动恢复和窗口大小变化。 |
| 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 标签
- 演示: 查看演示是了解它们的最佳方式。
欢迎在评论中提出任何实现细节方面的问题!