☰
vConsole + MCP:让AI直接读取H5日志实现智能Debug
2026/10/8 6:42:00 网站建设 项目流程

1. 移动端调试的困局与破局思路

做过 H5 的同学应该都有过这种体验:页面在电脑浏览器上跑得好好的,一上真机就各种玄学问题——接口偶尔 500、某个按钮点了没反应、页面白屏但控制台干干净净。更难受的是,这些问题往往只在特定机型、特定网络环境下复现,你拿着手机干瞪眼,连个报错都看不到。

传统的做法无非这么几种:一是靠alert大法,在关键节点弹窗打印变量,测完再删,代码被搞得千疮百孔;二是用 Charles、Fiddler 这类抓包工具看请求,但只能看网络层,JS 报错、console 输出一概看不到;三是接个 vConsole 或者 Eruda,在页面右下角挂个悬浮按钮,点开能看到日志和网络请求。第三种已经是目前最主流的方案了,vConsole 几乎是 H5 调试的标配。

但 vConsole 有个天然的局限:日志是给人看的,不是给机器看的。你得自己盯着那块小屏幕,一条条翻日志、一个个点请求,然后人脑去分析哪里出了问题。如果能让 AI 直接读取这些日志和请求,让它帮我定位问题,那效率就完全不一样了。这就是我最近折腾的一个方向——把 vConsole 的日志能力通过 MCP 协议暴露出来,让 AI 助手能够直接"看见"任意 H5 页面的运行状态,从而实现真正意义上的智能 debug。

这篇文章我会把整个思路、技术选型、实现细节和踩过的坑完整讲一遍。不管你是刚接触 H5 调试的新手,还是已经用惯了 vConsole 的老手,应该都能从中拿到一些可以直接抄作业的东西。核心关键词就几个:vConsole、MCP、H5、debug、WebSocket,整条链路都是围绕它们展开的。

2. 为什么是 vConsole 加 MCP 这套组合

2.1 vConsole 到底解决了什么问题

先给不熟悉的朋友补个课。vConsole 是腾讯开源的一个轻量级移动端调试面板,本质就是一个 JS 库,引入之后会在页面角落生成一个悬浮按钮,点开就是一个类似 Chrome DevTools 的面板,包含 Console、Network、Element、Storage 等几个核心模块。它的价值在于:不需要连电脑、不需要 USB 调试、不需要任何额外工具,只要页面能跑起来,你就能看到日志。

它的实现原理其实不复杂。Console 模块是重写了window.console的几个方法,把原本要输出到浏览器控制台的内容拦截下来,渲染到自己的面板里。Network 模块则是劫持了XMLHttpRequest和fetch,在请求发出和响应回来的时候记录下 URL、method、header、body、耗时、状态码这些信息。Storage 模块就是遍历localStorage、sessionStorage、cookie展示出来。

这套机制决定了 vConsole 的数据是结构化存在内存里的,这为后续把数据喂给 AI 提供了天然的基础。你不需要去解析什么文本日志,直接拿对象就行。

2.2 MCP 是什么,为什么它适合这个场景

MCP 全称 Model Context Protocol,是 Anthropic 推出的一个开放协议,用来标准化 AI 模型和外部工具、数据源之间的交互。你可以把它理解成"AI 世界的 USB 接口"——以前每个 AI 应用要接一个工具,都得自己写一套适配层,现在有了统一协议,工具方只要实现一个 MCP Server,任何支持 MCP 的 AI 客户端都能直接调用。

MCP 的核心概念有三个:Tools(工具)、Resources(资源)、Prompts(提示词模板)。Tools 是 AI 可以主动调用的函数,比如"获取最新日志"、"清空日志";Resources 是 AI 可以读取的数据,比如"当前页面的所有网络请求";Prompts 是预置的提示词模板,帮用户快速发起某类任务。

把这个协议套到我们的场景里,逻辑就很清晰了:vConsole 负责采集数据,MCP Server 负责把数据包装成 AI 能理解的形式,AI 客户端负责调用和推理。整条链路里,AI 不再是被动等你贴日志,而是可以主动去"问"页面现在什么状态。

