08 | 组件化:如何像搭积木一样开发网页?

你好,我是大圣。

在上一讲中,我给你讲解了响应式的基本原理和进阶用法。除了响应式,组件相关的知识在Vue中也非常重要,所以今天我就跟你聊一下Vue的组件化机制。

在我们的项目中,组件无处不在,通过对组件功能的封装,可以像搭积木一样开发网页。而我们现在已经很难想象,没有组件的开发状态是什么样了。你可以看下面 Vue官方的示例图,它对组件化开发做了形象化的展示。图中的左边是一个网页,可以按照功能模块抽象成很多组件,这些组件就像积木一样拼接成网页。

图片

什么是组件化开发

谈组件化开发之前,我们先来看看什么是组件。举个通俗的例子,我们在页面的源码里写出的button标签,会在前端页面中显示出下面的样式:

图片

这个button其实就是一个组件,这样前端页面在显示上会加上边框和鼠标悬停的样式,还可以使用click事件触发函数等。只不过这是浏览器帮我们封装好的组件,我们在编辑代码的任何地方,只需要使用下面的代码,就可以让前端页面显示一个按钮。

<button> 按钮 </button>

除了浏览器自带的组件外,Vue 还允许我们自定义组件,把一个功能的模板(template)封装在一个.vue文件中。例如在下图中,我们把每个组件的逻辑和样式,也就是把JavaScript 和CSS封装在一起,方便在项目中复用整个组件的代码。

图片

我们实际开发的项目中有导航、侧边栏、表格、弹窗等组件,并且也会引入Element3这个组件库进行开发。此外,我们也会定制业务相关的组件,最终通过这些组件,搭积木式地把页面搭建起来。

Vue已经把组件化的机制实现得很好了,你只需要在这个基础之上,去掌握和学习组件化在使用上的设计理念。这样做的目的是实现高效的代码复用,在后续的项目开发中,我们会把组件分成两个类型,一个是通用型组件,一个是业务型组件。

通用型组件就是各大组件库的组件风格,包括按钮、表单、弹窗等通用功能。业务型组件包含业务的交互逻辑,包括购物车、登录注册等,会和我们不同的业务强绑定。

组件的开发由于要考虑代码的复用性,会比通常的业务开发要求更高,需要有更好的可维护性和稳定性的要求。为了帮助你理解设计组件的要点,我先选择一个简单的组件展开讲解。小圣在继续开发项目的时候,有一个评级的需求,简单来说,就是在前端页面上,能够让商品显示1到5的评分。我会借此教小圣在组件开发上的入门和应用,并依次讲解组件的设计思路。

渲染评级分数

其实,对于简单的评级需求,我们就可以使用组件。这样,只需要一行代码就可以实现评级需求。比如下面的代码,rate是1到5的整数,通过slice方法,我们直接渲染出对应数量的星星即可。

"★★★★★☆☆☆☆☆".slice(5 - rate, 10 - rate)

想要查看上面这行代码的效果,你只需要传入评分值rate。这行代码的运行效果如下图所示,其中的星星代表着评价的等级,由rate的值来决定。

图片

每个组件渲染的内容并不完全一样,这是我们写组件时首先要确认的内容。每个组件在项目中的不同地方,会渲染不同的内容。

我们在这里写的这个组件就是根据rate的值,来渲染出不同数量的星星。我们进入到src/components目录,新建Rate.vue,然后写出下面的代码。在下面的代码中,我们使用defineProps来规范传递数据的格式,这里规定了组件会接收外部传来的value属性,并且只能是数字,然后根据value的值计算出评分的星星。

<template>
    <div>
        {{rate}}
    </div>
</template>

<script setup>
import { defineProps,computed } from 'vue';
let props = defineProps({
    value: Number
})
let rate = computed(()=>"★★★★★☆☆☆☆☆".slice(5 - props.value, 10 - props.value))
</script>

使用组件的方式就是使用:value的方式,通过属性把score传递给Rate组件,就能够在页面上根据score的值,显示出三颗实心的星星。下面的代码展示了如何使用Rate组件来显示3颗星星。

<template>
<Rate :value="score"></Rate>
</template>

<script setup>
import {ref} from 'vue'
import Rate from './components/Rate1.vue'
let score = ref(3)
</script>

根据传递的score值显示的不同的内容,我们也可以更进一步,回到Rate.vue代码里,加入如下的代码,比如在组件中内置一些主题颜色,加入CSS的内容。如下面代码,Rate组件新接收一个属性theme,默认值是orange。我们在Rate组件中内置了几个主题颜色,根据传递的theme计算出颜色,并且使用 :style 渲染。

