SKILL.md 13 KB


name: "fmode-code-lit" description: "用 Lit 原生 ESM 无构建方式写前端(零编译/零打包/浏览器直跑)。触发词:lit 项目、lit 组件、WebComponent、Web Component 组件库、纯 ESM 核心库、跨框架组件、无构建前端、原生 ESM、importmap、@lit-labs/signals、@lit-labs/context、多标签 KeepAlive、FMODE OBS+CDN 前端部署。适用场景:(1) 新建 lit 无构建项目并生成目录/importmap/骨架 (2) 写 LitElement 组件/Service/路由 (3) 把逻辑层抽成纯 ESM 核心库(core,零浏览器依赖、Node 可测)或把交互层做成跨框架 Web Component 组件库(wc) (4) 给已有项目补齐前端开发规则 (.claude/rules) (5) 部署到 OBS+CDN 并配 History 回退。产出遵循 fmode-code-lit 规则仓的分层与命名规范。" version: "1.0.0" license: "MPL-2.0" author: "Fmode (未来飞马)"

keywords: [lit, 项目, lit, 组件, WebComponent, Web, Component, 组件库, 纯, ESM, 核心库, 跨框架组件]

fmode-code-lit — Lit 原生 ESM 无构建前端

Overview

教 AI 用 无构建 / 无打包 / 纯原生 ESM + Lit 3.x 的方式开发前端:源码即产物,浏览器直接跑, 依赖走 FMODE Repose 统一托管,业务码走 OBS + CDN。分层(组件 / 服务 / 工具)、 @lit-labs/signals 响应式、@lit/context 依赖注入、自写 utils/router.js History 路由(子路径部署禁用 @lit-labs/router)、后台多标签 KeepAlive。

完整规则见 fmode-code-lit 规则仓(本技能随仓发布)。 本技能负责「初始化 + 生成代码」,规则仓负责「规范细则」。

何时触发

用户出现以下意图时启用:

  • 「新建 / 初始化一个 lit 项目」「写一个 lit 组件」「无构建 前端」「原生 ESM + importmap」
  • 「WebComponent / Shadow DOM 组件」「signals 响应式」「context 依赖注入」
  • 「后台多标签 / KeepAlive」「History 干净路由」
  • 「把这套 前端开发规则 给项目装上」「部署到 OBS+CDN」

