Vant Use 源码学习

377 阅读7分钟

Built-in composition APIs of Vant.

onMountedOrActivated

  1. onMountedOrActivated
优点
  • 合并钩子函数
  • 封装 mounted 状态在 hook 内部
export function onMountedOrActivated(hook: () => any) {
  let mounted: boolean; // 首次加载不触发onActivated钩子,防止触发两次事件

  onMounted(() => {
    hook();
    nextTick(() => {
      mounted = true;
    });
  });

  // 请注意:
  // onActivated 在组件挂载时也会调用,并且 onDeactivated 在组件卸载时也会调用。
  // 这两个钩子不仅适用于 <KeepAlive> 缓存的根组件,也适用于缓存树中的后代组件。
  // https://cn.vuejs.org/guide/built-ins/keep-alive.html#basic-usage
  onActivated(() => {
    if (mounted) {
      hook();
    }
  });
}

useEventListener

  1. useEventListener 事件监听
优点
  • 响应式的 target 节点变化会自动更新监听事件到新节点上
  • 自动挂载卸载监听事件
import { Ref, watch, isRef, unref, onUnmounted, onDeactivated } from 'vue';
import { onMountedOrActivated } from '../onMountedOrActivated';
import { inBrowser } from '../utils';

type TargetRef = EventTarget | Ref<EventTarget | undefined>;

export type UseEventListenerOptions = {
  target?: TargetRef;
  capture?: boolean; // 默认情况下,capture 参数为 false,表示事件处理程序在冒泡阶段执行。如果将 capture 参数设置为 true,事件处理程序将在捕获阶段执行。
  passive?: boolean; // 用于指示事件处理程序是否是被动模式。在被动模式下,事件处理程序不会调用 preventDefault() 来阻止事件的默认行为。这对于一些滚动事件等性能敏感的事件非常有用,因为它可以告诉浏览器该事件处理程序不会阻止滚动,从而优化滚动的性能。
};

// 函数重载
export function useEventListener<K extends keyof DocumentEventMap>(
  type: K,
  listener: (event: DocumentEventMap[K]) => void,
  options?: UseEventListenerOptions
): void;
export function useEventListener(
  type: string,
  listener: EventListener,
  options?: UseEventListenerOptions
): void;
export function useEventListener(
  type: string,
  listener: EventListener,
  options: UseEventListenerOptions = {}
) {
  // 非浏览器环境直接返回
  if (!inBrowser) {
    return;
  }

  const { target = window, passive = false, capture = false } = options;

  let attached: boolean; // 防止多次挂载以及卸载状态

  const add = (target?: TargetRef) => {
    const element = unref(target);

    if (element && !attached) {
      element.addEventListener(type, listener, {
        capture,
        passive,
      });
      attached = true;
    }
  };

  const remove = (target?: TargetRef) => {
    const element = unref(target);

    if (element && attached) {
      element.removeEventListener(type, listener, capture);
      attached = false;
    }
  };

  onUnmounted(() => remove(target));
  onDeactivated(() => remove(target));
  // 这是不是也可以写一个onUnmountedOrDeactivated
  onMountedOrActivated(() => add(target));

  // 实时更新监听函数
  if (isRef(target)) {
    watch(target, (val, oldVal) => {
      remove(oldVal);
      add(val);
    });
  }
}

useClickAway

优点
  • 功能封装
  • 自动挂载卸载监听事件
  1. useClickAway 点击在元素外部
  • target eventName 没有 watch 监听,不会响应式变化更新到
  • 核心利用了 contains 属性做判断有无包含在内
import { Ref, unref } from 'vue';
import { inBrowser } from '../utils';
import { useEventListener } from '../useEventListener';

export type UseClickAwayOptions = {
  eventName?: string;
};

export function useClickAway(
  target:
    | Element
    | Ref<Element | undefined>
    | Array<Element | Ref<Element | undefined>>,
  listener: EventListener,
  options: UseClickAwayOptions = {}
) {
  // 非浏览器环境直接返回
  if (!inBrowser) {
    return;
  }

  const { eventName = 'click' } = options;

  const onClick = (event: Event) => {
    const targets = Array.isArray(target) ? target : [target];
    // 是否全部节点都不包含点击的节点
    const isClickAway = targets.every((item) => {
      const element = unref(item);
      return element && !element.contains(event.target as Node);
      // contains() 表示一个节点是否是给定节点的后代,即该节点本身、其直接子节点(childNodes)、子节点的直接子节点等
    });

    // 满足不包含目标元素则触发回调
    if (isClickAway) {
      listener(event);
    }
  };
  // 监听事件
  // useEventListener options
  useEventListener(eventName, onClick, { target: document });
}

useCountDown

  1. useCountDown 倒计时器

优点

  • 更准确的倒计时使用,支持毫秒级和秒级倒计时
  • 组件卸载时自动清除倒计时事件
  • 控制事件全暴露给外部调用
参数
参数说明类型默认值
time倒计时时长,单位毫秒number-
millisecond是否开启毫秒级渲染booleanfalse
onChange倒计时改变时触发的回调函数(current: CurrentTime) => void-
onFinish倒计时结束时触发的回调函数-
原理
  1. 手动触发 start 函数 计算 endTime确定什么时候结束计时 和 变更 counting 状态
  2. 区分是否毫秒计时器计时
  3. 计时器通过 raf 递归时间,相比下 setInterval会被长任务阻塞导致不准
requestAnimationFrame cancelAnimationFrame API

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。

window.cancelAnimationFrame 取消一个先前通过调用 window.requestAnimationFrame()方法添加到计划中的动画帧请求。

以下是 requestAnimationFrame 的优势
  • 更加流畅更好的性能和节能:requestAnimationFrame 会在浏览器的重绘周期内执行回调函数,通常是每秒 60 次(具体频率取决于浏览器和设备)。它会与浏览器的渲染过程同步,确保在最佳的时机执行代码,以实现平滑的动画效果。相比之下,setInterval 的执行频率是固定的,可能会导致过早或过晚执行代码,从而产生不稳定的动画效果。此外,requestAnimationFrame 在页面不可见或处于后台标签页时会自动暂停,节省了不必要的计算资源。

  • 自动适应帧率:requestAnimationFrame 的回调函数会接收一个时间戳参数,可以利用这个时间戳计算动画的进度和变化。这样可以根据实际的渲染帧率来调整动画的速度和流畅度,使其在不同的设备和浏览器上都表现出一致的效果。而 setInterval 固定的时间间隔可能会导致动画在不同设备上的表现不一致。

  • 避免卡顿和掉帧:requestAnimationFrame 会在浏览器的重绘周期内执行代码,避免了连续执行大量计算造成的卡顿和掉帧现象。相比之下,setInterval 的代码执行不受浏览器的渲染周期限制,可能会在某些情况下导致页面响应变慢或出现明显的卡顿。

源码如下
let rafId: number; // 计时器ID
let endTime: number; // 结束时间
let counting: boolean; // 是否在计时
let deactivated: boolean; // 是否在后台
// 获取倒计时时长
const remain = ref(options.time);
// 实时计算格式化返回值
const current = computed(() => parseTime(remain.value));
// 直接利用剩余时间来记录是否结束计时器
// 1 不好拿刷新率间隔
// 2 实时算准确度更好
const getCurrentRemain = () => Math.max(endTime - Date.now(), 0); // 获取当前剩余时间

// 设置剩余时间
// 不停设置更新remain状态值,为零停止
const setRemain = (value: number) => {
  remain.value = value;
  options.onChange?.(current.value);

  if (value === 0) {
    pause();
    options.onFinish?.();
  }
};
// 毫秒级别的计时器
// 利用 raf 递归调用,直至剩余时间为0停止
const microTick = () => {
  rafId = raf(() => {
    // in case of call reset immediately after finish
    // 为什么需要 counting 状态?
    // cancelAnimationFrame 是用于取消由 requestAnimationFrame 方法创建的动画帧请求的函数。它通过传递给 requestAnimationFrame 的唯一标识符(即请求的帧ID)来取消计划的动画帧。然而,cancelAnimationFrame 并不能确保计时器被正确地停止更新的原因是,它只能取消尚未执行的动画帧请求。当你调用 requestAnimationFrame 时,浏览器会在下一次重绘之前执行指定的回调函数。如果在这之前调用了 cancelAnimationFrame,则可以阻止回调函数的执行。
    // 然而,如果回调函数已经开始执行并且处于动画循环中,调用 cancelAnimationFrame 将无法停止该循环。这是因为 cancelAnimationFrame 只会取消尚未执行的动画帧请求,并不能中断正在进行的帧循环。
    if (counting) {
      setRemain(getCurrentRemain());

      if (remain.value > 0) {
        microTick();
      }
    }
  });
};

// 秒级别的计时器
const macroTick = () => {
  rafId = raf(() => {
    // in case of call reset immediately after finish
    if (counting) {
      const remainRemain = getCurrentRemain();

      if (!isSameSecond(remainRemain, remain.value) || remainRemain === 0) {
        setRemain(remainRemain);
      }

      if (remain.value > 0) {
        macroTick();
      }
    }
  });
};

// 开始计时
const tick = () => {
  // should not start counting in server
  // see: https://github.com/vant-ui/vant/issues/7807
  if (!inBrowser) {
    return;
  }

  if (options.millisecond) {
    microTick();
  } else {
    macroTick();
  }
};

// 第一次开始计时
const start = () => {
  if (!counting) {
    endTime = Date.now() + remain.value;
    counting = true;
    tick();
  }
};

const reset = (totalTime: number = options.time) => {
  pause();
  remain.value = totalTime;
};

const pause = () => {
  counting = false;
  cancelRaf(rafId);
};

onBeforeUnmount(pause);

onActivated(() => {
  if (deactivated) {
    counting = true;
    deactivated = false;
    tick();
  }
});

onDeactivated(() => {
  if (counting) {
    pause();
    deactivated = true;
  }
});

return {
  start,
  pause,
  reset,
  current,
};
  1. CountDown组件

Props

参数说明类型默认值
time倒计时时长,单位毫秒number | string0
format时间格式stringHH:mm:ss
auto-start是否自动开始倒计时booleantrue
millisecond是否开启毫秒级渲染booleanfalse
  • 基于useCountDown上面的封装
  • 组件支持响应式 props.time 入参的变化实时响应
  • useExpose 暴露事件给用户调用
  • slots.default 支持默认插槽渲染
export default defineComponent({
  name,

  props: countDownProps,

  emits: ['change', 'finish'],

  setup(props, { emit, slots }) {
    const { start, pause, reset, current } = useCountDown({
      time: +props.time,
      millisecond: props.millisecond,
      onChange: (current) => emit('change', current),
      onFinish: () => emit('finish'),
    });

    const timeText = computed(() => parseFormat(props.format, current.value));

    const resetTime = () => {
      reset(+props.time);

      if (props.autoStart) {
        start();
      }
    };

    watch(() => props.time, resetTime, { immediate: true });

    useExpose({
      start,
      pause,
      reset: resetTime,
    });

    return () => (
      <div role="timer" class={bem()}>
        {slots.default ? slots.default(current.value) : timeText.value}
      </div>
    );
  },
});