第 7 章:类型系统
类型系统听起来很工程化、很 boring。但 sag-icons 的类型设计有几个真实价值:
<sag-icon name="..." /> 写错名字 TS 立刻报错
- 重命名图标时 TS 帮你找出所有引用点
- 按需导入的子路径有完整类型
- React / Vue 项目有 JSX 增强类型
读完这一章你会知道:
- 怎么用
as const + keyof typeof 派生 IconName
- 怎么写
.d.ts 文件做 JSX 增强
- 怎么设计
exports 子路径支持按需导入
- 类型系统的几个边界场景(字符串弱类型、tree-shaking)
7.1 IconName 类型派生
sag-icons 的核心类型:
src/data/index.ts
export const ICON_PATHS = {
brand,
home,
workbench,
// ... 78 个
} as const;
export type IconName = keyof typeof ICON_PATHS;
对应文件:src/data/index.ts:191-193
as const 是关键
export const ICON_PATHS = {
brand,
home,
workbench,
// ...
};
如果没 as const,ICON_PATHS 的类型是:
{
brand: string;
home: string;
workbench: string;
}
keyof typeof ICON_PATHS 是 "brand" | "home" | "workbench"(这是想要的)。但 sag-icons 还有一个细节:
ICON_PATHS["customer-service"] = customerService; // 多词图标
加了 as const 后,TS 把每个键视为字面量类型,包括 "customer-service"。
IconName 类型
export type IconName = keyof typeof ICON_PATHS;
IconName = "brand" | "home" | "workbench" | "customer-service" | ...
78 个图标的字面量联合类型。自动维护——ICON_PATHS 加一个图标,IconName 自动跟上。
// ✅ 合法
<sag-icon name="workbench" />
// ❌ TS 报错:Type '"workbenchh"' is not assignable
<sag-icon name="workbenchh" />
这就是 sag-icons 类型系统的核心。
7.2 ICON_NAMES:运行时数组
export const ICON_NAMES = Object.keys(ICON_PATHS) as IconName[];
对应文件:src/data/index.ts:193
Object.keys 返回 string[],这里 as IconName[] 是类型断言——保证数组每个元素都是合法 IconName。
应用场景:
// 在文档站自动生成图标列表
ICON_NAMES.map(name => `<sag-icon name="${name}" />`);
// 用户搜索图标
function search(query: string): IconName[] {
return ICON_NAMES.filter(name => name.includes(query));
}
类型断言这里安全,因为 ICON_PATHS 的 key 一定是字面量类型。
7.3 getIconPaths 函数
export function getIconPaths(name: string): string | undefined {
return (ICON_PATHS as Record<string, string | undefined>)[name];
}
对应文件:src/data/index.ts:195-197
接受 string 而不是 IconName——故意放宽:
// 合法但 TS 不一定知道(外部数据)
const userIcon = localStorage.getItem("last-icon");
getIconPaths(userIcon); // string 类型,不会编译错
返回 string | undefined 让调用方处理未知情况:
const paths = getIconPaths(name);
if (!paths) {
// 提示开发者:图标名不存在
}
这就是 sag-icons 内部 attributeChangedCallback 处理未知 name 的方式。
7.4 JSX 增强:sag-icon 在 TSX 里有类型提示
Web Component 在 TSX 里默认没有类型提示——name 字段是 string,传什么都行。
sag-icons 提供一个 .d.ts 文件做 JSX 增强:
src/web-components/jsx.d.ts
import type { IconName } from "../data";
declare global {
namespace JSX {
interface IntrinsicElements {
"sag-icon": SagIconAttrs;
}
}
}
export interface SagIconAttrs {
name: IconName | (string & {});
class?: string;
style?: import("react").CSSProperties;
"aria-label"?: string;
"aria-hidden"?: "true" | "false";
role?: string;
tabIndex?: number;
ref?: unknown;
}
对应文件:src/web-components/jsx.d.ts
declare global
declare global {
namespace JSX {
interface IntrinsicElements {
"sag-icon": SagIconAttrs;
}
}
}
JSX 的全局命名空间声明。给"sag-icon"标签加上 SagIconAttrs 类型。TSX 编译器看到 <sag-icon> 就去查 SagIconAttrs。
为什么需要单独的 .d.ts
不能直接写在 sag-icon.ts 里,因为:
sag-icon.ts 是值定义(class SagIcon extends HTMLElement)
- JSX 类型是纯类型,没有运行时意义
- 单独
.d.ts 文件让用户可选地引入
引入方式有两种:
方式 A:在 tsconfig.json 加:
{
"compilerOptions": {
"types": ["@jujiuyey/sag-icons/jsx"]
}
}
方式 B:在业务代码里:
import type {} from "@jujiuyey/sag-icons/jsx";
注意:第二种必须写成 import type {}——空 import,纯类型副作用。
7.5 SagIconAttrs 的字符串弱类型
name: IconName | (string & {});
这是 TypeScript 的一个惯用技巧——string & {} 是为了让:
// ✅ 自动补全(IDE 给所有 IconName)
<sag-icon name="..." />
// ✅ 但任意 string 也合法(不编译错)
<sag-icon name="dynamic-from-user-input" />
IconName | (string & {}) 实际就是 string(string & {} 等于 string),但因为联合类型中字面量在前,IDE 的补全会优先显示字面量。
这个技巧叫 autocomplete-friendly union。你可以在任何"想要补全但允许任意字符串"的场景用:
type Status = "pending" | "success" | "error" | (string & {});
其他字段
class?: string;
style?: import("react").CSSProperties;
"aria-label"?: string;
"aria-hidden"?: "true" | "false";
role?: string;
tabIndex?: number;
ref?: unknown;
这些是透传给 DOM 的属性。class / style 让用户能控制激活态和尺寸;aria 属性让用户做无障碍。
ref?: unknown 兼容 React 的 ref:
const iconRef = useRef<HTMLElement>(null);
<sag-icon ref={iconRef} name="workbench" />
7.6 exports 子路径:按需导入
sag-icons 设计上支持按需导入单个图标的字符串:
import { workbench } from "@jujiuyey/sag-icons/data/icons/nav/workbench";
但目前 package.json 的 exports 字段还没完整配置子路径。完整设计应该长这样:
package.json (扩展版)
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./css": "./dist/svg-icon.css",
"./css/svg-icon.css": "./dist/svg-icon.css",
"./data": "./dist/data/index.js",
"./data/*": "./dist/data/*"
}
加了 ./data/* 通配符后,用户能:
// 拿单个图标字符串
import { workbench } from "@jujiuyey/sag-icons/data/icons/nav/workbench";
// 拿所有图标
import { ICON_PATHS, IconName } from "@jujiuyey/sag-icons/data";
// 拿某个类别
import { workbench } from "@jujiuyey/sag-icons/data/icons/nav";
第 8 章会讲怎么把这条子路径配通。
为什么需要按需导入
大部分图标库不支持按需导入——用户引用一个图标,整包都进 bundle。
sag-icons 因为每个图标是独立 .ts 文件,物理上可拆分:
src/data/icons/nav/workbench.ts ← 独立模块
src/data/icons/base/home.ts ← 独立模块
现代打包器(Vite / Webpack 5 / Rspack)能自动 tree-shake。<sag-icon name="workbench"> 不会带 77 个其他图标的字符串进 bundle(前提是用户用 name 字符串而不是直接 import)。
7.7 自动推导 IconName 的替代方案
sag-icons 用手工的 ICON_PATHS = { ... } as const。如果你想自动扫描目录,也可以。
自动扫描方案:用 Node.js fs 在 build 时扫 src/data/icons/<category>/*.ts,自动生成 ICON_PATHS。
scripts/build-icon-paths.mjs
import fs from "node:fs/promises";
import path from "node:path";
const ICONS_DIR = path.resolve("src/data/icons");
const categories = await fs.readdir(ICONS_DIR);
const entries = [];
for (const cat of categories) {
const catDir = path.join(ICONS_DIR, cat);
const files = await fs.readdir(catDir);
for (const file of files) {
if (!file.endsWith(".ts")) continue;
const name = file.replace(/\.ts$/, "");
const camel = name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
entries.push({ name, camel, cat });
}
}
const imports = entries
.map(e => `import { ${e.camel} } from "./icons/${e.cat}/${e.name}";`)
.join("\n");
const pathsObj = entries
.map(e => ` "${e.name}": ${e.camel},`)
.join("\n");
const content = `${imports}
export const ICON_PATHS = {
${pathsObj}
} as const;
export type IconName = keyof typeof ICON_PATHS;
`;
await fs.writeFile("src/data/index.ts", content);
加进 pnpm build:lib:
"build:lib": "node scripts/build-icon-paths.mjs && tsc -p tsconfig.build.json && vite build"
sag-icons 现在没用这套自动扫描,因为 78 个图标手工维护还行。200+ 时可以加上。
7.8 类型系统的边界
name="..." vsname={variable}
// ✅ TS 校验通过
<sag-icon name="workbench" />
// ✅ TS 校验通过(运行时可能不存在)
<sag-icon name={someString} />
// ❌ TS 报错
<sag-icon name="workbench-typo" />
sag-icons 的 attributeChangedCallback 在运行时做"未知 name"的兜底:
const paths = getIconPaths(name);
if (!paths) {
if (import.meta.env?.DEV) {
console.warn(`[sag-icon] Unknown icon name: "${name}"`);
}
this._clearSvg();
return;
}
对应文件:src/web-components/sag-icon.ts:60-69
DEV 模式警告,PROD 模式静默。
跨框架类型
.d.ts 里的 style?: import("react").CSSProperties 是 React 特有的类型。Vue / Solid 用户也能用(只是没有类型提示)。
更通用的写法:
style?: Record<string, string | number>;
但这样没有 React 的具体类型补全。sag-icons 选了 React 优先——因为 React 用户最多。
7.9 这一章的产出
到这里你应该能:
- 解释
as const + keyof typeof 的类型派生
- 解释 JSX 增强
.d.ts 的工作原理
- 解释
name: IconName | (string & {}) 的 autocomplete 技巧
- 知道
exports 子路径怎么设计支持按需
下一章讲构建与发布——从源码到 npm 的完整流水线。
对应文件
| 内容 | 路径 |
|---|
| 类型派生 | src/data/index.ts:97-198 |
| JSX 增强 | src/web-components/jsx.d.ts |
| 类别类型 | src/data/categories.ts:124-126 |
| 未知 name 兜底 | src/web-components/sag-icon.ts:60-69 |