JS复杂封装代码调试技巧指北

1,863 阅读33分钟

作者:崔嘉伟(曲吉) cuijiawei004@ke.com

本文记录了笔者在改造公司内一个管理着百万员工的权限系统时,积累的一些不用通读代码也能直接定位到想要的位置(:-)) 的调试方法。也许其中有些方法不是那么常见,但他们都有一个共同好处:你首次写好这些代码,不管在什么项目中都可以对相同的场景直接CTRL+C / CTRL+V解决问题(当然你愿意直接复制本文中的代码也是极好的)。虽然标题只写了“复杂封装”,但下文内容同样适用于文档缺失变量命名不恰当缺少能检索的特征字符串设计模式不合理等各类原因导致的代码阅读与定位困难,尤对组传代码不听话的问题具有特别疗效^_^

阅读本文后您将至少了解三种较为通用的源码定位方法:

方法1. 利用浏览器事件配合调用堆栈

  • 主要内容在 【一.1 通过调用堆栈定位影响页面的代码】 方法2. 利用函数替换
  • 替换内容在 【一.2 通过替换函数定位生产或消费特定网络调用的代码】
  • 替换方法在 【一.3 在页面代码执行前运行自己的代码】 方法3. 利用CDP动态注入(文中以点击页面DOM自动输出该DOM在jsx/tsx源码中被定义的位置为例)
  • 基本原理运行效果在 【一.4 利用正则表达式定位生成任一DOM元素的JSX源码】
  • 注入方法在 【二.2 利用CDP高效动态修改JS代码的三种方法对比】【一.5 条件断点的特殊用法】
  • 注入位置在 【二.3 利用CDP定位JS语言的特征行为】【二.4 利用CDP锁定webpack一个函数的实例】
  • 注入内容在 【三.3 了解这些库的调用方法及实例】

在第四章还介绍了一些扩展调试能力的方法,比如在全局开启Node调试状态的方法,在Chrome中增强JS脚本能力的方法,还有一些基础性内容。  

一、基础定位篇

1. 通过调用堆栈定位影响页面的代码

笔者曾遇到一个页面,进入时不给任何特征提示就直接跳走了,也没有相关文档说明,当时找了很久才定位到关键函数。后面总结,遇到这种情况时,利用调用堆栈进行定位可能是一个比较好的方式。

调用堆栈记录了当前执行环境的上下文调用关系;在js中可以利用console.trace函数获取这个调用关系,具体执行时,参数一样位置不同的console.trace语句也会由于执行上下文不同,输出不同的结果;

以笔者刚才的项目为例,可以在窗口的onbeforeunload事件中调用console.trace函数:

// 这里是拦截页面跳转的代码
window.onbeforeunload = function () {
  console.trace('here jumps');
  debugger;
};

// 这里是模拟业务代码中的页面跳转
setTimeout(()=>{window.location='http://www.bing.com'}, 1000);

实际运行如下文截图:

image.png

注意上面代码中的核心是console.trace('here jumps')一句。该语句会打印出执行堆栈,如上图A处所示,Chrome完整的识别出本次页面跳转来自匿名函数(Console处手工输入)=>setTimeout语句=>匿名函数(setTimeout的第一个入参);这里测试代码比较简单,但是处理业务场景中更复杂的代码时,方法是完全一样的,只需要找到调用堆栈中最上面的来自src目录下的源码,一般就找到想要的位置了;同理也可以在触发debugger后,在截图的B部分(调用堆栈,Call Stack)手工切换查看执行环境,了解本次跳转的来源;两种方法原理相同,本质都是利用浏览器原生事件和调用堆栈进行特定行为的拦截;

类似的场景还有通过切换hash而不是整个pathname的方式来控制页面,可以通过onhashchange事件进行拦截,操作相同。

2. 通过替换函数定位生产或消费特定网络调用的代码

上面讲述了利用事件进行特定行为拦截定位源码的方法,还有一些场景并不会触发浏览器的某个特殊事件,或者通过事件拦截的方法不容易定位到具体代码;这时就可以考虑利用替换函数的方法进行定位拦截。

这里以网络访问为例,常见的网络请求方法主要有fetch函数和XHR对象两种,那么绝大多数情况,只要能拦截这两个原生函数,就可以愉快的拦截网络请求啦(事实上还有JSONP、长连接等特殊情况);尤其考虑到Chrome原生调试面板的网络流量搜索功能过于孱弱,如果在一些特定网络流量出现时能直接拦截下来大概是极好的:-P

上代码:

// 备份原始fetch函数
window.____fetch = window.fetch;

// 替换原始fetch函数,增加自己喜欢的功能
window.fetch = (...args)=>{
    console.trace("fetch log", JSON.stringify(args));
    return ____fetch.apply(null,args)
}

