Vue3 - Notification 组件开发

3,167 阅读3分钟

往期文章:

前言

简单研究了一下 vue3,感觉与 vue2 相比,差别还是蛮大的,新增和去掉了一些API,很多vue2的奇淫巧技无法再使用了,尤其是在改造自定义组件时,差点让我崩溃,这玩意想彻底掌握,还是有很多路要走,本文,将以 Notification 组件为例,描述一下 从 vue2 -> vue3 踩坑之旅

实现效果

准备工作

可以参考 Vue3 - 使用tsx编写组件

采用先整体后细节的方式描述

代码结构

├── components
│   ├── NotifyDemo.vue  # notification use example
│   ├── notification    # notification custom plugin
│   │   ├── function.tsx  # business call the notification. like alert
│   │   ├── index.ts      # install plugin out
│   │   └── notification.vue # component

开始

来,跟着我,左手右手一个慢动作~

入口文件注册组件

  • vue2
// main.js
import Vue from 'vue'
import App from './App.vue'
import Notification from './components/notification'

// 调用 插件的 install方法
Vue.use(Notification)

new Vue({
  render: h => h(App)
}).$mount('#app')
  • vue3
// main.ts
import { createApp } from 'vue';
import App from './App.vue';
import Notification from './components/notification'

const app = createApp(App);

// 调用 插件的 install方法
app.use(Notification);

app.mount('#app');

Notification install

  • vue2
// notification/index.js
/**
 * 通知插件
 * 全局注册组件
 * 调用方法挂载在全局Vue上,use anywhere,
 * 组件内使用:this.$notify({ content: 'test' })
 */
import Notification from './notification.vue'
import notify from './function'

export default (Vue) => {
  // 全局注册 component 
  Vue.component(Notification.name, Notification)
  // 将 $notify 方法 挂载在 vue的原型上
  Vue.prototype.$notify = notify
}
  • vue3
// notification/index.ts
/**
 * 通知插件
 * 全局注册组件
 * vue3 已经去掉 整体导出 Vue,这样无法将方法直接挂载在Vue的原型上了,而是通过config注册
 * 使用 组件获取instance.proxy,const { proxy } = getCurrentInstance();
 * proxy.$notify({ content: 'test' });
 * 不要使用 ctx,生产环境不支持
 */
import { Plugin, App } from 'vue';
import Notification from './notification.vue';
import notify from './function';

// 挂载组件方法
const install = (app: App): App => {
  // 
  app.config.globalProperties.$notify = notify;
  app.component(Notification.name, Notification);
  return app;
};

export default install as Plugin;

notification.vue

  • vue2
<template>
  <transition name="fade" @after-leave="afterLeave" @after-enter="afterEnter">
     <div class="notification" :style="style" v-show="visible" @mouseenter="clearTimer" @mouseleave="createTimer">
      <span class="content">{{ content }}</span>
      <a class="btn" @click="handleClose">{{ btn }}</a>
    </div>
  </transition>
</template>

<script>
export default {
  name: 'Notification',
  data () {
    return {
      verticalOffset: 0,
      autoClose: 3000,
      height: 0,
      visible: false,
    };
  },
  props: {
    content: {
      type: String,
      required: true
    },
    btn: {
      type: String,
      default: '关闭'
    }
  },
  computed: {
    style() {
      return {
        position: "fixed",
        right: "20px",
        bottom: `${this.verticalOffset}px`,
      };
    },
  },
  mounted() {
    this.createTimer();
  },
  methods: {
    handleClose (e) {
      e.preventDefault()
      this.$emit('close')
    },
    afterLeave () {
      this.$emit('closed')
    },
    createTimer() {
      if (this.autoClose) {
        this.timer = setTimeout(() => {
          this.visible = false;
        }, this.autoClose);
      }
    },
    clearTimer() {
      if (this.timer) {
        clearTimeout(this.timer);
      }
    },
    afterEnter() {
      // debugger // eslint-disable-line
      this.height = this.$el.offsetHeight;
    }
  },
  beforeDestory() {
    this.clearTimer();
  },
}
</script>

