# 核心库规范(core:纯 ESM、零浏览器依赖) > 本仓从「只会写页面」升级为**双方向**:既能写页面(page),也能写 **Web Component 组件库(wc)** > 与 **跨前端框架的核心库(core)**。三分层总览见 [architecture.md](./architecture.md),组件库见 > [web-component-library.md](./web-component-library.md),页面层仍见 [components.md](./components.md)。 > > **核心库 = 一个普通 npm ESM 包**:不含 Lit、不碰 DOM、可在 Node 里直接 `import` 与单测。 > 浏览器、Node、小程序、SSR、Web Worker 都能跑同一份代码;Web Component 组件库与页面层都**向下依赖**它。 ``` [core] 纯 ESM 逻辑库(本文件) ← 无 DOM / 无 Lit / 无 window / 无 fetch 硬编码 ↑ import [wc] 组件库(Web Component) ← 唯一的浏览器交互层 ↑ import [page] 页面(Lit 无构建) ← 组合 wc 与页内组件 ``` ## 一、铁律(违反即返工) 1. **零浏览器依赖**:`core/` 下任何文件都不得触碰 `window` / `document` / `navigator` / `localStorage` / `fetch` / `HTMLElement` / `CustomEvent` / `import.meta.url` 拼 DOM 路径。**唯一例外**:`node:` 内置模块(Node 可跑)。 2. **不 import Lit**:`import ... from 'lit'` / `lit-html` / `@lit/*` 一律禁止 —— 一旦 import Lit 就绑定了浏览器渲染。需响应式时用 `@lit-labs/signals`(`signal`/`computed`),它本身是纯 JS,可跑在 Node。 3. **只 export 纯逻辑**:纯函数、纯 Class、状态机、校验、格式化、DTO 解析、业务计算。**不 export 单例状态**(每次 `new` 或由调用方注入),不 export「全局 store」。 4. **副作用零**:模块被 `import` 时**不得**执行任何副作用(注册监听、读写存储、发请求、打印)。模块顶层只允许**声明**。 5. **Node 可测**:`core/` 的每个文件都必须能在 Node 里 `import` 并跑通测试;测试**不依赖 jsdom**。 6. **依赖方向单向**:`core` **不认识** `wc` / `page`;`wc`、`page` 认识 `core`。 > 判定口诀:**`node --input-type=module -e "import('./core/index.js')"` 能跑、`grep` 不到 DOM 全局,才是 core。** ## 二、目录规范 ``` / # 一个独立的 ESM 包(可跨项目) src/ index.js # 公共出口(只在这里 export 对外 API) / # 按领域分文件/子目录,如 workflow/、pricing/ *.js internal/ # 内部实现(不对外 export,可随意重构) test/ *.test.js # node:test 单测(不依赖 DOM) package.json # type: module,exports 映射 ``` - **`src/index.js` 是唯一公共契约**;内部实现对 `internal/` 隐藏,外部只能 import 包名(见第五节 exports)。 - **一个文件一个关注点**:`validate.js`(校验)、`machine.js`(状态机)、`format.js`(格式)。 - 命名:小写 kebab-case 文件;`PascalCase` 类;`camelCase` 函数。 ## 三、核心库的「好的形状」 | 该做 | 不该做 | |------|--------| | `export function validate(input, rules)` —— 纯函数,给什么算什么都由参数决定 | 读 `document.querySelector` 拿「当前表单」 | | `export class TaskMachine { constructor(config) {} next(event) {} }` —— 状态机,可 `new` 多份 | 模块级 `let store = {}` 饱含状态 | | 接收**注入**的 I/O(`fetchImpl`、`now`、`random`、`telemetry`) | 直接 `fetch()` / `Date.now()`(不可测) | | `export const parseConfig = (text) => {...}` | 模块顶层 `localStorage.getItem(...)` | ### 可注入的 I/O(让测试确定化) ```js // src/pricing/quote.js —— 纯计算,所有不确定输入都从参数注入 export function quote(items, { taxRate = 0.06, now = () => Date.now(), id = () => crypto.randomUUID() } = {}) { const subtotal = items.reduce((s, it) => s + it.price * it.qty, 0); const tax = Math.round(subtotal * taxRate * 100) / 100; return { id: id(), at: now(), subtotal, tax, total: Math.round((subtotal + tax) * 100) / 100 }; } ``` - `now` / `random` / `id` / `fetchImpl` 都可注入 → 测试断言**确定值**,不靠 mock 全局。 - 需要网络时:core 只定义**纯协议**(URL 拼装、DTO 解析),真正的 `fetch` 由 wc/page 层调用后传入结果。 ## 四、Node 可测(node:test,不依赖 jsdom) ```js // test/quote.test.js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { quote } from '../src/index.js'; test('含税总价四舍五入到分', () => { const r = quote([{ price: 19.99, qty: 3 }], { taxRate: 0.06, now: () => 0, id: () => 't1' }); assert.equal(r.subtotal, 59.97); assert.equal(r.total, 63.57); }); ``` 运行:`node --test test/` - **`package.json` 里 `"type": "module"`**,`node --test`/`node --check` 原生识别 ESM。 - 测试命名 `*.test.js`,与源码同构目录。 - **禁止**在 core 测试里引 jsdom / happy-dom —— 需要 DOM 的代码就不该在 core 里。 - CI 一条命令:`node --check`(语法)+ `node --test`(逻辑)。 ## 五、无构建打包为单文件(发布给浏览器用) > 铁律是「**源码不进构建流程**」—— 业务仓 / 页面仓不打包。**发布库**时若确需单文件(如嵌入第三方页面), > 走**独立发布流水线**、产物归档到 FMODE Repose,源码仍是单文件 ESM。**平时优先「无打包」,不要为了发布而给业务仓加 rollup。** ### 5.1 首选:保持多文件 ESM(零打包) `core/` 本身就是浏览器原生 ESM 可直接 import 的。只要**相对 import 带 `.js`**,`