Vue组件化实践

课堂目标


  1. 总结Vue组件化常用技术
    2. 深入理解Vue的组件化
    3. 设计并实现多种类型的组件
    4. 组件库源码学习
    5. vue3组件化注意事项

知识要点

  1. 组件通信方式
  2. 组件复合
  3. 组件构造函数和实例化
  4. 渲染函数
  5. 组件挂载
  6. 递归组件
  7. …

运行环境

  1. node 12.x
  2. vue.js 2.6.x
  3. vue-cli 4.x

知识点


组件化

vue组件系统提供了一种抽象,让我们可以使用独立可复用的组件来构建大型应用,任意类型的应用界面都可以抽象为一个组件树。组件化能 提高开发效率 , 方便重复使用 , 简化调试步骤 , 提升项目可维护性 , 便于多人协同开发。

image.png

组件通信常用方式

  • props
  • $emit/$on
  • event bus
  • vuex

边界情况

  • $parent
  • $children
  • $root
  • $refs
  • provide/inject
  • 非prop特性
    • $attrs
    • $listeners

组件通信

props

父给子传值

  1. // child
  2. props: { msg: String }
  3. // parent
  4. <HelloWorld msg="Welcome to Your Vue.js App"/>

自定义事件

子给父传值

  1. // child
  2. this.$emit('add', good)
  3. // parent
  4. <Cart @add="cartAdd($event)"></Cart>

事件总线

任意两个组件之间传值常用事件总线 或 vuex的方式。

  1. // Bus:事件派发、监听和回调管理
  2. class Bus {
  3. constructor () {
  4. this.callbacks = {}
  5. }
  6. $on (name, fn) {
  7. this.callbacks[name] = this.callbacks[name] || []
  8. this.callbacks[name].push(fn)
  9. }
  10. $emit (name, args) {
  11. if (this.callbacks[name]) {
  12. this.callbacks[name].forEach(cb => cb(args))
  13. }
  14. }
  15. }
  16. // main.js
  17. Vue.prototype.$bus = new Bus()
  18. // child1
  19. this.$bus.$on('foo', handle)
  20. // child2
  21. this.$bus.$emit('foo')

实践中通常用Vue代替Bus,因为Vue已经实现了$on和$emit

vuex

创建唯一的全局数据管理者store,通过它管理数据并通知组件状态变更。

组件通信最佳实践,预习视频第 12 章

$parent/$root

兄弟组件之间通信可通过共同祖辈搭桥,$parent或$root。

  1. // brother1
  2. this.$parent.$on('foo', handle)
  3. // brother2
  4. this.$parent.$emit('foo')

$children

父组件可以通过$children访问子组件实现父子通信。

  1. // parent
  2. this.$children[0].xx = 'xxx'

注意:$children不能保证子元素顺序 和$refs有什么区别?

refs

获取子节点引用

  1. // parent
  2. <HelloWorld ref="hw"/>
  3. mounted() {
  4. this.$refs.hw.xx = 'xxx'
  5. }

$attrs/$listeners

包含了父作用域中 不作为 prop 被识别 (且获取) 的特性绑定 (class 和 style 除外)。当一个组件没有
声明任何 prop 时,这里会包含所有父作用域的绑定 (class 和 style 除外),并且可以通过 v-bind="$attrs" 传入内部组件——在创建高级别的组件时非常有用。

  1. // child:并未在props中声明foo
  2. <p>{{$attrs.foo}}</p>
  3. // parent
  4. <HelloWorld foo="foo"/>
  5. // 给Grandson隔代传值,communication/index.vue
  6. <Child2 msg="lalala" @some-event="onSomeEvent"></Child2>
  7. // Child2做展开
  8. <Grandson v-bind="$attrs" v-on="$listeners"></Grandson>
  9. // Grandson使用
  10. <div @click="$emit('some-event', 'msg from grandson')">
  11. {{msg}}
  12. </div>

文档

provide/inject

能够实现祖先和后代之间传值

  1. // ancestor
  2. provide() {
  3. return {foo: 'foo'}
  4. }
  5. // descendant
  6. inject: ['foo']

范例:组件通信

组件通信范例代码请参考components/communication

插槽

插槽语法是Vue 实现的内容分发 API,用于复合组件开发。该技术在通用组件库开发中有大量应用。

匿名插槽
  1. // comp1
  2. <div>
  3. <slot></slot>
  4. </div>
  5. // parent
  6. <comp>hello</comp>

具名插槽

将内容分发到子组件指定位置

  1. // comp2
  2. <div>
  3. <slot></slot>
  4. <slot name="content"></slot>
  5. </div>
  6. // parent
  7. <Comp2>
  8. <!-- 默认插槽用default做参数 -->
  9. <template v-slot:default>具名插槽</template>
  10. <!-- 具名插槽用插槽名做参数 -->
  11. <template v-slot:content>内容...</template>
  12. </Comp2>