2.3 为什么不用现成的方案

市面上其实已经有一些移动端远程调试方案,比如 Weinre、Spy-Debugger,还有一些商业的云真机平台。但它们要么太重(需要装一堆依赖、配端口转发),要么太封闭(数据在别人服务器上),要么就是纯人工操作,没有和 AI 结合的接口。

自己搭一套 vConsole + MCP 的方案,好处是完全可控:数据不出本地、协议自己定、想加什么能力就加什么能力。而且整个实现下来代码量并不大,核心逻辑几百行就能搞定,维护成本很低。

3. 整体架构设计与数据流转

3.1 三层结构拆解

整套系统我把它拆成三层,从下往上分别是采集层、传输层、消费层。

采集层就是跑在 H5 页面里的 vConsole 增强版。我没有直接改 vConsole 源码,而是在它基础上做了一层包装:保留原有的 UI 面板,同时把 console 日志和 network 请求额外存一份到全局数组里,并暴露几个方法供外部调用。这样做的好处是原有调试体验不变,新增能力是叠加的。

传输层用的是 WebSocket。为什么不用 HTTP 轮询?因为调试场景下日志是实时产生的,轮询要么延迟高要么浪费请求。WebSocket 建立一条长连接之后,页面这边一有新日志就推给服务端,服务端再转发给 MCP Server,整个链路是事件驱动的,延迟基本在毫秒级。这里要注意 WebSocket 的心跳机制,移动端网络切换频繁,不心跳的话连接很容易假死。

消费层就是 MCP Server 和 AI 客户端。MCP Server 维护着每个已连接页面的最新状态,对外暴露几个 Tool 供 AI 调用。AI 客户端这边,我用的是支持 MCP 的桌面工具,配置好 Server 地址之后就能直接对话。

3.2 数据流转的完整链路

一条日志从产生到被 AI 看到,大概经历这么几步:

  1. 页面里某段代码执行console.log('user info', userInfo),被重写后的 console 方法拦截
  2. 拦截逻辑把这条日志包装成{level, args, timestamp, pageId}的结构,push 到本地队列
  3. WebSocket 客户端检测到队列有新数据,序列化后通过长连接发给服务端
  4. 服务端收到消息,按 pageId 归类存储,同时更新该页面的"最后活跃时间"
  5. AI 客户端调用 MCP Toolget_logs,MCP Server 从存储里取出对应页面的日志返回
  6. AI 拿到结构化日志,结合上下文分析,给出诊断建议

网络请求的流转类似,只是采集点在 XHR/fetch 的拦截逻辑里,数据结构更复杂一些,包含请求和响应两部分。

3.3 关键设计决策

有几个设计点我反复权衡过,这里说一下理由。

为什么用 pageId 而不是直接用 URL 区分页面:同一个 URL 可能开多个 tab,或者页面刷新后 URL 不变但其实是新会话。用 pageId(页面初始化时生成的随机 ID)能精确区分每个页面实例,避免数据串台。

为什么日志要设上限:移动端内存有限,如果页面跑一整天,日志能堆到几万条,既占内存又拖慢传输。我设的是每个页面保留最近 500 条 console 日志和 200 条网络请求,超出就丢最旧的。这个数字是实测下来比较平衡的,一般排查问题够用。

为什么 MCP Tool 要分页:AI 的上下文窗口是有限的,一次性返回 500 条日志可能直接把窗口撑爆。所以get_logs支持limit和offset参数,默认只返回最近 50 条,AI 可以按需翻页。

4. 采集层实现:改造 vConsole 暴露数据

4.1 引入 vConsole 并做增强包装

最基础的一步是把 vConsole 引进来。我用的是 npm 安装的方式,方便后续打包。

npm install vconsole

然后在入口文件里初始化,并挂载增强逻辑:

import VConsole from 'vconsole'; const vConsole = new VConsole({ theme: 'dark' }); // 全局日志存储 window.__DEBUG_STORE__ = { pageId: Math.random().toString(36).slice(2, 10), logs: [], requests: [], maxLogs: 500, maxRequests: 200 };

