跳转到内容

常用函数

观察元素大小的变化

ts
export type ResizeCallbackFn = (
  contentRect: DOMRectReadOnly,
  target: HTMLElement,
) => void

let resizeObserverInstance: ResizeObserver | null = null
const resizeCallbackMap = new WeakMap<HTMLElement, ResizeCallbackFn>()

/**
 * 观察元素大小的变化
 */
export function observeResize(
  element: HTMLElement,
  callback: ResizeCallbackFn,
) {
  if (!resizeObserverInstance) {
    resizeObserverInstance = new ResizeObserver((entries) => {
      for (const entry of entries) {
        const target = entry.target
        const callbackFn = resizeCallbackMap.get(target as HTMLElement)
        if (callbackFn) {
          callbackFn(entry.contentRect, target as HTMLElement)
        }
      }
    })
  }

  resizeObserverInstance.observe(element)
  resizeCallbackMap.set(element, callback)

  // 返回一个取消观察的函数
  return () => {
    resizeObserverInstance?.unobserve(element)
    resizeCallbackMap.delete(element)
  }
}

/**
 * 停止观察元素大小的变化
 */
export function unobserveResize(element: HTMLElement) {
  if (resizeObserverInstance) {
    resizeObserverInstance.unobserve(element)
    resizeCallbackMap.delete(element)
  }
}

观察元素可见性的变化

ts
export type IntersectionCallbackFu = (
  entry: IntersectionObserverEntry,
) => void

export type ObserveFn = (
  el: Element,
  callback: IntersectionCallbackFu,
) => () => void

export class ObserverIntersection {
  private observer: IntersectionObserver
  private observedElements: Map<Element, IntersectionCallbackFu>

  constructor(options?: IntersectionObserverInit) {
    this.observedElements = new Map()
    this.observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        const callback = this.observedElements.get(entry.target)
        if (callback) {
          callback(entry)
        }
      })
    }, options)
  }

  observe(el: Element, callback: IntersectionCallbackFu): () => void {
    if (this.observedElements.has(el)) {
      console.warn('Element is already being observed.')
      return () => this.unobserve(el)
    }

    this.observedElements.set(el, callback)
    this.observer.observe(el)

    return () => this.unobserve(el)
  }

  unobserve(el: Element): void {
    if (this.observedElements.has(el)) {
      this.observedElements.delete(el)
      this.observer.unobserve(el)
    } else {
      console.warn('Element is not being observed.')
    }
  }

  disconnectAll(): void {
    this.observer.disconnect()
    this.observedElements.clear()
  }
}

防抖

ts
export function debounce<T extends any[], R, THIS>(
  this: THIS,
  fn: (...args: T) => R,
  wait?: number
): (...args: T) => void {
  let timeout: ReturnType<typeof setTimeout>
  return function (this: THIS, ...args: T) {
    clearTimeout(timeout)
    timeout = setTimeout(() => {
      fn.apply(this, args)
    }, wait)
  }
}

lodash 版节流

ts
type ThrottleOptions = {
  leading?: boolean;
  trailing?: boolean;
};

function throttle<T extends (...args: any[]) => any>(
  func: T,
  wait: number,
  options: ThrottleOptions = {}
): (...args: Parameters<T>) => ReturnType<T> | void {
  let lastExecTime: number | undefined;
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
  let storedArgs: Parameters<T> | undefined;
  let storedThis: any;

  // 处理默认选项并确保至少有一个触发选项
  let { leading = true, trailing = true } = options;
  if (!leading && !trailing) {
    trailing = true;
  }

  // 清理定时器并重置状态
  const clearTimer = () => {
    if (timeoutId) {
      clearTimeout(timeoutId);
      timeoutId = undefined;
    }
  };

  // 执行 trailing 调用
  const trailingExec = () => {
    lastExecTime = Date.now();
    clearTimer();
    if (trailing && storedArgs) {
      func.apply(storedThis, storedArgs);
      storedArgs = undefined;
      storedThis = undefined;
    }
  };

  return function (this: any, ...args: Parameters<T>): ReturnType<T> | void {
    const now = Date.now();
    
    // 计算剩余时间
    const remaining = lastExecTime === undefined
      ? 0
      : wait - (now - lastExecTime);

    // 保存当前调用的上下文和参数
    storedArgs = args;
    storedThis = this;

    if (remaining <= 0) {
      clearTimer();
      lastExecTime = now;
      if (leading) {
        return func.apply(storedThis, storedArgs);
      }
    } else if (!timeoutId && trailing) {
      timeoutId = setTimeout(trailingExec, remaining);
    }

    // 非 leading 调用或无立即执行时返回 undefined
    return undefined;
  };
}

