DOCS|2026-09-20|12 min read

父子组件单向数据流

#技术#前端#Vue
背景

在商品列表页面中,父组件「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 重新加载
}
系统性整理

这两段代码功能并不相同

  1. 子组件中的defineProps和defineEmits

    定义了组件的 props 和 emits

    对应的就是在父组件中使用的:products="products"和@page-change="handlePageChange"。

  2. 其中 v-bind(:) 和 v-on(@) :

    • : 开头 = 单向绑定 props(把父组件的数据传进去给子组件)
    • @ 开头 = 监听事件 emit(接收子组件传出来的通知)
  3. 子组件「Product」的defineProps

    • 接收从父组件来的数据到props中,
    • 而defineEmits是定义一个发送"消息"到父组件的emit
  4. 父组件「ProductList」的handlePageChange

    • 接收子组件发出的事件
    • 并更新自己的状态page.value,从而触发 watch 重新加载数据。
  5. paginationConfig 是子组件的一个计算属性,其computed的加载时机与watch不一样:

    • computed 是在组件渲染时计算的,只有当依赖的 props 发生变化时才会重新计算。(懒 + 缓存 + 脏检查)
      1. 当a-table需要paginationConfig的值时 --> 触发第一次执行(拿到数据) --> 缓存
      2. 没人读这个值就 永远不算,即 "惰性求值"
      3. 父组件数据传过来,total从 0 -> 37,此时Vue只是把这个computed标记为脏(缓存已过期),但不会立即重跑函数
      4. 当父组件更新,子组件模版重渲染 --> 又读到paginationConfig --> 发现脏 --> 重跑函数,并返回新对象
  6. 与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 是子组件向父组件汇报用户行为的唯一通道。