35岁还在写代码的我,终于把 Web Components 玩明白了

何雨萱○
2026-04-09 06:37
阅读 1940

凌晨一点半,上海的雨敲打着出租屋的窗户。咖啡早就凉了,但脑子里的 bug 还是热乎的。我又一次被产品经理拉进会议室:“这个组件能不能在所有项目里复用?包括那几个用 Vue、React、甚至 jQuery 写的老系统?”

说实话,当时真想回一句:“你当我是 GPT-4o 啊,啥都能一键生成?”但转念一想,这需求其实挺合理——我们团队维护着七八个前端项目,技术栈横跨三代,每次做个通用弹窗或表单控件,都得复制粘贴三套代码,改一个样式全军覆没。

于是,我翻出了尘封已久的 Web Components 文档。没错,就是那个被很多人说“早就过时了”的原生组件化方案。


别急着喷,先看看它能解决什么

去年双11前,我们搞了个“统一登录组件”,要在主站、H5活动页、还有外包团队维护的后台系统里同时上线。三个项目分别用了 React 18、Vue 2 和纯 HTML + jQuery。按老办法,得让三个前端各自实现一套,还得保证交互、样式、API 完全一致。测试同学光是比对 UI 就快疯了。

这时候,Web Components 的优势就冒出来了:它不依赖任何框架,浏览器原生支持(现代浏览器),自带封装性,还能通过属性和事件跟外部通信。换句话说,只要你的浏览器不是 IE6,就能跑。

我试着写了个 <unified-login> 组件,打包成一个 JS 文件,丢给三个项目直接 <script> 引入。结果?除了 Vue 2 里有个小坑(后面讲),其他地方居然一次跑通!那一刻,我仿佛看到了代码复用的乌托邦。


踩坑实录:理想很丰满,现实全是坑

当然,事情没那么简单。下面这几个坑,我差点栽进去出不来。

坑一:Shadow DOM 的样式隔离,太“彻底”了

Web Components 默认用 Shadow DOM 隔离样式,本意是防止外部污染。但问题来了:公司 Design Token 是用 CSS Variables 定义的主题色,比如 --primary-color: #1890ff;。结果在 Shadow DOM 里读不到!

查了半天文档,才发现 CSS Variables 是可以穿透 Shadow DOM 的——但必须在根元素(:roothtml)上定义,不能只在某个 .app-container 里设。我们之前为了模块化,把变量塞进了业务容器里,导致组件内部拿不到。

解决方案:把设计系统的 CSS Variables 提升到 :root 层级。虽然有点破坏“模块封闭性”,但权衡之下,主题一致性更重要。

/* 正确姿势 */
:root {
  --primary-color: #1890ff;
  --border-radius: 4px;
}

/* 错误姿势(Shadow DOM 里读不到) */
.app-theme {
  --primary-color: #1890ff;
}

坑二:Vue 2 对自定义元素的“敌意”

Vue 2 默认会把不认识的标签(比如 <my-button>)当成普通 HTML 元素处理,不会等 Web Component 加载完再渲染。结果就是:组件还没注册,Vue 就把它当空 div 渲染了,里面的 slot 内容全丢了。

网上搜了一圈,发现得手动告诉 Vue:“这是个 Web Component,别动它”。方法是在 Vue.config.ignoredElements 里加白名单:

// main.js
Vue.config.ignoredElements = [
  'unified-login',
  'smart-table',
  // ...其他自定义元素
];

但问题是,每次新增组件都要手动加,容易漏。后来我写了个 Babel 插件,自动扫描项目里的 customElements.define 调用,生成忽略列表——这招是从 DeepSeek 的开源项目里偷师的,不得不说,国产大模型在工程细节上越来越靠谱了。

坑三:事件传递的“失联”现场

Web Components 内部派发的事件,默认是 不会冒泡出 Shadow DOM 的!这意味着你在外面写:

<my-dialog @close="handleClose"></my-dialog>

在 React 或 Vue 里根本监听不到 close 事件。

正确做法:派发事件时设置 bubbles: true, composed: true

// 在组件内部
this.dispatchEvent(new CustomEvent('close', {
  bubbles: true,
  composed: true, // 关键!允许事件穿透 Shadow DOM
  detail: { reason: 'user-clicked-cancel' }
}));

这个坑我踩了整整一个周末。周五晚上加班到三点,第二天醒来第一件事就是改这行代码。搞定那一刻,感觉比中彩票还爽。


实战:一个可复用的搜索框组件

下面是我最近做的 <smart-search> 组件核心代码,带防抖、loading 状态、支持受控/非受控模式,注释里都是血泪经验:

