Browser Extension Dev - 08. 发布 Chrome Web Store

前言 在之前的 7 篇博客中,我们依次了解了一些扩展开发中的基本概念,并且每一篇都附上了一个扩展示例。现在,我们终于要演示如何发布扩展了。下面我们将演示如何将之前做的自动冻结不活跃标签页的那个扩展发布到 Chrome Web Store 中,还记得吗?就是我们在 Browser Extension Dev - 04. Background Script 和 Browser Extension Dev - 05. 存储和配置 中作为示例的那个扩展。 步骤 准备 Chrome Web Store 发布账户 首先按照官方文档注册开发者账户,需要一个 Google 账户并且支付一次性注册费用 $5 即可完成。参考:https://developer.chrome.com/docs/webstore/register 然后继续完成设置,主要是设置开发者账户名称,以及验证邮箱,没什么太复杂的。参考 https://developer.chrome.com/docs/webstore/set-up-account 注册完成后打开 https://chrome.google.com/webstore/devconsole/ 应该可以看到如下页面。 构建扩展包 接下来,开始演示如何从构建到最终发布扩展。 首先,在项目中打开终端,然后运行 pnpm zip,应该会看到类似下面这样的输出,可以看到 Chrome 扩展已经被正常打包成 zip。 1 2 3 4 ℹ Zipping extension... ✔ Zipped extension in 12 ms └─ .output/05-storage-and-configuration-0.0.0-chrome.zip 13.37 kB Σ Total size: 13.37 kB 在 .output 目录下找到这个文件,记住这个路径。 上传到 Chrome Web Store 然后打开 https://chrome.google.com/webstore/devconsole/ 并点击右上角的 New Item 按钮。 选择刚刚找到的 zip 文件上传,此时遇到了一个错误,提示 The manifest has an invalid version: 0.0.0. Please format the version as defined,也就是版本号不能为 0.0.0 使用 pnpm version patch 将版本号增加到 0.0.1,然后重新运行 pnpm zip 构建并上传,即可看到扩展发布管理页面。 配置商店展示信息 (Store Listing) 其中,对于发布而言,最重要的两个标签页是 Store listing 和 Privacy。前者用于配置扩展在 Chrome Web Store 中的展示信息,例如简介、分类、图标和截图等等,后者则是权限使用说明和隐私政策链接。 对于这个扩展而言,选择分类为 Productivity > Tools,语言选择 English。 然后从 public/icon 目录选择 128.png 图标作为在商店显示的扩展图标。要截取精确 1280x800 像素的截图可能有点麻烦,但可以直接使用 https://squoosh.app 来调整截图的大小,使用 Resize 功能调整截图尺寸到 1280x800 就好了。 PS: 如果你使用 mac,可以使用小工具 Window Resizer 来将窗口尺寸修改为指定大小。 参考 Chrome 官方发布文档 https://developer.chrome.com/docs/webstore/publish 配置隐私政策 (Privacy) 切换到 Privacy 标签,可以看到有几个主要区域 Single purpose:单一用途说明,简单来说就是用一两句话说清楚扩展是做什么的 Permission justification:权限使用说明,注意最后的 Are you using remote code?,Chrome 禁止使用远程代码,某些库(如 zod)可能会不小心引入远程代码,需特别留意 Data usage:数据收集说明,选择扩展收集了什么数据,如果扩展是本地运行的,那么不需要选择任何选项 Privacy policy:隐私政策链接,这是唯一需要外部托管的内容,可以在个人域名上托管它,如果是开源的,也可以直接放上 GitHub 相关文件的链接。这是一个示例:https://rxliuli.com/webstore/privacy/ 提交审核 按下 Save draft 按钮之后,如果 Submit for review 按钮可用,那就说明可以提交扩展进行审核了。 提交审核后,将会进入审核队列,通常需要几天甚至更长时间进行初次审核,所以还请耐心等待,某些使用高风险权限(例如向所有网站注入脚本)或者针对高风险网站(当然,吾辈是在说 YouTube)的扩展可能需要等待更长时间。 总结 至此,浏览器扩展开发的基础内容就介绍完了。后续可能会有一些进阶主题的番外篇,比如国际化、GitHub Actions 自动发布等。 如果还对发布 Safari 扩展并上架 App Store 感兴趣,可以查看我之前写的博客 转换 Chrome Extension 为 Safari 版本 和 发布 Safari 扩展到 iOS 应用商店。提醒一下,这非常复杂,且开发者账户无试用期,必须满足 1)有一台 macOS 电脑并且安装 Xcode 等开发工具 2)开通 App Store 开发者账户并支付 $99/年的费用。

2026/1/26
articleCard.readMore

Browser Extension Dev - 07. Popup UI

前言 在上一章 Browser Extension Dev - 06. Inject Script on Demand 中,我们介绍了按需为网页注入脚本执行自定义的功能,还实现了一个简单的复制网页主要内容为 Markdown 的扩展。在这一章中,我们将继续实现一个 Popup 弹窗,用于显示页面主要内容转换得到的 Markdown,并支持在复制之前进行预览和编辑。 首先,需要明确 Popup 是什么? 之前我们已经接触过 Content Script 注入网页的 UI 和 Options 配置页面。Popup 类似于 Options 页面,独立运行,但权限相比 Background/Options 更加受限。通常而言,它和 Content Script UI 的应用场景非常接近,都是显示一些当前网页相关的内容,但它也有一些独有的适用场景: 安全与隔离,网站无法以任何方式主动访问 Popup UI,它们完全由浏览器的不同线程/进程进行隔离。Content Script 注入的任何 UI 都有可能被网页检测出来,这就是网页能够检测是否使用了广告拦截器的原因之一。 不受普通网站影响,例如一个定时刷新的扩展,可以自动刷新当前页面,我们肯定不希望每次刷新网页之后都重新注入并显示操控面板。 无需内存清理,注入 Content Script 很难完全清理内存,这在普通网页不会出现问题,但在 SPA 网页可能会导致问题,复杂的(换句话说,使用了很多 npm 包的)JavaScript 代码真的到处都是内存泄漏。而 Popup 在关闭后就彻底销毁了,下次会再次重建。 可以在特权页面打开,例如 https://chromewebstore.google.com/,所有扩展的 Content Script UI 都会在这个网站禁用,但可以打开 popup 并且获得当前标签页的 URL,这在特定场景很有用,例如用于下载扩展 zip 文件的工具 Content Script UI 则有其他几个优势 更大的 UI 区域:Popup UI 受限于面板宽度,无法制作全屏面板 更容易与网站本身高度集成,例如需要添加符合网站外观的按钮时 更容易控制和修改网站本身,例如希望拦截网络、监听并修改 DOM、或者拦截脚本的特定代码执行之类的 – Popup 可以结合 Background Script 注入脚本做到,但没有那么灵活 接下来,让我们接着之前的实现继续完善吧。 参考 Chrome 官方文档 https://developer.chrome.com/docs/extensions/develop/ui/add-popup 思考 现在面临一个问题:如何在 Popup 中获取页面的内容? 答案是无法直接获取,需要通过 Background Script 中转,大致流程如下: Popup → Background → executeScript(inject.js) → 返回 markdown → Popup 显示 但是等等,scripting.executeScript 可以有返回值吗?当然可以,它支持同步和异步返回值,但返回值必须是可结构化克隆的。 参考 Chrome scripting API 关于 Promise 返回值的官方文档 https://developer.chrome.com/docs/extensions/reference/api/scripting#promises 实现 添加 popup 页面 首先添加一个 popup 页面,在 entrypoints/popup 下添加 index.html 和 main.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 <!-- entrypoints/popup/index.html --> <!doctype html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Popup</title> <style> html, body, #root { margin: 0; padding: 0; width: 600px; height: 800px; font-size: 16px; } </style> </head> <body> <div id="root"></div> <script type="module" src="./main.ts"></script> </body> </html> 1 2 3 4 5 6 7 // entrypoints/popup/main.ts const root = document.getElementById('root')! root.innerHTML = ` <h1>Popup UI</h1> <p>This is a placeholder for the popup UI.</p> ` 在浏览器中加载扩展之后,点击 action 可以看到弹窗出现了。 修改注入的脚本 Inject Script 在实现通信部分之前,需要修改一下之前注入的 Inject Script,不再复制 Markdown 到剪切板,而是使用 return 返回给调用者。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import { Readability } from '@mozilla/readability' import TurndownService from 'turndown' export default defineUnlistedScript(async () => { const service = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', }) const reader = new Readability(document.cloneNode(true) as Document) const article = reader.parse() if (article && article.title && article.content) { return service.turndown(article.content) // const markdown = service.turndown(article.content) // await navigator.clipboard.writeText(`# ${article.title}\n\n${markdown}`) // alert('Article copied as Markdown!') } else { return null // alert('Failed to parse the article.') } }) 实现 Popup 与 Background Script 通信 下面开始实现 Popup 与 Background Script 的通信部分,由于 Chrome 原生的通信 API 使用起来非常痛苦,这里使用一个浅包装 @webext-core/messaging。 安装依赖 1 pnpm i @webext-core/messaging 然后在 lib/messager.ts 中定义接口 1 2 3 4 5 6 // lib/messager.ts import { defineExtensionMessaging } from '@webext-core/messaging' export const messager = defineExtensionMessaging<{ getMarkdown: () => string | null }>() 然后在 Background Script 定义实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 messager.onMessage('getMarkdown', async (ev) => { const tabs = await browser.tabs.query({ active: true, currentWindow: true, }) if (!tabs[0].id) { throw new Error('No active tab found') } const result = await browser.scripting.executeScript({ target: { tabId: tabs[0].id }, files: ['/inject.js'] as PublicPath[], }) // 执行脚本并获取返回值 return result[0].result as string | null }) 最后在 Popup 中调用,出于简化考虑,这里直接使用 pre 渲染了 Markdown,我们将在下一步引入所见即所得的 Markdown 编辑器。 1 2 3 4 5 6 7 import { messager } from '@/lib/messager' const root = document.getElementById('root')! const md = await messager.sendMessage('getMarkdown') const pre = document.createElement('pre') pre.textContent = md as string root.appendChild(pre) 添加 markdown 编辑器 由于并未使用 react,所以这里直接使用一个 vanilla JS 实现的 markdown 编辑器 easymde。 还是先安装依赖。 1 pnpm i easymde 然后在 Popup 中使用它。 1 2 3 4 5 6 7 8 9 10 11 12 import 'easymde/dist/easymde.min.css' import { messager } from '@/lib/messager' import EasyMDE from 'easymde' const root = document.getElementById('root')! const textarea = document.createElement('textarea') root.appendChild(textarea) const md = await messager.sendMessage('getMarkdown') new EasyMDE({ element: textarea, initialValue: md as string, }) 现在就可以看到最终的效果了。 总结 在这一章,我们介绍了 Popup 的应用场景、Popup 与 Background Script 的通信、以及从网页获取数据的功能与实现。在下一章,我们终于要发布插件了,我将演示如何将插件发布到 Chrome Web Store,以便让其他人也能使用开发的扩展。 如果有任何问题,欢迎加入 Discord 群组讨论。 https://discord.gg/VxbAqE7gj2 完整代码:https://github.com/rxliuli/browser-extension-dev-examples/tree/main/packages/07-popup-ui

2026/1/21
articleCard.readMore

Browser Extension Dev - 06. 按需注入脚本

前言 在上一章 Browser Extension Dev - 05. Storage and Configuration 中,我们介绍了为扩展添加设置页面并使用 Storage API 保存和读取配置的功能。在这一章,我们将介绍按需注入脚本。这种方式完全不会拖慢网站运行速度,同时在 Chrome Web Store 安装时不会有任何安全警告提示。接下来我们将实现一个扩展:点击图标即可将网页主要内容复制为 Markdown。 思考 我们之前已经接触过 Content Script 注入和 Background Script 监听扩展图标点击。虽然尚未介绍,但两者可以进行消息通信。 有了上面的背景知识,你可能会想要这样做: 为所有网页注入 Content Script 并监听后台消息 点击扩展图标时在 Background Script 通知 Content Script 在 Content Script 中执行具体逻辑 这种方法有几个主要缺点: 默认为所有网页注入 Content Script 不仅会拖慢网站速度,还可能导致风险,因为注入过程对用户完全无感知,后续扩展更新可能引入漏洞 权限要求极高,安装扩展时会提示安全警告,说明这个扩展要求读取和修改所有用户访问的网站 而对于需要明确动作触发的场景,其实有更简单的实现方式: 点击扩展图标时在 Background Script 中向当前网页注入一段脚本 在脚本中执行具体逻辑 这样,需要的权限就从 ['<all_urls>'] 变成了 ['activeTab', 'scripting'],虽然权限数量变多了,但风险反而更低,必须由用户触发才能执行代码,所以安装扩展时不会有任何警告。例如: 这里涉及到的关键 API 是 scripting.executeScript,顾名思义,用于执行自定义脚本。 参考 Chrome 官方 activeTab 指南:https://developer.chrome.com/docs/extensions/develop/concepts/activeTab 实现 在扩展图标点击时注入脚本 接下来在后台脚本中实现监听和注入部分。 首先还是更新 wxt.config.ts,添加所需权限以及空的 action 字段。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 import { defineConfig } from 'wxt' export default defineConfig({ manifestVersion: 3, manifest: { name: 'Copy As Markdown', description: 'Copy page content as Markdown', permissions: ['activeTab', 'scripting'], action: {}, }, webExt: { disabled: true, }, }) 然后添加一个用于测试的注入脚本。与 Content Script 不同,这类脚本在 WXT 中需要使用 defineUnlistedScript 声明。 1 2 3 4 // entrypoints/inject.ts export default defineUnlistedScript(() => { alert('Injected script executed!') }) 然后在 background 中监听并注入它。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 import { PublicPath } from 'wxt/browser' export default defineBackground(() => { browser.action.onClicked.addListener(async (tab) => { if (tab.id) { await browser.scripting.executeScript({ target: { tabId: tab.id }, // 这里的 /inject.js 是指构建之后的文件,如果你使用 pnpm build,就可以在 .output/chrome-mv3 中看到 inject.js 了 // 注:一开始这里可能会报 ts 类型错误,pnpm dev/build 启动之后 wxt 会正确扫描 entrypoints 并生成类型定义 files: ['/inject.js'] as PublicPath[], }) } }) }) 除了 files 参数,还可以通过 func/args 直接传递函数和参数,适用于简单场景。参考:https://developer.chrome.com/docs/extensions/reference/api/scripting#type-ScriptInjection 当我们打开 google.com 然后点击扩展图标时,却发现没有反应。查看扩展详情页面,可以看到一个错误。 1 Uncaught (in promise) Error: Could not load file: 'inject.js'. 和之前 Browser Extension Dev - 03. 注入 UI 时一样,需要在 manifest 中增加 web_accessible_resources 配置。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import { defineConfig } from 'wxt' export default defineConfig({ manifestVersion: 3, manifest: { name: 'Copy As Markdown', description: 'Copy page content as Markdown', permissions: ['activeTab', 'scripting'], action: {}, web_accessible_resources: [ { resources: ['inject.js'], matches: ['<all_urls>'], }, ], }, webExt: { disabled: true, }, }) 然后再试一次,可以看到脚本确实被注入并正确执行了。 ⚠️ 局限性:如果你希望注入的脚本能持久化(刷新或重新进入页面后仍自动运行),这是行不通的,仍然需要正确声明 host_permissions 权限来持久化注入 Content Script,即使使用 scripting API 仍然受到权限模型的限制。 在注入脚本中实现功能 接下来实现读取网页主要内容,转换为 Markdown,然后复制到剪贴板的功能。借助 npm 生态,实现起来非常简单。 读取网页主要内容:使用 @mozilla/readability 包 转换 HTML 为 Markdown:使用 turndown 包 首先安装需要的依赖 1 2 pnpm i @mozilla/readability turndown pnpm i -D @types/turndown 然后编写少量胶水代码即可完成。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import { Readability } from '@mozilla/readability' import TurndownService from 'turndown' export default defineUnlistedScript(async () => { const service = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', }) const reader = new Readability(document.cloneNode(true) as Document) // 深度复制避免影响到原网页 const article = reader.parse() // 解析主要内容 if (article && article.title && article.content) { const markdown = service.turndown(article.content) // 转换 HTML 为 Markdown await navigator.clipboard.writeText(`# ${article.title}\n\n${markdown}`) // 复制 alert('Article copied as Markdown!') } else { alert('Failed to parse the article.') } }) 总结 在本章中,我们实现了一个按需注入脚本的扩展,它不会影响网页正常运行,只在用户触发时才执行代码,真正做到即插即用。在下一篇,我们将继续完善这个扩展,使用 Popup 弹窗直接预览和编辑从当前页面复制的 Markdown。 如果有任何问题,欢迎加入 Discord 群组讨论。 https://discord.gg/VxbAqE7gj2 完整代码:https://github.com/rxliuli/browser-extension-dev-examples/tree/main/packages/06-inject-script-on-demand

2026/1/21
articleCard.readMore

Browser Extension Dev - 05. 存储和配置

