Web Components:被低估的原生组件化新解法
上周五晚上十点半,我瘫在工位上盯着屏幕上那个祖传的 jQuery 项目,心里直冒火。产品经理又提了个“小需求”——要在我们基于 Spring Boot 的后台管理系统里嵌入一个可复用的日历组件,要求支持主题切换、国际化,还要能和现有 Vue 2 代码无缝协作。更要命的是,下周三就要上线。
作为实验室里唯一一个既懂后端(Spring Boot 写过不少)又被迫搞前端的“全栈民工”,这个锅自然落到了我头上。当时脑子里第一个想法是:“要不直接抄个 Element UI 的 Calendar 组件改改?”但转念一想,这玩意儿绑定 Vue 太深,以后要是换成 React 或者 Svelte 怎么办?而且团队里还有同学在搞微前端架构实验,组件复用成了老大难问题。
就在这时,我突然想起半年前在 GitHub 上刷到的一个仓库:shoelace-style/shoelace,一个纯 Web Components 实现的 UI 库。当时没太在意,觉得“这不就是老旧的 custom elements 吗?”。但眼下,它似乎成了破局的关键。
被遗忘的“原生”武器
很多人以为 Web Components 是新东西,其实它早在 2014 年就被 W3C 提案了。只不过那几年 React/Vue 风头正盛,大家忙着写 .jsx 和 <template>,没人关心浏览器原生能干啥。再加上早期兼容性烂得一批,Polyfill 又重又慢,Web Components 就被打入冷宫。
但时代变了。现在 Chrome、Edge、Firefox、Safari 全面支持 Shadow DOM v1 和 Custom Elements v1,连 IE 都退休了,我们终于可以甩开膀子用原生能力了。
Web Components 的核心就三板斧:
- Custom Elements:自定义 HTML 标签,比如
<my-calendar> - Shadow DOM:封装样式和 DOM,避免全局污染
- HTML Templates:声明式模板,配合 JS 动态渲染
听起来是不是有点像“不用框架的组件化”?没错!它最大的优势就是框架无关。你可以在 Vue 里用,也可以在 React 里塞,甚至直接扔进 Spring Boot 渲染的 Thymeleaf 模板里——只要浏览器支持,就能跑。
实战:把日历组件做成 Web Component
说干就干。周六早上八点,我照例泡了杯速溶咖啡(实验室经费紧张,喝不起瑞幸),打开 VS Code 开始折腾。
第一步:创建基础结构
先建个 my-calendar.js:
class MyCalendar extends HTMLElement {
constructor() {
super();
// 创建 Shadow DOM
this.attachShadow({ mode: 'open' });
// 定义内部模板
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
font-family: Arial, sans-serif;
}
.calendar {
border: 1px solid #ddd;
padding: 16px;
background: white;
}
/* 更多样式... */
</style>
<div class="calendar">
<h3 id="month-title">Loading...</h3>
<div id="days-grid"></div>
</div>
`;
}
connectedCallback() {
// 组件挂载时初始化
this.render();
}
render() {
const now = new Date();
const month = now.toLocaleString('default', { month: 'long' });
this.shadowRoot.getElementById('month-title').textContent = month;
// 真实项目中这里会渲染日期网格...
}
}
// 注册自定义元素
customElements.define('my-calendar', MyCalendar);
注意几个关键点:
:host选择器用于设置组件根元素样式- 所有样式都封装在 Shadow DOM 内,不会影响外部页面
connectedCallback相当于 React 的useEffect(() => {}, [])或 Vue 的mounted
第二步:支持属性和事件
产品经理肯定要传参数,比如 locale="zh-CN" 或 theme="dark"。Web Components 通过 attributes 和 properties 通信:
// 监听属性变化
static get observedAttributes() {
return ['locale', 'theme'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this[name] = newValue; // 同步到内部属性
this.render(); // 触发重绘
}
}
对外暴露方法也很简单:
// 外部可通过 calendarRef.goToToday() 调用
goToToday() {
this.currentDate = new Date();
this.render();
}
// 触发自定义事件
dispatchEvent(new CustomEvent('date-selected', {
detail: { date: selectedDate },
bubbles: true,
composed: true // 允许事件穿越 Shadow DOM
}));
⚠️ 注意
composed: true!否则事件无法被 Vue/React 父组件监听。
第三步:集成到现有项目
我们的后台系统是 Spring Boot + Thymeleaf + Vue 2 混合架构。在某个页面的 .html 文件里直接引入:
<!-- 引入 Web Component -->
<script type="module" src="/static/js/my-calendar.js"></script>
<!-- 在 Vue 模板中使用 -->
<template>
<div>
<my-calendar
locale="zh-CN"
theme="light"
@date-selected="handleDateSelect">
</my-calendar>
</div>
</template>
<script>
export default {
methods: {
handleDateSelect(e) {
console.log('选中日期:', e.detail.date);
}
}
}
</script>
神奇的是,Vue 自动把 @date-selected 绑定到了自定义事件上!完全不需要额外适配。
踩坑实录:那些文档没告诉你的事
当然,过程不可能一帆风顺。以下是几个让我掉头发的坑:
坑1:样式隔离太彻底,反而不好调试
Shadow DOM 把样式封得死死的,DevTools 里看不到内部结构?别慌,在 Chrome DevTools 设置里勾选 “Show user agent shadow DOM”,就能展开查看了。
另外,有些全局样式(比如字体)需要穿透进去。可以用 CSS 变量:
/* 外部页面定义 */
my-calendar {
--font-family: "PingFang SC", sans-serif;
}
/* 组件内部使用 */
:host {
font-family: var(--font-family, Arial);
}
坑2:SSR(服务端渲染)不支持
如果你的 Spring Boot 用 Thymeleaf 做服务端渲染,<my-calendar> 在服务端就是个空标签。解决办法有两个:
- 降级显示:在
<my-calendar>内部放 fallback 内容,比如<span>Loading...</span> - 客户端激活:确保 JS 加载后再替换内容(我们目前采用这种方式)
坑3:属性类型只有字符串
Web Components 的 attribute 只能传字符串!想传对象?得自己序列化:
<!-- 不行 -->
<my-calendar config="{ theme: 'dark' }"></my-calendar>
<!-- 可以 -->
<my-calendar config='{"theme":"dark"}'></my-calendar>
然后在 JS 里 JSON.parse(this.getAttribute('config'))。虽然麻烦,但总比耦合框架强。
性能与兼容性:真的能用吗?
我用 Lighthouse 测了下,加载一个 8KB 的 Web Component(gzip 后),FCP(首次内容绘制)只增加了 12ms。对比引入整个 Vue 或 React 运行时(>30KB),优势明显。
兼容性方面,Can I Use 数据显示:
| 浏览器 | 支持情况 |
|---|---|
| Chrome | ✅ 54+ |
| Firefox | ✅ 63+ |
| Safari | ✅ 10.1+ |
| Edge | ✅ 79+ |
| iOS Safari | ✅ 10.3+ |
考虑到我们内部系统主要跑在现代浏览器,基本无压力。如果真要支持老古董,可以用 @webcomponents/webcomponentsjs 打个 Polyfill,不过体积会增加 15KB 左右。
为什么 Web Components 适合产品化?
最近在实验室做了一个内部工具平台,需要给不同业务线提供统一的 UI 组件(按钮、表单、图表等)。以前的做法是:
- Vue 团队维护一套
.vue组件 - React 团队另起炉灶写
.tsx - 后台 Java 同学只能用 Bootstrap 凑合
结果就是重复造轮子 + 设计不一致 + 升级困难。
现在我们把核心组件全部用 Web Components 重写,发布到私有 npm 仓库。各团队只需:
npm install @our-company/ui-components
然后在任意框架中直接使用 <oc-button variant="primary">。设计师改个色值,所有产品自动同步!
GitHub 上也有不少成功案例:
- GitHub 自己:大量使用 Web Components 构建 UI(比如
<details-menu>) - Ionic:从 Angular 专属转向 Web Components,实现框架无关
- Shoelace:纯 Web Components UI 库,Star 数已超 15k
教程资源推荐
如果你也想试试,这里是我踩坑后整理的入门路径:
- 官方文档:MDN Web Components Guide(中文友好)
- 实战教程:Google 的 Web Fundamentals - Custom Elements
- 脚手架:用
open-wc快速生成项目(支持 TypeScript + Lit) - UI 库参考:
个人建议:别一上来就手写原生 API,先用 Lit(Google 出品)简化开发。它只是个轻量 wrapper,最终产物仍是标准 Web Components。
写在最后
回过头看,Web Components 并非要取代 React/Vue,而是填补了框架之间的空白地带。尤其在以下场景特别香:
- 微前端架构中的共享组件
- 跨技术栈团队协作
- 需要嵌入第三方网站的 widget(比如客服聊天窗)
- 内部工具平台标准化
上周三,那个日历组件顺利上线。产品经理居然夸“这次交互很流畅”,我差点以为他被盗号了。运维同学也松了口气——毕竟没引入新框架,打包体积没暴涨。
现在,我已经在推动团队把常用组件逐步迁移到 Web Components。虽然写起来比 Vue 单文件组件啰嗦一点,但换来的是长期的可维护性和技术自由度。
毕竟,作为一个研二狗,谁不想少加班、多摸鱼呢?用对工具,才能早点下班去吃公司楼下那家开了十年的生煎包啊。
P.S. 本文示例代码已上传 GitHub:github.com/yourname/web-components-demo(名字当然是假的,别真去搜)。欢迎 Star(不是)——欢迎提 Issue 讨论!

评论 0