第 5 章:Web Component 核心
这一章是 sag-icons 的架构核心。
sag-icons 不是 React 组件、不是 Vue 组件。它是一个自定义元素——<sag-icon>。这是它能一个包跨所有框架的根本原因。
读完这一章你会知道:
- 什么是自定义元素、最小可运行例子
customElements.define 的幂等封装
observedAttributes + attributeChangedCallback 怎么工作
- 为什么 sag-icons 用
innerHTML 而不是手动建 DOM 节点
5.1 自定义元素是什么
Web Components 是浏览器原生支持的"自定义 HTML 标签"机制。它包含三部分:
- Custom Elements:自定义 HTML 元素
- Shadow DOM:隔离的样式和 DOM
- HTML Templates:
<template> 标签
sag-icons 只用 Custom Elements,不上 Shadow DOM。原因:Shadow DOM 会把 CSS 主题系统挡在门外(外部 CSS 进不去),而 CSS 主题系统是 sag-icons 的核心。
最简单的自定义元素:
class HelloWorld extends HTMLElement {
connectedCallback() {
this.textContent = "Hello, world!";
}
}
customElements.define("hello-world", HelloWorld);
<hello-world></hello-world>
<!-- 渲染为: Hello, world! -->
浏览器原生支持,零运行时依赖。
5.2 sag-icons 的最小可运行例子
sag-icons 的核心类:
src/web-components/sag-icon.ts
export class SagIcon extends HTMLElement {
static get observedAttributes(): readonly string[] {
return ["name"];
}
private _svg: SVGSVGElement | null = null;
connectedCallback(): void {
this._render();
}
attributeChangedCallback(
name: string,
oldValue: string | null,
newValue: string | null,
): void {
if (oldValue === newValue) return;
if (name === "name") {
this._render();
}
}
private _render(): void {
const name = this.getAttribute("name");
if (!name) return;
const paths = getIconPaths(name);
if (!paths) return;
if (!this._svg) {
this._svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
this._svg.setAttribute("viewBox", "0 0 24 24");
this._svg.setAttribute("fill", "none");
this._svg.setAttribute("aria-hidden", "true");
this._svg.setAttribute("focusable", "false");
this.appendChild(this._svg);
}
this._svg.innerHTML = paths;
}
}
对应文件:src/web-components/sag-icon.ts
我们一步步拆解。
5.3observedAttributes 声明要监听的属性
static get observedAttributes(): readonly string[] {
return ["name"];
}
自定义元素的属性默认是惰性的——你改了 name,浏览器不会通知你。要监听必须先声明。
observedAttributes 是个静态 getter,返回一组属性名。只要这些属性变化,attributeChangedCallback 就会被调用。
sag-icons 只观察 name——因为这是唯一影响渲染的属性。class、aria-label 这些是浏览器原生处理,自定义元素不需要管。
5.4connectedCallback 第一次挂载
connectedCallback(): void {
this._render();
}
connectedCallback 在元素插入 DOM 时触发。和 React 的 useEffect(() => {}, []) 类似,但只跑一次。
sag-icons 在这里调 _render(),第一次渲染 SVG 内容。
为什么不在构造函数(constructor)里渲染?因为:
- 构造时元素还没插入 DOM,某些操作(如
this.appendChild)会失败或报警告
connectedCallback 是规范明确指定的"挂载完成"时机
5.5attributeChangedCallback 属性变化
attributeChangedCallback(
name: string,
oldValue: string | null,
newValue: string | null,
): void {
if (oldValue === newValue) return;
if (name === "name") {
this._render();
}
}
任何在 observedAttributes 里的属性变化都会触发这个回调。三个参数:
name:哪个属性变了
oldValue:原值
newValue:新值
if (oldValue === newValue) return 是为了跳过首次插入(浏览器会触发一次"初始化"回调)。
应用场景:
<!-- 第一次渲染 name="home" -->
<sag-icon name="home"></sag-icon>
<!-- 用户切换了 name,attributeChangedCallback 触发 -->
<sag-icon name="settings"></sag-icon>
注意:直接改 element.name = "x" 不会触发。要改 attribute 必须用 setAttribute。
5.6_render() 的核心逻辑
private _render(): void {
const name = this.getAttribute("name");
if (!name) {
this._clearSvg();
return;
}
const paths = getIconPaths(name);
if (!paths) {
if (import.meta.env?.DEV) {
console.warn(`[sag-icon] Unknown icon name: "${name}"`);
}
this._clearSvg();
return;
}
if (!this._svg) {
this._svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
this._svg.setAttribute("viewBox", "0 0 24 24");
this._svg.setAttribute("fill", "none");
this._svg.setAttribute("aria-hidden", "true");
this._svg.setAttribute("focusable", "false");
this.appendChild(this._svg);
}
this._svg.innerHTML = paths;
}
四个步骤:
- 取
name 属性:空就清空 SVG
- 查图标数据:
getIconPaths(name) 拿字符串;查不到就在 DEV 模式警告
- 懒创建 SVG 容器:第一次渲染时才建
this._svg,复用后续渲染
- 填充 innerHTML:直接把字符串塞进 SVG
这里有几个有意的设计:
为什么用createElementNS
document.createElementNS("http://www.w3.org/2000/svg", "svg");
SVG 元素必须在 SVG 命名空间下创建。用 document.createElement("svg") 在某些浏览器里会出问题——创建出的元素不被识别为 SVG 子树。
为什么用innerHTML
this._svg.innerHTML = paths;
而不是 document.createElement 一个个节点拼起来?
因为 图标内容是字符串,整个塞进 innerHTML 是最快的做法。paths 里可能是 5 个 path、也可能是 20 个,手动建节点非常啰嗦。
innerHTML 的代价是 每次重新解析 HTML。但 sag-icons 的属性变化不频繁(用户切路由才换图标),这个开销可以接受。
为什么 SVG 属性写死
this._svg.setAttribute("viewBox", "0 0 24 24");
this._svg.setAttribute("fill", "none");
this._svg.setAttribute("aria-hidden", "true");
this._svg.setAttribute("focusable", "false");
viewBox="0 0 24 24":所有图标统一坐标系
fill="none":默认不填,由 path 自己的 fill 决定(currentColor 或 #fff)
aria-hidden="true":默认对屏幕阅读器隐藏(图标本身没有语义)
focusable="false":IE / Edge 旧版默认 <svg> 可聚焦,禁止它
aria-hidden="true" 是默认设置——用户想要语义化就传 aria-label:
<sag-icon name="trash" aria-label="删除"></sag-icon>
(sag-icons 内部把 aria-label 透传到 SVG 上,详见 jsx.d.ts)
5.7 register.ts:幂等封装
src/web-components/register.ts
import { SagIcon } from "./sag-icon";
let registered = false;
export function register(tagName: string = "sag-icon"): void {
if (registered) return;
if (typeof window === "undefined" || typeof customElements === "undefined") {
return;
}
if (customElements.get(tagName)) {
registered = true;
return;
}
customElements.define(tagName, SagIcon);
registered = true;
}
export { SagIcon } from "./sag-icon";
对应文件:src/web-components/register.ts
为什么不直接 customElements.define(...)?因为:
SSR 安全
if (typeof window === "undefined" || typeof customElements === "undefined") {
return;
}
Next.js / Nuxt 的 SSR 环境下,window 和 customElements 都不存在。直接 customElements.define 会抛 ReferenceError。
register() 在这种环境下静默跳过,不报错。
幂等
模块级变量 registered 保证 register() 多次调用只注册一次。
为什么需要幂等?因为:
- 用户可能在主入口 import 一次
- 在某些特殊场景又调一次
register()
- 不能因为调两次就抛
DOMException: this name has already been used
自定义 tag name(可选)
export function register(tagName: string = "sag-icon"): void;
允许用户传自定义标签名——比如团队的内部命名规范强制 si- 前缀:
import { register } from "@jujiuyey/sag-icons";
register("si-icon");
之后用 <si-icon name="workbench" />。这是高级功能,一般用户用不上。
5.8 自动注册:index.ts
src/index.ts
import { svgIconCss } from "./css/svg-icon";
import { register } from "./web-components/register";
const STYLE_ID = "sag-icons-styles";
function injectStyles(): void {
if (typeof document === "undefined") return;
if (document.getElementById(STYLE_ID)) return;
const styleEl = document.createElement("style");
styleEl.id = STYLE_ID;
styleEl.textContent = svgIconCss;
document.head.appendChild(styleEl);
}
injectStyles();
register();
export { SagIcon, register } from "./web-components/register";
export type { IconName } from "./data";
export type { SagIconAttrs } from "./web-components/jsx";
export { ICON_PATHS, ICON_NAMES, getIconPaths } from "./data";
export { svgIconCss } from "./css/svg-icon";
对应文件:src/index.ts
src/index.ts 是个包入口脚本:
import 触发 injectStyles() —— 注入 CSS
- 触发
register() —— 注册 Web Component
export 把核心 API 暴露出去
用户只要:
import "@jujiuyey/sag-icons";
就够了。副作用就是注册,没有显式 SagIcon.use() 或 mount() 之类。
这是 sag-icons 易用性的关键——一行 import 就能用。
5.9 这一章的产出
到这里你应该能:
- 解释自定义元素的工作机制
- 读懂
sag-icon.ts 整个类的逻辑
- 解释
register() 的 SSR 安全 + 幂等封装
- 理解为什么 sag-icons 不用 React / Vue 组件
下一章讲 sag-icons 的另一个核心——CSS 多色调系统。这是 is-active 类切换彩色态背后的机制。
对应文件
| 内容 | 路径 |
|---|
| 自定义元素 | src/web-components/sag-icon.ts |
| 注册逻辑 | src/web-components/register.ts |
| 入口 | src/index.ts |
| JSX 类型 | src/web-components/jsx.d.ts |