为革命保护“视”力
“观察自己发现,其实不是注意力不集中,而是注意力非常容易集中,比如一个小红点,一条信息,都会让自己从放空的状态中跳出来,进入到具体的事物中去。 或许问题不该是如何集中注意力,而是想办法把环境中容易吸引注意力的事物挪掉。 更深处的原因,则是这些自己对于「存在」的焦虑,所以任何社交媒体/工具的反馈,总是能第一时间得到自己关注及行动,以便再次确认自己「存在」且「有价值」。” —— 产品沉思录 Vol.029
前言
信息成瘾和注意力的夺取,在互联网平台和用户两者之间形成了相互同构的力量: 从平台角度,通过对产品细节的设计,给用户以信息反馈(如小红点,精准的内容推荐)机制,使用户养成对这种反馈的习惯,来保持用户的活跃度; 从用户角度,为了更多的流量的媒体和帐号,在发布的内容中精心的挑选和设计或穿插一些可能无关联甚至吸引性的图片。 大量无关联的配图造成了注意力的分散使得信息获取的过程受到干扰,无法专注。 图片和媒体资源的滥用,形成了一种噪音,使得纯文字的阅读能力和信息的表达深度在这视觉的转移运动中逐渐削弱。
如何获取更多的注意力主动权,保护自己的时间和行为价值,就像做眼保健操一样,是在“课前”需要逐渐的养成良好习惯。
另外推特这样的内容开放的平台,在公共场合浏览时,有时跳出一两条不合时宜的图片,让自己老脸一红或措手不及。
因为这样的出发点,顺手也想尝试下浏览器插件的开发,便产生了 X-Comfort-Browser 这个小工具,用来解决上述场景的问题。
开发
框架选择
在插件阵营,Webkit(Chrome、Edge、Safari、360...)和 Gecko(Firefox)两种不同内核的浏览器分占两壁江山。开发模式也存在差异,好在有成熟的框架可以对两者兼容,能省去不少开发的琐事。
目前主流的插件开发框架 Plasmo (10.5k) 和 WXT (4.4k) 对 Typescript 和多种前端框架都做了支持,开发时可以根据自己的技术偏好来进行选择。
因为是先接触到了 WXT,所以该文章和插件就基于 WXT 来展开进行。
插件构成
-
Manifest.json 配置文件:定义插件的基本信息和权限。
-
Popup 弹窗页面:用户界面,展示信息和接收用户输入。
-
Content Script:内容脚本,用于与网页内容交互。
-
Background Script:后台脚本,处理长期任务和事件。
-
其他组件:如 Options 页面、Browser Action、Page Action、通信机制等。
核心代码
import { defineContentScript } from "wxt/sandbox";
import { defaultBlur, storageKeys } from "@/const";
import { createButton } from "@/utils";
const BLUR_EMOJI = "👀";
const UN_BLUR_EMOJI = "🙈";
// 元素状态
const statusMap = new Map<string, boolean>();
// 资源选择器(需要被模糊屏蔽的媒体资源)
const selectors = [
'[data-testid="tweetPhoto"]',
'[data-testid="videoComponent"]',
'[data-testid="videoPlayer"]',
'[data-testid="card.layoutLarge.media"]',
'[data-testid="collection-hero-image"]',
'[data-testid="article-cover-image"]',
];
async function handleElements() {
const enable = (await storage.getItem<boolean>(storageKeys.enable)) ?? true;
const blur = (await storage.getItem<number>(storageKeys.blur)) ?? defaultBlur;
selectors.forEach((selector) => {
let elements: HTMLElement[] = Array.from(
document.querySelectorAll(selector)
);
elements.forEach((element) => {
let current = element;
let hasBlur = false;
while (current.parentElement !== null) {
current = current.parentElement;
if (current.matches(selectors.join(","))) {
hasBlur = true;
break;
}
}
// 检测是否已存在被处理的父级
if (hasBlur) return;
let comfortId = element.getAttribute("data-comfort-id");
if (!comfortId) {
// 标记待处理元素,生成唯一ID
comfortId = crypto.randomUUID();
element.setAttribute("data-comfort-id", comfortId);
// 添加按钮手动切换元素状态
const button = createButton(comfortId, handleElements);
button.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
const newStatus = !statusMap.get(comfortId!);
statusMap.set(comfortId!, newStatus);
if (newStatus) {
element.style.filter = `blur(${blur}px)`;
button.innerText = BLUR_EMOJI;
} else {
element.style.filter = "none";
button.innerText = UN_BLUR_EMOJI;
}
};
element.parentElement?.insertBefore(button, element);
}
// 保存元素的模糊状态
if (!statusMap.has(comfortId)) {
statusMap.set(comfortId, enable);
}
const targetElement = element as HTMLElement;
const toggleButton = document.getElementById(comfortId) as HTMLElement;
if (!enable) {
targetElement.style.filter = "none";
toggleButton.style.display = "none";
statusMap.clear();
return;
} else {
targetElement.style.transition = ".3s";
toggleButton.style.display = "block";
}
const blurStatus = statusMap.get(comfortId);
if (blurStatus && targetElement.style.filter !== `blur(${blur}px)`) {
targetElement.style.filter = `blur(${blur}px)`;
toggleButton.innerText = BLUR_EMOJI;
}
if (!blurStatus && targetElement.style.filter !== "none") {
targetElement.style.filter = "none";
toggleButton.innerText = UN_BLUR_EMOJI;
}
});
});
}
export default defineContentScript({
// 匹配脚本生效的域名
matches: ["*://x.com/*"],
// 脚本运行时机
runAt: "document_idle",
main(ctx) {
console.log("Hello from X-Comfort-Browser.");
// 监听 自定义参数值 变化
[storageKeys.blur, storageKeys.enable].forEach((key) => {
storage.watch<number | boolean>(key, (v) => {
handleElements();
});
});
// 监听 页面元素 变化
const observer = new MutationObserver(() => handleElements());
observer.observe(document.body, { childList: true, subtree: true });
},
});