学完这篇你能弄清 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,别踩这个坑。
小结
- 原生标签本身就是组件,组件化演进一直在补“结构、样式、行为”三件事。
- iframe / include 解决页面复用,jQuery 插件解决片段复用,但都没有隔离与生命周期。
- 框架组件强在工程化,弱在跨技术栈复用。
- Web Components 用
customElements + Shadow DOM + template/slot,浏览器原生、无框架依赖。
- 属性走
observedAttributes,事件走 composed: true 的 CustomEvent,初始化一律放 connectedCallback。
- 样式穿透用 CSS 变量、
::part、:host;SSR 用声明式 Shadow DOM。
- 迁移策略是小步替换,不是重写。