静态扫出 19 个“死按钮”,真跑起来是 0 个——给 221 个 AI 生成的 H5 做第二次体检

本文对221个AI生成的H5页面开展二次质量体检:先用静态分析识别19个‘死按钮’,再通过Playwright在Chromium中真实运行复核,发现误报率100%(0个真实死按钮)。分析三类典型假警报成因——非函数字符串、词法作用域下onclick可调用、源码存在但未渲染。强调静态扫描适合作为初筛,运行期检查才是验证关键,二者需分层协同。方法适用于所有AI生成的前端页面质量评估。

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%)一半是误报
只用 100vh53 个(24.0%)47 个(21.3%)差 6 个(双向都有)
<form> 会刷新整页26 个(12.0%)23 个(10.4%)3 个误报
外部资源加载失败—4 个(1.8%)静态看不见

两个数最扎眼:

  1. 221 个页面,0 个 pageerror。 也就是说这批 AI 生成的页面,没有一个"打开就白屏"。这跟上一篇的结论一致,而且是两次独立方法的交叉验证。
  2. 死按钮: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. 你可以直接用的那段代码

既然都写好了,就把它做成一个能随身带的东西:一段粘到浏览器控制台就能跑的体检脚本,不需要装任何东西,代码不出浏览器(没有一行上报)。

用法三步:

  1. 打开你要检查的页面(本地文件也行,file:// 直接开)
  2. F12 → Console → 粘贴下面的代码 → 回车
  3. 看表格 → 想修就 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 秒就改完。

三个实现上的坑(自己踩的,写出来省你一遍)

  1. 别用 typeof window[fn] 判函数存在。 顶层 const 声明的函数不在 window 上,但 onclick 能调到。用 (0, eval)(name)。
  2. 样式表要 try/catch。 跨域样式表读 cssRules 会抛 SecurityError,一条 try 不包,整个检查器就停在第一个外链 CSS 上。
  3. 修 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。


相关

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值