作用域插槽

分发内容要用到子组件中的数据

  1. // comp3
  2. <div>
  3. <slot :foo="foo"></slot>
  4. </div>
  5. // parent
  6. <Comp3>
  7. <!-- 把v-slot的值指定为作用域上下文对象 -->
  8. <template v-slot:default="slotProps">
  9. 来自子组件数据:{{slotProps.foo}}
  10. </template>
  11. </Comp3>

范例

插槽相关范例请参考components/slots中代码

组件化实战

通用表单组件

收集数据、校验数据并提交。

需求

  • 表单KForm
    • 载体,输入数据model,校验规则rules
    • 校验validate
  • 表单项KFormItem
    • label标签添加
    • 载体,输入项包起来
    • 校验执行者,显示错误
  • 输入框KInput
    • 双绑
    • 图标、反馈

最终效果:Element表单

范例代码查看components/form/ElementTest.vue

KInput

创建components/form/ KInput.vue

  1. <template>
  2. <div>
  3. <input :value="value" @input="onInput" v-bind="$attrs">
  4. </div>
  5. </template>
  6. <script>
  7. export default {
  8. inheritAttrs: false,
  9. props: {
  10. value: {
  11. type: String,
  12. default: ''
  13. },
  14. },
  15. methods: {
  16. onInput (e) {
  17. this.$emit('input', e.target.value)
  18. }
  19. },
  20. }
  21. </script>

使用KInput

创建components/form/index.vue,添加如下代码:

  1. <template>
  2. <div>
  3. <h3>KForm表单</h3>
  4. <hr>
  5. <k-input v-model="model.username"></k-input>
  6. <k-input type="password" v-model="model.password"></k-input>
  7. </div>
  8. </template>
  9. <script>
  10. import KInput from "./KInput";
  11. export default {
  12. components: {
  13. KInput
  14. },
  15. data () {
  16. return {
  17. model: { username: "tom", password: "" },
  18. };
  19. }
  20. };
  21. </script>

实现KFormItem

创建components/form/KFormItem.vue

  1. <template>
  2. <div>
  3. <label v-if="label">{{label}}</label>
  4. <slot></slot>
  5. <p v-if="error">{{error}}</p>
  6. </div>
  7. </template>
  8. <script>
  9. export default {
  10. props: {
  11. label: {// 输入项标签
  12. type: String,
  13. default: ''
  14. },
  15. prop: {// 字段名
  16. type: String,
  17. default: ''
  18. },
  19. },
  20. data () {
  21. return {
  22. error: '' // 校验错误
  23. }
  24. },
  25. };
  26. </script>

使用KFormItem

components/form/index.vue,添加基础代码:

  1. <template>
  2. <div>
  3. <h3>KForm表单</h3>
  4. <hr>
  5. <k-form-item label="用户名" prop="username">
  6. <k-input v-model="model.username"></k-input>
  7. </k-form-item>
  8. <k-form-item label="确认密码" prop="password">
  9. <k-input type="password" v-model="model.password"></k-input>
  10. </k-form-item>
  11. </div>
  12. </template>

实现KForm
  1. <template>
  2. <form>
  3. <slot></slot>
  4. </form>
  5. </template>
  6. <script>
  7. export default {
  8. provide () {
  9. return {
  10. form: this // 将组件实例作为提供者,子代组件可方便获取
  11. };
  12. },
  13. props: {
  14. model: { type: Object, required: true },
  15. rules: { type: Object }
  16. }
  17. };
  18. </script>

使用KForm

components/form/index.vue,添加基础代码:

  1. <template>
  2. <div>
  3. <h3>KForm表单</h3>
  4. <hr>
  5. <k-form :model="model" :rules="rules" ref="loginForm">
  6. ...
  7. </k-form>
  8. </div>
  9. </template>
  10. <script>
  11. import KForm from "./KForm";
  12. export default {
  13. components: {
  14. KForm,
  15. },
  16. data () {
  17. return {
  18. rules: {
  19. username: [{ required: true, message: "请输入用户名" }],
  20. password: [{ required: true, message: "请输入密码" }]
  21. }
  22. };
  23. },
  24. methods: {
  25. submitForm () {
  26. this.$refs['loginForm'].validate(valid => {
  27. if (valid) {
  28. alert("请求登录!");
  29. } else {
  30. alert("校验失败!");
  31. }
  32. });
  33. }
  34. }
  35. };
  36. </script>

数据校验

