一句话结论:
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 有两个细节:
- 属性不存在时返回
false; :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 开销,也无法保证时序。
总结
attrs是普通对象,不是响应式,computed和watch读它都不会因属性变化更新。- 能改组件就用自定义 prop,能改页面就用
provide/inject;两者都不行时,用MutationObserver观察 DOM 属性。 - 用 observer 时注意共享、清理、
'false'字符串、Boolean prop 默认值和 SSR 边界。
npm:https://www.npmjs.com/package/ep-field-view
验证版本:Vue 3.5.x / Element Plus 2.14.x