节流和防抖的结合函数

ts
/**
 * 节流和防抖函数,结合了 throttle(节流)和 debounce(防抖)的特性
 *
 * 该函数确保在指定的时间间隔内,第一次调用会立即执行(throttle 特性),
 * 在等待期间的后续调用会被防抖,只在最后一次调用后延迟执行(debounce 特性)
 *
 * @see {@link https://github.com/vuejs/vitepress/blob/v1.6.3/src/client/theme-default/support/utils.ts#L5-L17}
 *
 * 来自 VitePress 源码,并进行了一些修改
 */
export const throttleAndDebounce = <P extends any[]>(
  fn: (...args: P) => any,
  delay: number,
): (() => void) => {
  let timeoutId: string | number | NodeJS.Timeout
  let called = false

  return (...args: P): void => {
    if (timeoutId) clearTimeout(timeoutId)

    if (!called) {
      fn(...args)
      ;(called = true) && setTimeout(() => (called = false), delay)
    } else timeoutId = setTimeout(fn, delay)
  }
}

hasOwnProperty

ts
const rawHasOwnProperty = Object.prototype.hasOwnProperty

export const hasOwnProperty = <T extends object, K extends PropertyKey>(
  obj: T,
  key: K,
): boolean => rawHasOwnProperty.call(obj, key)

export const hasOwnProperties = <T extends object, K extends PropertyKey>(
  obj: T,
  keys: K[],
): boolean => keys.every(key => hasOwnProperty(obj, key))

export const hasOwnOrProperties = <T extends object, K extends PropertyKey>(
  obj: T,
  keys: K[],
): boolean => keys.some(key => hasOwnProperty(obj, key))

挑选属性 Pick

ts
export const pickProperties = <T extends object, K extends keyof T>(
  obj: T,
  ...keys: K[]
) => {
  const uniqueKeys = Array.from(new Set(keys))
  return uniqueKeys.reduce(
    (acc, key) => {
      if (key in obj) {
        acc[key] = obj[key]
      }
      return acc
    },
    {} as Pick<T, K>,
  )
}

排除属性 Omit

ts
export const omitProperties = <T extends object, K extends keyof T>(
  obj: T,
  ...keys: K[]
) => {
  const uniqueKeys = Array.from(new Set(keys))
  return uniqueKeys.reduce(
    (acc, key) => {
      delete acc[key]
      return acc
    },
    Object.assign({}, obj),
  ) as Omit<T, K>
}

修改所有属性的值 Record

ts
export const record = <T extends Record<string | number | symbol, any>, V>(
  obj: T,
  value: V,
): Record<keyof T, V> => {
  return Object.keys(obj).reduce(
    (acc, key) => {
      acc[key as keyof T] = value
      return acc
    },
    {} as Record<keyof T, V>,
  )
}

获取错误信息字符串

ts
/**
 * 将任意类型的错误对象转换为可读的错误信息字符串
 * @param {unknown} error - catch捕获的错误对象
 * @returns {string} 可读的错误信息
 */
const getErrorMessage = (error: unknown): string => {
  if (error instanceof Error) {
    return error.message
  }

  if (typeof error === 'object' && error !== null) {
    // 处理包含message属性的对象
    if ('message' in error && typeof error.message === 'string') {
      return error.message
    }

    // 尝试JSON序列化
    try {
      return JSON.stringify(error)
    } catch (_) {
      return String(error)
    }
  }

  // 处理基础类型和undefined/null
  return String(error ?? 'Unknown error')
}

