Skip to content

Instantly share code, notes, and snippets.

@ziyoung
Created December 26, 2023 02:02
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ziyoung/7149a1affcb12894919e8af82d13030c to your computer and use it in GitHub Desktop.
Save ziyoung/7149a1affcb12894919e8af82d13030c to your computer and use it in GitHub Desktop.
如何设计一个前端远程调试工具 --- 以 PageSpy 为例.md

PageSpy 是一个适用于远程 Web 项目调试的工具。在最近的更新中,还添加了对微信小程序的支持。

它通过对浏览器/微信小程序 API 的封装,将调用原生方法时的参数进行过滤、转化,整理成指定格式的消息供调试端消费;调试端收到消息后,在类似 Chrome devtools 的面板中将数据呈现出来。对于前端开发者来说,上手零成本。

PageSpy 调试面板

如果你现在还为调试远端的网页而苦恼,不妨先把 PageSpy 部署起来。如果你已经是 PageSpy 用户或者对这类调试工具的实现感兴趣,不妨跟着本文的讲解一探究竟。

主仓库地址:https://github.com/HuolalaTech/page-spy-web ,觉得好用的小伙伴可以点个 ⭐️。

概览

为了实现远端调试的功能,PageSpy 是需要在服务器部署后端服务的。借助后端服务,调试端 (开发者) 与用户端 (远端的用户) 建立了连接,并可以实时通信。下图简单描述整个过程。

概览图

因此 page-spy-web 仓库中既有前端代码也有后端代码。

├── src         # 前端代码
├── backend     # 后端代码
├── ...

消息与传输

从上图中可以看出,用户端以及调试端分别与服务器之间建立了连接。为了保障通信的实时性,使用 WebSocket 来传输消息再合适不过了。

// https://github.com/HuolalaTech/page-spy/blob/main/src/utils/socket.ts
const socket = new WebSocket('wss://remote-url');
socket.addEventListener('open', onOpen);
// ...
socket.addEventListener('message', onReceiveMessage);

在连接建立后,我们就重点关注 WebSocket 传输哪些内容。

JSON 数据可读性好,也便于浏览器处理。在 WebSocket 连接中传输的便是标准的 JSON 数据。消息的结构可以用下方的 interface 来代表。

// https://github.com/HuolalaTech/page-spy/blob/main/types/lib/message-type.d.ts
interface MessageItem<T> {
  role: 'client' | 'debugger';
  type: 'console' | 'network' | 'debug' | '...';
  data: T;
}

其中的 role 字段用于区分消息的来源,type 表明消息的类型,data 则是具体的消息内容,这个因 type 而异。用户端/调试端在收到特定的消息类型后,执行相应的操作,比如:

  • 将用户端页面的网络请求展示调试端的 Network 面板
  • 在用户端浏览器中执行调试端发送代码片段

远端调试需要采集的信息

确定了消息的格式后,接下来工作的核心便是尽可能地采集用户端的数据,将收集到的数据封装成指定的 Message 就可以发送到调试端。

对于开发者来说,需要关注的有以下几种数据:DOM,Console,Error,网络,Storage 等。

DOM

使用 document.documentElement.outerHTML 即可获得 DOM 元素的结构。

Console

劫持 console.* 等方法的实现来获取日志信息。

const levels = ['log', 'info', 'error', 'warn'];

levels.forEach((level) => {
  window.console[level] = logger;
});

当使用 console 打印复杂的对象时,该如何处理呢?下文中的 “数据的序列化与展示” 会做说明。

Error

JavaScript 执行过程中的错误有两类,分别监听 errorunhandledrejection 事件来获取错误信息。

window.addEventListener('error', reportError);
window.addEventListener('unhandledrejection', reportUnhandledrejection);

与此同时,加载静态资源的错误也需要收集的。与上方代码有所区别的是,这里需要排除一下 JavaScript 运行期间的错误。

window.addEventListener(
  'error',
  (event) => {
    // 忽略 JS 运行期间的报错
    if (!(evt instanceof ErrorEvent)) {
      const url = event.target.src || event.target.href;
      console.error(`Failed to load ${url}`);
    }
  },
  true,
);

网络

在浏览器端发送网络请求的有:XMLHttpRequestfetchwindow.sendBeacon,在小程序中则是 wx.request。要获取当前网页发出的网络请求,便需要劫持这几个 API,从而记录请求的耗时,Request 与 Response 等信息。

fetch 为例,劫持的逻辑可以简化成如下的代码:

// https://github.com/HuolalaTech/page-spy/blob/main/src/plugins/network/proxy/fetch-proxy.ts
const originFetch = window.fetch;

