Skip to content

Props

如果我们正在构建一个博客,我们可能需要一个表示博客文章的组件。我们希望所有的博客文章分享相同的视觉布局,但有不同的内容。要实现这样的效果自然必须向组件中传递数据,例如每篇文章标题和内容,这就会使用到 props。

image-20240726104129320

封装文章组件

vue
<!-- BlogArticle.vue -->
<template>
  <div class="article">
    <div class="art-top">
      <span class="art-type">随笔</span>
      <h3 class="title">文章标题</h3>
      <span class="pub-time">2023年 5月 12日</span>

      <p class="summary">
        前两天,服务器经历了一次崩溃重装。服务器的崩溃导致了博客无法访问、数据丢失、escook 小程序停服,同时很多线上 API 接口服务被迫终止。 经过两天的重装处理之后,博客和 escook
        小程序已基本恢复正常。此次事件…
      </p>

      <button class="btn-more">阅读更多</button>
    </div>

    <div class="art-bottom">
      <span class="author">作者:周星驰</span>
      <div class="art-bottom__right">
        <span class="cmt">62 评论</span>
        <span class="favor">★取消收藏</span>
        <span class="favor">☆收藏</span>
      </div>
    </div>
  </div>
</template>

<script setup></script>
<style scoped>
.article {
  padding: 15px;
  box-sizing: border-box;
  border-bottom: 1px solid #efefef;
}

.art-top {
  display: flex;
  flex-direction: column;
  align-items: center;

  .art-type {
    font-size: 12px;
    font-weight: bold;
    color: #ca9b52;
  }

  .title {
    font-size: 30px;
    font-weight: normal;
    margin: 5px;
  }

  .pub-time {
    font-size: 13px;
    font-style: italic;
    color: #a1a1a1;
  }

  .summary {
    font-size: 13px;
    line-height: 2em;
    color: #464646;
    margin: 20px 0;
    text-align: justify;
  }

  .btn-more {
    color: #ca9b52;
    background-color: #fff;
    border: 1px solid #ca9b52;
    font-weight: bold;
    font-size: 11px;
    padding: 10px 25px;
    cursor: pointer;
    &:hover {
      opacity: 0.8;
    }
  }
}
.art-bottom {
  display: flex;
  justify-content: space-between;
  font-size: 12px;
  font-style: italic;
  margin-top: 20px;

  .author {
    color: #464646;
  }

  .cmt {
    color: #a1a1a1;
  }

  .favor {
    color: #464646;
    margin-left: 15px;
  }
}
</style>

并在 App.vue 中使用此文章组件:

vue
<!-- App.vue -->
<script setup>
import BlogArticle from './components/BlogArticle.vue'
</script>

<template>
  <h1>博客文章列表</h1>
  <hr />
  <BlogArticle />
  <BlogArticle />
</template>

声明 Props

在封装自定义组件时,往往需要渲染动态的数据,而非把数据写死到组件中。因此,那些需要让外界传入到组件中的数据,可以声明为 Props。

例如,把文章的标题声明为 BlogArticle 组件的 Props,这样我们在使用 BlogArticle 组件时就可以传入文章的标题,从而在实现 UI 结构复用的前提下渲染不同的数据。

声明 Props 的语法格式

在使用 <script setup> 的单文件组件中,props 可以使用 defineProps() 宏来声明:

vue
<script setup>
// defineProps() 是 Vue3 内置的宏函数,无需导入即可使用,且只能在 <script setup> 中使用
// defineProps() 可以接收一个数组,数组中的每一项都是字符串,表示 prop 的名称
// defineProps() 的返回值是一个对象,包含了所有接收到的 Props 的数据
const props = defineProps(['title'])

// 在 JS 中需要通过 defineProps 返回的 props 对象,来访问具体的 prop 值
console.log(props.title)
</script>

<template>
  <!-- 在 template 中,直接根据 prop 名称访问其数据 -->
  <h3 class="title">{{ title }}</h3>
</template>