Input通知校验

  1. onInput(e) {
  2. // ...
  3. // $parent指FormItem
  4. this.$parent.$emit('validate');
  5. }

FormItem监听校验通知,获取规则并执行校验

  1. inject: ['form'], // 注入
  2. mounted () {// 监听校验事件
  3. this.$on('validate', () => { this.validate() })
  4. },
  5. methods: {
  6. validate () {
  7. // 获取对应FormItem校验规则
  8. console.log(this.form.rules[this.prop]);
  9. // 获取校验值
  10. console.log(this.form.model[this.prop]);
  11. }
  12. },

安装async-validator:npm i async-validator -S

  1. import Schema from "async-validator";
  2. validate () {
  3. // 获取对应FormItem校验规则
  4. const rules = this.form.rules[this.prop];
  5. // 获取校验值
  6. const value = this.form.model[this.prop];
  7. // 校验描述对象
  8. const descriptor = { [this.prop]: rules };
  9. // 创建校验器
  10. const schema = new Schema(descriptor);
  11. // 返回Promise,没有触发catch就说明验证通过
  12. return schema.validate({ [this.prop]: value }, errors => {
  13. if (errors) {
  14. // 将错误信息显示
  15. this.error = errors[0].message;
  16. } else {
  17. // 校验通过
  18. this.error = "";
  19. }
  20. });
  21. }

表单全局验证,为Form提供validate方法

  1. validate (cb) {
  2. // 调用所有含有prop属性的子组件的validate方法并得到Promise数组
  3. const tasks = this.$children
  4. .filter(item => item.prop)
  5. .map(item => item.validate());
  6. // 所有任务必须全部成功才算校验通过,任一失败则校验失败
  7. Promise.all(tasks)
  8. .then(() => cb(true))
  9. .catch(() => cb(false))
  10. }

实现弹窗组件

弹窗这类组件的特点是它们 在当前vue实例之外独立存在 ,通常挂载于body;它们是通过JS动态创建
的,不需要在任何组件中声明。常⻅使用姿势:

  1. this.$create(Notice, {
  2. title: '社会你杨哥喊你来搬砖',
  3. message: '提示信息',
  4. duration: 1000
  5. }).show();

create函数

  1. import Vue from "vue";
  2. // 创建函数接收要创建组件定义
  3. function create (Component, props) {
  4. // 创建一个Vue新实例
  5. const vm = new Vue({
  6. render (h) {
  7. // render函数将传入组件配置对象转换为虚拟dom
  8. console.log(h(Component, { props }));
  9. return h(Component, { props });
  10. }
  11. }).$mount(); //执行挂载函数,但未指定挂载目标,表示只执行初始化工作
  12. // 将生成dom元素追加至body
  13. document.body.appendChild(vm.$el);
  14. // 给组件实例添加销毁方法
  15. const comp = vm.$children[0];
  16. comp.remove = () => {
  17. document.body.removeChild(vm.$el);
  18. vm.$destroy();
  19. };
  20. return comp;
  21. }
  22. // 暴露调用接口
  23. export default create;

另一种创建组件实例的方式:Vue.extend(Component)

通知组件

建通知组件,Notice.vue

  1. <template>
  2. <div class="box" v-if="isShow">
  3. <h3>{{title}}</h3>
  4. <p class="box-content">{{message}}</p>
  5. </div>
  6. </template>
  7. <script>
  8. export default {
  9. props: {
  10. title: {
  11. type: String,
  12. default: ""
  13. },
  14. message: {
  15. type: String,
  16. default: ""
  17. },
  18. duration: {
  19. type: Number,
  20. default: 1000
  21. }
  22. },
  23. data () {
  24. return {
  25. isShow: false
  26. };
  27. },
  28. methods: {
  29. show () {
  30. this.isShow = true;
  31. setTimeout(this.hide, this.duration);
  32. },
  33. hide () {
  34. this.isShow = false;
  35. this.remove();
  36. }
  37. }
  38. };
  39. </script>
  40. <style>
  41. .box {
  42. position: fixed;
  43. width: 100%;
  44. top: 16px;
  45. left: 0;
  46. text-align: center;
  47. pointer-events: none;
  48. background-color: #fff;
  49. border: grey 3px solid;
  50. box-sizing: border-box;
  51. }
  52. .box-content {
  53. width: 200px;
  54. margin: 10px auto;
  55. font-size: 14px;
  56. padding: 8px 16px;
  57. background: #fff;
  58. border-radius: 3px;
  59. margin-bottom: 8px;
  60. }
  61. </style>

使用create

