Files
sundynix-pets/pets-fe/scripts/check.js
T
Blizzard 09322ecbec chore(fe): 静态自查按作用域算 class,当场抓出第三例跨页裸奔
## 原来的检查是错的
它把所有 wxss 并成一个全局集合再看「这个 class 有没有人定义过」。
可这个项目连着栽的两次都是「有人定义过,但定义在别的页面里」:

  .post-card    留在 community.wxss,用户主页用它 → 帖子完全没样式
  .profile-row  留在 settings.wxss,「我的」用它  → 四个入口整个散架

两次它都报「✓ 全部对得上」。全局并一起看,恰好检查不出这一类问题。

## 改成按作用域
app.wxss 全局;页面 wxss 只对自己那页生效;组件 wxss 只对组件自己的节点
生效,且要开了 addGlobalClass 才看得到全局。@import 跟着递归收
(pt-icon 的图标就是这么来的)。

class 不在作用域内时分两种报法——「没有任何定义」是拼错了,
「只定义在 xxx.wxss」是跨页借用,后者直接把定义位置指出来。

## 跑起来当场又抓到一个
pages/relations 的关注按钮 .follow-btn / .follow-btn.on 只定义在
community.wxss —— 关注/粉丝列表里那个按钮一直是裸的。已提到 app.wxss。

## 自测过
往 learn.wxml 塞一个只在 decorate.wxss 里定义的 class,确认报错并指对了
位置,再还原确认恢复绿。不然这检查本身就是个装饰。

脚本从临时目录挪进 pets-fe/scripts/check.js(原来只活在会话的临时目录里,
会话一结束就没了),路径改成相对自己。scripts/ 加进 packOptions.ignore,
不进小程序包体。

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

128 lines
6.1 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 定义`);
}
}
}
// 事件绑定的方法必须真的存在——绑了个不存在的方法,点了没反应且不报错
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 + ' 个图标)');