为什么我们需要 Web Components
如果你写过几年前端,一定经历过这样的场景:一个项目里用了三套 UI 库,A 库的 .button 样式和 B 库的 .btn 互相打架,最后不得不用 !important 和更长的选择器来“压住”对方。组件化的口号喊了这么多年,但样式污染、全局命名冲突、组件复用困难这些问题依然存在。
Web Components 是浏览器原生提供的一套组件化方案,它由三项技术组成:Custom Elements(自定义元素)、Shadow DOM(影子 DOM)和 HTML Templates(模板)。其中 Shadow DOM 是解决样式隔离和 DOM 封装的关键。这篇文章会带你从零实现一个可复用的用户卡片组件,把这三项技术串起来。
Shadow DOM 到底解决了什么问题
简单来说,Shadow DOM 允许你在一个宿主元素内部挂载一棵独立的 DOM 子树。这棵子树有自己的作用域,外部的 CSS 选择器进不来,内部的样式也出不去。这听起来像 iframe,但它比 iframe 轻量得多,不会产生新的浏览上下文,也不会阻塞页面渲染。
在 Shadow DOM 出现之前,我们靠 BEM 命名规范、CSS Modules、Vue 的 scoped 样式来模拟隔离,但这些都是“约定”或构建时的处理。Shadow DOM 是浏览器层面的硬隔离,不需要任何构建工具就能生效。
从零实现一个 UserCard 组件
先看完整代码,然后逐步拆解。
class UserCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
static get observedAttributes() {
return ['name', 'role', 'avatar'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.render();
}
}
connectedCallback() {
this.render();
}
render() {
const name = this.getAttribute('name') || '匿名用户';
const role = this.getAttribute('role') || '访客';
const avatar = this.getAttribute('avatar') || '';
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
font-family: system-ui, sans-serif;
}
.card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 1px solid #e0e0e0;
border-radius: 12px;
background: #fff;
transition: box-shadow 0.2s;
}
.card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
background: #f0f0f0;
}
.info { display: flex; flex-direction: column; }
.name { font-weight: 600; font-size: 16px; color: #1a1a1a; }
.role { font-size: 13px; color: #888; margin-top: 2px; }
</style>
<div class="card">
<img class="avatar" src="${avatar}" alt="${name}">
<div class="info">
<span class="name">${name}</span>
<span class="role">${role}</span>
</div>
</div>
`;
}
}
customElements.define('user-card', UserCard);
使用方式非常直观:
<user-card name="张三" role="前端工程师" avatar="/avatars/zhangsan.png"></user-card>
关键点解析
attachShadow({ mode: 'open' }) 创建影子根。open 表示外部可以通过 element.shadowRoot 访问内部 DOM,closed 则不允许。大多数场景用 open 就够了,调试也方便。
:host 选择器 指向宿主元素本身,也就是 <user-card>。你可以用它设置组件的默认显示方式,比如 display: block,因为自定义元素默认是 inline 的,这经常导致布局问题。
observedAttributes 和 attributeChangedCallback 让组件能响应属性变化。当外部修改 name 或 role 属性时,组件会自动重新渲染。这是 Custom Elements 生命周期中非常实用的一对。
connectedCallback 在元素被插入文档时触发,适合做初始化渲染。注意它可能被多次调用(比如元素被移除后又重新插入),所以渲染逻辑要幂等。
样式隔离的边界与穿透
Shadow DOM 的样式隔离是双向的。外部样式进不来,意味着你不需要担心全局的 * { box-sizing: border-box } 会意外影响组件内部。但这也带来一个问题:如果父容器想调整组件内部的间距怎么办?
答案是 CSS 自定义属性(CSS Variables)。自定义属性是可以穿透 Shadow 边界的。
user-card {
--card-padding: 24px;
--card-radius: 16px;
}
然后在组件内部:
.card {
padding: var(--card-padding, 16px);
border-radius: var(--card-radius, 12px);
}
这是 Web Components 最推荐的“对外接口”方式——用自定义属性暴露可配置的样式变量,而不是暴露内部类名。
如果确实需要让外部样式影响内部,可以用 ::part() 伪元素。在组件内部给元素加上 part 属性:
<div class="card" part="card">
外部就可以这样写:
user-card::part(card) {
border-color: #4a90d9;
}
::part() 比暴露类名更安全,因为它只允许外部修改你明确标记的部分。
插槽:让组件内容可组合
上面的 UserCard 把内容写死了,但实际组件往往需要让使用者传入自定义内容。<slot> 元素就是为此设计的。
改造一下,把 role 部分换成插槽:
<div class="info">
<span class="name">${name}</span>
<slot name="role"><span class="role">访客</span></slot>
</div>
使用时:
<user-card name="李四">
<span slot="role" style="color: #4a90d9;">高级工程师</span>
</user-card>
插槽内容仍然属于外部文档,所以外部的样式可以作用于它。这正好和 Shadow DOM 的隔离形成互补:组件内部结构用 Shadow DOM 保护,可定制的内容用 slot 交给使用者。
实战中的注意事项
服务端渲染(SSR) 是 Web Components 目前比较头疼的地方。Shadow DOM 在服务端无法生成,需要配合 Declarative Shadow DOM 或者客户端 hydration。如果你的项目强依赖 SSR 和 SEO,需要额外评估。
事件重定向 也值得注意。Shadow DOM 内部触发的事件,在外部监听时 event.target 会被重定向为宿主元素,而不是内部的实际元素。如果需要获取内部元素,可以用 event.composedPath()。
性能方面,Shadow DOM 的创建和样式计算有开销,但现代浏览器已经优化得很好。真正需要注意的是不要在 attributeChangedCallback 里做重操作,因为属性变化可能很频繁。
什么时候该用 Web Components
Web Components 最适合的场景是跨框架的组件库、嵌入式 widget、以及需要长期稳定运行的第三方嵌入代码。如果你整个项目都用 React 或 Vue,框架自带的组件模型已经够用,不必为了用而用。
但如果你需要写一个能在 React、Vue、Angular 甚至原生 HTML 里都能跑的组件,Web Components 是目前唯一的原生方案。掌握 Shadow DOM 和 Custom Elements,等于多了一把不受框架绑定限制的武器。
未经允许不得转载:任鹏个人博客 » Shadow DOM 与 Web Components 实战入门


朋友圈点赞图在线生成源码