Vue 进阶

152 阅读7分钟

主讲:云隐

优势一:模板化

插槽 - 模板更加灵活

  • 默认插槽:组件外部维护参数以及结构,内部安排放置位置;
  • 具名插槽:以 name 标识插槽的身份,从而在组件内部可以做到区分开来;
  • 作用域插槽:可以接受 propsscope-slot);

例子:

  • src\views\HomeView.vue
<template>
  <div class="home">
    <HelloWorld msg="Welcome to Your Vue.js App">
      <p>{{ msg }}</p>

      <template v-slot:header>{{ header }}</template>
      <template v-slot:body>{{ body }}</template>
      <template v-slot:footer>{{ footer }}</template>

      <!-- 老版本写法 -->
      <!-- <template slot="content" slot-scope="{ slotProps }">{{ slotProps }}</template> -->
      <!-- 新版本写法 -->
      <template v-slot:content2="{ slotProps2 }">{{ slotProps2 }}</template>
    </HelloWorld>
  </div>
</template>

<script>
import HelloWorld from '@/components/HelloWorld.vue';

export default {
  name: 'HomeView',
  data() {
    return {
      msg: 'zhaowa start',
      header: 'zhaowa header',
      body: 'zhaowa body',
      footer: 'zhaowa footer',
    };
  },
  components: {
    HelloWorld,
  },
};
</script>
  • src\components\HelloWorld.vue
<template>
  <div class="hello">
    <h1>{{ msg }}</h1>

    <!-- 默认插槽 -->
    <h3>默认插槽</h3>
    <slot></slot>

    <!-- 具名插槽 -->
    <h3>具名插槽</h3>
    <div class="header">
      <slot name="header"></slot>
    </div>
    <slot></slot>
    <div class="body">
      <slot name="body"></slot>
    </div>
    <div class="footer">
      <slot name="footer"></slot>
    </div>

    <!-- 作用域插槽 -->
    <h3>作用域插槽</h3>
    <div>
      <slot name="content" :slotProps="slotProps"></slot>
    </div>
    <div>
      <slot name="content2" :slotProps2="slotProps2"></slot>
    </div>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  props: {
    msg: String,
  },
  data() {
    return {
      slotProps: 'slotProps start',
      slotProps2: 'slotProps2 start',
    };
  },
};
</script>

模板数据的二次加工上

过滤器

// 使用:管道符 |
{{ timer | format }}
<template>
  <div class="hello">
    <!-- 过滤器 -->
    <h3>money:{{ money | moneyFilter }}</h3>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  data() {
    return {
      money: 100,
    };
  },
  filters: {
    // 声明过滤器
    moneyFilter(money) {
      // 面试题:filter 过滤器中的 this 不指向实例
      let res = money > 99 ? 99 : money;
      return res && res.toFixed(2);
    },
  },
};
</script>
面试题:filter 过滤器中的 this 不指向实例

v-html … 指令化

<template>
  <div class="hello">
    <!-- v-html 也可以处理逻辑 -->
    <h3 v-html="money > 99 ? 99 : money"></h3>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  data() {
    return {
      money: 100,
    };
  }
};
</script>

jsx

<script>
export default {
  name: 'HelloWorld1',
  data() {
    return {
      options: [
        {
          value: 1,
          text: 1,
        },
        {
          value: 2,
          text: 2,
        },
        {
          value: 3,
          text: 3,
        },
      ],
      money: 100,
    };
  },
  methods: {
    handleClick() {
      console.log('==== handleClick ====');
    },
  },
  // node => script(return node)
  render(h) {
    // 手写节点
    const moneyNode = <p>{this.money > 99 ? 99 : this.money}</p>;

    return (
      <ul>
        {
          // jsx 实现数组遍历
          this.options.map((item, index) => {
            return (
              // <li>{ item.text }</li>

              // 组件引入 - 属性 & 事件
              <content
                item={item}
                value={item.value}
                key={index}
                onClick={this.handleClick}
              >
                {moneyNode}
              </content>
            );
          })
        }
      </ul>
    );
  },
};
</script>

优势二: 组件化

传统模板化组件

// 注册
Vue.component('component', {
  template: '<h2>Essential Links</h2>'
})

// 创建实例
new Vue({
  el: '#app'
})

混入 mixin

  1. 应用场景:抽离公共逻辑(逻辑相同,但是模板不一样);

  2. 缺点:数据来源不太明确;

export default {
  data() {
    return {
      msg: '我是 mixin',
      obj: {
        title: 'mixinTitle',
        header: 'mixinHeader',
      },
    };
  },
  created() {
    console.log('mixin created');
  },
};

面试题:合并策略

  • data 冲突时,以组件主体为优先;
  • 生命周期钩子会先后执行,先 mixin 后主体;
  • 递归合并,递归合并优先级仍以主体优先;
// src\components\fragments\demoMixin.js

export default {
  data() {
    return {
      msg: '我是 mixin',
      obj: {
        title: 'mixinTitle',
        header: 'mixinHeader',
      },
    };
  },
  created() {
    console.log('mixin created');
  },
};
<template>
  <div class="hello">
    <h1>{{ msg }}</h1>
  </div>
</template>

<script>
import demoMixin from '@/components/fragments/demoMixin';

export default {
  name: 'HelloWorld',
  mixins: [demoMixin],
  data() {
    return {
      msg: 'Welcome to Your Vue.js App',
      obj: {
        number: 123,
        header: 'zhaowa header',
      },
    };
  },
  created() {
    console.log('hello world created');
  },
};
</script>

image-20220504140625458.png

继承拓展 extends

  • 应用场景:拓展独立逻辑;

面试题:合并策略 - 与 mixin 相同

  • 合并优先级上 mixin > extends
  • 声明周期回调优先级 extends > mixin
// src\components\fragments\demoExtends.js