格式化金额

ts
export interface FormatAmountOptions {
  /**
   * 货币符号
   */
  currency?: string
  /**
   * 小数位数
   */
  decimal?: number
  /**
   * NaN 的显示值
   */
  nanValue?: string
  /**
   * 0 的显示值
   */
  zeroValue?: string
  /**
   * 是否显示千分位
   */
  thousand?: boolean
  /**
   * 是否四舍五入(默认false)
   */
  rounding?: boolean
  /**
   * 是否优先使用真实小数位(默认true)
   */
  keepOriginalDecimal?: boolean
}

/**
 * 格式化金额
 */
export const formatAmount = (
  amount: unknown,
  {
    currency = '',
    decimal = 2,
    nanValue = '--',
    zeroValue,
    thousand = true,
    rounding = false,
    keepOriginalDecimal = true,
  }: FormatAmountOptions = {},
): string => {
  // 类型转换和清理
  const numericValue = convertToNumber(amount)

  if (isNaN(numericValue)) return nanValue
  if (numericValue === 0 && zeroValue !== undefined) return zeroValue

  // 处理小数位
  const actualDecimal = calculateActualDecimal(
    numericValue,
    decimal,
    keepOriginalDecimal,
  )
  let formatted = processDecimal(numericValue, actualDecimal, rounding)

  // 千分位处理
  if (thousand) {
    formatted = addThousandSeparator(formatted, actualDecimal)
  }

  return currency ? `${currency}${formatted}` : formatted
}

// 辅助函数分解
const convertToNumber = (amount: unknown): number => {
  if (typeof amount === 'string') {
    // 处理科学计数法
    if (/e/i.test(amount)) return Number(Number(amount).toFixed(20))
    return Number(amount.replace(/,/g, ''))
  }
  return Number(amount)
}

const calculateActualDecimal = (
  num: number,
  decimal: number,
  keepOriginal: boolean,
): number => {
  if (!keepOriginal) return decimal

  const str = num.toString()
  const decimalIndex = str.indexOf('.')
  const originalDecimals =
    decimalIndex === -1 ? 0 : str.length - decimalIndex - 1

  return originalDecimals > decimal ? originalDecimals : decimal
}

const processDecimal = (
  num: number,
  decimal: number,
  rounding: boolean,
): string => {
  const factor = 10 ** decimal
  const fixed = rounding
    ? Math.round(num * factor) / factor
    : Math.floor(num * factor) / factor

  return fixed.toLocaleString('en-US', {
    minimumFractionDigits: decimal,
    maximumFractionDigits: decimal,
    useGrouping: false,
  })
}

const addThousandSeparator = (numStr: string, decimal: number): string => {
  const [integerPart, decimalPart] = numStr.split('.')
  const formattedInteger = integerPart.replace(
    /\B(?=(\d{3})+(?!\d))/g,
    ',',
  )
  return decimal > 0
    ? `${formattedInteger}.${decimalPart || '0'.repeat(decimal)}`
    : formattedInteger
}

数字千位分隔符

ts
/**
 * 给数字添加千位分隔符
 */
export const addThousandsSeparator = (
  num: number | string,
  decimal = 0,
) => {
  const [int, dec = ''] = String(num).split('.')
  const formatted = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
  if (decimal > 0) {
    return `${formatted}.${dec.slice(0, decimal).padEnd(decimal, '0')}`
  } else {
    return formatted
  }
}

