(二)Vue 3 的 attrs 不是响应式:el-form 扩展属性同步的三种方案

一句话结论:attrs 是普通对象,computed 不会因为属性变化而失效;要么改成 prop,要么把状态放进响应式上下文,要么用 MutationObserver 观察 DOM 属性。

现象:computed 读 attrs 不更新

先看一个很常见的需求:给 el-form 传一个它没有声明的属性,子组件根据这个属性切换展示形态。

html 复制代码
<!-- App.vue -->
<template>
  <el-form ref="formRef" :model="model" :text="textMode">
    <FieldText prop="name" />
  </el-form>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElForm } from 'element-plus'
import FieldText from './FieldText.vue'

const formRef = ref<InstanceType<typeof ElForm>>()
const textMode = ref(false)
const model = reactive({ name: '张三' })
</script>

子组件想读表单的 text 属性:

html 复制代码
<!-- FieldText.vue -->
<script setup lang="ts">
import { computed, getCurrentInstance } from 'vue'

const props = defineProps<{ prop: string }>()
const instance = getCurrentInstance()

// 向上找 el-form 实例
let parent = instance?.parent
while (parent && (parent.type as { name?: string }).name !== 'ElForm') {
  parent = parent.parent
}

const isText = computed(() => parent?.attrs.text === true)
</script>

<template>
  <span v-if="isText">{{ props.prop }}</span>
  <el-input v-else :model-value="props.prop" />
</template>

切换 textMode,页面没有任何变化。isText 仍然是 false。

这不是 el-form 的问题,也不是 Vue 的 bug,而是 attrs 本身就不是响应式对象。

最小复现:不用 Element Plus 也能复现

用原生 <form> 和一个子组件就能复现,不需要任何组件库:

html 复制代码
<!-- App.vue -->
<template>
  <form ref="formEl" :text="textMode ? 'true' : 'false'">
    <FieldText :form-el="formEl" :model="model" prop="name" />
  </form>

  <button @click="textMode = !textMode">切换</button>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue'
import FieldText from './FieldText.vue'

const formEl = ref<HTMLFormElement>()
const textMode = ref(false)
const model = reactive({ name: '张三' })
</script>
html 复制代码
<!-- FieldText.vue -->
<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps<{
  formEl?: HTMLFormElement
  model: Record<string, unknown>
  prop: string
}>()

const isText = computed(() => props.formEl?.getAttribute('text') === 'true')
</script>

<template>
  <span v-if="isText">{{ props.model[props.prop] }}</span>
  <input v-else v-model="props.model[props.prop]" />
</template>

点击按钮后,<form> 的 text 属性确实从 "false" 变成了 "true",但 isText 并不会重新计算。

原因:

  • props.formEl 是普通 DOM 引用,不是响应式对象;
  • getAttribute 不是响应式 API;
  • computed 没有收集到任何响应式依赖,只在首次求值时执行一次。

为什么会这样

props 和 attrs 的区别

Vue 组件接收两类外部数据:

  • props:组件声明过的属性,是响应式的;
  • attrs:没有声明为 props 的属性,包括 class、style、事件监听和透传属性。
ts 复制代码
const props = defineProps<{ model: object }>()
const attrs = useAttrs()

props.model 变化会触发组件更新;attrs.text 变化不会。

attrs 是普通对象

Vue 内部把未声明的属性放在 instance.attrs 上,它就是一个普通对象:

ts 复制代码
// 简化示意
instance.attrs = {
  text: 'true',
  class: 'my-form'
}

它没有经过 reactive() 或 ref() 包装,也没有依赖收集。computed 读取它时,Vue 并不知道这个值未来会变。

fallthrough attrs 会渲染到根元素

el-form 没有声明 text prop,所以 :text="textMode" 会作为 fallthrough attribute 渲染到表单根元素:

html 复制代码
<form class="el-form" text="true">...</form>

DOM 属性确实变了,但 Vue 不会因为 DOM 属性变化而去更新 attrs 对象。

watch 也不行

ts 复制代码
watch(
  () => parent?.attrs.text,
  (value) => console.log(value)
)

watch 的 getter 读取的同样是普通对象,没有响应式依赖,回调不会触发。

