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 的页面加载。

真正的互操作问题通常不在“能不能显示”,而在于以下边界:

  1. 字符串属性和 JavaScript 属性如何传递;
  2. CustomEventdetail、冒泡和 Shadow DOM 穿透如何处理;
  3. Shadow DOM 如何隔离样式;
  4. Vue 的响应式更新如何进入 Web Component;
  5. Web Component 的生命周期和清理逻辑如何与 Vue 生命周期对应;
  6. 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>

这里的 titlesave 和组件实例都由 Vue 解释。浏览器本身并不知道 propsemits 的含义。

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 是事件携带的数据。CustomEventdetail 可以是字符串、对象、数组或其他 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>

这种方式有三个限制:

  1. 对象必须能被 JSON 序列化;
  2. 每次变化都要重新生成字符串;
  3. 组件必须捕获 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. bubblescomposed 是两个不同开关

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-cardhelloCard 不是同一个标签。

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 的互操作可以归纳为四条规则:

  1. Custom Element 是浏览器识别的自定义 HTML 元素,必须通过 customElements.define() 注册,名称必须包含连字符。
  2. attribute 是字符串形式的标记数据,property 是 JavaScript 运行时数据。复杂对象、数组和函数通常应通过 property 传递。
  3. CustomEvent 的业务数据位于 event.detail;是否能冒泡和穿过 Shadow DOM,分别由 bubblescomposed 控制。
  4. 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、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。