传入 Props 的值

在使用声明了 Props 的自定义组件时,可以通过标签属性的方式传入 Props 的值:

html
<script setup>
  import BlogArticle from './components/BlogArticle.vue'
</script>

<template>
  <h1>博客文章列表</h1>
  <hr />
  <!-- 在使用 BlogArticle 组件时,传入 title 属性的值 -->
  <BlogArticle title="论持久战" />
  <BlogArticle title="星星之火可以燎原" />
</template>

Props 的命名格式

在 SFC 组件中,通过 defineProps() 宏函数声明 Props 时,如果一个 prop 的名字很长,应使用 camelCase(小驼峰) 的形式:

vue
<script setup>
const props = defineProps(['title', 'articleType'])

// 小驼峰的 prop 名称是合法的 js 标识符
console.log(props.articleType)
</script>

<template>
  <!-- 小驼峰标识符,在模板中也可以被直接使用 -->
  <span class="art-type">{{ articleType }}</span>
  <h3 class="title">{{ title }}</h3>
</template>

而在传入 Props 的属性值时,建议把 camelCase(小驼峰)的 prop 名称写成 kebab-case(连字符) 的形式:

html
<script setup>
  import BlogArticle from './components/BlogArticle.vue'
</script>

<template>
  <h1>博客文章列表</h1>
  <hr />
  <!-- 传入 prop 值时,推荐采用“连字符”的形式提供 prop 名称(和 HTML 的属性规范保持一致) -->
  <BlogArticle title="论持久战" article-type="军事" />
  <BlogArticle title="星星之火,可以燎原" article-type="文摘" />
</template>

总结

组件名采用大驼峰(PascalCase)命名法;声明 prop 时采用小驼峰(camelCase)命名法;传入 prop 值时,采用连字符(kebab-case)命名法。

静态和动态 Props

TIP

静态 Props 和动态 Props 的典型区别:是否使用 v-bind 指令修饰 prop。

静态 Props

在给组件传入 prop 值的时候,如果未使用 v-bind 指令修饰 prop,则传入的 prop 值是静态的。静态 prop 有两个特点:

  1. 值是静态的(写死的),不会发生响应式的更新;
  2. 值总是 String 字符串类型;

例如下面的 titlecmt-countfavor 三个 prop 值都是静态的,且 BlogArticle 组件接收到的值都是 String 类型:

html
<!-- 请注意:title、cmt-count、favor 三个 prop 的值都是 String 类型 -->
<BlogArticle title="论持久战" cmt-count="20" favor="true" />

动态 Props

相应地,还可以使用 v-bind(缩写 :)来绑定动态的 Props 值。动态绑定的 prop 值可以是字面量响应式数据表达式

动态绑定字面量数据:

html
<!-- 这里的3个 prop 都添加了 v-bind 指令修饰,因此它们是动态 prop -->
<!-- 写在 = 后面的是 js 字面量,因此字符串需要使用引号包裹 -->
<!-- title 的值是 String 类型;cmt-count 的值是 Number 类型;favor 的值是 Boolean 类型。 -->
<!-- 除此之外,还可以通过 prop 传入 Object、Array 等任意类型的动态值 -->
<BlogArticle :title="'论持久战'" :cmt-count="20" :favor="true" />

动态绑定响应式数据:

当响应式数据变化时,会更新传入的 prop 的值:

html
<BlogArticle :title="title" :cmt-count="cmtCount" :favor="favor" />

对应的数据源为:

js
const title = ref('论持久战')
const cmtCount = ref(22)
const favor = ref(true)

请注意:如果 prop 名数据名相同,可以简写为 :prop名(即省略掉 = 以及右侧的数据名):

html
<BlogArticle :title :cmt-count :favor />

动态绑定表达式数据:

在 v-bind 修饰的 prop 中,还可以使用表达式动态传入 prop 的值:

html
<BlogArticle :title="blog.title" :cmt-count="blog.cmtCount" :favor="blog.favor" />

对应的数据源为:

