常用函数
观察元素大小的变化
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
选择文件
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
})
}
获取图片平均色
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 }
}
获取图片大小
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)
)
}