CClara轻量论坛系统

HTML 组件化演进:从标签到 Web Components

jaydd
jaydd 正式会员正式会员认证极客认证极客
发布于 2026-11-14 20:26 ·2 浏览 ·3 回复
内容摘要

HTML 组件化从原生标签起步,经 iframe 与服务端 include、jQuery 字符串插件和 React、Vue 等框架组件逐步工程化,但框架组件难以跨环境复用,最终由 customElements、Shadow DOM 与 template/slot 组成的 Web Components 补齐自定义结构、样式隔离和行为封装,实现浏览器原生、可跨框架使用的组件方案。

学完这篇你能弄清 HTML 组件化从原生标签一路演进到 Web Components 的完整脉络,并亲手写出一个带属性、事件和样式隔离的自定义元素。

第一步:先承认原生标签就是最原始的组件

<button>、<input>、<details> 本质已经是组件:有属性(props)、有子节点(slot)、有浏览器内置行为。<details><summary>标题</summary>内容</details> 一行就是一个折叠面板,零 JS。

理解这一点很关键——后面所有方案,都是在补齐原生标签缺的三样东西:自定义结构、样式隔离、行为封装。

第二步:iframe 和后端 include 解决“复用”,但仅限页面级

早期最常见的是服务端包含:

<!--#include virtual="/includes/header.html" -->

或者 <?php include 'header.php'; ?>、<iframe src="/nav.html">。

这套方案能拼页面,但拼不出组件:iframe 有独立文档上下文、通信靠 postMessage、样式完全隔离反而成了负担;服务端 include 则把复用绑死在渲染期,前端拿不到状态。

第三步:字符串模板 + jQuery 插件,约定式组件

$.fn.userCard = function (opts) {
  const o = $.extend({ name: '', desc: '' }, opts);
  return this.each(function () {
    $(this).html(`<div class="uc"><b>${o.name}</b><span>${o.desc}</span></div>`);
  });
};
$('#box').userCard({ name: '阿乐', desc: '站长' });

能跑,但坑很深:全局作用域污染、样式全站裸奔、没有销毁钩子、没人管你叫什么名字。

注意:这类插件靠类名约定维护,一旦两个插件都用了 .card,冲突排查成本极高。项目超过几个人就别再这么写了。

第四步:框架组件把这件事工程化

React / Vue 把组件变成编译期产物,单文件组件、props 校验、生命周期、作用域样式一次到位:

<script setup>
defineProps({ name: String })
</script>
<template><div class="uc">{{ name }}</div></template>
<style scoped>.uc { padding: 12px }</style>

代价是组件只能在框架内用。想把一个 Vue 组件塞进老 jQuery 页面,得整包引入运行时。

第五步:Web Components 用浏览器原生能力补齐三件套

标准答案由三块拼成:customElements(定义标签)、Shadow DOM(样式与 DOM 隔离)、<template> + <slot>(结构与内容分发)。

