我为什么会做这个库呢?源自于爱看直播这件事,某b平台的直播是没有某鱼的油猴+1插件导致有时候得手动独轮车,就搞得很烦,索性自己写个这方面的油猴脚本,拿来开源,给大家都爽爽。
说干就干,在把整体项目架构和具体实现都写得差不多时,我发现使用油猴对网页进行改造很麻烦,甚至于要操作一堆原生DOM API使得整体代码非常臃肿和难看,也无法应对一些复杂的情况,而且市面上的包能完全符合我的需求基本没有,我也就把这一部分从原有的项目拿出来,把重心投入这边来开发,再把他开源出来
库的目标是什么
- 稳定简洁的API
- 组件替代DOM API逻辑
- 组件的存活问题
- 把每一个注入行为抽象成一个
Task进行操作
根据这些目标,所以项目结构被定义成了这样
Typescript
src/
├── core/
│ ├── task/
│ │ ├── TaskContext.ts
│ │ ├── TaskLifeCycle.ts
│ │ ├── TaskRegister.ts
│ │ └── TaskRunner.ts
│ ├── watcher/
│ │ └── DomWatcher.ts
│ └── Injector.ts
├── util/
│ ├── getComponentName.ts
│ ├── markRawComponent.ts
│ └── uuid.ts
├── index.ts
└── type.ts
项目结构整体就分为两部分,core和util,很简单,core是用来写核心逻辑和执行流程的,util就是放一些工具func辅助开发核心逻辑。
设计思路
整体的执行流程是
通过TaskRegister把三种不同情况注册进来的封装成Task,并存储在TaskContext中,当监视到DOM中所要的元素出现时就会执行注入流程,整体的注入流程由TaskRunner负责,再注入完毕后可以使用TaskLifeCycle对其进行生命周期操作,销毁重置或者组件的复活启用与否。
技术难点
整体的技术难点主要聚焦于复杂情况下,DOM 元素的等待与识别问题。乍一看似乎只是两个问题,难度并不高,但实际上这两个问题会被拆解到很多具体场景里。
1. 关于"等待"
首先是"等待"并不只是简单地轮询某个节点是否出现。真实页面里,经常会遇到异步渲染、分段加载、路由切换、局部刷新、懒加载甚至节点短暂出现后又被替换的情况。
也就是说,目标元素并不是"没出现"和"出现了"这么简单,而是可能经历 "晚出现、反复重建、瞬间失活、重点覆盖目标节点移除与常见局部重建场景" 等多个状态。
2. 关于"识别"
其次是"识别"也不只是写一个选择器去匹配目标元素。
- 结构不确定性: 第三方页面的 DOM 结构往往不稳定,类名可能是动态生成的,层级结构可能随着版本迭代频繁变化。
- 实例多重性: 同一类节点还可能同时存在多个实例。
要真正做到稳定注入,除了找到元素本身,还要判断它:
- 是不是正确的目标?
- 是不是已经被注入过(防重)?
- 是不是处于可挂载状态?
3. 叠加后的工程复杂性
再进一步,这两个问题叠加后,会带来一系列工程层面的挑战:
- 时机与范围: 什么时候开始监听?监听到什么范围为止?
- 清理与重构: 如何在节点失活后安全清理 ?如何在页面重建后重新挂载?
- 性能取舍: 如何在确保稳定性 的同时,兼顾最低的性能开销?
这也是为什么这个库最终并没有停留在"找到节点然后挂组件"这么简单的层面,而是逐渐迭代成了一套围绕任务注册、目标等待、生命周期管理和重注入机制展开的方案。
解决方案
1. 基于 DomWatcher 的目标等待机制
在目标节点首次出现之前,任务会先进入等待态,由 DomWatcher 负责监听目标元素的出现时机。
这一步的目标不是简单轮询,而是通过 DOM 变更监听,在节点真实进入页面时再触发后续注入逻辑,从而适应异步渲染、延迟挂载和局部更新等常见场景。
2. 基于 alive 的组件复活与重注入机制
仅仅做到"等到节点出现再挂载"还不够。在很多第三方页面里,目标节点即使已经成功挂载,后续仍然可能因为局部 DOM 重建、列表刷新、路由切换等原因被移除。为了让注入组件能够持续存活,库中进一步引入了 alive 机制。
这套机制的核心逻辑是:
- 当组件完成挂载后,系统会继续为当前宿主节点建立"失活监听";
- 一旦检测到当前目标节点被移除,任务不会直接终止,而是先清理当前运行时状态;
- 清理完成后,任务会重新回到等待流程,继续监听目标节点是否重新出现;
- 当新的目标节点重新进入页面时,再执行一次注入,完成组件复活。
因此,这里的"复活"并不是保留旧节点继续工作,而是在节点失效后,让任务重新进入一个可重试、可恢复的注入闭环。
3. 配置化监听范围:
在重注入链路中,监听范围并不是固定不变的,而是允许根据宿主页面的稳定程度进行选择。
- 一般状态: 只要开发者能确定目标挂载点的父节点(Parent Node)在宿主环境中是相对稳定 的,
DomWatcher就能将观察范围锁定在局部。这样可以避开页面其他区域(如高频刷新的弹幕区)的干扰,以极低的 CPU 开销精准捕捉 DOM 变动,确保注入任务能够及时触发。 - 边界状态: 反之,如果宿主环境的 DOM 结构变动剧烈,连父节点都无法保持稳定,则需要将监听范围"向上挪移"至页面的
body元素甚至document级别。
4.状态一致性
在设计之初,使用nextTick之后开启重注入机制是一个保守的选择。在现在页面测试样本没起来的阶段我无法判断一些极端情况的执行,所以我选择了使用nextTick增加了一层时序缓冲层,稳定性优先,但也引入了异步,导致了状态不一致的问题。
针对这一点,我引入了 aliveEpoch 作为任务的版本标记。每当任务经历 enableAlive、disableAlive、reset、destroy 等生命周期切换时,都会推动 aliveEpoch 递增。异步回调执行时,会校验当前上下文中的 aliveEpoch 是否仍与闭包中捕获的版本一致;如果不一致,则说明该回调已经过期,应直接终止执行,避免旧状态污染当前任务。
从实现角度看,这个方案增加了异步控制和版本校验的复杂度,代价不小。但在库刚开源、真实页面反馈仍然有限的阶段,我认为这是一个值得接受的保守方案。后续是否继续保留这层设计,会根据更多实际场景再做调整。
边界与限制
库本身还是处于初期的状态,很多东西没有那么完善,所以也有着一定的限制
-
对于监听
DOM的onDomReady方法,底层其实是允许配置监听范围的,但在当前对外封装里,默认还是统一监听body元素。主要原因在于,现阶段这个库面对的宿主页面大多是第三方页面,这类页面的DOM结构往往不稳定,开发者在初次注入阶段未必总能准确给出一个长期稳定的局部父节点。相比之下,直接以body作为默认监听范围虽然会牺牲一部分性能上的精细控制,但可以尽量降低漏监听、监听节点失效、以及因为局部根节点判断错误而导致注入失败的问题。本质上,这是当前阶段一个偏保守的取舍,即优先保证初次注入的稳定性,而不是过早追求监听范围的最小化。(还有就是如果@run-at设置成了document-start,这时会报错的,这算是设计上的一个缺陷,会在后面版本修复的) -
目前整体设计仍然偏向于单目标注入场景,并不是一套面向多实例节点批量挂载的完整方案。也就是说,当同一类目标节点在页面中同时存在多个实例时,当前版本更关注"找到一个符合条件的稳定挂载点并完成注入",而不是对多个目标进行统一识别、去重、分发和生命周期管理。这部分能力后续如果要继续扩展,势必要引入更完整的
实例管理模型。不过也存在一些例外场景,竖向滚动弹幕触发的侧边栏。虽然交互入口来自多个弹幕实例,但最终注入目标往往是容器外部一个相对稳定、且只初始化一次的独立区域。对于这类场景,最终需要处理的仍然是一个相对稳定的单目标宿主,因此当前方案通常不会有太大问题。 -
对于"目标节点是否可挂载"的判断,当前版本主要还是基于
DOM层面的连通性,也就是节点是否已经真实进入文档树、是否仍然处于有效挂载状态。但这种判断并不等价于节点一定已经可见、布局已经稳定,或者页面已经进入适合注入的最终渲染时机。因此在一些动画过渡、延迟布局、折叠区域、虚拟列表等场景下,节点即使已经连接到DOM,也不代表一定是最佳注入时机。 -
当前版本对于宿主环境的覆盖范围也还有限。一些更复杂的
DOM场景,例如iframe内部文档、shadowRoot隔离树,以及依赖属性变化而不是节点增删来驱动状态切换的识别场景,目前都还没有被完整纳入这套监听与重注入机制中。换句话说,现阶段这套方案主要还是围绕标准文档流中的节点出现、移除和常见局部重建来工作,对于更复杂的宿主结构,后续仍然需要继续补足。(提一嘴,就是iframe这个问题我之前是尝试解决的,搞笑的是html插了进去但是样式还在iframe外 :D)。
场景设定
假设你要给一个竖向滚动弹幕的侧边栏添加一个按钮,这个按钮要可以发出你选择的这个人的弹幕内容(也就是很普通的+1功能)
具体步骤
- 你首先要分析整体页面结构
- 找出注入点的选择器
- 编写vue组件
- 调用代码执行注入
所以整体就会变成这样(下面的所有代码都是从我另一个项目里拿出来进行略微修改过的)
注入组件的部分
Typescript
import { createPinia } from 'pinia'
import { storeToRefs } from 'pinia';
import { Injector } from 'vue-implant';
import DanmakuPlusOneButton from '../../components/inject/DanmakuPlusOneButton.vue';
import { useConfigStore } from '../../store/useConfigStore';
import { onDanmakuMenuClick } from '../observer/onDanmakuMenuClick';
const pinia = createPinia();
const injector = new Injector();
injector.register('div.danmaku-menu>.none-select', DanmakuPlusOneButton, {
alive: true,
scope: 'global',
on: {
listenAt: '#chat-items',
type: 'click',
callback: onDanmakuMenuClick,
activitySignal: () => {
const configStore = useConfigStore();
const { isInjectPlusOneComponent } = storeToRefs(configStore);
return isInjectPlusOneComponent;
}
}
});
injector.setPinia(pinia);
injector.run();
侧边栏要添加的按钮
Vue
<script lang="ts" setup>
import { storeToRefs } from 'pinia';
import { useConfigStore } from '../../store/useConfigStore';
import { GM_getValue } from '$';
import { danmakuSender } from '../../module/danmaku';
const configStore = useConfigStore();
const { isInjectPlusOneComponent } = storeToRefs(configStore)
const handlePlusOneClick = () => {
danmakuSender(GM_getValue('currentDanmakuContent'));
}
</script>
<template>
<div class="danmaku-plus-one-button" v-show="isInjectPlusOneComponent" @click="handlePlusOneClick">
<a class="danmaku-plus-one-content">+1</a>
</div>
</template>
<style scoped>
.danmaku-plus-one-button {
cursor: pointer;
padding: 10px;
width: 100%;
}
.danmaku-plus-one-button:hover {
background: var(--float-bg-2-float, #f6f7f8) !important;
}
.danmaku-plus-one-content {
display: block;
color: #61666d !important;
text-decoration: none;
}
.danmaku-plus-one-content:hover {
color: var(--norm-brand-pink, #f69) !important;
}
</style>
我来详细解释一下这部分代码
register方法包含三部分
- 选择器:表明想要把组件挂载到什么地方,在通过这个选择器参数传进来之后,会获取到对应的元素,然后在该元素内部进行挂载。
- 组件:传递一个要注入的组件。
- 按需配置 :你可以在发起单个注入任务时传入自定义参数,这些参数将自动覆盖 全局默认设置,确保每个 Task 都能灵活适配不同的宿主环境。
- 这个配置项里面有着几种不同的配置,alive和scope则是配套的一对,用来进行重注入的配置。
- 而on这个配置项则是启动一个外部监听器,在这个场景里主要的作用就是要获取我们点击的那个弹幕的内容,使得能够发弹幕时能够有相同的内容。还要提一嘴就是,activitySignal这一项是为了控制这个外部监听器的开关,他绑定的是侧边栏组件里面的那个
isInjectPlusOneComponent,方便在不启用这个组件时能够移除这个监听器。
返回值 则是一个对象,里面包含了此次Task的TaskId,注册是否成功isSuccess,还有控制组件重注入的开关句柄。
这段代码通过在 Injector 中统一分发 Pinia 插件,我们确保了每一个动态注入的子组件都能接入全局状态总线,从而实现不同 Store 之间的数据同步与响应式通信。最后再调用run方法开始组件注入。
而这个vue组件做的事就很简单了,把isInjectPlusOneComponent从Store拿出来,作为控制显隐的响应变量,然后就是发送弹幕了
那么组合起来会是什么样的呢?

你只需要知道那个侧边栏+1按钮是用来控制+1组件的显隐就够了,在开启之后我们点击弹幕即可看到+1按钮,这时候点击之后就可以对其弹幕进行+1了。
一些碎碎念
这个库在刚发布的几天有了接近200的周下载量,要我说不开心是不可能,不过也给我看到这个库的潜力,我希望能够让这个库能够在开发油猴时能够提升你的体验,而这个库我也会一直维护下去的。
更多细节可以直接去看vue-implant的仓库: FlowingInk/vue-implant: A Vue 3 component injection framework for userscripts and browser extensions --- with lifecycle management, DOM detection, and auto re-injection.