export default {
  data() {
    return {
      msg: '我是 extends',
      obj: {
        title: 'extendsTitle',
        header: 'extendsHeader',
      },
    };
  },
  created() {
    console.log('extends created');
  },
};
<template>
  <div class="hello">
    <h1>{{ msg }}</h1>
    <p>{{ obj.header }}</p>
  </div>
</template>

<script>
import demoMixin from '@/components/fragments/demoMixin';
import demoExtends from '@/components/fragments/demoExtends';

export default {
  name: 'HelloWorld2',
  mixins: [demoMixin],
  extends: demoExtends,
  data() {
    return {
      msg: 'Welcome to Your Vue.js App',
      obj: {
        number: 123,
      },
    };
  },
  created() {
    console.log('hello world created');
  },
};
</script>

image-20220504141645038.png

整体拓展 - extend

从预定义的配置中拓展一个独立配置项,并且进行 合并

  • 路径:src\main.js
new Vue({
  router,
  store,
  render: h => h(App),
}).$mount('#app');

// 拓展一个构造器
let _baseOptions = {
  data() {
    return {
      course: 'zhaowa',
      session: 'vue',
      teacher: 'yy',
    };
  },
  created() {
    console.log('extend base');
  },
};

const BaseComponent = Vue.extend(_baseOptions);

// 基于 BaseComponent 再拓展逻辑
new BaseComponent({
  created() {
    console.log('extend created');
  },
});

image-20220504142728494.png

插件 - Vue.use(VueRouter)

  • 注册外部插件,作为整体实例能力的补充;
  • 自动做除重,防止多次重复注册相同插件;
  • 编写插件:
    • 外部使用 Vue.use(myPlugin, options)
    • 内部默认调用 install
// src\plugins\myPlugins.js

export default {
  install: (Vue, options) => {
    // 添加全局方法或属性
    Vue.globalMethod = function () {
      console.log('globalMethod');
    };

    // 全局指令
    Vue.directive('my-directive', {
      bind(el, binding, vnode) {},
      inserted(el, binding, vnode) {},
      update(el, binding, vnode) {},
      componentUpdated(el, binding, vnode) {},
      unbind(el, binding, vnode) {},
    });

    // 全局 mixin
    Vue.mixin({
      created() {
        console.log('plugin mixin created');
      },
    });

    // 全局方法
    Vue.prototype.$yyMethod = function () {
      console.log('yyMethod');
      // 使用:this.$yyMethod
    };
  },
};
// src\main.js

import myPlugins from '@/plugins/myPlugins';
// 加载拓展插件
// 插件配置
const _options = {
  name: 'my plugin',
};
Vue.use(myPlugins, _options);

扩展

插槽

Vue 2.6.0 之后采用全新 v-slot 语法取代之前的 slotslot-scope

一、匿名插槽(默认插槽)

  • 父组件:
<!-- 匿名插槽 -->
<Comp1></Comp1>

<Comp1>我是组件1的匿名插槽</Comp1>
  • 子组件:
<div>
  <slot>如果父组件中传递数据,会替换此处内容</slot>
</div>

image-20210916024507046.png

二、具名插槽

v-onv-bind 一样,v-slot 也有缩写,即把参数之前的所有内容 (v-slot:) 替换为字符 #,例如 v-slot:header 可以被重写为 #header

  • 父组件:
<!-- 具名插槽 -->
<Comp2>
  <!-- <template>我是组件2的匿名插槽</template> -->

  <!-- 或者 -->
  <!-- <template v-slot:default>我是组件2的匿名插槽</template> -->

  <!-- 或者 -->
  <template #default>我是组件2的匿名插槽</template>

  <template v-slot:content>具名插槽1</template>

  <template #content2>具名插槽2</template>
</Comp2>
  • 子组件:
<div>
  <slot>comp-slot 后备内容</slot>
  <br />
  <slot name="content">具名插槽</slot>
  <br />
  <slot name="content2">具名插槽</slot>
</div>

image-20210916025125258.png

三、作用域插槽

一般情况下,父组件中显示数据要声明的父组件中,使用作用域插槽父组件中要显示的数据可以由子组件决定。

  • 父组件:
<!-- 作用域插槽 -->
<Comp3>
  <!-- <template v-slot:default="ctx">我是组件3的作用域插槽:{{ ctx.foo }}</template> -->

  <!-- 或者 -->
  <template #default="ctx">我是组件3的作用域插槽:{{ ctx.foo }}</template>

  <template #content="ctx">我是组件3的作用域插槽:{{ ctx.dong }}</template>
  <!-- 解构 -->
  <template #content2="{ dong }">我是组件3的作用域插槽:{{ dong }}</template>
</Comp3>
  • 子组件:
<template>
  <div>
    <slot :foo="foo"></slot>
    <br />
    <slot name="content" dong="dong~~"></slot>
    <br />
    <slot name="content2" :dong="dong"></slot>
  </div>
</template>

<script>
export default {
  data() {
    return {
      foo: 'Comp3 的 foo',
      dong: 'Comp3 的 dong'
    };
  }
};
</script>

image-20210916025820710.png

四、动态插槽名

动态指令参数也可以用在 v-slot 上,来定义动态的插槽名。

  • 父组件:
<!-- 动态插槽名 -->
<Comp4>
  <template v-slot:[dynamicSlotName]="{ tua }">我是组件4的动态插槽名:{{ tua }}</template>
</Comp4>

<script>
  export default {
    data() {
      return {
        dynamicSlotName: 'comp4SlotName'
      };
    }
  };
</script>
  • 子组件:
<template>
  <div>
    <slot name="comp4SlotName" :tua="tua"></slot>
  </div>
</template>

<script>
export default {
  data() {
    return {
      tua: 'Comp4 的 tua'
    };
  }
};
</script>

image-20210916030200636.png

插槽的解析的流程