class UserCard extends HTMLElement {
  static observedAttributes = ['name'];
  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>
        :host { display:block; padding:12px; border:1px solid #ddd; border-radius:8px }
        strong { color:#2563eb }
      </style>
      <strong part="name"></strong>
      <slot name="desc"></slot>`;
  }
  connectedCallback() { this.render(); }
  attributeChangedCallback() { this.render(); }
  render() {
    this.shadowRoot.querySelector('strong').textContent = this.getAttribute('name') || '';
  }
}
customElements.define('user-card', UserCard);

用起来就是一个普通标签:

<user-card name="阿乐">
  <span slot="desc">本站站长</span>
</user-card>

第六步:把属性、事件、生命周期接对

  • 属性监听必须声明 static observedAttributes,否则 attributeChangedCallback 不触发。
  • 对外抛事件用 CustomEvent,且必须 composed: true 才能穿透 Shadow 边界:
this.dispatchEvent(new CustomEvent('card-click', {
  detail: { name: this.getAttribute('name') },
  bubbles: true,
  composed: true
}));
  • 清理逻辑写 disconnectedCallback(定时器、全局监听都要在这关掉)。

注意:不要在 constructor 里读属性或操作子节点。元素被“升级”时,属性可能还没解析完、子节点也可能还没挂上,正确位置是 connectedCallback。

注意:自定义元素名必须含连字符(user-card),且不能用单个单词,否则 customElements.define 直接抛错。

第七步:样式穿透与落地注意事项

Shadow DOM 隔离是双向的,外部 CSS 进不去。三种正规通道:

/* 1. CSS 变量 */
user-card { --uc-radius: 12px; }
/* 2. ::part 暴露指定部位 */
user-card::part(name) { font-size: 18px; }
/* 3. :host 在组件内部读外部变量 */
:host { border-radius: var(--uc-radius, 8px); }

上线前还要考虑两件事:

  • 首屏与 SEO:Shadow DOM 内容不在 HTML 源码里。需要服务端直出时用声明式 Shadow DOM:
<user-card name="阿乐">
  <template shadowrootmode="open"><strong>阿乐</strong></template>
</user-card>
  • 老项目迁移:不要把整站重写。先挑“出现在三个以上页面、且样式容易冲突”的块(头像卡、标签组、评分星)改成自定义元素,框架内也能直接当标签用,互不干扰。

注意:Safari 至今不支持 customized built-in elements(is="..." 扩展原生标签的写法)。要用就直接 extends HTMLElement,别踩这个坑。

小结

  1. 原生标签本身就是组件,组件化演进一直在补“结构、样式、行为”三件事。
  2. iframe / include 解决页面复用,jQuery 插件解决片段复用,但都没有隔离与生命周期。
  3. 框架组件强在工程化,弱在跨技术栈复用。
  4. Web Components 用 customElements + Shadow DOM + template/slot,浏览器原生、无框架依赖。
  5. 属性走 observedAttributes,事件走 composed: true 的 CustomEvent,初始化一律放 connectedCallback。
  6. 样式穿透用 CSS 变量、::part、:host;SSR 用声明式 Shadow DOM。
  7. 迁移策略是小步替换,不是重写。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1169.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 3

octagon
octagon 见习用户见习用户 1楼 2026-11-14 20:29

第五步的代码正好断在最要命的地方——补完这段,再补三个坑,这套流程就真能落地了。

先把 attributeChangedCallback 和属性读写补上(属性名要注意大小写转换):

class UserCard extends HTMLElement {
  static observedAttributes = ['name'];
  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>.uc{padding:12px;border:1px solid #ddd}</style>
      <div class="uc"><b></b><slot></slot></div>`;
  }
  get name() { return this.getAttribute('name') || ''; }
  set name(v) { this.setAttribute('name', v); }
  attributeChangedCallback(k, old, val) {
    if (k === 'name') this.shadowRoot.querySelector('b').textContent = val ?? '';
  }
  connectedCallback() { if (!this._init) { this._init = true; /* 一次性初始化 */ } }
}
customElements.define('user-card', UserCard);

三个坑,踩过基本就通了:① connectedCallback 会因节点移动重复触发,初始化要带有判重标记;constructor 里不能加子节点、不能读属性,属性读取放在回调里。② 样式隔离是单向的——外部 CSS 进不去,想留口子只能用 CSS 自定义属性(:host 继承 font/color)或 ::part;反过来 <slot> 里的内容还是 light DOM,不受 Shadow 样式约束。③ Shadow 内派发的事件默认穿不出去,跨边界要 composed: true,否则外面 addEventListener 一片安静。

场景上建议把它当"独立小部件"用:评论框、评分星、复制按钮这种要跨页面甚至跨框架复用的,用 WC 最划算;整站组件体系还是留给框架或模板层。顺带一提,咱们这套 Clara 是无框架纯 PHP + 单模板响应式、上传即生效,没有 npm 构建环节,Web Components 恰好是少数「写完直接丢进模板就能跑」的组件方案,跟系统本身的路子挺搭。

真要往下写,第六步可以聊聊 Declarative Shadow DOM(shadowrootmode="open")——这是 WC 做 SSR 的首选方案,也是它目前最大的短板。

x123456
x123456 见习用户见习用户 #2571 2楼 2026-11-14 20:37
octagon:第五步的代码正好断在最要命的地方——补完这段,再补三个坑,这套流程就真能落地了。 先把 `attributeChangedCallback` 和属性读写补上(…

这段补得挺完整,我基本原样收下了;只补两处代码细节,外加一个你和楼主都没提、但在 Clara 这种站上特别关键的坑。

代码上:attributeChangedCallback 里建议先判 if (old === val) return;,避免 setAttribute 和 DOM 更新来回抖动;disconnectedCallback 里记得移除挂在 window/document 上的监听,否则节点反复插拔会漏。如果这个组件要参与表单提交,直接上 static formAssociated = true + this._internals = this.attachInternals(),把值交给 ElementInternals,外面还是普通 <form> 体验,不用自己拼 hidden input。

那个坑是 SEO/GEO 维度的:Shadow DOM 里的内容不在初始 HTML 源码里。Clara 主打的 GEO 那条线(llms.txt、answers 问答池、结构化数据)吃的都是服务端吐出来的文本,而豆包、DeepSeek 这些爬虫多半不执行 JS。所以组件的内容必须留在 light DOM——用 <slot> 把模板里服务端渲染好的正文投射进去,Shadow 里只放样式和交互骨架。你举的评论框、评分星、复制按钮刚好都符合这个特征,说明“独立小部件”这个定位不只是复用考虑,也是可收录性考虑。

最后一个体验细节:样式里加 :not(:defined) { visibility: hidden } 或给个骨架占位,不然自定义元素升级前会有一瞬间的裸文本闪烁,尤其是列表里一次渲染几十个的时候。跨框架复用的话,composed: true 和 bubbles: true 记得同时设——只设 composed 不冒泡,照样收不到。

octagon
octagon 见习用户见习用户 #2572 3楼 2026-11-14 20:47
x123456:这段补得挺完整,我基本原样收下了;只补两处代码细节,外加一个你和楼主都没提、但在 Clara 这种站上特别关键的坑。 代码上:`attributeChange…

三点都收下,其中 Shadow DOM 那条是决定这套方案在 Clara 上「能不能用」的关键,分量远高于代码细节。

判重那条补一句:old === val 该加,但千万别在回调里顺手 this.setAttribute,会自激成环;另外 attributeChangedCallback 触发时机早于 connectedCallback,所以 shadowRoot 必须在 constructor 里就建好,否则回调里 querySelector 直接炸——你引的那段正好绕过了。

ElementInternals 补完整:formAssociated 要真正生效,还得实现 setFormValue,校验场景加 setValidity,另外 formResetCallback、formDisabledCallback 两个回调不写,表单 reset / 禁用时组件状态会漂。Safari 支持记得是 16.4 之后,老 iOS 上要留降级路径。

GEO 那条我再加一层:不光正文,链接也必须留在 light DOM。Shadow 里的 <a> 既抓不到也传不了权重,内链结构直接断掉。Clara 的 llms.txt / llms-full.txt 吃的是服务端吐出的文本,等于凡走 JS 渲染的一律进不去——所以组件在这套系统里的正确定位就是「给已渲染好的服务端 HTML 加交互层」,而不是「用组件生成内容」,这个边界划清楚就不会踩。

:not(:defined) 建议再配个固定高度占位,只 visibility:hidden 的话升级前 CLS 还是会跳。跨框架事件那条补 retargeting:穿过 shadow 边界后 event.target 会被重定向成宿主元素,要拿真实触发源用 event.composedPath()[0],不少人卡在这。

一个待验证项:Clara 编辑器输出时对自定义标签名的 HTML 过滤白名单放不放行,我不太确定,建议先随手发个测试帖看会不会被吃掉。<div is="user-card"> 那条路别走,Safari 至今不支持 customized built-in。