fix(mini): 修复设置页绑定漂移并新增 WXML-JS 一致性检查
Publish Mini Program Dev Version / publish (push) Successful in 1m5s

settings.js 同步绑定名 loggedIn→logged、login→wxLogin、navTo→navigateTo,恢复登录按钮与全部菜单项响应;npm test 接入新增 ci/check-bindings.cjs 门禁;calendar/wish-detail 各 1 处历史遗留漂移登记为仅告警另行跟进
This commit is contained in:
gouki
2026-09-30 00:48:50 +00:00
parent 59d1af73b9
commit 0e21d8e63f
4 changed files with 261 additions and 16 deletions
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env node
/**
* WXML-JS 绑定一致性静态检查(npm test / CI 门禁)
*
* 小程序不会在编译期校验 bind/catch 绑定名与 {{}} 数据引用:
* wxml 与页面 JS 一旦命名漂移(例如 wxml 写 wxLogin/navigateTo,
* 而 js 只有 login/navTo),页面不会报错,只是点击与渲染静默失效。
* 本脚本把这类漂移拦在提交与 CI 阶段。
*
* 检查范围:pages/<name>/<name>.wxml 与同目录同名 .js 的配对
* 检查规则:
* 1. bind/catch 事件绑定(含 bind:tap 冒号写法)的方法名必须存在于对应 JS
* 2. {{}} 表达式中的根标识符(点号前的名字)必须存在于对应 JS(数据字段或方法)
* 3. wx:for 默认变量 item/index、wx:for-item/wx:for-index 别名、
* 字符串字面量与 true/false/null/undefined 自动忽略
* 4. LEGACY_DRIFT 中登记的历史遗留条目仅告警不失败(修复后应删除对应条目)
*
* 存在漂移时以非零退出码结束,使 npm test 与 CI 直接失败。
*/
const fs = require("node:fs");
const path = require("node:path");
const projectPath = process.cwd();
const pagesPath = path.join(projectPath, "pages");
const LITERAL_KEYWORDS = new Set(["true", "false", "null", "undefined"]);
// wx:for 的默认循环变量属于模板结构名而非页面定义,始终忽略
const DEFAULT_LOOP_VARS = new Set(["item", "index"]);
// 引入本检查前就存在的历史遗留漂移(本轮修复范围外,另行跟进)。
// 命中时仅告警不失败;对应页面修复后应删除这里的条目,让检查恢复全量严格。
const LEGACY_DRIFT = new Set([
'pages/calendar/calendar.wxml catchtap="noop"',
'pages/wish-detail/wish-detail.wxml bindtap="onShare"',
]);
function listPageNames() {
return fs
.readdirSync(pagesPath, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((name) => {
const dir = path.join(pagesPath, name);
return (
fs.existsSync(path.join(dir, `${name}.wxml`)) &&
fs.existsSync(path.join(dir, `${name}.js`))
);
})
.sort();
}
function lineOf(text, index) {
let line = 1;
for (let i = 0; i < index; i++) {
if (text.charCodeAt(i) === 10) {
line++;
}
}
return line;
}
/** 提取 wxml 中的事件绑定与 {{}} 表达式(去掉注释,保留行号) */
function collectWxmlRefs(source) {
const text = source.replace(/<!--[\s\S]*?-->/g, "");
const loopVars = new Set();
const handlers = [];
const roots = [];
for (const match of text.matchAll(/wx:for-(?:item|index)="([A-Za-z_$][\w$]*)"/g)) {
loopVars.add(match[1]);
}
for (const match of text.matchAll(/((?:bind|catch):?[a-zA-Z]+)="([^"{}]+)"/g)) {
const name = match[2].trim();
if (/^[A-Za-z_$][\w$]*$/.test(name)) {
handlers.push({ ref: `${match[1]}="${name}"`, name, line: lineOf(text, match.index) });
}
}
for (const match of text.matchAll(/\{\{([^{}]*)\}\}/g)) {
for (const name of expressionRoots(match[1])) {
roots.push({ ref: `{{${name}}}`, name, line: lineOf(text, match.index) });
}
}
return { loopVars, handlers, roots };
}
/** 取 mustache 表达式的根标识符:字符串字面量、点号后的属性名、数字与关键字都不算 */
function expressionRoots(expression) {
const stripped = expression
.replace(/'(?:[^'\\]|\\.)*'/g, "''")
.replace(/"(?:[^"\\]|\\.)*"/g, '""');
const roots = new Set();
for (const match of stripped.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)/g)) {
if (!LITERAL_KEYWORDS.has(match[1])) {
roots.add(match[1]);
}
}
return roots;
}
/** 将 JS 的字符串与注释内容置为空白(保留换行),避免干扰键名提取与跨行匹配 */
function stripJsNoise(source) {
let result = "";
let index = 0;
while (index < source.length) {
const ch = source[index];
const next = source[index + 1];
if (ch === "/" && next === "/") {
while (index < source.length && source[index] !== "\n") {
result += " ";
index++;
}
continue;
}
if (ch === "/" && next === "*") {
while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) {
result += source[index] === "\n" ? "\n" : " ";
index++;
}
result += " ";
index += 2;
continue;
}
if (ch === "'" || ch === '"' || ch === "`") {
result += " ";
index++;
while (index < source.length) {
const current = source[index];
if (current === "\\") {
result += source[index + 1] === "\n" ? "\n" : " ";
index += 2;
continue;
}
if (current === ch) {
result += " ";
index++;
break;
}
result += current === "\n" ? "\n" : " ";
index++;
}
continue;
}
result += ch;
index++;
}
return result;
}
/** 提取页面 JS 中 Page({}) 的数据字段、setData 键与方法名 */
function collectJsNames(source) {
const clean = stripJsNoise(source);
const names = new Set();
// 对象键:{ 或 , 之后的 name:(data 初始字段、setData 键、回调键)
for (const match of clean.matchAll(/[{,]\s*([A-Za-z_$][\w$]*)\s*:/g)) {
names.add(match[1]);
}
// 方法定义:行首可带 async 的 name(
for (const match of clean.matchAll(/^\s*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\(/gm)) {
names.add(match[1]);
}
return names;
}
function main() {
if (!fs.existsSync(pagesPath)) {
console.error(`[check-bindings] 找不到页面目录:${pagesPath}`);
process.exitCode = 2;
return;
}
const pages = listPageNames();
const problems = [];
const legacyHits = [];
let checkedRefs = 0;
for (const name of pages) {
const dir = path.join(pagesPath, name);
const wxmlFile = path.join(dir, `${name}.wxml`);
const jsFile = path.join(dir, `${name}.js`);
const { loopVars, handlers, roots } = collectWxmlRefs(fs.readFileSync(wxmlFile, "utf8"));
const known = collectJsNames(fs.readFileSync(jsFile, "utf8"));
const ignored = new Set([...DEFAULT_LOOP_VARS, ...loopVars]);
const wxmlRel = path.relative(projectPath, wxmlFile);
const jsRel = path.relative(projectPath, jsFile);
for (const handler of handlers) {
checkedRefs++;
if (!known.has(handler.name)) {
if (LEGACY_DRIFT.has(`${wxmlRel} ${handler.ref}`)) {
legacyHits.push(`${wxmlRel}:${handler.line} ${handler.ref}`);
} else {
problems.push(`${wxmlRel}:${handler.line} ${handler.ref} 在 ${jsRel} 中找不到对应方法`);
}
}
}
for (const root of roots) {
if (ignored.has(root.name)) {
continue;
}
checkedRefs++;
if (!known.has(root.name)) {
if (LEGACY_DRIFT.has(`${wxmlRel} ${root.ref}`)) {
legacyHits.push(`${wxmlRel}:${root.line} ${root.ref}`);
} else {
problems.push(`${wxmlRel}:${root.line} ${root.ref} 在 ${jsRel} 的 data/方法中找不到`);
}
}
}
}
if (problems.length > 0) {
console.error(`[check-bindings] 发现 ${problems.length} 处 WXML 与 JS 不一致的引用:`);
for (const problem of problems) {
console.error(` - ${problem}`);
}
console.error(
"[check-bindings] 请统一 wxml 绑定名与页面 JS 的 data 字段/方法名,避免点击与数据绑定静默失效。",
);
}
if (legacyHits.length > 0) {
console.warn(
`[check-bindings] 另有 ${legacyHits.length} 处历史遗留漂移(引入检查前已存在,不计入本次失败,另行跟进):`,
);
for (const hit of legacyHits) {
console.warn(` - ${hit}`);
}
}
if (problems.length > 0) {
process.exitCode = 1;
return;
}
console.log(
`[check-bindings] 通过:${pages.length} 个页面、${checkedRefs} 处 bind/catch 与 {{}} 引用均能在同名 JS 中找到定义。`,
);
}
main();