对于解析的流程,我们可以分为以下的几个步骤:

  • 解析父组件的 vDOM,以配置的形式解析出来每个数组元素的内容;
  • 将父组件内部的内容转移至子组件 optionsrenderChildren 上(initInternalComponent);
  • 子组件转移 renderChildrenvm.$slots 上 (initRender);
  • 解析子组件 vDOM,将模版中的 slot 占位符替换为 _t 函数,最终 _t 执行,从 vm.$slots 中替换渲染内容(renderMixin);

表单组件实现

form表单示意图.png

实现要求:

模仿 Element-UI 的表单,分为三层结构:Form 表单组件、FormItem 表单项组件、InputCheckBox 组件,具体分工如下:

  • Form 表单组件:
    • 实现:预留插槽、管理数据模型 model、自定义校验规则 rules、全局校验方法 validate
  • FormItem 表单项组件:
    • 实现:预留插槽、显示 label 标签、执行数据校验、显示校验结果;
  • InputCheckBox 组件:
    • 实现:绑定数据模型 v-model、通知 FormItem 组件执行校验;

数据校验:

  • 思路:校验发生在 FormItem,它需要知道何时校验(让 Input 通知它),还需要知道怎么校验(注入校验规则);
  • 任务1:Input 通知校验;
onInput(e) {
  // $parent 指的 FormItem
  this.$parent.$emit("validate");
}
  • 任务2:FormItem 监听校验规则,获取规则并执行校验
inject: ["Form"], // 注入
mounted() {
  // 监听校验事件
  this.$on("validate", () => {
    this.validate();
  })
},
methods: {
  validate() {
    // 执行组件校验
    // 1、获取校验规则
    const rules = this.form.rules[this.prop];

    // 2、获取数据
    const value = this.form.model[this.prop];

    // 3、执行校验
    const desc = {
      [this.prop]: rules
    };
    const schema = new Schema(desc);

    // 参数1是值,参数2是校验错误对象数组
    // 返回的是 Promise<boolean>
    return schema.validate({ [this.prop]: value }, errors => {
      if (errors) {
        this.errorMessage = errors[0].message;
      } else {
        this.errorMessage = "";
      }
    });
  }
}
  • 任务3:表单全局验证,为 Form 提供 validate 方法
validate(cb) {
  // map 的结果是若干 Promise 数组
  const tasks = this.$children.filter(item => item.prop).map(item => item.validate());
  Promise.all(tasks)
    .then(() => cb(true))
    .catch(() => cb(false));
}

代码实现:

  • Input.vue
    • 双向绑定:@input:value
    • 派发校验事件
<template>
  <div>
    <!-- 
      1、自定义组件要实现 v-model 必须实现 :value 和 @input
      2、通知 FormItem 执行校验
      3、密码组件不应该明文显示,使用 v-bind="$attrs"
          $attrs 存储的是 props 之外的部分
      4、设置 inheritAttrs 为 false, 避免顶层容器继承属性
    -->
    <input :value="value" @input="onInput" v-bind="$attrs" />
  </div>
</template>

<script>
import emitter from '@/mixins/emitter';

export default {
  inheritAttrs: false, // 避免顶层容器继承属性
  mixins: [emitter],
  props: {
    value: {
      type: String,
      default: ''
    }
  },
  data() {
    return {};
  },
  methods: {
    onInput(e) {
      // 通知父组件数值发生变化
      this.$emit('input', e.target.value);

      // 通知 FormItem 执行校验
      // 这种写法不健壮,因为 Input 组件和 KFormItem 组件之间可能会隔代
      // this.$parent.$emit('validate');

      /* 
        这里什么时候用 this.$emit,什么时候用 this.$parent.$emit???
        【注意:事件的派发者是谁,事件的监听者就是谁】
          ==> 使用 $emit('input') 派发 input 事件,因为是 input 组件自己去监听,只不过是包含在 v-model 中了;
          ==> 使用 $parent.$emit('validate') 派发检验事件,也是父组件自己去监听
      */

      // ! 使用 mixins 混入方法
      this.dispatch('validate');
    }
  }
};
</script>

<style scoped></style>
  • checkbox.vue
<template>
  <section>
    <input type="checkbox" :checked="checked" @change="onChange" />
  </section>
</template>

<script>
import emitter from '@/mixins/emitter';

export default {
  mixins: [emitter],
  props: {
    checked: {
      type: Boolean,
      default: false
    }
  },
  model: {
    prop: 'checked',
    event: 'change'
  },
  methods: {
    onChange(e) {
      this.$emit('change', e.target.checked);
      // this.$parent.$emit("validate");
      this.dispatch('validate');
    }
  }
};
</script>
<style scoped lang="less"></style>
  • FormItem.vue
    • Input 预留插槽 - slot
    • 能够展示 label 和校验信息
    • 能够进行校验
      • npm i async-validator -S
<template>
  <div class="formItem-wrapper">
    <!-- 
      1、给 Input 组件预留插槽
      2、显示 label 标签
      3、监听校验事件,并执行校验(使用 async-validator 插件进行校验)
      4、需要思考的问题:如何得到校验的数据和规则?
          校验的数据和规则通过祖代的 provide 注入,在此组件内通过 inject 获取
      5、如何知道校验的是哪条信息?
          会在该组件上改在一个 prop 属性,属性值为当前 model 的数据
      6。显示校验结果
    -->
    <div class="content">
      <label v-if="label" :style="{ width: labelWidth }">{{ label }}:</label>
      <slot></slot>
    </div>
    <p v-if="errorMessage" class="errorStyle">{{ errorMessage }}</p>
  </div>
</template>

<script>
import Schema from 'async-validator';