这里pageId用随机字符串生成,简单够用。如果你需要更严格的唯一性,可以用crypto.randomUUID(),但注意部分老机型不支持。

4.2 重写 console 方法拦截日志

vConsole 本身已经重写了 console,但它没有把数据暴露出来。所以我在它初始化之后再包一层,把数据存到自己的 store 里。

const originalLog = console.log; const originalWarn = console.warn; const originalError = console.error; function captureLog(level, args) { const store = window.__DEBUG_STORE__; const entry = { level, timestamp: Date.now(), content: args.map(arg => { if (typeof arg === 'object') { try { return JSON.stringify(arg); } catch (e) { return String(arg); } } return String(arg); }).join(' ') }; store.logs.push(entry); if (store.logs.length > store.maxLogs) { store.logs.shift(); } // 触发推送 if (window.__WS_PUSH__) { window.__WS_PUSH__({ type: 'log', data: entry }); } } console.log = function(...args) { captureLog('log', args); originalLog.apply(console, args); }; console.warn = function(...args) { captureLog('warn', args); originalWarn.apply(console, args); }; console.error = function(...args) { captureLog('error', args); originalError.apply(console, args); };

这里有个细节要注意:JSON.stringify遇到循环引用的对象会直接抛错,所以必须包 try-catch。另外undefined、函数、Symbol 这些序列化后会丢失,实际用的时候心里要有数。

4.3 劫持 XHR 和 fetch 采集网络请求

网络请求的采集比日志复杂,因为要同时记录请求和响应,还要处理超时、错误这些情况。

先看 XHR 的劫持:

const OriginalXHR = window.XMLHttpRequest; function wrapXHR() { window.XMLHttpRequest = function() { const xhr = new OriginalXHR(); const requestInfo = { method: '', url: '', startTime: 0, endTime: 0, status: 0, requestBody: null, responseBody: null }; const originalOpen = xhr.open; xhr.open = function(method, url, ...rest) { requestInfo.method = method; requestInfo.url = url; return originalOpen.apply(xhr, [method, url, ...rest]); }; const originalSend = xhr.send; xhr.send = function(body) { requestInfo.startTime = Date.now(); requestInfo.requestBody = body ? String(body).slice(0, 2000) : null; xhr.addEventListener('loadend', () => { requestInfo.endTime = Date.now(); requestInfo.status = xhr.status; try { requestInfo.responseBody = String(xhr.responseText).slice(0, 5000); } catch (e) { requestInfo.responseBody = '[unreadable]'; } pushRequest(requestInfo); }); return originalSend.apply(xhr, [body]); }; return xhr; }; }

fetch 的劫持思路类似,但 fetch 返回的是 Promise,要在 then 里处理响应:

const originalFetch = window.fetch; window.fetch = function(input, init = {}) { const requestInfo = { method: init.method || 'GET', url: typeof input === 'string' ? input : input.url, startTime: Date.now(), requestBody: init.body ? String(init.body).slice(0, 2000) : null }; return originalFetch.apply(this, arguments).then(response => { const clone = response.clone(); clone.text().then(text => { requestInfo.endTime = Date.now(); requestInfo.status = response.status; requestInfo.responseBody = text.slice(0, 5000); pushRequest(requestInfo); }).catch(() => { requestInfo.endTime = Date.now(); requestInfo.status = response.status; requestInfo.responseBody = '[unreadable]'; pushRequest(requestInfo); }); return response; }).catch(err => { requestInfo.endTime = Date.now(); requestInfo.status = -1; requestInfo.responseBody = String(err); pushRequest(requestInfo); throw err; }); };

pushRequest就是往 store 里塞数据并触发 WebSocket 推送,逻辑和日志类似。这里响应体我截断到 5000 字符,因为有些接口返回的 JSON 特别大,全传过去既慢又占内存。

注意:劫持 fetch 的时候一定要用response.clone(),因为原始 response 的 body 只能读一次,直接读会导致业务代码拿不到数据。

5. 传输层实现:WebSocket 长连接与心跳

5.1 建立连接与断线重连

WebSocket 客户端的核心是稳定。移动端网络环境复杂,切后台、切 WiFi、信号弱都会导致断连,所以重连机制必须做。

let ws = null; let reconnectTimer = null; let heartbeatTimer = null; let isManualClose = false; function connect(url) { ws = new WebSocket(url); ws.onopen = () => { console.log('[debug-ws] connected'); startHeartbeat(); // 连接建立后把 pageId 上报 ws.send(JSON.stringify({ type: 'register', pageId: window.__DEBUG_STORE__.pageId, url: location.href })); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'pong') { // 心跳响应,什么都不用做 return; } // 处理服务端下发的指令,比如清空日志 handleServerCommand(msg); }; ws.onclose = () => { stopHeartbeat(); if (!isManualClose) { scheduleReconnect(url); } }; ws.onerror = (err) => { console.error('[debug-ws] error', err); }; } function scheduleReconnect(url) { if (reconnectTimer) return; reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(url); }, 3000); }

重连间隔我设的 3 秒,太短了会在服务端没起来的时候疯狂重试,太长了又影响体验。3 秒是个比较舒服的值。

5.2 心跳机制的具体实现

心跳的作用是检测连接是否还活着。有些情况下 TCP 连接看起来还在,但实际上已经断了(比如经过某些中间设备时),这时候不发心跳你根本发现不了。

function startHeartbeat() { stopHeartbeat(); heartbeatTimer = setInterval(() => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() })); } }, 15000); } function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }

心跳间隔 15 秒,服务端收到 ping 之后回 pong。如果连续两次心跳没收到响应,客户端就主动断开重连。这个逻辑我放在服务端做,客户端只管发。

5.3 消息格式约定

客户端和服务端之间的消息我定了几种类型,用 JSON 传输:

消息类型方向说明
register客户端到服务端页面注册,携带 pageId 和 URL
log客户端到服务端单条 console 日志
request客户端到服务端单条网络请求记录
ping客户端到服务端心跳请求
pong服务端到客户端心跳响应
command服务端到客户端下发指令,如清空日志

消息体尽量精简,因为移动端流量和电量都是成本。日志内容我做了截断,单条不超过 2000 字符。

6. 消费层实现:MCP Server 暴露调试能力

6.1 MCP Server 的基本骨架

MCP Server 我用 Node.js 实现,官方有 SDK 可以直接用。

npm install @modelcontextprotocol/sdk ws

Server 的核心是注册 Tools 和 Resources。先搭个基础框架:

import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { WebSocketServer } from 'ws'; const server = new Server({ name: 'vconsole-mcp', version: '1.0.0' }, { capabilities: { tools: {}, resources: {} } }); // 存储所有连接的页面数据 const pages = new Map(); // 启动 WebSocket 服务端 const wss = new WebSocketServer({ port: 8765 }); wss.on('connection', (ws) => { let currentPageId = null; ws.on('message', (raw) => { const msg = JSON.parse(raw.toString()); if (msg.type === 'register') { currentPageId = msg.pageId; pages.set(currentPageId, { pageId: msg.pageId, url: msg.url, logs: [], requests: [], lastActive: Date.now() }); } else if (msg.type === 'log' && currentPageId) { const page = pages.get(currentPageId); page.logs.push(msg.data); if (page.logs.length > 500) page.logs.shift(); page.lastActive = Date.now(); } else if (msg.type === 'request' && currentPageId) { const page = pages.get(currentPageId); page.requests.push(msg.data); if (page.requests.length > 200) page.requests.shift(); page.lastActive = Date.now(); } else if (msg.type === 'ping') { ws.send(JSON.stringify({ type: 'pong' })); } }); ws.on('close', () => { // 页面断开后保留数据一段时间,方便 AI 事后分析 // 这里不立即删除 }); });

6.2 注册核心 Tools

Tools 是 AI 能主动调用的能力,我设计了四个:

list_pages:列出当前所有活跃页面,让 AI 知道有哪些页面可以调试。