把上面的现象串起来,就是一条「属性变了但视图不动」的链路:
#mermaid-svg-6pwPp53y0J4l1ot5{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6pwPp53y0J4l1ot5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6pwPp53y0J4l1ot5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6pwPp53y0J4l1ot5 .error-icon{fill:#552222;}#mermaid-svg-6pwPp53y0J4l1ot5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6pwPp53y0J4l1ot5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6pwPp53y0J4l1ot5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6pwPp53y0J4l1ot5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6pwPp53y0J4l1ot5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6pwPp53y0J4l1ot5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6pwPp53y0J4l1ot5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6pwPp53y0J4l1ot5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6pwPp53y0J4l1ot5 .marker.cross{stroke:#333333;}#mermaid-svg-6pwPp53y0J4l1ot5 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6pwPp53y0J4l1ot5 p{margin:0;}#mermaid-svg-6pwPp53y0J4l1ot5 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6pwPp53y0J4l1ot5 .cluster-label text{fill:#333;}#mermaid-svg-6pwPp53y0J4l1ot5 .cluster-label span{color:#333;}#mermaid-svg-6pwPp53y0J4l1ot5 .cluster-label span p{background-color:transparent;}#mermaid-svg-6pwPp53y0J4l1ot5 .label text,#mermaid-svg-6pwPp53y0J4l1ot5 span{fill:#333;color:#333;}#mermaid-svg-6pwPp53y0J4l1ot5 .node rect,#mermaid-svg-6pwPp53y0J4l1ot5 .node circle,#mermaid-svg-6pwPp53y0J4l1ot5 .node ellipse,#mermaid-svg-6pwPp53y0J4l1ot5 .node polygon,#mermaid-svg-6pwPp53y0J4l1ot5 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6pwPp53y0J4l1ot5 .rough-node .label text,#mermaid-svg-6pwPp53y0J4l1ot5 .node .label text,#mermaid-svg-6pwPp53y0J4l1ot5 .image-shape .label,#mermaid-svg-6pwPp53y0J4l1ot5 .icon-shape .label{text-anchor:middle;}#mermaid-svg-6pwPp53y0J4l1ot5 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6pwPp53y0J4l1ot5 .rough-node .label,#mermaid-svg-6pwPp53y0J4l1ot5 .node .label,#mermaid-svg-6pwPp53y0J4l1ot5 .image-shape .label,#mermaid-svg-6pwPp53y0J4l1ot5 .icon-shape .label{text-align:center;}#mermaid-svg-6pwPp53y0J4l1ot5 .node.clickable{cursor:pointer;}#mermaid-svg-6pwPp53y0J4l1ot5 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6pwPp53y0J4l1ot5 .arrowheadPath{fill:#333333;}#mermaid-svg-6pwPp53y0J4l1ot5 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6pwPp53y0J4l1ot5 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6pwPp53y0J4l1ot5 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6pwPp53y0J4l1ot5 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6pwPp53y0J4l1ot5 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6pwPp53y0J4l1ot5 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6pwPp53y0J4l1ot5 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6pwPp53y0J4l1ot5 .cluster text{fill:#333;}#mermaid-svg-6pwPp53y0J4l1ot5 .cluster span{color:#333;}#mermaid-svg-6pwPp53y0J4l1ot5 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-6pwPp53y0J4l1ot5 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6pwPp53y0J4l1ot5 rect.text{fill:none;stroke-width:0;}#mermaid-svg-6pwPp53y0J4l1ot5 .icon-shape,#mermaid-svg-6pwPp53y0J4l1ot5 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6pwPp53y0J4l1ot5 .icon-shape p,#mermaid-svg-6pwPp53y0J4l1ot5 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6pwPp53y0J4l1ot5 .icon-shape .label rect,#mermaid-svg-6pwPp53y0J4l1ot5 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6pwPp53y0J4l1ot5 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6pwPp53y0J4l1ot5 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6pwPp53y0J4l1ot5 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 父组件 :text 变化
el-form 未声明 text prop
作为 fallthrough attr 渲染到根元素
DOM 属性 text 已更新
instance.attrs 普通对象
computed / watch 读取 attrs
无响应式依赖,不重新求值
视图不更新

三种可行方案

方案 做法 优点 代价
自定义 prop 包一层表单组件,声明 text prop 响应式、类型安全 需要改表单层
provide/inject 自己维护响应式状态 不依赖 DOM,SSR 友好 需要改表单层,跨组件树
MutationObserver 观察表单根元素的属性 不改现有组件 有 observer 开销,SSR 不生效