// 测试用例
console.log(addThousandsSeparator(1234567)) // "1,234,567"
console.log(addThousandsSeparator(1234567.89)) // "1,234,567"
console.log(addThousandsSeparator(1234567.89, 2)) // "1,234,567.89"
console.log(addThousandsSeparator(1000)) // "1,000"
console.log(addThousandsSeparator(0)) // "0"
console.log(addThousandsSeparator(0, 2)) // "0.00"
console.log(addThousandsSeparator(1234.5, 3)) // "1,234.500" (小数位不足补0)
console.log(addThousandsSeparator(1234.56789, 2)) // "1,234.56" (小数位超长截断)
console.log(addThousandsSeparator(999)) // "999" (不满千位不加分隔符)
console.log(addThousandsSeparator(-1234567)) // "-1,234,567" (负数处理)
console.log(addThousandsSeparator('987654321')) // "987,654,321" (字符串输入)
console.log(addThousandsSeparator('12345.678', 1)) // "12,345.6" (字符串+小数位)
console.log(addThousandsSeparator('1234567890123456')) // "1,234,567,890,123,456" (大数字)

格式化字节

另一个

ts
interface FormatBytesOptions {
  /**
   * 小数位数
   * @default 2
   */
  decimals?: number
  /**
   * 后缀
   * @default 'B'
   */
  suffix?: string
  /**
   * 去除结尾多余的 0(1.00KB -> 1KB  1.50KB -> 1.5KB)
   * @default false
   */
  trim?: boolean
  /**
   * 进制基数
   * @default 1024
   */
  radix?: number
  /**
   * 以比特为单位计算 (1b = 8bit)
   * @default false
   */
  bits?: boolean
}

const units = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']

const trimRegex = /\.?0+$/

export const formatBytes = (
  bytes: number,
  options?: FormatBytesOptions,
) => {
  const {
    decimals = 2,
    suffix = 'B',
    trim = false,
    radix = 1024,
    bits = false,
  } = options || {}

  const dm = decimals < 0 ? 0 : decimals

  let text: string
  let unit = ''

  if (bits) {
    bytes = bytes * 8
  }

  if (bytes > 0) {
    const i = Math.floor(Math.log(bytes) / Math.log(radix))
    text = (bytes / Math.pow(radix, i)).toFixed(dm)
    unit = units[i]
  } else {
    text = bytes.toFixed(dm)
  }

  if (trim) {
    text = text.replace(trimRegex, '')
  }

  return text + unit + suffix
}

// ========================= 测试 =========================

formatBytes(12345) // 12.06KB
formatBytes(-12345) // -12345.00B
formatBytes(0) // 0.00B
formatBytes(1024) // 1.00KB
formatBytes(12345, { decimals: 0 }) // 12KB
formatBytes(12345, { decimals: 4 }) // 12.0557KB
formatBytes(1024, { trim: true }) // 1KB
formatBytes(1536, { trim: true }) // 1.5KB
formatBytes(1048576, { suffix: 'B/s', trim: true }) // 1MB/s
formatBytes(1048576, { suffix: 'bps/s', trim: true, bits: true }) // 8Mbps/s
formatBytes(1048576, { radix: 1000 }) // 1.05MB

格式化用时

另一个

ts
const formatDuration = (ms: number) => {
  // 边界情况
  if (ms === 0) return '0ms'
  if (ms < 0.001) return `${ms * 1000}μs`

  let minus = false

  if (ms < 0) {
    minus = true
    ms = Math.abs(ms)
  }

  type UnitScale = [number, string]
  type TimePart = [number, string]

  const TIME_UNITS: UnitScale[] = [
    [3600000000, 'h'],
    [60000000, 'm'],
    [1000000, 's'],
    [1000, 'ms'],
    [1, 'μs'],
  ]

  const parts: TimePart[] = []
  let remainder = Math.floor(ms * 1000)

  for (const [radix, unit] of TIME_UNITS) {
    const n = Math.floor(remainder / radix)
    if (n > 0) {
      parts.push([n, unit])
    }
    remainder %= radix
    if (remainder <= 0) {
      break
    }
  }

  let text = parts.map(([n, unit]) => `${n}${unit}`).join(' ')

  if (minus) {
    // 处理负数
    text = '-' + text
  }

  return text
}

// ========================= 测试 =========================

