logo

鱼肚的博客

Don't Repeat Yourself

前端调试方法之偷天换日

本文中提出了一种调试前端应用的思路,适用于常见的SPA应用,已经在工作中使用了两个月且表现良好,供读者参考。

背景

一般来说,调试前端应用有两种套路:

  1. 在出现bug的场景中,打开devtools,跟踪source map调试问题
  2. 在本地启动前端应用,连接到对应的后端进行调试

这两种套路都有其局限性。

  1. 如果直接在浏览器中使用devtools进行调试,依赖于良好的source map,但是有时候出于安全或其它方面的考虑,某些环境(如生产环境)是不提供完备的source map的,调试起来非常困难。
  2. 如果本地启动前端应用,并连接到对应的后端。首先要处理好跨域问题、要处理好环境的切换。如果线上服务带有子路径,还需要处理路径不同等问题。总之在搭建环境的过程中,有可能遇到各种各样的困难。
  3. 有时候调试的网页不是自己控制的,没有源码,想基于线上的es5代码修改调试,上面的两种方法也很难做到。

思路

“偷天换日”调试法的核心思路,是通过本地篡改的方式,直接影响线上环境。

和后端应用不同,前端应用的所有代码,都是在浏览器中运行的。既然是在我们的主场,当然我们就可以对它进行一定程度的操控。

以前听过一个骗局,是讲有人通过展示自己的巨额比特币,获取别人的信任,而且主要受骗的是有一些技术水平的人。骗子通过录制视频的方式,展示自己在一个 通过HTTPS访问的,安全的比特币网站上,拥有巨量的比特币,尤其是其SSL证书的指纹和官方网站的指纹完全相同。最后揭秘骗子的手段就是通过浏览器插件篡改了网页的内容。

因此我想,如果能篡改SPA应用页面中的JS、CSS地址,改成本地地址,就能进行调试了。

原理

最开始尝试的方向是利用浏览器插件的Content Script,篡改HTML内容。这样当然是最暴力最简单的了,可惜不可行。

设想的流程是这样:

img

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

但是实际的流程是:

img

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产生的一些全局变量等已经存在,较难清除。

后来搜索了很多,换了种实现方法,拦截指定网页的网络请求,做重定向。

拦截前的流程

img

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

拦截后的流程

img

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的内容。

要解决这个问题主要有两种途径:

  1. 最简单的方式,是将本地路径的host换成 http://127.0.0.1,因为Chrome浏览器新版本中做了调整,对混合内容错误开了例外处理,来自 http://127.0.0.1的内容允许加载,而 http://localhosthttp://0.0.0.0 等方式访问时还是会触发错误。因此,我们只需要在Resource Override的目标地址中,写入 http://127.0.0.1 开头的地址即可,可以带端口号。这种方式依赖于浏览器的实现,在Chrome的旧版本,或其它浏览器上可能无法正常工作 。
  2. 另一种方式稍微复杂一些,是在本地启用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或其它奇奇怪怪的错。

解决办法有以下两种:

  1. 替换的时候,同时替换 index.js 和 vendor.js
  2. 本地 webpack-dev-server的时候,如果是用于篡改调试,可把 DllPlugin 配置改成production模式,加载mainfest.production.json

调试他人的代码

如果怀疑他人的代码有问题,但是没有源码可用来调试,也可以使用Resource Override做处理。

首先将对方应用中的JS文件下载下来,使用格式化工具将其格式化成基本可读的格式,本地将其调整之后,再通过本地服务(如 npm 的 serve 包,或nginx)提供此文件的本地地址,然后用 Resource Override工具,将原来的JS文件转换成本地修改过后的JS文件,即可实现调试他人的代码。虽然还是ES5代码比较难读,但至少可以通过修改文件、刷新页面的方式进行调试,可完成一些简单的任务。

常见的使用场景是跨部门协作或前后端协作时,找别人的BUG。

总结 & 使用心得

本文介绍了一种特殊的前端调试思路,及对应的方法和工具,可以在某些特殊的领域解决调试难的问题。

使用“偷天换日”调试法已经有将近两个月,也推广到了身边的同事使用,大家的反馈都是比较好用,感兴趣的读者可以尝试下。