<template>
    <div :style="fontstyle">
        {{rate}}
    </div>
</template>

<script setup>
import { defineProps,computed, } from 'vue';
let props = defineProps({
    value: Number,
    theme:{type:String,default:'orange'}
})
console.log(props)
let rate = computed(()=>"★★★★★☆☆☆☆☆".slice(5 - props.value, 10 - props.value))

const themeObj = {
  'black': '#00',
  'white': '#fff',
  'red': '#f5222d',
  'orange': '#fa541c',
  'yellow': '#fadb14',
  'green': '#73d13d',
  'blue': '#40a9ff',
}
const fontstyle = computed(()=> {
    return `color:${themeObj[props.theme]};`
})

</script>

在完成了上面代码所示的这一过程,也就是通过theme渲染星星颜色这一步,我们就可以使用下面的代码,传递value和theme两个属性,并且可以很方便地复用组件。

<Rate :value="3" ></Rate>
<Rate :value="4" theme="red"></Rate>
<Rate :value="1" theme="green"></Rate>

在下图中,也可以看到上面三个组件渲染的结果:

组件事件

使用这个Rate组件后,虽然前端页面显示评级的需求是完成了,但是评级组件还需要具备修改评分的功能,所以我们需要让组件的星星可点击,并且让点击后的评分值能够传递到父组件。

在Vue中,我们使用emit来对外传递事件,这样父元素就可以监听Rate组件内部的变化。现在我们对Rate组件进行改造,首先由于我们的星星都是普通的文本,没有办法单独绑定click事件。所以我们要对模板进行改造,每个星星都用span包裹,并且我们可以用width属性控制宽度,支持小数的评分显示。

我们回到Rate.vue组件,添加下面的代码,我们把★和☆用span包裹,并绑定鼠标的mouseover事件。然后通过:style,我们可以设置实心五角星★的宽度,实现一样的评级效果。

<template>
<div :style="fontstyle">
    <div class='rate' @mouseout="mouseOut">
      <span @mouseover="mouseOver(num)"  v-for='num in 5' :key="num">☆</span>
      <span class='hollow' :style="fontwidth">
        <span @mouseover="mouseOver(num)" v-for='num in 5' :key="num">★</span>
      </span>
    </div> 
</div>
</template>
<script setup>
// ...其他代码
// 评分宽度
let width = ref(props.value)
function mouseOver(i){
    width.value = i 
}
function mouseOut(){
    width.value = props.value
}
const fontwidth = computed(()=>`width:${width.value}em;`)

</script>

<style scoped>
.rate{
  position:relative;
  display: inline-block;
}
.rate > span.hollow {
  position:absolute;
  display: inline-block;
  top:0;
  left:0;
  width:0;
  overflow:hidden;
}
</style>

因为现在是通过宽度显示星星,所以我们还可以支持3.5分的小数评级,并且支持鼠标滑过的时候选择不同的评分。用下面的代码,我们可以使用Rate组件。

<Rate :value="3.5" ></Rate>

上面代码的显示效果如下图所示:

图片

然后我们需要做的,就是在点击五角星选择评分的时候,把当前评分传递给父组件即可。在Vue 3中,我们使用defineEmit来定义对外“发射”的数据,在点击评分的时候触发即可。下面的defineEmit代码就展示了点击评分后,向父元素“发射”评分数据num。

<template>
  省略代码
   <span @click="onRate(num)" @mouseover="mouseOver(num)" v-for='num in 5' :key="num">★</span>

</template>
<script setup>
import { defineProps, defineEmits,computed, ref} from 'vue';

let emits = defineEmits('update-rate')
function onRate(num){
    emits('update-rate',num)
}
</script>

在下面的代码中,我们使用@update-rate 接收Rate组件emit的数据,并且修改score的值,这样就完成了数据修改后的更新。

<template>

<h1>你的评分是 {{score}}</h1>
<Rate :value="score" @update-rate="update"></Rate>

</template>

<script setup>
import {ref} from 'vue'
import Rate from './components/Rate1.vue'
let score = ref(3.5)
function update(num){
    score.value = num
}

</script>

现在组件的示意图如下,我们通过defineProps定义了传递数据的格式,通过defineEmits定义了监听的函数,最终实现了组件和外部数据之间的同步。

图片

组件的v-model

