ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Bilibili-Evolved 深度解析:用户脚本注入、模块化架构与自定义插件开发

Bilibili-Evolved 深度解析:用户脚本注入、模块化架构与自定义插件开发

简介:Bilibili-Evolved 是一款面向哔哩哔哩网页端的增强脚本,基于 JavaScript 编写,适合希望深度定制 B 站使用体验的前端爱好者与进阶用户。它通过浏览器脚本管理器加载,可扩展播放器、界面与交互功能,解决原生页面功能有限、操作繁琐等问题。资源包共约 2000 个文件,以 1529 个 TypeScript 源码和 295 个 JavaScript 文件为核心,辅以 86 个 CSS、35 个 SCSS 样式文件及 26 个 Vue 组件,另含图片、JSON 配置与构建脚本等,压缩包整体约 67.36MB,结构完整,便于二次开发与功能裁剪。目前已有 955 人学习下载。脚本内置正式版、预览版、离线版等多种安装形态,并针对跨域资源访问、破坏性更新等场景给出提示,读者可借此理解大型用户脚本的模块划分、构建流程与样式组织方式,获得可直接运行或改造的完整工程参考。

1. 从「Bilibili-Evolved」说起:一个把网页端体验重写到极致的前端增强方案

如果你每天在浏览器里刷视频,却总觉得官方网页端少了点什么——播放器不够顺手、界面元素太杂、批量操作几乎为零——那 Bilibili-Evolved 这个名字大概率已经出现在你的搜索记录里。它不是某个官方功能,而是一套运行在浏览器里的用户脚本增强方案,核心思路是用 JavaScript 在页面加载后动态注入、改写和扩展原有交互。你可以把它理解成给网页端装了一个可编程的「外挂层」:不改动服务端,不依赖客户端,所有能力都在本地浏览器里完成。

它解决的问题非常具体:网页端功能更新慢、个性化程度低、重复操作多。适合两类人——一类是想按自己习惯重塑界面的重度用户,另一类是前端工程师,想找一个真实、复杂、持续维护的脚本工程来学习模块化、样式隔离和 API 拦截。接下来的内容会从架构拆到代码,从安装讲到自定义插件,中间穿插我踩过的坑,目标是让你能自己跑起来、改得动、知道边界在哪。

2. 拆开 Bilibili-Evolved 的运行时:脚本注入、样式隔离与 API 拦截三件事

2.1 它到底在浏览器里做了什么

要理解 Bilibili-Evolved 的工作方式,先得知道一个用户脚本从加载到生效经历了什么。浏览器扩展或脚本管理器在页面 DOM 构建的早期阶段注入代码,此时页面自身的 JavaScript 还没执行完。Bilibili-Evolved 利用这个时间窗口做三件事:第一,注册自己的模块系统,把功能拆成独立组件;第二,挂载样式表,用高优先级选择器覆盖原有 UI;第三,拦截关键的网络请求和全局对象,为后续功能提供数据入口。

这套机制的关键在于「时机」。注入太早,页面元素还不存在,选择器找不到目标;注入太晚,页面已经渲染完成,用户会看到明显的闪烁。常见做法是监听document.readyState和DOMContentLoaded,再配合MutationObserver监听后续动态加载的节点。Bilibili-Evolved 内部有一套自己的生命周期管理,每个组件声明自己依赖的 DOM 条件,满足后才执行初始化。

从工程角度看,它更像一个微前端框架,而不是一堆零散脚本的集合。组件之间通过事件总线和共享状态通信,样式通过前缀和 Shadow DOM 做隔离,避免和页面原有样式互相污染。这也是它比普通「油猴脚本」更稳定的原因。

2.2 最小可运行环境与安装路径

你不需要克隆整个仓库就能跑起来。最常见的做法是通过脚本管理器加载构建产物。下面是一个本地验证用的最小步骤,假设你已经安装了支持用户脚本的浏览器扩展。

# 1. 准备一个本地目录,用来存放构建产物和调试文件 mkdir -p ~/dev/be-local && cd ~/dev/be-local # 2. 如果你拿到的是源码,先安装依赖并构建 # 常见做法是使用项目自带的包管理配置 npm install npm run build # 3. 构建完成后,产物通常是一个 .user.js 文件 # 在脚本管理器中新建脚本,把文件内容粘贴进去 # 或者直接通过文件 URL 加载(部分管理器支持) ls -lh dist/*.user.js