注意示例代码里仅打印了请求参数;如果需要对返回数据进行打印,则需自己接管fetch的返回过程;这一步涉及针对fetch的返回类型(ResponseReadableStream)进行特殊处理,以及完全兼容fetch行为的错误处理。本文主要以不同调试方法为主,想深入了解fetch函数标准行为的小伙伴可以查询更多相关文章:-)

针对XMLHttpRequest对象的处理方式也类似,如果只拦截请求参数的话,替换XMLHttpRequest.prototype.open方法就好。可以看到,上面的代码示例同样使用了console.trace方法,可以充分利用调用堆栈提供的上下文信息,定位到业务代码中到底是哪里发起了本次调用。  

3. 在页面代码执行前运行自己的代码

书接上文。为了替换JS函数从而达到“夹带私货”的目的,我们需要让自己的代码在页面原始代码执行前执行,否则页面已经用未修改的函数/对象初始化了网络调用功能,我们再去替换这些原生对象也来不及了;

根据是否愿意自己编写代码,替换的方法分为利用插件利用调试协议(及其衍生出的库)两种;

直接利用插件,比如Chrome插件商店中搜索关键词inject、injector或injection进行浏览;目前提供这种能力的插件用下来感觉都不够可靠,都存在着不能不能完美保证注入代码领先先于页面脚本运行,其中效果相对好的插件是 neocotic.com/injector, 大概以70%的概率完成任务,算是差强人意;但对于快速建立自己的产品原型也够用了,如果某次没成功,刷新页面就好;

如果愿意稍微编写代码,个人认为效果最好的工具是微软提供的Playwright调试库,该库对于为页面注入脚本的能力进行了针对性的优化,可以在源码中发现他综合利用了多个CDP函数保证代码注入功能的稳定性及通用性;具体的使用方法为,初始化好页面实例后(初始化页面的详细信息可以参考官网文档:Playwright Library | Playwright)再加一句话就完事了:

// 初始化页面
const browser = await chromium.launch(); // Or 'firefox' or 'webkit'.\
const page = await browser.newPage();\

// 注入代码,一句话搞定
await page.addInitScript({ path: './preload.js' });

可靠性极佳,而且整个过程干净又卫生:-)

4. 利用正则表达式定位生成任一DOM元素的JSX源码

本节开始介绍一种朴素简单方法,让页面上的DOM元素在被点击时自己汇报自己在代码中的位置,并给后面两章的内容留下改进空间。

比如,我们希望点击一个页面DOM后能够知道这个DOM是哪一行源文件渲染的,那么利用正则表达式在源码的DOM范围内注入onClick={()=>{console.trace("imhere")}}这个语句即可,上代码:

find ./src -type f -iname "*.js*" |xargs sed -i "" 's/\(<[^ />?:+;,=]\{1,\}\)\( \{1,\}[^,:;|&)}\d]\)/\1 onClick={()=>{console.trace("imhere")}} \2/g'

这个命令利用正则表达式查找jsx文件中描述DOM节点的代码,并在其DOM范围内注入onClick={()=>{console.trace("imhere")}}这段语句。利用前面章节所讲的console.trace函数的特性,只要在页面DOM上进行点击,就可以利用控制台输出定位到渲染这个DOM组件的源码了。

当确认了页面DOM和jsx文件源码对应关系后,再用git的未提交文件还原功能,或者在终端运行下述命令即可:

find ./src -type f -iname "*.js*" |xargs sed -i "" 's/ onClick={()=>{console.trace("imhere")}} //g'

这个命令用于还原上一个命令对文件造成的修改。

当然,这个方法是侵入式的,并不优雅;并且用正则表达式对文件进行修改也并不可靠,但这一节也提供了一种思路。 如果我们共同使用:本节思路 + 下文CDP动态patch代码 + AST语法解析,则可以结合此节的思路改造处一种非常可靠非侵入式工作流程,用于建立页面上渲染的DOM结果与jsx/tsx源码的对应关系(当然也可以用来做别的,反正都用上AST了,想写什么写什么)。

做好后点击DOM时的效果图如下:

image.png

点击后直接依次输出子组件到父组件关联的jsx/tsx代码所在的文件、文件中的位置和具体的源码内容。实现方法从下一节开始详细介绍:-)

5. 条件断点的特殊用法

这里介绍一种利用条件断点进行代码注入的方法。条件端点可以在Chrome开发者工具的源码面板进行设置,如下图:

image.png

条件断点的常见用法是返回一个值,如果返回值可以转换为true,则Chrome会在此条件断点处断下,否则不会断下。

但条件断点也是可以改变JS原始运行状态的,下面是一个简单的例子:

var a = 1;
console.log(a); // 在这里下条件断点,并且在条件中输入 a = 2; false 就会看到输出的结果变成了2,如果把条件断点关掉,就会看到输出的结果变回了1

知道了这个特性后,再搭配下文提到的CDP中用于设置条件断点的函数,就可以随意改变相关JS代码的执行流程啦:-)

二、CDP篇

1. 什么是CDP

