Skip to content

组件通信与进阶用法(一) ​

本页对应 组件通信与进阶用法,共 6 段视频,约 1 小时 9 分。

学习目标 ​

  • 能用自己的话说明组件是什么,并列举组件化开发至少两个好处(如复用、易维护)。
  • 能准确指出单文件组件的三个组成部分,并说明 <template> 中只能有一个根元素的原因及多根元素时的处理方式。
  • 能独立完成组件的局部注册:在 components 目录创建组件、在父组件中导入、注册为标签并在模板中使用。
  • 能区分局部注册与全局注册的适用范围,并在 main.js 中完成全局组件注册,在任意组件中调用验证。
  • 能为组件样式添加 scoped 解决样式冲突,并将组件 data 改写为函数形式以返回独立数据对象。
  • 能根据给定页面结构完成组件拆分与局部注册,并通过 Vue 调试工具检查父子组件的嵌套关系。

知识点 ​

本节目标与学习路径 ​

本节笔记列出的目标是:组件三大组成部分(结构/样式/逻辑)、scoped 解决样式冲突、data 是一个函数;组件通信;小黑记事本组件版;以及 v-model、sync、ref/$refs、$nextTick 等进阶语法。需要说明的是,配套转写稿实际展开的是:组件是什么、单文件组件三部分、局部注册、全局注册,以及“小兔仙”页面的组件拆分与注册。组件通信、小黑记事本、进阶语法在转写稿中未展开,本文不编造细节,只按笔记保留目标,重点讲清已讲内容。转写稿里的“组建、GS、拍late、浪、赖斯、componies、SRRC”等,分别应纠正为“组件、JS、template、lang、less、components、src”。

组件是什么:页面拆分与复用 ​

为什么需要:一个页面如果所有结构、样式、逻辑都堆在 App.vue 里,代码会到几百行,难找难维护;按钮、头部、底部等重复出现时,每页都写一遍也浪费。
怎么想:组件就是页面的一部分。页面可以拆成一个个组件,组件再嵌套组件,最后由 App 根组件拼装。组件最大的好处是复用。
怎么做:把通用部分写成 .vue 文件,放到 components 目录;App.vue 作为根组件导入并使用它们。转写稿强调:页面中能看到的部分都可以理解为组件,组件关系像嵌套盒子。
易错点:不要把普通组件放 src 根下,应放 components;组件名、文件名、标签名尽量保持一致;App 是根组件,其他组件要导入到 App 中才显示。

单文件组件三部分:template、style、script ​

为什么需要:每个组件要有独立的结构、样式和逻辑,否则复用后互相干扰。
怎么想:template 是结构,只起包裹作用,最终不会渲染成真实标签;script 放 Vue 逻辑并导出对象;style 默认只支持 CSS。
怎么做:基本结构如下(示意):

vue
<template>
  <div class="app">...</div>
</template>

<script>
export default {
  name: 'App'
}
</script>

<style lang="less">
.app { padding: 10px; }
</style>

转写稿调试过程:先写普通 CSS 可以生效;写嵌套样式不生效,因为默认不是 less;把 lang 改成 less 后报错缺少加载器,于是安装 less 和 less-loader,再刷新即可。若用 SCSS,则安装 sass、sass-loader,并把 lang 写成 scss。
易错点:template 有且只有一个根元素;要放两个按钮时,用一层 div 包起来;不写 lang 只支持 CSS;安装 loader 时注意开发依赖;变量导入后提示“已定义但未使用”不等于写错,注册并使用后提示会消失。

局部注册:导入、注册、使用三步 ​

为什么需要:有些组件只在 App 中使用,局部注册可以避免全局污染。
怎么想:先导入组件变量,再在 components 中注册成标签,最后在模板中使用标签。
怎么做:

vue
<script>
import Header from './components/Header.vue'
import Main from './components/Main.vue'
import Footer from './components/Footer.vue'

export default {
  components: {
    Header,
    Main,
    Footer
  }
}
</script>

<template>
  <div class="app">
    <Header />
    <Main />
    <Footer />
  </div>
</template>

易错点:components 不要拼错;注册名和模板标签名要对应;可以写单标记标签,也可以写短横线形式;忘记第三步使用时,控制台会提示“已注册但未使用”;局部注册的组件只在当前导入注册的组件中可用。

全局注册:在 main.js 注册一次 ​

为什么需要:像按钮这种在 Header、Main、Footer 都要用的通用组件,如果每个文件都导入注册太麻烦。
怎么想:把通用组件放到入口 main.js 中全局注册,之后任何组件都能直接用。
怎么做:

js
import Vue from 'vue'
import Button from './components/Button.vue'

Vue.component('Button', Button)

使用时在任意组件模板写 <Button />。转写稿演示:三个位置都出现按钮;修改按钮组件样式一次,三个按钮同时变化,体现复用价值。
易错点:Vue.component 的 V 大写;第一个参数标签名加引号,第二个参数是导入变量,不加引号;全局注册也要先导入组件;标签名大小写和注册名要一致。

案例:小兔仙页面拆分与局部注册 ​