export default {
  inject: ['formModel'],
  props: {
    label: {
      type: String,
      default: ''
    },
    prop: String
  },
  data() {
    return {
      errorMessage: '',
      labelWidth: this.formModel.labelWidth
    };
  },
  mounted() {
    // 监听校验事件,并执行校验
    this.$on('validate', () => {
      this.validate();
    });
  },
  methods: {
    validate() {
      // 执行组件的校验
      // 1、获取数据
      const value = this.formModel.model[this.prop];
      // 2、获取校验规则
      const rules = this.formModel.rules[this.prop];
      // 3、执行校验
      const desc = {
        [this.prop]: rules
      };
      const schema = new Schema(desc);

      // 参数1是值,餐数2是校验错误对象数组
      // validate 校验可能是异步的,方法返回值是 Promise<Boolean>
      return schema.validate({ [this.prop]: value }, errors => {
        if (errors) {
          this.errorMessage = errors[0].message;
        } else {
          this.errorMessage = '';
        }
      });
    }
  }
};
</script>

<style scoped lang="less">
@labelWidth: 90px;

.formItem-wrapper {
  padding-bottom: 10px;
}
.content {
  display: flex;
}
.errorStyle {
  font-size: 12px;
  color: red;
  margin: 0;
  padding-left: @labelWidth;
}
</style>
  • Form.vue
    • FormItem 留插槽
    • 设置数据和校验规则
    • 全局校验
<template>
  <div>
    <!-- 
      1、给 FormItem 组件预留插槽
      2、传递 Form 实例给后代,比如 FormItem 用来获取校验的数据和规则
    -->
    <slot></slot>
  </div>
</template>

<script>
export default {
  provide() {
    return {
      formModel: this // 传递 Form 实例给后代,比如 FormItem 用来获取校验的数据和规则
    };
  },
  props: {
    model: {
      type: Object,
      required: true
    },
    rules: {
      type: Object
    },
    labelWidth: String
  },
  data() {
    return {};
  },
  methods: {
    validate(cb) {
      // 执行全局校验
      // map 结果是若干 Promise 数组
      const tasks = this.$children.filter(item => item.prop).map(item => item.validate());
      // 所有任务必须全部校验成功才算校验通过
      Promise.all(tasks)
        .then(() => cb(true))
        .catch(() => cb(false));

      // Promise.all(tasks)
      //   .then(cb(true))
      //   .catch(cb(false));

      // Promise.all(tasks)
      // .then(() => {
      //   cb(true);
      // })
      // .catch(() => {
      //   cb(false);
      // });
    }
  }
};
</script>

<style scoped></style>
  • index.vue
<template>
  <div>
    <Form :model="formModel" :rules="rules" ref="loginForm" label-width="90px">
      <FormItem label="用户名" prop="username">
        <Input v-model="formModel.username"></Input>
      </FormItem>
      <FormItem label="密码" prop="password">
        <Input type="password" v-model="formModel.password"></Input>
      </FormItem>
      <FormItem label="记住密码" prop="remember">
        <CheckBox v-model="formModel.remember"></CheckBox>
      </FormItem>
      <FormItem>
        <button @click="onLogin">登录</button>
      </FormItem>
    </Form>
  </div>
</template>

<script>
import Input from '@/components/form/Input';
import CheckBox from '@/components/form/CheckBox';
import FormItem from '@/components/form/FormItem';
import Form from '@/components/form/Form';

import Notice from '@/components/Notice';
import create from '@/utils/create';

export default {
  data() {
    const validateName = (rule, value, callback) => {
      if (!value) {
        callback(new Error('用户名不能为空'));
      } else if (value !== 'admin') {
        callback(new Error('用户名错误 - admin'));
      } else {
        callback();
      }
    };
    const validatePass = (rule, value, callback) => {
      if (!value) {
        callback(false);
      } else {
        callback();
      }
    };
    return {
      formModel: {
        username: '',
        password: '',
        remember: false
      },
      rules: {
        username: [{ required: true, validator: validateName }],
        password: [{ required: true, message: '密码必填' }],
        remember: [{ required: true, message: '记住密码必选', validator: validatePass }]
      }
    };
  },
  methods: {
    onLogin() {
      this.$refs.loginForm.validate(isValid => {
        // if (isValid) {
        //   alert("登录成功");
        // } else {
        //   alert("登录失败");
        // }

        // 创建弹窗实例
        create(Notice, {
          title: '提示信息',
          message: isValid ? '登录成功' : '登录失败',
          duration: 1000
        }).show();
      });
    }
  },
  components: {
    Input,
    CheckBox,
    FormItem,
    Form
  }
};
</script>

<style scoped></style>

思考问题:

1、dispatch 和 broadcast 的实现

// 分发 - 自下而上
Vue.prototype.$dispatch = function(eventName, data) {
  let parent = this.$parent;
  // 查找父元素
  while (parent) {
    // 父元素用 $emit 触发
    parent.$emit(eventName, data);
    // 递归查找父元素
    parent = parent.$parent;
  }
};

Vue.prototype.$broadcast = function(eventName, data) {
  broadcast.call(this, eventName, data);
};

// 广播 - 自上而下
function broadcast(eventName, data) {
  this.$children.forEach(child => {
    // 子元素触发 $emit
    child.$emit(eventName, data);
    if (child.$children.length) {
      // 递归调用,通过 call 修改 this 指向 child
      broadcast.call(child, eventName, data);
    }
  });
}

// 用法
<button @click="$broadcast('broadcast', '我是Child1')">广播子元素</button>
<button @click="$dispatch('dispatch', '哈喽 我是GrandGrandChild1')">dispatch</button>

mounted() {
  this.$on("dispatch", msg => {
    this.msg = "接收dispatch消息:" + msg;
  });
  this.$on("broadcast", msg => {
    this.msg = "接收broadcast消息:" + msg;
  });
}

2、.sync 和 v-model 的异同

  • v-model 是语法糖:
<!-- v-model 是语法糖 -->
<Input v-model="username">

<!-- 默认等效于下面这行 -->
<Input :value="username" @input="username=$event">

