Vue.js 2 使用 extends 扩展 element-ui 组件

246 阅读3分钟

为什么使用 extends 而不是 mixins

mixins 选项接受一个 mixin 对象数组。这些 mixin 对象可以像普通的实例对象一样包含实例选项,它们将使用一定的选项合并逻辑与最终的选项进行合并。

extends 使一个组件可以继承另一个组件的组件选项。这主要是为了便于扩展单文件组件。

从实现角度来看,extends 几乎和 mixins 相同。通过 extends 指定的组件将会当作第一个 mixin 来处理。

然而,extends 和 mixins 表达的是不同的目标。mixins 选项基本用于组合功能,而 extends 则一般更关注继承关系。

以上是官方文档的说明,看起来 extends 和 mixins 好像差不多,用哪个都可以,但其实他们有一个很大的区别:作用范围就不同

  • mixins 只能混合选项,执行选项合并策略。
<script>
export default {
  // mixins 能起作用的地方
  // 选项合并
}
<script>
  • extends 继承所有(template、script、style)。

template 执行模板替换,script 执行选项合并策略,style 执行样式追加,且支持继承自定义块。

<template>
  <!-- extends 能起作用的地方 -->
  <!-- 模板替换 -->
</template>
<script>
export default {
  // extends 能起作用的地方
  // 选项合并
}
<script>
<style>
  /* extends 能起作用的地方 */
  /* 样式追加 */
</style>

Vue.js 2 使用 extends 扩展 element-ui 组件

MyButton.vue

<script>
import { Button } from 'element-ui'

export default {
  extends: Button
}
</script>

main.js

import ElementUI from 'element-ui'
import MyButton from './components/MyButton'

Vue.use(ElementUI)

Vue.component('ElButton', MyButton)

以上 MyButton.vue 就只是简单的继承了 ElButton,没有加额外的扩展,并全局把 ElButton 替换成了 MyButton.vue 的实现。

此时 MyButton.vueElButton 一模一样,接下来我们就可以在 ElButton 的基础上扩展一些逻辑,比如:为 el-button 加上节流的限制,并加上一些样式。

当然也不能瞎扩展,首先要看看 ElButton 的源码,对于会被覆盖的选项,需要保证原逻辑的正常运行。

node_modules/element-ui/packages/button/src/button.vue

<template>
  ...
</template>
<script>
  export default {
    name: 'ElButton',
    ...
    methods: {
      handleClick(evt) {
        this.$emit('click', evt);
      }
    }
  };
</script>

为了省事我就只放部分源码了,核心是要覆盖 handleClick 方法。

直接上实现代码:

MyButton.vue

<script>
import { Button } from 'element-ui'

export default {
  extends: Button,
  props: {
    interval: {
      type: Number,
      default: 200
    }
  },
  data() {
    return {
      throttle: false
    }
  },
  methods: {
    // 覆盖 handleClick 方法
    // handleClick(evt) {
    //   this.$emit('click', evt)
    // }
    handleClick(evt) {
      // 节流
      if (!this.throttle) {
        this.throttle = true
        this.$emit('click', evt)
        setTimeout(() => {
          this.throttle = false
        }, this.interval)
      }
    }
  }
}
</script>

<style lang="scss" scoped>
.el-button {
  border-radius: 0;
}
</style>

image.png

MyButton.vue 继承了 ElButton,在支持 ElButton 完整功能的基础上,实现了点击节流功能:一定时间范围内(通过 props 传入 interval 属性,默认 200ms)只能点一次。<style> 里的样式也添加了。

解决 el-dialog mousedown mouseup 目标不一致的问题

以上是小试牛刀,使用 extends 扩展 element-ui 组件可以解决一些 element-ui 组件的 bug,比如 el-dialogel-popoverel-drawer 都有这样一个问题:在弹框内按下鼠标移动到弹框外松开,会触发弹框外(比如 el-dialog 的遮罩层)点击事件,即 mousedown 在弹框内,mouseup 在弹框外,反之亦然,均会触发遮罩层 click 事件(默认配置下就会执行 handleClose 关闭弹框)。

定位 el-dialog 源码可以看出,只监听了遮罩层(wrapper)的 click 事件,遮罩层仅触发 mousedown 或者 mouseup 都会触发 click 事件,无论 mousedown 和 mouseup 的目标(event.target)是否同一个。

node_modules/element-ui/packages/dialog/src/component.vue

<template>
  <transition>
    <div
      v-show="visible"
      class="el-dialog__wrapper"
      @click.self="handleWrapperClick">
      ...
    </div>
  </transition>
</template>