方案 A:自定义 prop

最简单的方式是包一层表单组件,把 text 声明成真正的 prop:

html 复制代码
<!-- AppForm.vue -->
<script setup lang="ts">
import { computed, provide } from 'vue'

const props = defineProps<{
  model: Record<string, unknown>
  text?: boolean
}>()

provide(
  'formText',
  computed(() => props.text ?? false)
)
</script>

<template>
  <el-form :model="props.model" :text="props.text">
    <slot />
  </el-form>
</template>

子组件:

ts 复制代码
import { computed, inject } from 'vue'
import type { ComputedRef } from 'vue'

const isText = inject<ComputedRef<boolean>>(
  'formText',
  computed(() => false)
)

优点:

  • 完全响应式;
  • 类型明确;
  • SSR 首屏一致;
  • 不依赖 DOM。

缺点:

  • 所有使用表单的地方都要换成 AppForm;
  • 如果表单来自第三方或已经有大量实例,改造成本高。

方案 B:provide/inject

如果不方便替换 el-form,可以在页面层提供响应式状态:

html 复制代码
<!-- Page.vue -->
<script setup lang="ts">
import { provide, reactive, ref } from 'vue'

const textMode = ref(false)
const model = reactive({ name: '张三' })

provide('formText', textMode)
</script>

<template>
  <el-form :model="model" :text="textMode">
    <FieldText prop="name" />
  </el-form>
</template>
ts 复制代码
// FieldText.vue
import { computed, inject, ref } from 'vue'

const formText = inject<Ref<boolean>>('formText', ref(false))
const isText = computed(() => formText.value)

这本质上和方案 A 一样:把状态从 attrs 搬到响应式上下文里。

优点:

  • 不依赖 DOM;
  • 可以跨多层组件;
  • SSR 可以直接用初始值。

缺点:

  • 每个页面都要记得 provide;
  • 组件库内部无法自动拿到,需要宿主配合;
  • 如果同一个页面有多个表单,需要不同的 key 或作用域。

方案 C:MutationObserver

如果不能改表单层,也不想要求每个页面 provide,可以观察表单根元素的属性变化:

ts 复制代码
import { onBeforeUnmount, onMounted, ref } from 'vue'

export function useFormText(getFormEl: () => Element | null | undefined) {
  const formText = ref(false)
  let stop: (() => void) | undefined

  const sync = () => {
    formText.value = readFormText(getFormEl())
  }

  onMounted(() => {
    sync()
    stop = observeFormText(getFormEl(), sync)
  })

  onBeforeUnmount(() => stop?.())

  return formText
}

function readFormText(element: Element | null | undefined): boolean {
  const raw = element?.getAttribute('text')
  return raw !== null && raw !== undefined && raw !== 'false'
}

readFormText 有两个细节:

  1. 属性不存在时返回 false;
  2. :text="false" 在 DOM 上是字符串 "false",不能只判断 hasAttribute。

使用:

html 复制代码
<script setup lang="ts">
import { ref } from 'vue'
import { ElForm } from 'element-plus'
import { useFormText } from './useFormText'

const formRef = ref<InstanceType<typeof ElForm>>()
const formText = useFormText(() => formRef.value?.$el as Element | undefined)
</script>

<template>
  <el-form ref="formRef" :model="model" :text="textMode">
    <span v-if="formText">文本态</span>
  </el-form>
</template>

注意:el-form 的 ref 拿到的是组件实例,需要 $el 才是 DOM 元素。