但是你也可以通过在 data 中设置 model 选项修改默认行为,例如:Checkbox.vue

{
  model: {
    prop: 'checked',
    event: 'change'
  }
}
<!-- 上面这样设置会导致上级使用 v-model 时行为变化,相当于 -->
<KCheckBox :checked="model.remember" @change="model.remember = $event"></KCheckBox>

场景:v-model 通常用于表单控件,它有默认行为,同时属性名和事件名均可在子组件定义;

  • sync 修饰符
<!-- sync修饰符添加于v2.4,类似于 v-model,它能用于修改传递到子组件的属性,如果像下面这样写 -->
<Input :value.sync="model.username">
  
<!-- 等效于下面这行,那么和 v-model 的区别只有事件名称的变化 -->
<Input :value="username" @update:value="username=$event">

<!-- 这里绑定属性名称更改,相应的属性名也会变化 -->
<Input :foo="username" @update:foo="username=$event">

场景:1、非表单控件;2、父组件传递的属性子组件想修改;

所以 sync 修饰符的控制能力都在 父级,事件名称也相对固定 update:xxx

习惯上表单元素用 v-model

  • 父组件
<template>
  <div>
    <h3>username: {{ modelForm.username }}</h3>

    <SyncInput :username.sync="modelForm.username"></SyncInput>

    <!-- 等效于下面这行 -->
    <!-- <SyncInput :username="modelForm.username" @update:username="val => (modelForm.username = val)"></SyncInput> -->
    <!-- <SyncInput :username="modelForm.username" @update:username="updateFn"></SyncInput> -->
  </div>
</template>

<script>
import SyncInput from '@/components/5-Sync/syncInput.vue';

export default {
  name: '',
  data() {
    return {
      modelForm: {
        username: 'Tom'
      }
    };
  },
  methods: {
    updateFn(val) {
      // 接收 $emit 传递的参数
      this.modelForm.username = val;
    }
  },
  components: {
    SyncInput
  }
};
</script>

<style scoped></style>
  • 子组件
<template>
  <div>
    <span>我是 sync 的子组件</span>

    <van-button plain size="small" type="primary" @click="handleUpdate">更改值</van-button>
  </div>
</template>

<script>
export default {
  name: 'syncInput',
  data() {
    return {
      num: 0
    };
  },
  methods: {
    handleUpdate() {
      this.$emit('update:username', `我是更改之后的值 ${this.num++}`);
    }
  }
};
</script>

<style scoped lang="less"></style>

Vue 插件 plugin

插件通常用来为 Vue 添加全局功能。插件的功能范围没有严格的限制,⼀般有下面几种:

  • 添加全局方法或者 property
    • 如:vue-custom-element
  • 添加全局资源:指令/过滤器/过渡等;
    • vue-touch
  • 通过全局混入来添加⼀些组件选项;
    • vue-router
  • 添加 Vue 实例方法,通过把它们添加到 Vue.prototype 上实现;
  • ⼀个库,提供自己的 API,同时提供上面提到的⼀个或多个功能;
    • vue-router

对于每个我们需要使用的插件来说,我们需要在具体的业务逻辑前,也就是实例化 Vue 前,使用 Vue.use 来使用插件。

Vue.use(MyPlugin);

new Vue({
  // ...组件选项
});

// 事实上等同于
Myplugin.install(Vue);

对于我们开发插件对象来说,我们需要给这个对象下暴露⼀个 install 方法。也就是说只要一个【对象】有 install 方法,同时它的第⼀个参数为 Vue 构造函数,第二个参数为一个 options,那么它就是一个合法的 Vue 插件。

// myPlugin 只需要是一个对象即可
let MyPlugin = {};
let MyPlugin = function () {};

MyPlugin.install = function (Vue, options) {};

我们可以使用插件做很多自动化的事情,某些情况下我们可以比【组件化】能够做更多细粒度的封装。

// 1. 添加全局静态方法 myGlobalMethod
Vue.myGlobalMethod = function () {};

// 2. 为组件增加 created 生命周期
Vue.mixin({ created: function () {} });

// 3. 添加实例方法 this.$myMethod
Vue.prototype.$myMethod = function (methodOptions) {};

我们可以使用插件的形式,更加方便的封装组件和通用的业务逻辑,甚至实现我们自己的⼀些生命周期。

1、使用插件注册一个 echarts 组件

  • 安装 echarts
    • npm install echarts --save
  • 新建 ./src/plugin.js
import * as echarts from 'echarts';

//! 通过插件方式注入组件
export const useEchartsComponent = {
  install: function (Vue) {
    let id = 0;
    Vue.component('echart', {
      data() {
        return {
          id: `__echart-el=${id++}`
        };
      },
      props: {
        options: {
          type: Object
        }
      },
      mounted() {
        const $el = document.getElementById(this.id);
        const myChart = echarts.init($el);
        myChart.setOption(this.options);
      },
      render(createElement) {
        return createElement('div', {
          attrs: {
            id: this.id
          },
          style: {
            width: '500px',
            height: '400px'
          }
        });
      }
    });
  }
};
  • ./main.js 中测试
import { useEchartsComponent } from './plugin';
Vue.use(useEchartsComponent);
  • ./App.vue 中使用
<template>
  <div id="app">
    <echart :options="options"></echart>
  </div>
</template>

<script>
export default {
  name: 'App',
  data() {
    return {
      options: {
        title: {
          text: 'ECharts 入门示例'
        },
        tooltip: {},
        legend: {
          data: ['销量']
        },
        xAxis: {
          data: ['衬衫', '羊毛衫', '雪纺衫', '裤子', '高跟鞋', '袜子']
        },
        yAxis: {},
        series: [
          {
            name: '销量',
            type: 'bar',
            data: [5, 20, 36, 10, 10, 20]
          }
        ]
      }
    };
  }
};
</script>

2、范例:移动 $bus 到插件

class Bus {
  constructor() {
    this.callbacks = {};
  }
  $on(name, fn) {
    this.callbacks[name] = this.callbacks[name] || [];
    this.callbacks[name].push(fn);
  }
  $emit(name, args) {
    if (this.callbacks[name]) {
      this.callbacks[name].forEach(cb => cb(args));
    }
  }
}

Bus.install = function(Vue) {
  Vue.prototype.$bus = new Vue();
};

export default Bus;

// 使用
// main.js
import Bus from "./plugins/bus";
Vue.use(Bus);

// this.$bus...

Vue 混合 mixin

混入(mixin)提供了⼀种非常灵活的方式,来分发 Vue 组件中的可复用功能。⼀个混入对象可以包含任意组件选项。当组件使用混入对象时,所有混入对象的选项将被 “混合” 进入该组件本身的选项。

我们的 mixin 可以在 组件级别全局 两种方式进行混入。

1、组件混入

// 定义⼀个混入对象
var myMixin = {
  created: function () {
    this.hello();
  },
  methods: {
    hello: function () {
      console.log('hello from mixin!');
    }
  }
};

// 定义⼀个使用混入对象的组件
var Component = Vue.extend({
  mixins: [myMixin]
});

var component = new Component(); // => "hello from mixin!"

2、全局对象

Vue.mixin({
  created: function () {
    var myOption = this.$options.myOption;
    if (myOption) {
      console.log(myOption);
    }
  }
});

对于 merge 的策略,主要分为以下几类:

  • 关于 datadata 部分会在内部进行递归的处理,也就是 deepMerge,遇到同名 key组件内部 的为准;
  • 关于生命周期:生命周期函数会进行⼀个数组处理,所有 mixin 进来的生命周期都会执行,并且 mixin 传入的生命周期会在组件内部生命周期之前执行

自定义选项将使用默认策略,即简单地覆盖已有值。如果想让自定义选项以自定义逻辑合并,可以向 Vue.config.optionMergeStrategies 添加⼀个函数:

Vue.config.optionMergeStrategies.myOption = function (toVal, fromVal) {
  // 返回合并后的值
};

3、在插件中定义混入

  • src\plugin.js
//! 通过插件方式注入 mixin,混入生命周期
export const notify = {
  install(Vue) {
    //! 全局注册混入
    Vue.mixin({
      data() {
        return {
          text: 'mixin.text',
          next: {
            info: 'mixin.next.info',
            text: 'mixin.next.text'
          }
        };
      },
      async created() {
        console.log('mixin 的 created');

        let promise = () => {
          return new Promise((resolve, reject) => {
            setTimeout(() => {
              resolve('promise执行了~');
            }, 2000);
          });
        };
        let result = await promise();
        console.log('result >>>>>> ', result);
      }
    });
  }
};

//! 通过插件方式注入 mixin,混入方法
export const noReachBottomNotify = {
  install(Vue) {
    Vue.mixin({
      mounted() {
        const THRESHOLD = 50;
        if (typeof this.onReachBottom === 'function') {
          window.addEventListener('scroll', () => {
            // window.screen.height:屏幕高度
            // window.scrollY:滚动高度
            // document.documentElement.offsetHeight:文档的高度

            const sub = document.documentElement.offsetHeight - (window.scrollY + window.screen.height);
            if (sub < THRESHOLD) {
              this.onReachBottom();
            }
          });
        }
      }
    });
  }
};
  • main.js 中注册:
import { notify, noReachBottomNotify } from './plugin';

Vue.use(notify);
Vue.use(noReachBottomNotify);
  • 在组件中使用:
<template>
  <div class="hello">
    <h1>{{ msg }}</h1>
    <h1>{{ next.text }}</h1>
    <h1>{{ next.info }}</h1>
    <p v-for="(item, index) in list" :key="item.title">{{ index }}: {{ item.title }}</p>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  props: {
    msg: String
  },
  data() {
    return {
      next: {
        text: 'this is next.text'
      },
      list: [
        { title: 'title 1' },
        { title: 'title 2' },
        { title: 'title 3' },
        { title: 'title 4' },
        { title: 'title 5' }
      ]
    };
  },
  created() {
    console.log('组件中的 created');
  },
  methods: {
    onReachBottom() {
      console.log('触发了 onReachBottom');
      this.list.push(
        ...[
          { title: Math.random() },
          { title: Math.random() },
          { title: Math.random() },
          { title: Math.random() },
          { title: Math.random() }
        ]
      );
    }
  }
};
</script>
  • 页面展示:

mixin-1.png

4、范例:移动 dispatch 和 broadcast 到 mixins

// mixins/emitter.js
export default {
  methods: {
    dispatch(eventName, data) {
      let parent = this.$parent;
      // 查找父元素
      while (parent) {
        // 父元素用$emit触发
        parent.$emit(eventName, data);
        // 递归查找父元素
        parent = parent.$parent;
      }
    },
    broadcast(eventName, data) {
      broadcast.call(this, eventName, data);
    }
  }
};

function broadcast(eventName, data) {
  this.$children.forEach(child => {
    // 子元素触发$emit
    child.$emit(eventName, data);
    if (child.$children.length) {
      // 递归调用,通过call修改this指向 child
      broadcast.call(child, eventName, data);
    }
  });
}
  • form/Kinput.vue 中使用
<template>
  <section class="kInput">
    <input :value="value" @input="onInput" v-bind="$attrs" />
  </section>
</template>

<script>
import emitter from '@/mixins/emitter';

export default {
  inheritAttrs: false, // 避免顶层容器继承属性
  mixins: [emitter],
  props: {
    value: {
      type: String,
      default: ''
    }
  },
  methods: {
    onInput(e) {
      // 通知父组件数值变化
      this.$emit('input', e.target.value);

      // 通知 FormItem 校验
      // this.$parent.$emit("validate");
      this.dispatch('validate'); // 使用 mixin 中 emitter 的 dispatch,解决跨级问题
    }
  }
};
</script>

