ahooks源码系列(六):DOM相关(一)

245 阅读9分钟

useEventListener

useEventListener 是让优雅的使用 addEventListener 的 hook

MDN 对于 addEventListener 的解释:

EventTarget.addEventListener() 方法将指定的监听器注册到 EventTarget 上,当该对象触发指定的事件时,指定的回调函数就会被执行。

这里的 EventTarget 可以是一个文档上的元素 ElementDocumentWindow 或者任何其他支持事件的对象

useEventListener 函数通过类型重载,对 Element、Document、Window 等元素以及其事件名称和回调参数都做了定义。

function useEventListener<K extends keyof HTMLElementEventMap>(
  eventName: K,
  handler: (ev: HTMLElementEventMap[K]) => void,
  options?: Options<HTMLElement>,
): void;
function useEventListener<K extends keyof ElementEventMap>(
  eventName: K,
  handler: (ev: ElementEventMap[K]) => void,
  options?: Options<Element>,
): void;
function useEventListener<K extends keyof DocumentEventMap>(
  eventName: K,
  handler: (ev: DocumentEventMap[K]) => void,
  options?: Options<Document>,
): void;
function useEventListener<K extends keyof WindowEventMap>(
  eventName: K,
  handler: (ev: WindowEventMap[K]) => void,
  options?: Options<Window>,
): void;
function useEventListener(
  eventName: string,
  handler: noop,
  options: Options,
): void;

然后看具体实现:

function useEventListener(eventName: string, handler: noop, options: Options = {}) {
  const handlerRef = useLatest(handler);

  useEffectWithTarget(
    () => {
      // 获取 target
      const targetElement = getTargetElement(options.target, window);
      if (!targetElement?.addEventListener) {
        return;
      }
      
      // 事件回调
      const eventListener = (event: Event) => {
        return handlerRef.current(event);
      };

      // 监听事件
      targetElement.addEventListener(eventName, eventListener, {
        capture: options.capture,
        once: options.once,
        passive: options.passive,
      });

      return () => {
        // 移除事件
        targetElement.removeEventListener(eventName, eventListener, {
          capture: options.capture,
        });
      };
    },
    [eventName, options.capture, options.once, options.passive],
    options.target,
  );
}

export default useEventListener;

思路也清晰明了

  • 首先根据传入的 options.target 获取需要监听的对象,然后判断该对象是否支持事件监听
  • 然后通过 addEventListener 监听事件,然后可以传入些配置项:
    • capture:listener 在事件捕获阶段传播到该 EventTarget 时触发
    • once:listener 在添加之后最多只调用一次。如果是 true,listener 会在其被调用之后自动移除。
    • passive:设置为 true 时,表示 listener 永远不会调用 preventDefault() ,也就是阻止默认行为。如果 listener 仍然调用了这个函数,客户端将会忽略它并抛出一个控制台警告。

useClickAway

useClickAway 用监听目标元素的点击事件。

根据官方文档的例子来看的话:

import React, { useState, useRef } from 'react';
import { useClickAway } from 'ahooks';


export default () => {
  const [counter, setCounter] = useState(0);
  const ref = useRef<HTMLButtonElement>(null);
  useClickAway(() => {
    setCounter((s) => s + 1);
  }, ref);


  return (
    <div>
      <button ref={ref} type="button">
        box
      </button>
      <p>counter: {counter}</p>
    </div>
  );
};

目标元素是 button,当点击 button 时,不会触发 setCounter,当点击 button 以外的地方时,会触发 setCounter

这个的应用场景应该是模态框,点击外部阴影部分,自动关闭的场景。

来看看源码:

