乐趣区

关于前端:解读-ahooks-源码系列DOM篇二

前言

本文是 ahooks 源码系列的第三篇,往期文章:

  • 【解读 ahooks 源码系列】(开篇)如何获取和监听 DOM 元素
  • 【解读 ahooks 源码系列】DOM 篇(一)

本文次要解读 useEventTargetuseExternaluseTitleuseFaviconuseFullscreenuseHover 源码实现

useEventTarget

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

官网文档

export interface Options<T, U> {
  initialValue?: T; // 初始值
  transformer?: (value: U) => T; // 自定义回调值的转化
}

根本用法

import React from 'react';
import {useEventTarget} from 'ahooks';

export default () => {const [value, { reset, onChange}] = useEventTarget({initialValue: 'this is initial value'});

  return (
    <div>
      <input value={value} onChange={onChange} style={{width: 200, marginRight: 20}} />
      <button type="button" onClick={reset}>
        reset
      </button>
    </div>
  );
};

应用场景

实用于较为简单的表单受控控件(如 input 输入框)治理

实现思路

  1. 监听表单的 onChange 事件,拿到值后更新 value 值
  2. 反对自定义回调值的转化,对外裸露 value 值、onChange 和 reset 办法

外围实现

这个实现比较简单,这里结尾代码有个as const,它示意强制 TypeScript 将变量或表达式的类型视为不可变的

具体能够看下这篇文章:杀手级的 TypeScript 性能:const 断言

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;
    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; // 将数组变为只读元组,能够确保其内容不会在其申明和函数调用之间发生变化
}

残缺源码

useExternal

动静注入 JS 或 CSS 资源,useExternal 能够保障资源全局惟一。

官网文档

根本用法

import React from 'react';
import {useExternal} from 'ahooks';

export default () => {
  const status = useExternal('/useExternal/test-external-script.js', {
    js: {async: true,},
  });

  return (
    <>
      <p>
        Status: <b>{status}</b>
      </p>
      <p>
        Response: <i>{status === 'ready' ? window.TEST_SCRIPT?.start() : '-'}</i>
      </p>
    </>
  );
};

实现思路

原理:通过 script 标签加载 JS 资源 / 创立 link 标签加载 CSS 资源,再通过创立标签返回的 Element 元素监听 load 和 error 事件 获取加载状态

  1. 正则判断传入的门路 path 是 JS 还是 CSS
  2. 加载 CSS/JS:创立 link/script 标签传入 path,反对传入 link/script 标签反对的属性,增加到 head/body 中,并返回 Element 元素与加载状态;这里需判断标签门路匹配是否存在,存在则返回上一次后果,以保障资源全局惟一
  3. 利用创立标签返回的 Element 元素监听 load 和 error 事件,并在回调中扭转加载状态

外围实现

主体实现构造:

export interface Options {
  type?: 'js' | 'css';
  js?: Partial<HTMLScriptElement>;
  css?: Partial<HTMLStyleElement>;
}