这段命令的核心是「构建」和「加载」两个动作。npm install拉取依赖,npm run build触发打包流程,最终产出一个自包含的脚本文件。参数上要注意:不同脚本管理器对@match和@grant的支持有差异,如果加载后没反应,先检查脚本头部元数据里的匹配规则是否覆盖了你访问的域名。

加载成功后,页面右下角通常会出现一个入口按钮,点击后展开功能面板。如果按钮没出现,打开浏览器控制台看有没有报错,最常见的是Uncaught ReferenceError,说明某个依赖没被打进产物,需要检查构建配置里的外部依赖声明。

2.3 模块化结构与自定义组件的接入点

Bilibili-Evolved 的功能不是写死的,它暴露了一套组件注册接口。你可以把自己写的逻辑挂进去,享受它的生命周期管理和样式隔离。下面是一个简化的自定义组件示例,展示注册、初始化和销毁三个环节。

// 自定义组件:在视频页标题旁插入一个标记 // 注意:实际接入时需要遵循项目的组件注册规范 const myComponent = { name: 'custom-title-marker', // 声明依赖的 DOM 条件,满足后才执行 dependencies: ['video-title'], // 初始化逻辑 async setup() { const title = document.querySelector('.video-title'); if (!title) return; // 创建一个标记元素 const marker = document.createElement('span'); marker.textContent = '[已标记]'; marker.style.cssText = 'color:#fb7299;font-size:12px;margin-left:8px;'; title.appendChild(marker); // 保存引用,便于销毁时清理 this.marker = marker; }, // 销毁逻辑,避免内存泄漏 dispose() { if (this.marker && this.marker.parentNode) { this.marker.parentNode.removeChild(this.marker); } } }; // 注册到全局组件管理器(具体 API 名称以实际项目为准) window.__BE_REGISTRY__?.register(myComponent);

这段代码的关键点有三个。第一,dependencies声明了组件生效的前提,避免在元素不存在时执行。第二,setup里做了空值判断,这是前端注入脚本的基本素养,页面结构随时可能变。第三,dispose负责清理,单页应用切换路由时如果不销毁旧组件,会出现重复插入和内存泄漏。

参数方面,name要全局唯一,否则注册会被覆盖;dependencies的字符串需要和项目内部定义的依赖名一致,写错了组件永远不会触发。如果你不确定有哪些依赖名可用,可以在控制台打印注册表内容,或者直接监听 DOM 变化自己判断。

3. 从零配置一套可调试的本地开发流:构建、热重载与日志分级

3.1 构建产物的组成与调试入口

当你第一次拿到构建后的文件,可能会被它的体积吓到——一个.user.js动辄几百 KB。这很正常,因为它把样式、工具函数、组件逻辑全部内联在一起,目的是减少运行时请求。调试时不要直接读产物,而是回到源码,用开发模式启动。

常见做法是配置一个开发构建,关闭压缩和混淆,保留 source map。这样在浏览器控制台里报错时,你能直接定位到源码行号。下面是一个典型的开发脚本配置片段。

// 开发模式构建配置示例(基于常见打包工具) module.exports = { mode: 'development', devtool: 'inline-source-map', // 关键:保留源码映射 optimization: { minimize: false // 关闭压缩,便于阅读 }, output: { filename: 'bilibili-evolved.dev.user.js' }, // 外部依赖不打包,由脚本管理器提供 externals: { 'vue': 'Vue' } };

devtool设为inline-source-map后,控制台报错会直接指向源码文件。minimize: false让产物保持可读,方便你搜索关键字。externals的作用是把体积大的库排除出去,前提是运行环境里已经有这些库,否则会报Vue is not defined。

调试入口有两个:一是浏览器控制台的 Sources 面板,找到注入的脚本文件打断点;二是项目自带的日志系统,通常支持分级输出。把日志级别调到 debug,能看到组件加载、依赖满足、API 拦截等详细过程。

3.2 热重载为什么容易翻车,以及怎么绕过去

热重载在普通 Web 项目里很常见,但在用户脚本场景下特别容易出问题。原因是脚本注入的时机和页面生命周期强绑定,热重载触发时页面状态可能已经变化,旧组件没清理干净,新组件又插进来,结果就是界面错乱或者功能重复。

