博客 / 詳情

返回

【解讀 ahooks 源碼系列】DOM篇(一)

前言

本文是 ahooks 源碼系列的第二篇,下面鏈接是第一篇 DOM 篇的前置講解:

  • 【解讀 ahooks 源碼系列】(開篇)如何獲取和監聽 DOM 元素

後續的文章將會直入主題,每篇文章解讀四至六個 Hooks 源碼實現。

useEventListener

優雅的使用 addEventListener。

  • 官方文檔

用法

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

export default () => {
  const [value, setValue] = useState(0);
  const ref = useRef(null);

  useEventListener(
    'click',
    () => {
      setValue(value + 1);
    },
    { target: ref },
  );

  return (
    <button ref={ref} type="button">
      You click {value} times
    </button>
  );
};

使用場景

通用事件監聽 Hook,簡化寫法(無需在 useEffect 卸載函數中手動移除監聽函數,由內部去移除)

實現思路

  1. 判斷是否支持 addEventListener
  2. 在單獨只有 useEffect 實現事件監聽移除的基礎上,將相關參數都由外部傳入,並添加到依賴項
  3. 處理事件參數的 TS 類型,addEventListener 的第三個參數也需要由外部傳入

核心實現

  • EventTarget.addEventListener():將指定的監聽器註冊到 EventTarget 上,當該對象觸發指定的事件時,指定的回調函數就會被執行

EventTarget 指任何其他支持事件的對象/元素 HTMLElement | Element | Document | Window

符合 EventTarget 接口的都具有下列三個方法

EventTarget.addEventListener()
EventTarget.removeEventListener()
EventTarget.dispatchEvent()
  • TS 函數重載
函數重載指使用相同名稱和不同參數數量或類型創建多個方法,讓我們定義以多種方式調用的函數。在 TS 中為同一個函數提供多個函數類型定義來進行函數重載
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 = {}) {
  const handlerRef = useLatest(handler);

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

      const eventListener = (event: Event) => {
        return handlerRef.current(event);
      };

      // 添加監聽事件
      targetElement.addEventListener(eventName, eventListener, {
        // true 表示事件在捕獲階段執行,false(默認) 表示事件在冒泡階段執行
        capture: options.capture,
         // true 表示事件在觸發一次後移除,默認 false
        once: options.once,
        // true 表示 listener 永遠不會調用 preventDefault()。如果 listener 仍然調用了這個函數,客户端將會忽略它並拋出一個控制枱警告
        passive: options.passive,
      });

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

完整源碼

useClickAway

監聽目標元素外的點擊事件。

  • 官方文檔
type Target = Element | (() => Element) | React.MutableRefObject<Element>;

/**
 * 監聽目標元素外的點擊事件。
 * @param onClickAway 觸發函數
 * @param target DOM 節點或者 Ref,支持數組
 * @param eventName DOM 節點或者 Ref,支持數組,默認事件是 click
 */
useClickAway<T extends Event = Event>(
  onClickAway: (event: T) => void,
  target: Target | Target[],
  eventName?: string | string[]
);

用法

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>
  );
};

使用場景

比如點擊顯示彈窗之後,此時點擊彈窗之外的任意區域時(如彈窗的全局蒙層),該彈窗要自動隱藏。簡而言之,屬於"點擊頁面其他元素,XX組件自動關閉"的功能。

實現思路

  1. 在 document 上綁定全局事件。如默認支持點擊事件,組件卸載的時候移除事件監聽
  2. 觸發事件後,可通過事件代理獲取到觸發事件的對象的引用 e,如果該目標元素 e.target 不在外部傳入的 target 元素(列表)中,則觸發 onClickAway 函數

核心實現

假如只支持點擊事件,只能傳單個元素且只能是 Ref 類型,實現代碼如下:

export default function useClickAway<T extends HTMLElement>(
  onClickAway: (event: MouseEvent) => void,
  refObject: React.RefObject<T>,
) {
  useEffect(() => {
    const handleClick = (e: MouseEvent) => {
      if (
        !refObject.current ||
        refObject.current.contains(e.target as HTMLElement)
      ) {
        return
      }
      onClickAway(e)
    }

    document.addEventListener('click', handleClick)

    return () => {
      document.removeEventListener('click', handleClick)
    }
  }, [refObject, onClickAway])
}