为什么需要:原页面结构、样式、逻辑都集中在 App.vue,代码多、难维护。
怎么想:先备份原 src,再在 components 中创建 7 个组件;把 App.vue 中对应结构和 index.css 中对应样式按注释剪切到各组件;最后回 App.vue 导入、注册、使用。
怎么做:创建组件文件后,模板保留一个根 div,把剪切来的结构粘进去;样式也按注释分段搬过去;App.vue 中用 import 导入 7 个组件,在 components 中注册,再在模板中用单标记标签逐个使用。
易错点:不要直接覆盖 src,先备份;剪切时保留注释便于定位;不要多剪或少剪根元素;样式要跟着结构一起搬;导入注册后刷新页面应和原页面一致;用 Vue 调试工具检查组件嵌套关系是否正确。

小结与未展开目标 ​

本节已讲清:组件是页面的一部分,核心价值是复用;单文件组件由 template、script、style 组成;局部注册三步是导入、注册、使用;全局注册在 main.js 中两行完成;案例拆分要先建组件、再搬结构样式、最后导入注册。笔记中的组件通信、小黑记事本、v-model 原理、sync、ref/$refs、$nextTick 因转写稿未展开,本文不编造,留待后续课程继续。

1. 默认样式为什么会冲突 ​

为什么需要:组件化后,一个页面由多个组件拼装而成。如果每个组件里的 <style> 默认都作用到全局,那么不同组件中相同或相近的类名就会互相影响,出现“改了 A 组件,B 组件也变了”的样式冲突。

怎么想:先看组件模板。笔记里的 BaseOne.vue 和 BaseTwo.vue 都使用了同一个类名 base-one。只要样式是全局的,那么给 .base-one 写的规则就可能同时命中两个组件。转写稿里也反复强调:组件是页面的一部分,结构和样式应跟着组件走;拆分页面时,结构和对应样式要一起拆到组件文件中。样式归属不清楚,冲突就容易出现。

怎么做:保留下面的组件结构和引入关系,先观察默认行为。

BaseOne.vue:

vue
<template>
  <div class="base-one">
    BaseOne
  </div>
</template>

<script>
export default {

}
</script>
<style scoped>
</style>

BaseTwo.vue:

vue
<template>
  <div class="base-one">
    BaseTwo
  </div>
</template>

<script>
export default {

}
</script>

<style scoped>
</style>

App.vue:

vue
<template>
  <div id="app">
    <BaseOne></BaseOne>
    <BaseTwo></BaseTwo>
  </div>
</template>

<script>
import BaseOne from './components/BaseOne'
import BaseTwo from './components/BaseTwo'
export default {
  name: 'App',
  components: {
    BaseOne,
    BaseTwo
  }
}
</script>

易错点:不要以为“写在组件文件里”就天然局部;默认样式仍可能全局生效。类名相同只是表象,根因是样式作用域没有限制。转写稿里调试样式时还遇到 style 默认按 CSS 处理、写嵌套不生效、配置 lang="less" 并安装 less、less-loader 后才正常,这说明 scoped 管的是作用域,不是预处理器。

2. 用 scoped 把样式限定在当前组件 ​

为什么需要:多个组件可能复用相同类名,或者父页面有公共类名。我们希望组件样式只影响当前组件模板里的元素,避免互相污染。

怎么想:既然问题出在“样式作用到全局”,就要给样式加作用域限制。Vue 单文件组件提供了 scoped,加在 <style> 标签上,让这份样式只作用于当前组件。

怎么做:在组件的 <style> 上写 scoped,如笔记代码中的 <style scoped>。写完后,当前组件模板中的元素才会被这份样式命中;其他组件即使类名相同,也不会被这份样式直接作用。

易错点:scoped 要加在 <style> 上,不是加在 <script> 或模板上;不要把它理解成“改类名就安全”,类名仍可能重复。调试时打开开发者工具看元素和样式规则,确认当前组件元素是否带上了对应作用域属性。转写稿中“改一个按钮样式,三个地方一起变”说明复用组件能集中维护样式;反过来说,若每个组件都写同名全局类,就会互相覆盖。

3. scoped 原理:data-v-hash 属性选择器 ​

为什么需要:只知道加 scoped 还不够,理解编译结果才能在样式不生效时判断问题:是选择器没命中,还是元素不在当前组件作用域内。

怎么想:Vue 编译时会给当前组件的模板元素加上一个唯一标记,同时把 CSS 选择器改写成“原选择器 + 属性选择器”。这样样式规则就只匹配带该标记的元素。

怎么做:笔记给出的原理是:当前组件内标签都被添加 data-v-hash值 属性;CSS 选择器都被添加 [data-v-hash值] 属性选择器。最终效果是:必须是当前组件的元素,才会有这个自定义属性,才会被这个样式作用到。

scoped原理示意图:组件标签带 data-v-hash,CSS 选择器带属性选择器

易错点:不要手动编造或固定写 data-v-xxx,它是编译生成的;不同组件通常不同。调试时可在 Elements 面板查看标签属性,在 Styles 面板查看规则是否带 [data-v-...]。如果元素没有对应属性,样式自然不会命中。

4. 总结与检查 ​

为什么需要:把结论落到开发习惯上,避免每个组件都重复踩坑。

怎么想:回答笔记的三个问题:1)style 默认样式作用到全局;2)scoped 让样式只作用于当前组件;3)组件样式一般推荐加 scoped,全局重置或公共基础样式再放到全局样式中。