export default function useClickAway<T extends Event = Event>(
  onClickAway: (event: T) => void,  // 回调函数
  target: BasicTarget | BasicTarget[], // 目标对象,支持多个目标对象,数组方式传入
  eventName: DocumentEventKey | DocumentEventKey[] = 'click', // 支持监听多个事件,以数组方式传入
) {
  const onClickAwayRef = useLatest(onClickAway);

  useEffectWithTarget(
    () => {
      const handler = (event: any) => {
        // 判断是不是监听多个对象
        const targets = Array.isArray(target) ? target : [target];
        if (
          // 如果点击的是 targets 数组中的任何一个目标对象,就直接 return 
          targets.some((item) => {
            const targetElement = getTargetElement(item);
            return !targetElement || targetElement.contains(event.target);
          })
        ) {
          return;
        }
        // 如果不是,则执行回调函数
        onClickAwayRef.current(event);
      };

      const documentOrShadow = getDocumentOrShadow(target);

      // 判断是否是监听多个事件
      const eventNames = Array.isArray(eventName) ? eventName : [eventName];
      // 监听每一个事件, 通过事件代理的方式知道目标节点
      eventNames.forEach((event) => documentOrShadow.addEventListener(event, handler));
       // 组件卸载的时候清除事件监听。
      return () => {
        eventNames.forEach((event) => documentOrShadow.removeEventListener(event, handler));
      };
    },
    Array.isArray(eventName) ? eventName : [eventName],
    target,
  );
}

思路:

  • 判断是否是监听多个事件,处理成数组,然后遍历事件列表,对每一个事件进行监听(通过事件代理的方式知道目标节点)
  • 然后在 handler 函数中,通过 event.target 获取到触发事件的对象 (某个 DOM 元素) 的引用,判断假如不在传入的 target 列表中,则触发定义好的 onClickAway 函数。
  • 最后组件卸载时,清除事件监听

其实 useClickAway 就是使用了事件代理的方式,通过 document 监听事件,判断触发事件的 DOM 元素是否在 target 列表中,从而决定是否要触发定义好的函数

useDrop && useDrag

处理元素拖拽的 Hook。

useDrop 可以单独使用来接收文件、文字和网址的拖拽。

useDrag 允许一个 DOM 节点被拖拽,需要配合 useDrop 使用。

向节点内触发粘贴动作也会被视为拖拽

先来看 useDrag 的源码吧:

useDrag


export interface Options {
  onDragStart?: (event: React.DragEvent) => void;
  onDragEnd?: (event: React.DragEvent) => void;
}

const useDrag = <T>(data: T, target: BasicTarget, options: Options = {}) => {
  const optionsRef = useLatest(options);
  const dataRef = useLatest(data);
  useEffectWithTarget(
    () => {
      // 获取需要拓展的目标元素
      const targetElement = getTargetElement(target);
      if (!targetElement?.addEventListener) {
        return;
      }

      // 开始拖拽回调
      const onDragStart = (event: React.DragEvent) => {
        optionsRef.current.onDragStart?.(event);
        event.dataTransfer.setData('custom', JSON.stringify(dataRef.current));
      };

      // 结束拖拽回调
      const onDragEnd = (event: React.DragEvent) => {
        optionsRef.current.onDragEnd?.(event);
      };

      // 需要设置 draggable = true 才能拖拽
      targetElement.setAttribute('draggable', 'true');
      // 监听 dragstart、dragend 事件
      targetElement.addEventListener('dragstart', onDragStart as any);
      targetElement.addEventListener('dragend', onDragEnd as any);

      // 组件卸载时移除事件监听
      return () => {
        targetElement.removeEventListener('dragstart', onDragStart as any);
        targetElement.removeEventListener('dragend', onDragEnd as any);
      };
    },
    [],
    target,
  );
};

export default useDrag;

思路:

  • 获取需要拖拽的目标元素对象
  • 监听 dragstartdragend 事件
  • 回调调用 optionsRef.current.onDragStartoptionsRef.current.onDragEnd

useDrop

useDrop 就是去监听 dragenterdragoverdragleavedroppaste 事件,进行特定的处理。其中在 drop 和 paste 事件中,获取到 DataTransfer 数据,并根据数据类型进行特定的处理。

主函数内容如下

