前言
在前端技术栈百花齐放的今天,我们经常面临一个痛点:组件复用难。React 组件无法直接在 Vue 项目中使用,Vue 2 的组件难以平滑迁移到 Vue 3。
Web Components 的出现正是为了解决这个问题。它是一套 W3C 标准,允许开发者创建可重用、封装良好且独立于框架的 UI 组件。无论你的主应用是 Vue、React 还是纯原生 JS,Web Components 都能完美运行。
一、 技术全景:什么是 Web Components?
Web Components 并非单一技术,而是由四项核心技术组成的规范集合,旨在实现组件的高内聚与低耦合。
1.1 核心组成体系
我们可以通过下图理解其运作机制:
graph TD
WC[Web Components] --> CE[Custom Elements]
WC --> SD[Shadow DOM]
WC --> HT[HTML Templates]
WC --> ES[ES Modules]
subgraph "逻辑层: Custom Elements"
CE --> CER[CustomElementRegistry]
CE --> LC[生命周期回调]
LC --> C1[connectedCallback <br/>(挂载)]
LC --> C2[disconnectedCallback <br/>(卸载)]
LC --> C3[attributeChangedCallback <br/>(属性变更)]
end
subgraph "视图层: Shadow DOM"
SD --> SR[ShadowRoot]
SD --> DOMI[DOM 隔离]
SD --> CSSI[样式 隔离]
end
-
Custom Elements:通过 CustomElementRegistry 定义浏览器直接识别的新标签(如 <tera-chat-root>)。
-
Shadow DOM :这是组件化的灵魂。它将组件内的 HTML 和 CSS 隐藏在 #shadow-root 中,完全隔离于外部文档。外部的 CSS 无法影响组件,组件的样式也不会污染外部。
-
HTML Templates:使用 <template> 标签定义结构。
-
ES Modules:标准的模块化加载方案。
二、 方案选型:为什么选择 Vue 3?
虽然原生 API 可以编写 Web Components,但通过 HTMLElement 手写繁琐的 DOM 操作和状态管理效率极低。
Vue 3 提供了 defineCustomElement API,让我们能用熟悉的 SFC (单文件组件) 语法开发,最后编译成标准的 Custom Element。
2.1 转换原理
Vue 编译器将组件转换为 Web Component 的流程如下:
graph TD
VueSFC[Vue 单文件组件 (.vue)] -->|编译| VueCE[defineCustomElement]
VueCE -->|封装| CE[HTMLElement 类]
subgraph "运行时行为"
CE -->|Props 映射| Atts[HTML Attributes]
CE -->|Emits 映射| Events[Custom Events]
CE -->|挂载| SR[Shadow Root]
end
SR -->|注入| Styles[CSS (Inline)]
SR -->|渲染| Template[DOM 结构]
三、 工程化架构
为了满足企业级开发需求(TypeScript、Pinia 状态管理、多环境构建),我们需要设计合理的目录结构。
3.1 项目结构 (vite-shadow-dom)
vite-shadow-dom/
├── demo/ # 调试/演示应用(模拟真实使用场景)
│ ├── main.ts
│ └── index.html
├── src/ # 组件库源码
│ ├── components/
│ │ └── ChatRoot.vue # 核心业务组件
│ ├── styles/ # 全局样式
│ ├── entry.ts # 【核心】自定义元素注册入口
│ └── vite-env.d.ts
├── scripts/ # 构建脚本 (npm publish, build)
├── vite.config.ts # 标准构建配置 (Vue 3)
├── vite.compat.config.ts # 兼容构建配置 (Vue 2/无框架)
└── package.json
3.2 产出物设计
为了兼顾不同使用场景,我们设计了两套构建产物:
-
Standard (标准版):依赖外部 Vue 运行时,体积小。适用于宿主环境已经是 Vue 3 的项目。
-
Compat (兼容版) :内联 Vue 运行时。适用于 Vue 2、React 或 jQuery 等非 Vue 3 环境,避免版本冲突。
四、 核心代码实现
4.1 解决痛点:Shadow DOM 中的样式与状态管理
在 Shadow DOM 中使用 Vue 生态库(如 Pinia)和全局样式会遇到两个挑战:
-
Pinia 挂载问题:Web Component 内部没有常规的 Vue App 实例。
-
样式隔离问题:全局 CSS 无法穿透 Shadow DOM。
我们需要在 entry.ts 入口文件中进行特殊处理:
// src/entry.ts
import { defineCustomElement, provide, h } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import ChatRoot from './components/ChatRoot.vue';
// 利用 ?inline 导入样式字符串,而非通过 style 标签插入 head
import commonStyles from '@/styles/index.scss?inline';
// 定义组件标签名常量
export enum SHADOW_DOM {
CHAT_ROOT = 'tera-chat-root',
CHAT_ROOT_UMD = 'TeraChatRoot',
};
// 封装 defineCustomElement
const ChatRootElement = defineCustomElement({
// 继承原始组件逻辑
...ChatRoot,
setup(props, ctx) {
// 1. 手动初始化 Pinia
const pinia = createPinia();
setActivePinia(pinia);
// 注入到组件树中
provide('pinia', pinia);
// 2. 调用原始组件的 setup
return ChatRoot.setup?.(props, ctx);
},
// 3. 注入样式:Vue 会自动将这些 CSS 字符串注入到 ShadowRoot 的 <style> 中
styles: [commonStyles, ...(ChatRoot.styles || [])],
});
// 注册自定义元素(防止重复注册)
if (!customElements.get(SHADOW_DOM.CHAT_ROOT)) {
customElements.define(SHADOW_DOM.CHAT_ROOT, ChatRootElement);
}
// 导出以便 UMD 环境挂载到 window
export { ChatRootElement as TeraShadowDom };
if (typeof window !== 'undefined') {
(window as any)[SHADOW_DOM.CHAT_ROOT_UMD] = ChatRootElement;
}
CSS 中若使用了 :root 定义变量,在 Shadow DOM 中需替换为 :host,否则无法生效。
4.2 构建配置:多版本共存
我们需要两个 Vite 配置文件来应对不同场景。
Vue 2 兼容版配置 (vite.compat.config.ts):
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
vue({ customElement: true }), // 开启 Custom Element 模式
],
build: {
lib: {
entry: 'src/entry.ts',
name: 'TeraShadowDomCompat',
fileName: (format) => `tera-shadow-dom.vue2-compat.${format}.js`,
},
// 关键点:将 rollupOptions.external 设为空
// 这样 Vue 运行时会被打包进组件库中,确保在 Vue 2 环境下也能运行 Vue 3 逻辑
rollupOptions: {
external: [],
},
},
});
五、 组件使用指南
构建完成后,我们的组件就可以在任何地方使用了。
场景 1:原生 HTML (CDN 方式)
直接引入 UMD 文件,像使用 HTML 原生标签一样使用它。
<body>
<!-- 引入打包后的 JS -->
<script src="./dist/tera-shadow-dom.vue2-compat.umd.js"></script>
<!-- 直接使用标签 -->
<tera-chat-root id="my-chat" token="sk-123456"></tera-chat-root>
<script>
const el = document.getElementById('my-chat');
// 监听自定义事件
el.addEventListener('btn-click', (e) => {
console.log('Clicked:', e.detail);
});
// 动态修改属性
el.setAttribute('token', 'new-token');
</script>
</body>
场景 2:在 Vue 2 项目中集成
由于 Vue 2 不认识 defineCustomElement,必须引入我们的 Compat (兼容) 版本。
// main.js
// 引入包含 Vue 3 运行时的兼容包
import '@baidu/vite-shadow-dom/dist/tera-shadow-dom.vue2-compat.es.js';
<!-- 组件内使用 -->
<template>
<div>
<!-- Vue 2 会将其视为原生标签,跳过组件解析 -->
<tera-chat-root
:token="token"
@btn-click="handleClick"
></tera-chat-root>
</div>
</template>
场景 3:在 Vue 3 项目中集成
Vue 3 环境天然支持,可以使用轻量版(不含 Vue 运行时)。
// main.ts
import '@baidu/vite-shadow-dom'; // 引入注册逻辑
如果使用 TS,记得在 vue 模块中补充类型声明,否则 <tera-chat-root> 可能会报类型错误。
六、 总结
基于Web Components + Vue 3能够实现 :
-
样式隔离:Shadow DOM 彻底解决了 CSS 污染问题。
-
框架解耦:一次编写,到处运行(Vue2/3/React/jQuery)。
-
开发效率:利用 Vue 3 的响应式系统简化开发,利用 Vite 实现高效构建。
这种模式非常适合开发通用的业务组件库(如 AI 助手、支付弹窗、反馈组件),让基础设施团队能够跨越业务线技术栈的差异,提供统一的服务。