三条铁律(生成任何代码前先记住)

  1. 源码即产物:不引入 rollup/esbuild/vite/webpack。写好的 .js 直接上传。
  2. 依赖不落地项目:lit、@lit/*、@lit-labs/* 由 importmap 指向 FMODE Repose,禁止把依赖拷进业务仓。
  3. 分层不越界:组件→服务→工具。组件禁 fetch/复杂业务;服务禁 DOM。

两个方向:页面 & 库(同一套铁律)

[core] 纯 ESM 核心库   零浏览器依赖、Node 可测 — 跨前端框架最底层
   ▲ import
[wc]   组件库(Lit 实现的 Web Component)   仅浏览器交互、零业务 — <my-x> 跨框架直接用
   ▲ import
[page] Lit 无构建页面   组合 core 与 wc,持有路由/布局/服务

core 不认识 DOM,wc 不认识业务,page 组装一切。

需求 写在哪 生成命令 规则篇
逻辑要在 Node + 多框架复用 lib/core/ new.mjs core <name> references/rules/core-library.md
UI 要跨框架(Lit/Vue/React/原生) lib/wc/ new.mjs wc <name> references/rules/web-component-library.md
只做本应用的页面 src/routes/ new.mjs page <name> references/rules/components.md
  • core 铁律:不 import lit、不碰 window/document/fetch;now/id/transport 等不确定输入注入;node --test 可测。
  • wc 铁律:必须用 Lit 框架特性写(extends LitElement + html + css,禁手写原生 wc:extends HTMLElement / attachShadow() / 手写 observedAttributes);逻辑来自 core;网络由宿主注入 transport;只暴露语义属性 + composed 事件 + slot + CSS 变量;标签加统一前缀;customElements.define 判重。 > Lit 本身构建在 Web Component 标准之上,产物即标准自定义元素 → 「用 Lit 写」与「跨框架」不冲突。

⚠️ 无构建环境的硬约束(最容易踩)

禁用一切装饰器语法:@customElement / @property / @state / @consume / @provide / @query。 装饰器是 TS/Babel 编译期特性,浏览器原生 JS 不执行,照抄 Lit 官方「装饰器版」文档会直接 SyntaxError。

一律改用标准静态字段 + 编程式 API:

装饰器版(❌) 无构建版(✅)
@customElement('x-y') customElements.define('x-y', XY)
@property({type:String}) name static properties = { name: { type: String } }
@state() kw='' static properties = { kw: { type: String, state: true } }
@consume({context}) new ContextConsumer(this, { context, subscribe, callback })
@provide({context}) 模板里 provide(ctx, value) 指令

用法

1) 初始化一个新 lit 无构建项目

# 在目标目录生成标准骨架(index.html + importmap + 分层目录 + 示例组件)
node <skill_dir>/scripts/scaffold.mjs ./my-admin --project my-admin

生成结构:

my-admin/
  src/
    index.html            # <base> + importmap + 入口
    importmap.json        # 本地 importmap(生产用 Repose 公共 importmap)
    app.js                # provide + 路由 + 根组件
    components/  routes/  services/  utils/
    .claude/rules/        # 前端规则(从规则仓同步)
  package.json            # 仅类型提示,不打包

2) 给已有项目装上规则

node <skill_dir>/scripts/install-rules.mjs .      # 写入 ./.claude/rules/ + CLAUDE.md 片段

3) 生成单个组件 / 服务 / 核心库模块 / 组件库组件

node <skill_dir>/scripts/new.mjs component user-card      # 页内通用组件
node <skill_dir>/scripts/new.mjs service auth-service     # 页面服务
node <skill_dir>/scripts/new.mjs page page-order-list     # 页面
node <skill_dir>/scripts/new.mjs core pricing             # 核心库模块(纯 ESM,零 DOM)
node <skill_dir>/scripts/new.mjs wc quote-calc            # Web Component(跨框架组件库)

4) 部署

bash scripts/deploy.sh <project>   # obsutil 同步 src/ → OBS → CDN 刷新

关键约定速查

项 约定
组件文件 kebab-case.js,类 PascalCase,标签含连字符
页面 page-*.js,标签 <page-*>
服务 *-service.js,类 *Service,单例 export const xService = new XService()
核心库(core) lib/core/*.js,纯函数/类,无 window/document/fetch,不 import lit;入口 index.js 单一出口
组件库(wc) lib/wc/*.js,extends LitElement(Lit 实现,禁原生 wc),customElements.define(判重),标签带统一前缀;逻辑来自 core
相对导入 必须带 .js 后缀
全局状态 @lit-labs/signals 的 signal + SignalWatcher
组件本地状态 static properties(state: true)
依赖注入 @lit/context 的 provide() + ContextConsumer(非 @lit-labs/context,已废弃)
路由 自写 utils/router.js,History 干净 URL(子路径部署禁用 @lit-labs/router)
子目录部署 必须 <base href="/<project>/">

依赖版本(2026-10 实测可用)

lit@3.3.3  lit-html@3.3.3  lit-element@4.2.2  @lit/reactive-element@2.1.2
@lit/context@1.1.6  @lit-labs/signals@0.3.0  signal-polyfill@0.2.2

注意:@lit-labs/signals 依赖 signal-polyfill,importmap 必须映射它(入口 dist/index.js)。 @lit-labs/context 已废弃 → 用 @lit/context。

部署要点

  • 依赖 → FMODE Repose(按版本目录,.js 必须 Content-Type: text/javascript,CORS 放行业务域名,开 HTTP/2)。
  • 业务码 → obsutil sync src/ obs://<bucket>/<project>/(public-read),再刷 CDN。
  • CDN History 回退:不存在路径返回 index.html 且状态码 200,禁 302(否则地址栏改写、OAuth 回调失败、路由丢失)。
  • <base href> 与部署前缀一致;index.html 设 no-cache。

常见坑(详见规则仓 references/rules/pitfalls.md)

装饰器 / CORS / MIME 非 JS / lit 多版本混用 / CDN 302 / base href 缺失 / 相对 import 漏 .js / signal 与 property 混用 / 老浏览器无 Import Map / 循环依赖。

多页面开发铁律(light DOM 五条死线)

来源:一次多页看板首次交付 6 页全部错乱的复盘(2026-10-29) 一句话:createRenderRoot(){return this} 不是「让 Tailwind 生效的小开关」,是渲染模型切换。切完以后 <slot>、:host、::slotted、样式隔离全部失效,但代码看起来和普通 Lit 一样、编辑器也不报错。

死线1:light DOM 下禁用 <slot>

没有 shadow root 就没有投影。<slot> 退化成普通未知元素,assignedSlot 为 null,子节点原封不动滞留在宿主元素上——外壳和内容成了兄弟节点,谁也没包住谁,于是滚动区空、导航错、高度链断。 自检一行:document.querySelector('main').children.length,为 0 就是投影断了。

死线2:外壳接管路由渲染,不靠投影传内容

页面元素由外壳自己按路由产出,让它天然成为 <main> 的子节点,父子关系由渲染函数保证:

// app-shell.js
_renderRoute() {
  const { name, params } = parseRoute(this.path);
  switch (name) {
    case 'dashboard': return html`<page-dashboard></page-dashboard>`;
    case 'project-detail': return html`<page-project-detail .code=${params.code||''}></page-project-detail>`;
  }
}
// render 里:<main ...><div class="page-enter key-route-${this.active}">${this._renderRoute()}</div></main>

app-root 退化成只负责挂载:render() { return html\` }`。

死线3:真需传内容的组件(卡片),用 capture/project 手动搬

light DOM 下只能自己搬节点。现成实现见 references/light-slot.js。顺序不能错:

  • capture(host) 在 connectedCallback 中、渲染前 调用,把 childNodes 存到 host.__lightIncoming
  • project(host) 在 updated() 中、渲染后 调用,把暂存的节点塞进 [data-light-slot="body"] 等容器
  • 必须靠 dataset.projected 做幂等,否则每次重渲染都重复搬、节点来回跳
  • 渲染后再扫宿主子节点是致命的:会把组件自己刚生成的内容当成「外部传入」搬走,越搬越乱

死线4:自定义元素必须显式 display: block; min-width: 0

自定义元素是未知元素,浏览器默认按 inline 处理:不吃 height/width(h-full/flex-1 全失效)、不形成块级盒子(内部 flex/grid 失去参照)、相邻元素间出现空白间隙。

app-shell, page-dashboard, page-analysis, data-table, kpi-card, empty-state, ... {
  display: block; min-width: 0;   /* min-width:0 让 flex 子项可收缩,否则长表格撑破父容器 */
}

