小程序调试技术详解(基于小猴小程序)

Posted Sahadev_

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了小程序调试技术详解(基于小猴小程序)相关的知识,希望对你有一定的参考价值。

本篇文章主要围绕小猴小程序调试技术第三版进行展开。

在上一篇导读文章中提到,小猴小程序的调试部分从无到有一共经历了3个版本。本篇文章会详细描述面向开发者的调试功能是如何实现的。

文章将会描述以下部分:

  • 调试实现的基本通信关系结构。
  • 如何实现完整的DOM审查能力。
  • 如何实现Console。
  • 如何实现Source以及断点调试。
  • 如何实现对网络记录的审查。
  • 如何实现基于页面的数据审查。

基本通信关系结构概述


上图完整的表达了实现小程序调试技术各关键部分之间的关系。主要分为以下几个关键角色:

  1. 调试面板: devtools frontend。
  2. 调试中继服务。
  3. 渲染层页面栈。
  4. 逻辑层运行容器。

调试面板

调试面板与我们开发者经常所用的开发者工具保持一致。它来自于chrome devtools frontend项目,也就是被嵌在chrome浏览器中的开发者工具。

不过在这里我采用的是2020年5月1日的版本。因为从这个版本之后,整个chrome devtools frontend全是用TS写的。这就导致调试起来非常不便,因为每次修改需要花费大约10分钟的时间编译后才能运行。

这里有篇文章简要的描述了devtools frontend的概况:深入理解 Chrome DevTools

chrome devtools frontend(后文简称为frontend)是通过WebSocket与外部保持通信的。内部传输的信息就是:Chrome Devtools Protocol。默认情况下,在启动frontend后,通过Url传给frontend一个调试源。类似于这样:http://localhost:8090/front_end/devtools_app.html?ws=127.0.0.1:9222/devtools/browser/2b82a4a4-c047-40f8-bbc4-7a09fdc501d3。

那么调试源的信息从哪获得呢?通常执行JS代码的引擎都会暴露调试源信息给外部。例如Node:

node --inspect-brk index.js
Debugger listening on ws://127.0.0.1:9229/51d4ee96-3759-4e04-8b2a-c0fd8a1c5db2
For help, see: https://nodejs.org/en/docs/inspector

例如Electron:

// 通过以下方式公布对外调试信息
app.commandLine.appendSwitch("remote-debugging-port", 8820);
app.commandLine.appendSwitch("remote-debugging-address", "http://127.0.0.1");
// 通过以下方式获得调试信息,这个接口会返回Electron中所有进程的调试源
http://127.0.0.1:8820/json/list
// 返回结果:
[
  
    "description": "",
    "devtoolsFrontendUrl": "/devtools/inspector.html?ws=127.0.0.1:9333/devtools/page/547B0C088951191189B7878999BA8048",
    "id": "547B0C088951191189B7878999BA8048",
    "title": "127.0.0.1:8886/dashboard.html",
    "type": "page",
    "url": "http://127.0.0.1:8886/dashboard.html",
    "webSocketDebuggerUrl": "ws://127.0.0.1:9333/devtools/page/547B0C088951191189B7878999BA8048"
  ,
  
    "description": "",
    "devtoolsFrontendUrl": "/devtools/inspector.html?ws=127.0.0.1:9333/devtools/page/6457E98873E6402B53C79C8A374723F6",
    "id": "6457E98873E6402B53C79C8A374723F6",
    "title": "127.0.0.1:8886/main.html",
    "type": "page",
    "url": "http://127.0.0.1:8886/main.html",
    "webSocketDebuggerUrl": "ws://127.0.0.1:9333/devtools/page/6457E98873E6402B53C79C8A374723F6"
  
]

具体信息可以查看:https://www.electronjs.org/docs/latest/api/command-line-switches/#–remote-debugging-portport

拿到了调试源后,就可以使frontend与调试源建立WS连接进行代码调试控制了。但这对于一款小程序开发者工具还远远不够,因为frontend只能连接一个调试源,而小程序实际是逻辑层与渲染层分离的,会有对应两个调试源。所以要不然只能调试逻辑代码,要不然只能进行DOM审查。所以怎么办呢?

最开始的解决办法是弄两个frontend实例出来,各自与逻辑层、渲染层的调试源进行连接,再通过一种巧妙的方式在切换DOM或Console时切换这两个frontend。但终究感觉这种方式不是很正统,只是一种取巧的办法。

于是调试中继服务诞生了。

调试中继服务

