You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
86 lines
3.5 KiB
86 lines
3.5 KiB
/**
|
|
* 可点击控件审计:找出「看起来能点、其实没有处理函数」的 Pressable / Touchable*。
|
|
*
|
|
* 背景:本项目把「不留假入口」当红线(点下去零反馈的控件会把开发中的状态摆给用户看)。
|
|
* 与 `check-routes.mjs` 的关系:那个查「页面能不能到达」,这个查「按钮点下去有没有反应」。
|
|
*
|
|
* 判定:控件标签内既没有 onPress / onPressIn / onLongPress / onPressOut,
|
|
* 也没有 `disabled`(禁用态是有意的——视觉上应当置灰)与属性展开({...props})。
|
|
*
|
|
* 豁免:标签所在行或上一行写了 `affordance-ok` 注释的跳过 —— 底部弹层里
|
|
* `onPress={() => {}}` 是**有意**用来吞掉冒泡的(不吞就会点内容区也关掉弹层),
|
|
* 与「忘了接线」是两回事,靠注释把两者区分开。
|
|
*
|
|
* 用法:node scripts/check-affordances.mjs # 列出全部
|
|
* node scripts/check-affordances.mjs --error # 有命中则 exit 1(可用于 CI)
|
|
*/
|
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
const ROOTS = ['app', 'components'];
|
|
const HANDLER_PROPS = ['onPress', 'onPressIn', 'onPressOut', 'onLongPress', 'disabled', '{...'];
|
|
|
|
/** 有意为之的控件(吞冒泡)在行内或上一行标这个,避免与「忘了接线」混在一起 */
|
|
const EXEMPT_MARKER = 'affordance-ok';
|
|
|
|
/** 从 `<Tag` 的起始下标起,找到该开标签的结束 `>`(粗略忽略字符串内的 `>`,对本仓库足够) */
|
|
function findTagEnd(text, from) {
|
|
let depth = 0;
|
|
for (let i = from; i < text.length; i += 1) {
|
|
const ch = text[i];
|
|
if (ch === '{') depth += 1;
|
|
else if (ch === '}') depth -= 1;
|
|
else if (ch === '>' && depth === 0) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function walk(dir, out = []) {
|
|
for (const entry of readdirSync(dir)) {
|
|
const full = join(dir, entry);
|
|
if (statSync(full).isDirectory()) walk(full, out);
|
|
else if (/\.tsx$/.test(entry)) out.push(full);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const TAG_RE = /<(Pressable|TouchableOpacity|TouchableHighlight|TouchableWithoutFeedback)\b/g;
|
|
|
|
/** 空处理函数:绑了 onPress 但什么都不做,点下去同样零反馈 */
|
|
const EMPTY_HANDLER_RE = /onPress=\{\s*\(\s*\)\s*=>\s*(\{\s*\}|undefined|null)\s*\}/;
|
|
|
|
const findings = [];
|
|
for (const root of ROOTS) {
|
|
for (const file of walk(root)) {
|
|
const text = readFileSync(file, 'utf8');
|
|
const lines = text.split('\n');
|
|
for (const match of text.matchAll(TAG_RE)) {
|
|
const end = findTagEnd(text, match.index);
|
|
if (end < 0) continue;
|
|
const tag = text.slice(match.index, end + 1);
|
|
const empty = EMPTY_HANDLER_RE.test(tag);
|
|
if (!empty && HANDLER_PROPS.some((prop) => tag.includes(prop))) continue;
|
|
const line = text.slice(0, match.index).split('\n').length;
|
|
// 豁免标记写在标签行或其上一行(弹层外的说明注释通常写在上一行)
|
|
if (lines[line - 1].includes(EXEMPT_MARKER) || (lines[line - 2] ?? '').includes(EXEMPT_MARKER)) {
|
|
continue;
|
|
}
|
|
findings.push({
|
|
file,
|
|
line,
|
|
reason: empty ? '空处理函数' : '无处理函数',
|
|
tag: lines[line - 1].trim(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (findings.length === 0) {
|
|
console.log('✅ 未发现无处理函数的可点击控件');
|
|
} else {
|
|
console.log(`=== 点下去没有反应的可点击控件 (${findings.length}) ===`);
|
|
for (const f of findings) {
|
|
console.log(`${f.file}:${f.line} [${f.reason}] ${f.tag}`);
|
|
}
|
|
if (process.argv.includes('--error')) process.exitCode = 1;
|
|
}
|
|
|