server.setRequestHandler('tools/list', async () => ({ tools: [ { name: 'list_pages', description: '列出所有已连接的 H5 页面', inputSchema: { type: 'object', properties: {} } }, { name: 'get_logs', description: '获取指定页面的 console 日志', inputSchema: { type: 'object', properties: { pageId: { type: 'string', description: '页面 ID' }, level: { type: 'string', enum: ['log', 'warn', 'error'], description: '日志级别过滤' }, limit: { type: 'number', description: '返回条数,默认 50' }, offset: { type: 'number', description: '偏移量,默认 0' } }, required: ['pageId'] } }, { name: 'get_requests', description: '获取指定页面的网络请求记录', inputSchema: { type: 'object', properties: { pageId: { type: 'string' }, onlyFailed: { type: 'boolean', description: '只返回失败的请求' }, limit: { type: 'number' }, offset: { type: 'number' } }, required: ['pageId'] } }, { name: 'clear_data', description: '清空指定页面的日志和请求记录', inputSchema: { type: 'object', properties: { pageId: { type: 'string' } }, required: ['pageId'] } } ] }));

6.3 Tool 调用逻辑实现

注册完 Tools 之后要处理调用请求:

server.setRequestHandler('tools/call', async (request) => { const { name, arguments: args } = request.params; if (name === 'list_pages') { const list = Array.from(pages.values()).map(p => ({ pageId: p.pageId, url: p.url, logCount: p.logs.length, requestCount: p.requests.length, lastActive: new Date(p.lastActive).toISOString() })); return { content: [{ type: 'text', text: JSON.stringify(list, null, 2) }] }; } if (name === 'get_logs') { const page = pages.get(args.pageId); if (!page) { return { content: [{ type: 'text', text: '页面不存在或已断开' }] }; } let logs = page.logs; if (args.level) { logs = logs.filter(l => l.level === args.level); } const limit = args.limit || 50; const offset = args.offset || 0; const result = logs.slice(-(offset + limit), logs.length - offset || undefined); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; } if (name === 'get_requests') { const page = pages.get(args.pageId); if (!page) { return { content: [{ type: 'text', text: '页面不存在或已断开' }] }; } let reqs = page.requests; if (args.onlyFailed) { reqs = reqs.filter(r => r.status === 0 || r.status >= 400); } const limit = args.limit || 50; const offset = args.offset || 0; const result = reqs.slice(-(offset + limit), reqs.length - offset || undefined); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; } if (name === 'clear_data') { const page = pages.get(args.pageId); if (page) { page.logs = []; page.requests = []; } return { content: [{ type: 'text', text: '已清空' }] }; } return { content: [{ type: 'text', text: '未知工具' }] }; });

6.4 启动 Server

最后把 Server 跑起来,用 stdio 传输(这是 MCP 最常用的方式):

const transport = new StdioServerTransport(); await server.connect(transport); console.error('vconsole-mcp server started');

注意日志要输出到 stderr,因为 stdout 被 MCP 协议占用了,往 stdout 写东西会破坏协议。

7. 常见问题与排查技巧实录

7.1 连接类问题速查

现象可能原因排查方法
WebSocket 连不上端口被占用或服务未启动lsof -i:8765检查端口
连上后立刻断开消息格式不对,服务端解析报错看服务端 stderr 日志
频繁重连心跳超时或网络不稳定检查心跳间隔和网络环境
数据不更新页面侧推送逻辑没触发在页面 console 里看__WS_PUSH__是否存在

7.2 数据类问题排查

日志丢失:最常见的原因是JSON.stringify抛错导致整个 captureLog 中断。解决办法是在序列化外面包 try-catch,失败就降级成String(arg)。

请求体为空:有些请求用的是 FormData 或者 Blob,String(body)会得到[object FormData]。这种情况需要特殊处理,FormData 可以遍历 entries 转成对象。

响应体乱码:如果接口返回的是二进制流(比如文件下载),response.text()会得到乱码。判断 content-type,非文本类型直接标记为[binary]跳过。

7.3 性能类问题

页面卡顿:如果日志量特别大,频繁的 WebSocket 推送会拖慢页面。解决办法是批量推送——不要每条日志都发,而是攒 100ms 或者攒够 20 条再一起发。