ahooks 則繼續拓展,思路如下:

  1. 同時支持傳入 DOM 節點、Ref:需要區分是DOM節點、函數、還是Ref,獲取的時候要兼顧所有情況
  2. 可傳入多個目標元素(支持數組):通過循環綁定事件,用數組some方法判斷任一元素包含則觸發
  3. 可指定監聽事件(支持數組):eventName 由外部傳入,不傳默認為 click 事件

來看看源碼整體實現:

第1、2點的實現

// documentOrShadow 這部分忽略不深究,一般開發場景就是 document
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));
};

第3點 handler 函數的實現:

const handler = (event: any) => {
    const targets = Array.isArray(target) ? target : [target];
    if (
      // 判斷點擊的目標元素是否在外部傳入的元素(列表)中,是則 return 不執行回調
      targets.some((item) => {
        const targetElement = getTargetElement(item); // 這裏處理了傳入的target是函數、DOM節點、Ref 類型的情況
        return !targetElement || targetElement.contains(event.target);
      })
    ) {
      return;
    }
    // 觸發事件
    onClickAwayRef.current(event);
};
  1. 這裏注意觸發事件的代碼是:onClickAwayRef.current(event);,實際是為了保證能拿到最新的函數,可以避免閉包問題
const onClickAwayRef = useLatest(onClickAway);

// 等同於
const onClickAwayRef = useRef(onClickAway);
onClickAwayRef.current = onClickAway;
  1. getTargetElement 方法獲取目標元素實現如下:

    if (isFunction(target)) {
     targetElement = target();
    } else if ('current' in target) {
     targetElement = target.current;
    } else {
     targetElement = target;
    }
  • 完整源碼

注意React17+版本的坑

Reactv17前,React 將事件委託到 document 上,在Reactv17及之後版本,則委託到根節點,具體見該文:

  • ahooks 的 useClickAway 在 React 17 中不工作了!

解決方案是給 useClickAway 的事件類型設置為 mousedown 和 touchstart

在寫這篇文章的時候,還沒更新:
具體可見 useClickAway判斷不對

其它寫法實現參考

總體來説 ahooks 的實現功能更齊全考慮的場景更多,但業務開發如果是自己寫 Hooks 實現的話,推薦下面的寫法,足以覆蓋日常開發場景:

  • react-use 的 useClickAway
  • useHooks 的 useOnClickOutside

useDocumentVisibility

監聽頁面是否可見。

  • 官方文檔

用法

import React, { useEffect } from 'react';
import { useDocumentVisibility } from 'ahooks';

export default () => {
  const documentVisibility = useDocumentVisibility();

  useEffect(() => {
    console.log(`Current document visibility state: ${documentVisibility}`);
  }, [documentVisibility]);

  return <div>Current document visibility state: {documentVisibility}</div>;
};

使用場景

當頁面在背景中或窗口最小化時禁止或開啓某些活動,如離開頁面停止播放音視頻、暫停輪詢接口請求

實現思路

  1. 定義並暴露給外部document.visibilityState狀態值,通過該字段判斷頁面是否可見
  2. 監聽 visibilitychange 事件(使用 document 註冊),觸發回調時更新狀態值

Document.visibilityState 與 visibilitychange 事件

Document.visibilityState(只讀屬性)

返回 document 的可見性,即當前可見元素的上下文環境。由此可以知道當前文檔 (即為頁面) 是在背後,或是不可見的隱藏的標籤頁,或者 (正在) 預渲染,共有三個可能的值。

  • visible: 此時頁面內容至少是部分可見。即此頁面在前景標籤頁中,並且窗口沒有最小化。
  • hidden: 此時頁面對用户不可見。即文檔處於背景標籤頁或者窗口處於最小化狀態,或者操作系統正處於 '鎖屏狀態' .
  • prerender: 頁面此時正在渲染中,因此是不可見的。文檔只能從此狀態開始,永遠不能從其他值變為此狀態。(prerender 狀態只在支持"預渲染"的瀏覽器上才會出現)。

visibilitychange

當其選項卡的內容變得可見或被隱藏時,會在文檔上觸發 visibilitychange (能見度更改) 事件。

