从 Vue 到 Web Components:我在项目重构中的组件化演进之路

朱志华
2025-06-17 01:46
阅读 253

开篇背景:为什么选择写 Web Components?

开篇背景:为什么选择写 Web Components?

两年前,我加入了一个正在经历技术转型的中型电商平台团队。我们的目标很明确——打造一套高度可复用、支持多框架接入的 UI 组件库,并且能够嵌入到多个业务线中,实现“一次开发,到处可用”。最初我们考虑过使用主流框架封装方案,但都存在较强的耦合性问题。

就在那个节点,我对 Web Components 产生了浓厚兴趣,因为它不依赖任何框架,直接运行于浏览器标准之上。于是我们开始尝试用原生的方式重构组件库的核心模块。今天我想分享的就是这段过程中的真实体验和踩坑心得。


问题描述:框架锁定带来的痛点

问题描述:框架锁定带来的痛点

在接手组件库维护之前,我们团队使用的是一套基于 Vue 封装的组件集。虽然功能完善、文档齐全,但在实际使用时却出现了很多协作障碍:

  • 不同业务线的技术栈不同:有的用 React,有的用 Angular,甚至还有使用 jQuery 的老项目。
  • 共享组件版本管理困难:每次更新一个基础组件都需要各个团队同步升级,否则界面行为可能不一致。
  • 样式污染严重:Vue 的 scoped CSS 在某些动态加载场景下并不能完全隔离。
  • 性能瓶颈明显:某些复杂列表组件在低端设备上表现卡顿。

这些痛点促使我们重新思考构建方式,而 Web Components 成为了我们的突破口。


解决方案:用原生组件打破技术壁垒

Web Components 是一套 W3C 标准,它允许我们创建可重用的自定义元素,其核心包括:

  1. Custom Elements:自定义 HTML 元素(如 <my-button>)。
  2. Shadow DOM:为组件提供独立的 DOM 和 CSS 作用域。
  3. HTML Templates:通过 <template><slot> 定义结构模板。
  4. ES Modules:标准化模块导入导出机制。

我们的目标:

  • 构建与框架无关的组件
  • 确保样式隔离和行为一致性
  • 支持按需加载和懒加载
  • 兼容现代浏览器和部分旧版本(如 IE11)

第一版尝试:从 lit-element 入手

早期我们选择了 Google 的 lit-element,它基于 LitHTML,提供了类组件 API,适合习惯类写法的人快速上手。

举个简单的例子:

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

export class MyButton extends LitElement {
  static get styles() {
    return css`
      button {
        background-color: #0366d6;
        color: white;
        border-radius: 4px;
        padding: 8px 16px;
      }
    `;
  }

  render() {
    return html`<button><slot></slot></button>`;
  }
}

customElements.define('my-button', MyButton);

这样我们就有了一个可以全局使用的按钮组件,在任意 HTML 页面中都可以直接使用:

<my-button>提交</my-button>

它的 Shadow DOM 中会渲染出带样式的按钮,而且不会影响外部页面的样式。

这个方案看起来可行,但很快我们遇到了一些挑战。


踩坑经验:那些你必须知道的坑点

坑1:浏览器兼容性没那么友好

虽然 lit-element 号称支持 IE11,但我们实测下来发现:

  • 不支持 ES Module 动态导入
  • 需要引入大量的 polyfill
  • 构建出来的包体积大(尤其对于简单组件来说)

解决方案

我们改用了更轻量的库如 Stencil.js 或者纯原生方式编写,同时结合 Babel + Webpack 打包降级处理。最终我们选择了自己搭建基础组件模板,不再依赖第三方框架。

坑2:事件传递与跨组件通信难

由于每个组件都在自己的 Shadow DOM 中,传统的事件监听方式无法穿透边界。

解决方案

this.dispatchEvent(new CustomEvent('some-event', {
  detail: { data: 'something' },
  bubbles: true,
  composed: true
}));

记得加上 composed: true,才能让事件“穿透” Shadow DOM 上升到主文档。

坑3:样式隔离 vs 外部定制需求冲突

有时候客户希望可以自定义组件外观,但 Shadow DOM 的样式是封闭的。

解决方案

  • 使用 CSS 自定义属性来暴露变量接口:
:host {
  --primary-color: #0366d6;
}
button {
  background-color: var(--primary-color);
}

然后用户可以在外部修改主题颜色:

<style>
  my-button {
    --primary-color: red;
  }
</style>
  • 或者使用开放模式(mode: 'open')暴露 Shadow DOM,供 JS 直接操作(但风险较大)。

代码实践:一个完整组件是如何工作的?

以下是一个基于原生 JS 实现的 Tab 组件示例,包含基本交互逻辑和样式封装。