// useDrop 可以单独使用来接收文件、文字和网址的拖拽。
const useDrop = (target: BasicTarget, options: Options = {}) => {
  const optionsRef = useLatest(options);
  // https://stackoverflow.com/a/26459269
  const dragEnterTarget = useRef<any>();

  useEffectWithTarget(
    () => {
      const targetElement = getTargetElement(target);
      if (!targetElement?.addEventListener) {
        return;
      }

      const onData = (
        dataTransfer: DataTransfer,
        event: React.DragEvent | React.ClipboardEvent,
      ) => {};

      const onDragEnter = (event: React.DragEvent) => {};

      const onDragOver = (event: React.DragEvent) => {};

      const onDragLeave = (event: React.DragEvent) => {};

      const onDrop = (event: React.DragEvent) => {};

      const onPaste = (event: React.ClipboardEvent) => {
        // DataTransfer 对象用于保存拖动并放下(drag and drop)过程中的数据。它可以保存一项或多项数据,这些数据项可以是一种或者多种数据类型。关于拖放的更多信息,请参见 Drag and Drop.
        onData(event.clipboardData, event);
        // 粘贴内容的回调
        optionsRef.current.onPaste?.(event);
      };
      targetElement.addEventListener('dragenter', onDragEnter as any);
      targetElement.addEventListener('dragover', onDragOver as any);
      targetElement.addEventListener('dragleave', onDragLeave as any);
      targetElement.addEventListener('drop', onDrop as any);
      targetElement.addEventListener('paste', onPaste as any);

      return () => {
        targetElement.removeEventListener('dragenter', onDragEnter as any);
        targetElement.removeEventListener('dragover', onDragOver as any);
        targetElement.removeEventListener('dragleave', onDragLeave as any);
        targetElement.removeEventListener('drop', onDrop as any);
        targetElement.removeEventListener('paste', onPaste as any);
      };
    },
    [],
    target,
  );
};

然后监听的 5 个事件的处理分别如下:

const onDragEnter = (event: React.DragEvent) => {
  // 阻止默认事件
  event.preventDefault();
  // 阻止事件冒泡
  event.stopPropagation();
  dragEnterTarget.current = event.target;
  // 拖拽进入
  optionsRef.current.onDragEnter?.(event);
};

const onDragOver = (event: React.DragEvent) => {
  event.preventDefault();
  // 拖拽中
  optionsRef.current.onDragOver?.(event);
};

const onDragLeave = (event: React.DragEvent) => {
  if (event.target === dragEnterTarget.current) {
    // 拖拽出去
    optionsRef.current.onDragLeave?.(event);
  }
};

const onDrop = (event: React.DragEvent) => {
  event.preventDefault();
  onData(event.dataTransfer, event);
  // 拖拽任意内容的回调
  optionsRef.current.onDrop?.(event);
};

const onPaste = (event: React.ClipboardEvent) => {
  // DataTransfer 对象用于保存拖动并放下(drag and drop)过程中的数据。它可以保存一项或多项数据,这些数据项可以是一种或者多种数据类型。关于拖放的更多信息,请参见 Drag and Drop.
  onData(event.clipboardData, event);
  // 粘贴内容的回调
  optionsRef.current.onPaste?.(event);
};

其中在 drop 和 paste 事件中,获取到 DataTransfer 数据,调用 onData 方法,并根据数据类型进行特定的处理。

const onData = (
  dataTransfer: DataTransfer,
  event: React.DragEvent | React.ClipboardEvent,
) => {
  const uri = dataTransfer.getData('text/uri-list');
  const dom = dataTransfer.getData('custom');

  // 拖拽/粘贴自定义 DOM 节点的回调
  if (dom && optionsRef.current.onDom) {
    let data = dom;
    try {
      data = JSON.parse(dom);
    } catch (e) {
      data = dom;
    }
    optionsRef.current.onDom(data, event as React.DragEvent);
    return;
  }

  // 拖拽/粘贴链接的回调
  if (uri && optionsRef.current.onUri) {
    optionsRef.current.onUri(uri, event as React.DragEvent);
    return;
  }

  // 拖拽/粘贴文件的回调
  if (
    dataTransfer.files &&
    dataTransfer.files.length &&
    optionsRef.current.onFiles
  ) {
    optionsRef.current.onFiles(
      Array.from(dataTransfer.files),
      event as React.DragEvent,
    );
    return;
  }

  // 拖拽/粘贴文字的回调
  if (
    dataTransfer.items &&
    dataTransfer.items.length &&
    optionsRef.current.onText
  ) {
    dataTransfer.items[0].getAsString(text => {
      optionsRef.current.onText!(text, event as React.ClipboardEvent);
    });
  }
};

useEventTarget

常见表单控件(通过 e.target.value 获取表单值) 的 onChange 跟 value 逻辑封装,支持自定义值转换和重置功能。

可以理解成把表单控件输入的逻辑多了层封装

interface EventTarget<U> {
  target: {
    value: U;
  };
}

export interface Options<T, U> {
  initialValue?: T; //初始值
  transformer?: (value: U) => T;  //转换函数
}