<style scoped>
.notification{
  display: inline-flex;
  background-color: #303030;
  color: rgba(255, 255, 255, 1);
  align-items: center;
  padding: 20px;
  min-width: 280px;
  box-shadow: 0px 3px 5px -1px rgba(0, 0, 0, 0.2), 0px 6px 10px 0px rgba(0, 0, 0, 0.14), 0px 1px 18px 0px rgba(0, 0, 0, 0.12);
  flex-wrap: wrap;
  transition: all .3s;
}
.content{
  padding: 0;
}
.btn{
  color: #ff4081;
  padding-left: 24px;
  margin-left: auto;
  cursor: pointer;
}
</style>
  • vue3
<template>
  <transition name="fade" @after-leave="afterLeave" @after-enter="afterEnter">
    <div
      v-show="visible"
      ref="root"
      class="notification"
      :style="styleObj"
      @mouseenter="clearTimer"
      @mouseleave="createTimer"
    >
      <span class="content">{{ content }}</span>
      <a class="btn" @click="handleClose">{{ btn }}</a>
    </div>
  </transition>
</template>

<script lang="ts">
import {
  defineComponent,
  reactive,
  ref,
  toRefs,
  computed,
  onMounted,
  onBeforeUnmount,
} from 'vue';

export default defineComponent({
  name: 'Notification',
  props: {
    content: {
      type: String,
      required: true,
    },
    duration: {
      type: Number,
      default: 3000,
    },
    btn: {
      type: String,
      default: '关闭',
    },
    verticalOffset: {
      type: Number,
      default: 0,
    },
    // eslint-disable-next-line vue/require-default-prop
    onClosed: Function,
  },
  setup(props) {
    const root = ref(null!);

    const state = reactive({
      height: 0,
      visible: false,
    });

    const styleObj = computed(() => ({
      position: 'fixed',
      right: '20px',
      bottom: `${props.verticalOffset}px`,
    }));

    const timer = ref(0);

    const handleClose = (e: MouseEvent): void => {
      e.preventDefault();
      state.visible = false;
    };

    const afterLeave = () => {
      (props as any).onClosed(state.height);
    };

    const afterEnter = () => {
      state.height = (root as any).value.offsetHeight;
    };

    const createTimer = () => {
      if (props.duration) {
        timer.value = setTimeout(() => {
          state.visible = false;
        }, props.duration) as unknown as number;
      }
    };

    const clearTimer = () => {
      if (timer.value) {
        clearTimeout(timer.value);
        timer.value = 0;
      }
    };

    onMounted(() => {
      state.visible = true;
      createTimer();
    });

    onBeforeUnmount(() => {
      clearTimer();
    });

    // toRefs 把reactive创建出的数据变更为响应式
    return {
      ...toRefs(state),
      root,
      styleObj,
      handleClose,
      afterLeave,
      afterEnter,
      clearTimer,
      createTimer,
    };
  },
});
</script>

<style scoped>
.notification {
  display: inline-flex;
  background-color: #303030;
  color: rgba(255, 255, 255, 1);
  align-items: center;
  padding: 20px;
  min-width: 280px;
  box-shadow: 0px 3px 5px -1px rgba(0, 0, 0, 0.2),
    0px 6px 10px 0px rgba(0, 0, 0, 0.14), 0px 1px 18px 0px rgba(0, 0, 0, 0.12);
  flex-wrap: wrap;
  transition: all 0.3s;
}
.content {
  padding: 0;
}
.btn {
  color: #ff4081;
  padding-left: 24px;
  margin-left: auto;
  cursor: pointer;
}
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.5s;
}
.fade-enter,
.fade-leave-to {
  opacity: 0;
}
</style>

function

/**
 * 创建通知插件的调用方法
 */
import Vue from 'vue'
import Component from './func-notification'

const NotificationConstructor = Vue.extend(Component)

const instances = []
let seed = 1

const removeInstance = instance => {
  if (!instance) return
  const len = instances.length
  const index = instances.findIndex(inst => instance.id === inst.id)
  instances.splice(index, 1)

  if (len < 1) return
  const removeHeight = instance.vm.height
  for (let i = index; i < len - 1; i++) {
    instances[i].verticalOffset = parseInt(instances[i].verticalOffset) - removeHeight - 16
  }
}

const notify = (options) => {
  if (Vue.prototype.$isServer) return
  const { autoClose, ...rest } = options
  const instance = new NotificationConstructor({
    propsData: { ...rest },
    data: {
      autoClose: autoClose === undefined ? 3000 : autoClose
    }
  })

  const id = `notification_${seed++}`
  instance.id = id
  instance.vm = instance.$mount()
  document.body.appendChild(instance.vm.$el)
  instance.visible = true

  let verticalOffset = 0
  instances.forEach(item => {
    verticalOffset += item.$el.offsetHeight + 16
  })
  verticalOffset += 16
  instance.verticalOffset = verticalOffset

  instances.push(instance)

  instance.vm.$on('closed', () => {
    removeInstance(instance)
    document.body.removeChild(instance.vm.$el)
    instance.vm.$destroy()
  })

  instance.vm.$on('close', () => {
    instance.vm.visible = false
  })

  return instance.vm
}

export default notify
  • vue3
    • vue3 去掉了 onon、off 等方法,如果想使用,可以使用 mitt 代替,这里换一种方式实现
/**
 * 创建通知插件的调用方法
 */
import { createApp, ComponentPublicInstance } from 'vue';
import Notification from './notification.vue';

export type OptionsType = {
  content: string;
  duration?: number;
  btn: string;
};

type InstanceType = {
  id: string;
  vm: ComponentPublicInstance<any>;
}

const instances: InstanceType[] = [];
let seed = 1;

const removeInstance = (id: string, removeHeight: number): void => {
  const index = instances.findIndex(item => item.id === id);
  const len = instances.length;
  
  // 删除 instance
  instances.splice(index, 1);
  
  if (len < 1) return;
  
  for (let i = index; i < len - 1; i++) {
    const inst = instances[i].vm;
    inst.bottomOffset = inst.bottomOffset - removeHeight - 16;
  }
};

const notify = (options: OptionsType): void => {
  const id = `notification_${seed++}`;
  const container = document.createElement('div');
  document.body.appendChild(container);

  let verticalOffset = 16;
  instances.forEach(item => {
    verticalOffset += item.vm.$el.offsetHeight + 16;
  });

  const instance = createApp({
    data() {
      return {
        bottomOffset: verticalOffset
      }
    },
    methods: {
      closedFunc(height: number):void {
        removeInstance(id, height);
        document.body.removeChild(container)
        instance.unmount();
      }
    },
    render() {
      return <Notification { ...options } verticalOffset={this.bottomOffset} onClosed={this.closedFunc} />
    }
  });

  instances.push({
    id,
    vm: instance.mount(container)
  });
};

export default notify;

尾声

代码写的不是很优雅,只是为加深对 vue3 了解,熟悉都谈不上,希望可以帮助到有需要的人。 目前 vue3 体验之旅到这里就结束了,本人在一段时间内也不会太关注 vue3(工作用不到),后续本人会转战到 PhaserJs 的研究,❤️ 期待关注 ❤️

❤️ 加入我们

字节跳动 · 幸福里团队

Nice Leader:高级技术专家、掘金知名专栏作者、Flutter中文网社区创办者、Flutter中文社区开源项目发起人、Github社区知名开发者,是dio、fly、dsBridge等多个知名开源项目作者

期待您的加入,一起用技术改变生活!!!

招聘链接: job.toutiao.com/s/JHjRX8B

WechatIMG4.jpeg