上面Rate组件中数据双向同步的需求在表单领域非常常见,例如第二讲中,我们在input标签上使用v-model这个属性就实现了这个需求。在自定义组件上我们也可以用v-model,对于自定义组件来说,v-model是传递属性和接收组件事件两个写法的简写。

在下面的代码中,首先我们把属性名修改成modelValue,然后如果我们想在前端页面进行点击评级的操作,我们只需要通过update:modelValue这个emit事件发出通知即可。

let props = defineProps({
    modelValue: Number,
    theme:{type:String,default:'orange'}
})
let emits = defineEmits(['update:modelValue'])

然后我们就可以按如下代码中的方式,使用Rate这个组件,也就是直接使用v-model绑定score变量。这样,就可以实现value和onRate两个属性的效果。

 <template>

<h1>你的评分是 {{score}}</h1>
<Rate v-model="score"></Rate>
</template>

插槽

和HTML的标签使用类似,很多时候我们也需要给组件中传递内容。就像在下面的代码中click并不是button标签的属性,而是子元素,button标签会把子元素渲染在居中的位置。

<button> click </button>

我们的Rate组件也是类似的,在Vue中直接使用slot组件来显示组件的子元素,也就是所谓的插槽。在下面的代码中,我们使用slot组件渲染Rate组件的子元素。

<template>
<div :style="fontstyle">
    <slot></slot>
    <div class='rate' @mouseout="mouseOut">
      <span @mouseover="mouseOver(num)"  v-for='num in 5' :key="num">☆</span>
      <span class='hollow' :style="fontwidth">
        <span @click="onRate(num)" @mouseover="mouseOver(num)" v-for='num in 5' :key="num">★</span>
      </span>
    </div> 
</div>
</template>

我们现在使用Rate组件的时候,组件的子元素都会放在评级组件之前。除了文本,也可以传递其他组件或者html标签 。下面代码显示的结果,第一个Rate组件显示课程评分 ,第二个Rate组件前面显示一个图片。

<Rate v-model="score">课程评分</Rate>
<Rate v-model="score">
    <img width="14" src="/favicon.ico">
</Rate>

总结

至此,我们设计了一个非常迷你的评级组件,我带你回顾一下今天都做了什么吧。

首先,我们用props属性传递的方式,通过传递的value属性去决定显示的星星数量,这也是设计组件的时候首先就要考虑的。一个组件库首先要实现的,就是通过props属性渲染内容,而在Rate组件里,我们可以根据value属性去渲染显示了几颗星星。

然后为了让用户可以点击评分,我们优化了显示的方式,每个★包裹一个span标签,并且绑定了mouseover和mouseout事件显示鼠标悬停的样式,最后在★标签的click事件上,对外通知事件,告知父组件数据的变化。

对于这个通知机制,我们使用defineEmits定义的方式来实现,这也是Vue组件中重要的数据交换机制。emits配合props,这样我们在使用一个组件的时候,就实现了给组件传递数据,并且我们也能够监听组件内部数据的变化。最后我们通过规范props和emit的名字,实现了直接在自定义的组件之上使用v-model。

当然,这个组件开发到这里,依然是比较精简,但实际上我们要考虑的问题更多。比如在下一讲,我会给你讲解如何在Vue中加上一些简单的动画。字符★显示的效果可能也不符合你所在公司的设计规范,所以你可能就需要使用图片替换五角星样式等。

更加完备的Rate组件,你可以去看Element3评级组件的实现,在这里我们先初步感受一个组件的完整设计过程,它包含着交互的小细节,比如键盘事件、自定义icon等等。

https://github.com/course-dasheng/geektime-vue-course

Rate组件写完,Vue的组件化你就算入门了,在课程第三部分中,我们会对更多场景的组件化进行实战开发,在那里我会给你详细说说更多组件进阶和扩展的用法。

思考题

今天我带你从0设计了一个组件,相信你对Vue 3的组件设计也有了新的思考,那么关于这个Rate组件,你觉得还有哪些功能扩展的需求呢?

欢迎在评论区分享你的思考,也欢迎你把这一讲分享给其他人,我们下一讲见!