上图中的粉红色区域表示了调试中继服务。中继服务所做的事情有:

  • 承担与frontend的通信。
  • 承担与逻辑层、渲染层调试源的通信(DOM、Console、Source、Network、Memory等等)。
  • 承担与逻辑层运行容器的通信(页面数据审查)。

所以中继服务所扮演的角色是消息分发与消息汇总,它来负责frontend与逻辑层、渲染层调试源的无缝通信。

调试中继服务内部有一个WebSocketServer与两个WebSocketClient。WebSocketServer负责与frontend通信。两个WebSocketClient分别负责与逻辑层调试源、渲染层调试源通信。此外,它还持有逻辑层运行容器的引用,负责执行一些JS脚本,这在数据审查的功能上会用到。

渲染层页面栈

小程序是多页面的,这与常规的H5应用有很大的不同。所以渲染层页面栈就出现了。它负责所有页面的管理,比如进栈、出栈、销毁等等。而调试中继服务所连接的对象永远是页面栈栈顶的那个BrowserView所对应的调试源。

逻辑层运行容器

逻辑层运行容器主要的职责是负责逻辑代码的运行。在小程序代码编译完成后,会生成一个logic.js这样的文件。而为了创造一个干净的运行环境,在逻辑层的运行环境中增加了一个worker,以保证开发者写的逻辑代码无法获得外部的API,比如无法访问window或者document。

好,介绍了以上四大关键角色之后,接下来会分别详细描述各个调试工具是怎么实现的。

各调试工具的实现

作为前端开发,我们经常用到的调试工具有:

  • Elements 用于DOM的审查与CSS样式的审查。
  • Console 用于在控制台看一些日志,执行一些脚本等。
  • Source 用于查看运行代码与控制调试的执行。
  • Network 用于查看所有的网络访问记录。包括资源、XHR、WS等。
  • Inspect 这个其实在浏览器中没有集成,但我们经常会使用的类似Vue-devtools与React-devtools这类调试工具。通过它可以查看页面所声明的data以及对data的一个修改。

在开始讲各个面板之前,我先简单描述一下小程序开发者工具启动后为调试做了哪些准备工作:

  1. 小程序开发者启动。
  2. 启动调试中继服务。
  3. 启动frontend静态资源服务。
  4. 通知小程序开发者工具载入frontend。
  5. frontend与调试中继服务建立WS连接。
  6. 开始等待小程序的载入。

小程序载入后中继服务又做了哪些事呢?

  1. 扫描Electron提供的所有调试源。
  2. 过滤出逻辑层调试源与渲染层调试源。
  3. 分别与逻辑层调试源、渲染层调试源建立WS连接。
  4. 发送frontend的消息给逻辑层调试源与渲染层调试源。
  5. 最后就是调试源与frontend的正常通信了。

Elements


上图为Elements的一个实现图。因为直接使用的是chrome devtools frontend的源代码,所以这里的功能全部是不需要作改动的。但为了保证正常的功能使用,还是需要做一些额外的处理的:

  1. 因为frontend在开发者工具启动后就启动了。frontend在启动后会马上发消息给被调试源,在这里是由调试中继服务接收的。而此时还没有加载小程序代码,所以还不能做简单的转发。需要由中继服务先将消息做缓存处理,等到小程序加载完成后,再把消息分别发送给逻辑层调试源或渲染层调试源。如下图所示:
  2. 因为小程序是多页面的,所以在打开新页面或返回时都需要刷新当前页面的DOM结构到Elements。Elements的DOM刷新这里遇到了一些挑战。因为页面栈的切换是外部的主动行为,所以我们需要怎么告知frontend该更新DOM了?这里就不得不深入去看frontend的源码,了解frontend初次是怎么去获得DOM信息的。再根据它内部的上下文看看怎么触发它再次请求DOM。浏览器的页面重定向功能给了我很大的启发。最终经过大量的摸索与实践,知道了可以通过以下方式触发DOM的更新:
// 触发页面DOM更新
frontendWS.send('"method":"DOM.documentUpdated","params":');

但这就完了吗?不,问题还有。这时发现hover元素与修改样式的功能都不起作用了。于是又经过一轮的排查发现需要开启一些能力才可用:

// 发送消息给渲染层
viewWS.send('"id":2,"method":"Page.enable"')
viewWS.send('"id":3,"method":"Page.getResourceTree"')
viewWS.send('"id":5,"method":"DOM.enable","params":')
viewWS.send('"id":6,"method":"CSS.enable"')
viewWS.send('"id":10,"method":"Overlay.enable"')
viewWS.send('"id":11,"method":"Overlay.setShowViewportSizeOnResize","params":"show":true')
viewWS.send('"id":25,"method":"Page.setAdBlockingEnabled","params":"enabled":false')

