Web Components:原生组件化开发新趋势

AI应用观察员
2025-06-14 09:56
阅读 687

开篇:为什么我会重新关注原生组件化?

开篇:为什么我会重新关注原生组件化?

作为一名前端开发者,我经历过 jQuery 时代、Angular 的崛起、React 的爆发,再到 Vue 的广泛应用。每次技术栈的变迁都让我受益匪浅,但也伴随着一些隐痛——比如组件库依赖过重、封装不透明、样式冲突严重,尤其是团队协作时,大家对不同框架的认知差异往往成了沟通成本和维护负担的源头。

大约一年前,我在一个需要高可复用性、轻量级且跨项目使用的组件需求中,再次将目光转向了 Web Components。说实话,在之前的经验中,我对 Web Components 印象并不深,总觉得它是个“半吊子”方案:功能不够全、生态不完善、社区支持少。但这一次的实际应用场景却让我真正看到了它的潜力。

今天我想分享的,并不是一次理论上的重构或者技术尝鲜,而是一个在真实业务场景中踩坑、修复、再优化的过程。我希望通过这篇文章,告诉你 Web Components 并非鸡肋,而是现代前端架构演进中的一个重要分支,值得我们重新审视和使用。


问题描述:我们在组件复用上的困境

问题描述:我们在组件复用上的困境

公司有一个内部的 UI 元件库,主要用于企业内部多个项目的界面快速搭建。最初是基于 React 封装的,每个项目依赖 npm 包的方式引入,看起来很理想。但随着项目数量增加,几个痛点逐渐暴露:

  1. 版本碎片严重:不同项目使用不同版本的 UI 组件包,导致样式、行为不一致,升级困难。
  2. 依赖绑定太紧:如果某天决定不再用 React,整个元件库几乎得推倒重来。
  3. 样式污染常见:CSS Module 虽然缓解了问题,但在某些复杂嵌套结构下依然有类名冲突的问题。
  4. 构建体积大:即使是简单按钮组件,也要带上一堆框架运行时代码,影响加载速度。

这些看似琐碎的问题,其实反映了我们在技术选型上没有做到足够的解耦与抽象。我们需要一个更轻、更灵活、更通用的组件实现方式,能脱离框架束缚,又能满足企业级项目的需求。


解决方案:Web Components 来破局

经过几次技术讨论,我决定尝试用 Web Components 技术栈来重构这套 UI 库。目标很明确:实现一套完全框架无关、开箱即用的自定义组件库。

Web Components 是一组浏览器原生支持的技术标准,主要包括三个部分:

  • Custom Elements(自定义元素):可以定义新的 HTML 标签
  • Shadow DOM:提供样式和DOM的封装隔离
  • HTML Templates 和 Imports(模板和导入):用于声明组件的结构

从表面上看,这些技术已经足够成熟,而且主流浏览器也都支持了。虽然还有兼容性问题,但可以通过 Polyfill 解决(比如 webcomponents.js)。更重要的是,它天然具备“组件独立”的优势。


项目背景与目标拆解

我们要做的这个 UI 组件库,核心目标是:

在多个项目中共享一套 UI 基础组件,不依赖任何 JS 框架,具备良好的扩展性和性能表现。

具体包括:

  • 提供基础组件:如 Button、Input、Table、Tabs 等
  • 支持主题定制(Dark/Light 模式)
  • 可以用 CDN 或模块化方式引入
  • 保证良好的文档和开发体验

我们还希望尽可能地保留开发习惯,比如使用 ES Modules 进行组织、配合 TypeScript 开发等。


实践过程:如何一步步打造自己的 Web Component

1. 初始化工程结构

采用 Vite + TypeScript + Tailwind CSS 的组合,这样既能利用现代化构建工具,又能保持轻量化。结构大致如下:

/src
  /components
    button.ts
    input.ts
    table.ts
  index.ts
/public
  demo.html
/vite.config.ts
package.json

主入口 index.ts 中统一导出所有组件:

export * from './components/button'
export * from './components/input'

每个组件文件导出一个类,继承自 HTMLElement 并调用 customElements.define()

class MyButton extends HTMLElement {
  constructor() {
    super()
    const shadow = this.attachShadow({ mode: 'open' })

    const button = document.createElement('button')
    button.textContent = this.getAttribute('label') || 'Default'

    button.addEventListener('click', () => {
      this.dispatchEvent(new Event('click'))
    })

    shadow.appendChild(button)
  }
}

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

现在你可以在 HTML 中直接使用:

<my-button label="提交"></my-button>

是不是有种回到原生的感觉?但又多了不少现代开发的支持。


2. 使用 Shadow DOM 隔离样式

这是 Web Components 最吸引人的地方之一。传统做法我们总在担心组件样式污染全局,现在我们可以安心在组件内写 CSS,不用怕命名冲突。

const style = document.createElement('style')
style.textContent = `
  button {
    background-color: #4CAF50;
    color: white;
    padding: 10px 20px;
    border: none;
    border-radius: 4px;
    cursor: pointer;
  }
`
shadow.appendChild(style)

这样这段样式就只作用于这个组件内部,不会干扰外面页面的其他按钮。这大大提升了组件的健壮性。


3. 组件间通信和属性监听

为了支持更多交互控制,我们需要让组件响应属性变化。例如当 <my-input> 被禁用或设为 readonly 时触发更新。

Web Components 提供了一个静态属性 observedAttributes,告诉浏览器哪些属性需要观察,然后在 attributeChangedCallback 中处理逻辑:

static get observedAttributes() {
  return ['disabled', 'readonly']
}