三种方案的选型可以按下面的决策路径走:
#mermaid-svg-MkJtApoWkWQGsOo6{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-MkJtApoWkWQGsOo6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-MkJtApoWkWQGsOo6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-MkJtApoWkWQGsOo6 .error-icon{fill:#552222;}#mermaid-svg-MkJtApoWkWQGsOo6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-MkJtApoWkWQGsOo6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-MkJtApoWkWQGsOo6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-MkJtApoWkWQGsOo6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-MkJtApoWkWQGsOo6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-MkJtApoWkWQGsOo6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-MkJtApoWkWQGsOo6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-MkJtApoWkWQGsOo6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-MkJtApoWkWQGsOo6 .marker.cross{stroke:#333333;}#mermaid-svg-MkJtApoWkWQGsOo6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-MkJtApoWkWQGsOo6 p{margin:0;}#mermaid-svg-MkJtApoWkWQGsOo6 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-MkJtApoWkWQGsOo6 .cluster-label text{fill:#333;}#mermaid-svg-MkJtApoWkWQGsOo6 .cluster-label span{color:#333;}#mermaid-svg-MkJtApoWkWQGsOo6 .cluster-label span p{background-color:transparent;}#mermaid-svg-MkJtApoWkWQGsOo6 .label text,#mermaid-svg-MkJtApoWkWQGsOo6 span{fill:#333;color:#333;}#mermaid-svg-MkJtApoWkWQGsOo6 .node rect,#mermaid-svg-MkJtApoWkWQGsOo6 .node circle,#mermaid-svg-MkJtApoWkWQGsOo6 .node ellipse,#mermaid-svg-MkJtApoWkWQGsOo6 .node polygon,#mermaid-svg-MkJtApoWkWQGsOo6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-MkJtApoWkWQGsOo6 .rough-node .label text,#mermaid-svg-MkJtApoWkWQGsOo6 .node .label text,#mermaid-svg-MkJtApoWkWQGsOo6 .image-shape .label,#mermaid-svg-MkJtApoWkWQGsOo6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-MkJtApoWkWQGsOo6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-MkJtApoWkWQGsOo6 .rough-node .label,#mermaid-svg-MkJtApoWkWQGsOo6 .node .label,#mermaid-svg-MkJtApoWkWQGsOo6 .image-shape .label,#mermaid-svg-MkJtApoWkWQGsOo6 .icon-shape .label{text-align:center;}#mermaid-svg-MkJtApoWkWQGsOo6 .node.clickable{cursor:pointer;}#mermaid-svg-MkJtApoWkWQGsOo6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-MkJtApoWkWQGsOo6 .arrowheadPath{fill:#333333;}#mermaid-svg-MkJtApoWkWQGsOo6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-MkJtApoWkWQGsOo6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-MkJtApoWkWQGsOo6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MkJtApoWkWQGsOo6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-MkJtApoWkWQGsOo6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MkJtApoWkWQGsOo6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-MkJtApoWkWQGsOo6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-MkJtApoWkWQGsOo6 .cluster text{fill:#333;}#mermaid-svg-MkJtApoWkWQGsOo6 .cluster span{color:#333;}#mermaid-svg-MkJtApoWkWQGsOo6 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-MkJtApoWkWQGsOo6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-MkJtApoWkWQGsOo6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-MkJtApoWkWQGsOo6 .icon-shape,#mermaid-svg-MkJtApoWkWQGsOo6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MkJtApoWkWQGsOo6 .icon-shape p,#mermaid-svg-MkJtApoWkWQGsOo6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-MkJtApoWkWQGsOo6 .icon-shape .label rect,#mermaid-svg-MkJtApoWkWQGsOo6 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MkJtApoWkWQGsOo6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-MkJtApoWkWQGsOo6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-MkJtApoWkWQGsOo6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 能
是
否,页面层可控
不能,第三方表单
低频
高频
需要子组件感知表单扩展属性
能改表单层吗
需要 SSR 首屏一致 / 类型安全
方案 A:自定义 prop
方案 B:provide/inject
属性变化频率
方案 C:MutationObserver
改用响应式状态(A/B)

共享 observer:不要在长表单里每个字段建一个

最朴素的实现是每个字段一个 MutationObserver:

ts 复制代码
const observer = new MutationObserver(() => {
  formText.value = readFormText(el)
})
observer.observe(el, { attributes: true, attributeFilter: ['text'] })

100 个字段就是 100 个 observer。属性变化频率不高时还能接受,但长表单切换时开销会成倍增加。

更好的做法是按表单元素共享:

ts 复制代码
interface Observation {
  observer: MutationObserver
  listeners: Set<() => void>
}

const observations = new WeakMap<Element, Observation>()

export function observeFormText(element: Element | null | undefined, onChange: () => void): () => void {
  if (!element || typeof MutationObserver === 'undefined') return () => {}

  let observation = observations.get(element)
  if (!observation) {
    const listeners = new Set<() => void>()
    const observer = new MutationObserver(() => {
      for (const listener of listeners) listener()
    })

    observer.observe(element, {
      attributes: true,
      attributeFilter: ['text']
    })

    observation = { observer, listeners }
    observations.set(element, observation)
  }

  observation.listeners.add(onChange)

  return () => {
    observation!.listeners.delete(onChange)
    if (observation!.listeners.size === 0) {
      observation!.observer.disconnect()
      observations.delete(element)
    }
  }
}

