背景
在商品列表页面中,父组件「ProductList」下有一个子组件「ProductTable」,子组件负责展示表格,父组件负责管理数据和状态。 用户在表格底部点击分页按钮时,子组件需要通知父组件更新页码并重新加载数据。
其中主要是在分页和数据重新请求后更新加载的地方需要注意「父子组件的通信」,在 Vue 中通过 emit 和 props 实现。
记录
一、父子组件通信和数据流通
在商品列表的父子组件中出现了两段代码:
ProductTable.vue(子组件)——computed 的 paginationConfig 里:
// 输入: 父组件通过 props 传入 (单向数据流, 只读)
const props = defineProps<{
products: Product[]
loading: boolean
page: number
total: number
}>()
// 通过 emit 来对应 @page-change 事件,向父组件传递数据
const emit = defineEmits(['page-change'])
const paginationConfig = computed(() => ({
current: props.page,
pageSize: PAGE_SIZE,
total: props.total,
// onChange这个名字 是 Pagination 分页组件约定好的 key,不能随便改
onChange: (newPage: number) => emit('page-change', newPage),
}))
ProductList.vue(父组件):
function handlePageChange(newPage: number) {
page.value = newPage // 触发 watch 重新加载
}
系统性整理
这两段代码功能并不相同
-
子组件中的
defineProps和defineEmits定义了组件的 props 和 emits
对应的就是在父组件中使用的
:products="products"和@page-change="handlePageChange"。 -
其中 v-bind(
:) 和 v-on(@) ::开头 = 单向绑定 props(把父组件的数据传进去给子组件)@开头 = 监听事件 emit(接收子组件传出来的通知)
-
子组件「Product」的
defineProps- 接收从父组件来的数据到
props中, - 而
defineEmits是定义一个发送"消息"到父组件的emit
- 接收从父组件来的数据到
-
父组件「ProductList」的
handlePageChange- 接收子组件发出的事件
- 并更新自己的状态
page.value,从而触发 watch 重新加载数据。
-
paginationConfig 是子组件的一个计算属性,其computed的加载时机与watch不一样:
- computed 是在组件渲染时计算的,只有当依赖的 props 发生变化时才会重新计算。(懒 + 缓存 + 脏检查)
- 当
a-table需要paginationConfig的值时 --> 触发第一次执行(拿到数据) --> 缓存 - 没人读这个值就 永远不算,即 "惰性求值"
- 父组件数据传过来,total从 0 -> 37,此时Vue只是把这个computed标记为
脏(缓存已过期),但不会立即重跑函数 - 当父组件更新,子组件模版重渲染 --> 又读到
paginationConfig--> 发现脏 --> 重跑函数,并返回新对象
- 当
- computed 是在组件渲染时计算的,只有当依赖的 props 发生变化时才会重新计算。(懒 + 缓存 + 脏检查)
-
与watch的区别:
执行时机 是否缓存 谁触发 普通函数 每次调用都执行 否 手动调用 computed 首次被读 +(脏时)被读 是 响应式依赖自动触发 watch 依赖一变就执行回调 否 响应式依赖自动触发
二、首次加载:渲染流程 vs 数据加载流程
Vue是 先渲染、后填数据
1.1 渲染流程(从父到子、同步)
Vue 的模版渲染是
从外向内、逐层递归的
| 层级 | 组件/文件 | 此时发生了什么 |
|---|---|---|
| 1 | App.vue | 渲染 <template>,遇到子组件 <ProductList /> |
| 2 | ProductList | 创建实例,继续渲染自己的模板 <template>, 遇到其子组件 <SearchBar /> |
| 3 | SearchBar | 创建实例,创建(独立,不阻塞,不等加载数据),遇到子组件 <ProductTable /> |
| 3 | ProductTable | 创建实例,props 注入初始值,遇到子组件 <a-table /> |
| 4 | a-table | 挂载表格壳, 渲染出 <a-pagination> 的页码按钮 |
关键点:每一层渲染时,只关心“有什么数据就渲染什么”。没有就渲染空状态
1.2 数据加载流程(异步,通过响应式绑定“填充”)
-
核心代码片段:
watch( [searchParams, page], ([params, p]) => { load({ ...params, page: p }) }, { immediate: true }, // ← 关键:挂载时立即执行一次 ) -
时序:
时间线: ─────────────────────────────────────────────────────────►
① Vue 创建
ProductList实例
② 渲染模板:products = [](空数组),loading = false
③ a-table 收到空数组,显示"暂无数据"或 loading 动画(取决于 loading 值)
④watch({ immediate: true })触发 → 调用 load()
⑤ load() 执行:loading = true→ 模板响应式更新,表格显示 loading 动画
⑥await getProducts(...)→ 等待 300ms 模拟延迟
⑦ load() 完成:products.value = data.list,loading = false
⑧ Vue 检测到 ref 变化 → 重新渲染受影响的组件
⑨ProductTable的 props.products 变了 → a-table 收到新数据 → 显示商品列表
1.3 数据绑定是怎么串起来的
ProductList.vue ProductTable.vue a-table
┌──────────────────┐ :products ┌──────────────────┐ products ┌──────────┐
│ products = ref() │ ──────────→ │ props.products │ ────────→ │dataSource│
│ page = ref(1) │ ──────────→ │ props.page │ │ │
│ total = ref(0) │ ──────────→ │ props.total │ │ │
│ loading = ref() │ ──────────→ │ props.loading │ loading │ loading │
└──────────────────┘ └──────────────────┘ └──────────┘
↑ ↑
│ │
直接绑定refs computed 从 props 读取
(响应式自动同步) 再包装成 a-table 需要的格式
ProductTable 里的关键代码 (第 19-26 行):
const paginationConfig = computed(() => ({
current: props.page, // 从父组件 props 读取
pageSize: PAGE_SIZE,
total: props.total, // 从父组件 props 读取
onChange: (newPage) => emit('page-change', newPage), // 点击时向上 emit
}))
paginationConfig (一个计算属性,依赖 props.page, props.total)
- 当父组件的
page或total变化时,paginationConfig会重新计算,a-table内部收到新的 pagination 配置,重新渲染分页按钮;
三、用户点击"第 2 页":交互流程(从内到外,再从外到内)
这和渲染流程方向相反, 事件从最内层向上传播,数据变化从最外层向下传播。
用户点击 a-pagination 的"第2页"按钮
│
▼
┌──────────── a-pagination (Ant Design 内部) ───────────┐
│ 检测到 click 事件 │
│ → 调用 paginationConfig.onChange(2) │
│ (这个 onChange 是通过 :pagination prop 传进来的) │
└────────────────────┬─────────────────────────────────┘
│
▼
┌──────────── ProductTable.vue ────────────────────────┐
│ paginationConfig.onChange(2) 执行 │
│ → emit('page-change', 2) │
│ (这是 ProductTable 向父组件发出的自定义事件) │
└────────────────────┬─────────────────────────────────┘
│
▼
┌──────────── ProductList.vue ─────────────────────────┐
│ @page-change="handlePageChange" 接收到事件) │
│ → handlePageChange(2) 执行 │
│ → page.value = 2 │
│ (page 是 ref,赋值触发 Vue 响应式系统) │
└────────────────────┬─────────────────────────────────┘
│
▼
┌─── Vue 响应式系统介入 ─────┐
│ watch 检测到 page 变了 │
│ → 自动调用 load() │
└────────────┬────────────┘
│
▼
load({ keyword, category, page: 2 })
│
▼
getProducts(params) ← 异步请求
│
▼
300ms 后返回第2页数据
│
▼
products.value = data.list ← 更新 ref
total.value = data.total ← 更新 ref
│
▼
┌─── Vue 响应式系统再次介入 ──────┐
│ (检测到 products 变了) │
│ -> 通知所有依赖它的组件重新渲染 │
└────────────┬─────────────────┘
│
▼
┌───────────── ProductTable.vue ─────────────────────┐
│ props.products 更新 → computed 重新计算 │
│ → a-table 收到新的 dataSource 和 paginationConfig │
│ → 重新渲染表格和分页按钮(高亮第2页) │
└────────────────────────────────────────────────────┘
四、Vue 生命周期 vs 数据加载时的时序对照
onBeforeMount ← 还没渲染 DOM,ref 值已就绪
│
onMounted ← DOM 已渲染完毕,此时 watch({ immediate: true }) 刚触发 load()
│ ⚠️ 注意:load() 是 async 的,此时数据还没回来!
│ ⚠️ 所以第一次渲染时 a-table 收到的是空数组
│
▼ (300ms 后数据返回)
│
products.value = data.list ← Vue 响应式触发 re-render
│
▼
更新后的 DOM 显示商品列表
这就是为什么你看到的是"先出现 loading 或空表格,然后数据才填充进来"——这不是 bug,是 Vue + 异步数据的标准行为。
总结
Vue 的渲染是"先骨架后血肉":
- 模板从外到内同步渲染,数据从 API 异步加载后通过响应式 ref 填充进去;
watch是连接"数据变化"和"重新加载"的桥梁;computed是把原始 props 加工成子组件需要的格式的中间层,emit是子组件向父组件汇报用户行为的唯一通道。