简单来说,自定义元素并非什么高深莫测的“高级技巧”,而是浏览器原生支持的一套扩展机制。您只需使用 customElements.define() 注册组件,它就能像 一样被直接使用。那么,为什么偏偏要用它来实现通知系统呢?因为通知系统天生就要求跨组件复用、统一管理生命周期,还得避免重复渲染带来的性能开销。巧了,这些恰恰是自定义元素的拿手好戏:它自带封装、天然可复用,还能精准响应DOM的生命周期,比如自动清理那些已经过期的通知。
一个很实在的好处是,它不依赖任何框架,哪怕您使用的是纯原生JS,也照样能跑起来。但这里有个小坑必须注意:必须在DOM加载完成之前完成注册,否则 document.createElement('notify-toast') 只会返回一个普通的HTMLUnknownElement,您为此付出的所有努力就白费了。

如何定义一个基础可复用的 组件
核心思路其实很简单:继承 HTMLElement,然后利用 connectedCallback 和 disconnectedCallback 这两个生命周期钩子,来控制通知的显示与销毁逻辑。看代码就明白了:
class NotifyToast extends HTMLElement {
static get observedAttributes() {
return ['message', 'type', 'duration'];
}
connectedCallback() {
if (this.rendered) return;
this.render();
this.rendered = true;
this.startAutoClose();
}
render() {
const message = this.getAttribute('message') || '';
const type = this.getAttribute('type') || 'info';
this.innerHTML = `
${message}
`;
}
startAutoClose() {
const duration = parseInt(this.getAttribute('duration') || '3000');
setTimeout(() => this.remove(), duration);
}
}
customElements.define('notify-toast', NotifyToast);
这里有几个关键点需要拎出来说一下:
- 用
:host来控制定位和层级,这是个好习惯,能有效避免被父容器的样式污染,确保通知组件独立显示。 observedAttributes的作用不是用来响应属性变更的“监听器”,而是告诉浏览器,当哪些属性变化时,需要触发attributeChangedCallback。这里没用到它,是因为toast通常只渲染一次,修改属性后不重绘,反而更安全,避免意料之外的闪烁。- 在
render()方法里,千万不要直接操作this.textContent,这很容易覆盖掉内联样式或子节点,导致整个组件崩溃,务必使用 innerHTML 或模板方式。
如何从任意JS模块触发通知,且不依赖全局变量
如果每次都要手动 new NotifyToast() 再 append 到页面上,那也太麻烦了。我们需要一个统一的入口函数来集中管理。推荐的做法是使用一个轻量级的事件总线,配合自动挂载机制:
// notify.js
export function notify(message, options = {}) {
const el = document.createElement('notify-toast');
el.setAttribute('message', message);
Object.entries(options).forEach(([k, v]) => el.setAttribute(k, v));
// 确保只挂到 body 一次,避免重复添加
if (!document.body.querySelector('notify-toast')) {
document.body.append(el);
} else {
document.body.prepend(el); // 新 toast 置顶
}
}
这样一来,使用场景就非常清晰了:
- 在Vue组件里,直接
import { notify } from './notify.js'; notify('保存成功', { type: 'success' });,干净利落,无需繁琐的DOM操作。 - 在fetch失败的回调中,
notify('网络错误', { type: 'error', duration: 5000 });,也毫无违和感,提升用户体验。 - 一个小提醒:
document.body.append(el)必须确保body已经存在,否则会报错。一个稳妥的做法是在模块顶层执行前加一个if (document.body)的守卫判断,避免启动时异常。
为什么toast堆叠失效或消失太快?
最令人头疼的,往往不是代码写错了,而是CSS层级或DOM移动时机不对。来看看几个典型的翻车现场:
典型错误现象:
- 多个toast只显示最后一个,因为所有toast都用
top: 1rem; right: 1rem;,互相重叠了,导致用户只能看到最后一条通知。 - toast创建后立刻消失,
setTimeout在connectedCallback中触发,但如果元素被快速remove()或还没挂载到document上,定时器依旧运行,结果误删了其他元素,造成通知丢失。 - 页面滚动时toast乱跳,虽然用了
position: fixed,但没设transform: translateZ(0),导致硬件加速没生效,性能跟不上,出现闪烁。
那么,修复方案是什么呢?
- 堆叠问题:改用
position: absolute,并动态计算top值,或者用一个flex容器统一管理子元素的顺序和间距,确保每个toast依次排列。 - 防误删:在
disconnectedCallback中调用clearTimeout(this._timer),并在startAutoClose中保存这个定时器的引用,确保精准无误,避免误删其他元素。 - 性能优化:给toast容器加上
will-change: transform,尤其是在高频触发的场景下,效果立竿见影,流畅滚动不卡顿。
最后,还有一个极易被忽略的细节:自定义元素无法在 或 内注册,必须等到DOM解析到script标签的位置,才能调用 customElements.define()。如果您用的是module script,记得加上 type="module",并确保执行时机正确,建议在文档底部或DOMContentLoaded事件中注册。