js
const blog = ref({
  title: '论持久战',
  cmtCount: 34,
  favor: true
})

请注意:如果您发现需要把对象的每个属性值传入 prop,并且属性名和 prop 名相同,则可以简写为没有参数的 v-bind。示例代码如下:

html
<BlogArticle v-bind="blog" />

除此之外,prop 还支持复杂的表达式绑定:

html
<!-- 根据一个更复杂表达式的值动态传入(字符串的拼接) -->
<BlogArticle :title="blog.title + 'by' + blog.author" />

单向数据流

所有的 props 都遵循着单向绑定原则:子组件的 props 因父组件的更新而变化,自然地将新的状态向下流往子组件,而不会逆向传递

好处:这避免了子组件意外修改父组件的状态的情况,不然应用的数据流将变得混乱而难以理解。

image-20240731164116599

不要在子组件中修改 prop 的值

单向数据流意味着:子组件的 Props 值需要从父组件传入,子组件只能被动接收 Props 值,且不能主动修改父组件传入的 Props 值。否则就破坏了单向数据流的约束,且 Vue 会在控制台抛出禁止修改 Props 的警告。

vue
<!-- 父组件 -->
<script setup>
import { ref } from 'vue'
// 导入子组件
import SingleProps from './components/SingleProps.vue'

const count = ref(0)
</script>

<template>
  <!-- 在父组件中,点击修改 count 值 -->
  <!-- 更新后的 count 值会自动向下流向子组件 -->
  <h1 @click="count++">单向数据流 --- {{ count }}</h1>
  <hr />

  <SingleProps :count />
</template>
vue
<!-- 子组件 -->
<script setup>
defineProps(['count'])
</script>

<template>
  <h3>接收到的 count 值是:{{ count }}</h3>
  <!-- 这里修改了 prop 的值,会产生警告: -->
  <!-- [Vue warn] Set operation on key "count" failed: target is readonly. -->
  <button @click="count++">count +1</button>
</template>

子组件想要修改 Props 的两种情况

把 prop 的值当做初始值

如果父组件传入的 prop 值,只是用来初始化子组件的局部数据,此时我们推荐大家在子组件中单独声明一个局部数据,这个局部数据以传入的 prop 值进行初始化,并在后续业务中被持续使用。而传入的 prop 仅用来初始化此局部数据

html
<!-- 父组件 -->
<script setup>
  import { ref } from 'vue'
  // 导入子组件
  import SingleProps from './components/SingleProps.vue'

  const count = ref(0)
</script>

<template>
  <h1 @click="count++">单向数据流 --- {{ count }}</h1>
  <hr />

  <!-- 1. 父组件传入初始 prop 值 -->
  <SingleProps :initial-value="count" />
</template>
vue
<!-- 子组件 -->
<script setup>
const props = defineProps(['initialValue'])
import { ref } from 'vue'
// 1. 子组件把初始 prop 值转存到 ref 响应式数据中
const num = ref(props.initialValue || 0)
</script>

<template>
  <!-- 2. 使用转存的 ref 数据,而非 prop 初始值 -->
  <h3>接收到的 count 值是:{{ num }}</h3>
  <button @click="num++">count +1</button>
</template>

需要对 prop 值做进一步转换

如果父组件传入的 prop 值无法直接使用,还需做进一步的转换和处理。此时不要直接对 prop 值进行转换,而是推荐使用计算属性进行转换和处理。好处是:prop 值的更新会触发计算属性的重新求值

vue
<!-- 父组件 -->
<script setup>
import RotatePic from './components/RotatePic.vue'
import { ref } from 'vue'
// 定义响应式数据(纯数字,没有角度单位)
const rotateDeg = ref(Math.ceil(Math.random() * 23) * 15)
</script>

<template>
  <button @click="rotateDeg -= 15">逆时针旋转15°</button>
  <button @click="rotateDeg += 15">顺时针旋转15°</button>

  <hr />
  <!-- 传入响应式数据 -->
  <RotatePic :deg="rotateDeg" />