CDP即Chrome DevTools Protocol,是谷歌提供的一种调试协议;通过CDP,外部工具可以充分利用各种Chrome浏览器的现有调试能力;在官网chromedevtools.github.io/devtools-pr… 上有具体介绍和推荐的使用库。

image.png

注意针对您调试的场景不同,所能使用的函数也不同;如果用于调试Chrome页面脚本,则几乎可以使用全部函数(对应左侧latest);如果用来调试NodeJS脚本,则只能使用v8-inspector(node)系列。

后面的章节假设您已经对CDP有相当的了解。关于CDP的基础信息可阅读本文附录篇的4、5、6三节。

2. 利用CDP高效动态修改JS代码的三种方法对比

对于已经写好的JS代码,至少可以在三个阶段进行相对高效的动态修改;分别是:网络连接阶段代码解析阶段代码执行阶段

在网络连接阶段动态修改代码

这种方法主要依赖CDP中的Network.setRequestInterception函数。该函数可以在Chrome发起网络连接/获取网络响应时进行操作。那么我们可以在页面请求JS脚本文件后,Chrome的V8引擎拿到JS脚本文件内容前,先对脚本文件做一些“修订”,再将修订后的文件内容传给Chrome。

这种方法由于Chrome首次解析就获得我们需要的JS文件内容,而且所有增加的内容都以原始代码的形式执行,因此效率最高。但是patch的成本也相对会高,主要体现在遇到使用eval执行的代码时(用NPM打包后会遇到这种)需要单独对eval的字符串参数进行解析。同理的还有用function方法创建的函数。另一个问题是,如果原始JS文件含有sourcemap,那么我们修订过的JS文件将不再和原始sourcemap文件对齐,使得Chrome读取并解析sourcemap时匹配错误。为了解决这个问题就需要我们先手工解析sourcemap,记录sourcemap和js语句的对应关系,然后修改js文件并同步修改sourcemap文件,使它和js语句保持对齐。

在代码解析阶段进行代码替换

这种方法主要依赖CDP中的 Debugger.scriptParsed事件进行代码替换。该事件可以让我们在代码完成解析时获得通知。当然根据场景不同也有可能是使用Debugger.setInstrumentationBreakpoint等函数先进行拦截。该函数会在新脚本(并不一定是新文件)载入时断下,这时可以响应Debugger.paused事件进行拦截。然后利用Debugger.setScriptSource函数进行脚本代码的修改。

因为我们要执行的代码也是以原生代码的形式执行,故这种方法的执行效率也很高。但要注意修改的时机并不是任意的,在有些情况下此函数会返回成功,但实际上并未成功,具体内容可以参考本文附录部分章节。

同时,这种方法解决了上一种方法中手动解析eval和function函数的问题,但依然有sourcemap对齐的问题,故额外成本也比较高。

在代码执行阶段动态修改代码

主要利用CDP的Debugger.setBreakpoint函数,搭配其条件断点参数使用。该函数可以对代码设置条件断点,上一章说过条件断点可以改变程序执行结果,那么我们就可以把需要patch的地方做成条件断点的条件,在js脚本执行时一同执行。

这种方法的编写成本是最低的,既解决了eval、function中的字符串参数手工解析的问题,也解决了sourcemap对齐的问题(因为我们额外注入的代码都出现在条件断点里,不会破坏原始代码和sourcemap的对齐关系)。并且可以通过启用、停用断点灵活的开启、关闭我们的代码注入,对于写产品原型、进行快速实验非常合适。

当然这种方法也有相应的缺点,就是条件断点的执行并不和原始代码完全在同一层面,会存在通信及运行环境切换等成本。但是对于产品原型的建立一般也够用了,当确认好需要注入的代码可以发挥作用后,推荐再用上面网络阶段与解析阶段的处理方式重写一遍以提升实际运行效率。

其他可能性

除了上述三种方法外,想动态修改函数还有其他方法,比如利用CDP debugger命名空间下的Debugger.setVariableValue函数,可以单独修改JS代码某个函数的返回值,但这种频繁调用CDP函数的做法会大幅降低代码执行效率,稍大一些的场景就不适用了;所以setVariableValue及同类的其他方法这里不再详细介绍。  

3. 利用CDP定位JS语言的特征行为

第一章已经介绍了使用浏览器事件及函数替换定位特征源码的方法;对于这两种方法不能定位的情况,都可以利用本节的方法进行锚定。

比较基础且笨重的方法, 比如先从要定位的行为中找到一个特征值,然后针对主要的/每个赋值语句都自动注入判断语句,如果发现运行中某个变量的值等于/包含这个特征值,就打个记录,最后就会得到一份这个特征值从“出生”到“死亡”,过程中每个阶段的流转情况,可以精确到流转到了哪个文件哪个函数的哪一行。