警告: 出於兼容性原因,請確保使用 document.addEventListener 而不是 window.addEventListener 來註冊回調。Safari <14.0 僅支持前者。

推薦閲讀:Page Visibility API 教程

核心實現

type VisibilityState = 'hidden' | 'visible' | 'prerender' | undefined;

const getVisibility = () => {
  if (!isBrowser) {
    return 'visible';
  }
  // 返回document的可見性,即當前可見元素的上下文環境
  return document.visibilityState;
};

function useDocumentVisibility(): VisibilityState {
  const [documentVisibility, setDocumentVisibility] = useState(() => getVisibility());

  // 監聽事件
  useEventListener(
    'visibilitychange',
    () => {
      setDocumentVisibility(getVisibility());
    },
    {
      target: () => document,
    },
  );

  return documentVisibility;
}

export default useDocumentVisibility;
  • 完整源碼

useDrop

處理元素拖拽的 Hook。

  • 官方文檔

用法

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


const DragItem = ({ data }) => {
  const dragRef = useRef(null);


  const [dragging, setDragging] = useState(false);


  useDrag(data, dragRef, {
    onDragStart: () => {
      setDragging(true);
    },
    onDragEnd: () => {
      setDragging(false);
    },
  });


  return (
    <div
      ref={dragRef}
      style={{
        border: '1px solid #e8e8e8',
        padding: 16,
        width: 80,
        textAlign: 'center',
        marginRight: 16,
      }}
    >
      {dragging ? 'dragging' : `box-${data}`}
    </div>
  );
};


export default () => {
  const [isHovering, setIsHovering] = useState(false);


  const dropRef = useRef(null);


  useDrop(dropRef, {
    onText: (text, e) => {
      console.log(e);
      alert(`'text: ${text}' dropped`);
    },
    onFiles: (files, e) => {
      console.log(e, files);
      alert(`${files.length} file dropped`);
    },
    onUri: (uri, e) => {
      console.log(e);
      alert(`uri: ${uri} dropped`);
    },
    onDom: (content: string, e) => {
      alert(`custom: ${content} dropped`);
    },
    onDragEnter: () => setIsHovering(true),
    onDragLeave: () => setIsHovering(false),
  });


  return (
    <div>
      <div ref={dropRef} style={{ border: '1px dashed #e8e8e8', padding: 16, textAlign: 'center' }}>
        {isHovering ? 'release here' : 'drop here'}
      </div>


      <div style={{ display: 'flex', marginTop: 8 }}>
        {['1', '2', '3', '4', '5'].map((e, i) => (
          <DragItem key={e} data={e} />
        ))}
      </div>
    </div>
  );
};

使用場景

  • useDrop 可以單獨使用來接收文件、文字和網址的拖拽。
  • 向節點內觸發粘貼動作也會被視為拖拽

涉及的拖拽 API

拖拽相關事件:

  • dragenter:事件在可拖動的元素或者被選擇的文本進入一個有效的放置目標時觸發。
  • dragleave:在拖動的元素或選中的文本離開一個有效的放置目標時被觸發。
  • dragover:在可拖動的元素或者被選擇的文本被拖進一個有效的放置目標時(每幾百毫秒)觸發。
  • drop:當一個元素或是選中的文字被拖拽釋放到一個有效的釋放目標位置時,drop 事件被拋出。
  • paste:當用户在瀏覽器用户界面發起“粘貼”操作時,會觸發 paste 事件。

實現思路

  1. 監聽以上 5 個事件
  2. 另外在 drop 和 paste 事件中獲取到 DataTransfer 數據,並根據數據類型進行特定的處理,將處理好的數據通過回調(onText/onFiles/onUri/onDom)給外部直接獲取使用。
export interface Options {
  // 根據 drop 事件數據類型自定義回調函數
  onFiles?: (files: File[], event?: React.DragEvent) => void;
  onUri?: (url: string, event?: React.DragEvent) => void;
  onDom?: (content: any, event?: React.DragEvent) => void;
  onText?: (text: string, event?: React.ClipboardEvent) => void;
  // 原生事件
  onDragEnter?: (event?: React.DragEvent) => void;
  onDragOver?: (event?: React.DragEvent) => void;
  onDragLeave?: (event?: React.DragEvent) => void;
  onDrop?: (event?: React.DragEvent) => void;
  onPaste?: (event?: React.ClipboardEvent) => void;
}

