Files
sundynix-pets/pets-fe/scripts/check.js
T
Blizzard 30b190363e fix(fe): 修弹层编译不过(链首被删),检查器补上 wx:if 链校验
## Bug
上一批把 9 个记录分支从弹层整体搬到 addrecord 页时,连带删掉了带 wx:if 的
第一支(weight),整条链从 wx:elif 开头。小程序直接报

  Bad attr `wx:elif` with message: `wx:if not found, then something must be wrong`

整个组件编译不过 —— 不是少个样式,是弹层完全打不开。把现在的链首
(就医前摘要)改成 wx:if。

## 检查器补这一条
这种错「删对了 9 支、漏改 1 个属性」就会发生,而且是编译期爆炸,
检查器原来完全看不见。

判断必须靠真正的标签栈。中间走错两次,都值得写下来:

**① 按缩进深度分链 —— 不行。** 这个项目里 scroll-view 和它的子节点缩进
是一样深的,「同层」判断会把父节点当兄弟节点。而且更糟的是它不报错:
前一条链的 wx:if 被当成这一条的链首,注入错误也查不出来,是个假绿。

**② 给 void 标签列白名单 —— 不行。** wxml 里 <image></image> 是显式闭合的,
把它当 void 就变成「开标签不入栈、闭标签却弹栈」,一路把父级弹光,8 个误报。
只认 /> 就够。

**③ 自闭合不能用单独的捕获组。** 属性那段是贪婪的,会把 /> 里的 / 一起吃掉,
(\/?) 永远匹配空。多行写的 <input ... /> 因此被当成非自闭合入了栈,
把整份栈层级带偏,7 个误报。改成从 attrs 尾部 /\/\s*$/ 判断。

## 自测
把链首改回 wx:elif → 精确报第 96 行那一处,其余 24 个 wxml 零误报;
还原 → 恢复绿。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:49:23 +08:00