感兴趣的小伙伴可以尝试用上文介绍的CDP方法去找到一个搜索引擎的核心搜索函数。具体的操作方法为:

  1. 让浏览器在调试模式运行
  2. 随意拟定一个搜索特征值,如123
  3. 选择自己喜欢的方法利用CDP注入代码,针对每个赋值语句都判断所赋的值是否包括123,如果包含就打个记录,或直接断下
  4. 在搜索框中输入第2步拟定的特征值,如123
  5. 根据断点位置或记录信息看到搜索业务相关函数

这整个流程和调试业务代码的方法已经非常接近了。唯一注意在业务场景中这样找到的是编译后的代码,需要配合sourcemap才能关联源码。

当然也有相对优雅的方法,比如可以在npm编译阶段进行代码动态注入。还记得第一章介绍过用正则表达式修改源码,然后通过任一DOM去找到生成它的原始JSX文件源码。那一节介绍的方法具有诸多局限性,那么这里给出更为优化的版本:

  1. 让Node执行环境在调试模式运行
  2. 选择自己喜欢的方法利用CDP针对webpack读取业务代码的函数进行代码注入,在读入文件后,进行后续步骤前,调用AST解析库对源码进行自己喜欢的操作,比如给其中的所有DOM注入一些额外属性
  3. 开始你的构建,enjoy it :-)

上述步骤中,具有两个核心:定位到webpack读取业务代码的关键函数,以及利用AST进行修改。定位webpack关键函数的操作将在下节介绍,AST操作的部分将在第三章介绍。

4. 利用CDP锁定webpack一个函数的实例

书接上文,如果我们要在Node执行环境定位一个函数,往往会比在浏览器中更困难一些,因为具有庞大依赖库的Node执行环境中的代码量很容易比浏览器环境中的代码量高出1或2个数量级,所以需要使用更高效的方法(像是上一节所述的“比较基础所笨重的方法”,如果不是确实没有其他方法的话,是不太推荐的)。

这时要尽量缩减修改代码的范围,比如这里要找出的函数已知具有文件操作功能,那么Node环境中最主要的文件操作库是fs(当然也有其他几个变种,但这里先从主要的情况说起)。我们可以对fs库中所有的同步、异步文件打开函数进行代码注入,这样注入点数量可以控制在10以内。只要找出其中读了关键业务代码(项目src目录下的源码文件)的fs库调用,查看调用堆栈,就能找到webpack中关键的函数了。这里定位出来的函数是是LoaderRunner.js文件中的processResource函数。

image.png

那么我们只要动态给processResource函数注入代码,配合下一章介绍的AST操作,就可以在业务代码被webpack读取后、被正式构建前做很多有趣的修改了。

这里有个注意点,前文都是通过给DOM相关JSX源码插入onClick事件进行注入的,但如果不是原生的React组件可能不会响应onClick属性,这时可以修改它的render函数,在函数返回前输出一些调试信息用于定位源码,操作方法都是一样的。同时,如果已经下决心要修改业务代码中的render函数,还可以做一些更有趣的操作,比如生成不同组件之间的调用链,包括:谁调用了谁,调用时传递了哪些参数,组件自己内部又有哪些公用变量(无论类组建的state还是函数组建的hook)。这些相当于把redux的历史回溯能力赋予了各类历史项目,对于调试老旧代码都是非常重要的。

同样的方法还可以应用到VUE系组件上,操作方法完全相同。

三、AST篇

1. AST是什么

AST即abstract syntax tree,抽象语法树,是一种标准化、结构化的语法表示方法。利用已有的AST生成、编辑库,我们可以对代码做各种检查与修改。事实上目前主流的JS语法检查和编译库都依赖于成熟的AST能力,包括tslint,eslint,babel,typescript系列。

2. 快速对比几种AST解析库的能力

墙裂推荐使用AST explorer,自带了对用户友好的AST解析与编辑结果展示功能,并集成了当下能找到的几乎所有AST解析与编辑库,更棒的是,它是跨语言的,不仅可以用于评估JS系语言的AST库,对于PHP、C,JAVA等语言同样适用。

image.png

对于混入DOM操作的typescript语言(tsx),目前拥有最完整解析能力的库可能是typescript官方库和第三方的typescript-eslint库。

typescript-eslint库的目标是让eslint语法提示在typescript语言规范上实现完美兼容。关于这个项目,可以参阅官网TypeScript ESLint (typescript-eslint.io)。下文也将重点以这个库为例提供调用示例。

3. 了解这些库的调用方法及实例

很多具有AST解析能力的库,在文档中并没有单独说明如何调用这部分能力;如果自己探索的话,往往需要花费很多时间。这时可以参阅astexplorer项目的官方源码。基于良好的目录设计,我们可以快速找到想用的AST库的调用方法。

只需在astexplorer/website/src/parsers/js at master · fkling/astexplorer · GitHub目录下寻找即可。

以typescript-eslint库为例,比如我想要调用它提供的AST解析能力,但是相关说明在官方文档中不好找,这时就可以在源码目录下找到用这个解析库命名的文件:

image.png

并在其中找到核心部分:

// 摘录自https://github.com/fkling/astexplorer/blob/master/website/src/parsers/js/typescript-eslint-parser.js

  loadParser(callback) {
    require(['@typescript-eslint/parser'], callback);
  },

  parse(parser, code, options) {
    return parser.parse(code, options);
  },

  getDefaultOptions() {
    return {
      range: true,
      loc: false,
      tokens: false,
      comment: false,
      useJSXTextNode: false,
      ecmaVersion: 6,
      sourceType: 'module',

      ecmaFeatures: {
        jsx: true,
      },
    };
  },

把上面的代码稍加修改,我们就可以自己写出利用这个库进行AST语法解析的代码:

var parser = require('@typescript-eslint/parser/') // 这里指向typescript-eslint库的实际安装位置
var code = 'console.log("123")' // 这里是我们需要进行解析的代码
var option = {
    range: true, // 注意没有这个选项会报错,提示范围0无效
    loc: true,
    tokens: true,
    comment: true,
    ecmaVersion: 'latest',
    sourceType: 'module',
    ecmaFeatures: {
      jsx: true,
    },
  }

var result = parser.parse(code, option) // 这句运行后会得到解析后的结果

然后在解析出的结果上,放飞自我进行各种修改~

四、附录篇

1. 在新进程中默认开启Node调试

上文介绍了很多利用CDP的调试方法,但实践中会一些库用Spawn.sync开启新进程导致调试状态丢失的情况;目前来看有三种方法可以处理:

1、利用CDP动态Patch Spawn.sync方法;此操作同上面CDP Patch fs库的操作流程,这里不再重复;

2、建立一个node的wrapper放在node目录下替换原始node程序;外部程序用默认路径调用node时其实调用的是wrapper,然后wrapper将外部传过来的参数增加调试选项传递给原始node程序;这种方法需要处理父子进程的上下文对齐,并保证SheBang执行环境下表现的一致性;实践过程中需要对系统行为的了解较多,这里不展开讨论;

3、重新编译node程序,默认打开调试选项并生成随机调试端口;这种方法比2要简单,具体操作方法为,修改node源码(下载地址:下载 | Node.js 中文网 (nodejs.cn) 写本文时node版本为16.13)src目录下env.cc文件的Environment类构造函数:

// 修改这个函数
Environment::Environment(IsolateData* isolate_data,
                         Isolate* isolate,
                         const std::vector<std::string>& args,
                         const std::vector<std::string>& exec_args,
                         const EnvSerializeInfo* env_info,
                         EnvironmentFlags::Flags flags,
                         ThreadId thread_id)

// 中间省略代码若干,直到下面这行
  options_ = std::make_shared<EnvironmentOptions>(
      *isolate_data->options()->per_env);

// 手工添加如下内容
  if((getenv("NODEINSPECTEVERYWHERE"))) {
    // 默认进入调试状态
    options_->debug_options().inspector_enabled = true;
    // 随机设定一个端口
    struct timeval tvStart;
    gettimeofday(&tvStart, NULL); // 调用高精度时间函数用于生成随机数种子
    srand((int)tvStart.tv_usec);
    int port = rand() % 900 + 100 + 9000; // 利用随机数指定调试端口,避免端口冲突
    options_->debug_options().host_port.set_port(port);
  }
  // 以上为手工添加的内容
  
  inspector_host_port_ = std::make_shared<ExclusiveAccess<HostPort>>(
      options_->debug_options().host_port);

其中getenv("NODEINSPECTEVERYWHERE")用来获取名为NODEINSPECTEVERYWHERE的环境变量的取值;这里作为开关使用,即设置了该环境变量则开启全局调试,未设置全局变量则不自动开启;具体使用中可以换成其他的条件,比如将标志位写入文件中,或者结合nvm使用,取消这里的判断条件;

代码中options_->debug_options().inspector_enabled = true;用来开启调试状态;但如果只设置调试状态不修改默认端口,则从第二个进入调试状态的node开始会提示端口被占用,所以使用int port = rand() % 900 + 100 + 9000;语句设置随机调试端口;这里要注意网文中转载较多的随机数生成源码一般是用time函数,但以笔者的MAC环境为例,这种方法精度不足,如果一秒内多次运行,则会获得重复的“随机数”,因此这里使用高精度时间函数,即gettimeofday

然后再进行编译(编译指令./configure以及make,更多编译指引另请参阅node/BUILDING.md at master · nodejs/node · GitHub)后即可生成能开启全局调试的node程序了;

2. CDP中setScriptSource函数无法真实成功的原因简析

上文中介绍了利用CDP修改JS函数脚本的几种方法,其中setScriptSource函数有一种在(笔者阅读过的)文档中未描述的行为:函数调用了,返回值也显示成功,但实际上没有成功。