<style scoped lang="less">
.kInput {
  display: inline-block;
}
</style>

render 函数详解

1、使用

  • 一些场景中需要 JavaScript 的完全编程的能力,这时可以用渲染函数,它比模板更接近编译器。
render(h) {
	return h(tag, {...}, [children])
}
  • createElement 函数
{
  // 与 v-bind:class 的 API 相同,
  // 接受⼀个字符串、对象或字符串和对象组成的数组
  class: {
    foo: true,
    bar: false
  },
  // 与 v-bind:style 的 API 相同,
  // 接受一个字符串、对象,或对象组成的数组
  style: {
    color: 'red',
    fontSize: '14px'
  },
  // 普通的 HTML 特性
  attrs: {
    id: 'foo'
  },
  // 组件 prop
  props: {
    myProp: 'bar'
  },
  // DOM 属性
  domProps: {
  	innerHTML: 'baz'
  },
  // 事件监听器器在 on 属性内,
  // 但不再支持如 v-on:keyup.enter 这样的修饰器。
  // 需要在处理函数中手动检查 keyCode。
  on: {
 		click: this.clickHandler
  },
}

2、自定义全局组件

  • ./main.js 页面
/* 
  <div id="box" class="foo"><span>aaa</span></div>
*/
Vue.component("comp", {
  // 报错 You are using the runtime-only build of Vue where the template compiler is not available. Either pre-compile the templates into render functions, or use the compiler-included build.
  // 不报错的情况:当前在浏览器里面,通过 script 的方式引入一个带编译器的版本,才能使用字符串模板
  // 当前运行环境是 webpack 的打包环境,打包环境默认是不带编译器的,因为要生成最小的输出的包,所以不能带编译器
  // template: '<div id="box" class="foo"><span>aaa</span></div>',
  render(h) {
    return h("div", { class: { foo: true }, attrs: { id: "box" } }, [
      h("span", "aaa")
    ]);
  }

  // jsx 写法
  // render() {
  //   return (
  //     <div id="box" class="foo">
  //       <span>aaa</span>
  //     </div>
  //   );
  // }
});
  • App.vue 页面
<!-- 使用 App.vue -->
<comp></comp>

$el

  • 类型string | Element
  • 限制:只在用 new 创建实例时生效。
  • 详细
    • 提供一个在页面上已存在的 DOM 元素作为 Vue 实例的挂载目标。可以是 CSS 选择器,也可以是一个 HTMLElement 实例;
    • 在实例挂载之后,元素可以用 vm.$el 访问;
    • 如果在实例化时存在这个选项,实例将立即进入编译过程,否则,需要显式调用 vm.$mount() 手动开启编译;

提供的元素只能作为挂载点。不同于 Vue 1.x,所有的挂载元素会被 Vue 生成的 DOM 替换。因此 不推荐 挂载 root 实例到 <html> 或者 <body> 上。

vm.$mount()

  • 参数
    • {Element | string} [elementOrSelector]
    • {boolean} [hydrating]
  • 返回值vm - 实例自身
  • 用法
    • 如果 Vue 实例在实例化时没有收到 el 选项,则它处于“未挂载”状态,没有关联的 DOM 元素。可以使用 vm.$mount() 手动地挂载一个未挂载的实例;
    • 如果没有提供 elementOrSelector 参数,模板将被渲染为文档之外的元素,并且你必须使用原生 DOM API 把它插入文档中;
    • 这个方法返回实例自身,因而可以链式调用其它实例方法;
  • 示例
//  Vue.extend: 使用基础 Vue 构造器,创建一个“子类”。参数是一个包含组件选项的对象
var MyComponent = Vue.extend({
  template: '<div>Hello!</div>'
})

// 创建实例并挂载到 #app (会替换 #app)
new MyComponent().$mount('#app')

// 同上
new MyComponent({ el: '#app' })

// 或者,在文档之外渲染并且随后挂载
var component = new MyComponent().$mount()
document.getElementById('app').appendChild(component.$el)

Vue.extends(options)

  • 参数

    • {Object} options
  • 用法

    使用基础 Vue 构造器,创建一个 “子类”。参数是一个包含组件选项的对象。

    data 选项是特例,需要注意 - 在 Vue.extend() 中它 必须是函数

    <div id="mount-point"></div>
    
    // 创建构造器
    var Profile = Vue.extend({
      template: '<p>{{firstName}} {{lastName}} aka {{alias}}</p>',
      data: function () {
        return {
          firstName: 'Walter',
          lastName: 'White',
          alias: 'Heisenberg'
        }
      }
    })
    
    // 创建 Profile 实例,并挂载到一个元素上。
    new Profile().$mount('#mount-point')
    

    结果如下:

    <p>Walter White aka Heisenberg</p>
    

实现弹窗组件

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

1、常见使用姿势

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

2、create.js

  • create 函数用于动态创建指定组件实例并挂载至 body
    • 使用 new Vue 创建 Vue 的实例,并使用 $mount() 挂载函数;
    • 获取真正的 DOM 节点:vm.$el
import Vue from 'vue';

/**
 * @description: 创建指定组件实例并挂载于 body 上
 * @param {*} Component 组件名称
 * @param {*} props 属性
 * @return {*} 组件实例
 */