我一般会分两步走。第一步,开发阶段不追求真正的热重载,而是用「手动重载 + 状态清理」代替。在控制台暴露一个全局函数,调用后先执行所有组件的dispose,再重新注册。

// 手动重载辅助函数,挂在全局便于控制台调用 window.__reloadBE = async function() { // 1. 销毁所有已注册组件 const registry = window.__BE_REGISTRY__; if (registry) { for (const comp of registry.getAll()) { try { await comp.dispose?.(); } catch (e) { console.warn('dispose failed:', comp.name, e); } } } // 2. 清空注册表 registry?.clear(); // 3. 重新执行入口逻辑(具体函数名以项目为准) window.__BE_BOOTSTRAP__?.(); console.log('[BE] reloaded'); };

这个函数的核心是「先清理再重建」。dispose用 try-catch 包住,是因为某个组件清理失败不应该阻塞其他组件。clear清空注册表,避免旧组件残留。最后调用入口函数重新初始化。

第二步,如果确实需要自动重载,用文件监听触发页面刷新,而不是模块热替换。刷新页面虽然慢一点,但状态最干净,不会出现玄学问题。参数上,监听范围要排除node_modules和构建产物目录,否则会陷入「构建触发监听、监听触发构建」的死循环。

3.3 日志分级与线上问题定位

开发时日志越多越好,上线后日志越少越好。Bilibili-Evolved 这类脚本运行在别人的页面上,控制台输出太多会被用户当成报错。所以日志分级不是可选项,是必选项。

常见做法是定义一个日志工具,支持 error、warn、info、debug 四个级别,默认只输出 warn 以上。用户遇到问题时,引导他们在设置里打开 debug 模式,再复现一次,把控制台输出截图或导出。

// 简易日志工具,支持运行时切换级别 const LOG_LEVELS = { error: 0, warn: 1, info: 2, debug: 3 }; let currentLevel = LOG_LEVELS.warn; function setLogLevel(level) { currentLevel = LOG_LEVELS[level] ?? LOG_LEVELS.warn; } function log(level, ...args) { if (LOG_LEVELS[level] > currentLevel) return; const prefix = `[BE][${level}]`; console[level === 'debug' ? 'log' : level](prefix, ...args); } // 使用示例 log('debug', 'component setup', componentName); log('error', 'api intercept failed', error);

setLogLevel可以在设置面板里绑定一个下拉框,让用户自己选。log函数根据级别决定是否输出,debug 级别映射到console.log,避免控制台里出现一堆 debug 样式的输出。参数上要注意,生产构建时可以把currentLevel默认值设为 warn,减少噪音。

定位线上问题时,先让用户打开 debug,然后看三类信息:组件加载顺序、API 拦截结果、DOM 依赖是否满足。大部分问题出在 DOM 依赖上,页面改版后选择器失效,组件静默不执行,用户以为功能坏了。

4. 避坑与排查:样式冲突、API 变动和性能损耗的 5 个真实翻车记录

4.1 样式覆盖不生效,反而把原页面搞乱

现象:自定义样式写进去了,但目标元素没变化,反而旁边几个无关元素变了样。原因通常是选择器优先级不够,或者用了过于宽泛的选择器。页面原有样式可能带!important,或者通过动态类名控制。

解决:不要用元素选择器裸写,加上自己的命名空间前缀,并且用:where()降低优先级或者用!important精确打击。更稳妥的做法是把样式挂到 Shadow DOM 里,彻底隔离。如果必须全局覆盖,先在小范围验证,再逐步扩大。

4.2 页面改版后组件集体失效

现象:某天打开页面,所有增强功能都没了,控制台一堆 null 错误。原因是页面 DOM 结构变了,组件依赖的选择器找不到目标。这是注入类方案最常见的翻车方式,没有之一。

解决:把选择器集中管理,不要散落在各个组件里。页面改版时只需要改一处。另外,给关键组件加上「找不到目标时输出 warn 日志」的逻辑,而不是静默失败。这样用户反馈问题时,你能快速判断是哪个依赖断了。

4.3 拦截 API 导致页面自身功能异常

现象:增强功能正常,但页面原本的某些操作失灵,比如点赞没反应、评论加载不出来。原因是拦截了全局的请求方法,修改了参数或返回值,影响了页面自身的调用。