前言 在上一章 Browser Extension Dev - 04. Background Script 中,我介绍了 Background Script 的概念和使用场景,并实现了一个自动休眠不活跃标签页的扩展。在本章,我将介绍如何在扩展中存储数据和配置选项,并提供一个配置页面来访问它。 Storage API(概念) 浏览器为扩展提供了 browser.storage API,允许存储 kv 数据,可以存储任何可以被结构化克隆的数据,通常而言对于扩展的配置功能足够了。除此之外,有时候还使用 localStorage(如果是 Content Script)或 indexeddb(简单的 kv 存储不足以满足需求时)来存储扩展的设置。 其中 browser.storage API 下有几个选项,它们的接口是一致的,只是存储的方式和行为有些不同 storage.local: 本地持久化存储 storage.sync: 在不同设备之间同步(有严重的局限性,仅限登录相同账号的相同浏览器,即便如此,Safari 也不支持同步) storage.session: 临时存储在内存中,不会持久化,浏览器关闭重启即消失 storage.managed: 企业环境使用,通常扩展开发者完全不必关心 参考 Chrome 官方文档: https://developer.chrome.com/docs/extensions/reference/api/storage 配置页面(概念) 配置页面是浏览器为扩展提供的一个专用页面,允许在单独的页面中调整扩展的选项,或者访问扩展提供的功能。下面是两种配置页面的使用方式 直接使用浏览器内嵌页面访问,布局紧凑,适合配置项较少的情况,也是官方推荐的默认方式。 或者在独立标签页中打开,有更大的空间展示完整配置甚至功能,但需要额外配置或编写代码才能让用户方便地访问。 在 WXT 中,可以在 options.html 中添加 meta 标签来修改它,参考 https://wxt.dev/guide/essentials/entrypoints.html#options 1 <meta name="manifest.open_in_tab" content="true|false" /> 同时,也有两种方法可以访问扩展的配置页面 点击扩展的 More Options > Options 来打开 进入扩展的详情页面,然后查找 Extension options 按钮 参考 Chrome 官方文档: https://developer.chrome.com/docs/extensions/develop/ui/options-page 实现 基础配置页面 在 WXT 中,需要在 entrypoints 目录下添加 options.html 或者 options/index.html 文件。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <!-- entrypoints/options/index.html --> <!doctype html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Options</title> </head> <body> <div> <label for="autoSleepInterval">Auto Sleep Interval (minutes):</label> <input type="number" id="autoSleepInterval" name="autoSleepInterval" min="1" value="30" /> </div> </body> </html> WXT options entrypoint 文档: https://wxt.dev/guide/essentials/entrypoints.html#options 效果: 添加 storage 权限并实现持久化 创建 entrypoints/options/main.ts 并在 html 的 body 标签末尾引入。 1 <script type="module" src="./main.ts"></script> 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // entrypoints/options/main.ts async function main() { const input = document.querySelector<HTMLInputElement>('#autoSleepInterval')! input.value = ( await browser.storage.local.get<{ autoSleepInterval?: number }>( 'autoSleepInterval', ) ).autoSleepInterval?.toString() ?? '30' // 读取保存的设置,如果找不到则使用默认值 30min input.addEventListener('input', async (ev) => { const value = (ev.target as HTMLInputElement).valueAsNumber // 每次修改设置时都写入 storage.local,不使用 change 事件是为了避免修改之后立刻刷新页面,有可能接收不到事件 await browser.storage.local.set({ autoSleepInterval: value }) }) } main() 打开配置页面测试,发现功能没有生效。右键打开开发者工具,在控制台中看到以下错误: 1 2 3 Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'local') at main (main.ts:3:3) at main.ts:15:1 这是因为缺少 storage 权限。使用需要权限的 API 之前都必须先声明,修改 wxt.config.ts 添加权限: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 import { defineConfig } from 'wxt' export default defineConfig({ manifestVersion: 3, manifest: { name: 'Auto Sleep Tabs', description: 'Automatically puts inactive tabs to sleep to save memory and CPU.', permissions: ['tabs', 'storage'], // new }, webExt: { disabled: true, }, }) 现在,修改页面中的 Auto Sleep Interval 选项的值之后,刷新页面,可以看到值已经被持久化了。 美化(tailwindcss) 不过,HTML 默认样式实在太丑了,让我们引入 tailwindcss 并添加一些样式。 安装依赖 1 pnpm install tailwindcss @tailwindcss/vite 更新配置并添加 tailwindcss 插件。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import { defineConfig } from 'wxt' import tailwindcss from '@tailwindcss/vite' export default defineConfig({ manifestVersion: 3, manifest: { name: 'Auto Sleep Tabs', description: 'Automatically puts inactive tabs to sleep to save memory and CPU.', permissions: ['tabs', 'storage'], }, vite: () => ({ plugins: [tailwindcss()], // new }), webExt: { disabled: true, }, }) 参考 https://tailwindcss.com/docs/installation/using-vite 然后在 html 中引入 tailwindcss 美化一下。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 <!doctype html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Options</title> <style> @import 'tailwindcss'; </style> </head> <body> <div class="p-6"> <h1 class="text-xl font-semibold text-gray-800">Settings</h1> <div class="space-x-4"> <label for="autoSleepInterval" class="text-gray-700"> Auto Sleep Interval (minutes): </label> <input type="number" id="autoSleepInterval" name="autoSleepInterval" min="1" value="30" class="w-24 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> </div> </body> </html> 现在,我们可以看到效果至少好看了一点。 在 background 中读取配置 将硬编码的 Timeout 改为从 storage 读取: 1 2 3 4 5 6 7 8 9 // const Timeout = 30 * 60 * 1000 const Timeout = (( await browser.storage.local.get<{ autoSleepInterval?: number }>( 'autoSleepInterval', ) ).autoSleepInterval ?? 30) * 60 * 1000 如果需要在修改配置后立刻触发重新检测,还可以使用 storage.onChanged API,由于上面已经监听了标签页切换时自动触发检测,所以下面这段代码只做演示。 1 2 3 4 5 6 7 8 9 browser.storage.onChanged.addListener((changes, areaName) => { if (areaName === 'local' && changes.autoSleepInterval) { console.log( 'autoSleepInterval changed to', changes.autoSleepInterval.newValue, ) autoDiscardTabs() } }) 自定义 action 打开配置页面 目前为止,我们都使用 Chrome 默认的方法打开配置页面,例如上面提到的两种方法。但其实我们还可以将点击浏览器右上角的 action 图标绑定到打开配置页面的行为。 首先在 wxt.config.ts 的 manifest 中声明 action 选项,目前留空即可。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import { defineConfig } from 'wxt' import tailwindcss from '@tailwindcss/vite' export default defineConfig({ manifestVersion: 3, manifest: { name: 'Auto Sleep Tabs', description: 'Automatically puts inactive tabs to sleep to save memory and CPU.', permissions: ['tabs', 'storage'], action: {}, // new }, vite: () => ({ plugins: [tailwindcss()], }), webExt: { disabled: true, }, }) 然后在 background script 中监听 browser.action.onClicked 事件 1 2 3 browser.action.onClicked.addListener(async () => { await browser.runtime.openOptionsPage() }) 现在,只要点击 action 就能打开配置页面,更加方便快捷。 总结 在这一篇中,主要介绍了添加配置页面以及使用 storage API。在下一篇中,将介绍按需向网页注入脚本,也将是目前为止唯一一个在 Chrome Web Store 安装扩展时不会有任何警告信息的扩展。 如果有任何问题,欢迎加入 Discord 群组讨论。 https://discord.gg/VxbAqE7gj2 完整代码:https://github.com/rxliuli/browser-extension-dev-examples/tree/main/packages/05-storage-and-configuration

2026/1/16
articleCard.readMore

Browser Extension Dev - 04. Background Script

前言 在上一章 Browser Extension Dev - 03. 注入 UI 中,我介绍了如何向网页中注入自定义的 UI,同时还了解了如何使用 Shadow DOM、Tailwind CSS 和使用 npm 包。在本章,我将介绍 Background Script,这是扩展的核心元素之一。 首先,什么是 Background Script? 顾名思义,这是扩展可以在后台运行的脚本,与注入到网页的脚本有所不同,它有几个显著的特点: 可以访问所有扩展 API,在扩展的其他部分,例如 Content Script,可以访问的扩展 API 极其受限,例如无法访问 tabs API 来获取当前浏览器所有打开的标签页。 全局唯一,对于 Content Script 而言,在多个标签页中可能会被注入多次,但 Background Script 始终保持唯一,它不会同时存在多个。 按需启动,在 Manifest V3 之后,Background Script 更改为基于事件的模型,也就是说,没有事件传入时(例如,扩展可以监听新标签页的打开事件),它会自动休眠节省资源 无法使用 DOM API,这点不太明显,虽然 Background Script 确实在浏览器环境,可以使用有限的 Web API,但无法访问 DOM,尽管确实有几个替代选项(jsdom/Offscreen) 可以和扩展其他部分通信,Background Script 能与 Content Script、Popup Page 等部分通信,但其他部分之间却不能直接通信,所以需要 Background Script 中转 参考: Background 简介:https://developer.chrome.com/docs/extensions/develop/concepts/service-workers(Manifest V3 后 Chrome 官方改名为 extension service workers,但通常还是习惯称呼为 Background Script) Manifest V3 简介:https://developer.chrome.com/docs/extensions/develop/migrate/what-is-mv3 上面就是 Background Script 的几个关键点,接下来我将实现一个自动休眠不活跃标签页的扩展来演示它,下面是将会涉及到的 Tabs API,参考 https://developer.chrome.com/docs/extensions/reference/api/tabs 思考 首先,我们该如何定义不活跃的标签页? 从简单的角度来说,一个长时间没有访问的标签页就是不活跃的,例如最后一次访问标签页还是 30 分钟之前,那么应该可以认为是不活跃的了。如何知道标签页的最后访问时间呢?这就需要监听标签页相关的事件了,下面是涉及到的三个基本事件: onCreated => 将标签页信息添加到扩展记录中 onRemoved => 从扩展记录的标签页列表中移除 onActivated => 更新标签页的最后访问时间 在长时间不活跃之后,我们可以自动冻结它,浏览器允许在不关闭标签页的情况下自动从内存中驱逐,再次访问时会自动重新加载。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 Extension starts │ ▼ Initialize existing tabs │ ▼ ┌─────────────────────────────────────────────────┐ │ Wait for events │◄──────┐ └─────────────────────────────────────────────────┘ │ │ │ ├─── onCreated ──► Record new tab time ───────────►│ │ │ ├─── onRemoved ──► Remove tab record ─────────────►│ │ │ └─── onActivated ──► Update access time │ │ │ ▼ │ Check all tabs │ │ │ ▼ │ Over 30 minutes? │ │ │ │ Yes │ │ No │ ▼ └──────────────────►│ Freeze tab │ │ │ └─────────────────────────────┘ 注意:Tabs API 本身提供了 lastAccessed 字段查看标签页的最后访问时间,但这个字段在 Safari 浏览器并不支持,参考 https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/Tab#browser_compatibility 实现 由于在之前的章节中已经说明过如何初始化扩展,这里不再赘述初始化过程。 1 2 # init project pnpm dlx wxt@latest init 04-background-script --template vanilla --pm pnpm 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // wxt.config.ts import { defineConfig } from 'wxt' export default defineConfig({ manifestVersion: 3, manifest: { name: 'Auto Sleep Tabs', description: 'Automatically puts inactive tabs to sleep to save memory and CPU.', }, webExt: { disabled: true, }, }) 监听标签页事件 首先打开 entrypoints/background.ts,可以看到 WXT 初始化的代码。 1 2 3 export default defineBackground(() => { console.log('Hello background!', { id: browser.runtime.id }) }) 让我们在函数中注册我们的监听器 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 export default defineBackground(() => { console.log('Hello background!', { id: browser.runtime.id }) const lastAccessed = new Map<number, number>() browser.tabs.onCreated.addListener((tab) => { if (!tab.id) { return } lastAccessed.set(tab.id, Date.now()) }) browser.tabs.onRemoved.addListener((tabId) => { lastAccessed.delete(tabId) }) browser.tabs.onActivated.addListener((activeInfo) => { lastAccessed.set(activeInfo.tabId, Date.now()) console.log('Tab activated:', activeInfo.tabId) }) }) 现在,使用 pnpm dev 启动开发模式,打开 chrome://extensions/,加载已解压的扩展并选择 .output/chrome-mv3-dev 目录,然后点击扩展的 service worker 链接打开 Background Script 的 DevTools Console 开始调试。 当我们添加一个新标签页时可以看到 Tab activated: 1207047510 相关的日志。 识别不活跃的标签页并自动冻结 然后我们需要在 onActivated 事件中检查已记录的标签页中是否有长时间未访问的标签页,如果发现就自动冻结它。在此之前,需要更新 wxt.config.ts 添加 tabs 权限,这是使用 browser.tabs.query API 所必须的。 1 2 3 4 5 6 7 8 9 import { defineConfig } from 'wxt' export default defineConfig({ manifest: { // other config... permissions: ['tabs'], }, // other config... }) 你可能注意到,下面查找标签页时包含了很多过滤条件,下面将会一一说明 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 browser.tabs.onActivated.addListener((activeInfo) => { lastAccessed.set(activeInfo.tabId, Date.now()) console.log('Tab activated:', activeInfo.tabId) autoDiscardTabs() }) async function autoDiscardTabs() { const Timeout = 30 * 60 * 1000 // 30 minutes const tabs = (await browser.tabs.query({})).filter( (tab) => tab.id && // 只查找包含 id 的普通标签页,某些特殊标签页可能不包含 id,例如浏览器调试窗口之类的 !tab.pinned && // 如果是固定标签页,则忽略 !tab.active && // 如果标签页还活跃,也就是说,一直呆在一个标签页里 !tab.audible && // 如果正在播放音视频,则忽略 !tab.frozen && // 如果已经被 Chrome 内置机制冻结了,则忽略 !tab.discarded && // 如果已经被手动冻结了,则忽略 lastAccessed.has(tab.id) && // 如果没有记录过这个标签页,则忽略 Date.now() - lastAccessed.get(tab.id)! > Timeout, // 如果最后访问时间距离现在已经超过 30 分钟,则认为满足条件 ) for (const tab of tabs) { // 注意:discard 可能会失败,例如标签页正在被使用或已被关闭 await browser.tabs.discard(tab.id!) console.log('Tab auto-discarded:', tab.id, tab.title) } } 将 Timeout 调整为 1ms,就可以方便的进行测试了。在切换标签页再切换回来之后,就能看到标签页自动刷新了,这意味着自动冻结功能确实生效了。 总结 现在,我们实现了基本的标签页自动冻结功能,这个实现非常粗糙,还有很多问题没有处理,如果感兴趣,你可以自行尝试解决下面几个问题: 如何让用户手动配置自动冻结时间,避免默认值不符合用户需求 如何解决用户长时间未使用浏览器,不触发任何事件导致无法休眠的问题 如何处理扩展启动时已存在的标签页(提示:browser.runtime.onStartup) 在下一章,我们将介绍配置相关的 API 和 options 页面来解决配置问题。 如果有任何问题,欢迎加入 Discord 群组讨论。 https://discord.gg/VxbAqE7gj2 完整代码:https://github.com/rxliuli/browser-extension-dev-examples/tree/main/packages/04-background-script

2026/1/14
articleCard.readMore

Browser Extension Dev Extra - 如何找到锁定滚动的元素

背景 最近因为多邻国 App 的 Bug,我正在考虑切换到 Web 版本。但没想到移动端 Web 版本体验那么差,每次打开首页都会弹出 App 下载推广,这真的太烦人了。我第一时间就想去写个 UserScript 彻底删除它,但没想到还碰到一些有趣的问题。 分析 从 DevTools > Elements 中很容易就找到了弹窗元素的稳定选择器。 1 #overlays:has([href*="adj.st"]) 页面遮罩也很容易定位。 1 [data-test="drawer-backdrop"] 然后我就可以正常点击了,但我仍然无法自由滚动,而滚动条锁定又不在 HTML/body 上,所以这种情况下应该如何找到是哪个 HTML 元素导致的滚动条锁定呢? 解决 想要解决这个问题需要一些简单的启发式的方法。首先,一般如何实现滚动条锁定? 通常只需要给 body 元素增加一些样式即可,例如 1 2 3 body { overflow: hidden; } 或者使用 position: fixed 1 2 3 document.body.style.position = 'fixed' document.body.style.top = `-${scrollPosition}px` document.body.style.width = '100%' 按照这种思路,我们可以找到一个在无法滚动区域内的元素,然后递归向上查找,直到找到 overflow: hidden; 或者 position: fixed 的元素即可认为它就是阻止滚动的罪魁祸首,实现起来相当简单。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 function findScrollBlocker(element) { const style = getComputedStyle(element) // 检查当前元素是否设置了阻止滚动的样式 if (style.overflow === 'hidden' || style.position === 'fixed') { return element } // 继续检查父元素 if (element.parentElement && element !== element.parentElement) { return findScrollBlocker(element.parentElement) } return null } // 在 DevTools > Console 中调用,传入当前选中的元素 findScrollBlocker($0) // 找到锁定元素后,可以通过以下方式恢复滚动 // const blocker = findScrollBlocker($0) // if (blocker) blocker.style.overflow = 'auto' 视频演示 现在,我们可以通过 CSS 恢复正常的滚动条了。 tip: $0 在 DevTools > Console 中代表在 DevTools > Elements 面板中选中的元素。 总结 如果你也想尝试开发 Extension 或者 UserScript 来改善你的 Web 体验,不妨一试,JavaScript 提供了相当多 trick 的技巧,如果只是编写普通的 Web App,可能永远不会有机会尝试。 相关资源 Browser Extension Dev - 01. 介绍基本概念 Tampermonkey 文档

2026/1/6
articleCard.readMore

Browser Extension Dev - 03. 注入 UI