export default function create(Component, props) {
  // ! vue 文件中 export default { ... } 导出的是一个对象,就是组件的相关配置,不是一个组件的构造函数,所以无法通过 new Component 去创建实例
  // ! 可以使用 Vue.extend(options),返回值是一个构造函数,然后再 new XXX

  // 1. 创建 vue 实例
  const vm = new Vue({
    render(h) {
      // render 方法提供给我们一个 h 函数,它可以渲染 VNode(就是虚拟 DOM)
      return h(Component, { props });
    }
  }).$mount(); // 更新操作,执行挂载函数,但未指定挂载目标,表示只执行初始化工作
  /* 
    $mount('#app'):通常情况下会指定一个更新目标,例如 '#app'
    $mount():没有指定目标,只把更新函数准备好,后面通过手动的方式挂载上去 - document.body.appendChild(vm.$el);
  */

  // 2. 上面 vm 帮我们创建组件实例
  // 3. 通过 $children 获取该组件实例,$children 返回的是子组件的数组
  const comp = vm.$children[0];

  // 4. 追加至 body
  // document.body.appendChild(comp); // 不可以这么写
  // comp 只是组件实例,不能真实的 DOM 节点,可以通过 vm.$el 获取真实的 DOM 节点
  document.body.appendChild(vm.$el);

  // 5. 给组件实例添加销毁方法,防止内存泄漏
  comp.remove = () => {
    document.body.removeChild(vm.$el);
    vm.$destroy();
  };

  // 6. 返回组件实例
  return comp;
}

3、创建通知组件 - Notice.vue

  • Notice.vue
<template>
  <div class="box" v-if="isShow">
    <h3 class="title">{{ title }}</h3>
    <p class="box-content">{{ message }}</p>
  </div>
</template>

<script>
export default {
  name: 'notice',
  props: {
    title: {
      type: String,
      default: ''
    },
    message: {
      type: String,
      default: ''
    },
    duration: {
      type: Number,
      default: 0
    }
  },
  data() {
    return {
      isShow: false
    };
  },
  methods: {
    show() {
      this.isShow = true;

      setTimeout(() => {
        this.hide();
      }, this.duration);
    },
    hide() {
      this.isShow = false;

      this.remove();
    }
  }
};
</script>

<style scoped lang="less">
.box {
  position: fixed;
  top: 50px;
  left: 0;
  right: 0;
  width: 300px;
  padding: 15px 20px;
  border: 1px solid #dddddd;
  background-color: #dddddd;
  border-radius: 5px;
  text-align: center;
  margin: 0 auto;
  .title {
    font-size: 20px;
    padding-bottom: 20px;
    font-weight: 600;
  }
  .box-content {
    width: 100%;
    box-sizing: border-box;
    font-size: 14px;
    text-align: center;
  }
}
</style>

4、使用 create api

<script>
import Notice from "../Notice"
import create from "@/utils/create"

export default {
  methods: {
    onLogin() {
      this.$refs.loginForm.validate(isValid => {
        // 创建弹窗实例
        create(Notice, {
          title: "这是一个标题",
          message: isValid ? "登录!!!" : "有错!!!",
          duration: 30000
        }).show()
      });
    },
  }
}
</script>

递归组件

递归组件是可以在它们自己模板中调用自身的组件;

  • 必须有结束条件;
  • name 对递归组件是必要的;
// Node.vue
<template>
	<div>
    <h3>{{ data.title }}</h3>

    <!-- 必须有结束条件 -->
    <Node v-for="d in data.children" :key="d.id" :data="d"></Node>
  </div>
</template>
<script>
  export default {
    name: 'Node', // name 对递归组件是必要的
    props: {
      data: {
        type: Object,
        require: true
      },
    }
  }
</script>

// 使用
<Node :data="{id:'1',title:'递归组件',children:[{...}]}"></Node>

实现 Tree 组件

Tree 组件是典型的递归组件,其他的诸如菜单组件都属于这一类,也是相当常见的。

1、组件设计

Tree 组件最适合的结构是无序列表 ul,创建⼀个递归组件 Item 表示 Tree 选项,如果当前 Item 存在 children,则递归渲染子树,以此类推;同时添加一个标识管理当前层级 item 的展开状态。

2、实现 Item 组件

<template>
  <li>
    <div @click="handleToggle" :class="{ handle: isFolder }">
      <!-- 标题 -->
      {{ model.title }}

      <!-- 有子元素就显示 +/- -->
      <span v-if="isFolder">{{ open ? '-' : '+' }}</span>
    </div>

    <ul v-show="open" v-if="isFolder" class="list">
      <TreeItem v-for="model in model.children" :model="model" :key="model.title"></TreeItem>
    </ul>
  </li>
</template>

<script>
export default {
  name: 'TreeItem',
  props: {
    model: {
      type: Object
    }
  },
  data() {
    return {
      open: false
    };
  },
  methods: {
    handleToggle() {
      if (this.isFolder) {
        this.open = !this.open;
      }
    }
  },
  computed: {
    isFolder() {
      return this.model.children && this.model.children.length;
    }
  }
};
</script>

<style scoped lang="less">
.handle {
  cursor: pointer;
}
.list {
  padding-left: 50px;
}
.item {
  line-height: 25px;
}
</style>

3、使用

  • ./index.vue
<template>
  <div>
    <ul>
      <Item class="item" :model="treeData"></Item>
    </ul>
  </div>
</template>

<script>
import Item from '@/components/6-Tree/Item';

export default {
  name: 'Tree',
  data() {
    return {
      treeData: {
        title: 'Web全栈架构师',
        children: [
          {
            title: 'JavaScript'
          },
          {
            title: 'JS高级',
            children: [
              {
                title: 'ES6'
              },
              {
                title: '动效'
              }
            ]
          },
          {
            title: 'Web全栈',
            children: [
              {
                title: 'Vue训练营',
                expand: true,
                children: [
                  {
                    title: '基础知识'
                  },
                  {
                    title: '组件化'
                  },
                  {
                    title: '源码'
                  }
                ]
              },
              {
                title: 'React',
                children: [
                  {
                    title: 'JSX'
                  },
                  {
                    title: '虚拟DOM'
                  }
                ]
              },
              {
                title: 'Node'
              },
              {
                title: 'WebPack'
              }
            ]
          }
        ]
      }
    };
  },
  components: {
    Item
  }
};
</script>

<style scoped></style>