console.log('0ms 场景:', formatDuration(0)) // 0ms
console.log('负值场景:', formatDuration(-1234.567)) // -1234567μs
console.log('微秒边界:', formatDuration(0.001)) // 1μs
console.log('毫秒边界:', formatDuration(999)) // 999ms
console.log('秒边界:', formatDuration(1000)) // 1s
console.log('分钟边界:', formatDuration(60000)) // 1m
console.log('小时边界:', formatDuration(3600000)) // 34h 17m 36s 789ms
console.log('复合单位:', formatDuration(123456789)) // 1s 999μs
console.log('浮点精度:', formatDuration(1000.999)) // 毫秒+微秒
console.log('极小值:', formatDuration(0.0005)) // 0.5μs
console.log('极大值:', formatDuration(1e12)) // 277777h 46m 40s
console.log('跳过零值单位:', formatDuration(3600001)) // 1h 1ms

选择文件

js 实现

ts
interface SelectFileOptions {
  /**
   * 文件类型
   */
  accept?: string
  /**
   * 是否多选
   */
  multiple?: boolean
}

/**
 * 选择文件
 * @example
 * selectFile({ accept: 'image/*', multiple: true }).then(files => {
 *     console.log(files)
 * })
 */
export const selectFile = (options?: SelectFileOptions) => {
  return new Promise<File[]>((resolve, reject) => {
    const { accept, multiple } = options || {}
    const input = document.createElement('input')
    input.type = 'file'
    if (accept) input.accept = accept
    if (multiple) input.multiple = multiple
    input.click()
    input.onchange = () => {
      const files: File[] = []
      for (let i = 0; i < input.files!.length; i++) {
        files.push(input.files![i])
      }
      if (files.length > 0) {
        resolve(files)
      } else {
        reject(new Error('CANCEL'))
      }
    }
    input.onerror = (_event, _source, _lineno, _colno, error) => {
      reject(error || new Error('UNKNOWN_ERROR'))
    }
    input.oncancel = () => {
      reject(new Error('CANCEL'))
    }
  })
}

加载图片

ts
export const loadImage = async (url: string, revoke = false) => {
  return new Promise<HTMLImageElement>((resolve, reject) => {
    const img = new Image()
    img.onload = () => {
      resolve(img)
      if (revoke) {
        URL.revokeObjectURL(img.src)
      }
    }
    img.onerror = () => {
      reject(new Error('load image error'))
      if (revoke) {
        URL.revokeObjectURL(img.src)
      }
    }
    img.src = url
  })
}

获取图片平均色

loadImage

ts
import { loadImage } from './load-image'

export const getImageAverageColor = async (file: File) => {
  const url = URL.createObjectURL(file)
  const image = await loadImage(url)
  const canvas = document.createElement('canvas')
  const ctx = canvas.getContext('2d')
  if (!ctx) {
    throw new Error('CanvasRenderingContext2D is null')
  }
  canvas.width = image.width
  canvas.height = image.height
  ctx.drawImage(image, 0, 0)
  const colors = ctx.getImageData(0, 0, image.width, image.height).data

  let r = 0
  let g = 0
  let b = 0

  for (let i = 0; i < colors.length; i += 4) {
    r += colors[i]
    g += colors[i + 1]
    b += colors[i + 2]
  }

  r = Math.round(r / (image.width * image.height))
  g = Math.round(g / (image.width * image.height))
  b = Math.round(b / (image.width * image.height))

  return { r, g, b }
}

获取图片大小

loadImage

ts
import { loadImage } from './load-image'

export interface ImageDimensions {
  width: number
  height: number
}

export const getImageDimensions = async (
  src: string,
): Promise<ImageDimensions> => {
  const img = await loadImage(src)
  return { width: img.naturalWidth, height: img.naturalHeight }
}

export const getImageDimensionsByFile = async (
  file: File,
): Promise<ImageDimensions> => {
  const img = await loadImage(URL.createObjectURL(file), true)
  return { width: img.naturalWidth, height: img.naturalHeight }
}

是否为矢量图

