Skip to content

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-loaderSFC 模板由哪一代编译器处理运行时升级了,编译器仍停在旧代
构建工具与浏览器目标可用语法、polyfill 与产物形态本地新浏览器能跑就代表生产能跑
组件库、插件、mixin隐式注入到 this 的字段与副作用把未知字段都当成组件自己的 data

从一个故障推导维护规则 ​

旧搜索框在正常网络下“看起来没问题”,但有四个真实输入会暴露边界:

  1. 用户提交全空白文本:不应发起搜索,要显示字段错误。
  2. 用户输入 fail:模拟请求拒绝,要显示可重试错误并恢复按钮。
  3. 请求尚未结束时再次提交:只能启动一次搜索。
  4. 输入 slow 后立刻关闭组件:迟到结果不能再修改已销毁实例。

这四条把维护规则推出来了:模板只显示状态;value 仍由父级拥有;组件用 input 提议更新;异步动作必须有阶段、失败收束和销毁失效令牌。

可运行组件:守住 Vue 2 的现有合同 ​

vue
<!-- 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>
vue
<!-- 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.valueprops查 prop 定义、父级传值和是否被错误修改
this.phasedata查初始类型与所有写入点
this.canSubmitcomputed确认无副作用,依赖是否完整
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 的公开合同。

用心记录每一步成长