这时的时序图如下所示:

3. 因为中继服务的特殊性,所以需要识别哪些消息是传给渲染层调试源的,哪些是传给逻辑层调试源的。这里就需要对Chrome DevTools Protocol有个非常清楚的了解。
基本逻辑如下:

if (message.includes('DOM') || message.includes('CSS') || message.includes('Page') || message.includes('Overlay')) 
    // 发送给渲染层调试源
 else 
    // 发送给逻辑层调试源

Console & Source & Network & Memory

这4个tab都连接的是逻辑层调试源,所以没有Elements那么复杂。只需要保证frontend与逻辑层调试源的信息是畅通的就可以。不过需要做特殊处理的仍然有以下三点:

  1. 页面进栈出栈时都不得再次与逻辑层调试源建立连接。
  2. 重新载入小程序时逻辑层运行容器需要reload。
  3. 如果需要监听网络情况,则需要在逻辑代码中增加Ajax请求。
  4. 如果想要在Console中正确的执行某些脚本,需要选择worker的执行上下文。

DataInspect

以上5个部分总体来说逻辑并不复杂,只需要确保信息分发合理、顺序不乱、数据不丢失就可以了。但马上要介绍的数据审查就不是一般的复杂了。

数据审查类似于vue-devtools,主要提供针对页面上声明的data的查看与修改的能力。这项能力必须是从0到1独立开发出来。因为frontend并没有这种能力。

摆在我面前的第一个问题就是:怎么在frontend上增加一个tab?

在frontend上增加tab

这个问题困扰了我很久。之前曾采用过chrome extensions的能力成功的在调试面板上增加了一个tab,但这种能力仅限于chrome浏览器自身或者使用electron自带的开发者工具才支持。而我们这里为了高度定制化,不得不采用了chrome devtools frontend的源码来做的。而frontend又不支持这种能力,于是踏上了frontend源码深度阅读之路。

经过大量的调试+阅读,才知道了frontend是如何加载一个tab的。这里不得不说一下frontend各个tab的加载机制。

在frontend根项目有这么一个文件:

在devtools_app.json中声明了需要frontend动态加载的所有模块。每个模块的名称代表了这个模块所在的目录。例如数据审查所在目录为inspect,而这里就需要增加声明 "name": "inspect"

在配置了需要加载模块名称之后呢,接下来就需要了解这个模块是怎么加载的。在每个模块的根目录下需要定义一个名为module.json的文件。需要在这个文件中声明当前模块需要加载的资源信息以及tab所需要放置的位置以及tab所对应的面板等关键信息。例如inspect/module.json就是这么定义的:


  "extensions": [
    
      "type": "view",
      "location": "panel",
      "id": "inspect",
      "title": "Inspect",
      "order": 101,
      "className": "Inspects.InspectsPanel"
    
  ],
  "dependencies": [
  ],
  "scripts": [],
  "modules": [
    "inspect.js",
    "inspect-legacy.js",
    "ElementsPanel.js"
  ],
  "resources": [
  ]

按照以上方式定义完成后,frontend就开始按照一定的顺序去加载并执行这些文件了。关键代码如下:

// root/Runtime.js 部分关键代码
_loadPromise() 
  ...
  const dependencies = this._descriptor.dependencies;
  const dependencyPromises = [];
  for (let i = 0; dependencies && i < dependencies.length; ++i) 
    dependencyPromises.push(this._manager._modulesMap[dependencies[i]]._loadPromise());
  
  this._pendingLoadPromise = Promise.all(dependencyPromises)
                                 .then(this._loadResources.bind(this))
                                 .then(this._loadModules.bind(this))
                                 .then(this._loadScripts.bind(this))
                                 .then(() => this._loadedForTest = true);
  return this._pendingLoadPromise;


_loadModules() 
  if (!this._descriptor.modules || !this._descriptor.modules.length) 
    return Promise.resolve();
  
  const namespace = this._computeNamespace();
  self[namespace] = self[namespace] || ;
  const legacyFileName = `$this._name-legacy.js`;
  const fileName = this._descriptor.modules.includes(legacyFileName) ? legacyFileName : `$this._name.js`;
  return eval(`import('../$this._name/$fileName')`);

通过以上方式便可以加载并运行在module.json中所声明的文件了。但目前尚未创建一个tab出来。还得继续往下走。

在所有资源都加载完成后,正主出来了。main/MainImpl.js负责整个面板的构建。在这个构造函数内部会等待DOMContentLoaded事件执行完毕后触发其内部的_loaded方法执行。这个方法会触发整个frontend页面的实例化过程。