class SmartSearch extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    
    // 内部状态
    this.value = this.getAttribute('value') || '';
    this.debounceTimer = null;
  }

  connectedCallback() {
    this.render();
    this.setupEventListeners();
  }

  render() {
    // 注意:这里用 template + clone 更高效,但为简洁直接 innerHTML
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          font-family: var(--font-family, sans-serif);
        }
        .search-box {
          border: 1px solid var(--border-color, #ddd);
          border-radius: var(--border-radius, 4px);
          padding: 8px 12px;
          width: 100%;
          box-sizing: border-box;
        }
        .search-box:focus {
          outline: none;
          border-color: var(--primary-color, #1890ff);
        }
        .loading {
          opacity: 0.6;
          pointer-events: none;
        }
      </style>
      <input 
        class="search-box" 
        type="text" 
        placeholder="${this.getAttribute('placeholder') || 'Search...'}"
        value="${this.value}"
      />
    `;
  }

  setupEventListeners() {
    const input = this.shadowRoot.querySelector('.search-box');
    
    input.addEventListener('input', (e) => {
      const newValue = e.target.value;
      this.value = newValue;
      
      // 派发 input 事件,供外部框架双向绑定
      this.dispatchEvent(new CustomEvent('input', {
        bubbles: true,
        composed: true,
        detail: { value: newValue }
      }));

      // 防抖搜索
      clearTimeout(this.debounceTimer);
      this.debounceTimer = setTimeout(() => {
        this.dispatchEvent(new CustomEvent('search', {
          bubbles: true,
          composed: true,
          detail: { query: newValue }
        }));
      }, 300);
    });
  }

  // 外部可通过 setAttribute 或 .value 更新
  set value(val) {
    this._value = val;
    if (this.shadowRoot?.querySelector('.search-box')) {
      this.shadowRoot.querySelector('.search-box').value = val;
    }
  }

  get value() {
    return this._value;
  }
}

customElements.define('smart-search', SmartSearch);

用起来超简单:

<!-- 纯 HTML -->
<smart-search placeholder="找商品..." @search="handleSearch"></smart-search>

<!-- Vue -->
<smart-search 
  :value="searchQuery" 
  @input="searchQuery = $event.detail.value"
  @search="onSearch"
></smart-search>

<!-- React -->
<smart-search
  value={searchQuery}
  onInput={(e) => setSearchQuery(e.detail.value)}
  onSearch={handleSearch}
/>

性能与兼容性:别被“原生”二字骗了

很多人以为 Web Components 是“原生”的就一定快,其实不然。Shadow DOM 的创建是有开销的,尤其在低端安卓机上。我们做过 A/B 测试:

方案 首屏加载 (ms) 内存占用 (MB) 低端机 FPS
React 组件 120 45 52
Web Components 95 38 48
纯静态 HTML 60 25 60

可以看到,Web Components 比框架轻,但比纯静态重。适合做中低频交互的通用组件(如按钮、卡片、表单控件),不适合做高频更新的列表或动画。

浏览器兼容性方面,现在连 iOS Safari 都全支持了(iOS 15+)。我们用 webcomponents/polyfills 打了个包,IE11 用户占比不到 0.3%,领导直接拍板放弃支持。


为什么现在才火?GPT-4o 和 DeepSeek 功不可没

说实话,Web Components 早在 2014 年就提出来了,但一直不温不火。为什么最近又热起来了?

我觉得有两个原因:

  1. 微前端架构普及:越来越多公司需要跨技术栈复用 UI,Web Components 成了天然胶水。
  2. AI 编程助手成熟:像 GPT-4o 和 DeepSeek 能快速生成标准 Web Components 代码,连生命周期、事件派发、属性监听这些细节都能写对,大大降低了学习成本。

上周我让 DeepSeek 帮我生成一个带 loading 和错误状态的图片上传组件,它居然自动加了 composed: true!要知道三个月前我还在 Stack Overflow 上翻这个问题的答案。


最后几句掏心窝的话

35 岁还在一线写代码,有时候挺累的。但每次看到自己写的组件被十几个项目调用,线上零故障跑了几个月,那种成就感,比涨工资还实在。

Web Components 不是银弹,但它解决了我们最痛的“跨框架复用”问题。如果你也在维护多技术栈项目,不妨试试。别被“过时”“冷门”这些标签吓住——技术的价值,永远取决于它能不能解决你手头的问题

对了,刚收到消息,下个版本要支持暗黑模式。看来,又得熬夜调 CSS Variables 了。不过没关系,反正我习惯深夜写代码,效率高嘛。

咖啡续上,继续干。

评论 0

最热最新
暂无评论
何雨萱○Lv.1
0
影响力
0
文章
0
粉丝