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

JackySummer發表於2023-03-10

前言

本文是 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及之後版本,則委託到根節點,具體見該文:

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

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

其它寫法實現參考

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

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

完整原始碼

相關文章