精选留言

  • Geek_7cdaba

    2021-11-05 17:22:00

    建议作者把代码弄全一点,v-model那敲了一遍还是没弄明白,呜呜呜
    作者回复

    第一部分实战代码在这里呀,欢迎遇见bug了提pr呀
    https://github.com/shengxinjing/geektime-vue-course

    2021-11-11 22:01:52

  • 请去学习吧

    2021-11-05 15:54:53

    被"★★★★★☆☆☆☆☆".slice(5 - props.value, 10 - props.value)惊艳到了,在这个课程里收获了好多惊喜
    作者回复

    感谢感谢

    2021-11-05 19:50:17

  • ll

    2021-11-03 16:01:04

    1. 对组件化的理解
    组件化好似“搭积木”;“分而治之”思想实际运用,所谓“大事化小,小事化了”或称为“抽
    象隔离”。就是,各个组件之间有自己需要解决的问题,有各自解决的方式方法,但互相
    不需要知道,它们沟通只看“结果”即 props,events(当然这是模拟事件,也是个可以
    研究的点)

    这里就要根据“角色”或“用途”分为通用型组件和业务型组件。

    举个不恰当的例子,通用型组件好比广义的“车”,你根据业务的需要加个“四轮驱动”之
    类的就变成个“越野车”这种业务组件了。

    当然,由于前端的特殊性,组件的不同也体现在不同的表现上(CSS 说的就是你)

    现在日常工作的常态大概就是写组件。日常写业务的我们,在优秀组件的基础上,“改造”出
    公司需要的产品。
    像大圣大神写出优秀的组件库,供我们“改造”。

    然后我推测公司的架构师的工作就是将“只要业务”“主要问题”合理的划分为一个个“组件”,
    最后由我们写。

    这样,宏观的组件化,和微观的组件化都有了。

    2. defineEmits, defineProps
    我尝试了下将 props “抽离出去”,结果没成功,这个 defineXXX 系列的 API 和
    useXXX 系列的 API 还是有不同,define 系列设计的貌似和组件 “强相关”,所以这也
    是之后或是课下我需要关注的点, 看看是怎么实现的。

    感觉 useXXX 是将XXX“引入”组件,这个 defineXXX 是“声明”组件中已经“存在"的东西。

    大概就是这样
    作者回复

    cool 好顶赞

    2021-11-03 17:02:57

  • 麦子熟了

    2021-11-03 13:20:15

    v-model那一部分,根据文中代码没实现出来
    作者回复

    function onRate(num){
    emits('update:modelValue',num)
    }
    这一段吗 sorry

    2021-11-03 17:03:59

  • cskang

    2021-11-03 22:51:56

    代码能放到 CodePen 上就好了,这样方便查看完整的代码,又可以通过修改调试学习。
    作者回复

    第一部分实战代码在这里呀,欢迎遇见bug了提pr呀
    https://github.com/shengxinjing/geektime-vue-course

    2021-11-11 21:53:39

  • peterpc

    2021-11-05 11:59:12

    v-model双向绑定笔记:
    如果子组件属性名定义为modelValue,则父组件直接v-model="父组件属性"即可绑定,如果子组件需要自定义属性名称,比如定义为abc,则父组件绑定时必须按照这种写法 v-model:abc="父组件属性"才能绑定。
    如下实例:
    子组件声明方式 父组件绑定方式
    let props = defineProps({
    mycc: Number => v-model:abc="父组件属性"
    })
    let props = defineProps({
    modelValue: Number => v-model="父组件属性"
    })

    请问大圣是这样吗?
  • The Word

    2021-11-05 01:27:07

    3.2以后defineEmits和difineProps都宏编译了,不需要引入了,defineEmit已经不能用了
    作者回复

    3.2的更新后面我会补充一下,并且3.2针对ref的value属性也做了优化

    2021-11-05 19:53:02

  • 费城的二鹏

    2021-11-03 15:23:39

    第一个版本的评分组件,简直是简洁的巅峰,自己没想到可以这么做,看到这个逻辑秒懂,太精巧了。
    作者回复

    但是用这个符号不够精致,用来演示还是够了

    2021-11-03 17:03:28

  • peterpc

    2021-11-04 11:08:23

    点击click事件@click="onRate(num)",浏览器调试提示如下警告,是什么原因:
    [Vue warn]: Component emitted event "update:modelValue" but it is neither declared in the emits option nor as an "onUpdate:modelValue" prop.

    另外在chrome和firefox都出现五角星显示错位的情况
    作者回复

    第一部分实战代码在这里呀,欢迎遇见bug了提pr呀
    https://github.com/shengxinjing/geektime-vue-course

    2021-11-11 21:51:20

  • Alias

    2021-11-05 20:58:03

    "vue": "^3.2.21"
    1 v-model="score" 写法,子组件接收时必须用 modelValue
    2 除非是 v-model:xxx="score" 才能自定义
    3 子组件接收后 props 不能解构。。否则mouseout 后又变成初始传进来的值。。。
    const {value, theme} = props
    const width = ref(value)
    const noMouseout = () => {
    width.value = value // 得用 props.value
    }
    4 有时候热更新不太好使哎。。。
    作者回复

    热更新的问题我去研究下
    第一部分实战代码在这里呀,欢迎遇见bug了提pr呀
    https://github.com/shengxinjing/geektime-vue-course

    2021-11-11 21:48:52

  • Lwein

    2021-11-27 16:26:19

    看不懂CSS样式代码,还是使用这个 {{width>=num?'★':'☆'}} 语法靠谱 ^_^
    作者回复

    其实也可以 哈哈

    2021-12-13 21:17:38

  • pawel

    2021-11-03 17:08:51

    let emits = defineEmits('update-rate') // 定义emits
    大圣老师,defineEmits好像只能接受Array<string> | Object
    作者回复

    是的是的,这里应该是个数组
    第一部分实战代码在这里呀,欢迎遇见bug了提pr呀
    https://github.com/shengxinjing/geektime-vue-course

    2021-11-11 21:54:43

  • NOVIAS🐱🦁

    2021-11-03 05:26:21

    老师你好,Demo写完之后我有两个小问题:
    1. 不知道是不是我样式写的不对,使用em时,当选中5星之后,元素出现了偏移。
    2. 对于主题这种对象,是不是可以写成常量,比如用ts的字符串类型枚举进行定义。
    作者回复

    em可能是字体的问题,我本地演示没问题就贴上去了,实际开发中肯定会有图片来取代这个字符,我晚点本地再调试一下 全部限制一下字体大小应该就可以

    用ts的话肯定是用emus更合适,这里演示一下的话,theme用字符串,或者是直接传递color都是可以的

    2021-11-03 17:11:44

  • gongshun

    2021-11-05 10:04:13

    vue3官网的 `v-model` 的语法是这样的,你那个是 vue2 的写法吧
    ```
    <my-component v-model:title="bookTitle"></my-component>
    ```
  • Geek_1abc92

    2021-11-04 16:17:26

    每一章能否贴一下完整的代码?
    作者回复

    第一部分实战代码在这里呀,欢迎遇见bug了提pr呀
    https://github.com/shengxinjing/geektime-vue-course

    2021-11-11 21:51:55

  • 费城的二鹏

    2021-11-03 15:25:38

    1. 代码目前都是一个颜色,能否添加关键词变色,方便我们阅读?
    2. 对代码的增量修改能否通过颜色或者其它方式的变动来指示哪些是新增的,哪些是旧的,方便读者阅读出新增部分代码。
    作者回复

    代码高亮吗?这个得给极客时间提个需求去

    2021-11-03 17:03:11

  • 王帅兵

    2022-04-20 14:33:14

    问题汇总:
    1.星星偏移:解决方案:①设置等宽字体、②固定每个星星的宽度为统一宽度
    2.Vue:warn 提示:defineEmits(['update:xxxx']) 或者 defineEmits({'update:xxx': null})
    3.v-model 补全代码:onRate代码替换成 emits('update:xxx', num) 逻辑
    4.直接对 defineProps 解构会丢失响应式,建议加上 toRefs(),包括解构时想换个变量名也需要这样做
    5.两个 Rate 同时依赖一个 score,A变化、B不变化的解决:加watch监听变化,拿 score 重新对 width 赋值
    6.另外也可以直接参考我的笔记 https://gitee.com/wang_da_da/learn_vue3.git
  • Geek_aa3c34

    2022-01-28 15:41:37

    建议鼠标事件改为 mouseenter/mouseleave 不然会有mouseout的冒泡事件,没划过一个星星执行一次mouseout
  • uncle 邦

    2021-11-04 22:52:58

    let emits = defineEmits('update-rate') // 定义emits
    function onRate(num){
    emits('update-rate',num)
    }
    使用"defineEmit" 定义一个xx事件,再派发这个xx事件。有点重复了。vue2.直接emit(‘XX事件’)会不会简约一点。不知道这个是出于什么设计逻辑。

    作者回复

    其实就是尽量所有功能都按需引入,比较方便做代码依赖检查

    2021-11-11 21:51:02

  • 轻飘飘过

    2021-11-05 12:30:47

    mac safari上星星显示正常,chrome上显示少了,了解到是em在不同浏览器中的问题,这个单位怎么兼容呢😃
    作者回复

    写死字体大小 过几天我更新一下代码把

    2021-11-05 19:50:57