@skyroc/utils

Web

仅限浏览器环境的工具集:文件下载策略、存储封装、环境检测、表单元素类型与取值、props 合并、新窗口打开与 HTML class 切换

概述

Web 子模块通过独立的子路径 @skyroc/utils/web 导出,与主入口分离,避免在非浏览器环境(Node.js、SSR、React Native)引入 BOM API。 主入口的 TS 项目 lib 只有 ESNext,没有 DOM——这类代码在那里根本编译不过,这条边界是编译期强制的。

import { downloadFileFromUrl, createStorage, openWindow, toggleHtmlClass } from '@skyroc/utils/web';
模块导出说明
downloaddownloadFileFromUrl 等 7 个函数多策略文件下载(本页)
windowopenWindow安全地在新窗口打开 URL(本页)
classtoggleHtmlClass切换 <html> 元素上的 class(本页)
storagecreateStoragecreateLocalforage类型安全的存储封装
envisWindowisMacOsisWindowsOsisPC环境与设备检测
formFieldElementCustomElement表单元素类型,仅类型导出(本页)
inputisCheckBoxInputisRadioInputisFileInputgetEventValue表单元素判定与取值(本页)
compose-propsmergePropswithClassNameReact props 合并(本页)

文件下载

下载场景的难点在于:不同来源(URL / Base64 / Blob)、不同平台(iOS / 桌面 / CORS 限制)需要不同的处理策略。download 模块将这些复杂性封装在内部,对外提供清晰的函数边界。

选择哪个函数?

文件来源是什么?
  ├─ 普通 URL(http/https)    → downloadFileFromUrl
  ├─ 图片 URL(需转 base64)   → downloadFileFromImageUrl
  ├─ Base64 / DataURL          → downloadFileFromBase64
  ├─ Blob 对象                 → downloadFileFromBlob
  ├─ BlobPart(ArrayBuffer 等)→ downloadFileFromBlobPart
  └─ 自定义 href               → triggerDownload(底层)

downloadFileFromUrl(options) — 异步

通过 URL 下载文件,内置跨平台兼容逻辑:

  1. iOS / iPadOS → 直接 openWindowa[download] 在 iOS 上不可靠)
  2. 桌面端,CORS 允许fetch → blob → a[download](最稳定,可自定义文件名)
  3. 桌面端,CORS 不允许 → 回退 openWindow

文件名解析优先级:Content-Disposition 响应头 → 参数 fileName → URL 路径中的文件名。

import { downloadFileFromUrl } from '@skyroc/utils/web';

// 最简用法
await downloadFileFromUrl({ source: 'https://example.com/report.pdf' });

// 指定文件名
await downloadFileFromUrl({
  source: 'https://example.com/export?id=123',
  fileName: '月度报表.xlsx'
});

// 在当前 tab 打开(适合预览)
await downloadFileFromUrl({
  source: 'https://example.com/preview.pdf',
  target: '_self'
});
interface DownloadOptions {
  source: string; // 文件 URL
  fileName?: string; // 自定义文件名(可选)
  target?: string; // 回退打开窗口的 target,默认 '_blank'
}

downloadFileFromBase64(options)

通过 Base64 / DataURL 下载文件,同步。

import { downloadFileFromBase64 } from '@skyroc/utils/web';

downloadFileFromBase64({
  source: 'data:application/pdf;base64,JVBERi0x...',
  fileName: 'document.pdf'
});

// 图片(省略 mimeType 前缀也可以,但推荐带完整 DataURL)
downloadFileFromBase64({
  source: 'data:image/png;base64,iVBORw0KGgo...',
  fileName: 'screenshot.png'
});

downloadFileFromImageUrl(options) — 异步

通过图片 URL 下载图片,内部用 Canvas 将图片转为 Base64 后下载。

跨域限制: 图片服务端必须返回 Access-Control-Allow-Origin 响应头,否则 Canvas 会被"污染"而抛错。

import { downloadFileFromImageUrl } from '@skyroc/utils/web';

await downloadFileFromImageUrl({
  source: 'https://cdn.example.com/avatar.png',
  fileName: 'my-avatar.png'
});

downloadFileFromBlob(options)

通过 Blob 对象下载,同步。适合服务端返回的二进制响应。

import { downloadFileFromBlob } from '@skyroc/utils/web';

// 配合 axios(responseType: 'blob')
const response = await axios.get('/api/export', { responseType: 'blob' });
downloadFileFromBlob({
  source: response.data, // Blob
  fileName: '数据导出.xlsx'
});

downloadFileFromBlobPart(options)

通过 BlobPartstring | ArrayBuffer | Uint8Array 等)下载,同步。适合需要自行构造文件内容的场景。

import { downloadFileFromBlobPart } from '@skyroc/utils/web';

const csvContent = 'name,age\nAlice,30\nBob,25';
downloadFileFromBlobPart({
  source: csvContent,
  fileName: 'users.csv'
});

urlToBase64(url, mimeType?) — 异步

将图片 URL 转为 Base64 DataURL(底层实现)。跨域图片需要服务端允许 CORS。