ts
export const isVectorImage = (file: File): boolean => {
  return file.type.startsWith('image/svg')
}

Canvas 转 Blob

ts
export const canvasToBlob = (
  canvas: HTMLCanvasElement,
  type?: string,
  quality?: number,
) => {
  return new Promise<Blob>((resolve, reject) => {
    canvas.toBlob(
      (blob) => {
        if (blob) {
          resolve(blob)
        } else {
          reject(new Error('canvas to blob error'))
        }
      },
      type,
      quality,
    )
  })
}

Blob 转 Base64

ts
export const blobToBase64 = (blob: Blob) => {
  return new Promise<string>((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = () => {
      if (typeof reader.result === 'string') {
        resolve(reader.result)
      } else {
        reject(new Error('blob to base64 error'))
      }
    }
    reader.onerror = (error) => {
      reject(error)
    }
    reader.readAsDataURL(blob)
  })
}

RGB 转 HSL

ts
/**
 * RGB 转 HSL
 */
const rgbToHsl = (rgb: { r: number; g: number; b: number }) => {
  const { r, g, b } = rgb

  // 归一化
  const rNorm = r / 255
  const gNorm = g / 255
  const bNorm = b / 255

  // 最大值、最小值、差值
  const max = Math.max(rNorm, gNorm, bNorm)
  const min = Math.min(rNorm, gNorm, bNorm)
  const delta = max - min

  // 亮度 L
  const l = (max + min) / 2

  // 饱和度 S
  let s = 0
  if (delta !== 0) {
    s = delta / (1 - Math.abs(2 * l - 1))
  }

  // 色相 H
  let h = 0
  if (delta !== 0) {
    if (rNorm >= gNorm && rNorm >= bNorm) {
      h = ((gNorm - bNorm) / delta) % 6
    } else if (gNorm >= bNorm) {
      h = (bNorm - rNorm) / delta + 2
    } else {
      h = (rNorm - gNorm) / delta + 4
    }
    h *= 60
    if (h < 0) h += 360
  }

  // 返回整数格式
  return {
    h: Math.round(h),
    s: Math.round(s * 100),
    l: Math.round(l * 100),
  }
}

HSL 转 RGB

ts
/**
 * HSL 转 RGB
 */
const hslToRgb = (hsl: { h: number; s: number; l: number }) => {
  const { h, s, l } = hsl

  // 归一化 HSL 值
  const H = h / 60 // 将 0~360 转换为 0~6
  const S = s / 100
  const L = l / 100

  // 计算中间值
  const C = (1 - Math.abs(2 * L - 1)) * S
  const X = C * (1 - Math.abs((H % 2) - 1))
  const m = L - C / 2

  // 根据 H 的区间确定 RGB 分量
  let r, g, b

  if (H >= 0 && H < 1) {
    r = C
    g = X
    b = 0
  } else if (H >= 1 && H < 2) {
    r = X
    g = C
    b = 0
  } else if (H >= 2 && H < 3) {
    r = 0
    g = C
    b = X
  } else if (H >= 3 && H < 4) {
    r = 0
    g = X
    b = C
  } else if (H >= 4 && H < 5) {
    r = X
    g = 0
    b = C
  } else if (H >= 5 && H < 6) {
    r = C
    g = 0
    b = X
  } else {
    // 处理 H = 360 的情况(即 H = 0)
    r = 0
    g = 0
    b = 0
  }

  // 应用偏移量 m,转换为 0~1 范围
  r += m
  g += m
  b += m

  // 转换为 0~255 的整数范围
  return {
    r: Math.round(r * 255),
    g: Math.round(g * 255),
    b: Math.round(b * 255),
  }
}

只处理一次错误

安全地处理错误,避免重复处理同一错误实例

ts
/**
 * 用于防止重复处理同一错误的Symbol标记
 * 当错误被处理后,会在错误对象上设置此标记以避免重复处理
 */
const ERROR_PROCESSED = Symbol('errorProcessed')

