Web Components:用原生的方式构建现代 UI 组件体系

PR审核员
2025-06-22 14:49
阅读 397

开篇背景:一次重构项目让我重新认识组件化开发

开篇背景:一次重构项目让我重新认识组件化开发

去年我参与了一个公司内部系统的前端重构项目。这个系统原本是使用 jQuery 搭建的,后来逐步引入了 Vue.js 来管理部分模块。但随着业务越来越复杂,Vue 和 jQuery 之间的耦合越来越高,代码维护成本直线上升。

项目初期,我们评估了多种技术方案:继续升级到 Vue 3?还是尝试迁移到 React?抑或采用微前端架构?

不过最后我们的目光聚焦在一个有点“复古”却又非常“现代”的技术点上 —— Web Components

当时我对这项技术了解不多,但在做技术选型时,我被它那句「Write Once, Use Everywhere」深深吸引了。更重要的是,它不依赖任何框架、可以自由地和现有系统集成 —— 这恰恰是我们这种渐进式重构项目的福音。

于是,我们决定以 Web Components 为核心来构建全新的 UI 组件体系,并逐步替代旧系统中的自定义组件。这不仅让整个项目结构变得清晰,也让前后端协作更加顺畅。

这篇文章,我想结合这次亲身经历,聊聊我们是如何在实际项目中落地 Web Components 的,也分享一下过程中的经验教训。


问题描述:老系统痛点 + 现有方案局限

响应式布局概念图-1

问题描述:老系统痛点 + 现有方案局限

我们的项目背景其实比较典型:一个运行多年的企业管理系统,页面种类多、交互复杂、用户量稳定但不容宕机。

老系统的问题:

  1. jQuery 手动操作 DOM 风格导致结构混乱
    有很多地方为了方便直接写 $().append(),后续没人敢轻易改动这些逻辑。

  2. Vue 模块和原生代码混杂
    不少新功能用 Vue 写的,而旧页面仍然用 jQuery 控制状态,导致组件复用极其困难。

  3. 组件难以统一,样式/行为存在差异
    比如弹窗组件每个模块都自己写一套,UI 样式和接口调用方式各不相同。

  4. 团队新人适应成本高
    新加入的同学需要花大量时间理解不同页面的“风格”,甚至要同时掌握 jQuery 和 Vue 两套思维模型。

响应式布局概念图-2

我们的期望目标:

  • 构建可复用的跨框架 UI 组件(兼容 Vue、React 或纯 HTML 页面)
  • 减少全局 CSS 冲突
  • 提供统一的 API 接口
  • 易于调试和测试
  • 渐进式迁移,不影响现有业务运行

我们试过一些其他方案,比如用 Stencil 构建跨框架组件,或者通过 Shadow DOM + 类似 LitElement 的库封装组件。但最终我们选择了原生的 Web Components API 实现核心能力,搭配一些轻量级辅助库来提升开发体验。


解决方案:从 Shadow DOM 到 Custom Elements 全家桶

我们在项目里构建了一套基于 Web Components 的通用组件库,命名为 @company/ui。这套组件的核心是使用浏览器原生支持的三大技术:

  • Custom Elements:用于定义新的 HTML 元素类型
  • Shadow DOM:用于封装组件内的 DOM 和 CSS,避免污染全局环境
  • HTML Templates:提前声明模板内容,提高渲染效率

另外,我们还借助了一些开源工具来提升开发体验:

  • 使用 Lit 提供响应式模板和数据绑定机制
  • 引入 Open WC 帮助我们快速搭建开发环境
  • 使用 Rollup.js 构建生产版本

举个例子,我们有一个核心组件 ui-button,它的作用就是提供一个统一的按钮风格,并对外暴露 type="primary"loadingdisabled 等属性:

<ui-button type="primary" loading>提交</ui-button>

下面是我为这个组件定义的结构:

核心结构:

// ui-button.js

import { LitElement, html, css } from 'lit';

export class UIButton extends LitElement {
  static properties = {
    type: { type: String },
    disabled: { type: Boolean, reflect: true },
    loading: { type: Boolean, reflect: true }
  };

  static styles = css`
    button {
      padding: 8px 16px;
      font-size: 14px;
      border-radius: 4px;
      background-color: var(--button-bg, #007BFF);
      color: white;
      border: none;
      cursor: pointer;
    }

    :host([loading]) button {
      opacity: 0.6;
      pointer-events: none;
    }
  `;

  render() {
    return html`
      <button ?disabled="${this.disabled || this.loading}">
        ${this.loading ? html`<span class="loader"></span>` : ''}
        <slot></slot>
      </button>
    `;
  }
}

customElements.define('ui-button', UIButton);

这个组件一旦定义完成,就可以在任意页面中使用,无论是 Vue 项目、React 应用,甚至是纯 HTML 页面。


关键实现点与开发技巧

在整个实践中,我们踩了不少坑,也摸索出了一些有效的开发模式。

✅ 局部封装 vs 全局样式

我们一开始就遇到了一个问题:如何平衡组件内部样式隔离和整体主题控制。

Shadow DOM 确实帮助我们隔绝了全局样式污染,但也带来了灵活性下降的问题。例如,如果我们要根据不同品牌定制主题色,怎么办?

解决方法:使用 CSS Variables

:host {
  --button-bg: red;
}

然后在父级页面中控制主题变量:

<style>
  ui-button.brand-a {
    --button-bg: blue;
  }
</style>
<ui-button class="brand-a">蓝色按钮</ui-button>

这样既保证了组件本身的封装性,又保留了灵活配置的可能性。


✅ 自定义事件和数据流设计

Web Components 的通信方式不像 Vue 或 React 那样直观。我们早期在父子组件传值时遇到了麻烦。

举个例子:我们的 ui-modal 组件点击关闭按钮时,应该触发一个 close 事件:

this.dispatchEvent(new Event('close', { bubbles: true }));

而在外部 Vue 项目中监听这个事件:

<ui-modal @close="handleClose"></ui-modal>

Vue 对此类事件默认是可以处理的,但如果你在某个 <div is="ui-modal"> 中使用 is 特性,事件监听就可能会失效。

解决办法:始终确保使用正确的自定义元素标签名,并在事件传递时设置 { bubbles: true, composed: true },以保证事件能穿透 Shadow DOM 并正确冒泡。


✅ 性能优化小贴士

虽然 Web Components 是原生 API,但如果滥用,也会带来性能问题。以下是我们总结的一些优化建议:

  • 延迟加载 Shadow DOM 内容:对于复杂的组件,可以在 connectedCallback 中才真正创建 Shadow DOM。
  • 避免频繁重绘:尤其是带有动画的组件(如 Loading 动画),尽可能利用 GPU 加速。
  • 合理使用 slots:过多嵌套 <slot> 会增加浏览器渲染负担。
  • 组件懒加载:将组件注册推迟到首次被使用时。
// 懒加载组件示例
if (!customElements.get('ui-card')) {
  customElements.define('ui-card', UICard);
}

✅ DevTools 调试技巧

Chrome DevTools 对 Web Components 支持非常好:

  • 右键查看 Shadow DOM 内容
  • 元素面板可以看到组件的类和构造函数信息
  • 在 Sources 面板下打断点也非常方便

我们也推荐大家在开发时使用 Open WC 的开发者服务,它可以自动热更新、ESM 支持,非常适合本地开发。


实战效果:组件复用率大幅提升,开发效率显著改善

项目上线后,我们做了几个维度的数据统计:

指标 上线前 上线后
公共组件数量 50+ 18
页面平均组件复用率 ~30% ~85%
新人学习时间(小时) 40+ 15~20
组件样式冲突数 月均 12 例 月均 1~2 例

最明显的一个变化是:从前每次新增一个表单控件都需要去各个页面拷贝代码、调整样式;现在只需要 <ui-input /> 就搞定了。

而且因为组件是原生的,我们可以很方便地在各种上下文中复用。比如后端同学写的 Django 模板页也能轻松集成我们的组件,前端同学也不再需要为每种场景单独写适配层。


经验与反思:适合你的项目吗?

Web Components 并不是银弹。在实际使用中,我也总结了一些是否适用它的判断标准:

✅ 适合使用 Web Components 的场景:

  • 企业级系统长期维护项目
  • 多个团队共建组件体系
  • 需要在多个框架之间共享组件
  • 强调封装性和可维护性的项目

❌ 不太适合的场景:

  • 项目完全由单一团队开发且只使用一个框架
  • 需要极致性能优化(比如大型 Canvas / WebGL 游戏)
  • 开发周期非常紧张的小项目

如果你的团队已经有成熟的 Vue/React 项目,也没有强烈的跨框架需求,那么 Web Components 可能反而增加了复杂度。

但如果你正在做一个需要组件长期维护、多人协作的大型应用重构,那我真的强烈建议你试试看这条路。


结语:拥抱浏览器原生的力量

这一年来,看着 Web Components 逐渐从小众走向主流,我内心其实蛮感慨的。

以前我们总在抱怨 “浏览器不给力”、“又要学新框架”。但现在回头看,很多框架的底层设计理念,其实都在朝着更接近浏览器原生的方向靠拢。

比如 React Fiber 的异步渲染、Vue 3 的 Composition API、以及越来越多对 Web Components 的官方支持(Vue 已经推出了 defineCustomElement API)。

Web Components 让我们重新思考了一个问题:前端的本质是什么?

在我看来,就是构建一个个可以在浏览器中“独立运行”的界面单元,无论背后用什么框架,只要浏览器能执行、用户能看到。

所以,在接下来的技术选型中,我不会再轻易说“这个必须用 Vue 写”。取而代之的是:“这个可以用 <ui-form-item /> 吗?”

这或许才是组件化的终极理想。


❤️ 小彩蛋:一个深夜 Debug 小插曲

记得有一次上线前夕,我在本地怎么也看不到某个 Shadow DOM 中的字体图标,反复检查 CSS 却没发现问题。

最后发现是因为组件内引用了外部字体文件,路径是从 ui-icon 当前文件的位置出发计算的。

但浏览器在渲染 Shadow DOM 时并不知道相对路径该从哪开始解析!

解决方案很简单 —— 使用绝对路径:

// 错误:
const iconUrl = './icons/arrow-up.svg';

// 正确:
const iconUrl = new URL('./icons/arrow-up.svg', import.meta.url).href;

这个问题花了我两个钟头才定位,但它也让我意识到:Web Components 是真正的“浏览器原生组件”,调试时你得像浏览器一样思考。

希望你们在使用过程中少走弯路 😊


如果你也在做类似的技术探索,欢迎留言交流!

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