const useDrop = (target: BasicTarget, options: Options = {}) => {}

核心實現

主函數實現比較簡單,需要注意的時候在特定事件需要阻止默認事件event.preventDefault();和阻止事件冒泡event.stopPropagation();,讓拖拽能正常的工作

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;
      }

      // 處理 DataTransfer 不同數據類型數據
      const onData = (dataTransfer: DataTransfer, event: React.DragEvent | React.ClipboardEvent) => {};

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

        dragEnterTarget.current = event.target;
        optionsRef.current.onDragEnter?.(event);
      };

      const onDragOver = (event: React.DragEvent) => {
        event.preventDefault(); // 調用 event.preventDefault() 使得該元素能夠接收 drop 事件
        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) => {
        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,
  );
};

在 drop 和 paste 事件中,獲取到 DataTransfer 數據並傳給 onData 方法,根據數據類型進行特定的處理

  • DataTransfer:DataTransfer 對象用於保存拖動並放下(drag and drop)過程中的數據。它可以保存一項或多項數據,這些數據項可以是一種或者多種數據類型。關於拖放的更多信息,請參見 Drag and Drop
  • DataTransfer.getData()接受指定類型的拖放(以 DOMString 的形式)數據。如果拖放行為沒有操作任何數據,會返回一個空字符串。數據類型有:text/plain,text/uri-list
  • DataTransferItem:拖拽項。
const onData = (
  dataTransfer: DataTransfer,
  event: React.DragEvent | React.ClipboardEvent,
) => {
  const uri = dataTransfer.getData('text/uri-list'); // URL格式列表(鏈接)
  const dom = dataTransfer.getData('custom'); // 自定義數據,需要與 useDrag 搭配使用

  // 根據數據類型進行特定的處理
  // 拖拽/粘貼自定義 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;
  }

  // 拖拽/粘貼文件的回調
  // dataTransfer.files:拖動操作中的文件列表,操作中每個文件的一個列表項。如果拖動操作沒有文件,此列表為空
  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:拖動操作中 數據傳輸項的列表。該列表包含了操作中每一項目的對應項,如果操作沒有項目,則列表為空
    // getAsString:使用拖拽項的字符串作為參數執行指定回調函數
    dataTransfer.items[0].getAsString((text) => {
      optionsRef.current.onText!(text, event as React.ClipboardEvent);
    });
  }
};

完整源碼

useDrag

處理元素拖拽的 Hook。

  • 官方文檔

使用場景

useDrag 允許一個 DOM 節點被拖拽,需要配合 useDrop 使用。

涉及的拖拽事件

  • dragstart: 在用户開始拖動元素或被選擇的文本時調用。
  • dragend: 在拖放操作結束時觸發(通過釋放鼠標按鈕或單擊 escape 鍵)。

實現思路

  1. 內部監聽 dragstart 和 dragend 方法觸發回調給外部使用
  2. dragstart 事件觸發時支持設置自定義數據到 dataTransfer 中

核心實現

export interface Options {
  // 在用户開始拖動元素或被選擇的文本時調用
  onDragStart?: (event: React.DragEvent) => void;
  // 在拖放操作結束時觸發(通過釋放鼠標按鈕或單擊 escape 鍵)
  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);
        // 設置自定義數據到 dataTransfer 中,搭配 useDrop 的 onDom 回調可獲取當前設置的內容
        event.dataTransfer.setData('custom', JSON.stringify(dataRef.current));
      };

      const onDragEnd = (event: React.DragEvent) => {
        optionsRef.current.onDragEnd?.(event);
      };

      targetElement.setAttribute('draggable', 'true');

      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,
  );
};

完整源碼

user avatar tigerandflower 頭像 dujing_5b7edb9db0b1c 頭像 chongdianqishi 頭像 sunhengzhe 頭像 zhangxishuo 頭像 huanjinliu 頭像 codeoop 頭像 fyuanlove 頭像 amsterdam_5caf807441f49 頭像 liujunqi 頭像 joyerli 頭像 mianduijifengba_59b206479620f 頭像
16 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.