/**
 * 安全地处理错误,避免重复处理同一错误实例
 *
 * 此函数确保每个错误只会被处理一次,防止在错误冒泡过程中
 * 多个捕获点对同一错误进行重复处理和提示
 *
 * @param error - 要处理的错误对象
 * @param handler - 实际处理错误的回调函数
 *
 * @example
 * ```typescript
 * try {
 *   // some code that might throw
 * } catch (error) {
 *   handleUniqueError(error, (err) => {
 *     // 显示错误提示给用户
 *     console.error('Error occurred:', err.message);
 *   });
 * }
 * ```
 */
const handleUniqueError = (
  error: unknown,
  handler: (error: Error) => void,
) => {
  if (error instanceof Error && !Reflect.get(error, ERROR_PROCESSED)) {
    Reflect.set(error, ERROR_PROCESSED, true)
    handler(error)
  }
}

Github 相关方法

ts
/**
 * 判断一个字符串是否为 GitHub 仓库的 HTTPS URL
 * 仅支持 https 协议,格式如:
 * - https://github.com/owner/repo
 *
 * @param url 待判断的字符串
 * @returns 如果是仓库 HTTPS URL 返回 true,否则返回 false
 */
export const isGithubRepoURL = (url: string): boolean => {
  const regex = /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+(\/)?$/
  return regex.test(url.trim())
}

/**
 * 从 GitHub 仓库 HTTPS URL 中提取 owner 和 repo
 * 仅支持格式如:https://github.com/owner/repo
 *
 * @param url 仓库的 HTTPS URL
 * @returns 包含 owner 和 repo 的对象,如果无法提取则返回 null
 * @throws 如果不是有效的 GitHub 仓库 HTTPS URL
 */
export const getGithubRepoInfo = (
  url: string,
): { owner: string; repo: string } | null => {
  if (!isGithubRepoURL(url)) {
    return null
  }
  const match = url
    .trim()
    .match(/^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)(\/)?$/)
  if (!match) {
    throw new Error('Unable to extract owner and repo from URL')
  }
  return { owner: match[1], repo: match[2] }
}

/**
 * 获取指定 GitHub 仓库的统计信息,包括 star 数、fork 数和 watcher 数。
 *
 * @param owner 仓库拥有者的用户名
 * @param repo 仓库名称
 * @returns 一个包含 stars、forks 和 watchers 数量的对象
 * @throws 当请求失败或无法获取数据时抛出错误
 */
export const getGithubRepoStatsAPI = async (
  owner: string,
  repo: string,
) => {
  try {
    const response = await fetch(
      `https://api.github.com/repos/${owner}/${repo}`,
    )
    if (!response.ok) {
      throw new Error('Failed to fetch repository data')
    }
    const data = await response.json()
    return {
      stars: data.stargazers_count,
      forks: data.forks_count,
      watchers: data.watchers_count,
    }
  } catch (error) {
    console.error('Error:', error)
    throw error
  }
}

NPM 相关方法

ts
/**
 * 获取指定 npm 包的下载量。
 * @param packageName 包名
 * @returns 下载量(number)
 */
export const getNpmDownloadCountAPI = async (
  packageName: string,
  range: 'last-week' | 'last-month' | 'last-year' = 'last-month',
): Promise<number> => {
  const url = `https://api.npmjs.org/downloads/point/${range}/${encodeURIComponent(
    packageName,
  )}`
  const response = await fetch(url)
  if (!response.ok) {
    throw new Error(
      `Failed to fetch download count for package: ${packageName}`,
    )
  }
  const data = await response.json()
  return data.downloads ?? 0
}

分步执行任务

ts
/**
 * 任务
 */
type Task = (...args: any[]) => any

/**
 * 调度器
 */
type Scheduler = (runChunk: (isGnOn: () => boolean) => void) => void

/**
 * 分步执行任务
 *
 * @param tasks 任务列表
 * @param scheduler 调度器
 *
 * @from teacher yuan
 */