解决:拦截时遵循「最小改动」原则,只读不写,或者只对特定 URL 生效。修改返回值前先确认页面自身是否依赖原始数据。我一般会先打印原始请求和响应,确认没有副作用后再加逻辑。另外,拦截代码要加 try-catch,异常时放行原始请求,避免把页面搞崩。

4.4 组件重复初始化导致内存泄漏

现象:页面用久了越来越卡,控制台里同一个组件的日志反复出现。原因是单页应用路由切换时,旧组件没有销毁,新组件又注册了一遍。

解决:在组件注册前检查是否已存在同名实例,存在就先销毁再注册。同时确保dispose里清理了所有事件监听、定时器和 DOM 引用。定时器尤其容易漏,setInterval不清除会一直跑。

4.5 构建产物体积失控

现象:产物从几百 KB 涨到几 MB,加载明显变慢。原因是引入了体积大的依赖,或者把开发用的库打进了生产包。

解决:检查externals配置,把运行环境已有的库排除。用打包分析工具看哪些模块占体积,按需引入。样式文件如果很大,考虑拆分,非首屏需要的样式延迟加载。参数上,设置一个体积阈值,超过就报警,防止不知不觉膨胀。

5. 进阶玩法:用自定义插件把 Bilibili-Evolved 改造成你的专属工作台

当你熟悉了组件注册和生命周期管理,就可以跳出「改改样式」的阶段,做一些真正省时间的自动化。我自己的习惯是把它当成一个浏览器内的轻量工作台,把重复操作封装成插件,用快捷键触发。

一个典型场景是批量处理视频信息。比如你想把当前页面所有视频的标题、时长、链接导出成结构化数据,手动复制粘贴效率太低。下面是一个插件思路,展示如何组合 DOM 查询、数据清洗和导出。

// 自定义插件:导出当前列表页的视频信息 // 依赖:列表容器已渲染完成 async function exportVideoList() { // 1. 定位列表容器,选择器需要根据实际页面调整 const items = document.querySelectorAll('.video-list-item'); if (!items.length) { console.warn('[BE] no items found, check selector'); return; } // 2. 提取字段,做空值兜底 const data = Array.from(items).map((item, index) => { const titleEl = item.querySelector('.title'); const linkEl = item.querySelector('a'); const durationEl = item.querySelector('.duration'); return { index: index + 1, title: titleEl?.textContent?.trim() ?? '', url: linkEl?.href ?? '', duration: durationEl?.textContent?.trim() ?? '' }; }).filter(item => item.title); // 过滤掉空标题的脏数据 // 3. 转成 CSV 格式,方便导入表格 const header = 'index,title,url,duration\n'; const rows = data.map(d => `${d.index},"${d.title.replace(/"/g, '""')}",${d.url},${d.duration}` ).join('\n'); const csv = header + rows; // 4. 触发下载 const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'video-list.csv'; a.click(); URL.revokeObjectURL(a.href); console.log(`[BE] exported ${data.length} items`); } // 绑定到快捷键,比如 Ctrl+Shift+E window.addEventListener('keydown', (e) => { if (e.ctrlKey && e.shiftKey && e.key === 'E') { e.preventDefault(); exportVideoList(); } });

这段代码的要点在数据清洗和导出环节。querySelectorAll拿到的是类数组,转成真数组才能用 map 和 filter。每个字段都做了空值兜底,因为页面结构不一定规整。CSV 里的引号需要转义,否则标题里带逗号会破坏格式。最后用 Blob 和 URL.createObjectURL 触发下载,记得 revoke 释放内存。

参数上,选择器是最容易翻车的地方,建议先在控制台里验证document.querySelectorAll的结果数量,再写进插件。快捷键绑定要加preventDefault,避免和页面自身快捷键冲突。如果导出数据量大,考虑分页或加进度提示,避免主线程卡死。

验证插件是否生效,可以看三个信号:控制台有没有输出导出条数、下载目录里有没有 CSV 文件、文件内容是否和页面显示一致。如果条数为零,检查选择器;如果文件为空,检查 filter 条件;如果格式错乱,检查转义逻辑。

我自己的习惯是每写一个新插件,先在控制台里把核心逻辑跑一遍,确认数据正确后再封装成组件。这样能省掉大量「改代码、刷新页面、看结果」的循环时间。另外,插件不要贪多,一个插件只做一件事,组合起来才灵活。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进