class MyTabs extends HTMLElement {
  constructor() {
    super();
    this._tabs = [];
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    const template = document.createElement('template');
    template.innerHTML = `
      <style>
        .tab-nav {
          display: flex;
          list-style: none;
          padding: 0;
        }
        .tab-nav li {
          margin-right: 10px;
          cursor: pointer;
        }
        .tab-content {
          margin-top: 1em;
        }
      </style>
      <ul class="tab-nav"></ul>
      <div class="tab-content"><slot></slot></div>
    `;
    this.shadowRoot.appendChild(template.content.cloneNode(true));


![用户交互流程图-1](https://code-guide.oss.shanghai.autogptai.club/common/file/download?name=date2025061701/7dee3f81-e0b6-4608-8e2e-54b74554613d.jpg)


    this.$nav = this.shadowRoot.querySelector('.tab-nav');
    this.$content = this.shadowRoot.querySelector('.tab-content');

    this.initTabs();
  }

  initTabs() {
    this._tabs = Array.from(this.querySelectorAll('[role="tabpanel"]'));
    this._tabs.forEach((panel, index) => {
      const label = panel.getAttribute('aria-labelledby') || `Tab ${index + 1}`;
      const li = document.createElement('li');
      li.textContent = label;
      li.addEventListener('click', () => this.selectTab(index));
      this.$nav.appendChild(li);
    });

    if (this._tabs.length > 0) {
      this.selectTab(0);
    }
  }

  selectTab(index) {
    this._tabs.forEach((panel, i) => {
      panel.hidden = i !== index;
    });
    Array.from(this.$nav.children).forEach((li, i) => {
      li.classList.toggle('active', i === index);
    });
  }
}

customElements.define('my-tabs', MyTabs);

使用方式如下:

<my-tabs>
  <div role="tabpanel" aria-labelledby="Home">首页内容...</div>
  <div role="tabpanel" aria-labelledby="Profile">个人中心内容...</div>
</my-tabs>

这个组件完全封装了自己的布局与交互,对外只需传入内容即可,非常适合集成到各种框架项目中。


效果总结:重构后的变化

JavaScript框架对比-2

经过半年的努力,我们将整个平台的基础组件逐步迁移到 Web Components 模式下,效果显著:

  • 统一了 UI 展示:无论哪个团队接入,看到的组件外观和行为都一致。
  • 提高了可维护性:组件本身不需要绑定特定框架生命周期,更容易维护。
  • 增强了灵活性:支持多种打包方式(ESM、UMD),方便被不同环境引入。
  • 减少了构建时间:去掉了框架依赖后,组件库整体包体积减少了约 40%。
  • 兼容性更强:经过适配后可在 IE11 上稳定运行。

更重要的是,我们在内部推行了一种新的协作流程:前端组件以“微前端”的形式进行管理和发布,真正实现了“开箱即用”。


经验分享:几点建议给还在观望的同学

  1. 不是所有项目都要用 Web Components

    • 如果你的项目本身就已经完全使用单一框架(比如全站 Vue),没必要强行迁移。
    • 但如果你有组件复用、多团队协作、跨平台等需求,WC 是非常值得投入的方向。
  2. 不要低估兼容性和构建成本

    • 对于需要支持老浏览器的项目,构建配置会比较繁琐,建议使用 Stencil、Open WC 这样的成熟工具链。
    • 可以使用 Playwright 编写端到端测试确保组件行为的一致性。
  3. 拥抱渐进式迁移

    • 不必一开始就全部重写。可以从最小化的组件(如 Button、Input)开始试点。
    • 在已有项目中局部替换,逐步验证价值再推广。
  4. 关注无障碍和语义化设计

    • Web Components 提供了更好的结构控制能力,但也容易写出不符合语义的标签。
    • 使用正确的 ARIA 属性、保持合理的 DOM 结构,是组件用户体验的关键。
  5. 调试技巧:利用 Chrome DevTools 的优势

    • 右键点击自定义元素 → “Reveal in Elements panel”
    • 可以看到完整的 Shadow DOM 结构和样式继承关系
    • $0.__proto__ 查看组件原型链,便于调试内部状态

写在最后:未来的趋势在于“去框架化”

这几年前端生态发展得太快了,Angular、Vue、React 各领风骚,但它们本质上还是语言层面的抽象。Web Components 是唯一一个在浏览器层面上达成共识的标准。随着 Svelte、SolidJS 等无虚拟 DOM 方案的兴起,开发者对“原生能力”的关注度也在提升。

对于我们这些一线开发人员来说,与其不断学习新框架,不如回归标准和本质,用最接近浏览器的方式构建产品。这不仅有助于提高性能和可维护性,也能让我们更专注在业务逻辑而不是框架语法上。

希望这篇文章能为你打开 Web Components 的大门。也许下一个项目的组件架构,就从这里开始。

评论 0

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