Vue 基础体系 · 第 36/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 与 Web Components:Custom Element、属性事件、样式和互操作
Vue 组件和 Web Components 都能封装可复用的 UI,但它们解决问题的边界不同:
- Vue 组件依赖 Vue 的响应式系统、模板编译器和组件运行时。
- Web Components是浏览器原生标准,核心包括 Custom Element、Shadow DOM、HTML Template 和 CustomEvent。
- Vue 可以消费原生 Web Components,也可以把 Vue 组件编译成 Custom Element,供不使用 Vue 的页面加载。
真正的互操作问题通常不在“能不能显示”,而在于以下边界:
- 字符串属性和 JavaScript 属性如何传递;
CustomEvent的detail、冒泡和 Shadow DOM 穿透如何处理;- Shadow DOM 如何隔离样式;
- Vue 的响应式更新如何进入 Web Component;
- Web Component 的生命周期和清理逻辑如何与 Vue 生命周期对应;
- Vite、Vue 模板编译器和 TypeScript 如何识别自定义元素。
一、先区分 Vue 组件与 Web Components
1. Vue 组件是什么
Vue 组件是 Vue 运行时管理的一个组件实例。组件通常具有:
props:父组件传入的数据;emits:组件向父组件通知变化的事件;slots:父组件传入的内容;setup():组合式 API 的逻辑入口;- Vue 的响应式更新和生命周期。
例如:
<script setup lang="ts">
defineProps<{
title: string
}>()
const emit = defineEmits<{
save: [id: number]
}>()
</script>
<template>
<button @click="emit('save', 1)">
{{ title }}
</button>
</template>
这里的 title、save 和组件实例都由 Vue 解释。浏览器本身并不知道 props 或 emits 的含义。
2. Web Component 是什么
Web Components 是浏览器提供的一组标准能力。最核心的 Custom Element 是一个继承自 HTMLElement 的 JavaScript 类:
class HelloBox extends HTMLElement {
connectedCallback() {
this.textContent = 'Hello'
}
}
customElements.define('hello-box', HelloBox)
注册后,浏览器可以识别:
<hello-box></hello-box>
Custom Element 的名称必须包含连字符,这是规范要求,用来避免和未来的标准 HTML 元素冲突。
Web Component 不依赖 Vue。只要浏览器支持相关标准,原生 JavaScript、Vue、React、Svelte 或服务端渲染后的 HTML 都可以使用它。
3. 两种互操作方向
Vue 与 Web Components 的关系有两个方向:
原生 Web Component ──被 Vue 使用──> Vue 应用
Vue 组件 ──包装或编译为──> Custom Element
第一种适合接入第三方组件库或跨框架组件。
第二种适合把 Vue 编写的组件发布给不使用 Vue 的宿主应用。但生成的 Custom Element 仍然会携带 Vue 运行时逻辑,不能把它误认为“完全没有框架依赖”的原生组件。
二、Custom Element 的生命周期与状态
1. 自定义元素的主要生命周期
Custom Element 常用的回调包括:
| 回调 | 触发时机 |
|---|---|
constructor() |
元素实例被创建时 |
connectedCallback() |
元素插入文档时 |
disconnectedCallback() |
元素从文档中移除时 |
attributeChangedCallback() |
被观察的属性发生变化时 |
adoptedCallback() |
元素被移动到新的文档时 |
只有列入 static observedAttributes 的属性变化才会触发 attributeChangedCallback()。
一个完整的状态流如下:
sequenceDiagram
participant H as HTML/宿主
participant E as Custom Element
participant D as DOM 属性
participant S as Shadow DOM
H->>E: customElements.define()
E->>E: constructor()
H->>E: 插入文档
E->>E: connectedCallback()
H->>D: setAttribute("name", "Ada")
D->>E: attributeChangedCallback()
E->>S: 更新内部 DOM
H->>E: remove()
E->>E: disconnectedCallback()
关键点是:属性变化和元素连接是两条独立路径。元素可能先收到属性,再连接;也可能先连接,再发生属性变化。
2. 一个可运行的原生 Custom Element
下面的组件实现一个 <hello-card>:
name是字符串属性;disabled是布尔属性;name同时提供 JavaScript property;- 点击按钮后派发
save事件; - 使用 Shadow DOM 隔离内部结构和样式。
// src/hello-card.ts
class HelloCard extends HTMLElement {
static observedAttributes = ['name', 'disabled']
private root: ShadowRoot
private currentName = ''
private currentDisabled = false
constructor() {
super()
this.root = this.attachShadow({ mode: 'open' })
// 属性可能在 connectedCallback 之前已经存在。
this.currentName = this.getAttribute('name') ?? ''
this.currentDisabled = this.hasAttribute('disabled')
}
get name(): string {
return this.currentName
}
set name(value: string) {
const normalized = String(value)
if (normalized === this.currentName) {
return
}
this.currentName = normalized
// 反射:让 property 的新值同步回 attribute。
if (this.getAttribute('name') !== normalized) {
this.setAttribute('name', normalized)
}
this.render()
}
get disabled(): boolean {
return this.currentDisabled
}
set disabled(value: boolean) {
const normalized = Boolean(value)
if (normalized === this.currentDisabled) {
return
}
this.currentDisabled = normalized
// HTML 布尔属性的语义是“是否存在”,不是字符串值。
if (normalized) {
this.setAttribute('disabled', '')
} else {
this.removeAttribute('disabled')
}
this.render()
}
connectedCallback() {
this.render()
}
attributeChangedCallback(
name: string,
oldValue: string | null,
newValue: string | null
) {
if (oldValue === newValue) {
return
}
if (name === 'name') {
this.currentName = newValue ?? ''
}
if (name === 'disabled') {
this.currentDisabled = newValue !== null
}
this.render()
}
disconnectedCallback() {
// 如果这里注册了 window、document 或定时器监听,
// 应该在这里解除监听和清理定时器。
}
private render() {
const buttonDisabled = this.currentDisabled ? 'disabled' : ''
this.root.innerHTML = `
<style>
:host {
display: inline-block;
font-family: sans-serif;
}
.card {
border: 1px solid #ccc;
border-radius: 8px;
padding: 12px;
}
button {
margin-top: 8px;
}
</style>
<div class="card">
<div class="message"></div>
<button type="button" ${buttonDisabled}>保存</button>
</div>
`
const message = this.root.querySelector('.message')
const button = this.root.querySelector('button')
if (!message || !button) {
throw new Error('hello-card 内部结构初始化失败')
}
// 使用 textContent,而不是把外部 name 拼进 innerHTML,
// 避免 name 中的 HTML 被当作标记解析。
message.textContent = `你好,${this.currentName || '访客'}`
button.addEventListener('click', () => {
if (this.currentDisabled) {
return
}
this.dispatchEvent(
new CustomEvent<{ name: string }>('save', {
detail: {
name: this.currentName
},
bubbles: true,
composed: true
})
)
})
}
}
if (!customElements.get('hello-card')) {
customElements.define('hello-card', HelloCard)
}
在入口文件中导入它:
// src/main.ts
import './hello-card'
然后可以在 HTML 中使用:
<hello-card name="Ada"></hello-card>
<script>
const card = document.querySelector('hello-card')
card.addEventListener('save', (event) => {
console.log(event.detail.name)
})
</script>
点击“保存”后,输出:
Ada
这里的 detail 是事件携带的数据。CustomEvent 的 detail 可以是字符串、对象、数组或其他 JavaScript 值。
三、属性与 property:同名但不是同一个东西
HTML 元素同时具有两种常见数据入口:
<hello-card name="Ada"></hello-card>
这里的 name="Ada" 是 attribute,也就是 DOM 属性节点。
而下面的代码访问的是 JavaScript property:
const card = document.querySelector('hello-card') as HTMLElement & {
name: string
}
card.name = 'Grace'
attribute 和 property 的关系可以概括为:
HTML attribute:字符串键值,存在于 DOM 标记和 attributes 集合中
JavaScript property:对象上的运行时字段或访问器,可以是任意 JS 类型
它们是否同步,取决于组件作者是否实现同步逻辑。
1. 字符串属性
<hello-card name="Ada"></hello-card>
读取时得到字符串:
card.getAttribute('name') // "Ada"
card.name // 如果组件实现了 name property,则为 "Ada"
如果组件只监听 attribute,而没有定义 name property,那么:
card.name = 'Grace'
可能只是给 DOM 对象写入一个普通字段,组件内部并不会自动更新。
2. 布尔属性
HTML 布尔属性遵循“存在即为真”的规则:
<button disabled="false">按钮</button>
这个按钮仍然是禁用的,因为 disabled 属性存在。
正确判断方式是:
element.hasAttribute('disabled')
Vue 绑定原生 Custom Element 时也应使用布尔值:
<hello-card :disabled="isDisabled" />
不要写成:
<hello-card disabled="false" />
后者传入的是字符串 "false",而不是布尔值 false。
3. 复杂对象不能依赖 HTML attribute
下面的 attribute 实际上只能表达字符串:
<data-panel data="{...}"></data-panel>
如果要传递对象,应使用 property:
const panel = document.querySelector('data-panel') as HTMLElement & {
data: { id: number; title: string }
}
panel.data = {
id: 1,
title: '文档'
}
如果宿主框架只能通过 HTML 属性传值,则可以约定 JSON 字符串,但必须自行处理序列化失败和类型校验:
<data-panel data='{"id":1,"title":"文档"}'></data-panel>
这种方式有三个限制:
- 对象必须能被 JSON 序列化;
- 每次变化都要重新生成字符串;
- 组件必须捕获
JSON.parse()错误,而不能假设输入永远合法。
4. 是否反射不是规范自动保证的
例如:
element.name = 'Ada'
并不意味着浏览器会自动执行:
element.setAttribute('name', 'Ada')
反过来也一样。组件可以选择:
- property 改变时同步 attribute;
- attribute 改变时同步 property;
- 两者完全独立;
- 只支持其中一个。
因此,使用第三方 Web Component 时,不能仅凭属性名相同就推断两者一定同步,应查看组件 API 或直接验证:
element.setAttribute('value', 'new')
console.log(element.value)
四、事件:CustomEvent、detail、冒泡与 Shadow DOM
1. 原生事件与自定义事件
原生事件例如 click 通常由浏览器产生。Custom Element 对外通知业务状态时,通常使用 CustomEvent:
this.dispatchEvent(
new CustomEvent('save', {
detail: {
id: 42
}
})
)
监听方:
element.addEventListener('save', (event) => {
const customEvent = event as CustomEvent<{ id: number }>
console.log(customEvent.detail.id)
})
事件名称最好使用小写和连字符,例如:
value-change
item-selected
request-close
这样能减少 HTML 属性大小写归一化带来的问题。普通 HTML 属性名不应依赖大小写区别。
2. bubbles 和 composed 是两个不同开关
new CustomEvent('save', {
detail: payload,
bubbles: true,
composed: true
})
两个选项作用不同:
bubbles: true:事件沿 DOM 树从目标向祖先传播;composed: true:事件可以穿过 Shadow DOM 边界。
如果事件只需要让 Custom Element 自己的宿主监听,通常直接监听元素本身即可:
card.addEventListener('save', handler)
如果希望事件继续冒泡到外层容器:
<section id="container">
<hello-card></hello-card>
</section>
则需要 bubbles: true。
如果事件是在 Shadow Root 内部触发,并希望外部文档监听到,通常还需要 composed: true。只设置 bubbles 而不设置 composed,事件可能在 Shadow Root 边界处停止。
3. 不要把事件 payload 和事件对象混为一谈
事件监听器收到的是事件对象:
element.addEventListener('save', (event) => {
// event 是 CustomEvent
// event.detail 才是业务数据
})
错误写法:
element.addEventListener('save', (payload) => {
console.log(payload.id) // 通常是 undefined
})
正确写法:
element.addEventListener('save', (event) => {
const payload = (event as CustomEvent<{ id: number }>).detail
console.log(payload.id)
})
4. 事件错误与业务错误应分开
组件内部执行异步任务时,不要把异常直接当作成功事件发送:
async function save() {
try {
const result = await api.save()
dispatchEvent(new CustomEvent('save-success', {
detail: result
}))
} catch (error) {
dispatchEvent(new CustomEvent('save-error', {
detail: normalizeError(error),
bubbles: true,
composed: true
}))
}
}
宿主可以分别处理:
card.addEventListener('save-success', onSuccess)
card.addEventListener('save-error', onError)
如果只派发一个含糊的 change 事件,宿主通常无法区分“用户改变了值”“保存成功”和“保存失败”。
五、Shadow DOM 与样式边界
1. Shadow DOM 做什么
Shadow DOM 为元素创建一个独立的 DOM 子树:
const shadowRoot = this.attachShadow({ mode: 'open' })
内部节点不再是宿主页面普通 CSS 选择器的直接目标。比如外部样式:
hello-card button {
color: red;
}
通常不能直接选中 hello-card 内部 Shadow Root 中的 button。
这提供了样式隔离,但不是完全隔离:
- CSS 自定义属性可以从宿主继承进入 Shadow Root;
:host可以设置宿主元素自身的样式;::part()可以暴露明确的样式入口;slot内容属于 Light DOM,其样式边界有特殊规则。
2. :host 和 CSS 自定义属性
组件内部可以读取宿主提供的 CSS 变量:
:host {
--card-accent: #42b883;
}
button {
background: var(--card-accent);
}
宿主页面可以覆盖它:
hello-card {
--card-accent: #2563eb;
}
这种方式适合定义主题令牌,比要求宿主深入修改内部 DOM 更稳定。
3. ::part() 暴露有限的样式接口
组件内部:
<button part="action">保存</button>
宿主页面:
hello-card::part(action) {
border-radius: 999px;
}
part 是显式暴露的样式 API。组件不应把所有内部节点都暴露为 part,否则内部结构变化会变成宿主页面的破坏性变更。
4. Shadow DOM 不会自动隔离所有资源
Shadow DOM 主要隔离 DOM 查询和 CSS 选择器,不能自动解决:
- 全局字体加载;
- 图片和网络请求;
- JavaScript 全局变量;
- 事件数据中的敏感信息;
- 组件自身的副作用清理。
例如组件在 connectedCallback() 中注册:
window.addEventListener('resize', this.handleResize)
就必须在 disconnectedCallback() 中解除:
window.removeEventListener('resize', this.handleResize)
否则组件反复挂载和卸载时会产生内存泄漏或重复执行。
六、在 Vue 中使用原生 Web Component
1. Vite 中配置自定义元素识别
Vue 模板编译器默认可能把未知标签当作 Vue 组件。对于原生 Web Component,建议在 vite.config.ts 中告诉 Vue:带有特定名称的标签是原生 Custom Element。
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag.startsWith('hello-')
}
}
})
]
})
这项配置只影响 Vue 模板编译器,不会自动注册元素。仍然需要导入定义文件:
// src/main.ts
import './hello-card'
如果元素未注册,浏览器仍可能先把它当作普通未知 HTML 元素,导致内部行为没有初始化。
2. Vue 绑定 attribute 和 property
使用前面的 <hello-card>:
<script setup lang="ts">
import { ref } from 'vue'
const name = ref('Ada')
const disabled = ref(false)
function handleSave(event: Event) {
const customEvent = event as CustomEvent<{ name: string }>
console.log('保存:', customEvent.detail.name)
}
</script>
<template>
<input v-model="name" />
<label>
<input v-model="disabled" type="checkbox" />
禁用
</label>
<hello-card
:name="name"
:disabled="disabled"
@save="handleSave"
/>
</template>
这里:
:name="name"传递字符串;:disabled="disabled"传递布尔值;@save监听 DOM 自定义事件;- 业务数据从
event.detail读取。
对于复杂对象,使用 property 绑定更明确:
<user-panel :user.prop="user" />
其中 .prop 明确要求 Vue 设置 DOM property,而不是把对象序列化为 attribute。
也可以使用动态绑定对象:
<user-panel v-bind.prop="{ user, permissions }" />
.prop 的含义是“把绑定值写入元素对象属性”。但前提是 Web Component 确实实现了相应 property;否则宿主和组件之间仍然没有共享状态。
3. TypeScript 对自定义元素的类型声明
Vue 模板能够运行,不代表 TypeScript 已经知道自定义元素的类型。可以为全局元素补充声明:
// src/custom-elements.d.ts
export {}
declare global {
interface HTMLElementTagNameMap {
'hello-card': HelloCardElement
}
interface HelloCardElement extends HTMLElement {
name: string
disabled: boolean
}
}
在需要获取元素引用时:
const card = document.querySelector('hello-card')
if (card) {
card.name = 'Grace'
card.disabled = true
}
如果声明文件没有被 TypeScript 的 include 范围包含,类型不会生效。Vite 的运行时也不会因为类型声明而自动注册元素,这两件事必须分别完成。
七、把 Vue 组件编译成 Custom Element
Vue 3 提供 defineCustomElement(),可以把 Vue 组件定义为原生 Custom Element。
下面是一个使用 Composition API 和 TypeScript 的计数器。
// src/vue-counter.ts
import { defineCustomElement, h, ref, watch } from 'vue'
const VueCounter = defineCustomElement({
props: {
value: {
type: Number,
default: 0
},
step: {
type: Number,
default: 1
}
},
emits: ['change'],
setup(props, { emit }) {
const count = ref(props.value)
watch(
() => props.value,
(nextValue) => {
count.value = nextValue
}
)
function increment() {
count.value += props.step
emit('change', count.value)
}
return () =>
h(
'button',
{
type: 'button',
onClick: increment
},
`当前值:${count.value}`
)
},
styles: [`
:host {
display: inline-block;
}
button {
border: 1px solid #42b883;
border-radius: 6px;
background: white;
color: #26734d;
cursor: pointer;
padding: 6px 12px;
}
`]
})
if (!customElements.get('vue-counter')) {
customElements.define('vue-counter', VueCounter)
}
入口文件:
// src/main.ts
import './vue-counter'
宿主页面可以不使用 Vue:
<vue-counter value="2" step="3"></vue-counter>
<script type="module">
const counter = document.querySelector('vue-counter')
counter.addEventListener('change', (event) => {
console.log(event.detail)
})
</script>
这里有一个版本和实现相关的重点:Vue Custom Element 的 props 来自 HTML attribute 时,值首先经过 Vue 的 prop 类型转换;事件则由 Vue 组件的 emit() 转换成 CustomEvent。
对于 emit('change', count.value),在原生监听器中应检查实际的 event.detail 结构。Vue 3 的 Custom Element 运行时通常将 emit 参数放入 detail 数组,因此常见结果是:
const event = event as CustomEvent<[number]>
const value = event.detail[0]
为了避免宿主依赖不清晰的 payload 结构,可以在组件库文档中明确事件契约,并通过实际运行测试确认:
counter.addEventListener('change', (event) => {
console.log(event.detail)
})
不要把 Vue 普通组件的事件处理方式直接套到原生监听器上。Vue 内部组件通常可以写:
<Counter @change="value = $event" />
但原生 DOM 监听器收到的是 CustomEvent,必须访问 event.detail。
Vue Custom Element 的生命周期对应关系
Vue Custom Element 被插入文档后,内部 Vue 应用会挂载;被移除后,Vue 会卸载内部组件。大致对应:
connectedCallback()
↓
Vue setup()
↓
onMounted()
disconnectedCallback()
↓
Vue 卸载
↓
onBeforeUnmount()
↓
onUnmounted()
如果组件内部使用 Vue 生命周期清理副作用:
import { onMounted, onUnmounted } from 'vue'
setup() {
const handleResize = () => {
// ...
}
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
}
则应保证监听器引用相同。下面的写法无法清理:
window.addEventListener('resize', () => {})
window.removeEventListener('resize', () => {})
因为两次传入的是两个不同的函数对象。
八、Vue Custom Element 中的属性更新
考虑宿主代码:
<script setup lang="ts">
import { ref } from 'vue'
const value = ref(1)
</script>
<template>
<vue-counter :value="value" />
<button @click="value++">外部增加</button>
</template>
状态变化过程是:
value.value 改变
↓
Vue 重新渲染宿主模板
↓
更新 <vue-counter> 的 attribute 或 property
↓
Vue Custom Element 接收新的 prop
↓
watch(() => props.value) 执行
↓
内部 count 更新
↓
Custom Element 内部重新渲染
如果组件内部只写:
const count = ref(props.value)
而没有 watch,那么 count 只会读取初始化值。宿主后续修改 value 时,props.value 会变化,但 count 不会自动同步。
这是一个常见的“初始化数据被误认为响应式双向同步”的错误:
const localValue = ref(props.value) // 只建立一次初始复制
如果确实需要本地可编辑状态,必须明确同步策略:
const localValue = ref(props.value)
watch(
() => props.value,
(newValue) => {
localValue.value = newValue
}
)
同时还要决定本地修改是否通过事件通知宿主:
localValue.value++
emit('update:value', localValue.value)
否则就会出现两套状态:
宿主 value = 10
组件内部 localValue = 11
此时任何一方都不能假设另一方已经知道变化。
九、插槽与内容边界
原生 Web Component 使用 <slot> 接收 Light DOM 内容:
class NoticeBox extends HTMLElement {
constructor() {
super()
const shadow = this.attachShadow({ mode: 'open' })
shadow.innerHTML = `
<style>
:host {
display: block;
padding: 12px;
border: 1px solid #ddd;
}
</style>
<strong><slot name="title">提示</slot></strong>
<div><slot></slot></div>
`
}
}
customElements.define('notice-box', NoticeBox)
使用:
<notice-box>
<span slot="title">保存结果</span>
内容已经保存。
</notice-box>
Vue 使用原生 Web Component 时,可以传递普通插槽内容:
<notice-box>
<template #title>保存结果</template>
内容已经保存。
</notice-box>
但这里要区分 Vue 插槽和原生 slot:
- Vue 组件的插槽是 Vue 渲染函数和作用域机制;
- Web Component 的
<slot>是浏览器 Shadow DOM 分发机制; - 复杂的 Vue 插槽作用域、动态组件和事件上下文不能直接等价映射到任意原生 Web Component。
如果设计跨框架组件,通常应优先使用明确的 attribute、property 和 CustomEvent API,而不是依赖复杂的插槽语义。
十、Vue 项目中常见的失败表现与诊断路径
1. 标签能显示,但组件没有行为
表现:
<hello-card></hello-card>
页面上出现标签,但没有 Shadow DOM 或点击逻辑。
诊断顺序:
customElements.get('hello-card')
如果返回 undefined,说明定义文件没有执行,通常是:
- 没有导入
./hello-card; - 入口文件没有被当前页面加载;
- 自定义元素注册代码因条件或异常没有执行;
- 元素名称拼写不一致。
2. Vue 把原生元素当成 Vue 组件
可能出现未知组件警告,或模板编译结果不符合预期。
检查 vite.config.ts:
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag.startsWith('hello-')
}
}
})
配置函数必须覆盖实际标签名称。hello-card 和 helloCard 不是同一个标签。
3. 对象传入后组件只收到字符串
错误示例:
<data-panel :data="record" />
如果 Vue 最终把值写成 attribute,组件收到的可能是字符串 "[object Object]",而不是对象。
可以显式使用 property:
<data-panel :data.prop="record" />
同时确认 Custom Element 已实现:
get data() {
return this._data
}
set data(value) {
this._data = value
this.render()
}
4. 监听器执行了,但 payload 是 undefined
错误:
function onSave(event: Event) {
console.log((event as any).id)
}
正确:
function onSave(event: Event) {
const customEvent = event as CustomEvent<{ name: string }>
console.log(customEvent.detail.name)
}
如果使用 defineCustomElement() 生成的事件,还应确认 detail 是单值还是参数数组。不同组件的事件契约可能不同,不能只依据事件名推断结构。
5. 外部 CSS 无法修改组件内部按钮
如果组件使用了 Shadow DOM,下面的规则通常无效:
hello-card button {
color: red;
}
应使用组件暴露的样式 API:
hello-card {
--card-accent: red;
}
或:
hello-card::part(action) {
color: red;
}
如果组件没有提供 CSS 自定义属性或 part,宿主不应通过访问内部 Shadow Root 的实现细节来强行修改,因为这会形成脆弱的内部耦合。
十一、浏览器升级时的 property 时序问题
自定义元素可能先被页面创建,再执行 customElements.define()。这种情况称为元素升级前状态:
<user-panel></user-panel>
<script type="module">
const panel = document.querySelector('user-panel')
// 此时 user-panel 可能还没有升级为自定义元素。
panel.user = { id: 1 }
</script>
如果 user property 在升级前被写入,组件定义后可能需要主动接管这个值。常见的兼容写法是在 connectedCallback() 中读取并删除临时 own property,再交给类的 setter:
class UserPanel extends HTMLElement {
user: unknown
connectedCallback() {
const pendingUser = this.user
if (Object.prototype.hasOwnProperty.call(this, 'user')) {
delete (this as Record<string, unknown>).user
this.user = pendingUser
}
this.render()
}
private render() {
// ...
}
}
工程上更简单的做法是:先加载并注册 Custom Element,再创建或挂载宿主应用。Vue 中通常把定义文件放在应用入口的早期导入位置:
import './user-panel'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
这不能替代组件自身对升级时序的健壮处理,但能减少常见的初始化竞态。
十二、什么时候用原生 Web Component,什么时候用 Vue 组件
如果组件只服务于一个 Vue 应用,普通 Vue 组件通常更直接:
- 可以直接使用 Vue 的类型推导;
- props、emits、slots 与响应式系统一致;
- 调试工具和状态流更自然;
- 不需要处理 attribute/property 转换。
如果组件需要被多个框架或非框架页面使用,Custom Element 更适合:
- 宿主只需要加载 JavaScript;
- API 可以通过 HTML attribute、DOM property 和 CustomEvent 表达;
- 样式可以通过 Shadow DOM、CSS 自定义属性和
::part()管理; - 组件生命周期由浏览器标准触发。
但跨框架发布会增加明确的 API 设计成本。一个稳定的 Custom Element API 至少应定义:
输入:
- 哪些值是 attribute
- 哪些值必须使用 property
- 布尔属性如何表示
- 对象和数组如何传递
- 默认值和非法值如何处理
输出:
- 事件名称
- event.detail 的精确类型
- 是否冒泡
- 是否跨 Shadow DOM
- 成功、失败和取消如何区分
样式:
- 哪些 CSS 变量是公开 API
- 哪些 part 名称稳定
- 宿主能否覆盖尺寸、颜色和字体
如果这些契约没有写清楚,组件虽然“能嵌入”,但宿主只能依赖内部实现,后续升级就容易产生隐蔽破坏。
十三、核心边界总结
Vue 与 Web Components 的互操作可以归纳为四条规则:
- Custom Element 是浏览器识别的自定义 HTML 元素,必须通过
customElements.define()注册,名称必须包含连字符。 - attribute 是字符串形式的标记数据,property 是 JavaScript 运行时数据。复杂对象、数组和函数通常应通过 property 传递。
- CustomEvent 的业务数据位于
event.detail;是否能冒泡和穿过 Shadow DOM,分别由bubbles与composed控制。 - Shadow DOM 隔离内部 DOM 和大部分 CSS 选择器,跨边界通信应使用明确的事件、CSS 自定义属性、
::part()和公开 property。
在 Vue 3 中,原生 Web Component 需要被正确注册,并通过 isCustomElement 告诉模板编译器如何处理。使用 defineCustomElement() 时,Vue 组件的 props、emits 和生命周期会被映射到 Custom Element,但事件 payload、property 绑定和样式边界仍然必须按照 DOM 与 Web Components 的规则验证,而不能完全套用 Vue 内部组件的直觉。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue Teleport:层级、事件、SSR、可访问性和弹窗架构
- 下一篇:Vue 文件上传:选择、拖拽、分片、进度、取消和重试
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论