// main/MainImpl.js 306
_showAppUI(appProvider) 
  ...
  const app = (appProvider).createApp();
  ...
  app.presentUI(document);
  ...

上面这部分代码会将整个面板挂载到document到,使页面上出现内容

再接着会通过ui/InspectorView.js这个类去构造顶部的Tab。关键代码:

export class InspectorView extends VBox 
  constructor() 
    super();
    ...

    // Create main area tabbed pane.
    this._tabbedLocation = ViewManager.instance().createTabbedLocation(
        Host.InspectorFrontendHost.InspectorFrontendHostInstance.bringToFront.bind(
            Host.InspectorFrontendHost.InspectorFrontendHostInstance),
        'panel', true, true, Root.Runtime.queryParam('panel'));

    ...
  

再通过动态实例化的方式去构造对应的面板类:

_createInstance() 
  const className = this._className || this._factoryName;
  ...
  const constructorFunction = self.eval((className));
  ...
  // 实例化对应的类
  return new constructorFunction(this);

在这里我们在module.json中所定义的类为:Inspects.InspectsPanel。这个类的代码如下:

import * as UI from '../ui/ui.js';

export class InspectsPanel extends UI.Panel.Panel 

    constructor() 
        super('inspect');

        const vueRootNode = createElement('div');
        vueRootNode.id = 'vue-root'
        this.element.appendChild(vueRootNode);

        self.ee = new EventEmitter3();

        new Vue(
            render: createElement => createElement(inspect_frontend),
            el: vueRootNode
        )

    

    /**
     * @return !InspectsPanel
     */
    static instance() 
        return /** @type !InspectsPanel */ (self.runtime.sharedInstance(InspectsPanel));
    

InspectsPanel这个类就是数据审查Tab所对应的那个页面。frontend的所有页面都是拿项目内部定义的控件去写的,非常复杂,加之又没有参考文档,所以这里将要实现的逻辑交给Vue实现非常合适。

在上面的代码中,Vue渲染了一个inspect_frontend。这个东西从哪来的呢?这是我单独写的一个Vue项目,再通过vue-cli构建出来一个库,被挂载到了全局。它其实就是个render函数。运行效果如下:

好,到这里就可以在frontend上通过Vue写具体的内容了。算是到达了一个关键的节点。接下来就是如何获得审查数据的部分了。

审查数据获取过程

为了完成基本的能力,需要有3个基本的功能:

  1. 获得小程序当前所有的页面。
  2. 获得小程序某个页面的页面数据。
  3. 修改数据。

因为小程序的所有数据都是由逻辑层控制的,所以我只需要去逻辑层拿数据就可以了。虽然说起来很简单,但实际上拿的这个过程非常复杂。那么复杂在哪里呢?

我简单说一下这个过程:

  1. 数据审查处通过ws发消息给中继服务。
  2. 中继服务收到消息后识别。
  3. 识别后通过执行JS脚本给逻辑层容器发送消息。
  4. 逻辑层容器收到消息后再通过postMessage发消息给worker。
  5. worker收到消息后执行相应的查询。
  6. worker的函数执行完之后,通过postMessage发消息给外部的逻辑层容器。
  7. 外部的逻辑层容器收到消息后通过IPC发消息给Electron。
  8. Eletron收到消息后交给中继服务进行识别。
  9. 中继服务识别后,找到对应的回调方法并触发。
  10. 回调方法通过WS再发消息给frontend。
  11. frontend再识别对应的回调给上层调用处。

这里的逻辑可以参考第三版的运行关系图来看(下图红色箭头走向)。

这个完整的过程一共经历了9个对象,数据被一层层包装,再一层层的拆开。复杂程度可堪比计算机的网络层次了。

查询页面数据

当打开数据审查面板时,第一个需要知道的就是当前打开了哪些页面。如下图所示:

有了上小节所描述的过程后,在这里查询页面数据就比较简单了。在业务代码中只需要通过这样的方式查询即可:

getPages() 
  sendMessage('getPages').then(res => 
    this.pages = res;
  ).catch(err => 
    console.error(err);
  )

而这个sendMessage是为了方便数据审查使用所抽象的公共方法:

export function sendMessage(method, params) 
    return new Promise((resolve, reject) => 
        self.target._router.sendMessage("", "DataInspect", `DataInspect.$method`, params, (error, result) => 
            if (error) 
                console.error('Request ' + method + ' failed. ' + JSON.stringify(error));
                reject(null);
                return;
            
            resolve(result);
        )
    )

第三行的self.target是frontend内部给上层业务使用的一个基本通信对象。它负责通过WS发消息给外部,再将收到的消息一一回调给上层对应的回调方法。

