Vue2组件化开发与父子通信

Vue2 组件化开发深度解析:组件定义、数据隔离与通信模式

导读:本文围绕 Vue2 组件化开发的核心机制,逐层拆解组件的三种模板定义方式、局部与全局注册的适用边界、组件嵌套形成的树状结构、data 必须为函数背后的内存隔离原理,以及父子组件之间 props 下行、$emit 上行、.sync 双向绑定三种通信模式的底层实现。每章配有完整可运行 HTML 示例、mermaid 流程图和面试级别的原理解析,适合有 Vue2 基础指令使用经验、希望向工程化组件设计进阶的开发者。


目录

  • 零、导读与学习价值
    • [0.1 示例覆盖清单](#0.1 示例覆盖清单 "#01-%E7%A4%BA%E4%BE%8B%E8%A6%86%E7%9B%96%E6%B8%85%E5%8D%95")
    • [0.2 核心名词速查](#0.2 核心名词速查 "#02-%E6%A0%B8%E5%BF%83%E5%90%8D%E8%AF%8D%E9%80%9F%E6%9F%A5")
    • [0.3 为什么要学本篇](#0.3 为什么要学本篇 "#03-%E4%B8%BA%E4%BB%80%E4%B9%88%E8%A6%81%E5%AD%A6%E6%9C%AC%E7%AF%87")
  • 一、组件化思想与组件定义方式
    • [1.1 什么是组件](#1.1 什么是组件 "#11-%E4%BB%80%E4%B9%88%E6%98%AF%E7%BB%84%E4%BB%B6")
    • [1.2 三种模板写法](#1.2 三种模板写法 "#12-%E4%B8%89%E7%A7%8D%E6%A8%A1%E6%9D%BF%E5%86%99%E6%B3%95")
    • [1.3 局部组件 vs 全局组件](#1.3 局部组件 vs 全局组件 "#13-%E5%B1%80%E9%83%A8%E7%BB%84%E4%BB%B6-vs-%E5%85%A8%E5%B1%80%E7%BB%84%E4%BB%B6")
    • [1.4 组件命名规范](#1.4 组件命名规范 "#14-%E7%BB%84%E4%BB%B6%E5%91%BD%E5%90%8D%E8%A7%84%E8%8C%83")
  • [二、组件中的 data 必须是函数------原理解析](#二、组件中的 data 必须是函数——原理解析 "#%E4%BA%8C%E7%BB%84%E4%BB%B6%E4%B8%AD%E7%9A%84-data-%E5%BF%85%E9%A1%BB%E6%98%AF%E5%87%BD%E6%95%B0%E5%8E%9F%E7%90%86%E8%A7%A3%E6%9E%90")
    • [2.1 现象与问题](#2.1 现象与问题 "#21-%E7%8E%B0%E8%B1%A1%E4%B8%8E%E9%97%AE%E9%A2%98")
    • [2.2 工厂函数与内存隔离原理](#2.2 工厂函数与内存隔离原理 "#22-%E5%B7%A5%E5%8E%82%E5%87%BD%E6%95%B0%E4%B8%8E%E5%86%85%E5%AD%98%E9%9A%94%E7%A6%BB%E5%8E%9F%E7%90%86")
  • 三、父向子通信:Props
    • [3.1 基本用法](#3.1 基本用法 "#31-%E5%9F%BA%E6%9C%AC%E7%94%A8%E6%B3%95")
    • [3.2 类型限制与默认值](#3.2 类型限制与默认值 "#32-%E7%B1%BB%E5%9E%8B%E9%99%90%E5%88%B6%E4%B8%8E%E9%BB%98%E8%AE%A4%E5%80%BC")
    • [3.3 Props 单向数据流原理](#3.3 Props 单向数据流原理 "#33-props-%E5%8D%95%E5%90%91%E6%95%B0%E6%8D%AE%E6%B5%81%E5%8E%9F%E7%90%86")
    • [3.4 商品卡片实战示例](#3.4 商品卡片实战示例 "#34-%E5%95%86%E5%93%81%E5%8D%A1%E7%89%87%E5%AE%9E%E6%88%98%E7%A4%BA%E4%BE%8B")
  • 四、子向父通信:事件与回调
    • [4.1 函数传递法](#4.1 函数传递法 "#41-%E5%87%BD%E6%95%B0%E4%BC%A0%E9%80%92%E6%B3%95")
    • [4.2 emit 自定义事件](#4.2 emit 自定义事件 "#42-emit-%E8%87%AA%E5%AE%9A%E4%B9%89%E4%BA%8B%E4%BB%B6")
  • [五、.sync 修饰符:双向绑定语法糖](#五、.sync 修饰符:双向绑定语法糖 "#%E4%BA%94sync-%E4%BF%AE%E9%A5%B0%E7%AC%A6%E5%8F%8C%E5%90%91%E7%BB%91%E5%AE%9A%E8%AF%AD%E6%B3%95%E7%B3%96")
  • 六、组件嵌套与通信实战案例
    • [6.1 组件树与嵌套规则](#6.1 组件树与嵌套规则 "#61-%E7%BB%84%E4%BB%B6%E6%A0%91%E4%B8%8E%E5%B5%8C%E5%A5%97%E8%A7%84%E5%88%99")
    • [6.2 Vue 实例与组件实例的关系](#6.2 Vue 实例与组件实例的关系 "#62-vue-%E5%AE%9E%E4%BE%8B%E4%B8%8E%E7%BB%84%E4%BB%B6%E5%AE%9E%E4%BE%8B%E7%9A%84%E5%85%B3%E7%B3%BB")
    • [6.3 Tab 切换综合案例](#6.3 Tab 切换综合案例 "#63-tab-%E5%88%87%E6%8D%A2%E7%BB%BC%E5%90%88%E6%A1%88%E4%BE%8B")
  • 总结

零、导读与学习价值

0.1 示例覆盖清单

序号 知识点 所在章节
1 字符串模板(template 字符串) 一、1.2
2 script 标签模板(type="x/template") 一、1.2
3 template 标签模板 一、1.2
4 局部组件注册(components 配置) 一、1.3
5 全局组件注册(Vue.component) 一、1.3
6 组件命名:大驼峰 / 短横线 / 首字母大写 一、1.4
7 组件嵌套(父子/多层) 六、6.1
8 Vue 实例与组件实例的原型链关系 六、6.2
9 组件中 data 为函数
10 data 为对象导致状态共享的反例 二、2.1
11 props 基本用法(数组形式) 三、3.1
12 props 类型限制(对象形式) 三、3.2
13 props 默认值、required、validator 三、3.2
14 书单卡片实战(props 综合) 三、3.4
15 子向父:函数传递法 四、4.1
16 子向父:$emit 自定义事件 四、4.2
17 .sync 修饰符
18 Tab 组件综合案例 六、6.3

0.2 核心名词速查

术语 一句话解释
组件(Component) 对 HTML 标签的扩展,将模板、数据、逻辑封装为可复用单元
局部组件 在某个 Vue 实例或组件的 components 选项中注册,仅在该作用域可用
全局组件 通过 Vue.component() 注册,在所有实例和组件中均可使用
props 父组件向子组件传递数据的通道,子组件以声明方式接收
单向数据流 props 只能父→子流动,子组件不得直接修改 props
$emit 子组件触发自定义事件并向上传递数据的方法
.sync 修饰符 :prop.sync="val":prop="val" @update:prop="val=$event" 的语法糖
工厂函数 每次调用都返回一个全新对象的函数;组件 data 必须是工厂函数
VueComponent Vue 内部用于构造组件实例的构造函数,由 Vue.extend() 生成
组件树 所有组件按父子嵌套关系形成的树形结构,根节点通常是根 Vue 实例

0.3 为什么要学本篇

组件化是 Vue 工程化的基石。掌握本篇之后:

  1. 生产级代码:能将页面按业务边界拆分为独立组件,每个组件职责清晰、可单独测试。
  2. 框架深度 :理解 data 为函数的内存隔离原理,以及 props 单向数据流的设计动机,才能在复杂场景中准确判断"数据应该放在哪里"。
  3. 面试准备 :"data 为什么必须是函数"、"props 与 data 的区别"、"$emit 的实现原理" 是 Vue 面试出现频率最高的几道题。
  4. Vue 3 衔接 :Vue 3 的 setup() 和 Composition API 仍遵循相同的父子通信契约,本篇打下的模式认知可直接迁移。

一、组件化思想与组件定义方式

1.1 什么是组件

名词解释

  • 组件(Component) :对 HTML 标签的扩展。原生 HTML 只有有限的内置标签(<div><input> 等),组件允许开发者自定义标签(如 <book-card><tab-panel>),并在标签内封装完整的模板、数据与逻辑。
  • 功能性组件:只在当前页面出现一次,目的是将大页面拆分为职责单一的小模块,提升可读性。
  • 通用性组件:在多个页面或多处位置复用的组件,如按钮、弹窗、表格行等。

概念与底层原理

Vue 在调用 Vue.component() 或处理 components 配置时,内部会调用 Vue.extend(options) 生成一个继承自 Vue 的子构造函数(通常叫 VueComponent)。每次在模板中使用 <my-com> 标签,Vue 都会用这个子构造函数 new VueComponent(options) 创建一个独立的组件实例。

这意味着组件与 Vue 实例在 API 上几乎一致:都支持 datamethodscomputedfilterswatch、生命周期钩子等选项。二者的核心差异仅有两点:

  1. 组件不需要 el 选项(它被父模板中的标签"挂载");
  2. 组件的 data 必须是函数(Vue 实例可以是对象,但官方同样推荐函数形式)。

【代码注释】上图描述了从组件定义到组件实例化的完整链路。蓝色 Vue.extend(options) 是入口,它内部基于原型继承生成紫色的 VueComponent 子构造函数(源码中通过 Sub.prototype = Object.create(Super.prototype) 建立链路);黄色节点表示模板编译阶段每遇到一次 <my-com> 标签,橙色节点就 new VueComponent() 创建一个独立实例;绿色两节点强调每个实例拥有独立的 data/methods/computed,并被挂载到父模板对应位置。Vue.extend 是 Vue 内部机制,开发者无需手动调用,使用 components 选项时 Vue 会自动完成这一步。为什么这样设计 :官方文档把这套机制定性为"组件是可复用的 Vue 实例"(组件基础)------每次标签出现就对应一次 new VueComponent(),正是"多次使用同一组件、各实例数据独立"的根本原因。市面应用 :Element UI 的 <el-button> 在一个页面被用几十次,每个按钮都是一次独立 new VueComponent(),互不干扰。

1.2 三种模板写法

Vue 组件的 template 选项支持三种写法:字符串、<script type="x/template"> 标签、<template> 标签。三种写法在功能上等价,差异只在于写法的便利性。

写法一:模板字符串(最常见)

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>模板字符串示例</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</head>
<body>
<div id="app">
  <h3>{{ title }}</h3>
</div>
<script>
  new Vue({
    el: "#app",
    data: { title: "模板字符串演示" },
    // 使用 ES6 模板字面量,可以写多行 HTML,且支持 {{ }} 插值
    template: `
      <div>
        <h3>我是通过 template 字符串定义的模板</h3>
        <p>{{ title }}</p>
      </div>
    `
  })
</script>
</body>
</html>

【代码注释】template 选项指定后,Vue 会用该字符串的内容替换 el 所挂载的元素。替换意味着原始 HTML 中 #app 标签的内容会被完全抹去,换上 template 中的 DOM 树。模板必须有单一根元素 (最外层只能一个标签),否则 Vue 会报错。市面应用 :Vue CLI 项目里每个 .vue 文件的 <template> 区块,原理与此完全相同,只是工具链帮你做了字符串拼接。


写法二:script 标签(了解即可)

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>script 标签模板示例</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</head>
<body>
<div id="app">
  <p>{{ msg }}</p>
</div>

<!-- type 设为非标准类型,浏览器不会执行此 script,Vue 通过 id 获取其文本内容作为模板 -->
<script type="x/template" id="myTpl">
  <div>
    <h3>script 标签模板</h3>
    <p>{{ msg }}</p>
  </div>
</script>

<script>
  new Vue({
    el: "#app",
    data: { msg: "通过 script 标签定义模板" },
    template: "#myTpl"   // 传入 id 选择器,Vue 会取该 script 的 textContent
  })
</script>
</body>
</html>

【代码注释】将 <script>type 设为 x/template 或任意非 text/javascript 的值,浏览器就会忽略该标签不去执行它;Vue 通过 document.querySelector('#myTpl').textContent 拿到模板字符串。此方式的优点是可以在 IDE 中获得 HTML 语法高亮,缺点是在页面中散落 <script> 标签、可维护性差。市面应用 :jQuery 时代的模板引擎(如 Handlebars)普遍采用此策略;现代 Vue 工程中基本被 .vue 单文件组件取代。


写法三:template 标签(推荐)

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>template 标签模板示例</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</head>
<body>
<div id="app">
  <p>{{ msg }}</p>
</div>

<!-- <template> 是 HTML5 原生标签,内容默认不渲染,浏览器将其视为文档片段 -->
<template id="myTpl">
  <div>
    <h3>template 标签模板</h3>
    <p>{{ msg }}</p>
  </div>
</template>

<script>
  new Vue({
    el: "#app",
    data: { msg: "通过 template 标签定义模板" },
    template: "#myTpl"
  })
</script>
</body>
</html>

【代码注释】<template> 是 HTML5 新增标签,其内容被浏览器解析为 DocumentFragment,不会显示在页面上,也不占 DOM 位置------天然适合充当模板容器。与 <script type="x/template"> 相比,<template> 的内容可被 IDE 完整识别并提供语法提示。市面应用 :Vue 单文件组件(.vue)内部的 <template> 区块就是这个标签的工程化升级版本。


三种写法对比

特性 字符串模板 script 标签 template 标签
多行书写 ES6 模板字面量 天然多行 天然多行
IDE 语法高亮 通常没有 一般没有 有(<template> 识别)
占 DOM 位置 不占 不占 不占(DocumentFragment)
推荐程度 中(适合简单组件) 高(非 SFC 场景首选)

【实战要点】

  • 经典应用场景 :不使用 webpack/Vite 构建工具的轻量 Vue 项目(如内嵌在服务端渲染页面的交互组件),常用 <template> 标签方式定义组件模板。
  • 常见坑 :三种写法的模板均要求单一根元素 。如果写了两个并列的顶层标签(如 <h3>...</h3><p>...</p>),Vue 会报错 "Component template should contain exactly one root element"。
  • 性能与最佳实践 :字符串模板会在运行时编译,而 Vue CLI 使用的 .vue 文件在构建阶段就完成了编译,运行时只执行渲染函数,性能更优。

【本章小结】

写法 核心机制 推荐场景
字符串 template: \...`` ES6 模板字面量,运行时编译 简单组件、Playground
<script type="x/template"> 借助非标准 type 使浏览器忽略执行 遗留代码,了解即可
<template id="xxx"> HTML5 DocumentFragment,不渲染 非构建工具场景首选

【面试考点】 Q:三种模板写法有什么区别?哪种在实际工程中用得最多? A:字符串写法是最简洁的,但多行 HTML 可读性差;<script> 方式利用非标准 type 绕开执行;<template> 利用 HTML5 文档片段特性不渲染内容。实际工程中大多使用 Vue CLI/Vite 构建,统一采用 .vue 单文件组件,三种原始写法都属于"无构建工具场景"的选项。


1.3 局部组件 vs 全局组件

名词解释

  • 局部组件(Local Component) :在特定 Vue 实例或组件的 components 选项中注册,只在该注册域内可见。
  • 全局组件(Global Component) :通过 Vue.component(name, options) 注册,对所有 Vue 实例及其子组件均可见。

概念与底层原理

Vue 在解析模板时,遇到未知标签(如 <my-card>),会依次查找:

  1. 当前组件的 components 选项(局部注册)
  2. 全局注册表(Vue.options.components
  3. 原生 HTML 标签

如果两处都找不到,就会报警告 "Unknown custom element"。全局组件本质上是被合并进 Vue.options.components 的------每个通过 Vue.extend 生成的子构造函数都会继承这个对象,因此全局组件在所有实例中都能用。

【代码注释】上图展示模板编译时 Vue 解析自定义标签的查找链路:蓝色起点是模板中出现 <my-card>,黄色菱形先查"当前组件 components"(局部注册),命中走绿色"使用局部注册的组件";未命中再查"Vue.options.components"(全局注册),命中走绿色全局分支;两处都找不到才走红色"Unknown custom element 警告"。为什么这样设计 :组件查找遵循"就近原则"------局部注册优先于全局注册,源码层面是因为合并选项时局部 components 的原型指向全局 Vue.options.components,先查自身属性再沿原型链查全局,于是同名时局部覆盖全局。市面应用:Element UI、Ant Design Vue 等组件库提供两种引入方式------全量引入(全局注册所有组件)和按需引入(在使用页面局部注册),大型项目推荐按需引入以配合打包工具做 Tree Shaking、减小体积。

示例:局部组件注册

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>局部组件注册</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</head>
<body>
<div id="app">
  <!-- 使用局部注册的三个组件 -->
  <user-card></user-card>
  <hr/>
  <product-badge></product-badge>
  <product-badge></product-badge>
  <hr/>
  <!-- 大驼峰注册名,模板中可用短横线形式 -->
  <comment-list></comment-list>
</div>

<template id="userCardTpl">
  <div style="border:1px solid #ddd;padding:12px;border-radius:6px;width:200px;">
    <h4>用户卡片</h4>
    <p>姓名:张三</p>
  </div>
</template>

<script>
  new Vue({
    el: "#app",
    components: {
      // 写法一:短横线命名(HTML 友好)
      "user-card": {
        template: "#userCardTpl"
      },
      // 写法二:大驼峰(模板中用短横线 <product-badge>)
      ProductBadge: {
        template: `<span style="background:#f60;color:#fff;padding:2px 6px;border-radius:3px;">NEW</span>`
      },
      // 写法三:首字母大写(模板中大小写均可)
      CommentList: {
        template: `<ul><li>评论一</li><li>评论二</li></ul>`
      }
    }
  })
</script>
</body>
</html>

【代码注释】components 是一个普通 JS 对象,键名是组件注册名,键值是组件配置对象(与 new Vue(options) 接收的 options 结构相同)。三种命名方式在视图中的使用规则:短横线名直接用;大驼峰 ProductBadge 在模板中写 <product-badge>;首字母大写 CommentList 在模板中写 <comment-list><CommentList> 均可。市面应用:绝大多数 Vue 项目采用大驼峰定义、短横线使用的惯例(与官方风格指南一致)。


示例:全局组件注册

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>全局组件注册</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</head>
<body>
<div id="app1">
  <h4>实例一中使用全局组件</h4>
  <global-badge></global-badge>
</div>
<div id="app2">
  <h4>实例二中也能使用全局组件</h4>
  <global-badge></global-badge>
</div>
<script>
  // 全局注册:在任意实例/组件的模板中都可以使用 <global-badge>
  Vue.component("global-badge", {
    template: `<span style="background:#409EFF;color:#fff;padding:2px 8px;border-radius:12px;">全局徽章</span>`
  });

  new Vue({ el: "#app1" });
  new Vue({ el: "#app2" });
</script>
</body>
</html>

【代码注释】Vue.component(名字, 配置对象) 必须在 new Vue() 之前调用,否则实例化时找不到该组件。全局组件的配置对象与局部组件完全一致,区别仅在于注册的位置。市面应用Vue.component 常用于注册跨页面使用的基础组件(如 <base-button><base-input>),通过 require.context 或动态 import 批量扫描并注册,是中大型项目的标准实践。

1.4 组件命名规范

注册方式 定义名 模板中使用
短横线 "user-card" <user-card>
首字母大写 UserCard <user-card><UserCard>
大驼峰 UserCard <user-card>(推荐)

官方风格指南推荐:定义用大驼峰(PascalCase),使用用短横线(kebab-case)

【本章小结】

注册类型 注册位置 可见范围 适合场景
局部 components 选项 当前实例/组件内 页面级私有组件
全局 Vue.component() 所有实例和组件 基础 UI 组件库

【面试考点】 Q:局部组件和全局组件的区别是什么?什么时候选择全局注册? A:局部注册通过 components 选项声明,只在当前组件作用域内可见;全局注册通过 Vue.component() 声明,被并入 Vue.options.components,所有实例通过原型继承均可访问。选择全局注册的场景:跨越多个不相关页面反复使用的基础组件(如按钮、图标、弹窗);选择局部注册:仅在特定页面或特定父组件内使用的业务组件,局部注册还能帮助打包工具进行 Tree Shaking。


二、组件中的 data 必须是函数------原理解析

2.1 现象与问题

名词解释

  • 工厂函数(Factory Function):每次调用都返回一个全新对象的函数,而非返回同一个对象的引用。
  • 引用共享(Reference Sharing):多处指向同一个对象,修改一处会影响所有引用。

先看两个对比示例,直观感受"data 为对象"与"data 为函数"的差异:

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>data 为对象 vs 函数</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</head>
<body>
<div id="app">
  <p>data 为函数(独立计数器,互不影响):</p>
  <!-- 三个 counter-ok 实例,各自独立 -->
  <counter-ok></counter-ok>
  <counter-ok></counter-ok>
  <counter-ok></counter-ok>

  <hr/>
  <p>data 为对象(共享计数器,一改全改):</p>
  <!-- 三个 counter-bad 实例,共享同一个 obj -->
  <counter-bad></counter-bad>
  <counter-bad></counter-bad>
  <counter-bad></counter-bad>
</div>
<script>
  // 反例:所有实例共享同一个对象
  const sharedObj = { count: 0 };

  new Vue({
    el: "#app",
    components: {
      // 正确写法:data 是函数,每次调用返回全新对象
      CounterOk: {
        data() {
          return { count: 0 };  // 每次 new VueComponent() 时调用,返回新对象
        },
        template: `<button @click="count++">OK: {{ count }}</button>`
      },
      // 错误写法:data 是对象,所有实例共享引用
      CounterBad: {
        data() {
          return sharedObj;  // 返回同一个对象引用------这正是"坏"的原因
        },
        template: `<button @click="count++">BAD: {{ count }}</button>`
      }
    }
  })
</script>
</body>
</html>

【代码注释】点击任意一个 counter-ok 按钮,只有该按钮的数字变化------三个实例的数据完全独立。点击任意一个 counter-bad 按钮,三个按钮同步变化------因为它们的 data 函数返回了同一个 sharedObj 引用,所有实例共享同一块内存。市面应用 :Vue 早期版本曾允许 data 为对象,带来了大量难以排查的"数据互相污染"Bug,因此后来规范要求组件必须使用函数形式。

2.2 工厂函数与内存隔离原理

概念与底层原理

Vue 在初始化组件实例时,会调用 data() 函数并将返回值作为该实例的响应式数据对象。关键在于:函数每次调用都执行一遍函数体,return { count: 0 } 会在堆内存中创建一个全新的对象,每个组件实例各自持有不同的内存地址。

【代码注释】上图揭示工厂函数实现数据隔离的本质:顶部蓝色 data() 是被三个实例共享的同一段代码;紫色三节点表示实例1/2/3 各自调用一次 data();黄色三节点是每次 return { count: 0 } 在堆内存创建的独立对象(地址 0xA001/0xA002/0xA003 互不相同);底部橙色/绿色对比说明修改实例1的 count 只影响 0xA001 这块内存,0xA0020xA003 完全不受影响。为什么这样设计 :官方文档原话是"一个组件的 data 选项必须是一个函数,因此每个实例可以维护一份被返回对象的独立的拷贝"(组件基础)------若 data 是对象,所有实例按引用共享同一块内存,点一个按钮会污染其它所有实例。市面应用 :Vue 源码在 initData 中执行 typeof data === 'function' ? data.call(vm, vm) : data || {},并对返回对象做响应式代理 observe(data),这正是商品卡片、评论列表项等"同组件多实例"场景数据互不干扰的底层保证。

Vue 源码级验证(简化)

javascript 复制代码
// Vue 内部 initData 逻辑(简化)
function initData(vm) {
  let data = vm.$options.data;
  // 如果 data 是函数,调用它获取数据对象;否则直接使用
  data = vm._data = typeof data === 'function'
    ? data.call(vm, vm)   // 每次实例化调用,返回新对象
    : data || {};
  
  // 将 data 对象的属性代理到 vm 实例上
  const keys = Object.keys(data);
  for (let i = 0; i < keys.length; i++) {
    proxy(vm, '_data', keys[i]);  // vm.count 等价于 vm._data.count
  }
  
  // 对数据对象做响应式处理
  observe(data);
}

【代码注释】data.call(vm, vm) 是调用 data 函数时传入当前实例作为 this 和第一个参数,因此在 data() 里可以用 this 访问其他实例属性(不过通常不需要)。observe(data) 是 Vue 响应式系统的入口,会递归地用 Object.defineProperty 将数据对象的属性转为 getter/setter,以便依赖追踪。

【实战要点】

  • 经典应用场景 :任何需要在页面中多次复用的组件(商品卡片列表、评论列表项、Tab 面板等),必须使用 data() 函数形式。
  • 常见坑 :将一个外部变量通过 data() { return externalObj } 返回,虽然语法上是函数,但返回的仍是同一引用------等同于直接把对象传进去,数据仍然共享。正确做法是 return { ...externalObj } 或在函数内 return { count: 0 } 字面量。
  • 性能与最佳实践data() 里应只包含驱动视图的响应式数据 ,不变的常量、计算中间值不应放入 data,因为 Vue 会为 data 的每个属性安装 getter/setter,无谓的字段会造成额外的内存消耗。

【本章小结】

对比项 data 是对象 data 是函数
多实例数据 共享同一引用,互相污染 每次调用产生新对象,完全独立
Vue 是否允许 组件中不允许,会有警告 推荐且必须
Vue 实例中 允许(只有一个实例) 同样推荐,但不强制

【面试考点】 Q:为什么 Vue 组件的 data 必须是函数,而 Vue 实例的 data 可以是对象? A:根本原因是组件会被多次实例化 。同一个组件定义可以在模板中出现 N 次,每次出现都调用 new VueComponent()。如果 data 是一个普通对象,所有实例会共享同一个对象引用(JS 对象按引用传递),修改一个实例的数据会污染所有实例。将 data 改为函数,每次实例化调用函数,return 语句在堆内存中创建全新对象,各实例数据完全独立。Vue 实例(new Vue())在一个页面通常只有一个,不存在共享问题,所以允许对象形式,但官方同样建议写成函数。


三、父向子通信:Props

3.1 基本用法

名词解释

  • Props(属性) :父组件向子组件传递数据的声明式接口。父组件在使用子组件标签时以属性形式传入值,子组件通过 props 选项声明并接收。
  • Props 命名转换 :HTML 属性名不区分大小写,Vue 自动将 camelCase 的 prop 名在模板中映射为 kebab-case(如 userNameuser-name)。

概念与底层原理

Vue 在初始化组件时调用 initProps(vm, propsOptions),将父组件传入的属性值($options.propsData)经过类型检查、默认值填充后,赋值给组件实例并做响应式处理。每当父组件的对应数据变化,Vue 的依赖追踪机制会自动更新子组件的 prop 值------这就是 props 的响应式更新原理。

子组件不允许直接修改 props。一旦修改,Vue 会在控制台发出警告:"Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders."

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Props 基本用法</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</head>
<body>
<div id="app">
  <h3>父组件数据:{{ userName }}</h3>
  <hr/>
  <!--
    a="1"        → 静态字符串 "1"
    :b="1"       → 动态绑定数字 1
    :user-name   → 驼峰 userName 的 kebab-case 写法
    :password    → 同名传递
  -->
  <child-info a="静态字符串" :b="100" :user-name="userName" :password="passWord"></child-info>
</div>
<script>
  new Vue({
    el: "#app",
    data: {
      userName: "张三",
      passWord: "abc123"
    },
    components: {
      ChildInfo: {
        // props 数组:声明允许接收的属性名列表
        props: ["a", "b", "userName", "password"],
        template: `
          <div style="border:1px solid #ccc;padding:12px;">
            <p>a(静态):{{ a }}(类型:{{ typeof a }})</p>
            <p>b(动态):{{ b }}(类型:{{ typeof b }})</p>
            <p>userName:{{ userName }}</p>
            <p>password:{{ password }}</p>
          </div>
        `
      }
    }
  })
</script>
</body>
</html>

【代码注释】a="静态字符串" 不加 :,传入的永远是字符串;:b="100" 加了 :,传入的是 JavaScript 数字 100。这是 Vue 中一个极常见的误区:count="5" 传入字符串 "5",而 :count="5" 传入数字 5,两者在数学运算中行为不同。user-nameuserName 的互相映射由 Vue 自动完成,子组件内统一用驼峰访问。市面应用 :所有基于 Vue 的 UI 组件库(Element UI 的 <el-input :value="val">)都遵循这套 props 传递规范。

【代码注释】上图展示 props 的单向数据流闭环:蓝色"父组件 data.userName 改为李四"经紫色"Vue 响应式系统"(setter 通知、依赖追踪)把新值下行到绿色"子组件 prop",触发子组件视图重渲染;红色节点强调子组件"不能直接修改 prop",若需反向影响父组件,必须走灰色虚线的 emit 上行而非直接赋值。为什么这样设计 :官方明确"所有的 prop 都使得其父子 prop 之间形成了一个单向下行绑定 :父级 prop 的更新会向下流动到子组件中,但是反过来则不行"(Prop)。单向约束让数据来源唯一可追溯,避免子组件偷偷改父级状态导致数据流难以理解。市面应用 :Element UI 的 <el-input :value="val"> 不会在内部直接写 value,而是 $emit('input', newVal) 把变更上抛给父级,完全遵循这套单向流契约。

3.2 类型限制与默认值

概念与底层原理

props 写为对象形式时,可以对每个 prop 进行完整约束:type(类型)、required(是否必填)、default(默认值)、validator(自定义校验函数)。这些校验发生在开发环境运行时,不影响生产包体积(生产环境 Vue 会跳过 prop 验证)。

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Props 类型限制与默认值</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</head>
<body>
<div id="app">
  <!-- 传递完整属性 -->
  <goods-card
    :price="128.5"
    goods-name="无线蓝牙耳机"
    :stock="30"
    :tags="['新品', '热卖']"
    status="sale"
  ></goods-card>
  <hr/>
  <!-- 仅传 price(其余使用默认值) -->
  <goods-card :price="99"></goods-card>
</div>
<script>
  new Vue({
    el: "#app",
    components: {
      GoodsCard: {
        props: {
          // 类型限制:必须是 Number
          price: {
            type: Number,
            required: true   // 不传则控制台警告
          },
          // 类型限制 + 默认值(基本类型可直接写值)
          goodsName: {
            type: String,
            default: "默认商品名称"
          },
          // 多类型:Number 或 String 均可
          stock: {
            type: [Number, String],
            default: 0
          },
          // 数组/对象的 default 必须是工厂函数(原因同 data)
          tags: {
            type: Array,
            default() {
              return [];
            }
          },
          // 自定义校验器
          status: {
            type: String,
            default: "sale",
            validator(value) {
              // 只允许这三个值,否则发出警告
              return ["sale", "soldout", "coming"].includes(value);
            }
          }
        },
        template: `
          <div style="border:1px solid #eee;padding:16px;border-radius:8px;width:240px;margin:8px;">
            <h4>{{ goodsName }}</h4>
            <p style="color:#f60;font-size:18px;">¥ {{ price.toFixed(2) }}</p>
            <p>库存:{{ stock }}</p>
            <p>标签:<span v-for="t in tags" :key="t" style="margin-right:4px;background:#e8f4ff;padding:2px 6px;border-radius:3px;">{{ t }}</span></p>
            <p>状态:{{ status }}</p>
          </div>
        `
      }
    }
  })
</script>
</body>
</html>

【代码注释】type 可以是 StringNumberBooleanArrayObjectDateFunction,或任意构造函数(Vue 会用 instanceof 检查)。required: truedefault 不能同时使用(必填意味着不需要默认值)。数组/对象的 default 必须写为函数 ,原因与 data 为函数同理:避免多个组件实例共享同一个默认数组/对象引用。validator 返回 false 时只发出警告,不阻断渲染------这是开发时的辅助工具而非运行时的安全墙。市面应用:Element UI 的每个组件都有详尽的 props 类型声明,这既是文档,也是开发时的错误提示系统。

底层补充:props 校验在"实例创建之前"执行(面试加分项)

很多人以为 type/validator 是在组件挂载时校验,其实更早。官方文档明确指出:"prop 会在一个组件实例创建之前进行验证,所以实例的 property(如 datacomputed 等)在 defaultvalidator 函数中是不可用的"Prop 验证)。源码层面,initProps_init 流程里先于 initDatainitComputed 执行,因此:

javascript 复制代码
// ❌ 反模式:default 函数里访问 this.data(此时 data 尚未初始化)
props: {
  pageSize: {
    type: Number,
    default() {
      return this.defaultSize  // undefined!data 还没建立
    }
  }
}

// ✅ 正确:默认值只用常量或入参,不依赖实例状态
props: {
  pageSize: { type: Number, default: 10 }
}

【代码注释】这段揭示一个隐蔽的执行时序坑:default/validator 函数运行时,组件的 data/computed 还没初始化,this.defaultSize 取到的是 undefined。根因是 Vue 的 _init 顺序为 initProps → initMethods → initData → initComputed------props 必须先于 data 就绪,因为 data 函数可能要读 props(如 data() { return { local: this.propVal } })。为什么这样设计 :props 是组件的"输入契约",必须最先确定,后续的 data/computed 才能基于已确定的 props 求值。市面应用 :分页、表格等组件的默认值一律写成字面量常量,绝不在 default 里访问实例状态------这是组件库作者的硬性纪律。另外,props 校验代码在生产版 Vue 中会被 process.env.NODE_ENV !== 'production' 剥离,零运行时开销,因此它是纯开发期辅助工具。

3.3 Props 单向数据流原理

Vue 明确规定 props 是只读的。父组件传下来的 prop 不能在子组件内直接赋值修改。这一设计来自 React 的单向数据流(Unidirectional Data Flow)理念,在 Vue 2 文档中有明确说明:

"所有的 prop 都使得其父子 prop 之间形成了一个单向下行绑定:父级 prop 的更新会向下流动到子组件中,但是反过来则不行。这样会防止从子组件意外变更父级组件的状态,从而导致你的应用的数据流向难以理解。"

如果需要在子组件内部"修改" prop 的值,正确做法是:

javascript 复制代码
// 方案一:拷贝到本地 data
data() {
  return {
    localCount: this.count  // 将 prop 值拷贝为本地数据
  }
}

// 方案二:用 computed 基于 prop 派生新值
computed: {
  doubleCount() {
    return this.count * 2
  }
}

【代码注释】方案一把 prop 的初始值拷贝给本地 data 属性,此后子组件操作 localCount 而非 count,父组件重新渲染不会覆盖本地修改(但也不会同步新的 prop 值,需注意)。方案二用 computed 基于 prop 派生只读的新值,不修改 prop,仅作展示用途。市面应用 :分页组件接收父组件的 currentPage prop,内部 data 中保存 localPage = this.currentPage,用户翻页时更新 localPage$emit('update:currentPage', localPage) 通知父组件,这是最符合 Vue 单向数据流规范的写法。

3.4 商品卡片实战示例

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Props 实战:书单组件</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <style>
    .book-section { display: inline-block; margin: 8px; vertical-align: top; }
    .book-section h3 { padding: 8px 12px; color: #fff; border-radius: 6px 6px 0 0; margin: 0; }
    .book-section ul { border: 2px solid; border-top: none; border-radius: 0 0 6px 6px; padding: 8px 16px; }
  </style>
</head>
<body>
<div id="app">
  <!-- 通过 props 向子组件传递不同数据,复用同一个 book-list 组件 -->
  <book-list color="steelblue"  title="武侠小说" :book-info="wuxia"></book-list>
  <book-list color="crimson"   title="言情小说" :book-info="yanqing"></book-list>
  <book-list color="seagreen"  title="玄幻小说" :book-info="xuanhuan"></book-list>
</div>
<script>
  new Vue({
    el: "#app",
    data: {
      wuxia: [
        { id: 1, bookName: "鹿鼎记",   author: "金庸" },
        { id: 2, bookName: "圆月弯刀", author: "古龙" }
      ],
      yanqing: [
        { id: 3, bookName: "情深深雨濛濛", author: "琼瑶" },
        { id: 4, bookName: "千山暮雪",     author: "匪我思存" }
      ],
      xuanhuan: [
        { id: 5, bookName: "斗罗大陆", author: "唐家三少" },
        { id: 6, bookName: "吞噬星空", author: "我吃西红柿" }
      ]
    },
    components: {
      BookList: {
        props: {
          bookInfo: { type: Array, required: true },
          title:    { type: String, default: "书单" },
          color:    { type: String, default: "gray" }
        },
        template: `
          <div class="book-section">
            <h3 :style="{ background: color, borderColor: color }">{{ title }}</h3>
            <ul :style="{ borderColor: color }">
              <li v-for="book in bookInfo" :key="book.id">
                《{{ book.bookName }}》--- {{ book.author }}
              </li>
            </ul>
          </div>
        `
      }
    }
  })
</script>
</body>
</html>

【代码注释】同一个 book-list 组件复用三次,通过 props 传入不同的 colortitlebookInfo,呈现出三种不同风格的书单。这正是组件化的核心价值:一次定义,多次复用,数据由外部注入required: true 保证调用方不会漏传关键数据;default 保证非关键属性有合理的备选值。市面应用:电商平台商品卡片、新闻列表项、评论条目,几乎所有"批量渲染相同结构不同数据"的场景都是这个模式。

【实战要点】

  • 经典应用场景 :商品列表(<goods-card :item="item" v-for="item in list">)、表格行(<table-row :row="row">)、用户头像组件(<avatar :url="user.avatar" :name="user.name" :size="48">)。
  • 常见坑一 :忘记加 : 导致传入字符串。<goods-card price="99"> 中 price 是字符串 "99",调用 price.toFixed(2) 会报错"price.toFixed is not a function"。
  • 常见坑二 :在子组件中直接 this.price = 200 修改 prop,Vue 会警告且在父组件重新渲染时该修改被覆盖。
  • 性能与最佳实践 :对象/数组类型的 props 传递的是引用,子组件直接 pushsplice 修改会影响父组件数据(不会触发 Vue 警告,但破坏了单向数据流)。安全做法:子组件内拷贝一份 [...this.list] 再修改。

【本章小结】

功能 写法 示例
接收 prop 数组 props: ['title', 'count']
类型限制 对象 count: Number
必填 required { type: Number, required: true }
默认值 default { type: String, default: '默认' }
数组/对象默认值 工厂函数 default() { return [] }
自定义校验 validator validator(v) { return v > 0 }

【面试考点】 Q:Props 为什么不能直接修改?应该如何处理需要"修改 prop"的场景? A:Props 遵循单向数据流------父组件负责数据的所有权,子组件只有读权限。直接修改 prop 会导致"数据来源不明确",当父组件重新渲染时修改也会被覆盖。正确处理方式:①如果子组件只需要 prop 作为初始值,将其拷贝到 datadata() { return { localVal: this.propVal } };②如果需要基于 prop 派生值,用 computed;③如果需要更新父组件的数据,通过 $emit 通知父组件修改。


四、子向父通信:事件与回调

4.1 函数传递法

名词解释

  • 函数传递法:父组件将一个函数作为 prop 传递给子组件,子组件通过调用该函数向父组件传递数据或触发父组件的状态变更。

概念与底层原理

函数传递法实质上仍然是 props:只不过传递的 prop 值是一个 Function 类型。子组件调用该函数时,执行上下文(this)是父组件实例(因为函数在父组件中定义,被箭头函数或 .bind(this) 绑定了父组件上下文),因此在函数内可以直接操作父组件的 data

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>子向父:函数传递法</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</head>
<body>
<div id="app">
  <h3>父组件:弹窗 {{ isShow ? '已显示' : '已隐藏' }}</h3>
  <button @click="changeIsShow(true)">显示弹窗</button>
  <hr/>
  <!-- 将 changeIsShow 函数作为 prop 传入子组件 -->
  <modal-box v-show="isShow" :on-close="changeIsShow"></modal-box>
</div>
<script>
  new Vue({
    el: "#app",
    data: { isShow: false },
    methods: {
      // 父组件的方法:负责更新 isShow
      changeIsShow(val) {
        this.isShow = val;
      }
    },
    components: {
      ModalBox: {
        props: {
          onClose: Function   // 声明接收函数类型的 prop
        },
        template: `
          <div style="background:#f0f8ff;border:2px solid #4a9eff;padding:24px;border-radius:8px;width:300px;">
            <h4>我是弹窗组件</h4>
            <p>子组件通过调用父传来的函数来关闭弹窗</p>
            <!-- 子组件调用函数,传入 false,通知父组件关闭 -->
            <button @click="onClose(false)">关闭弹窗</button>
          </div>
        `
      }
    }
  })
</script>
</body>
</html>

【代码注释】onClose 是一个函数 prop,子组件调用 onClose(false) 时,实际执行的是父组件的 changeIsShow(false),因此 this.isShow 修改的是父组件的数据。函数的 this 指向父组件实例(Vue methods 中的函数被框架自动绑定了实例)。市面应用 :React 完全依赖这种模式(称为"回调 props");Vue 虽然有 $emit,但在需要强调"这是一个操作"而非"这是一个事件"时,函数传递法更直观(如 Dialog 的 on-confirmon-cancel)。

4.2 $emit 自定义事件

名词解释

  • 自定义事件(Custom Event) :由组件内部触发的非原生 DOM 事件,通过 $emit(eventName, ...args) 发出。
  • 事件监听 :父组件在使用子组件时,用 @eventName="handler" 监听子组件发出的自定义事件。

概念与底层原理

Vue 2 的自定义事件基于发布-订阅(EventEmitter)模式 实现。$emit$on$off$once 都是 Vue.prototype 上的方法。当父组件写 @suibian="changeNum" 时,Vue 内部等价于在子组件实例上调用了 childInstance.$on('suibian', changeNum.bind(parentInstance));当子组件调用 this.$emit('suibian', 1) 时,等价于触发了这个事件,父组件的处理函数随即执行。

官方文档对此有一句容易被忽略却高频考察的规定:"不同于组件和 prop,事件名不会有任何自动化的大小写转换。触发的事件名需要完全匹配监听这个事件所用的名称。"自定义事件)这意味着 prop 名可以"定义用驼峰、模板用短横线",但事件名 $emit('myEvent')@myEvent 必须逐字符一致------这也是官方推荐事件名一律使用 kebab-case(如 @my-event)的根本原因:DOM 模板里大写字母会被浏览器统一转小写,导致 @myEvent 永远监听不到。

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>子向父:$emit 自定义事件</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</head>
<body>
<div id="app">
  <h3>父组件计数:{{ count }}</h3>

  <!--
    @add="handleAdd":监听子组件发出的 add 事件
    如果写成 @add="handleAdd(5,$event)",子组件 $emit 传的参数会被覆盖
  -->
  <counter-panel :count="count" @add="handleAdd" @reset="count=0"></counter-panel>
</div>
<script>
  new Vue({
    el: "#app",
    data: { count: 0 },
    methods: {
      handleAdd(step, remark) {
        console.log("收到子组件的 add 事件,step:", step, "remark:", remark);
        this.count += step;
      }
    },
    components: {
      CounterPanel: {
        props: ["count"],
        template: `
          <div style="border:1px solid #ccc;padding:16px;border-radius:6px;">
            <p>子组件收到的 count:{{ count }}</p>
            <button @click="$emit('add', 1, '单步加1')">+1</button>
            <button @click="$emit('add', 5, '快速加5')">+5</button>
            <button @click="$emit('add', 10, '大步加10')">+10</button>
            <button @click="$emit('reset')">重置</button>
          </div>
        `
      }
    }
  })
</script>
</body>
</html>

【代码注释】$emit('add', 1, '单步加1') 第一个参数是事件名,后续参数依次传给父组件的处理函数。父组件的 handleAdd(step, remark) 分别接收 1 和 '单步加1'。注意:@add="handleAdd(5, $event)" 这种写法会导致子组件传来的参数被 $event 捕获的方式限制为单个,推荐不加括号:@add="handleAdd",此时 Vue 会将 $emit 的所有参数自动展开传给 handleAdd市面应用 :Element UI <el-input @input="handler"> 内部就是 this.$emit('input', value)<el-form>@validate 事件携带验证结果。

【代码注释】上图把一次 $emit 的完整链路拆成五步:蓝色"子组件 $emit('add', 1, '单步加1')'"→紫色"Vue EventEmitter 遍历 add 事件订阅者列表"→黄色"父组件触发 @add 监听器,参数 (1, '单步加1')"→绿色"handleAdd 执行、this.count += 1"→橙色"数据通过 props 流回子组件触发视图更新",灰色虚线表示这是一个单向闭环。为什么这样设计$emit/$on/$off/$once 都定义在 Vue.prototype 上,基于发布-订阅模式;父组件写 @add="handleAdd" 时 Vue 内部等价于 childVm.$on('add', handleAdd),子组件 $emit 时遍历该事件的回调数组依次调用。这套机制让"事件上行 + 数据下行"形成可预测的环路。市面应用 :Element UI <el-input @input="..."> 内部正是 this.$emit('input', value)<el-form> 校验后 $emit('validate', ...) 携带结果,全部走这条订阅-派发链路。

函数传递法 vs $emit 对比

维度 函数传递法 $emit 自定义事件
语法 :on-close="fn" @close="fn"
子组件调用 this.onClose(args) this.$emit('close', args)
子组件 props 声明 需要(onClose: Function 不需要
语义 偏"执行操作" 偏"通知事件发生"
Vue 官方推荐 均可,$emit 更符合 Vue 惯例 推荐

【实战要点】

  • 经典应用场景 :子组件是表单项,数据验证通过后 $emit('submit', formData) 通知父组件提交;子组件是列表项,点击删除时 $emit('delete', item.id) 通知父组件从列表移除。
  • 常见坑 :事件名使用驼峰(如 @myEvent)在某些场景下可能失效(DOM 事件名不区分大小写,Vue 模板中建议使用 kebab-case:@my-event)。Vue 官方风格指南推荐自定义事件名全部小写或 kebab-case。
  • 性能与最佳实践 :避免在同一个组件的同一个事件上挂载大量监听器;组件销毁时若手动用 $on 挂了事件,务必在 beforeDestroy 中用 $off 取消,否则内存泄漏。

【本章小结】

通信方向 方案 核心 API
子 → 父 函数传递法 props: { fn: Function } + this.fn(args)
子 → 父 $emit 自定义事件 this.$emit('eventName', args) + @eventName="handler"

【面试考点】 **Q: emit的底层实现是什么?父组件如何接收子组件传来的数据?∗∗A:'emit 的底层实现是什么?父组件如何接收子组件传来的数据?** A:` emit的底层实现是什么?父组件如何接收子组件传来的数据?∗∗A:'emit基于 Vue 内部的 EventEmitter 实现(类似 Node.js 的events模块)。父组件在模板中写@custom-event="handler",Vue 内部调用 childVm. on(′custom−event′,handler.bind(parentVm))';子组件执行'this.on('custom-event', handler.bind(parentVm))`;子组件执行 `this. on(′custom−event′,handler.bind(parentVm))';子组件执行'this.emit('custom-event', data1, data2),内部遍历该事件的订阅者列表并依次调用。数据通过 $emit 的第二个及后续参数传递,父组件的 handler 函数按位接收:handler(data1, data2)`。


五、.sync 修饰符:双向绑定语法糖

名词解释

  • .sync 修饰符 :Vue 2.3+ 引入的语法糖,:prop.sync="val" 等价于 :prop="val" @update:prop="val = $event",让 prop 的"双向同步"写法更简洁。

概念与底层原理

.sync 并不是真正的双向绑定(v-model 才是),它只是一个语法糖,展开后仍然是标准的"prop 下行 + $emit 上行"模式。子组件只需约定发出 update:propName 事件即可触发父组件更新。

【代码注释】上图展示 .sync 语法糖的固定展开规律:蓝色起点 :count.sync="num" 经黄色"编译展开"变成紫色的标准写法 :count="num" @update:count="num=event",子组件橙色节点 $emit('update:count', newVal) 触发后,父组件绿色节点 num = newVal 完成同步。关键约定是事件名必须是 update:属性名为什么这样设计.sync 本质不是真正的双向绑定,它在编译期被还原为"prop 下行 + $emit 上行"两条标准链路;用固定的 update:propName 命名让读者一眼看出"这是一个会回写父组件的双向 prop",比任意自定义事件名更具可读性,也便于 Vue 3 平滑迁移到 v-model:propName市面应用 :Element UI 的 <el-dialog :visible.sync="dialogVisible"> 就是典型用法------弹窗内部关闭时 this.$emit('update:visible', false),父组件 dialogVisible 随即变 false

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>.sync 修饰符示例</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</head>
<body>
<div id="app">
  <h3>父组件 num:{{ num }}</h3>
  <button @click="num = 1">重置为1</button>
  <hr/>

  <!-- 写法一:手动展开 -->
  <counter-one :num="num" @update:num="num = $event"></counter-one>
  <hr/>

  <!-- 写法二:使用 .sync 语法糖(等价于写法一) -->
  <counter-two :num.sync="num"></counter-two>
  <hr/>

  <!-- 写法三:所有 prop 都 sync(对象形式,适合表单类组件) -->
  <!-- <my-form v-bind.sync="formData"></my-form> -->
</div>
<script>
  new Vue({
    el: "#app",
    data: { num: 1 },
    components: {
      CounterOne: {
        props: ["num"],
        template: `
          <button @click="$emit('update:num', num + 1)">
            手动展开写法:{{ num }}(点击+1)
          </button>
        `
      },
      CounterTwo: {
        props: ["num"],
        template: `
          <button @click="$emit('update:num', num + 2)">
            .sync 语法糖写法:{{ num }}(点击+2)
          </button>
        `
      }
    }
  })
</script>
</body>
</html>

【代码注释】两种写法在功能上完全一致。.sync 只是让父组件模板更简洁:不需要手动写 @update:num="num = $event",Vue 编译器会自动展开。子组件固定发出 update:num 事件(约定大于配置),父组件接收到事件后自动赋值。市面应用 :Element UI 的 <el-dialog :visible.sync="dialogVisible"> 就是典型的 .sync 用法;弹窗内部关闭时执行 this.$emit('update:visible', false),父组件的 dialogVisible 随即变为 false 关闭弹窗。

【实战要点】

  • 经典应用场景 :弹窗/抽屉的显示状态(:visible.sync)、分页组件的当前页(:current-page.sync)、Select 组件的选中值(:value.sync)。
  • 常见坑.sync 修饰的 prop 名在子组件中 $emit 时必须完全匹配:prop 为 currentPage,事件名就是 update:currentPage(大小写敏感)。
  • Vue 3 变化 :Vue 3 移除了 .sync,将其功能合并到 v-model(Vue 3 的 v-model 支持参数:v-model:title="val" 等价于 Vue 2 的 :title.sync="val")。

【本章小结】

写法 展开形式 子组件触发事件
:count.sync="n" :count="n" @update:count="n=$event" $emit('update:count', val)
v-bind.sync="obj" 展开 obj 的所有属性并添加对应 update 事件 每个 prop 单独 emit

【面试考点】 Q:.sync 修饰符的原理是什么?它和 v-model 有什么区别? A:.sync 是语法糖,:prop.sync="val" 编译展开为 :prop="val" @update:prop="val=$event"。子组件通过 $emit('update:prop', newVal) 触发父组件更新。v-model 默认绑定的是 value prop 和 input 事件(可通过 model 选项修改),而 .sync 可以用在任意命名的 prop 上,且约定使用 update:propName 事件名。两者本质相同(都是"prop 下行 + emit 上行"),区别在于事件命名约定和适用场景:v-model 侧重表单元素的双向绑定,.sync 侧重组件状态的父子同步。


六、组件嵌套与通信实战案例

6.1 组件树与嵌套规则

名词解释

  • 组件树(Component Tree):应用中所有组件按父子关系形成的树形结构。根节点是根 Vue 实例,其直接子节点是根实例模板中使用的组件,以此类推向下递归。
  • 父组件/子组件 :在模板中使用 <my-comp> 的组件是父组件,my-comp 本身是子组件。

概念与底层原理

组件的 components 选项定义了该组件的"私有子组件注册表"------在该组件的 components 中注册的子组件,只能在该组件模板中使用,外部无法访问。这保证了组件封装性:父组件不需要知道子组件内部又使用了什么组件。

官方文档把这种树形组织方式概括为"组件是可复用的 Vue 实例......一个应用以一棵嵌套的组件树的形式来组织"(组件基础)。这棵树有两条铁律决定了数据流向:props 沿树向下(父传子)事件沿树向上($emit 子传父) 。因此处于同一层级的兄弟组件(如下例的 SidebarContent)之间无法直接通信,必须由它们的共同父组件 Main 充当"中转站"------父组件用 props 把数据下发给某个子组件,又监听另一个子组件 $emit 的事件来修改自身状态,从而间接打通兄弟间的数据流。这正是后续 EventBus、Vuex 等"跨层通信"方案要解决的痛点。

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>组件嵌套示例</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</head>
<body>
<div id="app">
  <!-- 根实例只需知道 App 组件 -->
  <App></App>
</div>
<script>
  new Vue({
    el: "#app",
    components: {
      App: {
        template: `
          <div style="border:2px solid #333;padding:16px;">
            <h3>App 组件(根)</h3>
            <!-- App 组件的模板中使用 Header 和 Main -->
            <Header></Header>
            <Main></Main>
          </div>
        `,
        // Header 和 Main 是 App 组件的私有子组件
        components: {
          Header: {
            template: `<header style="background:#4a9eff;color:#fff;padding:8px;"><h4>Header 组件</h4></header>`
          },
          Main: {
            template: `
              <main style="border:1px solid #999;padding:12px;margin-top:8px;">
                <h4>Main 组件</h4>
                <!-- Sidebar 和 Content 是 Main 的私有子组件 -->
                <Sidebar></Sidebar>
                <Content></Content>
              </main>
            `,
            components: {
              Sidebar: {
                template: `<aside style="display:inline-block;width:100px;background:#f5f5f5;padding:8px;vertical-align:top;">侧边栏</aside>`
              },
              Content: {
                template: `<section style="display:inline-block;width:200px;padding:8px;vertical-align:top;">主内容区</section>`
              }
            }
          }
        }
      }
    }
  })
</script>
</body>
</html>

【代码注释】SidebarContent 注册在 Main.components 中,在 App 的模板中直接使用 <Sidebar> 会报"Unknown custom element"。这种封装性确保了组件的可移植性:移动 Main 组件时,它的子组件会随之携带,外部无需关心其内部依赖。市面应用 :Vue CLI 生成的项目中,App.vue 引入 router-viewviews/Home.vue 引入若干 section 组件,每个 section 又包含更细粒度的 UI 组件,这就是真实项目的组件树。

【代码注释】上图把示例的组件树画成层级结构:蓝色"根 Vue 实例(#app)"下挂紫色"App 组件",App 再嵌套绿色"Header"与紫色"Main",Main 又包含绿色"Sidebar"与"Content"。为什么这样设计 :组件的 components 选项是该组件的私有子组件注册表,子组件只能在注册它的组件模板中使用,这保证了封装性------移动 Main 时它的子组件会随之携带。数据和事件沿这棵树流动:数据向下(props),事件向上($emit)。非父子组件(如 Sidebar 与 Content 是兄弟关系)之间的通信需要借助共同父组件 Main 中转,或用事件总线(EventBus)/Vuex。市面应用 :Vue CLI 项目里 App.vue 引入 router-view,页面级 Home.vue 引入若干 section 组件,每个 section 再拆更细粒度的 UI 组件,构成的正是这样一棵真实组件树。

6.2 Vue 实例与组件实例的关系

概念与底层原理

Vue 实例(new Vue())和组件实例(new VueComponent())有以下原型链关系:

javascript 复制代码
组件实例 → VueComponent.prototype → Vue.prototype → Object.prototype
Vue 实例  → Vue.prototype → Object.prototype

【代码注释】这段原型链示意图描述了 Vue 组件与 Vue 实例在继承上的关系。组件实例比 Vue 实例多一层 VueComponent.prototype,但两者最终都汇聚到 Vue.prototype。正是这个共同的祖先,使得 Vue.prototype 上挂载的任何属性($router$store$http 等),在根实例和所有子孙组件中都能通过 this.xxx 访问------这是 Vue 插件系统(Vue.use())的理论基础。

因此:

  • 组件实例.__proto__.__proto__ === Vue.prototypetrue
  • Vue.prototype 上挂载的属性/方法(如 Vue.prototype.$http = axios),在所有组件实例中均可通过 this.$http 访问
html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Vue 实例与组件实例的关系</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</head>
<body>
<div id="app">
  <button @click="checkVm">检查 Vue 实例原型链</button>
  <hr/>
  <child-comp></child-comp>
</div>
<script>
  // 在 Vue.prototype 上挂载全局属性,所有实例和组件均可访问
  Vue.prototype.$globalConfig = { apiBase: "https://api.example.com" };

  const vm = new Vue({
    el: "#app",
    methods: {
      checkVm() {
        console.log("vm.__proto__ === Vue.prototype:", vm.__proto__ === Vue.prototype);
        console.log("Vue 实例访问 $globalConfig:", this.$globalConfig);
      }
    },
    components: {
      ChildComp: {
        template: `<button @click="checkComp">检查组件实例原型链</button>`,
        methods: {
          checkComp() {
            // 组件实例的原型链:VueComponent.prototype → Vue.prototype
            console.log("组件实例.__proto__.__proto__ === Vue.prototype:",
              this.__proto__.__proto__ === Vue.prototype);
            // 组件实例也能访问挂载在 Vue.prototype 上的属性
            console.log("组件实例访问 $globalConfig:", this.$globalConfig);
          }
        }
      }
    }
  })
</script>
</body>
</html>

【代码注释】打开控制台点击两个按钮,会看到两个 true 以及相同的 $globalConfig 输出。这解释了为什么 Vue.use(axios)Vue.prototype.$axios = axios 后,在任意组件中都能用 this.$axios------因为所有组件实例通过原型链最终都链接到 Vue.prototype市面应用Vue.prototype.$bus = new Vue() 创建全局事件总线;Vue.prototype.$toast = toast 注入全局提示方法;都利用了这个原型链继承机制。

【实战要点】

  • 经典应用场景 :插件开发(如 vue-routervuexaxios)都利用 Vue.prototype 注入全局实例,供所有组件通过 this.$routerthis.$storethis.$http 访问。
  • 常见坑 :在 Vue.prototype 上添加非函数的对象(如 Vue.prototype.$config = { base: '...' }),所有组件共享同一个对象引用------修改会影响所有组件。应对策略:函数形式工厂 / 只读 Object.freeze
  • 性能与最佳实践 :不要在 Vue.prototype 上挂载过多内容,尤其是大对象------每个组件实例访问都会触发原型链查找。Vuex 通过 $store 暴露一个统一的 store 对象,是比 Vue.prototype 散列挂载更规范的状态管理方案。

【本章小结】

概念 构造函数 原型链
Vue 实例 new Vue() vm.__proto__ === Vue.prototype
组件实例 new VueComponent() comp.__proto__.__proto__ === Vue.prototype
全局属性注入 Vue.prototype.$x = ... 所有实例和组件均可通过 this.$x 访问

【面试考点】 Q:Vue 实例和组件实例有什么关系?为什么在 Vue.prototype 上添加属性,所有组件都能访问? A:组件实例由 Vue.extend() 生成的 VueComponent 构造函数创建,VueComponent.prototype.__proto__ === Vue.prototype,形成了原型继承关系。JS 的原型链查找机制保证:当组件实例访问 this.$http 时,若实例本身没有,就沿原型链找到 VueComponent.prototype,再找到 Vue.prototype,若在那里定义了 $http,就能访问到。这是 Vue 插件系统的底层基础。

6.3 Tab 切换综合案例

这是一个综合示例,覆盖组件嵌套、props 传递、$emit 通信三个核心点:

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Tab 切换综合案例</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <style>
    .tab-container { font-family: sans-serif; max-width: 500px; }
    .tab-nav { display: flex; border-bottom: 2px solid #4a9eff; }
    .tab-btn {
      padding: 8px 20px; cursor: pointer; border: none;
      background: #f0f0f0; border-radius: 4px 4px 0 0; margin-right: 4px;
    }
    .tab-btn.active { background: #4a9eff; color: #fff; }
    .tab-content { padding: 16px; border: 1px solid #4a9eff; border-top: none; border-radius: 0 0 4px 4px; }
  </style>
</head>
<body>
<div id="app">
  <!-- 根实例只管理数据和使用顶层组件 -->
  <tab-app :tabs="tabList"></tab-app>
</div>

<template id="tabAppTpl">
  <div class="tab-container">
    <!-- 将 tabs 数据和 activeIndex 传给 tab-nav,并监听 change 事件 -->
    <tab-nav
      :tabs="tabs"
      :active-index="activeIndex"
      @change="activeIndex = $event"
    ></tab-nav>
    <!-- 将当前激活的 tab 内容传给 tab-content -->
    <tab-content :content="tabs[activeIndex].content"></tab-content>
  </div>
</template>

<template id="tabNavTpl">
  <div class="tab-nav">
    <button
      v-for="(tab, i) in tabs"
      :key="tab.id"
      class="tab-btn"
      :class="{ active: i === activeIndex }"
      @click="$emit('change', i)"
    >{{ tab.label }}</button>
  </div>
</template>

<script>
  new Vue({
    el: "#app",
    data: {
      tabList: [
        { id: 1, label: "商品详情", content: "这里是商品详情的文字内容,包含规格参数、包装信息等。" },
        { id: 2, label: "用户评价", content: "用户评价区域:张三说「质量很好」,李四说「物流很快」。" },
        { id: 3, label: "售后服务", content: "7天无理由退换货,官方正品保障,全国联保两年。" }
      ]
    },
    components: {
      TabApp: {
        props: {
          tabs: { type: Array, required: true }
        },
        data() { return { activeIndex: 0 }; },
        template: "#tabAppTpl",
        components: {
          TabNav: {
            props: {
              tabs: Array,
              activeIndex: Number
            },
            template: "#tabNavTpl"
          },
          TabContent: {
            props: { content: String },
            template: `<div class="tab-content">{{ content }}</div>`
          }
        }
      }
    }
  })
</script>
</body>
</html>

【代码注释】整个案例的数据流:根实例的 tabList 通过 props 传给 TabAppTabApp 管理 activeIndex 状态,再分别传给 TabNav(用于高亮激活项)和 TabContent(用于显示内容);TabNav 在用户点击时 $emit('change', i) 通知 TabApp 更新 activeIndexactiveIndex 变化后 TabContent 自动重新渲染。这个单向闭环展示了组件化的完整通信模式。市面应用 :各大 UI 组件库的 Tabs(如 Element UI <el-tabs>、Ant Design <a-tabs>)内部结构与此高度一致,只是增加了动画、过渡、懒加载等工程化细节。


总结

知识点回顾(思维导图)

【代码注释】上图以蓝色"Vue2 组件化"为根,展开五个主要维度:紫色"组件定义"(字符串/script/template 三种模板写法)、黄色"注册方式"(局部 components 与全局 Vue.component)、橙色"data 为函数"(工厂函数原理、内存隔离、每次 new 返回新对象)、绿色"父子通信"(Props 类型/默认/必填/校验/单向流、函数传递法、$emit.sync)、灰色"组件嵌套"(组件树、私有子组件、原型链关系)。这五个维度并非孤立,它们共同构成 Vue 组件化开发的完整体系------定义与注册解决"组件从哪来",data 为函数解决"实例数据如何隔离",父子通信解决"组件间如何协作",嵌套与原型链解决"组件如何组织成应用"。

高频面试题速查

Q1:为什么 Vue 组件的 data 必须是函数?

A:组件可以被多次实例化(同一个组件标签出现 N 次就创建 N 个实例)。如果 data 是普通对象,所有实例会共享同一个对象引用(JS 对象按引用传递),修改一个实例的数据会污染所有实例。data 改为函数后,每次 new VueComponent() 时 Vue 调用 data() 函数,return 语句在堆内存创建全新对象,各实例完全独立。Vue 实例(new Vue())通常只有一个,不存在共享问题,故可以是对象。

Q2:props 和 data 的区别是什么?

A:data 是组件自己管理、驱动自身视图的私有数据,可以自由读写;props 是由父组件传入的外部数据,在子组件中是只读的(单向数据流)。data 通过 data() 函数定义;props 通过 props 选项声明并接收。两者都会被 Vue 代理到实例上(通过 this.xxx 访问),但修改 prop 会触发警告。

Q3:props 的类型验证在什么阶段执行?生产环境有效吗?

A:props 验证在运行时 执行,在组件实例初始化阶段(initProps)调用。生产环境无效 :Vue 的生产版本(vue.min.js)已将 prop 验证代码通过 process.env.NODE_ENV !== 'production' 条件判断剥离,不会产生任何运行时开销。因此 prop 验证是纯粹的开发时辅助工具。

Q4:$emit 的原理是什么?

A:$emit 基于发布-订阅模式。父组件模板中 @eventName="handler" 等价于 childVm.$on('eventName', handler),将处理函数注册到子组件实例的事件订阅表中。子组件执行 this.$emit('eventName', args) 时,遍历该事件的订阅者并逐一调用。$emit$on$off$once 均定义在 Vue.prototype 上,所有组件实例继承可用。

Q5:.sync 与 v-model 的区别?

A:两者本质相同,都是"prop 下行 + emit 上行"的语法糖。v-model 默认绑定 value prop 和 input 事件(可通过 model 选项修改),主要用于表单元素;.sync 可以绑定任意命名的 prop,约定使用 update:propName 事件名,主要用于非表单类组件的状态同步(如弹窗 visible、分页 current)。Vue 3 移除了 .sync,统一为支持参数的 v-model:propName

Q6:Vue 实例和组件实例有什么共同点和区别?

A:共同点 :配置选项基本一致(datamethodscomputedwatchfilters、生命周期钩子),都支持响应式数据,原型链最终都到达 Vue.prototype区别 :Vue 实例需要 el 选项指定挂载元素,data 可以是对象;组件实例不需要 el(由父组件模板的标签决定挂载位置),data 必须是函数。底层上,Vue 实例由 Vue 构造函数创建,组件实例由 VueComponentVue.extend() 生成的子类)创建。

学习建议

  1. 动手实践优先 :本篇的每个 HTML 示例都可以直接保存在本地目录运行,建议按顺序逐个运行,在浏览器控制台观察输出,对比函数 data 与对象 data 的差异。

  2. 面试前精读两章:如果时间有限,"二、data 必须是函数"和"三、父向子通信 Props"是出现频率最高的面试考点,需要能脱口说出底层原理。

  3. 向 Vue 3 过渡 :Vue 3 的组件系统与本篇内容高度一致,核心差异在于:data() 改写为 setup() 中的 ref()/reactive();props 声明方式相同但接收方式改变;$emit 改为 emit 函数(setup(props, { emit }))。理解本篇原理后,Vue 3 的这些变化会水到渠成。

  4. 官方文档参考Vue 2 组件基础Props 深入 是本篇知识点的权威出处,遇到疑问优先查阅。

  5. 下一步:掌握本篇后,建议学习"非父子组件通信"(EventBus 全局事件总线、provide/inject)以及 Vuex 状态管理,它们是本篇父子通信的自然延伸。

相关推荐
Momo__2 小时前
TypeScript satisfies 操作符——比 as 更安全的类型守门员
前端·typescript
用户2136610035722 小时前
Vue2事件系统与指令进阶
前端·vue.js
labixiong2 小时前
实现一个能跑的迷你版Promise(一)
前端·javascript·面试
Csvn4 小时前
`??` 和 `||` 搞混,线上用户头像全挂了
前端
kyriewen4 小时前
白宫前脚下了限制令,OpenAI 后脚就把 GPT-5.6 发了
前端·gpt·openai
用户40269244819085 小时前
CRMEB Pro 新增后台接口全链路:路由、权限、验证器、返回格式一次讲清
前端·后端
泉城老铁5 小时前
springboot+vue+ ffmpeg 实现视频的拉流播放
前端
逸铭5 小时前
Day 5:三栏布局——左账号 / 中聊天 / 右工具
vue.js·electron
PedroQue996 小时前
uni-router v1.8.0新增冷启动守卫补执行
前端·uni-app