前言:一个看似简单的场景
vue
<!-- 父组件 -->
<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 组件设计中一个深层的问题------数据所有权与副作用边界之间的张力。
问题复现------二次渲染的本质
执行过程
- 组件挂载前 :
articleTitle和articleDescription初始值为空字符串。 - 首次渲染 :子组件收到
{ title: "", description: "" },完成第一次渲染(空白或加载占位)。 - 异步数据返回 :
articleTitle.value = ...触发ref的 setter。 - 父组件重新渲染:Vue 检测到响应式数据变化,重新执行父组件的 render 函数。
- 子组件二次渲染:子组件因为 props 变化而再次更新,显示正确内容。
结果:子组件渲染了两次(一次空数据,一次真实数据)。
为什么需要关注这个问题?
并非所有场景都需要关注二次渲染。但在以下情况中,它会成为实际问题:
- 子组件内部开销较大:图表库、大量 DOM 计算等重复执行,造成性能浪费。
- 子组件依赖 props 发起副作用 :比如
watchEffect根据 props 去请求图片或接口,导致请求重复发送。 - 动画或过渡异常:元素从无到有,又从有到空再到有,造成视觉闪烁。
- 表单组件收到两次初始值:可能导致用户输入被意外重置。
一个常见的"改进"及其设计困境
面对上述问题,很多开发者会做出一个看似更优的选择:
vue
<!-- 父组件只传递 ID,让子组件自己获取数据 -->
<UserArticleDisplay :article-id="articleId" />
子组件内部:
vue
<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 时,实际上是在声明式的外壳里隐藏了一个命令式的副作用:
vue
<!-- 从代码上看是声明 -->
<Article :article-id="id" />
<!-- 实际运行时等价于命令 -->
<Article @mount="fetchArticle(id)" @update:id="fetchArticle(newId)" />
这是声明式 UI 与命令式副作用之间的一个固有矛盾。没有绝对正确的答案,只有基于具体场景的权衡。
解决方案的分类与取舍
方案一:显式副作用设计
明确告知子组件需要产生副作用,并暴露钩子供父组件参与。
vue
<Article
:article-id="id"
:fetch-on-mount="true"
@loading="showSpinner"
@error="handleError"
/>
设计立场:副作用是必要的,但必须可见、可控。使用者清楚知道这个组件会发起网络请求。
方案二:副作用保留在父组件,保持子组件纯净
保持子组件为纯展示组件,父组件负责所有数据获取。
vue
<!-- 父组件获取数据,子组件只负责渲染 -->
<Article :title="title" :description="description" />
配合 v-if 缓解二次渲染:
vue
<Article
v-if="articleTitle && articleDescription"
:title="articleTitle"
:description="articleDescription"
/>
设计立场:子组件应该是可预测的纯函数。二次渲染是声明式 UI 的合理代价,可以通过条件渲染避免。
方案三:提取独立服务层
js
// 独立的 ArticleService
const articleService = useArticleService()
// 父组件调用服务,把结果传给子组件
const { data: article, execute } = useAsyncState(
() => articleService.fetch(id),
null
)
vue
<Article :data="article" v-if="article" />
设计立场:副作用既不在父组件也不在子组件,而在独立的服务层。这是最符合关注点分离原则的方案。
方案四:接受双重渲染,优化中间状态
承认异步 Props 必然导致多次渲染,但把中间状态(loading/error)作为一等公民暴露出来。
vue
<Article :article-id="id">
<template #loading>加载中...</template>
<template #error="{ retry }">加载失败,<button @click="retry">重试</button></template>
</Article>
设计立场:与其隐藏副作用,不如将其显式化、可定制化,让使用者拥有更好的控制权。
如何做出选择
在组件设计时,需要明确回答三个问题:
- 谁负责发起副作用?(父组件?子组件?服务层?)
- 副作用的可见性如何?(用户是否应该看到 loading?其他开发者是否应该知道组件会发请求?)
- 可测试性优先还是渲染次数优先?
| 优先级 | 推荐方案 | 副作用归属 |
|---|---|---|
| 子组件纯净、易测试 | 传递数据,接受二次渲染 + v-if |
父组件 |
| 子组件自包含、减少渲染 | 传递 ID,子组件自治,暴露 loading/error | 子组件(显式声明) |
| 架构清晰、可维护 | 独立服务层 + 传递数据 | 服务层 |
| 用户体验优先 | 传递 ID + 子组件智能加载(骨架屏 + 一次渲染) | 子组件 |
何时不必过度设计
以下场景中,最简单的方案(即最初的双重渲染方案)完全够用:
- 子组件非常轻量,二次渲染开销可忽略。
- 产品明确需要 loading 状态作为用户体验的一部分。
- 数据请求速度极快(有缓存或 Service Worker),用户感知不到两次渲染。
在这些场景下,无需引入复杂的设计模式。
结语
Vue 响应式系统与异步数据流结合时,ref 的初始值与最终值必然导致响应式派发更新。这不是 Vue 的设计缺陷,而是声明式 UI 框架的固有特性。
真正的组件设计不是消灭副作用或消灭二次渲染,而是:
- 明确决定副作用归属于谁
- 让这个决定在代码中显而易见
- 根据场景选择在哪个环节承担中间状态(父组件、子组件、服务层,或 Suspense)
传递 articleId 确实会将副作用责任转移给子组件。这本身不是错误------前提是开发者有意识地做出这个选择,并理解其代价(可测试性降低、副作用隐藏)。
优秀的组件设计在于理解每个决策的含义后,做出符合当前场景的权衡。
快速参考
| 场景 | 推荐方案 |
|---|---|
| 子组件渲染开销大,需要避免二次渲染 | v-if 就绪后渲染 |
| 子组件有独立的数据获取逻辑 | 传递 ID + 显式 loading/error 钩子 |
| 需要 loading 态作为产品需求 | 保留两次渲染,优化默认占位内容 |
| 追求架构清晰、组件可复用 | 独立服务层 + 传递数据 |
| 极致性能,数据返回极快 | 使用 Suspense 或预取数据 |