function useEventTarget<T, U = T>(options?: Options<T, U>) {
  // 记录初始值、转换函数
  const { initialValue, transformer } = options || {};
  const [value, setValue] = useState(initialValue);
  const transformerRef = useLatest(transformer);

  // 利用闭包,重置为初始值
  const reset = useCallback(() => setValue(initialValue), []);

  const onChange = useCallback((e: EventTarget<U>) => {
    // 拿到表单控件的值
    const _value = e.target.value;
    // 如果有转换函数,调用转换函数处理 _value
    if (isFunction(transformerRef.current)) {
      return setValue(transformerRef.current(_value));
    }
    // no transformer => U and T should be the same
    return setValue(_value as unknown as T);
  }, []);

  return [
    value,
    {
      onChange,
      reset,
    },
  ] as const;
}

export default useEventTarget;

思路:

  • 记录初始值、转换函数
  • 定义 reset 函数,利用闭包,重置 value 为 初始值
  • 定义 onChange 函数,内部通过 transformer 转换函数处理表单控件的值
  • 然后返回 [value, { onChange, reset }]

很简单的

useExternal

useExternal 动态注入 JS 或 CSS 资源,useExternal 可以保证资源全局唯一。

其实现原理创建 link 标签加载 CSS 资源或者 script 标签加载 JS 资源。通过 document.createElement 返回 Element 对象,监听该对象获取加载状态。

源码分为三部分:

// 加载 JS
const loadScript = () => {}
// 加载 CSS
const loadCss = () => {}
// 主函数
const useExternal = () => {}

其中,主函数中判断加载 CSS 还是 JS 资源

// 省略部分代码


const pathname = path.replace(/[|#].*$/, '');
// 判断是 CSS 类型
if (
  options?.type === 'css' ||
  (!options?.type && /(^css!|\.css$)/.test(pathname))
) {
  const result = loadCss(path, options?.css);
  ref.current = result.ref;
  setStatus(result.status);
  // 判断是是 JavaScript 类型
} else if (
  options?.type === 'js' ||
  (!options?.type && /(^js!|\.js$)/.test(pathname))
) {
  const result = loadScript(path, options?.js);
  ref.current = result.ref;
  setStatus(result.status);
} else {
  // do nothing
  console.error(
    "Cannot infer the type of external resource, and please provide a type ('js' | 'css'). " +
      'Refer to the https://ahooks.js.org/hooks/dom/use-external/#options',
  );
}

然后来看看 loadCss 方法,加载 CSS

const loadCss = (path: string, props = {}): loadResult => {
  // 创建 link 标签
  const css = document.querySelector(`link[href="${path}"]`);
  if (!css) {
    const newCss = document.createElement('link');
    // 设置 ref、href 属性
    newCss.rel = 'stylesheet';
    newCss.href = path;
    // 设置相应的属性
    Object.keys(props).forEach((key) => {
      newCss[key] = props[key];
    });
    // IE9+
    const isLegacyIECss = 'hideFocus' in newCss;
    // use preload in IE Edge (to detect load errors)
    // preload 预加载
    if (isLegacyIECss && newCss.relList) {
      newCss.rel = 'preload';
      newCss.as = 'style';
    }
    // 正在加载中
    newCss.setAttribute('data-status', 'loading');
    // 插入 head 标签里面
    document.head.appendChild(newCss);

    return {
      ref: newCss,
      status: 'loading',
    };
  }

  // 有则直接返回,并取 data-status 中的值
  return {
    ref: css,
    status: (css.getAttribute('data-status') as Status) || 'ready',
  };
};

然后是 loadScript,加载 JS:

// 加载 Script
const loadScript = (path: string, props = {}): loadResult => {
  // 看是否已经加载
  const script = document.querySelector(`script[src="${path}"]`);

  if (!script) {
    // 创建标签
    const newScript = document.createElement('script');
    // 设置 src
    newScript.src = path;
    // 设置属性值
    Object.keys(props).forEach(key => {
      newScript[key] = props[key];
    });

    // 设置 loading 状态
    newScript.setAttribute('data-status', 'loading');
    // 加到 document.body 中
    document.body.appendChild(newScript);

    return {
      ref: newScript,
      status: 'loading',
    };
  }

  return {
    ref: script,
    // 状态
    status: (script.getAttribute('data-status') as Status) || 'ready',
  };
};

最后,主函数中,监听 Element 的 loaderror 事件,判断其加载状态:

const handler = (event: Event) => {
  // 判断和设置加载状态
  const targetStatus = event.type === 'load' ? 'ready' : 'error';
  ref.current?.setAttribute('data-status', targetStatus);
  setStatus(targetStatus);
};

// 监听文件下载的情况
ref.current.addEventListener('load', handler);
ref.current.addEventListener('error', handler);

useTitle

useTitle 用于设置页面标题,这个页面标题指的是浏览器 Tab 中展示的。通过 document.title 设置。

function useTitle(title: string, options: Options = DEFAULT_OPTIONS) {
  // 存储初始值的 title
  const titleRef = useRef(isBrowser ? document.title : '');
  useEffect(() => {
    document.title = title;
  }, [title]);

  useUnmount(() => {
    // 组件卸载后,恢复上一次的 title
    if (options.restoreOnUnmount) {
      document.title = titleRef.current;
    }
  });
}

useFavicon

useFavicon 设置页面的 favicon。也就是这个

image.png

原理是通过 link 标签设置 favicon。

const ImgTypeMap = {
  SVG: 'image/svg+xml',
  ICO: 'image/x-icon',
  GIF: 'image/gif',
  PNG: 'image/png',
};

type ImgTypes = keyof typeof ImgTypeMap;

const useFavicon = (href: string) => {
  useEffect(() => {
    if (!href) return;

    const cutUrl = href.split('.');
    const imgSuffix = cutUrl[cutUrl.length - 1].toLocaleUpperCase() as ImgTypes;

    const link: HTMLLinkElement =
      document.querySelector("link[rel*='icon']") ||
      document.createElement('link');
    // 链接的内容的类型
    link.type = ImgTypeMap[imgSuffix];
    // 设置 url
    link.href = href;
    // 此属性命名链接文档与当前文档的关系,没见过这玩意儿,百度这样说的
    link.rel = 'shortcut icon';

    // 然后传入 head 标签里面
    document.getElementsByTagName('head')[0].appendChild(link);
  }, [href]);
};

useFullscreen

useFullscreen 管理 DOM 全屏的 hook

该 hook 主要是依赖 screenfull 这个 npm 包进行实现的。

主函数主要定义了下面三个方法:

const useFullscreen = () => {
  // 断是否是全屏,从而触发进入全屏的函数或者退出全屏的函数
  const onScreenfullChange = () = {}
  // 进入全屏
  const enterFullscreen = () => {}
  // 退出全屏
  const exitFullscreen = () => {}
  // 切换全屏展示
  const toggleFullscreen = () => {}
  
  // 组件写宅时,移除 onScreenfullChange 事件
  useUnmount(() => {
    if (screenfull.isEnabled && !pageFullscreen) {
      screenfull.off('change', onScreenfullChange);
    }
  });
}

其中 onScreenfullChange 方法:

const onScreenfullChange = useMemoizedFn(() => {
    if (screenfull.isEnabled) {
      const el = getTargetElement(target);

      if (!screenfull.element) {
        invokeCallback(false);
        setState(false);
        screenfull.off('change', onScreenfullChange);
      } else {
        const isFullscreen = screenfull.element === el;

        invokeCallback(isFullscreen);
        setState(isFullscreen);
      }
    }
  });

enterFullscreen 方法:手动进入全屏函数,支持传入 ref 设置需要全屏的元素。并通过 screenfull.request 进行设置,并监听 change 事件。

// 进入全屏
const enterFullscreen = () => {
  const el = getTargetElement(target);
  if (!el) {
    return;
  }

  if (screenfull.isEnabled) {
    try {
      screenfull.request(el);
      screenfull.on('change', onChange);
    } catch (error) {
      console.error(error);
    }
  }
};

exitFullscreen 方法:

// 退出全屏
const exitFullscreen = () => {
  if (!state) {
    return;
  }
  if (screenfull.isEnabled) {
    screenfull.exit();
  }
};

toggleFullscreen 方法:根据当前状态,调用上面两个方法,达到切换全屏状态的效果。

// 切换模式
const toggleFullscreen = () => {
  if (state) {
    exitFullscreen();
  } else {
    enterFullscreen();
  }
};

结尾

不想写了 还有 9 个 DOM 相关的 hook 下次再写