AI编程
前端
静态分析
代码质量
Playwright
上一篇我抓了 221 个 AI 生成的 H5,用 10 条静态规则扫了一遍(我逐个打开 221 个 AI 生成的 H5,发现最致命的不是语法错误)。这篇是第二次体检:把 221 个页面真的在浏览器里跑起来,再扫一遍。
结论先放这儿:静态扫描说的 19 个"点了没反应的按钮",真跑起来是 0 个。占位文案从 25 个降到 12 个,表单从 26 个降到 23 个。不是 bug 少了,是我上一篇的口径错了。
作者:林小黑 | 数据时间:2026-10-25 | 样本:221 个公开可访问的 AI 生成 H5 | 环境:Chromium 156,视口 390×780
0. 为什么要做第二次
上一篇我干的事:把 221 个 H5 的源码抓下来,写 10 条正则/状态机规则,统计命中率。
写完我自己心里有个疙瘩:规则命中的是"源码里长这样",不是"跑起来会这样"。 这两件事差多远,我当时不知道,但我知道一个反例——我自己写的第一版检查器,就跑出过一堆假警报。
所以这次补上第二步:用 Playwright 把每个页面真的打开,等它渲染完,再在页面里跑一遍运行期检查。同一批 221 个页面,两套口径,直接对照。
1. 结果:静态 vs 运行期,差在哪
| 检查项 | 静态扫描 | 真跑起来 | 差异 |
|---|---|---|---|
| JS 整体崩(pageerror) | 0 个语法错误 | 0 个 | 一致 |
| 按钮点了没反应(死按钮) | 19 个页面 | 0 个 | 19 个全是误报 |
| 占位文案留在页面上(TODO/示例数据) | 25 个(11.3%) | 12 个(5.4%) | 一半是误报 |
只用 100vh | 53 个(24.0%) | 47 个(21.3%) | 差 6 个(双向都有) |
<form> 会刷新整页 | 26 个(12.0%) | 23 个(10.4%) | 3 个误报 |
| 外部资源加载失败 | — | 4 个(1.8%) | 静态看不见 |
两个数最扎眼:
- 221 个页面,0 个 pageerror。 也就是说这批 AI 生成的页面,没有一个"打开就白屏"。这跟上一篇的结论一致,而且是两次独立方法的交叉验证。
- 死按钮:19 → 0。 100% 误报。这个必须掰开讲,因为我自己就是靠这个踩了一跤。
2. 19 个"死按钮",为什么全是假的
静态规则写得很直白:扫出 onclick="xxx()",再看源码里有没有 function xxx。没有,就记一笔"死按钮"。
听起来没毛病。实际扫出来 19 个页面,长这样:
<button onclick="if (n > 0) submit()">提交</button>
<button onclick="return confirmLeave()">返回</button>
<button onclick="openTryOn()">试穿</button>
假警报一:if 不是函数
第一行,正则抓到的是 if(。于是"函数 if 不存在"——废话,当然不存在。19 个页面里 5 个是这一种。 return confirmLeave() 同理,抓到的是 return。
低级吗?低级。但这种错误会一路走到"结论"里。我上一篇的表格没写死按钮,纯属运气——我当时的规则把 if/return 跳过了,但 openTryOn 没跳过。
假警报二:const 声明的函数不在 window 上,但 onclick 照样能调到
这个更有意思。openTryOn 确实存在:
const openTryOn = () => { /* ... */ };
它没有挂在 window 上(const 是块级/词法声明,不写到全局对象),但内联 onclick 执行时,作用域链的尽头就是全局词法环境,所以它能调到。
我第一版运行期检查就是这么写的:
if (typeof window[fn] !== 'function') dead.push(fn); // ❌ 误报源头
结果:静态判 19 个,我这版"运行期检查"判 6 个——看起来像是修好了,其实还是错的。那 6 个里 5 个是 if,剩下 1 个是 const $ = s => document.querySelector(s) 这种工具函数。
正确写法是在全局作用域里真的解析它一次:
const resolvable = (name) => {
try { return typeof (0, eval)(name) === 'function'; } // ✅ 能解析就是能调
catch (e) { return false; } // ReferenceError = 真没有
};
换成这一行之后,221 个页面,死按钮 0 个。
假警报三:源码里有,页面上没有
占位文案 25 → 12,差 13 个。原因就一个:源码里的 TODO 有两类——一类是渲染出来、用户真能看到的;另一类是注释、被 display:none 藏起来的、或者写在模板字符串里但那个分支根本没执行。
静态规则读的是文本,用户看的是渲染结果。中间隔着一整个运行时。
3. 那静态扫描还有没有用
有,但它的位置变了:静态扫描是筛子,运行期检查是显微镜。
| 静态扫描 | 运行期检查 | |
|---|---|---|
| 一次能扫多少 | 221 个全扫,几分钟 | 每页要真的加载,慢且依赖浏览器 |
| 能看见什么 | 源码里的结构性风险:86% 用字符串拼 innerHTML、7% 裸调 localStorage、依赖公共 CDN | 用户实际会遇到什么:底部按钮被地址栏盖住、点了没反应、数据没存上 |
| 看不见什么 | 运行时的真实表现(本文的死按钮) | 源码结构问题(innerHTML 拼串,跑起来完全正常) |
所以正确的顺序是:静态扫出候选 → 运行期复核 → 只把复核过的写进结论。
我上一篇的文章里,"86% 用字符串拼 innerHTML"这条至今没被推翻——因为它本来就只能在源码层看,跟运行期无关。但"占位 11%"这条,运行期口径是 5.4%,高估了一倍。这就是不复核的代价。
4. 你可以直接用的那段代码
既然都写好了,就把它做成一个能随身带的东西:一段粘到浏览器控制台就能跑的体检脚本,不需要装任何东西,代码不出浏览器(没有一行上报)。
用法三步:
- 打开你要检查的页面(本地文件也行,
file://直接开) - F12 → Console → 粘贴下面的代码 → 回车
- 看表格 → 想修就
H5FIX.apply()→ 要文件就H5FIX.export()
(() => {
if (window.H5FIX) return console.log('已在运行,直接 H5FIX.report()');
const S = { fixes: 0, log: [] };
// 1. 视口
const cViewport = () => {
const m = document.querySelector('meta[name=viewport]');
if (!m) return { level: '致命', title: '没有 viewport', detail: '手机上按 980px 布局再缩放,字小到看不清', fix: true };
if (!/width=device-width/.test(m.content)) return { level: '重要', title: 'viewport 没写 width=device-width', detail: m.content, fix: true };
return null;
};
// 2. 100vh(内联 + 样式表都查)
const vhRules = () => {
const found = [];
for (const ss of document.styleSheets) {
let rules; try { rules = ss.cssRules; } catch (e) { continue; } // 跨域表读不到
for (const r of rules || []) if (r.style && /100vh/.test(r.style.cssText || '')) found.push({ sheet: ss, css: r.cssText });
}
return found;
};
const cVh = () => {
let n = 0;
document.querySelectorAll('[style]').forEach(el => { if (/100vh/.test(el.getAttribute('style'))) n++; });
const css = vhRules().filter(r => !(S.fixedCss && S.fixedCss.has(r.css))).length;
if (!n && !css) return null;
return { level: '重要', title: '用了 100vh', detail: `内联 ${n} 处 / 样式表 ${css} 条;iOS/微信里 100vh 比可视区高,底部按钮会被地址栏盖住`, fix: true };
};
// 3. 死按钮(用间接 eval,别用 typeof window[fn])
const resolvable = (name) => { try { return typeof (0, eval)(name) === 'function'; } catch (e) { return false; } };
const deadBtns = () => {
const dead = [];
document.querySelectorAll('[onclick]').forEach(el => {
const m = (el.getAttribute('onclick') || '').match(/^\s*([A-Za-z_$][\w$]*)\s*\(/);
if (!m) return;
const fn = m[1];
if (['if', 'return', 'this', 'typeof', 'void'].includes(fn)) return;
if (!resolvable(fn)) dead.push({ el, fn });
});
return dead;
};
const cDead = () => {
const d = deadBtns();
if (!d.length) return null;
return { level: '致命', title: `${d.length} 个按钮点了没反应`, detail: 'onclick 引用的函数不存在:' + [...new Set(d.map(x => x.fn))].slice(0, 6).join(', '), fix: true };
};
// 4. 外部资源挂了(白屏头号原因)
const cRes = () => {
const bad = performance.getEntriesByType('resource').filter(r => r.duration > 0 && r.transferSize === 0 && !r.name.startsWith('data:'));
if (!bad.length) return null;
const hosts = [...new Set(bad.map(r => { try { return new URL(r.name).host; } catch (e) { return r.name; } }))].slice(0, 5);
return { level: '致命', title: `${bad.length} 个外部资源没加载成功`, detail: '来自 ' + hosts.join(' / ') + '(jsdelivr / Google Fonts 国内直连经常整段超时)', fix: false };
};
// 5. 占位文案(只看渲染出来的文本)
const cTodo = () => {
const re = /(TODO|FIXME|lorem ipsum|示例数据|待补充|敬请期待|xxx)/i;
const hits = [];
document.querySelectorAll('body *').forEach(el => {
if (el.children.length) return;
const t = (el.textContent || '').trim();
if (t && t.length < 80 && re.test(t)) hits.push(t.slice(0, 24));
});
if (!hits.length) return null;
return { level: '重要', title: `${hits.length} 处占位文案留在页面上`, detail: [...new Set(hits)].slice(0, 5).join(' | '), fix: false };
};
// 6. 表单会刷新整页
const cForm = () => {
const forms = [...document.querySelectorAll('form')].filter(f => !f.getAttribute('onsubmit') && !f.dataset.h5fixed);
if (!forms.length) return null;
return { level: '重要', title: `${forms.length} 个表单会刷新整页`, detail: '单页 H5 没有后端,提交后页面重载,用户以为数据丢了', fix: true };
};
// 7. 存储探针:真写一次,比扫源码准
const cStorage = () => {
if (S.storageProbe === undefined) {
try { localStorage.setItem('__probe', '1'); localStorage.removeItem('__probe'); S.storageProbe = true; }
catch (e) { S.storageProbe = false; S.storageErr = String((e && e.name) || e).slice(0, 40); }
}
if (S.storageProbe) return null;
return { level: '致命', title: '这个环境里 localStorage 用不了', detail: '实测写入抛 ' + S.storageErr + ';所有"保存"都会静默失效(微信内置浏览器 / 隐私模式 / file:// 常见)', fix: false };
};
// 8. 数据不持久
const cPersist = () => {
if (S.sawStorage) return null;
const inline = [...document.querySelectorAll('script:not([src])')].map(s => s.textContent).join('\n');
if (/localStorage\s*\.\s*setItem|sessionStorage\s*\.\s*setItem/.test(inline)) return null;
return { level: '重要', title: '没看到任何本地存储写入', detail: '刷新一下数据就没了;点几下再 H5FIX.report() 会更准', fix: false };
};
try {
['localStorage', 'sessionStorage'].forEach(k => {
const st = window[k]; if (!st) return;
const orig = st.setItem.bind(st);
st.setItem = function () { S.sawStorage = true; return orig.apply(null, arguments); };
});
} catch (e) {}
const CHECKS = [cViewport, cVh, cDead, cRes, cTodo, cForm, cStorage, cPersist];
const run = () => {
const rows = [];
CHECKS.forEach(fn => { try { const r = fn(); if (r) rows.push(r); } catch (e) {} });
return rows;
};
const report = () => {
const rows = run();
console.log('%c H5 急救包 ' + (rows.length ? `(${rows.length} 项问题)` : '(全过)'), 'background:#111;color:#0f0;padding:2px 6px');
if (rows.length) console.table(rows.map(r => ({ 级别: r.level, 问题: r.title, 说明: r.detail, 可自动修: r.fix ? '是' : '否' })));
console.log('%c下一步:H5FIX.apply() 自动修 → H5FIX.export() 下载修好的文件', 'color:#08f');
return rows;
};
const toast = (msg) => {
const d = document.createElement('div');
d.textContent = msg;
d.style.cssText = 'position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:rgba(0,0,0,.82);color:#fff;font:14px/1.5 system-ui;padding:10px 16px;border-radius:8px;z-index:2147483647;max-width:80vw;text-align:center';
document.body.appendChild(d);
setTimeout(() => d.remove(), 1800);
};
const apply = () => {
S.fixes = 0;
let vp = document.querySelector('meta[name=viewport]');
if (!vp) { vp = document.createElement('meta'); vp.name = 'viewport'; document.head.appendChild(vp); }
if (!/width=device-width/.test(vp.content)) { vp.content = 'width=device-width,initial-scale=1,viewport-fit=cover'; S.fixes++; }
if (!document.querySelector('meta[charset]')) { const m = document.createElement('meta'); m.setAttribute('charset', 'utf-8'); document.head.prepend(m); S.fixes++; }
document.querySelectorAll('[style]').forEach(el => {
const s = el.getAttribute('style');
if (/100vh/.test(s)) { el.setAttribute('style', s.replace(/100vh/g, '100dvh')); S.fixes++; }
});
S.fixedCss = S.fixedCss || new Set();
vhRules().forEach(({ sheet, css }) => {
try { sheet.insertRule(css.replace(/100vh/g, '100dvh'), sheet.cssRules.length); S.fixes++; S.fixedCss.add(css); } catch (e) {}
});
deadBtns().forEach(({ el }) => {
el.dataset.h5fixed = '1'; el.removeAttribute('onclick');
el.addEventListener('click', e => { e.preventDefault(); toast('这个功能还没接上'); });
S.fixes++;
});
[...document.querySelectorAll('form')].forEach(f => {
if (f.dataset.h5fixed) return;
f.dataset.h5fixed = '1';
f.addEventListener('submit', e => { e.preventDefault(); toast('已拦下提交:单页 H5 没有后端'); });
S.fixes++;
});
console.log('%c已修 ' + S.fixes + ' 处', 'color:#0a0;font-weight:bold');
return S.fixes;
};
const exportHtml = () => {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob(['<!DOCTYPE html>\n' + document.documentElement.outerHTML], { type: 'text/html;charset=utf-8' }));
a.download = (document.title || 'page').replace(/[\\/:*?"<>|]/g, '_').slice(0, 40) + '-fixed.html';
a.click();
};
window.H5FIX = { report, apply, export: exportHtml };
report();
})();
我在 4 个真实页面上跑的结果
| 页面 | 体检结果 | apply() 修了几处 |
|---|---|---|
| 单词打卡类(45KB) | 0 项 | 0 |
| 健身记录类(28KB) | 100vh(样式表 1 条) | 1(追加 100dvh 覆盖规则) |
| 衣橱管理类(65KB) | 100vh(样式表 2 条)+ 1 处"示例数据可继续扩展为个人衣…" | 2 |
| 数据看板类(13KB) | 100vh + 无本地存储写入 | 1 |
这四个页面有一个共同点:打开都不报错,看起来都能用。 排在前面的问题全是"用了 100vh 会被手机地址栏盖住"这种——不测就永远不知道,测了 30 秒就改完。
三个实现上的坑(自己踩的,写出来省你一遍)
- 别用
typeof window[fn]判函数存在。 顶层const声明的函数不在window上,但 onclick 能调到。用(0, eval)(name)。 - 样式表要
try/catch。 跨域样式表读cssRules会抛SecurityError,一条 try 不包,整个检查器就停在第一个外链 CSS 上。 - 修
100vh不能只改内联。 Tailwind 的h-screen在样式表里,得insertRule追加一条同选择器的100dvh规则(同权重、后写的赢),别去deleteRule原规则——会打乱后面所有覆盖关系。
5. 诚实记账:我上一篇错在哪
把我自己挂出来:
| 上一篇的说法(静态口径) | 真跑起来的数字 | 判定 |
|---|---|---|
| 占位文案 11%(25 个) | 5.4%(12 个) | 高估一倍 |
| 100vh 18%(40 个) | 21.3%(47 个) | 低估 |
| 语法错误 0 个 | pageerror 0 个 | 一致 ✅ |
86% 拼 innerHTML | 运行期无法判定 | 口径成立 ✅ |
两条错的,原因都一样:我把"源码里长这样"当成了"跑起来会这样"。 数据没错,口径错了。错口径的结论比没结论更危险,因为它看起来很硬。
所以这次我把两套口径都写出来,谁想复核,用文末的代码在自己机器上跑一遍就能对。
6. 这套方法不只对 H5 有用
任何"AI 生成的、要在浏览器里跑的东西",都可以这么体检:静态扫结构风险(一次扫完一大片)→ 真跑一遍复核(只信跑出来的)→ 把复核过的写进结论。
下一步我想做的是把它塞进构建流程:每次 AI 改完代码,自动跑一遍,只在"真出问题"的时候才叫人。 这次的数据说明了为什么必须分两层——只有静态,你会被 19 个假警报淹掉;只有运行期,你会漏掉 86% 的字符串拼 HTML。
相关
- 上篇:我逐个打开 221 个 AI 生成的 H5,发现最致命的不是语法错误
- 网页版(不用控制台,上传文件直接看报告):H5 修复台
- 本文的 221 个样本、检查脚本、原始结果都在我本地,可复现;需要哪一段数据可以在评论区说。
本文对221个AI生成的H5页面开展二次质量体检:先用静态分析识别19个‘死按钮’,再通过Playwright在Chromium中真实运行复核,发现误报率100%(0个真实死按钮)。分析三类典型假警报成因——非函数字符串、词法作用域下onclick可调用、源码存在但未渲染。强调静态扫描适合作为初筛,运行期检查才是验证关键,二者需分层协同。方法适用于所有AI生成的前端页面质量评估。
929

被折叠的 条评论
为什么被折叠?



