Vue组件化实践

课堂目标


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

知识要点

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

运行环境

  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 被识别 (且获取) 的特性绑定 (classstyle 除外)。当一个组件没有
声明任何 prop 时,这里会包含所有父作用域的绑定 (classstyle 除外),并且可以通过 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. 针对表格编写一个测试用例