前端调试方法之偷天换日
本文中提出了一种调试前端应用的思路,适用于常见的SPA应用,已经在工作中使用了两个月且表现良好,供读者参考。
背景
一般来说,调试前端应用有两种套路:
- 在出现bug的场景中,打开devtools,跟踪source map调试问题
- 在本地启动前端应用,连接到对应的后端进行调试
这两种套路都有其局限性。
- 如果直接在浏览器中使用devtools进行调试,依赖于良好的source map,但是有时候出于安全或其它方面的考虑,某些环境(如生产环境)是不提供完备的source map的,调试起来非常困难。
- 如果本地启动前端应用,并连接到对应的后端。首先要处理好跨域问题、要处理好环境的切换。如果线上服务带有子路径,还需要处理路径不同等问题。总之在搭建环境的过程中,有可能遇到各种各样的困难。
- 有时候调试的网页不是自己控制的,没有源码,想基于线上的es5代码修改调试,上面的两种方法也很难做到。
思路
“偷天换日”调试法的核心思路,是通过本地篡改的方式,直接影响线上环境。
和后端应用不同,前端应用的所有代码,都是在浏览器中运行的。既然是在我们的主场,当然我们就可以对它进行一定程度的操控。
以前听过一个骗局,是讲有人通过展示自己的巨额比特币,获取别人的信任,而且主要受骗的是有一些技术水平的人。骗子通过录制视频的方式,展示自己在一个 通过HTTPS访问的,安全的比特币网站上,拥有巨量的比特币,尤其是其SSL证书的指纹和官方网站的指纹完全相同。最后揭秘骗子的手段就是通过浏览器插件篡改了网页的内容。
因此我想,如果能篡改SPA应用页面中的JS、CSS地址,改成本地地址,就能进行调试了。
原理
最开始尝试的方向是利用浏览器插件的Content Script,篡改HTML内容。这样当然是最暴力最简单的了,可惜不可行。
设想的流程是这样:
1graph LR; 2 3%% browser(浏览器) --> getHtml{{请求HTML}}; 4 5%% getHtml --> contentScript[[contentScript篡改HTML]]; 6%% contentScript --> parseHtml{{解析HTML}}; 7 8%% parseHtml --> page(页面); 9 10%% style contentScript fill:#f9f,stroke:#333,stroke-width:1px
但是实际的流程是:
1graph LR; 2 3%% browser(浏览器) --> getHtml{{请求HTML}}; 4 5%% contentScript[[contentScript篡改HTML]]; 6%% parseHtml{{解析HTML}}; 7%% getHtml --> parseHtml --> contentScript; 8 9%% contentScript --> page(页面); 10 11%% style contentScript fill:#f9f,stroke:#333,stroke-width:1px
因为浏览器插件的Content Script是在HTML加载完毕之后才会加载的,在其有能力篡改之前,网页已经渲染出来了。即使此时再操作script标签换掉元素,之前的JS产生的一些全局变量等已经存在,较难清除。
后来搜索了很多,换了种实现方法,拦截指定网页的网络请求,做重定向。
拦截前的流程
1graph LR; 2 3%% server("服务器"); 4 5%% subgraph 6%% browser(浏览器) --> getHtml{{请求HTML}}; 7%% 8%% parseHtml1{{解析HTML}}; 9%% parseHtml2{{解析HTML}}; 10%% getHtml --> parseHtml1; 11 12%% parseHtml2 --> page(页面); 13%% end; 14 15%% parseHtml1 -->|请求JS/CSS| server; 16%% server -->|返回JS/CSS| parseHtml2; 17
拦截后的流程
1graph LR; 2%% server("服务器"); 3%% localServer("本地服务,如webpack-dev-server"); 4 5%% subgraph 6%% browser(浏览器) --> getHtml{{请求HTML}}; 7 8%% parseHtml1{{解析HTML}}; 9%% parseHtml2{{解析HTML}}; 10%% getHtml --> parseHtml1; 11 12%% parseHtml2 --> page(页面); 13 14%% plugin("浏览器插件"); 15 16%% parseHtml1 -->|"请求JS/CSS(被重定向)"| plugin; 17%% plugin -->|redirect| parseHtml1; 18 19 20%% end; 21 22 23%% plugin -.-> |被阻断| server; 24 25 26%% parseHtml1 -->|请求JS/CSS| localServer; 27 28%% localServer -->|返回JS/CSS| parseHtml2; 29 30%% linkStyle 3 stroke:#fa3,stroke-width:2px,color:red; 31%% linkStyle 4 stroke:#fa3,stroke-width:2px,color:red; 32
在启用了如上图的拦截之后,就可以在本地启动 webpack-dev-server,或其它类型的server,也可以是另一个远程链接。使用本地的JS/CSS替代掉页面中原来的JS/CSS。
工具
搞明白原理之后,最开始我是打算自己写一个浏览器插件的。
然而秉承着“凡是我想到的,99%的已经被实现过”的原则,我搜索了下,然后发现了一个浏览器插件可以直接拿来使用。
插件地址: https://chrome.google.com/webstore/detail/resource-override/pkoacgokdfckfpndoffpifphamojphii
代码地址:https://github.com/kylepaulsen/ResourceOverride
感兴趣的同学可以直接安装插件体验下,用法比较简单。如果没有科学上网打不开扩展商店的话,可以直接clone Github上的代码,然后打开开发者模式,加载本地插件。
安装好这个工具之后,在相应的规则配置页面,输入想要替换的URL地址,可使用 * 号通配符,再输入目标地址,即本地 webpack-dev-server 或其它服务产生的 JS/CSS地址即可完成替换。
替换完成之后,需要刷新页面生效。
在刷新之前,打开Chrome devtools,观察网络请求。如果Resource Override的规则配置正确的话,首先会看到原来的资源请求返回307的跳转状态码,Response Header中 Location 字段返回了新地址,即Resource Override中配置的目标地址。
之后,每次修改代码之后,触发webpack-dev-server重新构建,然后再重新载入页面即可。
HTTPS 的特殊处理
从原理上可知,浏览器插件做的只是资源请求的网络拦截,并做了次跳转。
所以在对待HTTPS的处理上,还是和原来的策略相同。
如果正在替换的网页是HTTPS的,而本地启动的 webpack-dev-server 默认是HTTP的,那就会触发浏览器的混合内容错误,即不允许在HTTPS的页面内加载HTTP的内容。
要解决这个问题主要有两种途径:
- 最简单的方式,是将本地路径的host换成 http://127.0.0.1,因为Chrome浏览器新版本中做了调整,对混合内容错误开了例外处理,来自 http://127.0.0.1的内容允许加载,而 http://localhost 、http://0.0.0.0 等方式访问时还是会触发错误。因此,我们只需要在Resource Override的目标地址中,写入 http://127.0.0.1 开头的地址即可,可以带端口号。这种方式依赖于浏览器的实现,在Chrome的旧版本,或其它浏览器上可能无法正常工作 。
- 另一种方式稍微复杂一些,是在本地启用HTTPS。如使用了webpack-dev-server,可配置其使用HTTPS证书。
1module.exports = { 2 //... 3 devServer: { 4 https: true, 5 key: fs.readFileSync('/path/to/server.key'), 6 cert: fs.readFileSync('/path/to/server.crt'), 7 ca: fs.readFileSync('/path/to/ca.pem'), 8 } 9};
详细的HTTPS配置方式,可参阅 webpack 官方文档:https://webpack.js.org/configuration/dev-server/#devserverhttps
那么如何获取HTTPS证书呢?一般来说需要自己颁发证书,并在自己的电脑上安装和信任此证书或CA根证书。
关于自颁发证书,有兴趣的可参阅 mkcert 工具:https://github.com/FiloSottile/mkcert,如果不想这么麻烦,还是推荐使用方案1。
颁发证书之后,如果想设置给团队内其它人共同使用,可以参考我之前写过的一个脚本:
1#!/usr/bin/env bash 2 3# 本文件用于安装 https 证书,需要将证书文件改名为 ca.crt,并放置在此脚本同目录下 4# 使用时需要提权,过程中会询问密码 5# 依赖于 mkcert 命令,mkcert的安装方式参见: https://github.com/FiloSottile/mkcert 6# Mac 上可使用 Homebrew 安装: brew install mkcert 7 8# 如果安装 mkcert 工具遇到问题,也可以手动安装证书到系统中。双击 ca.crt 文件安装进系统,并打开钥匙串找到证书,点击信任并保存 9 10# 如果是mac用户,自动使用brew安装mkcert 11brewCmd=`command -v brew` 12mkcertCmd=`command -v mkcert` 13if [[ "x$mkcertCmd" == "x" ]]; then 14 if [[ "$OSTYPE" == "darwin"* && "x$brewCmd" != "x" ]]; then 15 brew install mkcert; 16 fi; 17fi; 18 19mkcertCmd=`command -v mkcert` 20 21if [[ "x$mkcertCmd" == "x" ]]; then 22 echo "mkcert required, please install it first! https://github.com/FiloSottile/mkcert"; 23 exit 1; 24fi; 25 26DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 27cp $DIR/ca.crt $DIR/rootCA.pem 28CAROOT=$DIR mkcert -install
WebpackDllPlugin的特殊处理
如果使用了WebpackDllPlugin,则有可能会出现VendorLibrary is not defined
的错误。
假设 webpack在 development模式 产生了 index.js 和 vendor.js 两个文件,在production模式生成 index.min.js 和 vendor.min.js。
注意这里 index.js / vendor.js 或 index.min.js / vendor.min.js 必须配套使用。
在Resource Override替换时,如果只替换了 index.min.js -> index.js,则会出现 index.js 和 vendor.min.js 配套使用的情况,DLL解析失败,导致出现VendorLibrary is not defined
或其它奇奇怪怪的错。
解决办法有以下两种:
- 替换的时候,同时替换 index.js 和 vendor.js
- 本地 webpack-dev-server的时候,如果是用于篡改调试,可把 DllPlugin 配置改成production模式,加载
mainfest.production.json
。
调试他人的代码
如果怀疑他人的代码有问题,但是没有源码可用来调试,也可以使用Resource Override做处理。
首先将对方应用中的JS文件下载下来,使用格式化工具将其格式化成基本可读的格式,本地将其调整之后,再通过本地服务(如 npm 的 serve 包,或nginx)提供此文件的本地地址,然后用 Resource Override工具,将原来的JS文件转换成本地修改过后的JS文件,即可实现调试他人的代码。虽然还是ES5代码比较难读,但至少可以通过修改文件、刷新页面的方式进行调试,可完成一些简单的任务。
常见的使用场景是跨部门协作或前后端协作时,找别人的BUG。
总结 & 使用心得
本文介绍了一种特殊的前端调试思路,及对应的方法和工具,可以在某些特殊的领域解决调试难的问题。
使用“偷天换日”调试法已经有将近两个月,也推广到了身边的同事使用,大家的反馈都是比较好用,感兴趣的读者可以尝试下。