前情提要:公司项目有需求,在打开该网页期间,不允许他人通过控制台以及保存页面的形式来查看代码,一旦有以上情况跳转至其他页面或者空白页(反正就是不给看)。
我分析了下需求,除了使用链接通过下载器下载页面控制不住,其余在该页面上通过按键来操作的都是可以实现的,但问题来了。保存和 F12 可以通过打开按键 code 去禁用,那么控制台呢?如何判断当前页面开启了控制台?然后开始了各种百度谷歌的流程,最终找到这个 devtools-detector 这个库来实现控制台打开与否的判断。
如何使用
使用很简单,通过 npm 或 yarn 安装该库 npm install devtools-detector -S
引入:
import * as devtoolsDetector from 'devtools-detector';
const view = document.createElement('div');
document.body.appendChild(view);
// 1. add listener
devtoolsDetector.addListener((isOpen) => {
view.innerHTML = isOpen ? '打开' : '关闭';
});
// 2. launch detect
devtoolsDetector.launch();
这样就可以在 addListener 中,监听到控制台的启动了。
文章到此为止就太简单了,以下会进行分析它的执行流程,以及是如何实现监听控制台的。
注:由于公司只使用谷歌浏览器,所以在进行实现判断检测的时候都采用的谷歌浏览器。但该库在使用时,几个浏览器支持度都比较好,较低版本的浏览器未测试。
源码目录
源码目录从图片上可以看到还是算少的。其余的不看,重点分析 src 下辖的文件
├── src // 构建相关
│ ├── checkers // 监测相关
│ ├── classes // 主类
│ ├── constants // 常数存放地
│ ├── shared // 共享的方法和变量
│ ├── types // TS的声明
│ index.ts // 主入口文件
执行流程
在 index.ts 中可以看到,该库在主入口文件仅仅只做了一个简单的初始化,然后将监测函数变为 checkers 数组并传入。
// index.ts
import { DevtoolsDetector } from './classes/devtools-detector';
const defaultDetector = new DevtoolsDetector({
// 会按照 checker 的顺序执行检查
checkers: [
checkers.elementIdChecker,
checkers.regToStringChecker,
checkers.functionToStringChecker,
checkers.depRegToStringChecker,
checkers.debuggerChecker,
],
});
在 ./classes/devtools-detector 中可以看到该类的实现
// ./classes/devtools-detector.ts
export class DevtoolsDetector {
private readonly _checkers: DevtoolsStatusChecker[];
private _listeners: DevtoolsDetectorListener[] = [];
private _isOpen = false;
private _detectLoopStopped = true;
private _detectLoopDelay = 500;
private _timer!: number;
constructor({ checkers }: DetectorOptions) {
this._checkers = checkers.slice(); // 将传入的 checkers 赋值给 this._checkers
}
// 开启监听
launch() {
if (this._detectLoopDelay <= 0) {
this.setDetectDelay(500);
}
if (this._detectLoopStopped) {
this._detectLoopStopped = false;
this._detectLoop();
}
}
// 关闭监听
stop() {
if (!this._detectLoopStopped) {
this._detectLoopStopped = true;
clearTimeout(this._timer);
}
}
// 判断当前是否开启监听
isLaunch() {
return !this._detectLoopStopped;
}
// 设置监听间隔
setDetectDelay(value: number) {
this._detectLoopDelay = value;
}
// 添加监听事件
addListener(listener: DevtoolsDetectorListener) {
this._listeners.push(listener);
}
// 移除监听事件
removeListener(listener: DevtoolsDetectorListener) {
this._listeners = this._listeners.filter((value) => value !== listener);
}
// 将添加的监听事件循环执行
private _broadcast(value: DevtoolsDetail) {
for (const listener of this._listeners) {
try {
listener(value.isOpen, value);
} catch {
/** nothing to do */
}
}
}
// 监听执行主体
private async _detectLoop() {
let isOpen = false;
let checkerName = '';
// 循环校验函数
for (const checker of this._checkers) {
// 先判断该校验函数在当前环境下能否使用
const isEnable = await checker.isEnable();
if (isEnable) {
checkerName = checker.name;
isOpen = await checker.isOpen();
}
// 任意一个 checker 返回 true 就视为 devtools 已打开
if (isOpen) {
break;
}
}
// 更新类的 _isOpen 属性,并触发 _broadcast 函数
if (isOpen != this._isOpen) {
this._isOpen = isOpen;
this._broadcast({
isOpen,
checkerName,
});
}
// 判断间隔参数,是否继续执行 监听函数
if (this._detectLoopDelay > 0) {
this._timer = setTimeout(() => this._detectLoop(), this._detectLoopDelay);
} else {
this.stop();
}
}
}
从上述代码可以看出,流程很简洁,采用了 setTimeout 来进行间隔校验, 接下来,我们来查看下它实现的几种校验规则
校验规则
elementIdChecker
通过源码来看,似乎是对元素的 id 进行了劫持,来实现的方法。
const ele = createElement('div');
let isOpen = false;
Object.defineProperty(ele, 'id', {
get() {
isOpen = true;
return elementIdChecker.name;
},
configurable: true,
});
export const elementIdChecker: DevtoolsStatusChecker = {
name: 'element-id',
async isOpen(): Promise<boolean> {
isOpen = false;
log(ele);
clear();
return isOpen;
},
async isEnable(): Promise<boolean> {
return match({
/** 匹配所有浏览器 */
includes: [true],
excludes: [isIE, isEdge, isFirefox], // 虽然
});
},
};
虽然它剔除了 ie,eage, firefox,但是我用了 chrome,opera 测试似乎也是无效 chrome
Opera
测试并未成功,不清楚这条校验规则实现的原理。难道是开启控制台后输出元素会获取 元素 的 id 属性?由此进行的判断
regToStringChecker
通过重写 reg 正则的 toString 的方法,来进行判断是否打开了控制台。
const reg = / /;
let isOpen = false;
reg.toString = () => {
isOpen = true;
return regToStringChecker.name;
};
export const regToStringChecker: DevtoolsStatusChecker = {
name: 'reg-to-string',
async isOpen(): Promise<boolean> {
isOpen = false;
log(reg);
clear();
return isOpen;
},
async isEnable(): Promise<boolean> {
return match({
/** 匹配所有浏览器 */
includes: [true],
/** 排除 webkit */
excludes: [isWebkit],
});
},
};
该规则排除了 Webkit 内核的浏览器,firefox 输出时会直接调用其 reg.toString 方法, webkit 内核则不会 firxfox(支持有效)
chrome(直接了当不支持环境)
functionToStringChecker
通过重写函数的 toString 方法,通过 console.log() 进行输出,webkit 内核浏览器则会调用函数的 toString 方法,firefox 则不会
function devtoolsTestFunction() {
// nothing todo
}
let count = 0;
devtoolsTestFunction.toString = () => {
count++;
return '';
};
export const functionToStringChecker: DevtoolsStatusChecker = {
name: 'function-to-string',
async isOpen(): Promise<boolean> {
count = 0;
log(devtoolsTestFunction);
clear();
return count === 2;
},
async isEnable(): Promise<boolean> {
return match({
/** 匹配所有浏览器 */
includes: [true],
/** 排除 firefox 和 ipad 或 iphone 上的 chrome */
excludes: [
isFirefox,
// ipad 或 iphone 上的 chrome
(isIpad || isIphone) && isChrome,
],
});
},
};
chrome
firefox(环境不支持)
depRegToStringChecker
与上一条 regToStringChecker 方法一样通过重写 reg 的 toString 方法来实现校验。区别在于 之前是用 console.log 输出,这次是使用 console.table 进行输出。但经过我测试与第一条 elementIdChecker 一样都无效
const reg = / /;
let isOpen = false;
reg.toString = () => {
isOpen = true;
return depRegToStringChecker.name;
};
export const depRegToStringChecker: DevtoolsStatusChecker = {
name: 'dep-reg-to-string',
async isOpen(): Promise<boolean> {
isOpen = false;
table({ dep: reg });
clear();
return isOpen;
},
async isEnable(): Promise<boolean> {
return match({
/** 匹配所有浏览器 */
includes: [true],
/** 排除 firefox 和 ie */
excludes: [isFirefox, isIE],
});
},
};
chrome(无效)
firefox(不支持环境)
debuggerCheck
通过 debugger 特性(打开控制台一定进入 debugger,不打开则不进入)。来进行一个起止时间的相减。
// .checkers/debugger.checker.ts
import { DevtoolsStatusChecker } from '../types/devtools-status-checker.type';
function now() {
if (performance) {
return performance.now();
} else {
return Date.now();
}
}
export const debuggerChecker: DevtoolsStatusChecker = {
name: 'debugger-checker',
async isOpen(): Promise<boolean> {
const startTime = now(); // 开始时间
// tslint:disable-next-line:no-empty only-arrow-functions
// 主动进入 debugger
(function () {}.constructor('debugger')());
return now() - startTime > 100; // 结束 - 开始 > 100(0.1s)
},
async isEnable(): Promise<boolean> {
return true;
},
};
由以下动图可知,当未打开控制台时,由于不会出现 debugger 所以起止时间相减 < 100 ,但打开控制台,起止时间相减必定大于 100。当然由于 debugger 的原因,这条校验规则被排到了最后。
所有浏览器都支持
结束
看完了这个库的源码,我的业务需求也成功了,但这个库只实现了控制台的问题,还有按键码需要去做处理。再在此添上完整功能
const view = document.createElement('div');
document.body.appendChild(view);
devtoolsDetector.addListener(function (isOpen) {
if (isOpen) {
window.location.href = 'about:blank';
}
});
devtoolsDetector.launch();
// 禁用 Crtl + S
window.addEventListener(
'keydown',
function (e) {
if (e.keyCode == 83 && (navigator.platform.match('Mac') ? e.metaKey : e.ctrlKey)) {
e.preventDefault();
}
},
false
);
// 禁用 f12
document.onkeydown = function () {
var e = window.event || arguments[0];
if (e.keyCode == 123) {
return false;
}
};
// 禁用右键
document.oncontextmenu = function () {
return false;
};
还有由于,这个库开启后是一直在运行的,所以通过别的页面打开控制台在进入该页面的话,一样会被监听到,总体就是该库监听控制台是否存在,不在乎何时打开。
小声的说,如果页面被当作 iframe 嵌入其他页面,也会受到影响 推荐一个录屏软件,LICEcap 免费好用