这种行为可以在源码中找到痕迹,涉及Chromium工程的两个文件:

// chromium\src\v8\src\debug\liveedit.cc 文件

bool CanPatchScript(const LiteralMap& changed, Handle<Script> script,
                    Handle<Script> new_script,
                    FunctionDataMap& function_data_map,
                    debug::LiveEditResult* result) {
  for (const auto& mapping : changed) {
    FunctionData* data = nullptr;
    function_data_map.Lookup(script, mapping.first, &data);
    FunctionData* new_data = nullptr;
    function_data_map.Lookup(new_script, mapping.second, &new_data);
    Handle<SharedFunctionInfo> sfi;
    if (!data->shared.ToHandle(&sfi)) {
      continue;
    } else if (data->stack_position == FunctionData::ON_STACK) {
      result->status = debug::LiveEditResult::BLOCKED_BY_ACTIVE_FUNCTION; // 就是这里的BLOCKED_BY_ACTIVE_FUNCTION状态导致了上述行为;
      return false;
    } else if (!data->running_generators.empty()) {
      result->status = debug::LiveEditResult::BLOCKED_BY_RUNNING_GENERATOR;
      return false;
    }
  }
  return true;
}
// chromium\src\v8\src\runtime\runtime-debug.cc 文件

RUNTIME_FUNCTION(Runtime_LiveEditPatchScript) {
  HandleScope scope(isolate);
  DCHECK_EQ(2, args.length());
  CONVERT_ARG_HANDLE_CHECKED(JSFunction, script_function, 0);
  CONVERT_ARG_HANDLE_CHECKED(String, new_source, 1);

  Handle<Script> script(Script::cast(script_function->shared().script()),
                        isolate);
  v8::debug::LiveEditResult result;
  LiveEdit::PatchScript(isolate, script, new_source, false, &result);
  switch (result.status) {
    case v8::debug::LiveEditResult::COMPILE_ERROR:
      return isolate->Throw(*isolate->factory()->NewStringFromAsciiChecked(
          "LiveEdit failed: COMPILE_ERROR"));
    case v8::debug::LiveEditResult::BLOCKED_BY_RUNNING_GENERATOR:
      return isolate->Throw(*isolate->factory()->NewStringFromAsciiChecked(
          "LiveEdit failed: BLOCKED_BY_RUNNING_GENERATOR"));
    case v8::debug::LiveEditResult::BLOCKED_BY_ACTIVE_FUNCTION: // 这里检查了上面函数相关的返回结果
      return isolate->Throw(*isolate->factory()->NewStringFromAsciiChecked(
          "LiveEdit failed: BLOCKED_BY_ACTIVE_FUNCTION"));
    case v8::debug::LiveEditResult::OK:
      return ReadOnlyRoots(isolate).undefined_value();
  }
  return ReadOnlyRoots(isolate).undefined_value();
}

就是这种BLOCKED_BY_ACTIVE_FUNCTION状态使得CDP调用setScriptSource函数既返回成功,但实际上又未成功。这种状态的触发条件根据名称也可以略猜一二,但同时注意触发此状态时会产生副作用,即Chrome会首先编译(compile)setScriptSource函数带过去的源码,然后才会检查是否产生这种状态,于是chrome内部返回BLOCKED_BY_ACTIVE_FUNCTION状态时已经产生了代码缓存并进行了一些预运算。

本文的重点并不是讨论Chromium实现细节,对此处具体行为感兴趣的小伙伴欢迎以调试模式跟踪上面的两个函数,可以看到更多有趣的具体信息:-)

此外,关于Chromium工程源码获取及编译的更多信息另请参阅chromium/get_the_code.md at master · chromium/chromium · GitHub

3. 在Chrome中增强JS脚本功能(持久化和调用堆栈感知)

这一节讨论如何扩展Chrome的原生功能。调试中,有时候我们会希望直接把某个JS变量写盘,这样不管用来和其他程序共享还是自己作为记录留存都非常方便。但浏览器内的JS原生不支持这种操作,我们可以想办法添加这种能力。比如,我们希望console.log函数能够在首个参数以"write "开头时,把这个字符串直接记录到文件中。

基于源码直接修改

既然Chrome是个开源软件,那最直接的想法就是直接改源码,这样运行效率也会相对有保障;本文重点不是这种方法,所以直接上代码:

// 修改Chromium工程中main_thread_debugger.cc文件的void MainThreadDebugger::consoleAPIMessage函数,这是MAC版,注意Win版本需要替换路径查找方式,写文件规则也不同
// 记得添加fstream、sstream、string等头文件

// 这里只提供一种示例,事实上有至少三个位置都适合做类似的修改达到增加写盘能力的目的

