iter(g217): 补 frame 内核冒烟并修复 effect 注销
This commit is contained in:
@@ -27,7 +27,7 @@
|
|||||||
- `apps/demo`(@ui-arch/demo):交互演示,产物 apps/demo/dist/demo.html
|
- `apps/demo`(@ui-arch/demo):交互演示,产物 apps/demo/dist/demo.html
|
||||||
- `apps/docs`:文档站,纯产物(ua docs 生成)
|
- `apps/docs`:文档站,纯产物(ua docs 生成)
|
||||||
|
|
||||||
**门禁一条龙**:`npm run check` = build + demo + style + typecheck + smoke + index + verify + lint-iterations + audit:visual,提交前必过。`ua audit:visual` 统计皮肤包里五类硬编码视觉字面量(字号 / 动效时长 / 阴影 / 圆角 / 焦点环)并与 `.ua/visual-baseline.json` 做棘轮:任一类超过基线即退出码 1 并指出疑似引入的提交;迁移脚本 `npm run visual:migrate` 可把字面量幂等改写成令牌。`ua verify` 校验清单 ↔ 文档 ↔ 演示一致(docs.components 的 id 必须在 api.exports 有对应条目、props 必须带 n/t/d、每个文档字段名必须在组件实现源码里真实出现(改名后忘改文档会直接报错)、反向也校验 `src/types.ts` 里 `<组件>Props`(可用清单的 `propsType` 覆盖接口名,`false` 表示无入参)每个字段都在文档 props 表登记(新加字段忘了写文档会报错)、demo 锚点必须在 `apps/demo/main.ts` 中存在,且该组件必须被 demo 真实调用 `id(` / `id.`),检查皮肤包样式表无硬编码颜色(`#hex` 一律禁止,必须走 `--ua-*` 令牌)与令牌引用有效(`var(--ua-*)` 必须是 tokens.css 已定义或组件自设的变量)、核对每个 `--ua-color-*` 都在 `[data-theme='dark']` 里有覆盖值且暗色块没有凭空多出的变量名(新增颜色令牌漏写暗色值直接报错)、核对 `tokens.json` 的 `color` 键与 tokens.css 的 `--ua-color-*` 同名同值(名字按 camelCase → kebab 折算,亮色值与暗色值都比,双向查漏——只改一处时文档色板会凭空多/少一个令牌,过去只能靠人眼发现),核对 README 的 `<!-- ua:components -->` 区块与「分组:」行都覆盖清单组件集合(漏写/多写/重复都报错),并交叉校验 backlog 的 done 条目与 `.ua/iterations.log` 的 done 留痕一一对应(另外查 backlog id 不重复、g 编号严格递增、同一 id 只有一条 done 留痕,且留痕 ts 带 +08:00、时间线不回退、不落在未来——都是过去手工插条目/手写时间戳踩过的坑);配方还要求编号连续、每条都有代码块与要点,且代码块里至少认出一个已注册组件 id(拼错组件名时 `ua recipes` 会把它列成空组件表)。
|
**门禁一条龙**:`npm run check` = build + demo + style + typecheck + smoke + index + verify + lint-iterations + audit:visual,提交前必过。其中 `smoke` 是两段内核断言:`@ui-arch/frame` 14 条(信号读写与同值短路、effect 注销与条件分支重追踪、computed 链式派生、多订阅者扇出)+ `@ui-arch/table-core` 21 条(排序 / 筛选 / 分页 / 选择的派生管线);改内核语义前先跑它,别靠组件 demo 肉眼验。`ua audit:visual` 统计皮肤包里五类硬编码视觉字面量(字号 / 动效时长 / 阴影 / 圆角 / 焦点环)并与 `.ua/visual-baseline.json` 做棘轮:任一类超过基线即退出码 1 并指出疑似引入的提交;迁移脚本 `npm run visual:migrate` 可把字面量幂等改写成令牌。`ua verify` 校验清单 ↔ 文档 ↔ 演示一致(docs.components 的 id 必须在 api.exports 有对应条目、props 必须带 n/t/d、每个文档字段名必须在组件实现源码里真实出现(改名后忘改文档会直接报错)、反向也校验 `src/types.ts` 里 `<组件>Props`(可用清单的 `propsType` 覆盖接口名,`false` 表示无入参)每个字段都在文档 props 表登记(新加字段忘了写文档会报错)、demo 锚点必须在 `apps/demo/main.ts` 中存在,且该组件必须被 demo 真实调用 `id(` / `id.`),检查皮肤包样式表无硬编码颜色(`#hex` 一律禁止,必须走 `--ua-*` 令牌)与令牌引用有效(`var(--ua-*)` 必须是 tokens.css 已定义或组件自设的变量)、核对每个 `--ua-color-*` 都在 `[data-theme='dark']` 里有覆盖值且暗色块没有凭空多出的变量名(新增颜色令牌漏写暗色值直接报错)、核对 `tokens.json` 的 `color` 键与 tokens.css 的 `--ua-color-*` 同名同值(名字按 camelCase → kebab 折算,亮色值与暗色值都比,双向查漏——只改一处时文档色板会凭空多/少一个令牌,过去只能靠人眼发现),核对 README 的 `<!-- ua:components -->` 区块与「分组:」行都覆盖清单组件集合(漏写/多写/重复都报错),并交叉校验 backlog 的 done 条目与 `.ua/iterations.log` 的 done 留痕一一对应(另外查 backlog id 不重复、g 编号严格递增、同一 id 只有一条 done 留痕,且留痕 ts 带 +08:00、时间线不回退、不落在未来——都是过去手工插条目/手写时间戳踩过的坑);配方还要求编号连续、每条都有代码块与要点,且代码块里至少认出一个已注册组件 id(拼错组件名时 `ua recipes` 会把它列成空组件表)。
|
||||||
|
|
||||||
`ua verify` 另外校验 `ITERATION.md` 的《视觉回归抽查清单》:button / textField / select / modal / dataTable / toast 六段各 ≥3 条可勾选要点、每条维度标签必须落在边框 / 阴影 / 过渡 / 焦点环 / 暗色五类内、合计 ≥18 条且五维度全覆盖——视觉类条目收尾前照单过一遍,改清单时别把条目删漏。
|
`ua verify` 另外校验 `ITERATION.md` 的《视觉回归抽查清单》:button / textField / select / modal / dataTable / toast 六段各 ≥3 条可勾选要点、每条维度标签必须落在边框 / 阴影 / 过渡 / 焦点环 / 暗色五类内、合计 ≥18 条且五维度全覆盖——视觉类条目收尾前照单过一遍,改清单时别把条目删漏。
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p packages/core/table-core/tsconfig.json && tsc -p packages/core/frame/tsconfig.json && tsc -p packages/skin/ui/tsconfig.json",
|
"build": "tsc -p packages/core/table-core/tsconfig.json && tsc -p packages/core/frame/tsconfig.json && tsc -p packages/skin/ui/tsconfig.json",
|
||||||
"typecheck": "tsc -p packages/core/table-core/tsconfig.json --noEmit && tsc -p packages/core/frame/tsconfig.json --noEmit && tsc -p packages/skin/ui/tsconfig.json --noEmit && tsc -p apps/demo/tsconfig.json",
|
"typecheck": "tsc -p packages/core/table-core/tsconfig.json --noEmit && tsc -p packages/core/frame/tsconfig.json --noEmit && tsc -p packages/skin/ui/tsconfig.json --noEmit && tsc -p apps/demo/tsconfig.json",
|
||||||
"smoke": "node packages/core/table-core/scripts/smoke.mjs",
|
"smoke": "node packages/core/frame/scripts/smoke.mjs && node packages/core/table-core/scripts/smoke.mjs",
|
||||||
"demo": "node apps/demo/build.mjs",
|
"demo": "node apps/demo/build.mjs",
|
||||||
"style": "node packages/tools/cli/bin/ua.js style",
|
"style": "node packages/tools/cli/bin/ua.js style",
|
||||||
"index": "node packages/tools/cli/bin/ua.js index",
|
"index": "node packages/tools/cli/bin/ua.js index",
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/*** 作用:@ui-arch/frame 内核冒烟——信号 / 副作用 / 计算值的语义断言(纯 JS,无需 DOM;跑 npm run smoke 执行) ***/
|
||||||
|
import { computed, effect, signal } from '../dist/signal.js';
|
||||||
|
|
||||||
|
let pass = 0;
|
||||||
|
let fail = 0;
|
||||||
|
|
||||||
|
/*** 断言:失败也继续跑完,最后统一给退出码(否则第一个红就看不到后面) ***/
|
||||||
|
const ok = (label, cond) => {
|
||||||
|
if (cond) {
|
||||||
|
pass += 1;
|
||||||
|
console.log(`ok - ${label}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fail += 1;
|
||||||
|
console.error(`NOT OK - ${label}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// —— 1. 信号的读写与同值短路 ——
|
||||||
|
const count = signal(0);
|
||||||
|
let hits = 0;
|
||||||
|
effect(() => {
|
||||||
|
count();
|
||||||
|
hits += 1;
|
||||||
|
});
|
||||||
|
ok('effect 创建时立即执行一次', hits === 1);
|
||||||
|
count(1);
|
||||||
|
ok('写值后重跑', hits === 2);
|
||||||
|
count(1);
|
||||||
|
ok('写入相同值不触发(Object.is 短路)', hits === 2);
|
||||||
|
|
||||||
|
// —— 2. 注销后不再重跑 ——
|
||||||
|
const live = signal('a');
|
||||||
|
let liveHits = 0;
|
||||||
|
const dispose = effect(() => {
|
||||||
|
live();
|
||||||
|
liveHits += 1;
|
||||||
|
});
|
||||||
|
live('b');
|
||||||
|
ok('注销前正常重跑', liveHits === 2);
|
||||||
|
dispose();
|
||||||
|
live('c');
|
||||||
|
ok('注销后写值不再重跑(dispose 必须真的摘订阅)', liveHits === 2);
|
||||||
|
|
||||||
|
// —— 3. 条件分支重追踪:上一轮读过、这一轮不读的信号不该再唤它 ——
|
||||||
|
const flag = signal(true);
|
||||||
|
const left = signal(0);
|
||||||
|
const right = signal(0);
|
||||||
|
let branchHits = 0;
|
||||||
|
effect(() => {
|
||||||
|
branchHits += 1;
|
||||||
|
if (flag()) left();
|
||||||
|
else right();
|
||||||
|
});
|
||||||
|
const afterInit = branchHits;
|
||||||
|
flag(false);
|
||||||
|
ok('切分支本身触发一次重跑', branchHits === afterInit + 1);
|
||||||
|
const afterSwitch = branchHits;
|
||||||
|
left(9);
|
||||||
|
ok('切走后旧的依赖不再唤它(重追踪要清旧订阅)', branchHits === afterSwitch);
|
||||||
|
right(9);
|
||||||
|
ok('切走后新依赖仍唤它', branchHits === afterSwitch + 1);
|
||||||
|
|
||||||
|
// —— 4. computed:派生、链式、同值短路 ——
|
||||||
|
const price = signal(10);
|
||||||
|
const qty = signal(2);
|
||||||
|
const total = computed(() => price() * qty());
|
||||||
|
ok('computed 首次读即得派生值', total() === 20);
|
||||||
|
price(3);
|
||||||
|
ok('源变化后 computed 立即跟着变', total() === 6);
|
||||||
|
let derivedHits = 0;
|
||||||
|
effect(() => {
|
||||||
|
total();
|
||||||
|
derivedHits += 1;
|
||||||
|
});
|
||||||
|
const derivedBase = derivedHits;
|
||||||
|
qty(3);
|
||||||
|
ok('computed 变更会唤上层 effect(链式依赖成立)', derivedHits === derivedBase + 1);
|
||||||
|
ok('链式计算值正确', total() === 9);
|
||||||
|
|
||||||
|
// —— 5. 多订阅者:扇出与互不影响 ——
|
||||||
|
const fan = signal(0);
|
||||||
|
let a = 0;
|
||||||
|
let b = 0;
|
||||||
|
const disposeA = effect(() => {
|
||||||
|
fan();
|
||||||
|
a += 1;
|
||||||
|
});
|
||||||
|
effect(() => {
|
||||||
|
fan();
|
||||||
|
b += 1;
|
||||||
|
});
|
||||||
|
fan(1);
|
||||||
|
ok('一次写值唤全部订阅者', a === 2 && b === 2);
|
||||||
|
disposeA();
|
||||||
|
fan(2);
|
||||||
|
ok('注销一个订阅者不影响另一个', a === 2 && b === 3);
|
||||||
|
|
||||||
|
console.log(`\n${fail === 0 ? 'SMOKE OK' : 'SMOKE FAILED'}(${pass} 通过 / ${fail} 失败)`);
|
||||||
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
// 当前正在收集依赖的 effect;null 表示非追踪态
|
// 当前正在收集依赖的 effect;null 表示非追踪态
|
||||||
let active: (() => void) | null = null;
|
let active: (() => void) | null = null;
|
||||||
|
// 当前 effect 已订阅到的「信号订阅集」集合:重跑前据此清旧订阅,注销时据此摘干净
|
||||||
|
let activeDeps: Set<Set<() => void>> | null = null;
|
||||||
|
|
||||||
/*** 响应式信号:无参调用读值并收集依赖,传参写值并触发订阅 ***/
|
/*** 响应式信号:无参调用读值并收集依赖,传参写值并触发订阅 ***/
|
||||||
export interface Signal<T> {
|
export interface Signal<T> {
|
||||||
@@ -13,7 +15,10 @@ export function signal<T>(initial: T): Signal<T> {
|
|||||||
const subs = new Set<() => void>();
|
const subs = new Set<() => void>();
|
||||||
const read = (...args: unknown[]) => {
|
const read = (...args: unknown[]) => {
|
||||||
if (args.length === 0) {
|
if (args.length === 0) {
|
||||||
if (active) subs.add(active); // 依赖收集
|
if (active) {
|
||||||
|
subs.add(active); // 依赖收集
|
||||||
|
activeDeps?.add(subs); // 同时记下「我订阅了哪个集」,注销时才能摘干净
|
||||||
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
const v = args[0] as T;
|
const v = args[0] as T;
|
||||||
@@ -24,20 +29,30 @@ export function signal<T>(initial: T): Signal<T> {
|
|||||||
return read as Signal<T>;
|
return read as Signal<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*** 副作用:立即执行一次并追踪内部读取的信号,依赖变化自动重跑 ***/
|
/*** 副作用:立即执行一次并追踪内部读取的信号,依赖变化自动重跑;返回注销函数 ***/
|
||||||
export function effect(fn: () => void): () => void {
|
export function effect(fn: () => void): () => void {
|
||||||
|
const deps = new Set<Set<() => void>>(); // 本 effect 订阅到的所有信号订阅集
|
||||||
|
let stopped = false; // 注销标志:注销后即使还在某个订阅集里也不再执行
|
||||||
const run = () => {
|
const run = () => {
|
||||||
|
if (stopped) return; // 卫语句:注销后不再跑
|
||||||
|
for (const subs of deps) subs.delete(run); // 清上一轮订阅:条件分支换掉后的旧依赖不该再唤它
|
||||||
|
deps.clear();
|
||||||
const prev = active;
|
const prev = active;
|
||||||
|
const prevDeps = activeDeps;
|
||||||
active = run;
|
active = run;
|
||||||
|
activeDeps = deps;
|
||||||
try {
|
try {
|
||||||
fn();
|
fn();
|
||||||
} finally {
|
} finally {
|
||||||
active = prev;
|
active = prev;
|
||||||
|
activeDeps = prevDeps;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
run();
|
run();
|
||||||
return () => {
|
return () => {
|
||||||
/* 演示级实现:不做依赖清理 */
|
stopped = true;
|
||||||
|
for (const subs of deps) subs.delete(run); // 真摘订阅:否则卸载后的组件仍会被信号唤醒
|
||||||
|
deps.clear();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user