self.target这种可以保证回调消息不乱的机制让我开了眼界。

中继服务在收到消息后,进行简单的识别:

// 数据审查消息分发
if (message.includes('DataInspect')) 
    const dataInspectMessageBody = JSON.parse(message);
    if (dataInspectMessageBody.method === 'DataInspect.getPages') 
        // 获取所有的页面信息
        const result = await this.executeCodeInAdapter('globalVar.getRoutes()');
        this.sendMessageToDataInspect(dataInspectMessageBody.id, result);
    

上面的executeCodeInAdapter就是负责去逻辑层容器执行指定脚本的。参数globalVar.getRoutes()就是定义在逻辑代码中的一个函数。它会被层层传递,最终到达worker中运行:

// worker运行的adapter.js
function dispatchMessage(message) 
    switch (message.type) 
        case 'loadCode':
            loadLogicFile(message.content);
            break;
        case 'runCode':
            const result = eval(message.content);
            syncExecuteResult(
                executeResult: result,
                executeExpression: message.content,
                executeId: message.id,
            );
            break;
        default:
            console.warn(`未处理的消息类型,请关注: $message.type`);
            break;
    

最终,globalVar.getRoutes()会被eval函数执行。并同步返回一个结果。而这个结果并不能同步返回给外面,还得通过一系列过程发送给外部,最终在electron中进行结果分发:

onMessage( type, args ) 
  // 同步执行结果
  if (type === 'syncExecuteResult') 
    const resolveCallback = this.resolveMap[args.executeId];
    if (resolveCallback) 
      resolveCallback(args.executeResult);
    
   else 
    // 其它通信消息,比如与渲染层的通信
    this.options.onMessage(messageObj);
  

上面函数中的resolveMap就是保留了所有异步消息的回调方法,当有结果时,根据id取到对应的方法执行就可以了。

上面的逻辑就是借鉴的frontend的self.target的逻辑。

当resolveCallback被执行后,前文中的await this.executeCodeInAdapter('globalVar.getRoutes()')就会继续执行,并拿到结果。再通过sendMessageToDataInspect将结果通过WS返回给业务调用处。

到此,查询所有页面的信息逻辑通路完成。

查询指定页面的所有数据

与查询页面数据类似,也是基于同样的通信机制。只是方法不同:

sendMessage('getPageData', 
  pagePath: this.path // 页面路径,例如: pages/index/index
).then(res => 
  this.pageData = res;
).catch(err => 
  console.error(err);
)
设置指定页面的某项数据

这与查询数据也一样:

sendMessage('setPageData', 
  pagePath: path,
  data: 
    // 所编辑的key与value
    [this.editedKey]: value
  
).then(res => 
  this.$emit('submit-edit');
  self.ee.emit('refresh');
).catch(err => 
  console.error(err);
)

发送消息虽然都相同,但发送消息后的逻辑却有一些不同。在发送编辑消息后需要重新查询当前页面的数据以及通知DOM更新。

监听渲染层主动变更

监听渲染层主动变更时什么意思呢。就是说,会有渲染层主动触发的行为。例如:点击checkbox,在输入框输入内容等等。这些情况下也需要实时反馈到数据审查面板上。前面的三种方式都是主动去逻辑层拿的过程,而这个却是渲染层主动触发的。所以怎么解决这个问题呢?

我提供的思路是:

  1. 当开启数据审查面板时,主动向逻辑层注册一个方法。
  2. 当渲染层的数据发生变更时,会通过一系列过程告知逻辑层。
  3. 逻辑层再去触发第1步注册的那个方法,使得触发frontend中的回调。
  4. 当frontend收到回调后,再次进行注册。

具体实现逻辑如下:

  1. 在frontend中主动注册。
    这一步和上面三种数据查询方式没什么不同。
// 在frontend中主动注册回调
sendMessage('registerRefreshEvent').then(this.getPageData);
  1. 在中继服务中主动注册。
    当中继服务收到主动注册指定后,做特别处理。将resolve方法对外暴露。
// 中继服务消息分发
if (dataInspectMessageBody.method === 'DataInspect.registerRefreshEvent') 
       // 注册数据更新回调
       console.info(`已注册数据更新回调`);
       await new Promise(resolve => this.refreshEventResolve = resolve);
       this.sendMessageToDataInspect(dataInspectMessageBody.id, 小程序调试技术详解(基于小猴小程序)

小程序调试技术详解(基于小猴小程序)

支付宝小程序开发者工具可以用来调试微信小程序吗

小程序调试技术导读

小程序调试技术导读

小程序调试技术导读