关键点:

  • WeakMap 不阻止元素被垃圾回收;
  • 同一个表单只保留一个 MutationObserver;
  • 属性变化时统一通知所有订阅者;
  • 最后一个订阅者离开时 disconnect();
  • 清理函数可重复调用。

共享 observer 的结构关系如下:
#mermaid-svg-c4ZFSH9wCE4kp4JT{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-c4ZFSH9wCE4kp4JT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-c4ZFSH9wCE4kp4JT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-c4ZFSH9wCE4kp4JT .error-icon{fill:#552222;}#mermaid-svg-c4ZFSH9wCE4kp4JT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-c4ZFSH9wCE4kp4JT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-c4ZFSH9wCE4kp4JT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-c4ZFSH9wCE4kp4JT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-c4ZFSH9wCE4kp4JT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-c4ZFSH9wCE4kp4JT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-c4ZFSH9wCE4kp4JT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-c4ZFSH9wCE4kp4JT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-c4ZFSH9wCE4kp4JT .marker.cross{stroke:#333333;}#mermaid-svg-c4ZFSH9wCE4kp4JT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-c4ZFSH9wCE4kp4JT p{margin:0;}#mermaid-svg-c4ZFSH9wCE4kp4JT .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-c4ZFSH9wCE4kp4JT .cluster-label text{fill:#333;}#mermaid-svg-c4ZFSH9wCE4kp4JT .cluster-label span{color:#333;}#mermaid-svg-c4ZFSH9wCE4kp4JT .cluster-label span p{background-color:transparent;}#mermaid-svg-c4ZFSH9wCE4kp4JT .label text,#mermaid-svg-c4ZFSH9wCE4kp4JT span{fill:#333;color:#333;}#mermaid-svg-c4ZFSH9wCE4kp4JT .node rect,#mermaid-svg-c4ZFSH9wCE4kp4JT .node circle,#mermaid-svg-c4ZFSH9wCE4kp4JT .node ellipse,#mermaid-svg-c4ZFSH9wCE4kp4JT .node polygon,#mermaid-svg-c4ZFSH9wCE4kp4JT .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-c4ZFSH9wCE4kp4JT .rough-node .label text,#mermaid-svg-c4ZFSH9wCE4kp4JT .node .label text,#mermaid-svg-c4ZFSH9wCE4kp4JT .image-shape .label,#mermaid-svg-c4ZFSH9wCE4kp4JT .icon-shape .label{text-anchor:middle;}#mermaid-svg-c4ZFSH9wCE4kp4JT .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-c4ZFSH9wCE4kp4JT .rough-node .label,#mermaid-svg-c4ZFSH9wCE4kp4JT .node .label,#mermaid-svg-c4ZFSH9wCE4kp4JT .image-shape .label,#mermaid-svg-c4ZFSH9wCE4kp4JT .icon-shape .label{text-align:center;}#mermaid-svg-c4ZFSH9wCE4kp4JT .node.clickable{cursor:pointer;}#mermaid-svg-c4ZFSH9wCE4kp4JT .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-c4ZFSH9wCE4kp4JT .arrowheadPath{fill:#333333;}#mermaid-svg-c4ZFSH9wCE4kp4JT .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-c4ZFSH9wCE4kp4JT .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-c4ZFSH9wCE4kp4JT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-c4ZFSH9wCE4kp4JT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-c4ZFSH9wCE4kp4JT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-c4ZFSH9wCE4kp4JT .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-c4ZFSH9wCE4kp4JT .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-c4ZFSH9wCE4kp4JT .cluster text{fill:#333;}#mermaid-svg-c4ZFSH9wCE4kp4JT .cluster span{color:#333;}#mermaid-svg-c4ZFSH9wCE4kp4JT div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-c4ZFSH9wCE4kp4JT .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-c4ZFSH9wCE4kp4JT rect.text{fill:none;stroke-width:0;}#mermaid-svg-c4ZFSH9wCE4kp4JT .icon-shape,#mermaid-svg-c4ZFSH9wCE4kp4JT .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-c4ZFSH9wCE4kp4JT .icon-shape p,#mermaid-svg-c4ZFSH9wCE4kp4JT .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-c4ZFSH9wCE4kp4JT .icon-shape .label rect,#mermaid-svg-c4ZFSH9wCE4kp4JT .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-c4ZFSH9wCE4kp4JT .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-c4ZFSH9wCE4kp4JT .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-c4ZFSH9wCE4kp4JT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 共享层
observeFormText
observeFormText
observeFormText
attributes 变化
统一通知
WeakMap<Element, Observation>
MutationObserver(每个表单一个)
listeners: Set<回调>
字段组件 1
字段组件 2
字段组件 N