前言 在此之前,通过 Browser Extension Dev - 02. 使用 WXT 我们已经了解了扩展的基本结构和 WXT 的使用,下面我们将进一步演示如何在网页中注入复杂的 UI,使用 React、Tailwind CSS、shadcn、或任何需要的 npm 包是怎么做的。 你可能有几个问题 如果网站也使用了 Tailwind CSS,扩展重复使用不会导致样式冲突吗? React 之类的现代 Web 框架如何注入到现有的网站中? 下面我将通过实现一个 Youtube 视频截图扩展来进行演示。 思考 首先,需要明确期望的扩展 UI & UX 是什么样的 扩展的图标自动注入到视频右下角的工具栏中 点击图标自动截取视频当前帧为图片 自动复制图片到剪切板并自动下载 这里可能的问题是什么? 如何注入图标到 Youtube 工具栏 → 注入按钮到工具栏 如何截取视频的一帧为图片 → 实现截图功能 如何复制图片到剪切板 → 复制到剪切板并下载 实现 首先,使用 WXT 来初始化一个使用 react 的模版项目。 1 pnpm dlx wxt@latest init 03-inject-ui --template react --pm pnpm 并为 wxt.config.ts 添加一些基本配置。其中 @wxt-dev/module-react 是 WXT 对 React 的集成支持,让我们可以直接在 Content Script 中使用 React。 1 2 3 4 5 6 7 8 9 10 11 12 13 import { defineConfig } from 'wxt' export default defineConfig({ modules: ['@wxt-dev/module-react'], manifestVersion: 3, manifest: { name: 'Youtube Video Screenshot', description: 'Take screenshots of YouTube videos easily.', }, webExt: { disabled: true, }, }) 然后删除模版项目 entrypoints 目录下的所有内容。 1 rm -rf ./entrypoints/* 安装 Tailwind CSS 和 shadcn 组件 首先来 Tailwind CSS 和 shadcn,由于是在扩展程序中,所以 shadcn 的自动安装命令基本都不可用,需要手动安装并配置,参考 https://ui.shadcn.com/docs/installation/manual。 安装 Tailwind CSS 和 shadcn 需要的依赖 1 2 pnpm i -D tailwindcss @tailwindcss/vite pnpm i clsx tailwind-merge tw-animate-css class-variance-authority 更新 tsconfig.json,添加 baseUrl 和 paths 字段 1 2 3 4 5 6 7 8 9 10 11 { "extends": "./.wxt/tsconfig.json", "compilerOptions": { "allowImportingTsExtensions": true, "jsx": "react-jsx", "baseUrl": ".", "paths": { "@/*": ["./*"] } } } 更新 wxt.config.ts,增加 Tailwind CSS 插件 1 2 3 4 5 6 7 8 9 import { defineConfig } from 'wxt' import tailwindcss from '@tailwindcss/vite' export default defineConfig({ // other config... vite: () => ({ plugins: [tailwindcss() as any], }), }) 添加 css 全局样式文件,需要注意,WXT 在 Shadow DOM 模式下会使用 all: initial !important; 重置所有样式,所以这里需要显式指定 html 的高度,否则按钮可能无法正确显示。参考 https://wxt.dev/api/reference/wxt/utils/content-script-ui/shadow-root/type-aliases/ShadowRootContentScriptUiOptions.html#inheritstyles entrypoints/content/styles.css 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 @import 'tailwindcss'; @import 'tw-animate-css'; @custom-variant dark (&:is(.dark *)); :root { --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); --primary: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); --muted: oklch(0.97 0 0); --muted-foreground: oklch(0.556 0 0); --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.577 0.245 27.325); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); --radius: 0.625rem; --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); --sidebar-primary: oklch(0.205 0 0); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.97 0 0); --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); } .dark { --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); --card: oklch(0.145 0 0); --card-foreground: oklch(0.985 0 0); --popover: oklch(0.145 0 0); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.985 0 0); --primary-foreground: oklch(0.205 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0); --muted-foreground: oklch(0.708 0 0); --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.396 0.141 25.723); --destructive-foreground: oklch(0.637 0.237 25.331); --border: oklch(0.269 0 0); --input: oklch(0.269 0 0); --ring: oklch(0.439 0 0); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.269 0 0); --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(0.269 0 0); --sidebar-ring: oklch(0.439 0 0); } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); --color-card-foreground: var(--card-foreground); --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); --color-chart-1: var(--chart-1); --color-chart-2: var(--chart-2); --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); } @layer base { * { @apply border-border outline-ring/50; } html { height: 40px; } body { height: 100%; display: flex; align-items: center; } } 添加 shadcn 需要的辅助函数 lib/utils.ts 1 2 3 4 5 6 import { clsx, type ClassValue } from 'clsx' import { twMerge } from 'tailwind-merge' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } 然后添加 shadcn 安装的组件的配置文件 components.json 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": false, "tsx": true, "tailwind": { "config": "", "css": "entrypoints/content/styles.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "iconLibrary": "lucide" } 现在,尝试使用 shadcn 命令安装一个 button 组件,如果你能看到类似下面这样的输出,就说明你的配置完全正确,可以进行下一步了。 1 2 3 4 5 $ pnpm dlx shadcn@latest add button --yes ✔ Checking registry. ✔ Installing dependencies. ✔ Created 1 file: - components/ui/button.tsx 在 Content Script 中使用 shadcn 这是很多人转不过来弯的地方,但 shadcn 最终也只是 JavaScript 和 CSS,而扩展程序允许我们注入这些,所以,我们确实可以使用它。 第一步是创建 entrypoints/content/index.tsx 入口文件 1 2 3 4 export default defineContentScript({ matches: ['https://www.youtube.com/*'], main() {}, }) 然后我们使用 Shadow Root 模式注入 UI,这能很好的隔离样式,网站的 CSS 不会影响扩展,反之亦然,参考 https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM,这解决了我们最初提到的第一个问题(样式冲突)。 而 React 作为 JavaScript 库,会在构建时被打包到 .output/chrome-mv3/content-scripts/content.js 中,随 Content Script 一起注入页面。换句话说:Content Script 能运行任何 JavaScript,所以我们能用任何 JavaScript 框架 —— React、Vue、Svelte 都可以,这就解决了第二个问题。可以在 WXT 官方文档中看到多个 Web 框架的使用示例 https://wxt.dev/guide/essentials/content-scripts.html#shadow-root 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 import './styles.css' // 引入 css,会在 bundle 时自动处理 import { Button } from '@/components/ui/button' import { createRoot } from 'react-dom/client' function App() { return ( <div className={ 'fixed z-[999999] top-0 left-0 w-full py-2 bg-white text-center' } > <Button>Click me</Button> </div> ) } export default defineContentScript({ matches: ['https://www.youtube.com/*'], cssInjectionMode: 'ui', // 这里的配置要求 WXT 将 CSS 动态注入到页面中 async main(ctx) { // 这里使用了 Shadow Root 模式注入 UI,意味着能够正确地避免我们的 Tailwind CSS "污染"到网页本身 const ui = await createShadowRootUi(ctx, { name: 'inject-ui-app', position: 'inline', anchor: 'body', onMount: (container) => { const root = createRoot(container) root.render(<App />) return root }, onRemove: (root) => { root?.unmount() }, }) ui.mount() }, }) 可以看到,我们确实成功在顶部注入了一个按钮,在 Devtools > Elements 中也可以看到注入的 inject-ui-app Shadow DOM 元素。 接下来我们需要找到适合注入图标的位置,然后注入 UI。 Content Script 不同的 UI 注入模式参考 https://wxt.dev/guide/essentials/content-scripts.html#ui 注入按钮到工具栏 首先我们需要找到在哪里注入合适。由于希望在右下角的工具栏中增加额外的图标,所以可以先找到找到容器元素,具体来说,就是 #movie_player .ytp-right-controls-left 然后修改之前的 Content Script 脚本,修改 App 组件 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 function App() { function onTakeScreenshot() { alert('Take screenshot!') } return ( <Button className={'h-[80%] px-6 bg-transparent hover:bg-white/10 rounded-full'} onClick={onTakeScreenshot} > <img src={browser.runtime.getURL('/icon/32.png')} // 这里直接使用扩展图标作为操作 icon alt={'icon'} className={'w-[20px] h-[20px]'} /> </Button> ) } 在创建 Shadow DOM 容器时使用 anchor 指定放置的位置,并使用 insertBefore 添加到工具栏的最左边。指定的容器元素有可能不存在,但 WXT 会正确在容器元素出现和消失时自动挂载和卸载 UI,参考 https://wxt.dev/guide/essentials/content-scripts.html#mounting-ui-to-dynamic-element 1 2 3 4 5 6 7 const ui = await createShadowRootUi(ctx, { anchor: '#movie_player .ytp-right-controls-left', // 注入的容器元素 append(anchor, ui) { anchor.insertBefore(ui, anchor.firstChild) // 添加到最左边 }, // other code... }) 现在,我们应该可以在右下角看到我们注入的 icon 了。但实际上,只得到了一个错误,提示无法加载这个资源。 1 Denying load of chrome-extension://aheclehodijmphbifdolliophgjiagof/icon/32.png. Resources must be listed in the web_accessible_resources manifest key in order to be loaded by pages outside the extension. 实际上还需要在 wxt.config.ts 中配置 web_accessible_resources,允许网页访问扩展的资源。 1 2 3 4 5 6 7 8 9 10 11 12 13 export default defineConfig({ // other config... manifest: { // other config... web_accessible_resources: [ { resources: ['icon/*'], matches: ['https://www.youtube.com/*'], }, ], }, // other config... }) 然后,我们就能在网页中看到注入的 icon 按钮了,点击就会弹出 Take screenshot!。 参考 Chrome 官方文档 https://developer.chrome.com/docs/extensions/reference/manifest/web-accessible-resources 完整代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 import './styles.css' import { Button } from '@/components/ui/button' import { createRoot } from 'react-dom/client' function App() { function onTakeScreenshot() { alert('Take screenshot!') } return ( <Button className={'h-[80%] px-6 bg-transparent hover:bg-white/10 rounded-full'} onClick={onTakeScreenshot} > <img src={browser.runtime.getURL('/icon/32.png')} alt={'icon'} className={'w-[20px] h-[20px]'} /> </Button> ) } export default defineContentScript({ matches: ['https://www.youtube.com/*'], cssInjectionMode: 'ui', async main(ctx) { const ui = await createShadowRootUi(ctx, { name: 'inject-ui-app', position: 'inline', anchor: '#movie_player .ytp-right-controls-left', append(anchor, ui) { anchor.insertBefore(ui, anchor.firstChild) }, onMount: (container) => { const root = createRoot(container) root.render(<App />) return root }, onRemove: (root) => { root?.unmount() }, }) ui.mount() }, }) 可以看到,已经成功注入了一个图标按钮到工具栏。 实现截图功能 现在,我们要实现核心的截图功能。首先我们需要找到 video 元素,可以在 #movie_player video 看到。 然后需要使用 canvas 来截取视频的一帧,其中看起来最神奇的代码莫过于 ctx.drawImage(video, 0, 0),将一个视频传入了画布,但实际上这是支持的,参考 drawImage 文档 https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage#image,可以看到其中不仅允许 HTMLImageElement(图片),还允许 HTMLVideoElement(视频)甚至 HTMLCanvasElement(其他画布)。 1 2 3 4 5 6 7 8 const video = document.querySelector('#movie_player video') const canvas = document.createElement('canvas') canvas.width = video.videoWidth canvas.height = video.videoHeight const ctx = canvas.getContext('2d') ctx.drawImage(video, 0, 0) const blob = await new Promise((r) => canvas.toBlob(r, 'image/png', 1)) blob 在 Devtools > Console 中运行这段代码,可以看到我们确实得到了一个 image Blob。 复制到剪切板并下载 现在我们有了一个 Blob,想要复制到剪切板很简单 1 2 const data = [new ClipboardItem({ [blob.type]: blob })] await navigator.clipboard.write(data) 如果直接粘贴到 Devtools > Console 执行,会出现错误,这是因为访问剪切板需要在用户操作触发时才能使用,参考 https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API#security_considerations。不过没关系,稍后我们会把这些代码都连接到一起放到 icon 的点击事件里。 1 2 Uncaught NotAllowedError: Failed to execute 'write' on 'Clipboard': Document is not focused. at <anonymous>:2:27 接下来实现图片保存,这里我们直接使用 file-saver 来保存,为了生成使用日期的文件名,还需要使用 dayjs。 首先安装依赖 1 2 pnpm i file-saver dayjs pnpm i -D @types/file-saver 然后只需要使用 saveAs 方法即可下载 Blob。 1 2 3 4 5 6 7 import { saveAs } from 'file-saver' import dayjs from 'dayjs' const filename = `Youtube-Screenshot_${dayjs().format( 'YYYY-MM-DD_HH-mm-ss', )}.png` // 会得到类似 Youtube-Screenshot_2026-01-14_09-49-06.png 这样的文件名 saveAs(blob, filename) 连接起来 现在,修改 App 组件,在 onTakeScreenshot 函数中执行上面的代码。这样,当我们点击时就能看到图片确实复制到了剪切板,并且触发了下载。 App 组件完整代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 import './styles.css' import { Button } from '@/components/ui/button' import saveAs from 'file-saver' import dayjs from 'dayjs' function App() { // 这里有可能出现错误,例如 video 元素不存在、剪切板权限被拒绝、下载失败等等,但这里暂时忽略 async function onTakeScreenshot() { const video = document.querySelector( '#movie_player video', ) as HTMLVideoElement const canvas = document.createElement('canvas') canvas.width = video.videoWidth canvas.height = video.videoHeight const ctx = canvas.getContext('2d')! ctx.drawImage(video, 0, 0) const blob = (await new Promise<Blob | null>((r) => canvas.toBlob(r, 'image/png', 1), ))! const data = [new ClipboardItem({ [blob.type]: blob })] await navigator.clipboard.write(data) const filename = `Youtube-Screenshot_${dayjs().format( 'YYYY-MM-DD_HH-mm-ss', )}.png` // 会得到类似 Youtube-Screenshot_2026-01-14_09-49-06.png 这样的文件名 saveAs(blob, filename) } return ( <Button className={'h-[80%] px-6 bg-transparent hover:bg-white/10 rounded-full'} onClick={onTakeScreenshot} > <img src={browser.runtime.getURL('/icon/32.png')} alt={'icon'} className={'w-[20px] h-[20px]'} /> </Button> ) } 总结 现在,我们完成了 Youtube 视频截图的扩展,仍然有许多边缘情况没有处理,例如 在截图复制到剪切板时可能出现异常情况 一开始视频未播放时无法获取到视频帧 广告播放时的如何正确处理 但关键功能已经实现。而且使用了 React、Tailwind CSS、shadcn 和一些 npm 包,你觉得怎么样?在下一章中,我们将介绍 Background Script,它允许访问所有扩展 API,但同时无法访问 DOM API,我们将演示只有它能做到的事情。 如果有任何问题,欢迎加入 Discord 群组讨论。 https://discord.gg/VxbAqE7gj2 完整代码:https://github.com/rxliuli/browser-extension-dev-examples/tree/main/packages/03-inject-ui

2026/1/3
articleCard.readMore

Browser Extension Dev - 02. 使用 WXT

什么是 WXT? 在第一章 Browser Extension Dev - 1. 介绍基本概念 里面,我使用了原始的 JavaScript 实现了一个简单的 Chrome 扩展。现在,我将使用 WXT 重写扩展。那么问题是:什么是 WXT? 简单来说,WXT 是用于浏览器扩展的开发框架,就像 Vite 是用于 Web 开发的流行框架一样。实际上,WXT 是基于 Vite 实现的,所以它也可以使用 Vite 插件的生态系统。在我看来,这是个非常棒的决定。 WXT 解决了什么问题? 那么,它能解决什么问题呢? 支持使用 TypeScript、Npm、React 等现代前端开发工具 – 对于熟悉现代 Web 工具链的开发者而言会感到宾至如归,不喜欢 React,也可以使用 Svelte,它不限制 UI 层框架 跨浏览器构建,主要的浏览器有 Chrome、Firefox 和 Safari – 是的,它对于跨浏览器扩展开发非常有用,打包多个 dist 轻而易举,从一开始提供的 API 就考虑到了跨浏览器构建 支持热更新,对于所有主流 Web 框架都已支持 – 你可能注意到之前每次修改扩展后都需要手动去浏览器刷新一下扩展加载修改,WXT 让这变得不再必要 提供了相对标准化的 message 通信、content script UI 注入方法 – 想要让注入的复杂 UI 和网页原本的 UI 互不干扰是个有挑战性的事情 如果想了解更详细的功能对比,可以参考官方文档:https://wxt.dev/guide/resources/compare.html 初始化项目 首先,让我们初始化一个项目,由于不涉及 UI 部分,所以只需要使用 vanilla 模版就好了。 1 2 3 pnpm dlx wxt@latest init 02-use-wxt --template vanilla --pm pnpm cd 02-use-wxt pnpm i 项目结构说明 现在我们得到一系列的目录和文件,让我们依次了解 1 2 3 4 5 6 7 wxt.config.ts # 扩展入口文件,定义 manifest 和构建流程,但通常不需要改动 entrypoints # 定义不同的入口点文件,例如 content script 或者 background 之类的,会自动写入到输出扩展的 manifest 中 public # 公共目录,会被原样复制到最终输出扩展的目录中,里面包含 icon 目录,用于放置扩展图标,会自动写入到输出扩展的 manifest 中 assets # 需要 bundle 的媒体资源目录,可以暂时忽略 components # 组件目录,通常放一些通用组件,例如 shadcn 之类的,可以暂时忽略 package.json tsconfig.json 使用以下命令开发和构建 开发与构建流程 使用 pnpm dev 启动开发模式,输出目录在 .output/chrome-mv3-dev,在 Chrome 中需要加载这个目录作为扩展目录,而不是项目根目录。不过 WXT 会自动启动一个 Chrome 进程并自动加载扩展,所以可以不需要手动加载扩展。 但如果你不希望自动打开 Chrome 窗口,也可以配置 wxt.config.ts 来禁用这个行为。对于调试需要登录的网站而言,这也是必要的。 1 2 3 4 5 6 7 import { defineConfig } from 'wxt' export default defineConfig({ webExt: { disabled: true, }, }) 对于不熟悉现代前端工具链的人而言,开发和构建代码是分离的,不像早期那样编写的 JavaScript 就是运行在用户设备上的 JavaScript,使用 WXT 开发扩展也是一样的。 构建 使用 pnpm build 启动构建模式,输出目录在 .output/chrome-mv3,通常只有在需要调试 Firefox 或 Safari 版本时才需要使用构建后的扩展。 使用 pnpm zip 可以打包扩展的 zip 文件,对于 Firefox 还会有一个额外的 source 文件(Firefox AMO 要求提交扩展必须包含源码),这在提交到 Chrome Web Store 时才需要,这里先提一下。 实现扩展功能 设置 Manifest 在使用 WXT 之后,manifest 有许多部分都不再需要,它们通常都变成“约定配置”,不再需要手动处理。例如下面是之前实现扩展的 manifest.json 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 { "manifest_version": 3, "name": "Hide AI Mode on Google Search", "version": "0.0.1", "description": "Hide the AI Mode button on Google Search pages.", "content_scripts": [ { "matches": ["https://www.google.com/"], "js": ["content-scripts/content.js"], "run_at": "document_start" } ], "icons": { "128": "icon/128.png" } } manifest_version 会自动推导,Chrome/Safari 使用 v3,而 Firefox 默认使用 v2,不过我仍然建议统一使用 v3,以避免一些边缘情况需要兼容 name/version/description 可以在 package.json 中配置,它会自动合并到输出目录中的 manifest.json。由于 package.json 的 name 字段有大小写限制,而且可能包含包名,所以我仍然建议在 wxt.config.ts 中配置 name/description 字段 content_scripts/icons 字段完全不需要,它们会从 entrypoints 和 public/icon 目录自动推导出来 所以更新后的 wxt.config.ts 是 1 2 3 4 5 6 7 8 9 10 11 import { defineConfig } from 'wxt' export default defineConfig({ manifestVersion: 3, manifest: { name: 'Hide AI Mode on Google Search', version: '0.0.1', description: 'Hide the AI Mode button on Google Search pages.', }, // other config... }) 实现 Content Script 首先,还是让我们先清理一下无关文件。 1 rm -r ./assets ./components ./entrypoints/background.ts ./entrypoints/popup ./public/wxt.svg 接下来打开 entryponits/content.ts 文件,可以看到初始内容如下 1 2 3 4 5 6 export default defineContentScript({ matches: ['*://*.google.com/*'], main() { console.log('Hello content.') }, }) 而这里就是有趣的部分,defineContentScript 实际上定义了 manifest 和对应实际执行的脚本,这就是为什么上面在 wxt.config.ts 中省略 content_scripts 字段的原因,不过这也合理,将相关的代码和配置放在一起。将之前扩展的代码和配置修改过来之后变成: 1 2 3 4 5 6 7 8 9 10 export default defineContentScript({ matches: ['https://www.google.com/'], runAt: 'document_start', main() { const style = document.createElement('style') style.textContent = 'button:has([d="M15.65 11.58c.18-.5.27-1.03.31-1.58h-2c-.1 1.03-.51 1.93-1.27 2.69-.88.87-1.94 1.31-3.19 1.31C7.03 14 5 12.07 5 9.5 5 7.03 6.93 5 9.5 5c.46 0 .89.08 1.3.2l1.56-1.56C11.5 3.22 10.55 3 9.5 3 5.85 3 3 5.85 3 9.5S5.85 16 9.5 16c.56 0 2.26-.06 3.8-1.3l6.3 6.3 1.4-1.4-6.3-6.3c.4-.5.72-1.08.95-1.72z"]) { display: none; }' document.documentElement.appendChild(style) }, }) 作为对比,之前的 content script 是放在 manifest.json 和 content-scripts/content.js 两个文件中完成。 1 2 3 4 5 6 7 8 9 { "content_scripts": [ { "matches": ["https://www.google.com/"], "js": ["content-scripts/content.js"], "run_at": "document_start" } ] } 1 2 3 4 5 // content-scripts/content.js const style = document.createElement('style') style.textContent = 'button:has([d="M15.65 11.58c.18-.5.27-1.03.31-1.58h-2c-.1 1.03-.51 1.93-1.27 2.69-.88.87-1.94 1.31-3.19 1.31C7.03 14 5 12.07 5 9.5 5 7.03 6.93 5 9.5 5c.46 0 .89.08 1.3.2l1.56-1.56C11.5 3.22 10.55 3 9.5 3 5.85 3 3 5.85 3 9.5S5.85 16 9.5 16c.56 0 2.26-.06 3.8-1.3l6.3 6.3 1.4-1.4-6.3-6.3c.4-.5.72-1.08.95-1.72z"]) { display: none; }' document.documentElement.appendChild(style) 可以看到 WXT 的 content script 仍然是非常直观的,只是将下划线字段重命名为驼峰字段了(run_at => runAt)。在我们更新 content script 之后,直接打开 google.com 可以看到扩展已经热更新为了我们修改后的代码。扩展效果 注意:你可能注意到闪烁问题又出现了,这是因为 wxt 为了实现热更新加载机制,content script 是动态注入的,这导致 run_at: document_start 设置在开发模式下不太有用,但无需担心,在构建后它的工作正常。参考:https://github.com/wxt-dev/wxt/issues/357 提示:在开发过程中,如果需要查看 content script 的 console.log 输出,需要在网页 google.com(而不是扩展页面)打开 Devtools > Console 查看。 添加 icon 接下来,我们来添加扩展 icon。在 WXT 中,只需要将图标放到 ./public/icon/ 目录就好了,创建项目后可以看到自动创建了一些不同尺寸的图标,这是用于在显示时自动选择合适的图标,你可以选择使用任何工具来生成合适尺寸的图标,这里使用 ImageMagick 作为演示。 1 2 3 4 5 6 cd ./public magick logo.png -resize 16x16 ./icon/16.png magick logo.png -resize 32x32 ./icon/32.png magick logo.png -resize 48x48 ./icon/48.png magick logo.png -resize 96x96 ./icon/96.png magick logo.png -resize 128x128 ./icon/128.png 如果更喜欢可视化工具,也可以使用 https://squoosh.app/ 来调整图片尺寸。或者使用 WXT 官方的 icon 模块 https://wxt.dev/auto-icons 来自动生成。 回到浏览器扩展管理页面,可以看到 icon 已经被正确识别了。 总结 现在,我们完成了第一个使用 WXT 实现的扩展就完成了,你觉得怎么样?在下一章中,我们将使用现代 Web 框架和 npm 包,为网页注入 UI 并实现更复杂的功能。 如果有任何问题,欢迎加入 Discord 群组讨论。 https://discord.gg/VxbAqE7gj2 完整代码:https://github.com/rxliuli/browser-extension-dev-examples/tree/main/packages/02-use-wxt 参考 WXT 官网 https://wxt.dev/

2026/1/3
articleCard.readMore

Browser Extension Dev - 01. 介绍基本概念

你是否曾经对某个网页的功能感到不满?比如 Google 搜索页面上那个显眼的 AI Mode 按钮: 通过浏览器扩展,你可以让它彻底消失: 什么是浏览器扩展? 一般而言,浏览器扩展是一种修改用户浏览网页的方式,它赋予了网站使用者而不是开发者更多的权限。让有能力的用户可以自定义它们的网页浏览体验,现代浏览器提供了极其丰富的扩展 API,甚至能实现一些看起来需要 app 才能做到的事情,但它的基本出发点是让用户可以控制正在浏览的网页。 一些例子 隐藏 Google AI 相关功能 根据规则自定义网页重定向 解除网页上的复制粘贴限制 在浏览器后台运行定时任务 基本结构 1 2 3 4 5 6 manifest.json # 扩展入口文件 icon/*.png # 扩展图标 content-scripts/content.js # 注入到网页的脚本,可选 background.js # 后台脚本,可选 options.html # 配置页面,可选 popup.html # 点击扩展 icon 的弹出窗口,可选 创建 Manifest 现在,我们来创建一个基本的扩展,不使用任何包管理器、TypeScript、或者 Web 框架,只使用基本的 JavaScript 完成。在下一章节中,将会使用现代开发工具链(WXT)重写它,这里只是让我们对扩展的实际结构有个了解。作为例子,我们将会创建一个扩展来隐藏 google.com 中那个 AI Mode 按钮。 首先,创建一个 manifest.json,里面是一些基本信息,manifest_version 是一个固定值,代表使用扩展 API 的第三个主要版本。 1 2 3 4 5 6 { "manifest_version": 3, "name": "Hide AI Mode on Google Search", "version": "0.0.1", "description": "Hide the AI Mode button on Google Search pages." } 实现 Content Script 然后我们需要创建一个 content-scripts/content.js 脚本(这个路径不是固定的,只是一般做法),里面实现在网页加载时自动隐藏 AI Mode 按钮。 基本思路也很简单 找到 google.com 中的 AI Mode 按钮的 CSS selector 在 google.com 加载时注入一个 js 脚本,自动隐藏它 分析获得 CSS selector 打开 google.com,然后打开 DevTools > Elements 可以看到 AI Mode 是一个 button 元素,但它看起来并没有可以作为稳定 CSS selector 的东西。 对于现代网站而言,class 通常被构建工具压缩的连亲妈都不认识了,所以需要一些跳脱的方法,例如 button 中包含的 SVG 似乎很适合作为一个选择器(网站上的 icon 改动频率并不高)。这样,借助 :has 子选择器和属性选择器,我们就可以组合出一个稳定的 selector 了。 1 button:has([d="M15.65 11.58c.18-.5.27-1.03.31-1.58h-2c-.1 1.03-.51 1.93-1.27 2.69-.88.87-1.94 1.31-3.19 1.31C7.03 14 5 12.07 5 9.5 5 7.03 6.93 5 9.5 5c.46 0 .89.08 1.3.2l1.56-1.56C11.5 3.22 10.55 3 9.5 3 5.85 3 3 5.85 3 9.5S5.85 16 9.5 16c.56 0 2.26-.06 3.8-1.3l6.3 6.3 1.4-1.4-6.3-6.3c.4-.5.72-1.08.95-1.72z"]) :has 指的是如果元素包含指定 selector 的子元素,就匹配它。对于这个 selector 而言,这意味着只有包含特定 AI Mode svg 图标的 button 按钮才会被匹配到。 实现 content script 接下来创建 content.js 脚本,由于我们只想隐藏这个按钮,最简单的方法就是注入一个 css 样式,例如 1 2 3 4 const style = document.createElement('style') style.textContent = 'button:has([d="M15.65 11.58c.18-.5.27-1.03.31-1.58h-2c-.1 1.03-.51 1.93-1.27 2.69-.88.87-1.94 1.31-3.19 1.31C7.03 14 5 12.07 5 9.5 5 7.03 6.93 5 9.5 5c.46 0 .89.08 1.3.2l1.56-1.56C11.5 3.22 10.55 3 9.5 3 5.85 3 3 5.85 3 9.5S5.85 16 9.5 16c.56 0 2.26-.06 3.8-1.3l6.3 6.3 1.4-1.4-6.3-6.3c.4-.5.72-1.08.95-1.72z"]) { display: none; }' document.head.appendChild(style) 在 Manifest 中声明 接下来,我们还需要在 manifest.json 中声明这个内容脚本,告诉浏览器要在哪些网站注入,这里我们只需要在 google.com 注入,所以需要修改为 1 2 3 4 5 6 7 8 9 { // before config... "content_scripts": [ { "matches": ["https://www.google.com/"], // 只匹配首页,因为 AI Mode 按钮仅在首页出现 "js": ["content-scripts/content.js"] } ] } 调试扩展 接下来,我们需要在 Chrome 中加载这个扩展。 首先,打开 chrome://extensions/,并启用 Developer Mode 然后,使用 Load unpacked 按钮选择扩展目录,就能加载扩展到浏览器了。 访问 google.com,可以看到 AI Mode 按钮确实不见了,但会闪一下出现然后才消失,这意味着注入的脚本时机不够早,在注入的脚本执行之前,AI Mode 按钮就已经显示了,然后脚本注入才隐藏了按钮,这是一个基本的时序问题。幸运的是,可以简单调整 manifest 配置解决这个问题。 1 2 3 4 5 6 7 8 9 { "content_scripts": [ { "matches": ["https://www.google.com/"], "js": ["content-scripts/content.js"], "run_at": "document_start" // 在网页刚开始加载时就注入脚本,参考 https://developer.chrome.com/docs/extensions/reference/manifest/content-scripts#world-timings } ] } 然后在 chrome://extensions/ 重新加载扩展让修改生效。 接着就看到了一个错误 Uncaught TypeError: Cannot read properties of null (reading 'appendChild')。 这是因为注入脚本的时机非常早,甚至连 document.head 标签都还没有渲染,可以修改为在 document.documentElement 注入 style 标签。 1 2 3 4 const style = document.createElement('style') style.textContent = 'button:has([d="M15.65 11.58c.18-.5.27-1.03.31-1.58h-2c-.1 1.03-.51 1.93-1.27 2.69-.88.87-1.94 1.31-3.19 1.31C7.03 14 5 12.07 5 9.5 5 7.03 6.93 5 9.5 5c.46 0 .89.08 1.3.2l1.56-1.56C11.5 3.22 10.55 3 9.5 3 5.85 3 3 5.85 3 9.5S5.85 16 9.5 16c.56 0 2.26-.06 3.8-1.3l6.3 6.3 1.4-1.4-6.3-6.3c.4-.5.72-1.08.95-1.72z"]) { display: none; }' document.documentElement.appendChild(style) // document.documentElement 代表页面的根元素,即 `<html></html>` 标签。 再次刷新扩展然后访问 google.com 可以看到 AI Mode 按钮不见了,而且也不再出现闪烁的情况。 添加图标 最后,这个扩展还缺少图标,我们将一张 128x128 的 png 图像放在 icon/128.png。 然后修改 manifest 添加 icons 设置即可。 1 2 3 4 5 { "icons": { "128": "icon/128.png" } } 再次刷新扩展,就可以看到扩展图标已经被正确加载了。 总结 这就是第一个基本扩展。通过这个例子,我们了解了扩展开发的几个核心概念: Manifest 文件定义了扩展的基本信息和需要哪些权限 Content Script 可以注入到网页中,直接操作 DOM 脚本注入时机会影响实际效果,需要根据场景选择 document_start、document_end 或 document_idle 扩展的调试流程和普通网页开发类似,都可以在 DevTools 中查看错误 你可能注意到,直接编写原生 JavaScript 并手动管理文件有些繁琐。在下一章中,我们将使用 WXT 这个现代开发工具重构这个扩展,体验 TypeScript、热重载等特性。 如果有任何问题,欢迎加入 Discord 群组讨论。 https://discord.gg/VxbAqE7gj2 完整代码:https://github.com/rxliuli/browser-extension-dev-examples/tree/main/packages/01-basic 参考 Google Chrome 扩展开发文档 https://developer.chrome.com/docs/extensions

2026/1/2
articleCard.readMore

Chrome => Firefox 扩展移植的那些坑

背景 在为 Chrome 开发了一个扩展程序之后,接下来就是移植到其他浏览器中了,而 Firefox 一般认为是首要选择,它们都使用类似的 Browser Extension API 接口,所以这应该非常简单,对吧? 不,Firefox 有很多微妙的长期问题,如果你遇到了,可能会变得非常棘手,下面是一些吾辈在移植扩展到 Firefox 中已经发现的问题。 CSP 问题 CSP 在 Firefox 扩展中很奇怪,与 Chrome 甚至 Safari 都非常不同,例如 Firefox Extension 不支持访问 localhost Firefox Extension 不支持访问 localhost 导致 wxt 这种扩展开发框架无法支持 dev mode 热更新 [1],在 bugzilla 中提出的 issue 也已经有 2 年了 [2]。目前唯一的解决方法就是在 Chrome 中进行开发,然后构建在 Firefox 进行验证和测试。 Firefox 会根据网站本身的 CSP 来限制扩展注入的 Content Script Firefox 会根据网站本身的 CSP 来限制扩展注入的 Content Script(使用前朝的剑来斩本朝的官)[3]。这也涉及到一些已经存在 9 年的 bug [4],预计短期内不可能得到解决,幸运的是,可以使用 declarativeNetRequest 来禁用网站的 CSP 来绕过这个问题。 下面是一个基本的 rules.json 规则配置文件来删除特定网站的 Content-Security-Policy,当然,这会导致一些安全问题,但这也是对于业务层侵入最小的方法。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 [ { "id": 1, "condition": { "urlFilter": "https://example.com/**", "excludedResourceTypes": [] }, "action": { "type": "modifyHeaders", "responseHeaders": [ { "header": "content-security-policy", "operation": "remove" } ] } } ] Firefox Extension Page 中使用 wasm 会出现错误 Firefox Extension Page 中使用 webworker 会出现错误,例如使用 esbuild-wasm 会出现以下 CSP 错误 1 Content-Security-Policy: The page’s settings blocked a worker script (worker-src) at blob:moz-extension://708674c8-9b11-450a-9552-c0e679d39d8e/0dff485f-4f32-4d1a-a109-8ca61a3037a2 from being executed because it violates the following directive: “script-src 'self' 'wasm-unsafe-eval'” 即便已经在 manifest 中设置了 CSP 1 2 3 4 5 { "content_security_policy": { "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';" } } 这同样与一个存在 9 年的 bug 有关,即便它已经被开发者关闭,但通过 blob URI 使用 webworker 仍然无法工作 [5]。 对于 esbuild-wasm 而言,它提供一个选项可以禁用 webworker,这可以解决该问题。 1 2 3 4 5 await initialize({ wasmURL: wasmUrl, // Firefox Extension Page CSP disable blob worker worker: import.meta.env.CHROME, }) 无法安装未签名扩展 基本上 Firefox 就像 Apple 一样,要求所有扩展都必须进行公证和签名 [6],即使不打算发布给其他人使用,也必须提交到 AMO [7] 进行审核。如果你直接拖拽一个构建好的 zip 到 Firefox,就会出现 This add-on could not be installed because it is not been verified 的错误。 AMO 审核问题 当你的扩展使用人数达到一定数量,AMO 每次审核都会变得异常缓慢,因为总是需要人工审核。并且审核人员并不总是优秀的浏览器扩展开发者,他们甚至可能不是 Web 开发者,糟糕的审核流程惹恼了一些非常知名的扩展开发者,让他们放弃了在 Firefox 发布扩展,例如 uBlock Origin Lite,Chrome 版本用户超过 11M [11] Enhancer for YouTube,Chrome 版本用户超过 1M [12] AMO 限制 JavaScript 代码尺寸 例如在使用 monaco-editor 时,ts 的 LSP 代码很大,直接导致了扩展被自动拒绝,而在 Chrome 中根本不会发生。 结语 Firefox 曾经是一个优秀的浏览器,但这几年除了“碰瓷” Chrome 的名声与 Vue 类似之外,似乎没什么值得大惊小怪的。而且最近开始往浏览器中塞 AI 相关的功能,似乎总是在追逐闪闪发光的东西而不是真的去正视现有的问题。 https://github.com/wxt-dev/wxt/issues/1626 ↩ https://bugzilla.mozilla.org/show_bug.cgi?id=1864284 ↩ https://github.com/wxt-dev/wxt/discussions/1442 ↩ https://bugzilla.mozilla.org/show_bug.cgi?id=1267027 ↩ https://bugzilla.mozilla.org/show_bug.cgi?id=1294996 ↩ https://addons.mozilla.org ↩ https://github.com/wxt-dev/wxt/discussions/1205#discussioncomment-11373354 ↩ https://github.com/uBlockOrigin/uBOL-home/issues/197#issuecomment-2329365796 ↩ https://www.mrfdev.com/contact ↩

2025/9/18
articleCard.readMore

发布 Safari 扩展到 iOS 应用商店

背景 今年以来,吾辈开始发布一些 Safari 扩展程序到 AppStore 中,由于吾辈并不使用 iPhone,所以仅发布了 Mac 版本。而这个月吾辈开始实践全平台浏览器扩展的开发,即为所有主流的桌面浏览器(Chrome/Safari/Edge/Firefox)和所有支持扩展的移动端浏览器(Kiwi/Edge/Safari/Firefox)发布相同的插件,这让吾辈将发布 iOS Safari 扩展重新提上日程。 关于如何转换 Chrome 扩展为 Safari 扩展,请参考 转换 Chrome Extension 为 Safari 版本 过程 在已经有一个 Mac Safari 扩展的情况下,为它发布到 iOS 版本理论上很简单。但发布之前,必须通过模拟器调试确保没有漏洞。 使用模拟器测试 首先,在 Target 中选择 iOS 平台,然后选择一个模拟器,建议 iPhone 16 Pro,最后点击 Build 按钮。 其次,模拟器中的 iOS 扩展的封装 App 就会自动打开。 再其次,点击模拟器顶部工具栏的 Home 图标,返回桌面,打开 Settings > Apps > Safari > Extensions 中,即可看到刚刚 Build 的扩展。默认情况下它应该是 Disable 的,进入然后 Enable 即可。如果你无法找到刚刚 Build 的扩展,请参考下面的问题,就我而言,在排查问题的过程中 Claude 4.1 Opus 确实给了不错的提示,让吾辈意识到排查错误的方向和关键词是什么。 最后,点击 Home 回到桌面,找到 Safari 打开,你应该能在浏览器工具栏看到刚刚 Build 的扩展。 参考: Apple 官方视频 2022 发布到 AppStore 在测试完成确认没有漏洞之后,就可以发布到 AppStore 了。 首先,在 Xcode 中选择 Product > Archive 进行打包。 其次,在弹窗中点击 Distribute App 按钮,接着选择 App Store Connect 作为分发渠道,最后点击 Distribute 按钮,你的 App 就会开始上传到 AppStore 了。 但请注意,上传完成之后并未发布,只是上传了一个构建包,还需要到 App Store Connect 添加版本信息、App 描述、截图等一系列常规信息,并提交审核才能最终发布。 问题 如果没有报错但同时也不生效,那可能不是你的错,只能怪 Apple/Safari 的开发体验太糟。 Apps 中看不到任何 App">iOS 里面的 Settings > Apps 中看不到任何 App 如图 根据这个 社区 issue,可以知道是 18.2 的 bug,升级到 18.4 解决。 验证 Safari > Extensions 中始终看不到开发的扩展">在 Settings > Safari > Extensions 中始终看不到开发的扩展 如图 升级至最新版的 XCode 及虚拟机解决,就吾辈而言,是 XCode 16.4 及 iOS 18.4 的虚拟机。 验证方法是通过 XCode 创建一个全新的 Safari Extension 项目,然后 Build 并检查 Settings > Safari > Extensions 中是否能看到。 Apple 的官方文档几乎没什么用 https://developer.apple.com/documentation/safariservices/troubleshooting-your-safari-web-extension 但吾辈看到一个今年刚出的视频感觉很有帮助 https://www.youtube.com/watch?v=DZe7L70CDPc CJK 输入法输入的空格不是 \u0020,而是 \u3000 一个很小的问题,CJK 输入法在 Mac 上输入的字符是 \u0020,即便输入的是中文的空格,但 keydown 事件中仍然识别为标准的 \u0020。 例如 1 2 3 document.addEventListener('keydown', (e) => { console.log(e.key) // 输出 ' ' }) 而在 iOS Safari 上,输入中文空格后,在 beforeinput 事件中,e.data 是 \u3000。 1 2 3 document.addEventListener('beforeinput', (e) => { console.log(e.data) // 输出 ' ' }) 所以针对 iOS Safari 必须小心处理输入相关的事件。 如果发布区域包含国区并存在 LLM 相关功能,则需要额外注意 例如,在插件中使用了 OpenAI 的 API 实现部分功能,发布到国区就无法过审。Apple 声称根据 MIIT(工信部)的要求,所有 AI 相关的 App 都必须报备取得资质。如果是个人开发者,建议直接放弃国区。Fuck of MIIT。 总结 截止目前为止,吾辈已经成功发布了两个全平台浏览器扩展,分别是 Redirector: https://rxliuli.com/project/redirector/ IDBPort: https://rxliuli.com/project/idbport/ 发布 Safari 扩展虽然有趣,却也让人意识到 Safari 扩展的开发体验有多么糟糕,吾辈在开发过程中踩了不少坑,也浪费了不少时间。

2025/8/8
articleCard.readMore

再游新疆 -- 自驾

前言 六月初有个去新疆自驾的机会,于是便和新一开始了二刷新疆之旅。大致路线定的是南疆环线,由于距离霍尔果斯口岸很近,所以也顺便出国去哈萨克斯坦看了看。这次的旅行体验比上次报团要好得多,主要是单个地点好玩的话可以多玩会而不再卡时间了。 赛里木湖 首先第一站前往了赛里木湖,中间由于路途太远,所以在精河县暂住一晚,第二天继续前往赛里木湖。 首先去游客中心买票,确认了自驾是每个人 140,人车合一而非车单独计算。 将这里称作天空之镜似乎很合适。 让吾辈大吃一惊的雪堆,在 6 月中旬这个季节。 远处的湖边有人正在拍“场照”? 从赛里木湖西侧的山坡木栈道上向下俯拍,可以看到赏心悦目的风景。 雨过天晴,很幸运的看到了双彩虹。不幸的是,吾辈没有拍好它。 第二天早上,早起前往点将台等待日出。 上午的湖水无比湛蓝。 还在湖边用石头堆起了一个玛尼堆。 霍尔果斯/扎尔肯特 接下来,由于赛里木湖附近就有一个陆上口岸,加之哈萨克斯坦又是免签国家,所以之后前往了口岸城市霍尔果斯,休息一晚后第二天出发前往扎尔肯特。 霍尔果斯当地并没有什么东西,旧口岸附近有一个观光塔。 闹市之中有一处欧式建筑。 暂住一晚后第二天前往哈萨克斯坦的扎尔肯特小镇,从新国门出去。 再经过两三个小时漫长的安检审核流程之后,终于到了扎尔肯特,由于有 3 个小时的时差,所以还能吃上午饭。第一次吃如此巨大的鸡肉卷。 在路上看到的一个广告牌,有点好奇这是否就是 Local Superman? 之后前往了旅馆,但由于哈萨克斯坦是旧苏联加盟国,所以西方软件在此地不好使,打车、住宿和支付都使用俄罗斯 Yandex 系列的软件,而不是常用的 Uber/Booking/GooglePay。由于没有自驾而且也无法打车,所以步行前往了最近的清真寺。这个清真寺似乎不太清真,杂糅了中国风? 这是小镇上最大的超市,大部分本地人都来这里采购东西,甚至被作为一日游的景点了。 在路上随处可见的管道中不知道有什么,自来水?或者是天然气? 伊昭公路 回国之后到了伊宁,六星街没什么好玩的,喀赞其民俗旅游区也因为天气原因没有去看,所以直接略过,开始翻越天山的伊昭公路之旅。 山脉之间。 尽管已经快没有信号了,但海拔三千米仍然有人卖现杀羊肉烧烤。 这是沿山而筑的一条小路,虽然危险,但风景却也是极好的。 下山后快到昭苏时,开始看到牛和羊。 这个弧线看着真的太舒服了,如果没有人就更好了。 山上还牧民的马。 昭苏玉湖 吃完午饭,开始前往了昭苏附近的一个小景点:玉湖。但它却着实带来了不少惊喜,首先,它的门票人和车分开计算,一辆车只会计算一次,所以人均只有 55,与赛里木湖(140)、那拉提(200)相比,实在太划算了。 只看湖水颜色有点像西藏的羊卓雍湖。 但真正带来惊喜的不是湖水,而是景区内部公路的长度,单程至少超过 45km(没能走到终点),而景区区间车只走 25km。在后面能看到雪山、草地和成群的牛羊,风景着实不错。 往回走时看到远方层层叠叠的山有一些透明的感觉。 天山花海 在中途经过特克斯八卦城住下之后,前往了非常有名的天山花海,但却大失所望,里面的花海挺壮观,但从地面上非常难拍。 成片的薰衣草花海。 蜜蜂正在采蜜。 似乎已经到末期的绳子草。 在纪念品购买的地方看到介绍天山花海似乎是个农业庄园,只是兼具旅游的功能。 那拉提 与赛里木湖一样,那拉提也是二刷。上次抱团来的时候体验极其糟糕,只是乘坐区间车快速走马观花看了空中草原(盛名之下,其实难副)。这次自驾进来,在 48 小时内一共进来了 3 次,空中草原的体验仍然一般般,但河谷草原末端登上天鹰台才真正看到很棒的风景。 不知道什么时候立的一座雕像。 空中草原,也就是说,在海拔很高的山上的一片草原。 来的时候还下着淅淅沥沥的小雨,天气比较糟糕。 曾经的牧民就住在这种小房子中,牛羊在外面吃草。 一条小溪从山间流下,也许正是来自雪莲谷。 终于,在经过上午去空中草原的失望之后。在河谷草原末端,登上天鹰台,便可以远眺整个那拉提。 山顶的小路两侧正卧着几只牛。 山上还放牧着一群马。 还看到一朵不知名的花。 独库公路 之后就是独库公路,由于之前已经走过伊昭公路,也同样是翻山越岭,加之在那拉提镇不小心磕伤需要提前回去,所以并没能带来预期的体验。 路边随手一拍。 雪山草甸。 雪山之顶。 奇形怪石。 高山深壑。 途径山泉。 丹霞地貌。 天山峡谷。 总结 这次旅行起于偶然,终于偶然。几乎盲目的进入新疆,完全没有计划。所有的住宿都是临时决定,所有的门票都是现场购买。这种旅行体验之前从未尝试过,但自驾旅行的话,似乎确实不需要完整的旅行计划。实在不行,睡在车上凑活一晚也不是不行。

2025/6/28
articleCard.readMore

实践: 使用 Hono 开发全栈应用

场景 最近写了几个前后端都包含的应用,从最初的 Next.js 到后来的 SvelteKit,再到 Tanstack Router,终究不如熟悉的 Hono 框架那么好使。所有的 Web 元框架都在尝试将服务端加入到框架中,但没有一个做的足够好。例如 Cloudflare 上包含许多官方服务,作为一个服务端框架,Hono 的集成做的很棒,但 Web 元框架并非如此。 为什么使用 Hono 为什么 Web 元框架已经有服务端路由了,还要使用 Hono 呢?有几个原因 抽象不一:每个元框架都有不同的语法和规则,例如 Next.js Server Components [1]、SvelteKit Server routing [2]、或者 TanStack Server Functions [3]。 功能残缺:处理简单的 JSON API?没问题。复杂的 API 结合 Cloudflare 多个服务?很困难。 尺寸很大:元框架的 bundle size 非常庞大,即便以小巧著称的 SvelteKit 也有 132kb,而 Hono 构建后只有 18kb. 抽象不一 不管使用什么 Web 框架,Hono 的知识都是通用的。可以轻松的将 Hono 应用部署到任何地方,例如 Cloudflare、Vercel、Deno 等。而 Web 元框架。。。好吧,最好的说法是百花齐放。看几个例子 Next.js 声称在 React 组件中直接耦合数据库查询推荐的做法。 PHP:敢问今夕是何年? 1 2 3 4 5 6 7 8 9 10 11 12 import { db, posts } from '@/lib/db' export default async function Page() { const allPosts = await db.select().from(posts) return ( <ul> {allPosts.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> ) } 好吧,其实它也有 Route Handlers,像是下面这样。是的,需要 export 不同的函数来处理不同的请求,而路径则取决于文件相对路径。想要快速搜索特定路径的 API?抱歉,你需要在文件系统中找找看。 1 2 3 export async function GET() { return Response.json({ message: 'Hello World' }) } SvelteKit 也是类似的。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import { error } from '@sveltejs/kit' import type { RequestHandler } from './$types' export const GET: RequestHandler = ({ url }) => { const min = Number(url.searchParams.get('min') ?? '0') const max = Number(url.searchParams.get('max') ?? '1') const d = max - min if (isNaN(d) || d < 0) { error(400, 'min and max must be numbers, and min must be less than max') } const random = min + Math.random() * d return new Response(String(random)) } Tanstack 据称从 tRPC 得到了灵感,嗯。。。 1 2 3 4 5 6 // routes/hello.ts export const ServerRoute = createServerFileRoute().methods({ GET: async ({ request }) => { return new Response('Hello, World!') }, }) 好吧,它们有什么共通之处?嗯,显然基本概念是类似的,但除此之外?生态系统全部没有共享,想要连接 KV?数据库?OAuth2 登录?抱歉,你需要找到适合 Web 元框架的方法。 功能残缺 而且对于 Cloudflare 来说,Hono 的集成度相当高,包括 KV/D1、R2、Pages 等。而且对于其他服务端需要的功能,例如数据库、登录、OAuth2 以及测试集成都做的非常棒。 数据库:对 D1/Postgresql 支持的都很好(不过推荐使用 Drizzle 而非 Prisma)[4] 登录:支持 JWT 中间件,使用起来非常简单 [5] OAuth2: 官方的 OAuth Providers [6] 比 Auth.js 和 Better Auth 更简单,也更容易理解和调试,它的黑盒部分较少,不关心数据如何存储 测试:全面拥抱 vitest [7],某知名框架至今仍然优先支持 jest 尺寸很大 这是一个直观的对比,可以明显看到不管是构建时间还是最终 bundle 产物的大小差异都非常明显。 SvelteKit minimal Hono starter 实现 谁在前面? 现在,同时使用 Hono 和 Web 元框架,例如 SvelteKit,来开发一个应用。问题来了,谁在前面?也就是说,Hono 在前面并转发路由,还是 SvelteKit 在前面并转发路由?由于下面几个特征,Hono 在前面会更好 Hono 的代码更少,启动更快 元框架可能会有一些意外的行为,例如自动序列化所有 Response [8] 如果没有 SSR(例如 SPA/SSG),那么元框架根本不会有服务端代码 Hono 作为入口 现在,终于到了实现的部分,下面是 Hono 作为入口,静态资源转发到 SvelteKit 的静态产物。最终部署到 Cloudflare Workers 上。 首先确定静态资源在哪儿,例如在 SvelteKit 中,它是由 @sveltejs/adapter-cloudflare 插件配置的。例如下面配置的是 dist 目录。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // packages/client/svelte.config.js const config = { // other config... kit: { adapter: adapter({ pages: 'dist', assets: 'dist', fallback: undefined, precompress: false, strict: true, }), }, } export default config 然后需要配置 wrangler.json 来将静态资源绑定到 ASSETS 上。例如下面配置的是 dist 目录。 1 2 3 4 5 6 7 8 9 10 // packages/server/wrangler.json { "name": "sveltekit-to-hono-demo", "main": "src/index.ts", "compatibility_date": "2025-01-24", "assets": { "directory": "../client/dist", "binding": "ASSETS" } } 最后在 hono 的入口文件中将找不到的路由全部转发到 SvelteKit 的静态资源就好了。 1 2 3 4 5 6 7 8 9 // packages/server/src/index.ts import { Hono } from 'hono' const app = new Hono<{ Bindings: Env }>() app.get('/api/ping', (c) => c.text('pong')) app.all('*', (c) => c.env.ASSETS.fetch(c.req.raw)) export default app 现在,就可以在编码时服务端使用 Hono 而客户端使用喜欢的 Web 元框架了。 1 2 cd packages/client && pnpm build cd ../server && pnpm wrangler dev --port 8787 缺点 说了这么多,这种模式的缺点是什么? Hono 在前面时如果 SSR 需要调用服务端 API,不能在内部转换为函数调用,而是必须经过外部绕一圈请求回来。 没有 Web 元框架提供的类型安全,当然这是一个可以解决的问题,例如 Trpc 或 OpenAPI 等。 一般需要拆分为 monorepo 多模块,即 packages/server 和 packages/client,可能会增加一些复杂性 如果仍然需要 SSR,那么还需要在 Hono 中拦截 404 请求并调用 Web 元框架构建出来的 server/index.js 动态执行 总结 Web 全栈开发是一个流行的趋势,将 Web 的前端/服务端放在一起写看起来很有吸引力,但最终可能在一如既往的绕远路,就像 Next.js 终究活成了一个怪物。另外对于不需要动态渲染 UGC [9] 的网站而言,SSR 通常增加的复杂性可能是没有必要的。 https://nextjs.org/docs/app/getting-started/fetching-data#with-an-orm-or-database ↩ https://svelte.dev/docs/kit/routing#server ↩ https://tanstack.com/start/latest/docs/framework/react/server-functions ↩ https://hono.dev/examples/prisma#d1-database ↩ https://hono.dev/docs/middleware/builtin/jwt ↩ https://github.com/honojs/middleware/tree/main/packages/oauth-providers ↩ https://hono.dev/docs/guides/testing ↩ https://github.com/sveltejs/kit/issues/9401 ↩ https://en.wikipedia.org/wiki/User-generated_content ↩

2025/6/7
articleCard.readMore

Web 流式写入文件

背景 由于吾辈之前使用的一个域名即将到期,需要将 IndexedDB 数据迁移到新的域名,因此这两天创建了一个新的浏览器扩展 IDBPort,用于迁移 IndexedDB 数据到其他域名。而在迁移数据时,需要将数据导出为并下载到本地,然后导入到新的域名。由于数据量较多,同时包含一些图像之类的二进制数据,所以需要使用流式写入的方式来避免内存占用过高。 首先,Web 中有什么 Target 可以流式写入数据吗? 实际上,是有的,例如 Blob+Response,或者 OPFS 私有文件系统,它们都支持流式写入数据到磁盘,考虑到 OPFS 仍然相对较新,所以下面使用 Blob+Response 来实现。 流式写入 如果不考虑流式写入,可以将数据全部都放在内存中的话,那么直接使用一个 string[] 来存储数组,然后一次性创建一个 Blob 对象,也是一种选择。但如果数据有数百 M(包含图像或视频)甚至上 G,那么内存就会爆掉,需要使用流式写入保持内存不会线形增长。在之前 在 Web 中解压大型 ZIP 并保持目录结构 中有提到过,但由于当时使用 zip.js,而它们直接提供了 BlobWriter/BlobReader 来使用,所以并未深入研究实现,也没有尝试手动实现。这里涉及到几个关键 API Blob: 二进制数据存储接口,它会在数据过多时透明的从内存转移到磁盘 [1],这保证了内存占用不会太大 Response: Response 允许接收一个 ReadableStream 并创建一个 Blob 对象 TransformStream:提供一个通道,提供一个 ReadableStream 和 WritableStream,让流式写入变的简单 TextEncoderStream: 将一个文本流转换为 Uint8Array 流,这是 Response ReadableStream 所需要的数据格式 基本流程 创建 TransformStream 使用 ReadableStream 结合 TextEncoderStream 创建 Response 立刻获取 blob,触发 ReadableStream 的拉取 使用 WritableStream 开始写入 关闭 TransformStream await promise blob 来获取写入完成的 blob 10 行代码即可验证 1 2 3 4 5 6 7 8 9 10 11 12 13 const transform = new TransformStream<string, string>() const blobPromise = new Response( transform.readable.pipeThrough(new TextEncoderStream()), ).blob() const writable = transform.writable.getWriter() await writable.ready await writable.write('line1\n') await writable.write('line2\n') await writable.close() const blob = await blobPromise console.log(await blob.text()) // line1 // line2 流式读取 相比之下,流式读取使用的 API 要更少,只需要使用 blob.stream() 即可流式读取一个 Blob(或者一个一个 File)。几个关键的 API TextDecoderStream: 将一个 Uint8Array 字节流转换为文本流 由于 blob.stream() 返回的 chunk 可能存在截断或不完整,例如假使预期的 chunk 是按照换行分割点文本 line1\nline2\n,blob.stream() 可能会返回 line1 甚至截断的 line1\nli,所以必须使用自定义的 TransformStream 来将默认的流转换为预期的按行分割的流。 用户选择文件 得到 File(Blob 子类) file.stream() 流式读取 使用 TextDecodeStream 解码 Uint8Array 为文本 自定义 LineBreakStream 根据 line 分割 chunk 流式遍历 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class LineBreakStream extends TransformStream<string, string> { constructor() { let temp = '' super({ transform(chunk, controller) { temp += chunk const lines = temp.split('\n') for (let i = 0; i < lines.length - 1; i++) { const it = lines[i] controller.enqueue(it) temp = temp.slice(it.length + 1) } }, flush(controller) { if (temp.length !== 0) controller.enqueue(temp) }, }) } } 然后来验证它是否有效,下面写入了 3 个不规则的 chunk,但最终得到的结果仍然是 [ "line1", "line2" ],也就是说,LineBreakStream 生效了。 1 2 3 4 5 6 7 8 9 10 11 12 13 const transform = new TransformStream<Uint8Array, Uint8Array>() const readable = transform.readable .pipeThrough(new TextDecoderStream()) .pipeThrough(new LineBreakStream()) const promise = Array.fromAsync(readable) // 触发拉取 const writer = transform.writable.getWriter() const encoder = new TextEncoder() await writer.ready await writer.write(encoder.encode('line1')) await writer.write(encoder.encode('\nli')) await writer.write(encoder.encode('ne2\n')) await writer.close() console.log(await promise) // [ "line1", "line2" ] 现在,来使用它读取 Blob 就很简单了。 1 2 3 4 5 6 7 8 9 10 11 const blob = new Blob(['line1\nline2\n']) const readable = blob .stream() .pipeThrough(new TextDecoderStream()) .pipeThrough(new LineBreakStream()) const reader = readable.getReader() while (true) { const chunk = await reader.read() if (chunk.done) break console.log(chunk.value) } 总结 在浏览器中创建和读取大型文本文件似乎是个小众的需求,但如果确实需要,现代浏览器确实可以处理。考虑到之前做过的在线压缩工具,确认甚至可以处理数十 GB 尺寸的文件。 https://chromium.googlesource.com/chromium/src/+/224e43ce1ca4f6ca6f2cd8d677b8684f4b7c2351/storage/browser/blob/README.md ↩

2025/5/28
articleCard.readMore

在构建时而非运行时编译 Markdown

背景 最近重构了个人主站,添加了作品集和博客部分,它们都使用 markdown 来编写内容。而直接引入 react-markdown [1] 组件在运行时编译 markdown 不仅成本较高,要添加一系列的 unified 依赖来进行编译 markdown,还要引入相当大的 shikijs [2] 来实现代码高亮。经过一些快速测试,打算尝试使用预编译 markdown 为 html 的方式来解决这个问题。 调研 首先,吾辈尝试了几个现有的工具。 mdx-js: 就吾辈的场景而言,完全不需要 markdown 与 react 交互性,而且绑定 react 会导致一些其他问题,例如吾辈后续还希望在 svelte 项目中使用 vite-plugin-markdown: 不幸的是,基于 markdown-it 而非 mdast 系列,扩展起来更加困难 vite-plugin-md: 仅支持 vue,不支持 react 中使用 而且由于吾辈还需要在编译时就获取 markdown 的一些元数据,例如 frontmatter/toc 等等,所以最终考虑基于 unified.js 自行封装 vite 插件来处理。 实现 基本上,吾辈决定遵循 vite 的惯例 [3],即通过 import query 来支持不同的导入,例如 1 2 3 4 import frontmatter from './README.md?frontmatter' // 导入 frontmatter import toc from './README.md?toc' // 导入 toc 大纲 import html from './README.md?html' // 导入编译后的 html import ReactComponent from './README.md?react' // 导入编译后的 react 组件 实现思路 代码实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 import type { Plugin } from 'vite' import { unified } from 'unified' import remarkParse from 'remark-parse' import remarkGfm from 'remark-gfm' import remarkFm from 'remark-frontmatter' import rehypeStringify from 'rehype-stringify' import { toHast } from 'mdast-util-to-hast' import { select, selectAll } from 'unist-util-select' import { remove } from 'unist-util-remove' import rehypeShiki from '@shikijs/rehype' import rehypeReact from 'rehype-react' import { readFile } from 'node:fs/promises' import type { Heading, Yaml } from 'mdast' import type { Root } from 'hast' import type { JSX } from 'react/jsx-runtime' import * as production from 'react/jsx-runtime' function resolveId(id: string): | { type: 'frontmatter' | 'toc' | 'html' | 'react' path: string } | undefined { if (id.endsWith('.md?frontmatter')) { return { type: 'frontmatter', path: id.slice(0, -'?frontmatter'.length), } } else if (id.endsWith('.md?toc')) { return { type: 'toc', path: id.slice(0, -'?toc'.length), } } else if (id.endsWith('.md?html')) { return { type: 'html', path: id.slice(0, -'?html'.length), } } else if (id.endsWith('.md?react')) { return { type: 'react', path: id.slice(0, -'?react'.length), } } } type TransformCache = { frontmatter: string toc: string html: string react: string } interface TocItem { id: string text: string level: number children?: TocItem[] } function convertToTocItem(heading: Heading): TocItem { const text = toString(heading.children[0]) const id = slug(text) return { id, text, level: heading.depth, } } function markdownToc(md: Root): TocItem[] { const headings = selectAll('heading', md) as Heading[] const root: TocItem[] = [] const stack: TocItem[] = [] for (const heading of headings) { const item = convertToTocItem(heading) while (stack.length > 0 && stack[stack.length - 1].level >= item.level) { stack.pop() } if (stack.length === 0) { root.push(item) } else { const parent = stack[stack.length - 1] if (!parent.children) { parent.children = [] } parent.children.push(item) } stack.push(item) } return root } async function transform(raw: string): Promise<TransformCache> { const root = unified() .use(remarkParse) .use(remarkGfm) .use(remarkFm) .parse(raw) const yaml = select('yaml', root) as Yaml const frontmatter = yaml?.data ?? {} remove(root, 'yaml') const toc = markdownToc(root) const hast = toHast(root) as Root const html = unified() .use(rehypeShiki, { theme: 'github-dark', } satisfies Parameters<typeof rehypeShiki>[0]) .use(rehypeStringify) .stringify(hast) const file = await unified() .use(rehypeShiki, { theme: 'github-dark', } satisfies Parameters<typeof rehypeShiki>[0]) .use(rehypeReact, production) .stringify(hast) const jsx = stringifyJsx(file) return { frontmatter: `export default ${JSON.stringify(frontmatter)}`, toc: `export default ${JSON.stringify(toc)}`, html: `export default ${JSON.stringify(html)}`, react: `import React from "react"\nconst ReactComponent = () => ${jsx};\nexport default ReactComponent`, } } export function markdown(): Plugin { const map: Record<string, TransformCache> = {} return { name: 'vite-plugin-markdown', async transform(_code, id) { const resolved = resolveId(id) if (!resolved) { return } const { type, path } = resolved if (map[path]) { return map[path][type] } const raw = await readFile(path, 'utf-8') const cache = await transform(raw) map[path] = cache return cache[type] }, } } 类型定义 要在 TypeScript 中使用,还需要在 vite-env.d.ts 中添加一些额外的类型定义,让 TypeScript 能正确识别特定文件名及后缀。[4] 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 declare module '*.md?frontmatter' { const frontmatter: Record<string, any> export default frontmatter } declare module '*.md?toc' { interface TocItem { id: string text: string level: number children?: TocItem[] } const toc: TocItem[] export default toc } declare module '*.md?html' { const html: string export default html } declare module '*.md?react' { import { ComponentType } from 'react' const Component: ComponentType export default Component } 问题 这里碰到了一个问题,如何将转换 markdown 为编译后的 jsx。例如 1 2 3 # title content 希望得到的是 1 2 3 4 5 6 7 8 9 10 11 import React from 'react' const ReactComponent = () => React.createElement( React.Fragment, null, React.createElement('h1', { id: 'title' }, 'title'), React.createElement('p', null, 'content'), ) export default ReactComponent 是的,吾辈尝试先将 markdown 转换为 html,然后使用 esbuild 编译 jsx。不幸的是,html 与 jsx 不完全兼容。即便解决了 html/jsx 兼容问题,再将 jsx 编译为 js 时仍然可能存在问题,例如 react-element-to-jsx-string [5] 是一个常见的包,但它也存在一些问题,例如处理 code block 中的 ‘\n’ 时会自动忽略,导致编译后的代码不正确。 最终,吾辈决定直接转换 react element 为 js 字符串,本质上它也只是一个字符串拼接罢了,远没有想象中那么复杂。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 function stringifyJsx(jsx: JSX.Element): string { if ( typeof jsx === 'string' || typeof jsx === 'number' || typeof jsx === 'boolean' ) { return JSON.stringify(jsx) } const { children, ...props } = jsx.props ?? {} if (jsx.key !== undefined && jsx.key !== null) { props.key = jsx.key } function parseType(jsx: JSX.Element) { if (typeof jsx.type === 'string') { return `"${jsx.type}"` } if ( typeof jsx.type === 'symbol' && jsx.type === Symbol.for('react.fragment') ) { return 'React.Fragment' } throw new Error(`Unknown type: ${jsx.type}`) } const _props = Object.keys(props).length === 0 ? null : JSON.stringify(props) const _children = children === undefined ? undefined : Array.isArray(children) ? children.map(stringifyJsx) : stringifyJsx(children) if (_children === undefined) { if (_props === null) { return `React.createElement(${parseType(jsx)})` } return `React.createElement(${parseType(jsx)},${_props})` } return `React.createElement(${parseType(jsx)},${_props},${_children})` } 总结 目前,完整功能在 unplugin-markdown [6] 实现并发布至 npm,吾辈只是意外一个看似常见的需求居然没有很好的现成解决方案,即便已经有人做过的事情,只要有所改进仍然可以再次创建。 https://bundlephobia.com/package/react-markdown ↩ https://shiki.style ↩ https://vite.dev/guide/assets.html#importing-asset-as-string ↩ https://vite.dev/guide/env-and-mode.html#intellisense-for-typescript ↩ https://www.npmjs.com/package/react-element-to-jsx-string ↩ https://www.npmjs.com/package/unplugin-markdown ↩

2025/5/6
articleCard.readMore

在 Web 中解压大型 ZIP 并保持目录结构

背景 最初是在 reddit 上看到有人在寻找可以解压 zip 文件的 Firefox 插件 [1],好奇为什么还有这种需求,发现作者使用的是环境受限的电脑,无法自由的安装本地程序。于是吾辈便去检查了现有的在线解压工具,结果却发现排名前 5 的解压工具都没有完全支持下面两点 解压大型 ZIP 文件,例如数十 G 的 ZIP 解压目录时保持目录结构 下面的视频展示了当前一些在线工具的表现 实际上,只有 ezyZip 有点接近,但它也不支持解压 ZIP 中的特定目录。 实现 在简单思考之后,吾辈考虑尝试使用时下流行的 Vibe Coding 来创建一个 Web 工具来满足这个需求。首先检查 zip 相关的 npm 包,吾辈之前经常使用的是 jszip,但这次检查时发现它的不能处理大型 ZIP 文件 [2]。所以找到了更快的 fflate,但遗憾的是,它完全不支持加密解密功能,但作者在 issue 中推荐了 zip.js [3]。 流式解压 官网给出的例子非常简单,也非常简洁明了。如果是解压文件并触发下载,只需要结合使用 BlobWriter/file-saver 即可。 1 2 3 4 5 6 7 8 9 10 11 import { saveAs } from 'file-saver' const zipFileReader = new BlobReader(zipFileBlob) const zipReader = new ZipReader(zipFileReader) const firstEntry = (await zipReader.getEntries()).shift() const blobWriter = new BlobWriter() // 创建一个解析 zip 中的文件为 blob 的适配器 const blob = await firstEntry.getData(blobWriter) // 实际进行转换 await zipReader.close() // 关闭流 saveAs(blob, 'test.mp4') // 保存到磁盘 这段代码出现了一个有趣之处:BlobWriter,它是如何保存解压后的超大型文件的?毕竟数据总要在某个地方,blob 似乎都在内存中,而且也只允许流式读取而不能流式写入。检查一下 GitHub 上的源代码 [4]。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class BlobWriter extends Stream { constructor(contentType) { super() const writer = this const transformStream = new TransformStream() const headers = [] if (contentType) { headers.push([HTTP_HEADER_CONTENT_TYPE, contentType]) } Object.defineProperty(writer, PROPERTY_NAME_WRITABLE, { get() { return transformStream.writable }, }) writer.blob = new Response(transformStream.readable, { headers }).blob() } getData() { return this.blob } } 是的,这里的关键在于 Response,它允许接受某种 ReadableStream [5] 类型的参数,而 ReadableStream 并不保存数据到内存,它只是一个可以不断拉取数据的流。 例如下面手动创建了一个 ReadableStream,它生成一个从零开始自增的无限流,但如果没有消费,它只会产生第一条数据。 1 2 3 4 5 6 7 8 9 let i = 0 const stream = new ReadableStream({ pull(controller) { console.log('generate', i) controller.enqueue(i) i++ }, }) const resp = new Response(stream) 如果消费 100 次,它就会生成 100 个值。 1 2 3 4 5 6 7 // before code... const reader = resp.body!.getReader() let chunk = await reader.read() while (!chunk.done && i < 100) { console.log('read', chunk.value) chunk = await reader.read() } 而在 zip.js 解压时,通过 firstEntry.getData(blobWriter) 将解压单个文件产生的二进制流写入到了 Response 并转换为 Blob 了。但是,难道 await new Response().blob() 不会将数据全部加载到内存中吗? 是的,一般认为 Blob 保存的数据都在内存中,但当 Blob 过大时,它会透明的转移到磁盘中 [6],至少在 Chromium 官方文档中是如此声称的,JavaScript 规范并未明确指定浏览器要如何实现。有人在 Stack Overflow 上提到 Blob 只是指向数据的指针,并不保存真实的数据 [7],这句话确实非常正确,而且有趣。顺便一提,可以访问 chrome://blob-internals/ 查看浏览器中所有的 Blob 对象。 解压目录 解压目录主要麻烦的是一次写入多个目录和文件到本地,而这需要利用浏览器中较新的 File System API [8],目前为止,它在浏览器中的兼容性还不错 [9],所以这里利用它来解压 ZIP 中的目录并写入本地。无论如何,只要做好降级处理,使用这个新 API 是可行的。 首先,可以通过拖拽 API 或者 input File 来获取一个目录的 FileSystemDirectoryHandle 句柄。一旦拿到它,就可以访问这个目录下所有的文件,并且可以创建子目录和写入文件(支持流式写入)。假设我们有一个要写入的文件列表,可以轻松使用下面的方法写入到选择的目录中。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 const list = [ { path: 'test1/test1.txt', content: 'test1', }, { path: 'test1/test2.txt', content: 'test2', }, { path: 'test3/test3.txt', content: 'test3', }, ] function fs(rootHandle: FileSystemDirectoryHandle) { const dirCache = new Map<string, FileSystemDirectoryHandle>() dirCache.set('', rootHandle) async function mkdirp(path: string[]): Promise<FileSystemDirectoryHandle> { if (path.length === 0) { return rootHandle } const dirPath = path.join('/') if (dirCache.has(dirPath)) { return dirCache.get(dirPath)! } const parentPath = path.slice(0, -1) const parentDir = await mkdirp(parentPath) const newDir = await parentDir.getDirectoryHandle(path[path.length - 1], { create: true, }) dirCache.set(dirPath, newDir) return newDir } return { async write(path: string, blob: Blob) { const pathParts = path.split('/').filter(Boolean) const dir = await mkdirp(pathParts) const fileHandle = await dir.getFileHandle(pathParts.pop()!, { create: true, }) const writable = await fileHandle.createWritable() await blob.stream().pipeTo(writable) // 流式写入文件到本地 }, } } const rootHandle = await navigator.storage.getDirectory() // rootHandle 是拖拽 API 或者 input File 获取的句柄,这里只是用来测试 const { write } = fs(rootHandle) for (const it of list) { console.log('write', it.path) await write(it.path, new Blob([it.content])) } 局限性 尽管 File System API 已经可以胜任普通的文件操作,但它仍然有一些局限性,包括 用户可以选择的目录是有限制的,例如,无法直接选择 ~/Desktop 或 ~/Downlaod 目录,因为这被认为是有风险的 [10]。 无法保存一些特定后缀名的文件,例如 *.cfg 或者以 ~ 结尾的文件,同样被认为有风险 [11] 总结 这是一个很早之前就有人做过的事情,但直到现在仍然可以发现一些有趣的东西。尤其是 Blob 的部分,之前从未真正去了解过它的存储方式。 基于本文探讨的技术,吾辈最终实现了一个名为 MyUnzip 的在线解压工具,欢迎访问 https://myunzip.com 试用并提出反馈。 Is there a Firefox addon that can extract a zip file to a selected directory and maintain the directory structure? ↩ https://github.com/Stuk/jszip/issues/912 ↩ https://github.com/101arrowz/fflate/issues/90#issuecomment-929490126 ↩ https://github.com/gildas-lormeau/zip.js/blob/601dedd3251676587123fd35228447682c9b02c9/lib/core/io.js#L207-L228 ↩ https://developer.mozilla.org/docs/Web/API/ReadableStream ↩ https://source.chromium.org/chromium/chromium/src/+/main:storage/browser/blob/README.md ↩ https://stackoverflow.com/questions/38239361/where-is-blob-binary-data-stored ↩ https://developer.mozilla.org/docs/Web/API/File_System_API ↩ https://developer.mozilla.org/docs/Web/API/File_System_API#browser_compatibility ↩ https://github.com/WICG/file-system-access/issues/381 ↩ https://issues.chromium.org/issues/380857453 ↩

2025/4/24
articleCard.readMore

Cloudflare D1 数据库查询优化之路

背景 最近在做一些服务端相关的事情,使用了 Cloudflare Workers + D1 数据库,在此过程中,遇到了一些数据库相关的问题,而对于前端而言数据库是一件相当不同的事情,所以在此记录一下。 下图是最近 30 天的请求记录,可以看到数据库查询变化之剧烈。 发现问题 解决问题的前提是发现问题,有几个方法可以更容易留意到相关问题。 检查 D1 仪表盘,确定数据库操作是否有异常增长 检查查询语句及读取/写入行数,特别关注 count/rows read/rows written 排在前列的查询 使用 c.env.DB.prepare('<sql>').run()).meta 并检查返回的 meta,它包含这个 sql 实际读取/写入的行数 使用 batch 批量请求 首先明确一点,Workers 和 D1 虽然同为 Cloudflare 的服务,但同时使用它们并不会让 D1 变得更快。拿下面这个简单的查询举例,它的平均响应时间(在 Workers 上发起查询到在 Workers 上得到结果)超过了 200ms。 1 await db.select().from(user).limit(1) 所以在一个接口中包含大量的数据库操作时,应该尽量使用 d1 batch 来批量完成,尤其是对于写入操作,由于没有只读副本,它只会比查询更慢。例如 1 2 await db.insert(user).values({...}) await db.insert(tweet).values({...}) 应该更换为 1 2 3 4 await db.batch([ db.insert(user).values({...}), db.insert(tweet).values({...}) ]) 这样只会向 d1 发出一次 rest 请求即可完成多个数据库写入操作。 ps1: prisma 不支持 d1 batch,吾辈因此换到了 drizzle 中,参考 记录一次从 Prisma 到 Drizzle 的迁移。 ps2: 使用 batch 进行批量查询时需要小心,尤其是多表有同名的列时,参考 https://github.com/drizzle-team/drizzle-orm/issues/555 update 操作排除 id 在 update 时应该排除 id(即使实际上没有修改)。例如下面的代码,将外部传入的 user 传入并更新,看起来没问题? 1 await db.update(user).set(userParam).where(eq(user.id, userParam.id)) 实际执行的 SQL 语句 1 update "User" set "id" = ?, "screenName" = ?, "updatedAt" = ? where "User"."id" = ? 然而,一旦这个 id 被其他表通过外键引用了。它就会导致大量的 rows read 操作。例如另一张名为 tweet 的表有一个 userId 引用了这个字段,并且有 1000 行数据。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 await db.batch([ db.insert(user).values({ id: `test-user-1`, screenName: `test-screen-name-1`, name: `test-name-1`, }), ...range(1000).map((it) => db.insert(tweet).values({ id: `test-tweet-${it}`, userId: `test-user-1`, text: `test-text-${it}`, publishedAt: new Date().toISOString(), }), ), ] as any) 然后进行一次 update 操作并检查实际操作影响的行数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 const userParam: InferInsertModel<typeof user> = { id: 'test-user-1', screenName: 'test', }const r = await db.update(user).set(userParam).where(eq(user.id, userParam.id)) console.log(r.meta) // { // served_by: 'miniflare.db', // duration: 1, // changes: 1, // last_row_id: 1000, // changed_db: true, // size_after: 364544, // rows_read: 2005, // rows_written: 3 // } 可以看到 rows read 突然增高到了 2005,而预期应该是 1,考虑一下关联的表可能有数百万行数据,这是一场噩梦。而如果确实排除了 id 字段,则可以看到 rows read/rows written 确实是预期的 1,无论它关联了多少数据。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const r = await db .update(user) .set(omit(userParam, ['id'])) .where(eq(user.id, userParam.id)) console.log(r.meta) // { // served_by: 'miniflare.db', // duration: 0, // changes: 1, // last_row_id: 1000, // changed_db: true, // size_after: 364544, // rows_read: 1, // rows_written: 1 // } 可以说这是个典型的愚蠢错误,但前端确实对数据库问题不够敏锐。 避免 count 扫描全表 吾辈在 D1 仪表盘中看到了下面这个 SQL 语句在 rows read 中名列前矛。像是下面这样 1 SELECT count(id) as num_rows FROM "User"; 可能会在仪表盘看到 rows read 的暴增。 这导致了吾辈在实现分页时直接选择了基于 cursor 而非 offset,而且永远不会给出总数,因为即便 id 有索引,统计数量也会扫描所有行。这也是一个已知问题:https://community.cloudflare.com/t/full-scan-for-simple-count-query/682625 避免多表 leftJoin 起因是吾辈注意到下面这条 sql 导致了数十万的 rows read。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 SELECT "modlist"."id", "modlist"."updatedat", "modlistsubscription"."action", Json_group_array(DISTINCT "modlistuser"."twitteruserid"), Json_group_array(DISTINCT "modlistrule"."rule") FROM "modlist" LEFT JOIN "modlistsubscription" ON "modlist"."id" = "modlistsubscription"."modlistid" LEFT JOIN "modlistuser" ON "modlist"."id" = "modlistuser"."modlistid" LEFT JOIN "modlistrule" ON "modlist"."id" = "modlistrule"."modlistid" WHERE "modlist"."id" IN ( ?, ? ) GROUP BY "modlist"."id", "modlistsubscription"."action"; 下面是对应的 ts 代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 await db .select({ modListId: modList.id, updatedAt: modList.updatedAt, action: modListSubscription.action, modListUsers: sql<string>`json_group_array(DISTINCT ${modListUser.twitterUserId})`, modListRules: sql<string>`json_group_array(DISTINCT ${modListRule.rule})`, }) .from(modList) .leftJoin(modListSubscription, eq(modList.id, modListSubscription.modListId)) .leftJoin(modListUser, eq(modList.id, modListUser.modListId)) .leftJoin(modListRule, eq(modList.id, modListRule.modListId)) .where(inArray(modList.id, queryIds)) .groupBy(modList.id, modListSubscription.action) 可以看到这里连接了 4 张表查询,这种愚蠢的操作吾辈不知道当时是怎么写出来的,也许是 LLM 告诉吾辈的 😂。而吾辈并未意识到这种操作可能会导致所谓的“笛卡尔积爆炸”[1],必须进行一些拆分。 “笛卡尔积爆炸”是什么?在这个场景下就吾辈的理解而言,如果使用 leftJoin 外连多张表,并且外联的字段相同,那么就是多张表查询到的数据之和。例如下面这条查询,如果 modListUser/modListRule 都有 100 条数据,那么查询的结果则有 100 * 100 条结果,这并不符合预期。 1 2 3 4 5 db.select() .from(modList) .leftJoin(modListUser, eq(modList.id, modListUser.modListId)) .leftJoin(modListRule, eq(modList.id, modListRule.modListId)) .where(eq(modList.id, 'modlist-1')) // 10101 rows read 而如果正确的拆分查询并将数据分组和转换放到逻辑层,数据库的操作就会大大减少。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 await db.batch([ db .select({ modListId: modListUser.modListId, twitterUserId: modListUser.twitterUserId, }) .from(modListUser) .where(eq(modListUser.modListId, 'modlist-1')), db .select({ modListId: modListRule.modListId, rule: modListRule.rule, }) .from(modListRule) .where(eq(modListRule.modListId, 'modlist-1')), ]) // 200 rows read insert values 写入多条数据 如果 rows written 数量不多,或者没有批处理的需求,那这可能只是过早优化。 这是在优化写入性能时尝试的一个小技巧,可以提升批量写入的性能。考虑下面这个批量插入的代码 1 await Promise.all(users.map((it) => db.insert(user).values(it)) as any) 嗯,这只是个愚蠢的例子,当然要使用 batch 操作,就像上面说的那样。 1 await db.batch(users.map((it) => db.insert(user).values(it)) as any) 但是否忘记了什么?是的,数据库允许在一行中写入多条数据,例如: 1 await db.insert(user).values(users) 不幸的是,sqlite 允许绑定的参数数量有限,D1 进一步限制了它 [2],每次参数绑定最多只有 100 个。也就是说,如果我们有 10 列,我们最多在一条 SQL 中插入 10 行,如果批处理数量很多,仍然需要放在 batch 中处理。 幸运的是,实现一个通用的自动分页器并不麻烦,参考 https://github.com/drizzle-team/drizzle-orm/issues/2479#issuecomment-2746057769 1 2 3 4 5 await db.batch( safeChunkInsertValues(user, users).map((it) => db.insert(user).values(it), ) as any, ) 那么,我们实际获得性能收益是多少? 就上面举的 3 个例子进行了测试,每个例子分别插入 5000 条数据,它们在数据库执行花费的时间是 78ms => 37ms => 14ms 吾辈认为这个优化还是值得做的,封装之后它对代码几乎是无侵入的。 总结 服务端的问题与客户端相当不同,在客户端,即便某个功能出现了错误,也只是影响使用者。而服务端的错误可能直接影响月底账单,而且需要一段时间才能看出来,因此需要小心,添加足够的单元测试。解决数据库查询相关的问题时,吾辈认为遵循 发现 => 调查 => 尝试解决 => 跟进 => 再次尝试 => 跟进 => 完成 的步骤会有帮助,第一次解决并不一定能够成功,甚至有可能变的更糟,但持续的跟进将使得及时发现和修复问题变得非常重要。 https://learn.microsoft.com/ef/core/querying/single-split-queries ↩ https://developers.cloudflare.com/d1/platform/limits/#:~:text=Maximum%20number%20of%20columns%20per%20table ↩

2025/4/3
articleCard.readMore

转换 Chrome Extension 为 Safari 版本

背景 这两天吾辈开始尝试将一个 Chrome 扩展发布到 Safari,这是一件一直想做的事情,但由于 Xcode 极其糟糕的开发体验,一直没有提起兴趣完成。这两天又重新燃起了一丝想法,来来回回,真正想做的事情总是会完成。所以于此记录一篇,如何做到以及踩过的坑。下面转换的扩展 Redirector 实际上已经发布到 Chrome/Firefox/Edge,将作为吾辈第一个发布到 App Store 的 Safari 扩展。 转换扩展 首先,在 WXT 的官方文档中提到了如何发布 Safari 版本 [1],提到了一个命令行工具 xcrun [2],它允许将一个 Chrome 扩展转换为 Safari 扩展。 WXT 提供的命令 1 2 pnpm wxt build -b safari xcrun safari-web-extension-converter .output/safari-mv2 由于吾辈使用了 Manifest V3,第二条命令必须修改为 1 2 3 4 5 6 7 8 xcrun safari-web-extension-converter .output/safari-mv3 # Output Xcode Project Location: /Users/rxliuli/code/web/redirect App Name: Redirector App Bundle Identifier: com.yourCompany.Redirector Platform: All Language: Swift 不幸的是,立刻就可以发现一个错误,默认的 App Bundle Identifier 不正确,需要手动指定 --bundle-identifier,由于需要运行多次这个命令,所以还应该指定 –force 允许覆盖现有 Output。 1 2 3 4 5 6 7 8 xcrun safari-web-extension-converter --bundle-identifier com.rxliuli.redirector --force .output/safari-mv3 # Output Xcode Project Location: /Users/rxliuli/code/web/redirect App Name: Redirector App Bundle Identifier: com.rxliuli.redirector Platform: All Language: Swift 现在可以在 Redirector 目录下看到一个 Xcode 项目,并且会自动使用 Xcode 打开该项目。 构建并测试 接下来切换到 Xcode 开始 build 并运行这个扩展。 然而,打开 Safari 之后默认不会看到刚刚 build 的扩展,因为 Safari 默认不允许运行未签名的扩展 [3]。 需要设置 Safari 选中 Safari > Settings > Advanced > Show features for web developers 选中 Safari > Settings > Developer > Allow unsigned extensions 此时,如果你像吾辈一样之前安装然后卸载过这个扩展的话,需要手动使用 --project-location 来指定另一个路径重新转换,然后在 Xcode 中构建,这是一个已知的 issue [4]。 好的,完全退出 Xcode/Safari,然后重新运行新的转换命令,指定一个其他目录(这里是用了日期后缀)作为转换 Xcode 项目目录。 1 2 pnpm wxt build -b safari xcrun safari-web-extension-converter --bundle-identifier com.rxliuli.redirector --force --project-location 'Redirector 2025-03-13-17-20' .output/safari-mv3 在 Safari 扩展故障排除中可以有这样一条命令,可以检查已经识别安装的扩展 [5]。当然,实际上即使识别出来了,也有可能在 Safari 中看不到,必要不充分条件,转换之前最好检查 /Users/username/Library/Developer/Xcode/DerivedData 目录并清理构建的临时扩展。 1 pluginkit -mAvvv -p com.apple.Safari.web-extension 无论如何,如果一切正常,就可以在 Extensions 中查看并启用临时扩展了。 启用它,然后就可以在 Safari Toolbar 中看到扩展图标并进行测试了。 如果你发现 Mac 生态下的开发很痛苦,经常没有任何错误但也没有正常工作,那可能是正常的。 更换不兼容的 API 好吧,如果幸运的话,扩展就可以正常在 Safari 中工作了对吧,Safari 支持 Chrome 扩展对吧?oh sweet summer child,当然不可能这么简单,Safari 有一些不兼容的 API,它不会出错,但也确实不工作。你可以在官方兼容性文档 [6] 中检查一些不兼容的 API,并采用其他方法绕过。 例如 webRequest 相关的 API,Manifest v3 中 webRequest blocking API 被删除是已知的,但根据这个 App Developer Forums 上的 issue 可知,对于 Safari 而言,Manifest v3 中 webRequest API 整个功能都不生效,仅在 Manifest v2 persistent background pages 中生效,有趣的是,iOS 不支持 persistent background pages。所以之前使用的 webRequest API 需要转换,幸运的是,对于 Redirector 而言,只需要转换为一个。 browser.webRequest.onBeforeRequest.addListener => browser.webNavigation.onCommitted.addListener 可以参考官方文档 [7] 调试 background script,它不方便,但它是唯一的方法。 现在,扩展可以正常工作了。 发布到 App Store 现在,让我们开始构建并发布到 App Store 或在其他地方发布扩展,无论哪种方式,都需要正确配置签名证书。 首先修改 Project > Build Settings > Checkout ‘All’ & ‘Combined’ > Search ‘team’ > Development Team 在 Mac/iOS Targets > General > Identity > App Category 选择产品类型 在发布之前,还需要手动指定版本,因为它并不跟随 manifest 中指定的版本,而是单独的,建议在转换之后就指定。 该配置也在 <project name>/<project name>.xcodeproj/project.pbxproj 文件中,可以搜索并替换 MARKETING_VERSION = 1.0; 然后从 Xcode 工具栏选择 Product > Archive,就可以构建一个 bundle 并等待分发了。 首先点击 Validate App 确保 bundle 没有什么配置错误。这里遇到了一个错误,提示吾辈的扩展名(不是扩展 id)已存在,需要使用一个其他的名字。 好的,让我们修改 manifest 中的名字并重复以上转换和构建流程,重新验证,没有发现错误。 接下来就可以分发 App 了,点击 Distribute App,然后选择 App Store Connect 在 App Store 上架或 Direct Distribute 公证插件。 吾辈发现这个视频很有帮助 https://youtu.be/s0HtHvgf1EQ?si=rbzc88E1Y_6nZY6k 完善发布信息 最后,还需要前往 https://appstoreconnect.apple.com/apps 完善发布信息,包括截图、隐私政策和定价等等。吾辈没有意识到 Apple 使用网页来管理 App 发布,这与 Apple 万物皆可 App(App Developer/TestFlight) 的风格似乎不太相像,因此白白苦等了 2 周。 吾辈还发现 App 描述中禁止使用 emoji 字符,否则会提示 The message contains invalid characters. 总结 Mac/iOS 开发是非常封闭的平台,开发工具与体验与 Web 大有不同,但考虑到 Safari 是 Mac 上默认的浏览器,而在 iOS 上更是无法修改的事实上的标准,可能还是值得投入一些精力去支持它,尽管它甚至比 Firefox 更加糟糕。 https://wxt.dev/guide/essentials/publishing.html#safari ↩ https://developer.apple.com/documentation/safariservices/converting-a-web-extension-for-safari ↩ https://developer.apple.com/documentation/safariservices/running-your-safari-web-extension#Configure-Safari-in-macOS-to-run-unsigned-extensions ↩ https://developer.apple.com/forums/thread/765761 ↩ https://developer.apple.com/documentation/safariservices/troubleshooting-your-safari-web-extension ↩ https://developer.apple.com/documentation/safariservices/assessing-your-safari-web-extension-s-browser-compatibility ↩ https://developer.apple.com/documentation/safariservices/troubleshooting-your-safari-web-extension ↩

2025/3/13
articleCard.readMore

svelte5:一个更糟糕的 vue3

背景 svelte5 在去年 10 月发布,据说是 svelte 发布以来最好的版本。其中,他们主要为之自豪的是 runes,这是一个基于 proxy 实现的一个反应式状态系统。但经过 vue3 composition api 和 solidjs signals,吾辈并未感到兴奋。相反,这篇博客将主要针对吾辈在实际项目中遇到的 svelte5 问题进行说明,如果你非常喜欢 svelte5,现在就可以关闭页面了。 runes 只能在 svelte 组件或者 .svelte.ts 文件中 例如,当吾辈像 react/vue 一样用 runes 编写一个 hook,例如 useCounter 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 export function useCounter() { let count = $state(0) return { get value() { return count }, inc() { count++ }, dec() { count-- }, } } const counter = useCounter() console.log(counter.value) counter.inc() 但这个函数不能放在普通的 .ts 文件,必须使用 .svelte.ts 后缀并让 @sveltejs/vite-plugin-svelte 编译这个文件中的 runes 代码,否则你会得到 $state is not defined。这也包括 unit test,如果想要使用 runes(通常是测试 hooks/svelte component),文件名也必须以 .svelte.test.ts 结尾,真是糟糕的代码感染。 https://svelte.dev/docs/svelte/what-are-runes#:~:text=Runes%20are%20symbols%20that%20you%20use%20in%20.svelte%20and%20.svelte.js%20/%20.svelte.ts%20files%20to%20control%20the%20Svelte%20compiler 使用 runes 编写 hooks 传递或返回 runes 状态须用函数包裹 看到上面的 useCounter 中返回的值被放在 get value 里面了吗?那是因为必须这样做,例如如果尝试编写一个 hooks 并直接返回 runes 的状态,不管是 $state 还是 $derived,都必须用函数包裹传递来“保持 reaction”,否则你会得到一个错误指向 https://svelte.dev/docs/svelte/compiler-warnings#state_referenced_locally。当然这也包括函数参数,看到其中的讽刺了吗? 1 2 3 4 5 6 7 8 9 10 11 12 import { onMount } from 'svelte' export function useTime() { let now = $state(new Date()) onMount(() => { const interval = setInterval(() => { now = new Date() }, 1000) return () => clearInterval(interval) }) return now } 当然,你不能直接返回 { now } 而必须使用 get/set 包裹,svelte5 喜欢让人写更多模版代码。 1 2 3 4 5 6 7 8 9 10 11 export function useTime() { // other code... return { get now() { return now }, set now(value) { now = value }, } } https://github.com/sveltejs/svelte/discussions/15264 https://github.com/TanStack/query/pull/6981#issuecomment-2002611355 class 是 runes 一等公民,或许不是? 哦,当吾辈说必须使用函数包裹 runes 状态时,显然 svelte 团队为自己留了逃生舱口,那就是 class。检查下面这段代码,它直接返回了 class 的实例,而且正常工作!如果你去查看 sveltekit 的官方代码,他们甚至将 class 的声明和创建放在了一起:https://github.com/sveltejs/kit/blob/3bab7e3eea4dda6ec485d671803709b70852f28b/packages/kit/src/runtime/client/state.svelte.js#L31-L40 1 2 3 4 5 6 7 8 9 10 11 12 export function useClazz1() { class Clazz1 { count = $state(0) inc() { this.count++ } dec() { this.count-- } } return new Clazz1() } 显然,它不能应用于普通的 js object 上,不需要等到运行,在编译阶段就会爆炸。 1 2 3 4 5 6 7 8 9 10 11 export function usePojo() { return { value: $state(0), // `$state(...)` can only be used as a variable declaration initializer or a class field https://svelte.dev/e/state_invalid_placement inc() { this.value++ }, dec() { this.value-- }, } } 最后,让我们看看 $state 是否可以将整个 class 变成响应式的? 1 2 3 4 5 6 7 8 9 10 class Clazz2 { value = 0 inc() { this.value++ } dec() { this.value-- } }const clazz = $state(new Clazz2()) 当然不行,像 mobx 一样检测字段 class field 并将其变成响应式的显然太难了。然而,有趣的是,在这里你可以使用普通的 js 对象了。当然,当然。。。 1 2 3 4 5 6 7 8 9 const clazz = $state({ value: 0, inc() { this.value++ }, dec() { this.value-- }, }) 关于 class field 不是响应式的 issue 印象中这几种写法在 vue3 中都可以正常工作,看起来怪癖更少一点。 svelte 模板包含一些无法使用 js 实现特定功能 就像 svelte 官方文档中说明的一样,在测试中无法使用 bindable props,因为它是一个模版的专用功能,无法在 js 中使用,必须通过额外的组件将 bindable props 转换为 svelte/store writable props,因为它可以在 svelte 组件测试中使用。 1 2 3 4 5 6 7 8 9 10 <!-- input.svelte --> <script lang="ts"> let { value = $bindable(), }: { value?: string } = $props() </script> <input bind:value /> 当想要测试这个包含 bindable props 的组件时,必须编写一个包装组件,类似这样。 1 2 3 4 5 6 7 8 9 10 11 12 <!-- Input.test.svelte --> <script lang="ts"> import { type Writable } from 'svelte/store' let { value, }: { value?: Writable<string> } = $props() </script> <input bind:value={$value} /> 单元测试代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import { expect, it } from 'vitest' import { render } from 'vitest-browser-svelte' import InputTest from './Input.test.svelte' import { get, writable } from 'svelte/store' import { tick } from 'svelte' it('Input', async () => { let value = writable('') const screen = render(InputTest, { value }) expect(get(value)).empty const inputEl = screen.baseElement.querySelector('input') as HTMLInputElement inputEl.value = 'test1' inputEl.dispatchEvent(new InputEvent('input')) expect(get(value)).eq('test1') value.set('test2') await tick() expect(inputEl.value).eq('test2') }) 是说,有办法像是 vue3 一样动态绑定多个 bindable props 吗? 1 2 3 4 5 6 7 8 9 10 11 12 13 <template> <my-component v-bind="dynamicProps"></my-component> </template> <script setup> import { reactive } from 'vue' const dynamicProps = reactive({ title: 'Title', description: 'Desc', active: true, }) </script> 不,它甚至没有提供逃生舱口,所以无法实现某种通用的 Bindable2Writable 高阶组件来将 bindable props 自动转换为 writable props,这是非常愚蠢的,尤其是 vue3 已经珠玉在前的前提下,svelte5 的实现如此糟糕简直难以理解。 https://github.com/sveltejs/svelte/discussions/15432 表单组件默认是非受控的,有时候会带来麻烦 对于下面这样一个组件,它只是一个双向绑定的 checkbox,很简单。 1 2 3 4 5 <script lang="ts"> let checked = $state(false) </script> <input type="checkbox" bind:checked /> 那么,如果去掉 bind 呢?单向绑定?不,它只是设置了初始值,然后就由 input 的内部状态控制了,而不是预期中的不再改变。观看 3s 演示 https://x.com/rxliuli/status/1896856626050855298/video/3 当然,这不是 svelte 的问题,除了 react 之外,其他 web 框架的单向数据流似乎在遇到表单时都会出现例外。 生态系统太小 这点是所有新框架都避免不了的,但在 svelte 却特别严重,包括 router: 支持 memory spa 的 router,在 1 月初的时候找不到适配了 svelte5 的 https://github.com/ItalyPaleAle/svelte-spa-router/issues/318 query: tanstack query 也支持 svelte,但截至今日 svelte5 支持仍未发布 https://github.com/TanStack/query/discussions/7413 shadcn/ui: 同样的,svelte 中也有 shadcn 实现,但对 shadow dom 的支持很糟糕,不得不修改了内部引用的 svelte-toolbelt 库 https://github.com/huntabyte/bits-ui/issues/828 table/form: 两个最复杂的 ui 组件没有找到合适的,tanstack table 的 api 很糟糕 virtual list: 找不到支持可变行高/列宽的 list/grid 的库 chart: 自行引入 echarts 集成,给一个现有的 svelte-echarts 包提 pr 支持了 svelte5,但作者还未正式发布 https://github.com/bherbruck/svelte-echarts/pull/34 社区反应 每当有人抱怨 svelte5 变得更复杂时,社区总有人说你是用 svelte5 编写 hello world 的新手、或者说你可以继续锚定到 svelte4。首先,第一点,像吾辈这样曾经使用过 react/vue 的人而言,svelte4 看起来很简单,吾辈已经用 svelte4 构建了一些程序,它们并不是 hello world,事实上,可能有 10k+ 行的纯代码。其次,锚定到旧版本对于个人是不可能的,当你开始一个新项目的时候,几乎没有办法锚定到旧版本,因为生态系统中的一切都在追求新版本,旧版本的资源很难找到。 https://www.reddit.com/r/sveltejs/comments/1hx7ksl/comment/m68898o 就在吾辈发布完这篇博客之后,立刻有人以 “Svelte’s reactivity doesn’t exist at runtime” 进行辩护,而在 svelte5 中,这甚至不是一个站得住脚的论点。当然,他获得了 5 个 👍,而吾辈得到了一个 👎。 https://www.reddit.com/r/sveltejs/comments/1j6ayaf/comment/mgnctgm/ 总结 svelte5 变得更好了吗?显然,runes 让它与 react/vue 看起来更像了一点,但目前仍然有非常多的边界情况和怪癖,下个项目可能会考虑认真使用 solidjs,吾辈已经使用 react/vue 太久了,还是想要找点新的东西看看。

2025/3/4
articleCard.readMore

记录一次从 Prisma 到 Drizzle 的迁移

背景 最近使用 Cloudflare D1 作为服务端的数据库,ORM 选择了很多人推荐的 Prisma,但使用过程中遇到了一些问题,主要包括 不支持 D1 的 batch 批处理,完全没有事务 https://www.prisma.io/docs/orm/overview/databases/cloudflare-d1#transactions-not-supported 不支持复杂查询,例如多表 Join SQL 语法 https://github.com/prisma/prisma/discussions/12715 单次查询很慢,通常在 200ms 以上,这很奇怪,吾辈相信这与 prisma 内部使用 wasm 导致初始化时间更长有关 https://github.com/prisma/prisma/discussions/23646#discussioncomment-9059560 不支持事务 首先说一下第一个问题,Cloudflare D1 本身并不支持事务,仅支持使用 batch 批处理,这是一种有限制的事务。https://developers.cloudflare.com/d1/worker-api/d1-database/#batch 例如 1 2 3 4 5 6 7 const companyName1 = `Bs Beverages` const companyName2 = `Around the Horn` const stmt = env.DB.prepare(`SELECT * FROM Customers WHERE CompanyName = ?`) const batchResult = await env.DB.batch([ stmt.bind(companyName1), stmt.bind(companyName2), ]) 而如果你使用 Prisma 的 $transaction 函数,会得到一条警告。 1 prisma:warn Cloudflare D1 does not support transactions yet. When using Prisma's D1 adapter, implicit & explicit transactions will be ignored and run as individual queries, which breaks the guarantees of the ACID properties of transactions. For more details see https://pris.ly/d/d1-transactions 这条警告指向了 cloudflare/workers-sdk,看起来是 cloudflare d1 的问题(当然,不支持事务确实是问题),但也转移了关注点,问题是为什么 prisma 内部不使用 d1 batch 函数呢?嗯,它目前不支持,仅此而已,检查 @prisma/adapter-d1 的事务实现。 不支持复杂查询 例如下面这个统计查询,统计 + 去重,看起来很简单? 1 2 3 SELECT spamUserId, COUNT(DISTINCT reportUserId) as reportCount FROM SpamReport GROUP BY spamUserId; 你在 prisma 中可能会想这样写。 1 2 3 4 5 6 const result = await context.prisma.spamReport.groupBy({ by: ['spamUserId'], _count: { reportUserId: { distinct: true }, }, }) 不,prisma 不支持,检查已经开放了 4 年 的 issue#4228。 顺便说一句,drizzle 允许你这样做。 1 2 3 4 5 6 7 const result = await context.db .select({ spamUserId: spamReport.spamUserId, reportCount: countDistinct(spamReport.reportUserId), }) .from(spamReport) .groupBy(spamReport.spamUserId) 单次查询很慢 这一点没有真正分析过,只是体感上感觉服务端 API 请求很慢,平均时间甚至达到了 1s,而目前最大的一张表数据也只有 30+k,而大多数其他表还不到 1k,这听起来不正常。但事后来看,从 prisma 切换到 drizzle 之后,bundle 尺寸从 2776.05 KiB / gzip: 948.21 KiB 降低到了 487.87 KiB / gzip: 93.10 KiB,gzip 之后甚至降低了 90%,这听起来并不那么不可思议了。 有人做了一些测试,似乎批量插入 1k 条的性能问题更糟糕,甚至超过了 30s。https://github.com/prisma/prisma/discussions/23646#discussioncomment-10965747 踩坑 说完了 Prisma 的这么多问题,接下来说一下在迁移过程中踩到的坑。 坑 1: 从 schema.prisma 生成 schema.ts 有问题 在迁移时,首先使用 Grok 从 schema.prisma 自动生成了 drizzle 需要的 schema.ts。但发现了以下问题 原本的表结构 1 2 3 4 5 CREATE TABLE "LocalUser" ( "id" TEXT NOT NULL PRIMARY KEY, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" DATETIME NOT NULL, ) Grok 自动转换生成 1 2 3 4 5 6 7 8 9 10 11 export const localUser = sqliteTable('LocalUser', { id: text('id') .primaryKey() .default(sql`uuid()`), createdAt: integer('createdAt', { mode: 'timestamp' }) .default(sql`CURRENT_TIMESTAMP`) .notNull(), updatedAt: integer('updatedAt', { mode: 'timestamp' }) .default(sql`CURRENT_TIMESTAMP`) .notNull(), }) 这里的自动转换有几个问题 sql`uuid()` 在 prisma 中由应用抽象层填充,但 schema.ts 里使用 sql`uuid()`,这里应该同样由应用抽象层填充 updatedAt 有相同的问题,schema.ts 里使用了 sql`CURRENT_TIMESTAMP` createdAt/updatedAt 实际上是 text 类型,而 schema.ts 里使用了 integer,这会导致无法向旧表插入数据,也无法正确查询到对应的字段,只会得到 Invalid Date 实际上需要修改为 1 2 3 4 5 6 7 8 9 export const localUser = sqliteTable('LocalUser', { id: text('id').primaryKey().$defaultFn(uuid), createdAt: text('createdAt') .notNull() .$defaultFn(() => new Date().toISOString()), updatedAt: text('createdAt') .notNull() .$defaultFn(() => new Date().toISOString()), }) 坑 2: db.batch 批量查询有时候会出现返回的 Model 填充数据错误的问题 嗯,在 join 查询时 drizzle 并不会自动解决冲突的列名。假设有 User 和 ModList 两张表 idscreenNamename user-1user-screen-nameuser-name idnameuserId modlist-1modlist-nameuser-1 然后执行以下代码,非批量查询的结果将与批量查询的结果不同。 1 2 3 4 5 6 7 8 9 const query = db .select() .from(modList) .innerJoin(user, eq(user.id, modList.userId)) .where(eq(modList.id, 'modlist-1')) const q = query.toSQL() const stmt = context.env.DB.prepare(q.sql).bind(...q.params) console.log((await stmt.raw())[0]) console.log((await context.env.DB.batch([stmt]))[0].results[0]) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // 非批量查询 ;[ 'modlist-1', 'modlist-name', 'user-1', 'user-1', 'user-screen-name', 'user-name', ] // 批量查询 { // id: 'modlist-1', 被覆盖 // name: 'modlist-name', 被覆盖 id: 'user-1', name: 'user-name', userId: 'user-1', screenName: 'user-screen-name', } 这里的 ModList 和 User 中有冲突的列名 id/name,在批量查询时后面的列将会覆盖掉前面的,参考相关的 issue。 https://github.com/cloudflare/workers-sdk/issues/3160 https://github.com/drizzle-team/drizzle-orm/issues/555 需要手动指定列的别名。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 db.select({ modList: { id: sql<string>`${modList.id}`.as('modlist_id'), name: sql<string>`${modList.name}`.as('modlist_name'), }, user: { id: sql<string>`${user.id}`.as('user_id'), screenName: sql<string>`${user.screenName}`.as('user_screen_name'), name: sql<string>`${user.name}`.as('user_name'), }, }) .from(modList) .innerJoin(user, eq(user.id, modList.twitterUserId)) .where(eq(modList.id, 'modlist-1')) 然后就会得到一致的结果。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 非批量查询 ;[ 'modlist-1', 'modlist-name', 'user-1', 'user-screen-name', 'user-name' ]// 批量查询 { modlist_id: 'modlist-1', modlist_name: 'modlist-name', user_id: 'user-1', user_screen_name: 'user-screen-name', user_name: 'user-name' } 甚至可以实现一个通用的别名生成器。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import { AnyTable, TableConfig, InferSelectModel, getTableName, getTableColumns, sql, SQL, } from 'drizzle-orm' export function getTableAliasedColumns<T extends AnyTable<TableConfig>>( table: T, ) { type DataType = InferSelectModel<T> const tableName = getTableName(table) const columns = getTableColumns(table) return Object.entries(columns).reduce( (acc, [columnName, column]) => { ;(acc as any)[columnName] = sql`${column}`.as( `${tableName}_${columnName}`, ) return acc }, {} as { [P in keyof DataType]: SQL.Aliased<DataType[P]> }, ) } 然后就不再需要手动设置别名,而且它还是类型安全的! 1 2 3 4 5 6 7 db.select({ modList: getTableAliasedColumns(modList), user: getTableAliasedColumns(user), }) .from(modList) .innerJoin(user, eq(user.id, modList.twitterUserId)) .where(eq(modList.id, 'modlist-1')) 总结 数据迁移时兼容性最重要,修改或优化 schema 必须放在迁移之后。整体上这次的迁移结果还是挺喜人的,后续开新坑数据库 ORM 可以直接用 drizzle 了。

2025/2/23
articleCard.readMore