怎么做:组件私有样式写 <style scoped>;公共样式单独放全局。调试时先看两个组件是否用了相同类名,再看标签是否有 data-v-hash,最后看样式规则是否带属性选择器。

易错点:不要把 scoped 当成万能隔离;全局样式仍可能影响组件。也不要把预处理器配置问题和作用域问题混在一起:scoped 解决样式冲突,lang="less" 和 loader 解决的是样式语言支持。

data 为什么必须写成函数 ​

为什么需要:组件不是一次性的页面,而是可以被反复使用的零件。转写稿里讲组件好处时反复强调一句话——组件最大的好处就是复用,“多次使用”。一个按钮组件、一个计数器组件,在一个页面里出现几次,Vue 就会为它创建几个组件实例。既然实例有多个,每个实例就必须有自己的一份数据,否则改了一个,另一个跟着变,复用就失去了意义。

怎么想:如果 data 是一个普通对象,那么无论创建多少个实例,它们拿到的都是同一个对象的引用,数据就被“共享”了。要让每个实例都拿到属于自己的数据,就得在每次创建实例时重新造一份数据。而“调用一次函数就产生一个新对象”正好满足这个要求,所以 data 必须写成函数。

怎么做:data 写成函数,函数内部 return 一个对象,这个对象就是这个实例的数据。笔记里给出的结论是:

一个组件的 data 选项必须是一个函数。目的是为了:保证每个组件实例,维护独立的一份数据对象。每次创建新的组件实例,都会新执行一次 data 函数,得到一个新对象。

data 写成函数以保证每个组件实例拥有独立数据的示意图

易错点:初学最常见的错误是把 data 直接写成一个对象(data: { count: 100 })。这样写多个实例会共用同一份数据,点其中一个组件的“+”,另一个组件的数字也会跟着变,而且 Vue 在控制台会给出警告。记住判断标准:只要这个选项要“每个实例一份”,就用函数返回。

代码演示:BaseCount 组件 ​

先写一个计数器组件,注意 data 是 function () { return { ... } } 的形式:

vue
<template>
  <div class="base-count">
    <button @click="count--">-</button>
    <span>{{ count }}</span>
    <button @click="count++">+</button>
  </div>
</template>

<script>
export default {
  data: function () {
    return {
      count: 100,
    }
  },
}
</script>

<style>
.base-count {
  margin: 20px;
}
</style>

然后在 App.vue 里导入、注册、使用它:

vue
<template>
  <div class="app">
    <BaseCount></BaseCount>
  </div>
</template>

<script>
import BaseCount from './components/BaseCount'
export default {
  components: {
    BaseCount,
  },
}
</script>

<style>
</style>

这里的三步和转写稿里讲的局部注册完全一致:先导入、再注册、最后在模板里当标签用。注册时 components 里的键名 BaseCount,就是模板里写的标签名。

怎么验证“独立数据” ​

把 App.vue 模板里的 <BaseCount></BaseCount> 复制成两三个,刷新页面后分别点击各自的加号、减号。如果 data 写对了,每个计数器的数字互不影响,各自从 100 开始独立增减;如果 data 写成了对象,你会看到所有计数器的数字一起变化——这正是“共享同一个数据对象”的表现。这也是笔记里强调“每次创建新的组件实例,都会新执行一次 data 函数”的实际含义:实例有几个,data 函数就被调用几次。

易错点:验证时不要只放一个组件。只放一个实例时,函数写法和对象写法看起来“都对”,看不出区别,很容易误以为自己写对了。一定要放两个以上实例对比。

总结 ​

data 写成函数的目的是什么?答案就是一句话:保证每个组件实例都维护一份独立的数据对象。组件会被复用,复用就会产生多个实例,多个实例不能共用一份数据;把 data 写成函数,每次创建实例时执行一次函数、返回一个新对象,实例之间自然互不干扰。

写代码时记住这个模板:

js
data: function () {
  return {
    // 这个实例自己的数据
  }
}

