深入vue工具类源码Directive和VueUse,学会函数封装思想,实现简单工具类

2,812 阅读6分钟

简介

Directive:  就是vue的自定义指令,除了核心功能默认内置的指令 (例如 v-model 、 v-show、v-if等),Vue 也允许注册自定义指令。注意,在 Vue 中,代码复用和抽象的主要形式是组件。然而,有的情况下,你仍然需要对普通 DOM 元素进行底层操作,这时候就会用到自定义指令。

VueUse:  VueUse 是一个基于 Composition API 的实用函数集合。通俗的来说,这就是一个工具函数包,它可以帮助你快速实现一些常见的功能,免得你自己去写,解决重复的工作内容,让你在 vue3 中更加得心应手。 分享目的:通过让大家了解如何编写通用的工具方法,来解决日常开发中不与业务逻辑耦合的一些底层重复性的操作,飞速提升代码编写效率和可读性。 而且vueuse的封装函数的思想在vue3中也恰恰比较符合函数封装规范,官方源码(github.com/vueuse/vueu…

为什么要做此次分享

通常我们使用第三方提供的一些工具类来完成项目,我们在感慨这些优秀库的同时,是否也可以静静地思考其实现方式,通过学习优秀的源码案例,并自己手动简单实现是否也能有一些成就感呢。当然我们自己在项目中也有自定义指令在使用,看起来很简单的东西,往往存在没有考虑全面的情况,导致封装的东西会有一丢丢小bug, 俗话说,熟能生巧,近距离的接触这些常用的工具类也会让自己有所收获,当积累的经验,踩得坑越来越多的同时,我们也可以形成一些自己的心得,之前项目中遇到一个关于指令的bug,也是场景和使用上没有考虑那么多导致的,下面我会还原问题场景,并且使用源码案例和自己编写的简易案例来让大家简单学习一下工具函数封装思想,希望能给大家带来一点点的收获,也希望大家能创造自己的轮子,不是跑两圈就断的那种!

Directive 核心介绍

下面是我抄的官网的一些介绍,当了解了他的一些内容之后,也就是围绕这些内容来做一些dom处理,就可以自己去实现一些demo了。

const myDirective = {
  // 在绑定元素的 attribute 前
  // 或事件监听器应用前调用
  created(el, binding, vnode) {
    // 下面会介绍各个参数的细节
  },
  // 在元素被插入到 DOM 前调用
  beforeMount(el, binding, vnode) {},
  // 在绑定元素的父组件
  // 及他自己的所有子节点都挂载完成后调用
  mounted(el, binding, vnode) {},
  // 绑定元素的父组件更新前调用
  beforeUpdate(el, binding, vnode, prevVnode) {},
  // 在绑定元素的父组件
  // 及他自己的所有子节点都更新后调用
  updated(el, binding, vnode, prevVnode) {},
  // 绑定元素的父组件卸载前调用
  beforeUnmount(el, binding, vnode) {},
  // 绑定元素的父组件卸载后调用
  unmounted(el, binding, vnode) {}
}

指令的所有钩子会传递以下几种参数:

  • el:指令绑定到的元素。这可以用于直接操作 DOM。

  • binding:一个对象,包含以下属性。

    • value:传递给指令的值。例如在 v-my-directive="1 + 1" 中,值是 2
    • oldValue:之前的值,仅在 beforeUpdate 和 updated 中可用。无论值是否更改,它都可用。
    • arg:传递给指令的参数 (如果有的话)。例如在 v-my-directive:foo 中,参数是 "foo"
    • modifiers:一个包含修饰符的对象 (如果有的话)。例如在 v-my-directive.foo.bar 中,修饰符对象是 { foo: true, bar: true }
    • instance:使用该指令的组件实例。
    • dir:指令的定义对象。
  • vnode:代表绑定元素的底层 VNode。

  • prevVnode:代表之前的渲染中指令所绑定元素的 VNode。仅在 beforeUpdate 和 updated 钩子中可用。

下面通过分享一两个简单的案例来了解其vue3中的用法。

v-loading

背景:在日常使用的项目中loading极为常见,通常我们都使用第三方库提供的,类似element-plus的loading, 但是在我们了解完官网自定义指令的用法之后,也可以自己简单动手实现,而且还可以结合使用参数、修饰符等添加定制化逻辑实现多场景,在这里通过简版的代码给大家讲一下实现思路。这里也简单支持了全局loading和局部loading。

思考实现一个loading需要哪些步骤?

  1. 首先需要一个loading组件
  2. createApp创建实例
  3. 将应用实例挂载到 HTML DOM 中的某个元素上。
  4. 将dom添加到某个指定位置(当前绑定元素或全局)
  5. 可以根据参数进行loading定制化,使loading丰富
  6. 在loading状态改变后进行移除
import { createApp } from "vue"
import Loading from "@/components/loading.vue"
export default {
  mounted(el, binding) {
    const {modifiers, value} = binding;
    if (value) {
      createInstance(el, binding)
    }
  },
  updated(el, binding) {
    createInstance(el, binding);
  }
}

const createInstance = (trigger, binding) => {
  const {value, oldValue, modifiers, arg} = binding;
  const fullScreen = modifiers?.fullScreen
  let target = fullScreen ? document.body : trigger;
  console.log('arg----', arg);
  console.log('fullScreen',  modifiers)
  if (oldValue !== value) {
    if (value && !oldValue) {
      const app = createApp(Loading);
      const loadingEl = app.mount(document.createElement('div')).$el
      loadingEl.setAttribute('id', 'curLoadingId');
      if (fullScreen) loadingEl.classList.add('is-full-screen');
      target.style.position = 'relative'
      arg.bg && (target.style.background = arg.bg);
      target.appendChild(loadingEl);
    } else {
      const childDiv = target.querySelector('#curLoadingId');
      target.style.position = '';
      target?.removeChild(childDiv);
      target.style.background = ''
    }
  } 
}

v-hotkey 自定义指令-热键

发现了很有意思的指令,看了演示的demo (dafrok.github.io/v-hotkey/#/… 觉得很好玩,我把vue3的代码扒下来看了下它是怎么实现的。

function bindEvent(el, { value, modifiers }, alias) {
  el._keyMap = getKeyMap(value, alias)
  el._keyHandler = e => assignKeyHandler(e, el._keyMap, modifiers)

  document.addEventListener('keydown', el._keyHandler)
  document.addEventListener('keyup', el._keyHandler)
}

function unbindEvent(el) {
  document.removeEventListener('keydown', el._keyHandler)
  document.removeEventListener('keyup', el._keyHandler)
}

export function buildDirective(alias) {
  return {
    mounted(el, binding) {
      bindEvent(el, binding, alias)
    },
    updated(el, binding) {
      if (binding.value !== binding.oldValue) {
        unbindEvent(el)
        bindEvent(el, binding, alias)
      }
    },
    unmounted(el) {
      unbindEvent(el)
    },
  }
}
export const assignKeyHandler = (e, keyMap, modifiers) => {
  const { keyCode, ctrlKey, altKey, shiftKey, metaKey } = e
  const eventKeyModifiers = { ctrlKey, altKey, shiftKey, metaKey }

  if (modifiers.prevent)
    e.preventDefault()

  if (modifiers.stop)
    e.stopPropagation()

  const { nodeName, isContentEditable } = document.activeElement
  if (isContentEditable)
    return
  if (FORBIDDEN_NODES.includes(nodeName))
    return

  const callback = getHotkeyCallback(keyMap, keyCode, eventKeyModifiers)
  if (!callback)
    return e
  e.preventDefault()
  callback[e.type](e)
}

比如我注册一个enter键和一个esc键的事件,当我按下空格或esc分别触发不同的事件,监听事件的方法和如何调用我在指令内部去做,而我业务方只需要关心这些热键的回调,根据回调去做一些业务逻辑的处理,调用方式很简单。

其他方法我就不做过多说明怎么实现了,当然自己编写的方法还存在很大的可优化空间,只是通过案例,让大家了解工具的使用方法,了解了这些方法的实现原理后,自己是不是也可以通过自定义指令实现一些自己想做的事情呢。

VueUse

起源:这个项目很大程度启发于react-use。Vue Compostion API支持了更好的逻辑分离,让这些常用的工具可以被复用,能够让你快速地构建丰富的应用, 说白了就是函数工具集。之前从vue2迁到vue3中,没有思维转变,导致开发的过程中,某些方法,用了自己造的轮子,但是发现vueuse这个宝藏库之后,发现自己的实现方式不香了,就看了一些它某些方法的源码实现方式,就想学习分享一下。

vueuse核心思想

  • 建立连接 (建立输入 -> 输出的链接,输出会自动根据输入的改变而改变)
  • 尽可能的使用option对象来作为参数
  • 可组合的函数(将一个个单一职责的函数组合形成另一个函数,达到逻辑复用的能力,我觉得这也便是组合式函数的魅力所在吧.当然,每个函数也都可以进行独立使用,用户可以根据自己的需要进行选择)
  • 无耦合性 (这也是封装思想必备的特性)

VueUse 中的大多数函数都返回一个refs 对象,您可以使用ES6 的对象解构语法来获取您需要的内容。 它目前有几十个解决方案,适用于常见的开发者用例,跟踪Ref变化、检测元素可见性、简化常见的Vue模式、键盘/鼠标输入等。这是一个真正节省开发时间的好方法,因为你不必自己添加所有这些标准功能。 

其实vueuse就是进行了基于 Composition API 封装的工具方法,所以它的好处也就特别明显。所以它拥有所有composition api的好处, vueuse尽可能使用选项对象作为参数,以便在未来的扩展中更加灵活。当涉及浏览器尚未广泛实现的 Web API 时,也输出isSupported标志,输出方法的兼容性,用于tryOnUnmounted优雅地清除副作用

这里我简单用其中一个源码案例,来让大家认识如何编写一个vueuse函数,以及自己去实现一个vueuse。

场景1: 实现一个元素拖拽, 并记录元素位置。(引用useDraggable源码探讨其实现思路)

image.png 我分别用两种方式实现了一下拖拽,我分别演示一下两种实现方式。可以对比一下vueuse实现方式与directive方式优缺点,并通过对比发现vueuse的灵活性。 实现步骤:

  • 确认函数参数:目标元素(dom)
  • 配置参数(拖拽相关事件回调(包括拖拽开始、拖拽结束、拖拽中)、初始化位置(元素初始化位置)等)
  • 函数返回值:拖拽元素的位置、元素style(为了改变元素的位置)、拖拽状态等。

扩展:其实我们也可以在hook中集成一些埋点啊,坐标上报啊,这样与业务隔离的方法会使我们的主页面看起来更清爽

import { useEventListener } from '@vueuse/core';

export default function useDraggable(target, options = {}) {
  const position = ref(options.initialValue ?? { x: 0, y: 0 });
  const draggingElement = options.draggingElement ?? window;
  const pressedDelta = ref();
  const preventDefault = (e) => {
    if (options.preventDefault) e.preventDefault();
  };
  const start = (e) => {
    if (unref(options.exact) && e.target !== unref(target)) return;
    const rect = unref(target).getBoundingClientRect();
    const pos = {
      x: e.pageX - rect.left,
      y: e.pageY - rect.top,
    };
    if (options.onStart?.(pos, e) === false) return;
    pressedDelta.value = pos;
    preventDefault(e);
  };

  const move = (e) => {
    if (!pressedDelta.value) return;
    position.value = {
      x: e.pageX - pressedDelta.value.x,
      y: e.pageY - pressedDelta.value.y,
    };
    options.onMove?.(position.value, e);
    preventDefault(e);
  };
  const end = (e) => {
    pressedDelta.value = undefined;
    options.onEnd?.(position.value, e);
    preventDefault(e);
  };
  useEventListener(target, 'pointerdown', start, true);
  useEventListener(draggingElement, 'pointermove', move, true);
  useEventListener(draggingElement, 'pointerup', end, true);
  return {
    position,
    isDragging: computed(() => !!pressedDelta.value),
    style: computed(() => `left:${position.value.x}px;top:${position.value.y}px;`),
  };
}

场景2: 实现一个校验input单项的工具函数

思考我们应该传入什么、需要获得什么,以及怎么做到隔离,易于扩展

我们同样按照vueuse编码核心来做这件事

  1. 首先明确校验载体,也就是案例中的input
  2. 给载体增加失焦事件,以及手动校验函数
  3. 根据传入的规则进行校验确定输入是否正确
  4. 返回校验状态、重置校验函数、主动校验函数

扩展tips: 参数传入根据自己的需求可以定制化,但要明确其含义,避免封装时给人误导

import { unref, ref, watch, onMounted } from "vue";
export function unrefElement(elRef) {
  const plain = unref(elRef);
  return plain?.$el ?? plain;
}

const resetError = (el) => {
  console.log('ssss', el);
  el.className = el.className.replace("err_class", "").trim();
  if (el.parentNode) {
    const ErrorNode = el.parentNode.querySelector(".err_message");
    if (ErrorNode) {
      el.parentNode.removeChild(ErrorNode);
    }
  }
};
const validateError = (el, errorMsg) => {
  if (Array.prototype.includes.call(el.classList, "err_class")) {
    //如果当前组件里已经有了错误提示信息,什么也不做
    return;
  } else {
    const errorNode = document.createElement("p");
    errorNode.className = "err_message";
    errorNode.textContent = errorMsg;
    if (el.parentNode) {
      // 在当前input 元素后追加一个p元素,内容为错误提示
      el.parentNode.appendChild(errorNode);
    }
    // 在当前input 元素上添加一个err_class类名
    el.className += " err_class";
  }
};
export default function useValidate(target, callback, rules) {
  const isPass = ref(true);
  const blur = (e) => {
    const el = unrefElement(target);
    resetError(el);
    isPass.value = true;
    const val = el.value;
    if (rules["max"] && val.length > rules["max"]) {
      isPass.value = false;
      validateError(el, `长度不能超过${rules["max"]}`);
    } else if (rules["required"] && val.length <= 0) {
      isPass.value = false;
      validateError(el, `不能为空`);
    }
  };
  console.log('----', callback);
  const validate = () => {
    var ev = new Event("blur", { bubbles: true, cancelable: true });
    const el = unrefElement(target);
    el.dispatchEvent(ev);
    isPass.value ? callback.success('成功') : callback.fail('失败')
    return isPass;
  };
  onMounted(() => {
    const el = unrefElement(target);
    el.addEventListener("blur", blur);
  });
  return {
    isPass,
    validate,
    resetError
  };
}

调用方式:

const validateRef = ref(null);
const { isPass, validate, resetError } = useValidate(
  validateRef,
  {
    success: res => {
      alert(res);
    },
    fail: res => {
      console.log("-----");
      alert(res);
    },
  },
  {
    max: 5,
    required: true,
  }
);

总结

我们通过演示和代码分析,慢慢了解了如何去写指令、写vueuse工具函数,其实就是为了实现一个目的,学会函数封装思想,以及如何在实战中应用。当然真正实战封装时不像demo这种比较糙。所以在业务逻辑中碰到一些可以可复用的、处理过程单独拆分的,可以自己挑战一下,动手封装一个vueuse去实现。为团队提供一些通用的解决方案。

待办事项

  • directive 指令学习
  • vueuse 学习
  • striapi实现cms系统
  • nuxt2 nuxt3 服务端渲染
  • 如何自己编写插件集成在项目中,比如常见babel插件、webpack插件等
  • serverless技术初识
  • vite 与 webpack 对比,以及新型的打包插件

gitee.com/jinjin-hand… vue2版本自定义指令

gitee.com/jinjin-hand… vue3版本自定义指令以及vueuse

image.png