第 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;
}

四个步骤:

  1. 取 name 属性:空就清空 SVG
  2. 查图标数据:getIconPaths(name) 拿字符串;查不到就在 DEV 模式警告
  3. 懒创建 SVG 容器:第一次渲染时才建 this._svg,复用后续渲染
  4. 填充 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() 在这种环境下静默跳过,不报错。

幂等

if (registered) return;

模块级变量 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 是个包入口脚本:

  1. import 触发 injectStyles() —— 注入 CSS
  2. 触发 register() —— 注册 Web Component
  3. 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