const performTask = (tasks: Task[], scheduler: Scheduler) => {
  let index = 0
  const _run = () => {
    scheduler((isGnOn) => {
      while (index < tasks.length && isGnOn()) {
        tasks[index++]()
      }
      if (index < tasks.length) {
        _run()
      }
    })
  }

  _run()
}

/**
 * 分步执行任务(基于 requestIdleCallback)
 *
 * @param tasks 任务列表
 */
const idlePerformTask = (tasks: Task[]) => {
  performTask(tasks, (runChunk) => {
    requestIdleCallback((idle) => {
      runChunk(() => idle.timeRemaining() > 0)
    })
  })
}

/**
 * 分步执行任务(基于时间调度)
 *
 * @param tasks 任务列表
 * @param interval 时间间隔
 * @param count 每次执行的任务数量
 */
const timedPerformTask = (
  tasks: Task[],
  interval: number,
  count: number,
) => {
  performTask(tasks, (runChunk) => {
    let i = 0
    setTimeout(() => {
      runChunk(() => i++ < count)
    }, interval)
  })
}

创建一个可过期的 Promise 包装器

ts
/**
 * 创建一个可过期的 Promise 包装器
 *
 * 该函数接收一个返回 Promise 的函数,并返回一个新的函数。
 * 新函数在被多次调用时,会使之前未完成的 Promise 过期,确保只处理最新的请求结果。
 *
 * @example
 * const expirableFetch = createExpirablePromise(fetchData);
 * const result1 = expirableFetch('/api/data1');
 * const result2 = expirableFetch('/api/data2');
 * // result1 的 expired 将会是 true,因为 result2 后调用
 * // 只有 result2 的 result 会被使用
 */
export const createExpirablePromise = <P extends any[] = any[], R = any>(
  fn: (...args: P) => Promise<R>,
): (() => Promise<{ result: R; expired: boolean }>) => {
  let setExpired: (() => void) | undefined

  return async (...args: P) => {
    if (setExpired) {
      // 设置上一个 Promise 已过期
      setExpired()
    }

    let expired = false
    setExpired = () => {
      expired = true
    }

    try {
      const result = await fn(...args)

      // 清理引用避免内存泄漏
      setExpired = void 0

      return {
        result,
        expired,
      }
    } catch (error) {
      // 发生错误时也清理引用
      setExpired = void 0
      throw error
    }
  }
}

获取元素在滚动容器中的偏移量

ts
/**
 * 获取元素在滚动容器中的偏移量
 *
 * @param target - 目标元素
 * @param scroller - 滚动容器元素
 */
export const getScrollOffset = (
  target: HTMLElement,
  scroller: HTMLElement,
) => {
  const targetRect = target.getBoundingClientRect()
  const scrollerRect = scroller.getBoundingClientRect()
  const scrollerComputedStyle = getComputedStyle(scroller)

  return {
    top:
      targetRect.top -
      scrollerRect.top +
      scroller.scrollTop -
      parseInt(scrollerComputedStyle.paddingTop),
    left:
      targetRect.left -
      scrollerRect.left +
      scroller.scrollLeft -
      parseInt(scrollerComputedStyle.paddingLeft),
  }
}

/**
 * 获取元素在滚动容器中的顶部偏移量
 *
 * @param target - 目标元素
 * @param scroller - 滚动容器元素
 */
export const getScrollOffsetTop = (
  target: HTMLElement,
  scroller: HTMLElement,
) => {
  return (
    target.getBoundingClientRect().top -
    scroller.getBoundingClientRect().top +
    scroller.scrollTop -
    parseInt(getComputedStyle(scroller).paddingTop)
  )
}

/**
 * 获取元素在滚动容器中的左边偏移量
 *
 * @param target - 目标元素
 * @param scroller - 滚动容器元素
 */
export const getScrollOffsetLeft = (
  target: HTMLElement,
  scroller: HTMLElement,
) => {
  return (
    target.getBoundingClientRect().left -
    scroller.getBoundingClientRect().left +
    scroller.scrollLeft -
    parseInt(getComputedStyle(scroller).paddingLeft)
  )
}