</template>
vue
<!-- 子组件 -->
<script setup>
const props = defineProps(['deg'])

import logo from '../assets/logo.svg'
import { computed, watch, nextTick } from 'vue'

// 把不包含角度单位的数字,转换为包含角度单位的值
const rotateDeg = computed(() => props.deg + 'deg')

// 根据传入的 prop 值(数字),判断图片是否摆正了
watch(
  () => props.deg,
  (deg) => {
    if (deg % 360 === 0) {
      console.log('通过测试!')
    }
  }
)
</script>

<template>
  <br />
  <img :src="logo" />
</template>

<style>
img {
  width: 150px;
  height: 150px;
  position: absolute;
  left: 50%;
  top: 50%;
  /* 样式绑定 */
  transform: translate(-50%, -50%) rotateZ(v-bind('rotateDeg'));
}
</style>

更改对象 / 数组类型的 Props

根据单向数据流的原则,子组件中不应该修改父组件传入的 Props 值。但如果父组件向下传入的是对象/数组类型的数据,此时子组件修改对象中的属性或数组中的元素,会直接影响到父组件中的数据。

因为父组件向下传递对象/数组类型的 Props 时,是把引用传入了子组件。子组件会根据引用直接修改到父组件中的数据。这会破坏单向数据流的约束,因此在实际开发中,请尽量避免这种逆向的数据更改。

vue
<!-- 子组件 -->
<script setup>
defineProps(['user', 'hobby'])
</script>

<template>
  <h3>User:</h3>
  <pre>{{ JSON.stringify(user, null, '  ') }}</pre>
  <button @click="user.age++">age++</button>

  <h3>Hobby:</h3>
  <pre>{{ JSON.stringify(hobby, null, '  ') }}</pre>
  <button @click="hobby.push(hobby.length)">添加元素</button>
</template>
vue
<!-- 父组件 -->
<script setup>
import ReferenceProps from './components/ReferenceProps.vue'
import { ref } from 'vue'

const user = ref({ name: 'zs', age: 20 })
const hobby = ref(['吃', '喝', '抽'])
</script>

<template>
  <p>User:{{ JSON.stringify(user) }}</p>
  <p>Hobby:{{ JSON.stringify(hobby) }}</p>
  <hr />
  <ReferenceProps :user :hobby />
</template>

最佳实践

如果子组件需要修改到父组件中的数据,子组件应该抛出一个事件*来通知父组件做出改变。

Props 校验

想象一下:在项目团队中,小红要调用别人封装好的通用组件。小红在使用别人的组件时,会有如下的 4 个疑问:

  1. 这个组件是否需要传入 Props,需要传入哪些 Props?
    • defineProps(['title', 'cmt-count'])
  2. 每个 prop 应该传入何种类型的数据?
  3. 每个 prop 都是必传项吗,如果漏传了会怎样?
  4. 未传/漏传的 prop 是否提供了默认值?

针对上述的第 2~4 个问题,Vue 提供了对应的解决方案,那就是 Props 校验。

基础校验

在调用 defineProps() 宏函数时,如果传入的是数组,则只是声明了 Props 的名称,并没有指定每个 prop 所要求的数据类型。

为此,Vue 提供了对象格式的入参,其中键是 prop 名,值是当前 prop 所要求的数据的类型。如果使用组件时传入的数据类型不匹配,则会导致控制台的警告:

js
// const props = defineProps(['title', 'articleType', 'cmtCount', 'favor'])

const props = defineProps({
  // prop 名称: 数据类型
  title: String,
  articleType: String,
  cmtCount: Number,
  favor: Boolean
})

在这里,最常用的数据类型如下:

String、Number、Boolean、Array、Object

其它不常用的数据类型如下:

Date、Function、Symbol、Error

多种可能的类型

通过数组可以为 prop 指定多种可能的类型,例如下面的 articleType 值可以是字符串或数字:

js
const props = defineProps({
  title: String,
  // 这里的 articleType 值可以是 String 或 Number 类型,但不能是其它类型
  articleType: [String, Number],
  cmtCount: Number,
  favor: Boolean
})

必传校验

默认情况下不会对未传入的 prop 进行必传校验,除非我们显示指定了 required: true 选项:

js
const props = defineProps({
  // 仅校验类型
  title: String,
  // 多种可能的类型
  articleType: [String, Number],
  // 类型 + 必传
  cmtCount: {
    // type 用来指定类型
    type: Number,
    // required: true 用来指定必传项
    required: true
  },
  favor: Boolean
})

默认值

通过 default 选项,可以为 prop 指定默认值。默认值可以提高组件的健壮性灵活性

js
const props = defineProps({
  title: String,
  articleType: [String, Number],
  cmtCount: {
    type: Number,
    required: true,
    // required 和 default 同时存在时,它们会同时生效
    default: 0
  },
  favor: {
    type: Boolean,
    // default 用来指定默认值
    default: false
  }
})

对象类型 & 默认值

对象类型的 prop 可以使用 Object自定义的 class 类来指定。区别是:自定义的 class 类可以约束对象中的属性,而 Object 不能。

使用 Object 作为对象类型

js
const props = defineProps({
  title: String,
  articleType: [String, Number],
  cmtCount: {
    type: Number,
    required: true,
    default: 0
  },
  favor: {
    type: Boolean,
    default: false
  },
  // author 为 Object 类型,此时不约束对象的属性,
  // 因此传入任何对象都不会产生警告
  author: Object
})

使用自定义的 class 作为对象类型

  1. 定义 propTypes.js 的模块:

    js
    // 自定义的 class 类,用来约束对象类型的 prop 中的属性
    export class Author {
      constructor(name, age) {
        this.name = name
        this.age = age
      }
    }
  2. 在自定义组件中导入并使用 Author 类,从而约束对象中的属性:

    js
    // 导入需要的 class 类
    import { Author } from '../propTypes'
    
    const props = defineProps({
      title: String,
      articleType: [String, Number],
      cmtCount: {
        type: Number,
        required: true,
        default: 0
      },
      favor: {
        type: Boolean,
        default: false
      },
      // 把 class 类作为 prop 的类型,
      // Vue 会在内部调用 instanceof Author 来判断 prop 的值是否为 Author 类的一个实例
      author: Author
    })
  3. 在使用自定义组件时,创建 class 类的实例之后,将实例当做 prop 值传入组件:

    vue
    <script setup>
    import BlogArticle from './components/BlogArticle.vue'
    // 1. 导入 class 类
    import { Author } from './propTypes'
    
    // 2. 创建 class 实例
    const author = new Author('刘龙宾', 12)
    </script>
    
    <template>
      <!-- 3. 传入 prop 值 -->
      <BlogArticle :title="'论持久战'" :article-type="'军事'" :cmt-count="6" :author />
    </template>

注意

自定义的 class 类不要直接放到 <script setup> 中,因为 defineProps() 宏中的参数不可以访问 <script setup> 中定义的其他变量,因为在编译时整个表达式都会被移到外部的函数中。

对象类型的默认值

Vue 官方规定:对象或数组的默认值必须从一个 default() 工厂函数中返回,且工厂函数的形参是当前组件接收到的原始 props

js
// 导入需要的 class 类
import { Author } from '../propTypes'

const props = defineProps({
  title: String,
  articleType: [String, Number],
  cmtCount: {
    type: Number,
    required: true,
    default: 0
  },
  favor: {
    type: Boolean,
    default: false
  },
  author: {
    type: Author,
    // 工厂函数,用来返回对象的默认值
    default(rawProps) {
      // 形参中的 rawProps 是组件接收到的原始 props 对象
      console.log(rawProps)
      // return { name: 'ls', age: 30 }
      return new Author('佚名', 0)
    }
  }
})

注意

