Webpack + TypeScript 开发油猴脚本:斗鱼直播自动切换画质 2.0
基于以下两个项目:
- 切换画质基础实现:自动切换最高或最低画质
React + TypeScript + Tailwindcss 开发油猴脚本
- 删减后形成 Webpack + TypeScript 基础配置
项目地址:https://github.com/Eished/douyu-helper
改进的功能
- 点击浏览器油猴插件图标,弹出菜单栏,可切换全局默认画质和选择当前直播间画质。
- 油猴菜单栏选项根据直播间画质选项动态加载。
- 切换画质时不刷新页面。
- 自动记住每个直播间选择的画质,并自动切换到上次选择的画质
- 点击播放器切换或者油猴插件图标切换都可以记住选择的画质。
- 局部配置优先,没有匹配到局部配置时,使用全局默认画质。
开发思路
- 重构为 TypeScript
- 本打算扩展最高或最低画质,增加中间选项让画质选项自动添加和模糊匹配最接近全局预设的画质,但不知道画质取值范围和排序,写到一半没什么好的思路,于是让每个直播间的画质设定独立保存,并保留最高或最低画质功能。
- 获取斗鱼直播间 id
- 保存设定值到直播间 id 下
- 初始化运行时取设定值匹配的画质选项并选择
- 未匹配到则使用默认画质设定值
- 监听画质选项点击事件,保存用户的点击画质选项事件值,不保存自动选择画质的点击事件值
- 手动编写斗鱼播放器 HTML 模拟其主要功能,用于本地快速开发和调试。
- 油猴 API 类型定义
主要代码实现
index.ts
:
- 根据是否是开发环境决定是否执行
import app from '@/app';
import { isTampermonkey } from '@/lib/environment';
if (PRODUCTION) {
app();
} else {
// 本地开发时注入页面的js,设置为只在油猴环境运行。
// 开启压缩后 webpack 在生产环境构建会把这部分代码删掉。
if (isTampermonkey()) {
app();
}
}
app.ts
:
- 主要逻辑实现
https://github.com/Eished/douyu-helper/blob/main/src/app.ts
const app = () => {
let rid = new URLSearchParams(window.location.search).get('rid');
if (!rid) {
const results = window.location.pathname.match(/[\d]{1,10}/);
if (results) {
rid = results[0];
} else {
return;
}
}
const videoSub = document.querySelector('.layout-Player-videoSub');
if (rid && videoSub) {
autoSelectClarity(rid, videoSub);
}
};
const autoSelectClarity = (rid: string, videoSub: Element) => {
const Clarities = ['全局默认最高画质', '全局默认最低画质'];
const selectedClarity: string | undefined = GM_getValue(rid);
const defaultClarity: number | undefined = GM_getValue('defaultClarity');
const clickClarity = (li: HTMLLIElement, save = false) => {
// 阻止点击事件循环
if (!li.className.includes('selected')) {
save ? GM_setValue(rid, li.innerText) : null;
li.click();
}
};
const selectClarity = (list: NodeListOf<HTMLLIElement>) => {
// 注册菜单栏
Clarities.forEach((clarity, index) => {
GM_registerMenuCommand(clarity, () => {
if (index === 0) {
clickClarity(list[0]);
GM_setValue('defaultClarity', 1);
} else {
clickClarity(list[list.length - 1]);
GM_setValue('defaultClarity', 0);
}
});
});
let notFoundCount = 0;
list.forEach((li) => {
const availableClarity = li.innerText;
if (selectedClarity === availableClarity) {
// 选择自定义画质
clickClarity(li);
} else {
notFoundCount++;
}
// 防止误触发保存,仅保存真实点击
li.addEventListener('click', (e) => clickClarity(li, e.isTrusted));
// 注册菜单栏
GM_registerMenuCommand(availableClarity, () => clickClarity(li, true));
});
// 选择默认画质
if (notFoundCount === list.length) {
if (defaultClarity === 0) {
clickClarity(list[list.length - 1]);
} else {
clickClarity(list[0]);
}
}
};
const callback = (mutations: MutationRecord[], observer: MutationObserver) => {
const controller = videoSub?.querySelector(`[value="画质 "]`);
if (controller) {
observer.disconnect();
const ul = controller.nextElementSibling;
const list = ul?.querySelectorAll('li');
list ? selectClarity(list) : console.debug('斗鱼直播助手:未找到画质选项');
}
};
const observer = new MutationObserver(callback);
observer.observe(videoSub, {
childList: true,
subtree: true,
});
};
export default app;
模拟斗鱼播放器:
https://github.com/Eished/douyu-helper/blob/main/public/index.html
油猴 API 类型定义:
https://github.com/Eished/douyu-helper/blob/main/src/global.d.ts
开发文档:
https://github.com/Eished/douyu-helper/tree/main/docs
学习到的知识点
TypeScript 与闭包
let stringOrNull: string | null = null;
stringOrNull = 'test';
const funcA = (a: string) => {
console.log(a);
};
funcA(stringOrNull);
if (stringOrNull) {
const funcB = () => {
funcA(stringOrNull); // 类型“string | null”的参数不能赋给类型“string”的参数。
};
funcB();
}
const funcC = () => {
if (stringOrNull) {
funcA(stringOrNull);
}
};
问题:
- 在函数内使用外部变量时 TypeScript 类型推导失效。如上代码
funcB
中无法推导出stringOrNull
是字符串类型。
原因:
这种情况看起来是由于引用的外部变量作用域的问题,更细致来看符合闭包的特征,闭包让你可以在一个内层函数中访问到其外层函数的作用域。
MDN: 闭包是由函数以及声明该函数的词法环境组合而成的。该环境包含了这个闭包创建时作用域内的任何局部变量。
原因是:闭包仅收集函数作用域内的局部变量的引用,没有记录逻辑代码,所有无法用外部逻辑代码在内层函数内对变量的定义进行类型推导。
- 注:示例有三层作用域,第二层作用域未进行任何操作。
C++ 11 中 lambda 闭包(closure) 的实现:c++ 编译期把闭包捕获的变量都放在 global上,使用记录的 c++ 捕捉范围,把那些引用用调用方式传进去
解决方案:
- 不使用闭包,使用传参。
- 缩小作用域范围,在闭包生成前确定类型。
let stringOrNull: string | null = null;
const funcA = (a: string) => {
console.log(a);
};
if (stringOrNull) {
const str = stringOrNull; // 缩小作用域范围,在闭包生成前确定类型。
const funcB = () => {
funcA(str);
};
funcB();
funcN(stringOrNull); // 直接传参
}
MutationObserver
之前使用 javascript 不清晰的写法:
- 这样写由于变量声明提升可以正确运行,只是
observer.disconnect();
不能明显看到引用来源
const callback = () => {
const controller = videoSub?.querySelector(`[value="画质 "]`);
if (controller) {
observer.disconnect();
}
};
const observer = new MutationObserver(callback);
observer.observe(videoSub, {
childList: true,
subtree: true,
});
之后使用 typescript 的写法:
const callback = (mutations: MutationRecord[], observer: MutationObserver) => {
const controller = videoSub?.querySelector(`[value="画质 "]`);
if (controller) {
observer.disconnect();
}
};
const observer = new MutationObserver(callback);
observer.observe(videoSub, {
childList: true,
subtree: true,
});
仅响应真实 Click 事件
- 点击全局最高/最低画质选项会触发
click
事件,导致当前直播间的画质选项被保存,导致全局设置失效。- 使用
e.isTrusted
来判断这个点击是来自用户/代码,只保存用户的点击事件产生的调用。 GM_registerMenuCommand
油猴菜单栏选项绑定了播放器画质选项,也是用户点击,直接传入true
保存。
- 使用
const clickClarity = (li: HTMLLIElement, save = false) => {
// 阻止点击事件循环
if (!li.className.includes('selected')) {
save ? GM_setValue(rid, li.innerText) : '';
li.click();
}
};
const selectClarity = (list: NodeListOf<HTMLLIElement>) => {
// 注册菜单栏
Clarities.forEach((clarity, index) => {
GM_registerMenuCommand(clarity, () => {
if (index === 0) {
clickClarity(list[0]);
GM_setValue('defaultClarity', 1);
} else {
clickClarity(list[list.length - 1]);
GM_setValue('defaultClarity', 0);
}
});
});
let notFoundCount = 0;
list.forEach((li) => {
const availableClarity = li.innerText;
if (selectedClarity === availableClarity) {
// 选择自定义画质
clickClarity(li);
} else {
notFoundCount++;
}
// 防止误触发保存,仅保存真实点击
li.addEventListener('click', (e) => clickClarity(li, e.isTrusted));
// 注册菜单栏
GM_registerMenuCommand(availableClarity, () => clickClarity(li, true));
});
// 选择默认画质
if (notFoundCount === list.length) {
if (defaultClarity === 0) {
clickClarity(list[list.length - 1]);
} else {
clickClarity(list[0]);
}
}
};
Webpack 配置的改进
自动生成油猴头文件,以及生产环境和开发环境所需的不同参数
热刷新解决方案:
HtmlWebpackPlugin
将websocket
代码注入到打包好的 js 里面,再把 js 插入 html,devServer
和websocket
客户端通信实现网页热刷新- 问题:
- 油猴脚本不需要通过 webpack 将 js 插入网页。
- 造成重复运行相同代码,webpack 插入的 js 没有油猴环境,运行到调用油猴 API 时报错。
- 解决方案:在代码开始时里加上判断当前环境的函数。
- 问题:生产环境不需要这些代码。
- 解决:让 webpack 在生产环境构建时删掉这些代码
DefinePlugin
变量替换NormalModuleReplacementPlugin
正则匹配替换- 这两个插件都能实现替换效果,
DefinePlugin
能自动优化无法达到的分支,需要写的代码更少,所以使用这个完成。
代码构建缓慢:
之前很快:
webpack 5.72.0 compiled successfully in 1362 ms
- 现在:
webpack 5.72.0 compiled successfully in 9335 ms
- 现在:
不设置
devtool: 'source-map'
就恢复正常了development
模式下默认是eval
production
模式下默认是false
文档介绍:
source-map build: slowest rebuild: slowest
用于找到具体的报错位置什么时候使用?
(1)开发环境:需要考虑速度快,调试更友好
- 速度快(
eval
>inline
>cheap
>… )eval-cheap-souce-map
eval-source-map
- 调试更友好
souce-map
cheap-module-souce-map
cheap-souce-map
最终得出最好的两种方案 –> eval-source-map(完整度高,内联速度快) / eval-cheap-module-souce-map(错误提示忽略列但是包含其他信息,内联速度快)
(2)生产环境:需要考虑源代码要不要隐藏,调试要不要更友好
- 内联会让代码体积变大,所以在生产环境不用内联
- 隐藏源代码
nosources-source-map
全部隐藏(打包后的代码与源代码)hidden-source-map
只隐藏源代码,会提示构建后代码错误信息
最终得出最好的两种方案 –> source-map(最完整) / cheap-module-souce-map(错误提示一整行忽略列)
- 速度快(
同步 Click 事件为什么不会死循环?
const list = document.querySelectorAll('li');
list.forEach((li) => {
li.addEventListener('click', (e) => {
if (e.target === li) {
i++;
console.log('第' + i + '次触发');
// li.click();
setTimeout(() => {
li.click();
}, 1);
}
});
});
这个问题的关键是每个 element.click()
方法上都有一个隐藏的标志。
每个元素都有一个关联的点击进行中标志,该标志最初未设置。
文档:https://html.spec.whatwg.org/multipage/interaction.html#dom-click
一旦这个方法被激活,这个标志就会从progess Status == unset
到progess Status == active
(伪代码)
(然后一旦它包含的代码被完全执行,它就会返回到它的初始状态)
当此标志处于该active
状态时,将忽略对该方法的任何调用。