实际踩坑

1. Boolean prop 未传会被转成 false

如果组件这样声明:

ts 复制代码
const props = defineProps<{ text?: boolean }>()

父组件不传 text 时,props.text 是 false,不是 undefined。这会导致子组件无法区分「没传」和「显式传 false」。

解决:

ts 复制代码
const props = withDefaults(defineProps<{ text?: boolean }>(), {
  text: undefined
})

2. DOM 属性是字符串

html 复制代码
<el-form :text="false"></el-form>

渲染结果是:

html 复制代码
<form text="false">...</form>

所以:

ts 复制代码
// 错误:只要属性存在就认为是 true
const isText = el.hasAttribute('text')

// 正确:判断字符串值
const raw = el.getAttribute('text')
const isText = raw !== null && raw !== 'false'

3. 表单根元素不一定是 form

el-form 的根元素是 <form>,但自定义表单组件可能是 <div>。观察前要确认:

  • 元素存在;
  • 属性确实渲染在根元素上;
  • 组件没有设置 inheritAttrs: false 把属性挪到别处。

4. 生命周期和 keep-alive

  • 在 onMounted 后拿元素,不要在 setup 里直接读;
  • 在 onBeforeUnmount 清理;
  • 组件被 keep-alive 缓存时,onActivated 后重新读取一次;
  • 表单元素被替换时,旧 observer 要断开。

5. SSR 没有 MutationObserver

Node.js 环境没有 MutationObserver:

ts 复制代码
if (typeof MutationObserver === 'undefined') {
  return () => {}
}

SSR 首屏只能读取初始属性,客户端挂载后再接管。不要在 setup 里根据 window 决定文本状态,否则服务端和客户端初始值可能不同,触发 hydration mismatch。

6. 属性变化频率

MutationObserver 适合低频属性:主题、模式、尺寸、只读状态。如果属性每秒变化几十次,应该改用响应式状态(方案 A/B),否则观察回调本身会成为瓶颈。

7. 嵌套表单

WeakMap 按元素缓存,嵌套表单各自一个 observer。子组件要观察最近的表单元素,而不是 document.querySelector('.el-form')。

测试动态切换

nextTick() 只能等待 Vue 的更新队列,不一定覆盖 MutationObserver 回调。测试里通常要再等一个宏任务:

ts 复制代码
import { nextTick } from 'vue'

async function settle() {
  await nextTick()
  await new Promise((resolve) => setTimeout(resolve, 0))
}

断言:

ts 复制代码
const text = ref(false)
const wrapper = mount(Component, {
  props: { text }
})

expect(wrapper.find('input').exists()).toBe(true)

text.value = true
await settle()

expect(wrapper.find('input').exists()).toBe(false)
expect(wrapper.text()).toContain('张三')

还需要验证卸载后不再回调:

ts 复制代码
wrapper.unmount()
text.value = false
await settle()
// 不应报错,也不应有残留更新

测试时的时序大致是这样:
"组件状态" "MutationObserver" "Vue 更新队列" "测试用例" "组件状态" "MutationObserver" "Vue 更新队列" "测试用例" #mermaid-svg-sRn1gOcnS28yUfCP{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-sRn1gOcnS28yUfCP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sRn1gOcnS28yUfCP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sRn1gOcnS28yUfCP .error-icon{fill:#552222;}#mermaid-svg-sRn1gOcnS28yUfCP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-sRn1gOcnS28yUfCP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sRn1gOcnS28yUfCP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sRn1gOcnS28yUfCP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sRn1gOcnS28yUfCP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sRn1gOcnS28yUfCP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sRn1gOcnS28yUfCP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sRn1gOcnS28yUfCP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-sRn1gOcnS28yUfCP .marker.cross{stroke:#333333;}#mermaid-svg-sRn1gOcnS28yUfCP svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-sRn1gOcnS28yUfCP p{margin:0;}#mermaid-svg-sRn1gOcnS28yUfCP .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-sRn1gOcnS28yUfCP text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-sRn1gOcnS28yUfCP .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-sRn1gOcnS28yUfCP .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-sRn1gOcnS28yUfCP .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-sRn1gOcnS28yUfCP .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-sRn1gOcnS28yUfCP #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-sRn1gOcnS28yUfCP .sequenceNumber{fill:white;}#mermaid-svg-sRn1gOcnS28yUfCP #sequencenumber{fill:#333;}#mermaid-svg-sRn1gOcnS28yUfCP #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-sRn1gOcnS28yUfCP .messageText{fill:#333;stroke:none;}#mermaid-svg-sRn1gOcnS28yUfCP .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-sRn1gOcnS28yUfCP .labelText,#mermaid-svg-sRn1gOcnS28yUfCP .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-sRn1gOcnS28yUfCP .loopText,#mermaid-svg-sRn1gOcnS28yUfCP .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-sRn1gOcnS28yUfCP .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-sRn1gOcnS28yUfCP .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-sRn1gOcnS28yUfCP .noteText,#mermaid-svg-sRn1gOcnS28yUfCP .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-sRn1gOcnS28yUfCP .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-sRn1gOcnS28yUfCP .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-sRn1gOcnS28yUfCP .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-sRn1gOcnS28yUfCP .actorPopupMenu{position:absolute;}#mermaid-svg-sRn1gOcnS28yUfCP .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-sRn1gOcnS28yUfCP .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-sRn1gOcnS28yUfCP .actor-man circle,#mermaid-svg-sRn1gOcnS28yUfCP line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-sRn1gOcnS28yUfCP :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} "修改 text 值" "nextTick 刷新 DOM" "DOM 属性变化触发回调" "同步 formText" "触发重新渲染" "await settle() 后再断言"

边界与替代方案

场景 推荐方案
能改表单层 自定义 prop 或 provide/inject
第三方表单,不能改 MutationObserver
SSR 首屏要一致 自定义 prop / provide
属性高频变化 响应式状态
需要类型安全 自定义 prop
多表单嵌套 按元素缓存 observer
只想读一次初始值 直接读 attrs / getAttribute

不建议用轮询:

ts 复制代码
// 不要这样做
setInterval(() => {
  formText.value = readFormText(el)
}, 100)

轮询会带来无谓的 CPU 开销,也无法保证时序。

总结

  1. attrs 是普通对象,不是响应式,computed 和 watch 读它都不会因属性变化更新。
  2. 能改组件就用自定义 prop,能改页面就用 provide/inject;两者都不行时,用 MutationObserver 观察 DOM 属性。
  3. 用 observer 时注意共享、清理、'false' 字符串、Boolean prop 默认值和 SSR 边界。

npm:https://www.npmjs.com/package/ep-field-view

验证版本:Vue 3.5.x / Element Plus 2.14.x

完整实现参考:https://github.com/jktantan/ep-field-view

相关推荐
前端 贾公子1 小时前
第 5 章:Codex 从可运行程序到可交付工具
java·前端·数据库
用户360055579002 小时前
Kestrel_VLLM - 26 · 发射空档三步曲:事件计时 → nsys → 判别实验
前端
echome8882 小时前
JavaScript async/await 错误处理:别让 Promise 静默失败
开发语言·前端·javascript·promise
PedroQue992 小时前
v0.2.0 架构升级:class + reactive 重塑状态管理
前端·uni-app
鶴哥只手遮天2 小时前
Angular 22 源码级深度解析-第 02 章 编译链路总览:从模板字符串到 `ɵɵ` 指令
前端·开源·angular.js
尘世中一位迷途小书童2 小时前
别再堆万字 Prompt:用 Agent Skills 给团队写一个「前端 Review」Skill
前端·ai编程·cursor
aixingpan2 小时前
aixingpan.cn API开发文档:api_docs_bichart_common2接口指南
前端·php
super_Omni2 小时前
text/PDF/Word → 解析成纯文本 → 分词 → 词表匹配 → 加权打分 → 低于阈值
前端·chrome
阶进遐2 小时前
浏览器工具自制指南·对话导出(3):消息池设计
前端