// 用法: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(/]*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 + ' 个图标)');