let pendingMessages = []; let flushTimer = null; window.__WS_PUSH__ = function(msg) { pendingMessages.push(msg); if (!flushTimer) { flushTimer = setTimeout(() => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'batch', data: pendingMessages })); } pendingMessages = []; flushTimer = null; }, 100); } };

内存泄漏:store 里的数组一定要设上限,否则页面跑久了内存会一直涨。我前面设的 500 和 200 就是干这个的。

7.4 几个踩过的坑

坑一:vConsole 的 console 重写顺序。如果你在 vConsole 初始化之前重写 console,vConsole 会把你重写后的方法再包一层,导致日志重复。正确做法是先new VConsole(),再重写。

坑二:fetch 劫持影响业务。有些库会检查window.fetch.toString()判断是否是原生实现,劫持后可能触发兼容问题。如果遇到,可以在劫持函数上加个标记,或者改用 Proxy 方式。

坑三:MCP Server 的 stdout 污染。我一开始用console.log打调试信息,结果 MCP 客户端一直报协议错误。后来全部改成console.error才正常。这个坑很隐蔽,因为本地跑的时候看不出问题。

坑四:pageId 冲突。用Math.random().toString(36).slice(2, 10)生成 ID,理论上碰撞概率极低,但如果页面开了几十个 tab,还是有可能撞。更稳妥的做法是加上时间戳前缀。

8. 实际使用场景与效果

8.1 场景一:接口偶发失败定位

之前遇到一个接口,100 次里大概有 3 次返回 500,本地怎么都复现不了。接上这套系统之后,我让 AI 持续监控这个接口,一旦出现失败就自动把前后 10 条日志和相关请求上下文拉出来分析。结果发现是某个参数在特定情况下会传空,触发了后端的校验逻辑。整个过程没让我手动翻一条日志。

8.2 场景二:页面白屏排查

白屏是最难查的,因为往往连报错都没有。有了这套系统,AI 可以直接调get_logs看有没有 error 级别日志,调get_requests看有没有关键接口失败,再结合页面 URL 判断是不是路由问题。有一次白屏是因为某个 CDN 资源加载失败导致后续 JS 没执行,AI 从网络请求里一眼就看出来了。

8.3 场景三:性能问题分析

通过分析请求的 startTime 和 endTime,可以算出每个接口的耗时。AI 能自动找出耗时最长的几个请求,并给出优化建议。这个能力在排查首屏慢的问题时特别有用。

8.4 效果对比

维度传统 vConsolevConsole + MCP
日志查看手动翻面板AI 自动读取分析
问题定位人脑推理AI 辅助推理
多页面管理逐个切换统一列表
历史数据刷新即丢服务端保留
分析效率依赖经验标准化流程

9. 后续可以扩展的方向

这套系统目前跑通的是最核心的链路,还有不少可以深挖的地方。

一是增加 sourcemap 支持。现在日志里的堆栈是压缩后的,可读性差。如果能结合 sourcemap 还原出源码位置,AI 定位问题会更准。

二是接入性能指标。把 FCP、LCP、CLS 这些 Web Vitals 指标也采集上来,AI 就能做更全面的性能诊断。

三是支持远程执行。让 AI 不仅能读数据,还能主动在页面里执行一些诊断代码,比如查询某个 DOM 节点的状态、调用某个全局方法。这个能力要谨慎设计,避免安全风险。

四是多页面聚合分析。现在每个页面是独立的,如果能跨页面关联分析,比如追踪一个用户从 A 页面跳到 B 页面的完整链路,价值会更大。

五是日志持久化。目前数据在内存里,服务重启就没了。可以落盘到本地文件或者轻量数据库,方便事后回溯。

我在实际使用中最大的体会是:调试这件事,本质上是信息不对称。你知道的越多,定位越快。vConsole 解决了"看得见"的问题,MCP 解决了"让 AI 也看得见"的问题,两者结合之后,很多以前要花半小时翻日志的问题,现在几分钟就能定位。这套方案不算复杂,但确实把日常 debug 的效率往上提了一个台阶。如果你也在做 H5 开发,强烈建议试试这个思路,哪怕不接 AI,单纯把 vConsole 的数据结构化存下来,配合自己的分析脚本,也能省不少事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询