const useExternal = (path?: string, options?: Options) => {const [status, setStatus] = useState<Status>(path ? 'loading' : 'unset');

  const ref = useRef<Element>();

  useEffect(() => {if (!path) {setStatus('unset');
      return;
    }
    const pathname = path.replace(/[|#].*$/, '');
    if (options?.type === 'css' || (!options?.type && /(^css!|\.css$)/.test(pathname))) {const result = loadCss(path, options?.css);
    } else if (options?.type === 'js' || (!options?.type && /(^js!|\.js$)/.test(pathname))) {const result = loadScript(path, options?.js);
    } else { }

    if (!ref.current) {return;}

    const handler = (event: Event) => {};

    ref.current.addEventListener('load', handler);
    ref.current.addEventListener('error', handler);
    return () => {// 移除监听 & 革除操作};
  }, [path]);

  return status;
};

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

const pathname = path.replace(/[|#].*$/, '');
if (options?.type === 'css' || (!options?.type && /(^css!|\.css$)/.test(pathname))) {const result = loadCss(path, options?.css); // 加载 css 资源并返回后果
  ref.current = result.ref; // 返回创立 link 标签返回的 Element 元素,用于后续绑定监听 load 和 error 事件
  setStatus(result.status); // 设置加载状态
} 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 办法:

往 HTML 标签上增加任意以 “data-” 为前缀来设置咱们须要的自定义属性,能够进行一些数据的寄存

const loadCss = (path: string, props = {}): loadResult => {const css = document.querySelector(`link[href="${path}"]`);
  // 不存在则创立
  if (!css) {const newCss = document.createElement('link');

    newCss.rel = 'stylesheet';
    newCss.href = path;
    // 设置 link 标签反对的属性
    Object.keys(props).forEach((key) => {newCss[key] = props[key];
    });
    // IE9+
    const isLegacyIECss = 'hideFocus' in newCss;
    // use preload in IE Edge (to detect load errors)
    if (isLegacyIECss && newCss.relList) {
      newCss.rel = 'preload';
      newCss.as = 'style';
    }
    // 设置自定义属性 [data-status] 为 loading 状态
    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 办法的实现也相似:

const loadScript = (path: string, props = {}): loadResult => {const script = document.querySelector(`script[src="${path}"]`);

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

    newScript.setAttribute('data-status', 'loading');
    // 增加到 body 标签
    document.body.appendChild(newScript);

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

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

后面获取到 Element 元素后,监听 Element 的 load 和 error 事件,判断其加载状态并更新状态

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

用于设置页面题目。

官网文档

根本用法

import React from 'react';
import {useTitle} from 'ahooks';

export default () => {useTitle('Page Title');

  return (
    <div>
      <p>Set title of the page.</p>
    </div>
  );
};

应用场景

当进入某页面须要改浏览器 Tab 中展现的题目时

外围实现

这个实现比较简单

const DEFAULT_OPTIONS: Options = {restoreOnUnmount: false, // 组件卸载时,是否复原上一个页面题目};

function useTitle(title: string, options: Options = DEFAULT_OPTIONS) {const titleRef = useRef(isBrowser ? document.title : '');
  useEffect(() => {document.title = title;}, [title]);

  useUnmount(() => {if (options.restoreOnUnmount) {
      // 组件卸载时,复原上一个页面题目
      document.title = titleRef.current;
    }
  });
}

如果我的项目中咱们本人实现的话,有个须要留神的中央,不要把 document.title = title; 写在外层,要写在 useEffect 外面,具体见该文:检测意外的副作用

残缺源码

useFavicon

设置页面的 favicon。

官网文档

favicon 指显示在浏览器收藏夹、地址栏和标签题目后面的个性化图标

根本用法

import React, {useState} from 'react';
import {useFavicon} from 'ahooks';

export const DEFAULT_FAVICON_URL = 'https://ahooks.js.org/simple-logo.svg';

export const GOOGLE_FAVICON_URL = 'https://www.google.com/favicon.ico';

export default () => {const [url, setUrl] = useState<string>(DEFAULT_FAVICON_URL);

  useFavicon(url);

  return (
    <>
      <p>
        Current Favicon: <span>{url}</span>
      </p>
      <button
        style={{marginRight: 16}}
        onClick={() => {setUrl(GOOGLE_FAVICON_URL);
        }}
      >
        Change To Google Favicon
      </button>
      <button
        onClick={() => {setUrl(DEFAULT_FAVICON_URL);
        }}
      >
        Back To AHooks Favicon
      </button>
    </>
  );
};

应用场景

当须要改浏览器 Tab 中展现的图标 icon 时

外围实现

原理:通过 link 标签设置 favicon

更多 favicon 常识可见:具体介绍 HTML 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];
    // 指定被链接资源的地址
    link.href = href;
    // rel 属性用于指定以后文档与被链接文档的关系,间接应用 rel=icon 就能够,源码下方的 `shortcut icon` 是一种过期的用法
    link.rel = 'shortcut icon';

    document.getElementsByTagName('head')[0].appendChild(link);
  }, [href]);
};

残缺源码

useFullscreen

治理 DOM 全屏的 Hook。

官网文档

根本用法

import React, {useRef} from 'react';
import {useFullscreen} from 'ahooks';

export default () => {const ref = useRef(null);
  const [isFullscreen, { enterFullscreen, exitFullscreen, toggleFullscreen}] = useFullscreen(ref);
  return (<div ref={ref} style={{background: 'white'}}>
      <div style={{marginBottom: 16}}>{isFullscreen ? 'Fullscreen' : 'Not fullscreen'}</div>
      <div>
        <button type="button" onClick={enterFullscreen}>
          enterFullscreen
        </button>
        <button type="button" onClick={exitFullscreen} style={{margin: '0 8px'}}>
          exitFullscreen
        </button>
        <button type="button" onClick={toggleFullscreen}>
          toggleFullscreen
        </button>
      </div>
    </div>
  );
};

原生全屏 API

  • Element.requestFullscreen():用于收回异步申请使元素进入全屏模式
  • Document.exitFullscreen():用于让以后文档退出全屏模式。调用这个办法会让文档回退到上一个调用 Element.requestFullscreen()办法进入全屏模式之前的状态
  • [已过期不倡议应用]:Document.fullscreen:只读属性报告文档以后是否以全屏模式显示内容
  • Document.fullscreenElement:返回以后文档中正在以全屏模式显示的 Element 节点,如果没有应用全屏模式,则返回 null
  • Document.fullscreenEnabled:返回一个布尔值,表明浏览器是否反对全屏模式。全屏模式只在那些不蕴含窗口化的插件的页面中可用
  • fullscreenchange:元素过渡到或过渡到全屏模式时触发的全屏更改事件的事件
  • fullscreenerror:在 Element 过渡到或退出全屏模式产生谬误后处理事件

screenfull 库

useFullscreen 外部次要是依赖 screenfull 这个库进行实现的。

screenfull 对各种浏览器全屏的 API 进行封装,兼容性好。

上面是该库的 API:

  • .request(element, options?):使元素或者页面切换到全屏
  • .exit():退出全屏
  • .toggle(element, options?):在全屏和非全屏之间切换
  • .on(event, function):增加一个监听器,监听全屏切换或者谬误事件。event 反对 change 或者 error
  • .off(event, function):移除之前注册的事件监听
  • .isFullscreen:判断是否为全屏
  • .isEnabled:判断以后环境是否反对全屏
  • .element:返回该元素是否是全屏模式展现,否则返回 undefined

实现思路

看看 useFullscreen 的导出值:

return [
  state,
  {enterFullscreen: useMemoizedFn(enterFullscreen),
    exitFullscreen: useMemoizedFn(exitFullscreen),
    toggleFullscreen: useMemoizedFn(toggleFullscreen),
    isEnabled: screenfull.isEnabled,
  },
] as const;

那么实现的方向就比较简单了:

  1. 外部封装并裸露 toggleFullscreen、enterFullscreen、exitFullscreen 办法,裸露外部是否全屏的状态,还有是否反对全屏的状态
  2. 通过 screenfull 库监听 change 事件,在 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);
    }
  }
};

// 退出全屏办法
const exitFullscreen = () => {const el = getTargetElement(target);
  if (screenfull.isEnabled && screenfull.element === el) {screenfull.exit();
  }
};

const toggleFullscreen = () => {if (state) {exitFullscreen();
  } else {enterFullscreen();
  }
};

onChange 办法

const onChange = () => {if (screenfull.isEnabled) {const el = getTargetElement(target);
    // screenfull.element:以后元素以全屏模式显示
    if (!screenfull.element) {
      // 退出全屏
      onExitRef.current?.();
      setState(false);
      screenfull.off('change', onChange); // 卸载 change 事件
    } else {
      // 全屏模式展现
      const isFullscreen = screenfull.element === el; // 判断以后全屏元素是否为指标元素
      if (isFullscreen) {onEnterRef.current?.();
      } else {onExitRef.current?.();
      }
      setState(isFullscreen);
    }
  }
};

上方 onChange 以及 exitFullscreen 执行退出全屏前有行须要判断的代码留神下,具体起因能够看下修复 useFullScreen 当全屏后,子元素反复全屏和退出全屏操作后父元素也会退出全屏

// 判断以后全屏元素是否为指标元素,反对对多个元素同时全屏
const isFullscreen = screenfull.element === el;

screenfull.element 的实现:

element: {
  enumerable: true,
  get: () => document[nativeAPI.fullscreenElement] ?? undefined,
},

残缺源码

useHover

监听 DOM 元素是否有鼠标悬停。

官网文档

根本用法

import React, {useRef} from 'react';
import {useHover} from 'ahooks';

export default () => {const ref = useRef(null);
  const isHovering = useHover(ref);
  return <div ref={ref}>{isHovering ? 'hover' : 'leaveHover'}</div>;
};

鼠标监听事件

  • mouseenter:第一次挪动到触发事件元素中的激活区域时触发
  • mouseleave:在定点设施(通常是鼠标)的指针移出某个元素时被触发

扩大下几个鼠标事件的区别:

  • mouseenter:当鼠标移入某元素时触发。
  • mouseleave:当鼠标移出某元素时触发。
  • mouseover:当鼠标移入某元素时触发,移入和移出其子元素时也会触发。
  • mouseout:当鼠标移出某元素时触发,移入和移出其子元素时也会触发。
  • mousemove:鼠标在某元素上挪动时触发,即便在其子元素上也会触发。

外围实现

原理是监听 mouseenter 触发 onEnter 回调,切换状态为 true;监听 mouseleave 触发 onLeave回调,切换状态为 false。

残缺实现:

export interface Options {onEnter?: () => void;
  onLeave?: () => void;
  onChange?: (isHovering: boolean) => void;
}

export default (target: BasicTarget, options?: Options): boolean => {const { onEnter, onLeave, onChange} = options || {};

  // useBoolean:优雅的治理 boolean 状态的 Hook
  const [state, { setTrue, setFalse}] = useBoolean(false);

  // 监听 mouseenter 判断有鼠标进入指标元素
  useEventListener(
    'mouseenter',
    () => {onEnter?.();
      setTrue();
      onChange?.(true);
    },
    {target,},
  );

  // 监听 mouseleave 判断有鼠标是否移出指标元素
  useEventListener(
    'mouseleave',
    () => {onLeave?.();
      setFalse();
      onChange?.(false);
    },
    {target,},
  );

  return state;
};

残缺源码

退出移动版