window.fetch = function (input: RequestInfo | URL, init: RequestInit = {}) {
  const fetchInstance = originFetch(request, init);

  // 获取请求的 URL, METHOD, REQUEST HEADERS 等信息
  const url = retrieveUrl(input);
  // ...

  const startTime = Date.now();
  fetchInstance.then((res) => {
    const costTime = Date.now() - startTime;

    // 获取 RESPONSE HEADERS 等信息
    const status = res.status;

    // 这里一定要使用 res.clone(),避免影响到后续在业务中的使用
    const responseBody = res.clone().text();
    // res.clone().json() | res.clone().blob() | res.clone().arrayBuffer()
  });

  return fetchInstance;
};

XMLHttpRequestnavigator.sendBeaconwx.request 的劫持与上述代码思路相似。不过因为运行环境的限制,有许多的 header 无法通过 API 获取。

  • Accept-Charset,Accept-Encoding
  • Access-Control-Request-Headers
  • Access-Control-Request-Method
  • Connection
  • Content-Length
  • ...

完整的列表参见 https://fetch.spec.whatwg.org/#forbidden-header-name

Storage

浏览器支持多种形式的存储:sessionStoragelocalStoragecookie。对于 cookie 可以使用新的 API window.cookieStorage,从而避免了手动解析 cookie,并且可以获取更多有关 cookie 的配置信息。sessionStoragelocalStorage 都是 Storage 实例,遍历方法也是类似的。

小程序中的存储为 wx.getStorage* 这一类 API。通过劫持原有的实现,即可实现。代码不再赘述。

// https://github.com/HuolalaTech/page-spy/blob/main/src/plugins/storage.ts
const cookies = await window.cookieStorage.getAll();

for (let i = 0; i < window.cookieStorage.length; i++) {
  const key = window.cookieStorage.key(i);
  const value = window.cookieStorage.getItem(key);
}

因为部分 cookie 添加 HttpOnly 属性,通过 API 的形式并不保证可以获取到所有的 cookie。

动态运行代码

前端开发者会经常在浏览器控制台输入代码片段并执行。PageSpy 也是支持这种功能。调试端输入的代码本质上就是字符串,将其封装成特定消息后发送到用户端并执行。在浏览器中,使用 eval 或者 new Function 都可以动态运行代码。

// https://github.com/HuolalaTech/page-spy/blob/main/src/utils/socket.ts#L282
const result = new Function(`return ${message.source.data}`);

数据的序列化与展示

动态运行代码后的返回值复杂多变,这里需要面临下面两个问题:

  1. 采取何种序列化数据
  2. 用户端发来数据该如何展示

对于问题 1,直接使用 JSON.stringify 可能是不行。很多数据无法直接被序列化。这里可以参考 Chrome devtools 的做法:像 numberstring 这类简单的数据直接转化成序列化后传输。对于一些复杂的对象,先获取的自身的属性,转化成相应的 JSON 数据再发送出去。仅当在用户点击展开后,才去查找原型链,再重复上一步的操作。

打印复杂对象

基于上面的方案,问题 2 就比较好解决了。相当于要实现一个支持懒加载的 JSONView 组件。对于组件中包含 children (支持展开) 的节点,需要给其分配一个 id,从而确保在点击之后可以找到原来在内存中的数据。在 PageSpy 的代码中使用 Atom 来记录这种对应关系。

获取对象自身的属性可以使用 Object.getOwnPropertyDescriptors,原型链查找使用 Object.getPrototypeOf

// 获取对应自身的属性,并计算对应的值
const descriptors = Object.getOwnPropertyDescriptors(cacheData);

// 沿原型链查找,并且生成节点数据
const proto = Object.getPrototypeOf(data);
const descriptors = Object.getOwnPropertyDescriptors(proto);

严格来说,还应该调用 Object.getOwnPropertySymbols 获取对象的 Symbol 属性与其对应值。不过实践中,对象的 Symbol 属性使用少,暂时忽略。

复杂对象的展示的逻辑相对复杂,完整的代码可以到这里查看。

总结

本文带领大家对 PageSpy 的核心逻辑进行了梳理。跟着本文的思路,读者可以大致搞清楚 PageSpy 这个前端远程调试工具的内在实现。实现一个远程调试工具并没有什么黑魔法,最终围绕着还是浏览器/小程序中的核心 API 来做。

自 PageSpy 上线以来,许多知名互联网企业中的开发者都已经在公司内部署该服务,实测下来,确实解决了前端开发者远程调试的烦恼。最后,对 PageSpy 感兴趣的用户不妨去我们的 Github 仓库看看 https://github.com/HuolalaTech/page-spy-web ,里面包含更详细的使用文档与贴士。

PageSpy 用户

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment