普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月26日首页

Vue 响应式对象异步赋值作为 Props:二次渲染问题与组件设计哲学

2026年4月26日 15:39

前言:一个看似简单的场景

<!-- 父组件 -->
<template>
  <Article
    :title="articleTitle"
    :description="articleDescription"
  />
</template>

<script setup>
import { ref } from 'vue'
import Article from './Article.vue'

const articleTitle = ref("")
const articleDescription = ref("")

// 这里发起请求
fetchArticles(user, publishedDate).then(response => {
  articleTitle.value = response.data.title // 响应式数据发生变化,派发更新
  articleDescription.value = response.data.description
})
</script>

上面的代码会导致子组件 <Article> 渲染两次:第一次收到空字符串,第二次收到真实数据。

这看起来只是一个技术细节。但当开发者把目光从“如何解决”转向“为什么会产生这个问题”时,会发现它触及了 Vue 组件设计中一个深层的问题——数据所有权与副作用边界之间的张力


问题复现——二次渲染的本质

执行过程

  1. 组件挂载前articleTitlearticleDescription 初始值为空字符串。
  2. 首次渲染:子组件收到 { title: "", description: "" },完成第一次渲染(空白或加载占位)。
  3. 异步数据返回articleTitle.value = ... 触发 ref 的 setter。
  4. 父组件重新渲染:Vue 检测到响应式数据变化,重新执行父组件的 render 函数。
  5. 子组件二次渲染:子组件因为 props 变化而再次更新,显示正确内容。

结果:子组件渲染了两次(一次空数据,一次真实数据)。

为什么需要关注这个问题?

并非所有场景都需要关注二次渲染。但在以下情况中,它会成为实际问题:

  • 子组件内部开销较大:图表库、大量 DOM 计算等重复执行,造成性能浪费。
  • 子组件依赖 props 发起副作用:比如 watchEffect 根据 props 去请求图片或接口,导致请求重复发送。
  • 动画或过渡异常:元素从无到有,又从有到空再到有,造成视觉闪烁。
  • 表单组件收到两次初始值:可能导致用户输入被意外重置。

一个常见的“改进”及其设计困境

面对上述问题,很多开发者会做出一个看似更优的选择:

<!-- 父组件只传递 ID,让子组件自己获取数据 -->
<UserArticleDisplay :article-id="articleId" />

子组件内部:

<script setup>
const props = defineProps<{ articleId: string }>()
const article = ref(null)

watch(() => props.articleId, async (id) => {
  if (id) {
    article.value = await fetchArticle(id)
  }
}, { immediate: true })
</script>

效果:子组件只渲染一次(数据加载完成后直接渲染真实内容,中间用 loading 态占位)。

设计困境:副作用归属问题

传递的内容 副作用的承担者 渲染次数 副作用可见性
title / description(数据) 父组件 2 次 副作用在父组件,透明
articleId(标识符) 子组件 1 次 副作用被子组件隐藏,不透明

传递 articleId 意味着:子组件不仅接收一个 ID,还被默认有能力、有责任去获取数据并处理网络请求。这相当于将副作用责任从父组件转移到了子组件。

更深层的矛盾:声明式与命令式的冲突

Vue 本质上是声明式的:开发者声明“UI 应该是什么样”,框架帮助实现。

但网络请求本质上是命令式的:在某个时刻“命令”组件去获取数据。

当传递 articleId 时,实际上是在声明式的外壳里隐藏了一个命令式的副作用:

<!-- 从代码上看是声明 -->
<Article :article-id="id" />

<!-- 实际运行时等价于命令 -->
<Article @mount="fetchArticle(id)" @update:id="fetchArticle(newId)" />

这是声明式 UI 与命令式副作用之间的一个固有矛盾。没有绝对正确的答案,只有基于具体场景的权衡。


解决方案的分类与取舍

方案一:显式副作用设计

明确告知子组件需要产生副作用,并暴露钩子供父组件参与。

<Article 
  :article-id="id" 
  :fetch-on-mount="true"
  @loading="showSpinner"
  @error="handleError"
/>

设计立场:副作用是必要的,但必须可见、可控。使用者清楚知道这个组件会发起网络请求。

方案二:副作用保留在父组件,保持子组件纯净

保持子组件为纯展示组件,父组件负责所有数据获取。

<!-- 父组件获取数据,子组件只负责渲染 -->
<Article :title="title" :description="description" />

配合 v-if 缓解二次渲染:

<Article
  v-if="articleTitle && articleDescription"
  :title="articleTitle"
  :description="articleDescription"
/>

设计立场:子组件应该是可预测的纯函数。二次渲染是声明式 UI 的合理代价,可以通过条件渲染避免。

方案三:提取独立服务层

// 独立的 ArticleService
const articleService = useArticleService()

// 父组件调用服务,把结果传给子组件
const { data: article, execute } = useAsyncState(
  () => articleService.fetch(id),
  null
)
<Article :data="article" v-if="article" />

设计立场:副作用既不在父组件也不在子组件,而在独立的服务层。这是最符合关注点分离原则的方案。

方案四:接受双重渲染,优化中间状态

承认异步 Props 必然导致多次渲染,但把中间状态(loading/error)作为一等公民暴露出来。

<Article :article-id="id">
  <template #loading>加载中...</template>
  <template #error="{ retry }">加载失败,<button @click="retry">重试</button></template>
</Article>

设计立场:与其隐藏副作用,不如将其显式化、可定制化,让使用者拥有更好的控制权。


如何做出选择

在组件设计时,需要明确回答三个问题:

  1. 谁负责发起副作用?(父组件?子组件?服务层?)
  2. 副作用的可见性如何?(用户是否应该看到 loading?其他开发者是否应该知道组件会发请求?)
  3. 可测试性优先还是渲染次数优先?
优先级 推荐方案 副作用归属
子组件纯净、易测试 传递数据,接受二次渲染 + v-if 父组件
子组件自包含、减少渲染 传递 ID,子组件自治,暴露 loading/error 子组件(显式声明)
架构清晰、可维护 独立服务层 + 传递数据 服务层
用户体验优先 传递 ID + 子组件智能加载(骨架屏 + 一次渲染) 子组件

何时不必过度设计

以下场景中,最简单的方案(即最初的双重渲染方案)完全够用:

  • 子组件非常轻量,二次渲染开销可忽略。
  • 产品明确需要 loading 状态作为用户体验的一部分。
  • 数据请求速度极快(有缓存或 Service Worker),用户感知不到两次渲染。

在这些场景下,无需引入复杂的设计模式。


结语

Vue 响应式系统与异步数据流结合时,ref 的初始值与最终值必然导致响应式派发更新。这不是 Vue 的设计缺陷,而是声明式 UI 框架的固有特性。

真正的组件设计不是消灭副作用或消灭二次渲染,而是:

  1. 明确决定副作用归属于谁
  2. 让这个决定在代码中显而易见
  3. 根据场景选择在哪个环节承担中间状态(父组件、子组件、服务层,或 Suspense)

传递 articleId 确实会将副作用责任转移给子组件。这本身不是错误——前提是开发者有意识地做出这个选择,并理解其代价(可测试性降低、副作用隐藏)。

优秀的组件设计在于理解每个决策的含义后,做出符合当前场景的权衡。


快速参考

场景 推荐方案
子组件渲染开销大,需要避免二次渲染 v-if 就绪后渲染
子组件有独立的数据获取逻辑 传递 ID + 显式 loading/error 钩子
需要 loading 态作为产品需求 保留两次渲染,优化默认占位内容
追求架构清晰、组件可复用 独立服务层 + 传递数据
极致性能,数据返回极快 使用 Suspense 或预取数据
❌
❌