// 在frame->Console().ReportMessageToClient(...之前加入下述内容
char targetpath[512];
std::string homedir(getenv("HOME")); // 写文件的路径,也可以做成其他规则
String msg_str = ToCoreString(message);
String filetag = "kerome.txt"; // 要记录的文件名
if (msg_str.Substring(0, 6) == "write ") { // 判断写入的条件,换成自己喜欢的就可以
  filetag = msg_str.Substring(2, 14); // 文件命名规则,也是换成自己喜欢的就可以
}

// 转换路径
realpath((homedir+"/Desktop/"+filetag.Utf8()+".txt").c_str(), targetpath); 

std::ofstream fout(targetpath, std::ios::app);
// 这里实际写入了文件,可以把写入格式换成自己喜欢的版本;
fout << line_number << "," << column_number << "," << ToCoreString(url) << ","
<< ToCoreString(message).Utf8() << "," << location->ToString()<<std::endl;
fout.close();

这样修改源码后重新构建一下,然后用no-sandbox参数运行编译好的Chromium就会看到结果了。这里给Chromium的沙箱能力点赞,防代码注入方面确实有一套。

基于CDP进行增强

本文花了大量篇幅介绍CDP的各种奇妙用法,这里想要增强JS脚本功能的话,肯定也少不了CDP的身影。事实上CDP的运行效率是不如直接修改源码的,但是开发效率却要高得多,谁也不想做产品原型的时候随便一个小小的修改就要编译个30分钟+,能马上看到结果一定是极好的;还是直接上代码:

// 这里用给页面注入新函数的方式实现,页面中只要调用_writeFile('yourContent')即可写文件了;
// 为了突出重点,这里只写了较核心的代码

const puppeteer = require('puppeteer-core');
const fs = require('fs')
const webSocketDebuggerUrl = "ws://localhost:9222/devtools/browser/93972e12-e794-492c-a240-fb872fb936b8" // 获取方式:http://localhost:9222/json/version
let browser, page, client

puppeteer.connect({
  browserWSEndpoint: webSocketDebuggerUrl,
  defaultViewport: null,
  // args: ['--no-sandbox', '--disable-setuid-sandbox'],
})
  .then(v => { browser = v; return browser.newPage() })
  .then(v => {
    page = v;
    page.exposeFunction('_writeFile', (v) => {
        fs.writeFile('/YourPath/test.txt', v, ()=>{});
        return '';
    })
    return page.target().createCDPSession()
  })

注意这种思路还可以用来做很多事,比如之前说console.trace给出的调用堆栈是无法被JS代码本身获取的,那么我们能不能用这种方法让JS本身也获取自己所在的调用堆栈呢?完全可以哒(响应Runtime.consoleAPICalled事件时会一并获取调用堆栈)。

4. CDP的一些常用函数推荐

下面漫谈的方式介绍几个笔者觉得很好用的CDP函数,对于本文提供的示例代码不符合读者所需场景的情况,可以组合下面这些函数获取想要的结果;

Debugger命名空间

Debugger.evaluateOnCallFrame 用来在指定的堆栈内执行JS源码,比Runtime命名空间下的同类函数有用,主要体现在可以指定执行的上下文,获取各类想要的局部变量。Runtime命名空间下的执行函数经常拿不到内部变量(严格来说有个Runtime.callFunctionOn函数可以提供类似功能,但调用起来写法更复杂)。Debugger空间下这个版本主要功能和开发者工具界面上的实现几乎完全一致了。

Debugger.setInstrumentationBreakpoint 用来设置工具断点,但啥是工具断点呢?根据这个函数的调用参数可以看出来,基本就是指在新脚本首次执行前的时刻断下来的那种。注意这里的新脚本不仅仅是外部脚本加载,还包括了eval,function等情况。

Debugger.getPossibleBreakpoints 可以看作一个简化版AST了,提供有限几类断点的可用位置清单。注意如果使用JS语言进行CDP控制话,最好不要大面积的在这个清单中广撒网,普遍来说,放飞自我的操作会付出性能代价;同时注意,并不是只有这里提供的位置才能下断点,比如在这里提供的两个位置之间的位置下断点,根据具体代码的不同有时候也是可以的;但这个函数提供的位置都可靠性较高。顺便,非常期待Chrome CDP出一个官方语法树函数,不然V8引擎解析过的语法树还要再用第三方库单独解析一遍,感觉很浪费。

Debugger.setBreakpoint 注意其中的条件断点参数非常有用,上一部分专门讨论过“条件断点的特殊用法”,下文也有具体实例代码。

Page.getCookies 现在很多页面鉴权时用到了cookie,这个函数可以跨URL获取cookie信息,用来做正式页面和测试页面的登录态移植非常有用。

Page命名空间

Page.addScriptToEvaluateOnNewDocument 调试必备,可以在页面中注入代码;注意这一块的库实现,Playwright做的比Puppeteer更为优雅(前者做了很多工作使得代码可以“一次注入、到处运行”,后者则纯粹调用了Page.addScriptToEvaluateOnNewDocument函数),具体的可以参考Playwright源码中的page.add_init_script实现部分。