170 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 用法:node scripts/check.js(在 pets-fe 目录下跑,或直接给全路径)
//
// 静态自查:class 有没有人定义、而且是不是「在能生效的地方」定义、pt-icon 的 name
// 是否存在、绑的事件方法是否存在、组件是否已注册。
//
// 防的是「漏改一处 → 元素完全裸奔」这种不报错、不在 diff 里、只在特定页面/弹层才暴露
// 的问题。这个项目已经踩过两次同一个坑:.post-card 留在 community.wxss 却被用户主页
// 用(帖子没样式)、.profile-row 留在 settings.wxss 却被「我的」用(入口整个散架)。
// 所以 class 检查是按作用域来的,不是全局并一起看——那样恰好检查不出这类问题。
const fs = require('fs');
const path = require('path');
const FE = path.resolve(__dirname, '..');
const walk = (d, out = []) => {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, e.name);
if (e.isDirectory()) { if (!['node_modules', '.git'].includes(e.name)) walk(p, out); }
else out.push(p);
}
return out;
};
const files = walk(FE);
const read = (p) => fs.readFileSync(p, 'utf8');
const rel = (p) => path.relative(FE, p);
// ── 一个 wxss 里定义的 class,跟着 @import 一起收 ────────────────────────────
function classesOf(wxss, seen = new Set()) {
const out = new Set();
if (!fs.existsSync(wxss) || seen.has(wxss)) return out;
seen.add(wxss);
const src = read(wxss);
for (const m of src.matchAll(/\.([a-zA-Z][\w-]*)/g)) out.add(m[1]);
for (const m of src.matchAll(/@import\s+["']([^"']+)["']/g)) {
for (const c of classesOf(path.resolve(path.dirname(wxss), m[1]), seen)) out.add(c);
}
return out;
}
// ── 作用域 ─────────────────────────────────────────────────────────────────
// app.wxss 是全局的;页面 wxss 只对自己那一页生效;组件 wxss 只对组件自己的节点生效。
// 页面之间互不可见——这正是前面两次事故的成因。
const GLOBAL = classesOf(path.join(FE, 'app.wxss'));
// 谁定义了这个 class,报错时好指路
const whereDefined = new Map();
for (const f of files.filter((f) => f.endsWith('.wxss'))) {
for (const c of classesOf(f)) {
if (!whereDefined.has(c)) whereDefined.set(c, []);
whereDefined.get(c).push(rel(f));
}
}
const iconNames = new Set();
for (const m of read(path.join(FE, 'styles/iconfont.wxss')).matchAll(/\.pt-i-([\w-]+)\{/g)) iconNames.add(m[1]);
const problems = [];
for (const f of files.filter((f) => f.endsWith('.wxml'))) {
const src = read(f);
const r = rel(f);
const isComponent = /^(components|custom-tab-bar)\//.test(r);
// 这个 wxml 能看到的 class:自己那个同名 wxss,加上全局(组件要开了 addGlobalClass 才有)
const own = classesOf(f.replace(/\.wxml$/, '.wxss'));
const js = f.replace(/\.wxml$/, '.js');
const seesGlobal = !isComponent ||
(fs.existsSync(js) && read(js).includes('addGlobalClass'));
const scope = seesGlobal ? new Set([...own, ...GLOBAL]) : own;
for (const m of src.matchAll(/class="([^"]*)"/g)) {
// 先把 {{...}} 整段挖掉(里面是表达式不是 class),再看剩下的静态部分;
// 表达式里的三元字面量 'active' 单独收集
const dyn = [...m[1].matchAll(/\{\{([^}]*)\}\}/g)].map((x) => x[1]).join(' ');
const statics = m[1].replace(/\{\{[^}]*\}\}/g, ' ');
const names = statics.split(/\s+/).filter(Boolean);
// 只认三元表达式里的字面量(? 'x' : 'y');wxs 函数的字符串入参不是 class
for (const q of dyn.matchAll(/[?:]\s*'([\w-]+)'/g)) names.push(q[1]);
// 纯布局壳,没样式是故意的
const LAYOUT_ONLY = new Set(['greeting', 'tab-text', 'aiPlan']);
for (const raw of names) {
if (LAYOUT_ONLY.has(raw)) continue;
// class="row-{{item.role}}" 挖掉表达式后剩个 row-,是拼接前缀不是类名
if (raw.endsWith('-')) continue;
if (scope.has(raw)) continue;
const at = whereDefined.get(raw);
if (at && at.length) {
// 最坑的一种:class 有定义,但定义在别的页面/组件里,这一页拿不到,元素裸奔
problems.push(`${r}: class "${raw}" 只定义在 ${at.join(' / ')} —— 页面级 wxss 跨不了页,这里是裸的。两页共用就提到 app.wxss`);
} else {
problems.push(`${r}: class "${raw}" 没有任何 wxss 定义`);
}
}
}
// wx:if / wx:elif / wx:else 链的第一支必须是 wx:if。
// 删掉链首那一支(比如把 9 个记录分支整体搬走)会让整条链从 wx:elif 开头,
// 小程序报「wx:if not found」直接编译不过——整个组件一起挂,不是少个样式。
//
// 判断必须靠真正的标签栈,不能靠缩进:这个项目里 scroll-view 和它的子节点
// 缩进是一样深的,按缩进判「同层」会把父节点当成兄弟节点,什么都查不出来。
{
const noComment = src.replace(/<!--[\s\S]*?-->/g, '');
const stack = [{ last: null }]; // 每层记「上一个兄弟节点带的条件指令」
const re = /<(\/?)([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g;
let m;
while ((m = re.exec(noComment))) {
const [, closing, tag, rawAttrs] = m;
// 自闭合要从 attrs 尾部判断:属性那段是贪婪的,会把 /> 里的 / 一起吃掉,
// 单独用一个 (\/?) 捕获组永远是空的。多行写的 <input ... /> 就是这么
// 被当成非自闭合、入了栈,把整份栈的层级全带偏,满屏误报。
const selfClose = /\/\s*$/.test(rawAttrs);
const attrs = rawAttrs.replace(/\/\s*$/, '');
const top = stack[stack.length - 1];
if (closing) {
if (stack.length > 1) stack.pop();
continue;
}
const cond = /\bwx:if\b/.test(attrs) ? 'if'
: /\bwx:elif\b/.test(attrs) ? 'elif'
: /\bwx:else\b/.test(attrs) ? 'else' : '';
if (cond === 'elif' || cond === 'else') {
if (top.last !== 'if' && top.last !== 'elif') {
const line = noComment.slice(0, m.index).split('\n').length;
problems.push(`${r}: 第 ${line} 行附近 <${tag} wx:${cond}> 前面没有 wx:if` +
` —— 链首被删了,这个文件编译不过`);
}
}
// 没带条件的兄弟节点会把链断开,所以无条件覆盖
top.last = cond || null;
// 不搞「void 标签」白名单:wxml 里 <image></image> 是显式闭合的,
// 把它当 void 就会「开标签不入栈、闭标签却弹栈」,一路把父级弹光,
// 于是满屏误报。只认 /> 自闭合,其余一律入栈。
if (!selfClose) stack.push({ last: null });
}
}
// 事件绑定的方法必须真的存在——绑了个不存在的方法,点了没反应且不报错
if (fs.existsSync(js)) {
const code = read(js);
const handlers = new Set();
for (const m of src.matchAll(/\b(?:bind|catch)(?::)?[a-z]+="([A-Za-z_$][\w$]*)"/g)) handlers.add(m[1]);
for (const h of handlers) {
if (!new RegExp(`(^|[\\s,{])${h}\\s*[(:]`, 'm').test(code)) {
problems.push(`${r}: 绑了 ${h},但 ${path.basename(js)} 里没有这个方法 —— 点了没反应`);
}
}
}
// pt-icon 的静态 name
for (const m of src.matchAll(/<pt-icon[^>]*name="([^"{]*)"/g)) {
if (m[1] && !iconNames.has(m[1])) problems.push(`${r}: pt-icon name="${m[1]}" 图标不存在`);
}
// 用到的自定义组件是否在同名 .json 里注册
const json = f.replace(/\.wxml$/, '.json');
const reg = fs.existsSync(json) ? Object.keys(JSON.parse(read(json)).usingComponents || {}) : [];
const known = ['nav-bar', 'pet-switch', 'bottom-sheet', 'fab', 'pt-icon', 'seg-tabs', 'stat-ring', 'profile-head'];
for (const tag of known) {
if (new RegExp('<' + tag + '[\\s>]').test(src) && !reg.includes(tag)) {
problems.push(`${r}: 用了 <${tag}> 但 ${path.basename(json)} 没有注册`);
}
}
}
if (problems.length) {
console.log('发现 ' + problems.length + ' 处问题:');
problems.forEach((p) => console.log(' ✗ ' + p));
process.exit(1);
}
console.log('✓ class 作用域 / 图标名 / 事件方法 / 组件注册 全部对得上(检查了 ' +
files.filter((f) => f.endsWith('.wxml')).length + ' 个 wxml' + iconNames.size + ' 个图标)');