<script>
  export default {
    name: 'ElDialog',

    methods: {
      handleWrapperClick() {
        if (!this.closeOnClickModal) return;
        this.handleClose();
      },
      handleClose() {
        if (typeof this.beforeClose === 'function') {
          this.beforeClose(this.hide);
        } else {
          this.hide();
        }
      },
      hide(cancel) {
        if (cancel !== false) {
          this.$emit('update:visible', false);
          this.$emit('close');
          this.closed = true;
        }
      }
    },

    mounted() {
      if (this.visible) {
        this.rendered = true;
        this.open();
        if (this.appendToBody) {
          document.body.appendChild(this.$el);
        }
      }
    },

    destroyed() {
      // if appendToBody is true, remove DOM node after destroy
      if (this.appendToBody && this.$el && this.$el.parentNode) {
        this.$el.parentNode.removeChild(this.$el);
      }
    }
  };
</script>

在基于 Vue 3 的 element-plus 中就没有这个问题了,从源码可以看出 element-plus 判断了 mousedown 和 mouseup 的目标(event.target)都是当前目标(event.currentTarget)才会去执行 onModalClick 方法(即 mousedown 和 mouseup 目标都在遮罩层才触发点击遮罩层关闭)。

node_modules/element-plus/packages/components/dialog/src/dialog.vue

<template>
  <teleport>
    <transition>
      <el-overlay>
        <div
          role="dialog"
          @click="overlayEvent.onClick"
          @mousedown="overlayEvent.onMousedown"
          @mouseup="overlayEvent.onMouseup"
        >
          ...
        </div>
      </el-overlay>
    </transition>
  </teleport>
</template>

<script lang="ts" setup>
import { useDeprecated, useNamespace, useSameTarget } from '@element-plus/hooks'
import { useDialog } from './use-dialog'

const {
  onModalClick,
} = useDialog(props, dialogRef)

const overlayEvent = useSameTarget(onModalClick)
</script>

node_modules/element-plus/packages/hooks/use-same-target/index.ts

import { NOOP } from '@vue/shared'

export const useSameTarget = (handleClick?: (e: MouseEvent) => void) => {
  if (!handleClick) {
    return { onClick: NOOP, onMousedown: NOOP, onMouseup: NOOP }
  }

  let mousedownTarget = false
  let mouseupTarget = false
  // refer to this https://javascript.info/mouse-events-basics
  // events fired in the order: mousedown -> mouseup -> click
  // we need to set the mousedown handle to false after click fired.
  const onClick = (e: MouseEvent) => {
    // if and only if
    if (mousedownTarget && mouseupTarget) {
      handleClick(e)
    }
    mousedownTarget = mouseupTarget = false
  }

  const onMousedown = (e: MouseEvent) => {
    // marking current mousedown target.
    mousedownTarget = e.target === e.currentTarget
  }
  const onMouseup = (e: MouseEvent) => {
    // marking current mouseup target.
    mouseupTarget = e.target === e.currentTarget
  }

  return { onClick, onMousedown, onMouseup }
}

借鉴 element-plus 的处理,我们同样可以使用 extends 扩展 element-ui 的 el-dialog,并全局将 ElDialog 组件替换为扩展后的组件。

MyDialog.vue

<script>
import { Dialog } from 'element-ui'

export default {
  extends: Dialog,
  data() {
    return {
      mousedownTarget: false,
      mouseupTarget: false
    }
  },
  mounted() {
    this.$el.addEventListener('mousedown', this.onMousedown)
    this.$el.addEventListener('mouseup', this.onMouseup)
  },
  methods: {
    // 覆盖 handleWrapperClick 方法
    // handleWrapperClick() {
    //   if (!this.closeOnClickModal) return;
    //   this.handleClose();
    // },
    handleWrapperClick() {
      if (!this.closeOnClickModal) return
      if (this.mousedownTarget && this.mouseupTarget) {
        this.handleClose()
      }
      this.mousedownTarget = this.mouseupTarget = false
    },
    onMousedown(e) {
      this.mousedownTarget = e.target === e.currentTarget
    },
    onMouseup(e) {
      this.mouseupTarget = e.target === e.currentTarget
    }
  }
}
</script>

main.js

import ElementUI from 'element-ui'
import MyDialog from './components/MyDialog'

Vue.use(ElementUI)

Vue.component('ElDialog', MyDialog)

MyDialog.vue 的处理和 element-plus 差不多,监听 mousedown 和 mouseup 事件,设置相应触发标志,覆盖 handleWrapperClick 方法,mousedown 和 mouseup 的目标(event.target)都是当前目标(event.currentTarget)才执行 handleClose 方法。