这里再说个第三方库的事件,就是Playwright和Puppeteer都提供了page.on方法,其中可以指定request事件,目前笔者认为这是操作上最为简单直接的页面流量替换方法。

Runtime 命名空间

Runtime.runIfWaitingForDebugger 对于等待调试器接入的执行环境,需要首先调用这个函数;否则不管调用多少次resumecontinue之类都是没用的,算是个小坑,这里重点提一句。

Runtime.consoleAPICalled 是个事件,在JS代码调用console.log等console系列函数时会触发此函数;下文增强JS语言的持久化能力时会再次提到这个事件;类似的还有Page.handleJavaScriptDialog事件;

Runtime.addBinding 给JS执行环境注入各种功能的函数。非常有用的功能,用好了可以做很多原始JS语法做不到的事。下文“在Chrome中赋予JS脚本更强的反射能力”一节就重点用到了这个函数。

5. 几个推荐的CDP操作库

现阶段最推荐的肯定是官方库(Playwright以及Puppeteer,但是没必要用完整版puppeteer,建议优先考虑使用Puppeteer-core),也许它们语法不是最优雅的,但功能一定是最全面的,使用上坑也较少;

如果有些小可爱愿意探索一些非官方库的话,有两个库我觉得是实现的非常不错的:

一个是python语言的pychrome库,不管是函数调用语法还是事件响应语法都非常优雅,函数调用时就直接写这个函数搭配命名参数,响应事件时就直接对要响应的事件进行赋值,书写过程非常符合直觉;唯一遗憾的是,好像无法针对浏览器(browser)对象进行操作?

另一个库是JS语言的chrome-remote-interface库,和pychrome语法很像(主要差异是响应事件的语法是将响应函数作为参数),缺点也很像,疑似缺少浏览器级操作?算是异曲同工了。

此外还有一种比较少见的情况,就是在chrome extensions内进行调用,只需要在注册权限时加上debugger即可。这种方式,调用的语法也是比较优雅的,唯一就是回调函数并不是现在主流的异步方式实现的;感兴趣的小伙伴可以参考官方实例github.com/GoogleChrom… 自己尝试一下。

6. 在Win及MAC中开始使用JS调试功能

想要使用CDP功能需要先进入调试模式。

针对Node执行环境

开启Node执行环境或浏览器执行环境主要依赖于--inspect--inspect-brk参数,两者的差异是,后者将使执行环境在脚本的首行断下,而前者没有这个行为。这两个参数也可以追加取值,比如--inspect=9000表示在本机9000端口打开监听。

同理,想要运行当前目录下的test.js脚本,并在默认端口打开监听执行的话,执行node ./test.js --inspect即可。如果想要对npm语句进行监听,则需要找到当前项目对应的npm文件所在目录,添加调试参数并用node运行;

当然,也可以利用VSCODE的调试功能,如下图所示,打开package.json,找到可执行的代码段,会有调试提示。

image.png

这里有个小坑,有可能库脚本内部会调用spwan.sync开启新进程,让我们要调试的代码在新进程中执行;但通过--inspect参数指定的调试状态正常情况下不会传递到新打开的进程中。这就导致我们真正想要调试的代码脱离了调试状态。

针对这种情况的解决方案,以及如果我们想要针对各种npm包都自动开启调试而不再手动增加调试参数的实现方法,都可阅读“在新进程中默认开启Node调试”一节。

针对浏览器执行环境

在浏览器环境下开启调试功能只需要增加remote-debugging-port参数即可

在Win环境可以建立快捷方式并加上这些参数。比如Chrome的安装路径是D:\Application\chrome.exe 那么建立快捷方式,修改“目标”为D:\Application\chrome.exe remote-debugging-port=5003即可在5003端口打开调试监听。

在Mac环境,可以通过自动操作,比如open /Users/YourAccountName/Desktop/codes/chrome/chromium/src/out/stable/Chromium.app --args --remote-debugging-port=9222即可在9222端口打开调试监听。

连接调试环境

在chrome浏览器输入chrome://inspect/并点击其中的Open dedicated DevTools for Node 超链接,即可打开全局调试窗口,点击其中的“添加连接”,输入您设定的监听端口号,即可连接对应的调试环境。

image.png

image.png

总结

以上内容总结了笔者在维护一个“祖传”项目时,用过的一些非侵入式的,根据特征行为、表现(文中以DOM组件为例)直接定位程序源码的方法。具体的实现涉及了基础调试方法、CDP的利用以及AST的利用,也介绍了一些利用现有程序无法解决问题时,重新构建程序以解决问题的方法。

如果各位阅读此文的小可爱所做的工作恰好和上文有重合,那么希望上文的原理和方法能帮助到你;如果读者所做的工作和上文内容重合较少,也希望文中的理论及方法可以开启读者思路,用于解决实际遇到的问题:-)

祝各位小伙伴天天都开开心心的^_^