import { urlToBase64 } from '@skyroc/utils/web';

const base64 = await urlToBase64('https://cdn.example.com/image.png');
// 'data:image/png;base64,...'

triggerDownload(href, fileName, revokeDelay?) — 底层

通用下载触发函数,通过动态创建 <a> 标签并模拟点击实现下载。是其他 download* 函数的内部实现。

一般不需要直接调用,除非需要传入已有的 blob URL 或 data URL:

import { triggerDownload } from '@skyroc/utils/web';

const blobUrl = URL.createObjectURL(myBlob);
triggerDownload(blobUrl, 'file.zip');
// blob URL 会在 150ms 后自动 revokeObjectURL

openWindow

import { openWindow } from '@skyroc/utils/web';

在新窗口(或指定 target)打开一个 URL,默认启用 noopener,noreferrer 安全策略防止 opener 劫持。

// 新 tab 打开(默认)
openWindow('https://docs.example.com');

// 当前 tab
openWindow('/settings', { target: '_self' });

// 关闭安全策略(需要 opener 引用时)
openWindow('https://external.com', { secure: false });
interface OpenWindowOptions {
  target?: '_blank' | '_parent' | '_self' | '_top' | string; // 默认 '_blank'
  secure?: boolean; // 默认 true,开启 noopener + noreferrer
}

toggleHtmlClass

import { toggleHtmlClass } from '@skyroc/utils/web';

切换 document.documentElement(即 <html> 标签)上的 class,常用于主题切换(暗色模式)。

const darkMode = toggleHtmlClass('dark');

darkMode.add(); // <html class="dark">
darkMode.remove(); // <html class="">

在 React 中配合主题状态使用:

import { useEffect } from 'react';
import { toggleHtmlClass } from '@skyroc/utils/web';

const dark = toggleHtmlClass('dark');

const ThemeWatcher = ({ isDark }: { isDark: boolean }) => {
  useEffect(() => {
    isDark ? dark.add() : dark.remove();
  }, [isDark]);

  return null;
};

表单元素类型

import type { CustomElement, FieldElement } from '@skyroc/utils/web';

FieldElement 是表单收集器能接受的元素:

type FieldElement<T = unknown> = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | CustomElement<T>;

CustomElement<T = unknown> 描述第三方 / 自研控件至少要暴露的结构(valuetypecheckedfilesoptionsfocus),T 是该控件额外暴露的字段。泛型默认值是 unknown 而不是 any——交叉类型里出现 any 会把整个类型塌成 any,那样 FieldElement 就完全失去约束力了。

这两个类型依赖 DOM lib,所以住在 ./web 而不是 ./type。React Native 侧不要从这里 import——@skyroc/form 的根出口刻意不再转发 FieldElement,就是为了不把 DOM 类型带进无 DOM lib 的环境。


表单取值

import { isCheckBoxInput, isRadioInput, isFileInput, getEventValue } from '@skyroc/utils/web';

getEventValue(valuePropName?, ...args)

从受控组件的 onChange 参数里取出真实值。受控组件的第一个参数既可能是原生 / 合成事件,也可能是裸值(antd 的 SelectDatePicker 就直接给值),这个函数把两种形态统一掉:

getEventValue('value', event); // <input> → event.target.value
getEventValue('value', checkboxEvt); // <input type="checkbox"> → event.target.checked
getEventValue('value', 'hello'); // 裸值 → 'hello' 原样返回
getEventValue('checked', event); // 自定义 valuePropName

取值顺序:不是事件形状 → 原样返回;没有 target → 原样返回;是 checkbox → 取 target.checked;其余取 target[valuePropName]

元素类型守卫

函数判定
isCheckBoxInput(el)el.type === 'checkbox'
isRadioInput(el)el.type === 'radio'
isFileInput(el)el.type === 'file'

三者都是类型守卫,命中后把 FieldElement 收窄为 HTMLInputElement


React props 合并

import { mergeProps, withClassName } from '@skyroc/utils/web';

mergeProps(slotProps, childProps)

用于 asChild / Slot 模式:把外层容器的 props 合到使用者自己写的子元素上。合并规则按 prop 名分三档:

prop规则
on[A-Z]* 事件处理器两边都有时都调用,先子后父,返回子的返回值;只有一边时用那一边
style浅合并,子元素的同名字段胜出
className用空格拼接,两个都保留
其他子元素的值胜出
const props = mergeProps(
  { onClick: trackClick, className: 'btn', style: { color: 'red' } },
  { onClick: submit, className: 'primary', style: { fontSize: 12 } }
);
// onClick   → 先 submit 再 trackClick
// className → 'btn primary'
// style     → { color: 'red', fontSize: 12 }

withClassName(element, ...className)

给一个 React 元素追加 class,内部走 cn,因此 Tailwind 冲突类会被正确覆盖:

withClassName(<Icon className="size-4" />, 'text-red-500');
// <Icon className="text-red-500 size-4" />

注意参数顺序:元素自身的 className 排在后面,冲突时元素自己的类胜出

Last updated on