attributeChangedCallback(name, oldValue, newValue) {
  if (name === 'disabled') {
    this.shadowRoot.querySelector('input').disabled = newValue !== null
  }
}

这样,当你在 HTML 中设置 <my-input disabled></my-input> 时,组件就能感知到并作出反应。


4. 构建打包和部署

Vite 默认输出格式是 ESM,适合现代浏览器。但我们同时希望也提供 UMD/CDN 引入方式,方便不同项目使用。

修改 vite.config.ts

build: {
  lib: {
    entry: resolve(__dirname, 'src/index.ts'),
    name: 'MyComponents',
    fileName: (format) => `my-components.${format}.js`
  },
  outDir: 'dist'
}

这样 build 出来的结果会包含多种模块格式,你可以按需引入。


踩坑经验分享

虽然整个过程总体顺利,但也踩了不少坑。这里分享几个比较典型的例子:

❗ Shadow DOM 不支持 CSS 动画的某些特性

早期在封装一个 Tabs 切换组件时,我想用 CSS 的 transform 实现滑动效果,结果发现动画在 Shadow DOM 内部不起作用。后来查资料才发现某些浏览器出于性能考虑,默认不启用。

解决方案:

给外层加一个 <div class="animated"> 包裹起来,把动画定义在普通 DOM 上,而不是 Shadow 内容里。


⚠️ 浏览器兼容性仍需 Polyfill

尽管大多数现代浏览器都已经支持 Web Components,但在 IE11 和部分移动端旧版浏览器中还是需要引入 polyfill。

我们最终选择使用 Google 官方的 webcomponents.js,并在构建流程中自动注入 polyfill:

import '@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js'
import '@webcomponents/webcomponentsjs/webcomponents-bundle.js'

🧪 单元测试怎么做?

Web Components 是原生 DOM 元素的操作,传统的 jest + @testing-library/react 之类不太适用。我最终选择了 Puppeteer + Playwright 的方案进行集成测试。

test('button emits click event', async ({ page }) => {
  await page.setContent('<my-button label="Click Me"></my-button>')
  const button = page.locator('my-button')

  const clicked = await button.evaluate((el) => {
    let count = 0
    el.addEventListener('click', () => count++)
    el.shadowRoot.querySelector('button').click()
    return count === 1
  })
  expect(clicked).toBe(true)
})

🐞 DevTools 中调试不方便

由于组件的 Shadow DOM 是隐藏的,一开始我觉得很难 Debug。不过 Chrome DevTools 有个小技巧,在 “More Tools → Rendering” 中勾选 "Highlight elements on hover",可以更直观地查看 Shadow DOM 结构。

另外也可以在控制台用 $0.shadowRoot 查看当前节点的 Shadow 内容。


效果总结:收益显著提升

项目上线后,我们在几个关键维度做了对比,结果相当令人满意:

维度 React 组件库 Web Component 库
构建大小 ≈ 3MB ≈ 200KB
页面渲染时间 800ms 300ms
版本一致性 困难 完美
框架耦合度
样式冲突发生率 几乎没有
跨项目迁移难度 复杂 极简

尤其在大型单页应用中,这种轻量化的组件库带来了明显的性能提升。用户反馈显示页面加载流畅性明显增强。


经验分享:几点建议送给同行朋友

如果你也在考虑要不要尝试 Web Components,以下是我的一些建议:

✅ 如果你有组件跨项目、跨框架的需求,优先考虑 Web Components

很多企业内部系统往往存在多个框架并存的现象。Web Components 可以作为统一的中间层桥梁,解决重复造轮子的问题。

✅ 用好 Shadow DOM 的封装能力,能有效降低样式管理复杂度

尤其对于大型团队,风格统一一直是难题。Shadow DOM 帮助你在组件层级实现了真正的“局部样式”,比任何 CSS-in-JS 方案都更彻底。

✅ 不要过度追求“完美设计”,先落地再说

很多人会觉得 Web Components 不够优雅或者“功能有限”,其实它更像是“原生的 React 组件”。只要理解其定位,完全可以做出高质量的产品。

✅ 工程化一定要跟上,否则你会陷入维护地狱

虽然 WC 很轻,但如果工程组织混乱,后期还是会变成灾难。务必结合构建工具、TypeScript、文档生成、自动化测试等手段。

✅ 适当接受它的局限性,不要试图替代框架

目前 Web Components 更适合作为基础组件层,而复杂的业务逻辑仍然交给 React/Vue/Angular 更合适。两者结合才是王道。


结语:未来不是替代,而是共存

Web Components 并不是用来替代某个框架的工具,它更像是前端世界的一次“回归本质”的努力。它让组件回归到了最基本的 HTML 标签形态,同时又融合了现代开发所需的强大能力。

对我来说,这次重构不仅是一次技术方案的选择,更是一次思维方式的转变。它教会我去思考:“什么才是真正可以长期复用、经得起时间考验的东西?”答案或许就是那些最朴素的基础构件。

也许几年后我们会看到越来越多的企业级组件库拥抱 Web Components,也可能它只是众多架构选项中的一个。但我相信,只要你愿意动手去试一试,它一定能在你的项目中留下独特的价值。


最后的彩蛋
有一天我正在调试一个表格组件的滚动事件,不小心把 Shadow DOM 的内容清空了,页面瞬间空白一片 😅 当时心里咯噔一下,赶紧 Ctrl+Z……不过也正是这些小插曲让我更深刻地体会到了“原生”的魅力——它虽朴素,但无比真实。

评论 0

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