12|Vue 2 组件维护
初次接手 Vue 2,从实例与 Options API到组件通信连续学习,再回到本篇处理单文件组件和迁移切片。本篇保留为组件专题入口。
Vue 学习笔记总览 / 第一章概览 / 12。目标:先让存量组件的行为变得可观察,再决定修补、隔离还是迁移。
本页示例使用 Vue 2.7、Vue CLI 5、JavaScript SFC。在已有 Vue 2 项目中放入 src/components/LegacySearch.vue 与 src/App.vue 即可运行;先核对当前loader和编译器实际从哪里加载:Vue2.7支持自己的SFC编译入口,不要为了这篇示例额外安装旧编译器;如果现有工具链确实使用 vue-template-compiler,它必须与Vue匹配。若项目仍是 Vue 2.6、webpack 3/4 或使用旧组件库,请保留原工具链先跑行为,不要为了运行示例顺手升级全家桶。
先判定现场:能写 setup 不等于已经是 Vue 3
Vue 2.7 回移了 Composition API、<script setup> 和部分 SFC 能力,但核心仍是 Vue 2 运行时。组件 v-model 默认仍使用 value/input,$listeners 仍与 $attrs 分开,销毁钩子仍叫 beforeDestroy。因此维护工作的第一条规则是:
先从锁文件与实际构建产物确认版本,再解释代码;不要从一段相似语法反推运行时。
至少记录以下四项:
| 必查项 | 它决定什么 | 常见误判 |
|---|---|---|
vue 的锁定版本 | 响应式、组件事件与全局 API 语义 | 只看 package.json 的范围版本 |
vue-template-compiler / vue-loader | SFC 模板由哪一代编译器处理 | 运行时升级了,编译器仍停在旧代 |
| 构建工具与浏览器目标 | 可用语法、polyfill 与产物形态 | 本地新浏览器能跑就代表生产能跑 |
| 组件库、插件、mixin | 隐式注入到 this 的字段与副作用 | 把未知字段都当成组件自己的 data |
从一个故障推导维护规则
旧搜索框在正常网络下“看起来没问题”,但有四个真实输入会暴露边界:
- 用户提交全空白文本:不应发起搜索,要显示字段错误。
- 用户输入
fail:模拟请求拒绝,要显示可重试错误并恢复按钮。 - 请求尚未结束时再次提交:只能启动一次搜索。
- 输入
slow后立刻关闭组件:迟到结果不能再修改已销毁实例。
这四条把维护规则推出来了:模板只显示状态;value 仍由父级拥有;组件用 input 提议更新;异步动作必须有阶段、失败收束和销毁失效令牌。
可运行组件:守住 Vue 2 的现有合同
<!-- src/components/LegacySearch.vue -->
<script>
function searchCatalog(query) {
const delay = query === 'slow' ? 1200 : 300
return new Promise((resolve, reject) => {
window.setTimeout(() => {
if (query === 'fail') {
reject(new Error('模拟网络失败'))
return
}
resolve([`${query} 入门`, `${query} 实战`])
}, delay)
})
}
export default {
name: 'LegacySearch',
inheritAttrs: false,
props: {
value: {
type: String,
default: ''
}
},
data() {
return {
phase: 'idle',
errorMessage: '',
results: [],
requestToken: 0
}
},
computed: {
isLoading() {
return this.phase === 'loading'
},
canSubmit() {
return this.value.trim().length > 0 && !this.isLoading
}
},
beforeDestroy() {
// 让仍在等待的结果失效。真实 fetch 若支持取消,还应同时 abort。
this.requestToken += 1
},
methods: {
updateValue(event) {
this.$emit('input', event.target.value)
},
async submit() {
const query = this.value.trim()
if (!query) {
this.phase = 'error'
this.errorMessage = '请输入搜索词。'
return
}
if (this.isLoading) return
const token = ++this.requestToken
this.phase = 'loading'
this.errorMessage = ''
this.results = []
try {
const results = await searchCatalog(query)
if (token !== this.requestToken) return
this.results = results
this.phase = 'success'
} catch (error) {
if (token !== this.requestToken) return
this.phase = 'error'
this.errorMessage = error instanceof Error
? error.message
: '搜索失败,请重试。'
} finally {
// 只有当前请求可以收束当前状态;失效请求不能覆盖新状态。
if (token === this.requestToken && this.phase === 'loading') {
this.phase = 'idle'
}
}
}
}
}
</script>
<template>
<form :aria-busy="String(isLoading)" @submit.prevent="submit">
<label for="legacy-search">搜索课程</label>
<input
id="legacy-search"
:value="value"
:disabled="isLoading"
:aria-describedby="errorMessage ? 'legacy-search-error' : undefined"
v-bind="$attrs"
@input="updateValue"
>
<button :disabled="!canSubmit">
{{ isLoading ? '搜索中…' : '搜索' }}
</button>
<p v-if="errorMessage" id="legacy-search-error" role="alert">
{{ errorMessage }}
</p>
<ul v-else-if="results.length > 0">
<li v-for="result in results" :key="result">{{ result }}</li>
</ul>
<p v-else-if="phase === 'success'">没有匹配结果。</p>
</form>
</template><!-- src/App.vue -->
<script>
import LegacySearch from './components/LegacySearch.vue'
export default {
name: 'App',
components: { LegacySearch },
data() {
return {
query: '',
showSearch: true
}
}
}
</script>
<template>
<main>
<button type="button" @click="showSearch = !showSearch">
{{ showSearch ? '销毁搜索框' : '重新创建搜索框' }}
</button>
<p>父级当前值:{{ query }}</p>
<LegacySearch
v-if="showSearch"
v-model="query"
autocomplete="off"
/>
</main>
</template>这里没有直接修改 prop。输入事件沿着“原生 input → 子组件 $emit('input') → 父级 query → 子组件 value”走一圈,这就是 Vue 2 组件 v-model 的默认合同。
this 上的名字到底从哪来
Options API 的难点不是 this 本身,而是同一个命名空间可能接收多种来源:
| 读取到的名字 | 可能来源 | 审查方法 |
|---|---|---|
this.value | props | 查 prop 定义、父级传值和是否被错误修改 |
this.phase | data | 查初始类型与所有写入点 |
this.canSubmit | computed | 确认无副作用,依赖是否完整 |
this.submit() | methods | 检查早退、重复触发、失败与 finally |
this.$http 等 | prototype/plugin | 从入口插件安装处追踪,不要猜类型 |
| 普通业务名 | mixin、extends、inject | 查合并顺序、重名覆盖与销毁清理 |
遇到陌生字段时,先做来源清单,再改名或抽函数。直接把所有 this.x 机械改成 x.value,只会把隐式依赖换一种写法藏起来。
为什么四条失败路径这样收束
- 空输入: 错误在组件内可见,且没有进入 loading;仅禁用按钮不够,因为方法仍可能被测试或其他入口调用。
- 请求失败:
catch保存用户可见错误;下一次提交先清掉旧错误。 - 重复提交:
isLoading是逻辑护栏,按钮禁用只是交互护栏,两者共同防止重复动作。 - 销毁:
beforeDestroy让 token 失效,迟到 Promise 只结束自身,不再写组件状态。真实网络层若支持 AbortController 或取消令牌,还应释放底层资源。
注意例子没有声称“令牌等于取消请求”。它只阻止迟到结果提交;网络、定时器或订阅是否真正释放,要看边界 API 有没有取消能力。
修补、隔离还是迁移
| 现场 | 本轮更合适的动作 | 原因 |
|---|---|---|
| 明确的空输入或重复提交缺陷 | 在 Vue 2 原组件内小修并补回归 | 风险窄,可单独证明 |
| mixin / 全局插件依赖过多 | 先用适配层收口输入输出 | 未摸清依赖前重写容易漏行为 |
| 编译器与运行时不同代 | 先恢复版本一致 | 模板结果不可信,业务重构没有稳定地基 |
| 行为已锁定、依赖可替换、工具链允许 | 按组件边界迁移 | 可以逐批比较,不必一次翻新全仓 |
练习:为旧搜索组件补齐维护护栏
复制上面的 LegacySearch.vue,先暂时移除 token、空输入错误和 loading 早退,再由测试现象把三处护栏补回来。输入合同固定为:value 是父级拥有的字符串;vue 返回两条结果,fail 拒绝,slow 延迟 1.2 秒,空白不请求。
可观察结果:
- 输入
' vue '后父级仍保存原草稿,提交时才用vue搜索。 - 连续触发两次 submit 只启动一次搜索。
fail显示错误并恢复按钮;随后输入vue可以成功重试。slow提交后立刻销毁组件,控制台没有“销毁后更新”警告,重新创建也不会出现旧结果。
关键边界:令牌只屏蔽迟到提交,不等于释放底层请求;把真实 API 接入时,另行验证取消与订阅清理。
参考答案:查看第一章练习答案中的第 12 题。
通关自检:你能从一个 this.x 追到唯一来源,并用四条输入证明小修没有改变 Vue 2 的公开合同。