第 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