组件库 = 一批用
customElements.define()注册的自定义元素,内部封装 DOM / 交互 / 样式, 逻辑全部来自核心库 core。产物是标准 Web Component —— 在 Lit、Vue、React、Angular、原生 HTML 里都能直接写<my-x>,无需跑任何前端框架。与页面内组件(components.md)的区别:页面组件服务于「一个应用」; 组件库组件服务于「多个应用 / 多个框架」,因此契约更严、实现更克制。
| 项 | 组件库(wc) | 页面内组件(page) |
|---|---|---|
| 消费方 | 多项目、多框架,非本项目也能用 | 仅本应用 |
| 契约 | 语义属性 / DOM 事件 / slot / CSS 自定义属性,稳定 | 可随应用演进 |
| 业务逻辑 | ❌ 一律来自 core,组件只做接线 | 可在页面 Service 中 |
| 样式 | Shadow DOM 强隔离,主题走 CSS 变量 | 可宽松 |
| 依赖 | core + lit(仅 LitElement/html/css) |
core + 页面 services/ |
fmode-code-agent(core,纯 ESM)
▲ 只 import 纯逻辑
<designer-chat>(wc,LitElement,仅 DOM/交互/样式)
▲ 任意框架直接 <designer-chat>
页面(page:Lit / Vue / React / 原生 HTML …)
static properties + customElements.define()。组件库要让任何无构建页面直接跑,装饰器会让源码 SyntaxError。services/、routes/、业务 model;外部世界只通过属性和事件进入。fetch:网络由宿主注入(见第五节)或由 core 的协议函数描述、宿主执行。document.head 样式;主题只通过 CSS 自定义属性 + ::part() 暴露。<fm-chat>、<fm-icon>),避免与其他库撞名(撞名 → define 抛错)。customElements.get() 判空 —— 允许同一页面被加载两次(多入口 / 宿主重复 import)。// src/chat/designer-chat.js
import { LitElement, html, css } from 'lit';
import { AgentMachine } from 'fmode-code-agent'; // ← 核心库(core),纯 ESM
export class DesignerChat extends LitElement {
/** 对外契约:语义属性 */
static properties = {
prompt: { type: String }, // 宿主可传 / attribute 可设
disabled: { type: Boolean, reflect: true },
_state: { state: true }, // 内部:core 的状态映射
_messages: { state: true }, // 内部:渲染用
};
static styles = css`
:host { display: block; --dc-accent: #2b6cb0; } /* 主题变量,可被外部覆盖 */
button { background: var(--dc-accent); }
.log { max-height: 240px; overflow: auto; }
`;
#machine = null;
#unsubscribe = null;
constructor() {
super();
this.prompt = '';
this.disabled = false;
this._state = 'idle';
this._messages = [];
}
/** 宿主注入 I/O(transport),组件层不写死 fetch */
set transport(t) { this.#machine = new AgentMachine({ transport: t }); }
connectedCallback() {
super.connectedCallback();
if (!this.#machine) {
// 未注入 transport 时给一个 fallback,避免 use 时崩
this.#machine = new AgentMachine({ transport: { call: async ({ text }) => ({ text: `(echo) ${text}` }) } });
}
this.#unsubscribe = this.#machine.subscribe((s) => { this._state = s; });
this._state = this.#machine.state;
}
disconnectedCallback() {
this.#unsubscribe?.();
this.#unsubscribe = null;
super.disconnectedCallback();
}
render() {
return html`
<form @submit=${this.#onSubmit}>
<input .value=${this.prompt} @input=${(e) => { this.prompt = e.target.value; }}
?disabled=${this.disabled || this._state !== 'idle'} placeholder="描述你想要的图片…" />
<button ?disabled=${this.disabled || this._state !== 'idle'}>${this._state === 'idle' ? '生成' : '生成中…'}</button>
</form>
<div class="log" part="log">
${this._messages.map((m) => html`<p>${m}</p>`)}
</div>
`;
}
async #onSubmit(e) {
e.preventDefault();
const text = this.prompt.trim();
if (!text) return;
this._messages = [...this._messages, `> ${text}`];
/** 子 → 父:语义事件,composed 穿透 Shadow DOM */
this.dispatchEvent(new CustomEvent('dc-submit', { detail: { prompt: text }, bubbles: true, composed: true }));
try {
const result = await this.#machine.send(text);
this._messages = [...this._messages, result?.text ?? ''];
this.dispatchEvent(new CustomEvent('dc-done', { detail: { result }, bubbles: true, composed: true }));
} catch (err) {
this.dispatchEvent(new CustomEvent('dc-error', { detail: { code: err.code, message: err.message }, bubbles: true, composed: true }));
}
}
}
if (!customElements.get('designer-chat')) customElements.define('designer-chat', DesignerChat);
要点:
prompt / disabled / transport / dc-* 事件;_state / _messages 是内部渲染状态。static styles 全在 Shadow DOM;外部换主题只改 --dc-accent、::part(log)。machine.subscribe),组件不自造状态机。# 存机器实例,不挂在公开属性上。| 手段 | 用法 | 规则 |
|---|---|---|
| 属性(attribute) | <designer-chat disabled> |
只用字符串可表达的语义(disabled、placeholder、size="lg");布尔用 ?attr 或 presence |
| 属性(property) | 框架里 el.prompt = '…'、Lit 里 .prompt=${x} |
复杂对象(回调、配置)走 property;Lit/原生里要用 .prop= 绑定,别指望 attribute 传对象 |
| 事件 | el.addEventListener('dc-done', fn) |
kebab-case 事件名;detail 为对象;bubbles: true, composed: true(穿透 Shadow DOM);组件库事件统一前缀避免撞车 |
| Slot | 宿主投影内容 | <slot name="header"> / <slot>;不用 .innerHTML |
| CSS 自定义属性 | --dc-accent: red 作用在宿主或其祖先 |
主题的唯一入口 |
::part() |
designer-chat::part(log) { ... } |
需要暴露的内部结构加 part="…" |
| 方法(可选) | el.focus()、el.reset() |
只给「命令式交互」,不暴露内部状态改写入 |
::part 是唯一对外样式钩子)。组件库不知道宿主的网络、登录、路由、存储。两种注入方式:
A. 宿主直接传实例(最通用,跨框架 OK)
<script type="module">
import { AgentMachine } from 'fmode-code-agent';
const el = document.querySelector('designer-chat');
el.transport = {
call: async ({ text }) => {
const res = await fetch('/api/agent', { method: 'POST', body: JSON.stringify({ text }) });
return res.json();
},
};
</script>
B. Lit 页面里用 Context(仅 Lit 宿主) —— 见 context-di.md。组件库内部用 ContextConsumer 兜底读注入的实例,读不到时用属性。
原则:组件库只定义 shape,不拥有实例。这也让组件在 Node / SSR 里并不被实例化,逻辑仍留在 core。
组件的唯一运行要求:浏览器能执行 ESM + customElements。下图是同一份 dist 在四个宿主里的用法:
<!-- ① 原生 HTML(无任何框架) -->
<designer-chat></designer-chat>
<script type="module" src="/npm/designer-chat/v1.0.0/index.js"></script>
<!-- ② Lit 页面(本仓页面层):先 import 触发注册,再在模板里使用 + 监听事件 -->
<script type="module">import './components.js';</script>
<designer-chat .prompt=${q} @dc-done=${(e) => console.log(e.detail)}></designer-chat>
// ③ React 19:自定义元素已原生支持(prop 走 attribute/property 自动映射)
<designer-chat ref={ref} prompt={q} onDcDone={onDone}></designer-chat>
// ④ React 18-:需包装层(ref + addEventListener),或复用 @lit/react 的 createComponent
<!-- ④ Vue 3:.vue 里把标签加进 compilerOptions.isCustomElement,或 app.config.compilerOptions.isCustomElement -->
<designer-chat :prompt="q" @dc-done="onDone" />
<!-- Vue 3 对自定义元素:attribute 透传;复杂属性建议 ref + el.transport = ... -->
| 宿主 | 属性绑定 | 事件 | 注意事项 |
|---|---|---|---|
| 原生 / Lit | .prop=(Lit) / el.prop = |
addEventListener / @event |
Lit 必须 . 前缀绑定复杂值 |
| Vue 3 | :prop(复杂值透传为 property) |
@kebab-event |
配置 isCustomElement(构建型 Vue) |
| React 19+ | prop={...}(已映射到 property) |
onEvent={...}(dc-done → onDcDone) |
事件名注意大小写映射 |
| React 18- | 需 wrapper(createComponent) |
ref + addEventListener |
低版本 React 会把未知 prop 当 attribute |
验收口诀:组件库必须在一个非 Lit 宿主(纯 HTML 或 Vue)里也能跑,否则就不是跨框架组件库。
:host { --dc-accent: #2b6cb0 },外部可在祖先或宿主上覆盖;::part():part="log" 暴露需要精细调整的节点;slots:允许宿主替换整块内容(如标题、空状态)。document.adoptedStyleSheets 注入全局样式;!important 向外压制;依赖宿主 CSS 类名。theme.css),宿主 <link> 引入即可,变量名统一前缀(--fm-)。组件库与 core 一样:无构建、源码即产物,托管到 FMODE Repose,用 importmap 引入。
/npm/designer-chat/v1.0.0/
index.js # 入口:import 所有组件并注册(或单组件入口)
designer-chat.js # 单组件
theme.css # 可选:设计令牌
{ "imports": {
"designer-chat": "/npm/designer-chat/v1.0.0/index.js",
"fmode-code-agent": "/npm/fmode-code-agent/v2.0.0/src/index.js"
} }
index.js 只做 import + 注册:副作用仅「注册自定义元素」,无渲染、无请求。lit,由宿主的公共 importmap 解析 → 全局单实例 lit(否则重复注册 / 多版本冲突,见 pitfalls.md 第三节)。| 层 | 怎么做 |
|---|---|
| core 逻辑 | node --test(不依赖 DOM,见 core-library.md 第四节) |
| 组件契约 | 浏览器里断言:customElements.get('x-y') 存在;改属性 → 渲染变化;点按钮 → 事件带 detail 且 composed |
| 跨框架 | 同一页面用纯 HTML 与 Lit 各挂一次;两个入口渲染结果一致 |
| 样式隔离 | 宿主页全局 p { color: red } 不影响组件内部;--dc-accent 能改主题 |
| 幂等 | 同名脚本 import 两次不报 define 冲突 |
| 无装饰器 | node --check 全过;grep 无 @property 等 |
el.setMessages()),只暴露语义属性 + 语义事件composed: true → 穿过 Shadow DOM 后宿主收不到<chat> 与别的库撞车)customElements.define 不判重 → 二次加载 NotSupportedError