只要看到组件的 data 后面跟的是 { 而不是 function,就应当立刻警觉——这大概率是一个会引发数据串味的错误。

什么是组件通信 ​

组件通信示意图

为什么需要:组件的数据是独立的,一个组件不能直接访问另一个组件的数据。转写稿里反复强调“组件就是页面的一部分”,页面由一个个组件拼装出来,组件之间必然要配合;配合的第一步,就是让数据在组件之间传递。

怎么想:先别急着写代码,先判断“谁用谁的数据”。如果子组件要展示父组件的数据,就是父向子;如果子组件里的操作要影响父组件,就是子向父。转写稿里说 APP 是根组件,其他组件都要导入到 APP 中使用,因为页面最终展示的是 APP。这个“包含关系”就是判断通信方向的基础。

怎么做:把组件通信理解为“数据传递 + 通知更新”。父组件把数据交给子组件展示;子组件通过事件通知父组件,由父组件修改自己的数据。不要在子组件里直接改父组件的数据。

易错点:不要把“组件嵌套”当成“数据自动共享”。组件数据独立,父子关系只说明结构包含,不说明数据可以直接访问;通信要显式传值和接收。

组件关系与通信方案 ​

组件关系分类

为什么需要:不同关系对应不同通信方式。笔记把关系分为父子关系和非父子关系;关系判断错了,方案就会用错。

怎么想:转写稿里用“组件树”解释嵌套:APP 是最大的盒子,里面可以放头部、主体、底部,主体里还能再放商品组件。谁包含谁,就是父子;没有直接包含关系的,就是非父子。先画清组件树,再选方案。

怎么做:父子关系优先用 props 向下传、$emit 向上通知。非父子关系的解决方案,笔记用图给出分类,本节重点先掌握父子通信流程,后续再展开非父子方案。

通信解决方案

易错点:不要一上来就找“全局方案”。转写稿讲局部注册时强调“在哪里导入、在哪里注册,就只能在哪里用”,这说明组件使用范围会影响通信设计;先确认关系,再选方案。

父子通信流程 ​

父子通信流程

为什么需要:父子通信是组件通信的主线。父组件掌握数据,子组件负责展示;子组件产生操作后,不能直接改父组件数据,要通知父组件更新。

怎么想:把流程拆成两条线:向下是“父传子”,靠 props;向上是“子通知父”,靠 $emit 触发事件,父组件监听事件并处理。

怎么做:父组件通过 props 将数据传递给子组件;子组件利用 $emit 通知父组件修改更新。父组件监听 $emit 触发的事件,并提供处理函数,在函数参数中获取子组件传过来的参数。

易错点:$emit 只是“发通知”,不是直接修改父组件数据;真正修改数据的动作要放在父组件的事件处理函数里。事件名、监听名要对应,处理函数参数别漏接。

父向子通信代码示例 ​

父组件通过 props 将数据传递给子组件。笔记中的父组件 App.vue:

vue
<template>
  <div class="app" style="border: 3px solid #000; margin: 10px">
    我是APP组件 
    <Son></Son>
  </div>
</template>

<script>
import Son from './components/Son.vue'
export default {
  name: 'App',
  data() {
    return {
      myTitle: '学前端,就来黑马程序员',
    }
  },
  components: {
    Son,
  },
}
</script>

<style>
</style>

子组件 Son.vue:

vue
<template>
  <div class="son" style="border:3px solid #000;margin:10px">
    我是Son组件
  </div>
</template>

<script>
export default {
  name: 'Son-Child',
}
</script>

<style>

</style>

父向子传值效果

为什么需要:父组件有数据,子组件要展示,就必须把数据“送下去”。转写稿里说组件可以复用,复用后的组件展示什么内容,往往由父组件决定。

怎么想:父组件使用子组件标签时,把数据作为属性写在子组件标签上;子组件内部声明 props 接收;模板中直接使用 props 接收的值。三步缺一不可。

怎么做:按笔记步骤:给子组件以添加属性的方式传值;子组件内部通过 props 接收;模板中直接使用 props 接收的值。上面示例保留了父子组件骨架和父组件数据 myTitle,真正传值时要在 <Son> 上添加属性,并在 Son 中声明 props。

易错点:只写父组件数据、不在子组件标签上传,子组件拿不到;子组件不声明 props,属性也不会成为可用的 props;props 是只读的,不要在子组件里直接修改 props。

子向父通信与调试 ​

子向父通信流程

为什么需要:子组件里可能发生点击、输入等操作,但数据属于父组件,子组件不能直接改,只能通知父组件。

怎么想:把 $emit 理解成“子组件给父组件发消息”。父组件要提前监听这个消息,并准备处理函数;处理函数的参数就是子组件传过来的数据。

怎么做:按笔记步骤:$emit 触发事件,给父组件发送消息通知;父组件监听 $emit 触发的事件;提供处理函数,在函数参数中获取传过来的参数。

易错点:事件名写错、父组件没监听、处理函数没接参数,都会导致“子组件发了但父组件没反应”。调试时可借助 Vue 调试工具查看组件嵌套关系;转写稿提到鼠标放在组件上,页面会出现蒙层,可用来确认父子结构。若组件根本不显示,先查局部注册三步:导入、注册、使用;若样式用了 less/scss 报缺少 loader,则按转写稿提示安装对应 loader,而不是误判成通信问题。

1. Props 定义 ​

为什么需要:页面拆成组件后,App 是根组件,UserInfo 这类子组件要被导入、注册、再放到 App 模板里使用。转写稿里反复说“页面可以拆成一个个组件”“App 是最大的盒子”,那父子组件之间总要有数据传递方式。Props 就是组件上注册的一些自定义属性。

怎么想:把组件想成一个自定义标签,标签上可以写属性,只不过这些属性由 Vue 管理,用来把父组件的数据交给子组件。

怎么做:在子组件上注册自定义属性;父组件使用子组件时,把这些属性写在标签上。注意本节笔记的子组件代码里 script 还是空的,说明它重点演示父组件如何传值,子组件接收声明的完整代码本节未展开。

易错点:不要把 props 和普通 HTML 属性混为一谈;也不要以为父组件写了属性,子组件就自动会显示,子组件还需要声明接收并在模板中使用。

2. Props 作用 ​

为什么需要:父组件 App 里已经有 username、age、car 等数据,子组件 UserInfo 负责展示个人信息。如果子组件直接写死数据,组件就不好复用。

怎么想:数据放在父组件,子组件通过 props 接收,这样同一个子组件可以接收不同数据。

怎么做:父组件在 data 中准备数据,在模板里通过 :username="username" 这类写法传给 UserInfo。

易错点:age="28" 和 :age="age" 不一样。前者传字符串,后者通过 v-bind 绑定表达式。对象、数组、布尔值通常要用绑定写法。

3. 特点:任意数量、任意类型 ​

为什么需要:真实组件往往不只接收一个姓名,还可能接收年龄、是否单身、座驾、爱好等。组件要灵活,就要允许传多个 prop,也允许不同类型。

怎么想:每一个自定义属性就是一个 prop;字符串、数字、布尔、对象、数组都可以作为值。

怎么做:看下面示意图和代码,父组件一次传了 5 个 prop。

Props 传递数据示意图

易错点:数量多时要注意命名清晰;对象和数组要用绑定;属性名在父组件模板和子组件声明中要对应。

4. 代码演示 ​

父组件 App.vue:

vue
<template>
  <div class="app">
    <UserInfo
      :username="username"
      :age="age"
      :isSingle="isSingle"
      :car="car"
      :hobby="hobby"
    ></UserInfo>
  </div>
</template>

<script>
import UserInfo from './components/UserInfo.vue'
export default {
  data() {
    return {
      username: '小帅',
      age: 28,
      isSingle: true,
      car: {
        brand: '宝马',
      },
      hobby: ['篮球', '足球', '羽毛球'],
    }
  },
  components: {
    UserInfo,
  },
}
</script>

<style>
</style>

子组件 UserInfo.vue:

vue
<template>
  <div class="userinfo">
    <h3>我是个人信息组件</h3>
    <div>姓名:</div>
    <div>年龄:</div>
    <div>是否单身:</div>
    <div>座驾:</div>
    <div>兴趣爱好:</div>
  </div>
</template>

<script>
export default {
  
}
</script>

<style>
.userinfo {
  width: 300px;
  border: 3px solid #000;
  padding: 20px;
}
.userinfo > div {
  margin: 20px 10px;
}
</style>

阅读方法:父组件先 import UserInfo,再在 components 中注册,模板里才能写 <UserInfo>。转写稿里说的“局部注册”就是:在哪里导入注册,才能在哪里使用。这里 UserInfo 在 App 中注册,所以 App 模板可以使用它并传 props。

调试思路:按转写稿习惯,先在 Vue 调试工具里确认 UserInfo 是 App 的子组件,再看父组件模板上的属性名和绑定符号是否正确。当前子组件模板只写了“姓名:”等文字,没有插值,script 也没声明 props,所以页面不会显示“小帅、28”等值。这不是父组件没传,而是本节笔记还没写子组件接收和渲染部分。

易错点:只写父组件传值,子组件不声明 props,就无法在子组件里使用这些值;组件没导入注册就写标签会报错;对象、数组、布尔值不要漏掉冒号绑定。

为什么需要 props 校验 ​

转写稿里反复强调:组件就是页面的一部分,组件最大的好处是复用——按钮、头部、底部都可以封装成一个组件,在多个页面或多个位置反复使用。也正因为要复用,组件接收的外部数据就不能假设“调用者一定传得对”。props 是父组件传给子组件的数据入口,如果入口不设检查,传字符串、传空、传错类型都可能让页面样式或逻辑异常。

所以 props 校验的作用是:为组件的 prop 指定验证要求,不符合要求时,控制台给出错误提示,帮助开发者快速发现错误。这里要纠正转写稿里的同音字:是“组件”不是“组题/图点”,是“复用”不是“富有”。

怎么想:不要等页面样式坏了再回头找,而是让组件自己“报错提醒”。
怎么做:在子组件的 props 里写校验规则。
易错点:校验不是强制拦截运行,而是开发期提示;不要把它当成业务逻辑的替代。

先想清楚:组件的 props 可以乱传吗 ​

笔记第 1 点提出“组件的 props 可以乱传吗”。答案是不应该乱传。转写稿里讲组件注册使用时,反复说“导入、注册、使用”三步,组件用起来像标签一样方便;但标签属性写错时,组件内部未必知道。比如进度条组件 BaseProgress 的 w 决定宽度,如果父组件传了 '30'、空值、负数或超过 100 的值,宽度样式就可能不符合预期。

为什么需要:组件越复用,调用点越多,靠人肉检查越不可靠。
怎么想:把 prop 当成组件的“接口参数”,接口要有类型和范围。
怎么做:先写最基础的类型校验,再按需要补非空、默认值、自定义校验。
易错点:不要直接在子组件里修改 prop;prop 应由父组件传,子组件只负责使用。

校验语法怎么落地 ​

props校验语法图

笔记列出四类:类型校验、非空校验、默认值、自定义校验。在 Vue 2 的 Options API 中,props 可以从数组写法升级为对象写法。

类型校验:为什么需要?防止把字符串当数字用。怎么想?先确认这个 prop 应该是什么类型。怎么做?props: { w: Number }。易错点:类型构造器写 Number,不要写成字符串 'Number'。

非空校验:为什么需要?防止父组件忘记传。怎么想?这个值是不是组件运行必需。怎么做?w: { type: Number, required: true }。易错点:required 和 default 同时乱用会让人误解“到底可不可以不传”。

默认值:为什么需要?让组件在未传值时也有可用状态。怎么想?选一个安全的初始值。怎么做?w: { type: Number, default: 0 }。易错点:数组/对象默认值在 Vue 2 中要用函数返回,避免多个实例共享同一引用。

自定义校验:为什么需要?类型对了,范围未必对。怎么想?把业务规则写成布尔判断。怎么做?例如宽度合理范围可写成:

js
w: {
  type: Number,
  validator(value) {
    return value >= 0 && value <= 100
  }
}

易错点:validator 要返回 true/false;返回 undefined 不算通过。以上写法是语法说明,下面代码仍保留笔记原样。

代码演示 ​

App.vue:

vue
<template>
  <div class="app">
    <BaseProgress :w="width"></BaseProgress>
  </div>
</template>

<script>
import BaseProgress from './components/BaseProgress.vue'
export default {
  data() {
    return {
      width: 30,
    }
  },
  components: {
    BaseProgress,
  },
}
</script>

<style>
</style>

BaseProgress.vue:

vue
<template>
  <div class="base-progress">
    <div class="inner" :style="{ width: w + '%' }">
      <span>{{ w }}%</span>
    </div>
  </div>
</template>

<script>
export default {
  props: ['w'],
}
</script>

<style scoped>
.base-progress {
  height: 26px;
  width: 400px;
  border-radius: 15px;
  background-color: #272425;
  border: 3px solid #272425;
  box-sizing: border-box;
  margin-bottom: 30px;
}
.inner {
  position: relative;
  background: #379bff;
  border-radius: 15px;
  height: 25px;
  box-sizing: border-box;
  left: -3px;
  top: -2px;
}
.inner span {
  position: absolute;
  right: 0;
  top: 26px;
}
</style>

调试与易错点 ​

转写稿里讲组件效果时,常用做法是“保存后打开页面看效果”“刷新一下”“用 Vue 调试工具看父子组件嵌套关系”。props 校验的调试也可以沿用这个流程:先看页面宽度是否正常,再打开控制台看有没有校验提示,最后用调试工具确认 App 是父组件、BaseProgress 是子组件,w 是否按预期传入。注意不要编造具体错误文案,不同 Vue 版本提示可能不同。

易错点总结:props 是只读的;类型校验只是开发期提示;required 表示必传;默认值要选安全值;自定义校验必须返回布尔值;组件注册使用仍是“导入、注册、使用”三步,props 校验是在子组件定义处完成的。

课件图示 ​

课件第10页:组件通信, 就是指组件与组件之间的数据传递。 什么是组件通信 组件A 自己的数据

课件第12页:props 和$emit 组件通信解决方案: 父子关系 非父子关系 1. pro

课件第16页:总结 总结 1. 两种组件关系分类和对应的组件通信方案 父子关系 → props

课件第5页:组件的三大组成部分- 注意点说明 逻辑 <script> 结构 <templat

课件第6页:组件的样式冲突scoped 默认情况:写在组件中的样式会全局生效→ 因此很容易造

课件第11页:组件关系分类: 1. 父子关系 2. 非父子关系 不同的组件关系和组件通信方案分

课件第13页:父子通信流程图: 父组件 子组件 子组件 data数据 props props

课件第17页:Prop 定义:组件上注册的一些自定义属性 Prop 作用:向子组件传递数据 什

代码示例 ​

代码一:scoped 解决样式冲突(BaseOne.vue / BaseTwo.vue / App.vue) ​

vue
<!-- components/BaseOne.vue -->
<template>
  <div class="base-one">
    BaseOne
  </div>
</template>

<script>
export default {}
</script>

<style scoped>
.base-one {
  width: 200px;
  height: 100px;
  background-color: pink;
  margin: 10px;
}
</style>
vue
<!-- components/BaseTwo.vue -->
<template>
  <div class="base-one">
    BaseTwo
  </div>
</template>

<script>
export default {}
</script>

<style scoped>
.base-one {
  width: 200px;
  height: 100px;
  background-color: skyblue;
  margin: 10px;
}
</style>
vue
<!-- App.vue -->
<template>
  <div id="app">
    <BaseOne></BaseOne>
    <BaseTwo></BaseTwo>
  </div>
</template>

<script>
import BaseOne from './components/BaseOne'
import BaseTwo from './components/BaseTwo'
export default {
  name: 'App',
  components: {
    BaseOne,
    BaseTwo
  }
}
</script>

关键行与易错点

  • 两个组件里都写了 .base-one 选择器,这正是“样式冲突”的场景;各自 <style> 加 scoped 后,两个盒子颜色各管各的,互不干扰。
  • 易错点:scoped 不加时,写在组件里的样式是全局生效的,后加载的 .base-one 会覆盖前一个,页面上两个盒子颜色一样——这就是冲突。
  • scoped 原理:当前组件内标签会被加上 data-v-hash 属性,同时 CSS 选择器被改写成 .base-one[data-v-hash],所以“必须是当前组件的元素才会被作用到”。
  • 子组件的根元素同时带父组件和子组件的 data-v-hash,所以父组件样式可以命中子组件根元素——这是 scoped 的常见迷惑点。

代码二:data 必须是函数(BaseCount.vue / App.vue) ​

vue
<!-- components/BaseCount.vue -->
<template>
  <div class="base-count">
    <button @click="count--">-</button>
    <span>{{ count }}</span>
    <button @click="count++">+</button>
  </div>
</template>

<script>
export default {
  data: function () {
    return {
      count: 100,
    }
  },
}
</script>

<style>
.base-count {
  margin: 20px;
}
</style>
vue
<!-- App.vue -->
<template>
  <div class="app">
    <BaseCount></BaseCount>
    <BaseCount></BaseCount>
    <BaseCount></BaseCount>
  </div>
</template>

<script>
import BaseCount from './components/BaseCount'
export default {
  components: {
    BaseCount,
  },
}
</script>

<style>
</style>

关键行与易错点

  • data: function () { return { ... } }:每次创建组件实例都会重新执行一次 data 函数,得到一份全新对象,所以点第一个 BaseCount 的 +,另外两个的 count 不会跟着变。
  • 易错点:写成 data: { count: 100 }(对象)时,三个实例共用同一份数据对象,改一个三个一起变——这就是“data 必须写函数”的原因。
  • 根组件 App 的 data 也只能是函数,虽然它只实例化一次,但语法要统一。
  • 模板中 @click="count++" 直接改 count,是因为 count 已由 data 返回并被 Vue 响应式处理。

代码三:父传子 props + props 校验(App.vue / BaseProgress.vue) ​

vue
<!-- App.vue -->
<template>
  <div class="app">
    <BaseProgress :w="width"></BaseProgress>
  </div>
</template>

<script>
import BaseProgress from './components/BaseProgress.vue'
export default {
  data() {
    return {
      width: 30,
    }
  },
  components: {
    BaseProgress,
  },
}
</script>

<style>
</style>
vue
<!-- components/BaseProgress.vue -->
<template>
  <div class="base-progress">
    <div class="inner" :style="{ width: w + '%' }">
      <span>{{ w }}%</span>
    </div>
  </div>
</template>

<script>
export default {
  // 校验写法:类型校验 + 非空校验 + 默认值 + 自定义校验
  props: {
    w: {
      type: Number,
      required: true,
      default: 0,
      validator(value) {
        return value >= 0 && value <= 100
      },
    },
  },
}
</script>

<style scoped>
.base-progress {
  height: 26px;
  width: 400px;
  border-radius: 15px;
  background-color: #272425;
  border: 3px solid #272425;
  box-sizing: border-box;
  margin-bottom: 30px;
}
.inner {
  position: relative;
  background: #379bff;
  border-radius: 15px;
  height: 25px;
  box-sizing: border-box;
  left: -3px;
  top: -2px;
}
.inner span {
  position: absolute;
  right: 0;
  top: 26px;
}
</style>

关键行与易错点

  • 父向子传值三步:① 父组件给子组件以属性的方式传值 :w="width";② 子组件内部用 props 接收;③ 模板中直接使用 w。
  • 易错点:w="30" 传的是字符串,w: 30 才是数字;要传变量/数字必须用 :(v-bind 简写)。
  • props 只读,子组件不能直接 this.w = xxx 修改,会报错;需要改必须通过子传父 $emit 通知父组件改。
  • 校验只起开发期提示作用:类型不对、required 缺失、validator 返回 false,都只在控制台报警告,页面照样渲染,不要指望它拦截。
  • props 写数组 ['w'] 只能接收,写对象才能做类型校验/非空/默认值/自定义校验四件事。
  • 把 w 用于 :style="{ width: w + '%' }" 时要注意单位拼接,w 是数字,不加 '%' 宽度不生效。

常见错误 ​

1. 模板里并列写了两个根标签

  • 现象:控制台报 Component template should contain exactly one root element,页面白屏或整块渲染失败。
  • 原因:单文件组件的 <template> 只允许一个根节点,多个并列标签无法确定挂载点。
  • 修法:外层套一个 <div>(或 <section> 等)作为唯一根,把并列内容包进去;不要试图用注释或空行"隔开"来绕过。

2. 子组件直接给 props 赋值

  • 现象:报 Avoid mutating a prop directly since the value will be overwritten,或改完父组件一刷新又被覆盖。
  • 原因:props 是单向数据流,只读;子组件持有的是父组件数据的引用/副本,直接改会破坏数据来源的唯一性。
  • 修法:子组件用 $emit('update-xxx', 新值) 通知父组件,由父组件改自己的 data;若只是想"基于初值做本地编辑",在 data() 里另存一份(this.xxx 作为初始值),并明确它不是同步的。

3. 传数字/布尔时漏写冒号

  • 现象::age 写成 age="18",子组件拿到的是字符串 "18";如果配了 type: Number 校验,控制台出现 Invalid prop: type check failed。
  • 原因:不加 v-bind 的属性值一律按字符串字面量传递。
  • 修法:传表达式(数字、布尔、对象、数组、变量)一律加 :;只有纯文本才不加。校验报错时先看是"值错了"还是"类型错了"。

4. scoped 之后改不动子组件内部样式

  • 现象:父组件写了 .child .title { color: red } 不生效;改第三方组件(弹窗、输入框)内部类名同样无效。
  • 原因:scoped 会把选择器编译成带 [data-v-hash] 的属性选择器,而子组件内部元素带的是它自己的 hash,匹配不上。
  • 修法:用深度选择器 ::v-deep(旧写法 /deep/、>>>)穿透,或把这段样式抽到不带 scoped 的全局样式文件中;同时反思是不是该用 props 控制样式而不是硬改。

5. 组件注册了但页面不显示 / 报未知标签

  • 现象:控制台警告 Unknown custom element: <xxx>,或标签原样留在 DOM 上不渲染。
  • 原因:三种常见情况——components 里漏注册;注册名与模板中标签名不匹配(驼峰注册却在模板里写成另一种写法);import 路径或文件名大小写不对。
  • 修法:注册名与使用名保持一一对应,多词组件统一用 kebab-case 使用(MyHeader → <my-header>);排查时先在 components 里确认键名,再看 import 路径。

6. data 写成对象

  • 现象:同一组件被使用两次,改其中一个的 count,另一个跟着变;或直接报 data functions should return an object。
  • 原因:对象是引用类型,多个实例共用同一份数据。
  • 修法:data() 返回一个新对象,每次实例化执行一次函数得到独立副本。

7. $refs 取不到值

  • 现象:this.$refs.input 是 undefined,或 $el 报错。
  • 原因:在 created 阶段 DOM 还没生成;或元素被 v-if 隐藏时根本不存在;或 ref 加在组件上却直接当 DOM 用。
  • 修法:在 mounted 及之后访问;v-if 场景先确保渲染再取;组件上的 ref 拿 DOM 用 this.$refs.xxx.$el。需要"改完数据立刻读 DOM"时配 $nextTick。

8. 列表用下标直接赋值,视图不更新

  • 现象:this.list[index] = newItem 后数据变了但页面不动(记事本案例的高频坑)。
  • 原因:Vue 2 无法拦截数组下标赋值和长度修改。
  • 修法:用 splice、push 等变更方法,或用 this.$set(this.list, index, newItem);删除用 filter/splice 而不是 delete。

练习 ​

练习 1|验证 data 与 scoped(10 分钟) 写一个 BaseCount 组件(含 +/- 按钮),在 App 中放两个实例,验证数据互不影响;再把 data 改成对象,观察两个实例是否联动。同时给两个组件都加 class="box",一个文件写 scoped、一个不写,各给 .box 一个背景色,观察污染情况。

  • 验收:能口述"为什么要函数"和"scoped 的 hash 加在哪"。

练习 2|props 校验全覆盖(15 分钟) 封装 BaseProgress,props 为 w,要求:type: Number、required: true、default: 0、validator 限定 0–100。父组件先传 "30"(字符串)、再传 120、最后不传,分别记录控制台提示,再改回正确传法。

  • 验收:三次错误提示都能对应到具体的校验规则上。

练习 3|父子通信闭环 + $nextTick(20 分钟) 父组件持有 list,子组件用 props 接收并渲染,子组件点击"删除"只负责 $emit,父组件收到后删除对应项,并在 $nextTick 里打印当前剩余条数、同时让一个搜索框自动获得焦点。

  • 验收:子组件内没有任何对 props 的赋值;打印出的条数是删除后的正确值(这是 $nextTick 的意义所在)。

练习 4|小黑记事本组件化(40 分钟) 把单文件版的记事本拆成 TodoHeader / TodoList / TodoFooter(或按你的职责划分),完成:列表渲染、添加、删除、统计未完成数、清空已完成。再补上 localStorage 持久化。

  • 验收:App 中只剩组装逻辑;刷新页面数据还在;统计数字与实际列表一致;拆分后每个文件不超过 100 行。

练习 5|封装可复用输入组件(进阶,25 分钟) 用 v-model(或 :value + @input)封装一个带清空按钮的搜索框组件,父组件能双向同步关键词;再给组件加一个 .sync 控制的"是否显示清空按钮"属性,父组件可动态开关。

  • 验收:父组件数据变化 → 子组件输入框同步;子组件输入 → 父组件数据同步;两个方向都不靠直接改 props。

原视频对照 ​

#视频时长
1组件的介绍和好处11:14
2组件的三个组成部分06:37
3局部注册使用组件13:22
4局部注册组件再次梳理10:37
5全局注册组件07:27
6案例-小兔仙组件拆分-注册局部组件19:42

小结 ​

  1. 组件拆分的判断标准是"是否复用 + 职责是否单一",而不是代码行数;400 行能拆、80 行也可能该拆。
  2. 数据流向永远是单向的:props 只读、$emit 只通知,谁的数据谁修改——这条守住了,组件通信就不会乱。
  3. 注册方式决定作用域:局部注册应当是默认选择,全局注册只留给真正跨多处复用的通用组件(按钮、图标、弹窗)。
  4. scoped 是隔离手段不是万能锁,遇到样式穿透说明你正在越界修改别人的组件,优先考虑用 props 控制外观。
  5. props 校验本质是给自己和团队写"运行期文档",开发阶段越早报错,联调阶段越省时间。
  6. ref / $refs 是逃生舱,只在必须操作 DOM 或直接调用子组件方法时打开,能靠数据驱动解决的就别用它。
  7. $nextTick 的使用信号是"我要在 DOM 更新之后做事情",不是"加上就不报错"——加上它之前先想清楚依赖的是哪一次渲染。
  8. 组件化的收益在项目中期才显现:早期多花的拆分时间,会在改样式、加需求、排查 bug 时连本带利还回来。