两个例外必须排除:section-card、chart-box 的 class 自带 flex h-full flex-col,写死 display: block 会覆盖掉、把高度链断掉。

死线5:图表容器用 height:100% + min-height,不用 flex:1

chart-box { height: 100%; min-height: 260px; }   /* 父级无确定高度时 flex:1 算出来是 0,ECharts 拿到 0 高度直接不画 */

交付前自检清单

  1. 先跑通最小路径再铺量:外壳 + 一个空白页 + 一次路由跳转,确认 DOM 父子关系正确,再往里填 6 个页面。规模越大定位成本越高。
  2. node scripts/check-elements.mjs 自动校验所有自定义元素都声明了 display —— 自动检查比文档写三遍有用。
  3. 长模板用 node --check 检查语法:少一个闭标签,lit-html 会把后面所有兄弟节点当成它的子节点吞掉,整页塌陷,且症状和渲染模型错误混在一起,排查方向严重跑偏。
  4. 症状≠根因:“样式全乱”先去查 DOM 结构,别一上来翻 Tailwind 配置(这次查错方向错了两轮)。
  5. 多页症状不一致时先找共同根因:各页症状不同通常是同一个总根因的不同投影,不是 n 个独立 bug。最严重的那页反而最好的线索。

同批高频白屏(非 Lit 问题,但一起记)

  • 子路径白屏:index.html 用绝对路径引资源,部署到 /dev/XXX/ 子路径全 404。用 __BASE__ 占位 + document.write 注入。
  • 改配置后白屏:dev-server 进程还服务旧配置。改配置必须重启,别信热更新。

诚实失败

生成前先探测目标目录;已存在文件不覆盖(除非 --force)。若依赖版本无法从 Repose 解析, 明确报错并给出配置指引,不生成一个跑不起来的骨架。