测试,components/form/index.vue

  1. <script>
  2. import create from "@/utils/create";
  3. import Notice from "@/components/Notice";
  4. export default {
  5. methods: {
  6. submitForm (form) {
  7. this.$refs[form].validate(valid => {
  8. const notice = create(Notice, {
  9. title: "社会你杨哥喊你来搬砖",
  10. message: valid ? "请求登录!" : "校验失败!",
  11. duration: 1000
  12. });
  13. notice.show();
  14. });
  15. }
  16. }
  17. };
  18. </script>

思考拓展

  1. 范例中$parent/$children写法不够健壮,如何修正?
  2. 学习element源码,从中获取答案

vue3中的组件化

起始

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>Document</title>
  7. </head>
  8. <body>
  9. <div id="app">
  10. {{data}}
  11. </div>
  12. <script src="https://unpkg/vue@next"></script>
  13. <script>
  14. const {
  15. createApp
  16. } = Vue
  17. createApp({
  18. data() {
  19. return {
  20. data: 'fooooo'
  21. }
  22. },
  23. }).mount('#app')
  24. </script>
  25. </body>
  26. </html>

composition-api

更好的代码组织和复用性

  1. createApp({
  2. setup () {
  3. const data = ref('fooooo')
  4. return { data }
  5. }
  6. }).mount('#app')

文档 视频教程

global-api改为实例方法

全局静态方法引发一些问题,vue3将global-api改为app实例方法

  1. // Vue.component()
  2. const app = createApp({})
  3. .component('comp', { template: '<div>i am comp</div>' })
  4. .mount('#app')

.sync和model选项移除,统一为v-model

以前.sync和v-model功能有重叠,容易混淆,vue3做了统一。

  1. <div id="app">
  2. <h3>{{data}}</h3>
  3. <comp v-model="data"></comp>
  4. </div>
  1. app.component('comp', {
  2. template: `
  3. <div @click="$emit('update:modelValue', 'new value')">
  4. i am comp, {{modelValue}}
  5. </div>
  6. `,
  7. props: ['modelValue'],
  8. })

渲染函数api修改

不再传入h函数,需要我们手动导入;拍平的props结构。scopedSlots删掉了,统一到slots

  1. import { h } from 'vue'
  2. render() {
  3. const emit = this.$emit
  4. const onclick = this.onclick
  5. return h('div', [
  6. h('div', {
  7. onClick () {
  8. emit('update:modelValue', 'new value')
  9. }
  10. }, `i am comp, ${this.modelValue}`),
  11. h('button', {
  12. onClick () {
  13. onclick()
  14. }
  15. }, 'buty it!')
  16. ])
  17. },

组件emits选项

该选项用于标注自定义事件及其校验等。

  1. createApp({
  2. setup () {
  3. return {
  4. // 添加一个onBuy方法
  5. onBuy (p) {
  6. console.log(p);
  7. },
  8. };
  9. },
  10. })
  11. .component("comp", {
  12. template: `
  13. <div>
  14. <div @click="$emit('update:modelValue', 'new value')">i am comp,
  15. {{modelValue}}</div>
  16. <button @click="$emit('buy', 'nothing')">buy it!</button>
  17. </div>
  18. `,
  19. // emits标明组件对外事件
  20. // emits: ['buy', '...']
  21. emits: {
  22. 'update:modelValue': null, // 不做校验
  23. buy (p) { // 校验buy事件
  24. if (p === 'nothing') {
  25. console.warn('参数非法');
  26. return false
  27. } else {
  28. return true
  29. }
  30. }
  31. },
  32. })
  1. <comp v-model="data" @buy="onBuy"></comp>

$on, $once, $off被移除

上述 3 个方法被认为不应该由vue提供,因此被移除了,可以使用其他库实现等效功能。

  1. <script src="https://unpkg.com/mitt/dist/mitt.umd.js"></script>
  1. // 发送事件
  2. emitter.emit('foo', 'foooooooo')
  3. // 监听事件
  4. emitter.on('foo', msg => console.log(msg))

作业

仿照element-ui table实现KTable

  1. 基本展示

使用形式如下:

  1. <k-table :data="tableData">
  2. <k-table-column prop="date" label="日期"></k-table-column>
  3. </k-table>

展示效果图
image.png

  1. 可以自定义列模板

形式如下

  1. <k-table-column label="操作" :default-sort = "{prop: 'date', order:'descending'}">
  2. <k-table-column prop="date" label="日期" sortable></k-table-column>
  3. </k-table-column>
  1. 实现表格排序方法

要求:在k-table-column中传入sortable参数即可在该列表头出现升序和降序排序方法,进行相应的排
序
形式如下

  1. <k-table-column label="操作">
  2. <template v-slot="scope"> {{ scope.row.operation }} </template>
  3. </k-table-column>

效果如下:
image.png

  1. 针对表格编写一个测试用例