如果 type: Object 则工厂函数直接 return 字面量的对象即可; 如果 type 是自定义的 class 类,则工厂函数必须 return 一个 class 类的实例。

函数类型 & 默认值

函数类型的 prop 用 Function 来表示,函数类型的默认值就是 default() 函数(请注意,这里的 default 函数不再是工厂函数,而是默认值)。

js
// 导入需要的 class 类
import { Author } from '../propTypes'

const props = defineProps({
  title: String,
  articleType: [String, Number],
  cmtCount: {
    type: Number,
    required: true,
    default: 0
  },
  favor: {
    type: Boolean,
    default: false
  },
  author: {
    type: Author,
    default(rawProps) {
      return new Author('佚名', 0)
    }
  },
  readMore: {
    type: Function,
    // 请注意:这里的 default 函数不是工厂函数,
    // 而是被当做了默认值,如果用户没有传入一个函数,
    // 则把 default 函数作为 readMore 函数进行调用。
    default() {
      console.log('执行了默认的 default 函数!')
    }
  }
})

再次强调

在对象或数组类型的 prop 中,default 是工厂函数;在函数类型的 prop 中,default 是默认值。

自定义类型校验函数

如果以上的类型校验无法满足实际的校验需求,Vue 还允许程序员通过 validator()自定义类型校验的函数。例如:articleType 的 prop 值只能在新闻军事娱乐中三选一:

js
const props = defineProps({
  title: String,
  articleType: {
    // 自定义的类型校验函数,接收两个形参:
    // 形参1:用户传入的 prop 值
    // 形参2:用户传入的所有的 props 对象(3.4+)
    validator(value, props) {
      // 如果 return 的结果为 true 表示校验通过;
      // 如果 return 的结果为 false 表示校验不通过,会在控制台输出警告消息。
      return ['新闻', '军事', '娱乐'].includes(value)
    },
    // validator() 也能配合 required 和 default 一起使用
    required: true,
    default: '军事'
  },
  cmtCount: {
    type: Number,
    required: true,
    default: 0
  },
  favor: {
    type: Boolean,
    default: false
  },
  author: {
    type: Author,
    default(rawProps) {
      console.log(rawProps)
      return new Author('佚名', 0)
    }
  },
  readMore: {
    type: Function,
    default() {
      console.log('执行了默认的 default 函数!')
    }
  }
})

可为 null 的类型

如果该类型是必传可为 null 的,你可以用一个包含 null 的数组语法:

js
const props = defineProps({
  // title 必传,但可为 null
  title: {
    type: [String, null],
    required: true
  }
  // ...省略其它 props
})

请注意,如果 type 仅为 null 而非使用数组语法,它将允许任何类型

js
const props = defineProps({
  // title 虽然是必传,但它允许任何类型
  title: {
    type: null,
    required: true
  }
  // ...省略其它 props
})

Boolean 类型转换

对于 Boolean 类型的 prop,用户在传入 prop 值的时候完整写法如下:

html
<!-- 传入的值为 true -->
<BlogArticle :favor="true" />

<!-- 传入的值为 false -->
<BlogArticle :favor="false" />

同时 Vue 还提供了简化的写法:

html
<!-- 表示:传入的 favor 值为 true -->
<BlogArticle favor />

<!-- 表示:传入的 favor 值为 false -->
<BlogArticle />

响应式 Props 解构 3.5+

在 Vue3.5 之前对 Props 进行解构会丢失响应性,从 Vue3.5 开始支持响应式 Props 解构。示例代码如下:

js
const { count } = defineProps(['count'])

watchEffect(() => {
  // 在 3.5 之前只运行一次
  // 在 3.5+ 中在 "count" prop 变化时重新执行
  console.log(count)
})

当我们需要使用 watch() 侦听解构的 prop 时,需要把它包装在 getter 中:

js
watch(() => count /* ... */)

此外,当我们需要传递解构的 prop 到外部函数中并保持响应性时,也推荐把它包装在 getter 中:

js
useComposable(() => count)

天不生夫子,万古长如夜