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 - 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

当吾辈遇上 Firefox 中 9 年的陈年老 Bug

背景 最近开发一个跨浏览器的扩展时,由于需要在 Content Script 中请求远端服务 API,在 Chrome 中没有遇到任何问题,但在 Firefox 中,它会在 Content Script 上应用网站的 CSP 规则。不幸的是,一些网站禁止在 Script 中请求它们不知道的 API 端点。 首先,这里出现了一个关键名词:CSP,又叫内容安全策略,主要是用来解决 XSS 的。基本上,它允许网站所有者通过服务端响应 Content-Security-Policy Header 来控制网站页面可以请求的 API 端点。例如下面这个规则仅允许请求 https://onlinebanking.jumbobank.com,其他 API 端点的请求都将被浏览器拒绝。 1 Content-Security-Policy: default-src https://onlinebanking.jumbobank.com 也就是说,你可以打开 Twitter,并在网站 Devtools Console 中执行 fetch('https://placehold.co/200'),然后就得到了一个 CSP 错误。 如果你将相同的代码放在扩展的 Content Script 中,然后在 Chrome 中测试扩展,一切正常。 而在 Firefox 中,嗯,你仍然会得到一个 CSP 错误。 如果你使用 Manifest V2,Firefox 则会正常放过,并且不会显示在 Network 中。吾辈甚至不想知道它做了什么。 经过一番调查,吾辈成功找到了相关的 issue,而它们均创建于 9 年前,最新的讨论甚至在 4 天前。检查下面两个 issue。 https://bugzilla.mozilla.org/show_bug.cgi?id=1294996 https://bugzilla.mozilla.org/show_bug.cgi?id=1267027 思路 那么,问题就在这儿,看起来也无法在短期内解决,如果想要让自己的扩展支持 Firefox,现在应该怎么办? 好吧,基本思路有 2 个: 绕过去,如果 Content Script 无法不能请求,为什么不放在 Background Script 中然后加一层转发呢? 如果网站的 CSP 有麻烦,为什么不使用 declarativeNetRequest/webRequestBlocking API 来修改或删除它们呢? Background Script 转发">Content Script => Background Script 转发 首先需要在 Background Script 中定义接口,准备接受参数并转发请求。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // src/entrypoints/background.ts browser.runtime.onMessage.addListener( ( message: { type: 'fetch' request: { url: string } }, _sender, sendResponse, ) => { if (message.type === 'fetch') { // 这儿看起来非常“怪异”,但没办法,Chrome 就是这样定义的 fetch(message.request.url) .then((r) => r.blob()) .then((b) => sendResponse(b)) return true } }, ) 同时必须在 manifest 中 声明正确的 host_permissions 权限,添加你要请求的域名。 1 2 3 { "host_permissions": ["https://placehold.co/**"] } 然后在 Content Script 中调用它。 1 2 3 4 5 6 7 8 9 10 // src/entrypoints/content.ts console.log( 'Hello content.', await browser.runtime.sendMessage({ type: 'fetch', request: { url: 'https://placehold.co/200', }, }), ) 可以看到现在可以正常得到结果了,但这种方式的主要问题是与原始的 fetch 接口并不相同,上面实现了 blob 类型的请求接口,但并未完整支持 fetch 的所有功能。嗯,考虑到 Request/Response 都不是完全可以序列化的,这会有点麻烦。 使用 declarativeNetRequest API 来删除网站的 CSP 设置 接下来,将介绍一种非侵入式的方法,允许在不修改 Content Script 解决该问题,首先是 declarativeNetRequest API,由于 WebRequestBlocking API 被广泛的应用于广告拦截器中,直接导致了 Chrome Manifest V3 正式将其废弃,并推出了静态的 declarativeNetRequest API 替代(尽管远远不能完全替代),但对于解决当下的这个问题很有用,而且很简单。 首先在 manifest 中声明权限,注意 host_permissions 需要包含你要处理的网站。 1 2 3 4 5 6 7 8 9 10 11 12 13 { "host_permissions": ["https://x.com/**"], "permissions": ["declarativeNetRequest"], "declarative_net_request": { "rule_resources": [ { "id": "ruleset", "enabled": true, "path": "rules.json" } ] } } 然后在 public 目录中添加一个 rules.json 文件,其中定义了要删除 x.com 上的 content-security-policy response header。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 [ { "id": 1, "condition": { "urlFilter": "https://x.com/**", "excludedResourceTypes": [] }, "action": { "type": "modifyHeaders", "responseHeaders": [ { "header": "content-security-policy", "operation": "remove" } ] } } ] 可以看到网站的 CSP 已经不复存在,可以看到浏览器也不会拦截你的请求了。但是,这种方法的主要问题是网站安全性受损,就这点而言,这不是一个好方法。 参考 一般而言,推荐使用 Background Script 转发请求,尽管它要编写更多的样板代码,吾辈也就此在 WXT 上问过框架作者,他似乎一般也会这样做。 参考: https://github.com/wxt-dev/wxt/discussions/1442#discussioncomment-12219769

2025/2/16
articleCard.readMore

将数据导入到 Cloudflare D1

背景 最近在实现 Mass Block Twitter 插件的 Spam 账户共享黑名单时,使用了 Cloudflare D1 作为服务端的存储数据库。而之前在本地 indexedDB 中已经存储了一些数据,所以需要导入现有的数据到 Cloudflare D1 中。使用 IndexedDB Exporter 插件将 indexedDB 数据库导出为 JSON 数据,但如何导入仍然是个问题。 最初参考了 Cloudfalre D1 的官方文档,但它并没有如何导入 JSON 数据的指南,而且它也只能在 Cloudflare Worker 中使用,但它确实提供通过 wrangler 执行 SQL 的功能,所以重要的是如何从 JSON 得到 SQL。 尝试 直接从 JSON 生成 Insert SQL 最初看起来这很简单,既然已经有了 JSON 数据,从 JSON 数据生成 SQL 字符串似乎并不难,只需要一些简单的字符串拼接即可。但实际上比想象中的麻烦,主要是 SQL 中有许多转义很烦人,边界情况似乎很多,JSON.stringify 生成的字符串在 SQL 中有错误的语法。 例如 " 双引号 ' 单引号 \n 换行 可能的 SQL 注入 其他。。。 虽然也调研过一些现有的 npm 包,例如 json-sql-builder2,但它似乎并不支持生成在单个 SQL 中插入多行的功能,因此一开始并为考虑。而且它生成的结果如下,看起来是给代码用的,而非直接可以执行的 SQL 文件。 1 2 3 4 { "sql": "INSERT INTO people (first_name, last_name, age) VALUES (?, ?, ?)", "values": ["John", "Doe", 40] } 调用 Rest API 在生成的 SQL 一直出现语法错误之后,一度尝试过直接在 Worker 中实现一个 API 用来做这件事,直到在 wrangler dev 在本地测试发现效率很低后放弃了,实现 API 本身反而并不复杂。 导入到 sqlite 然后 dump 出 Insert SQL 最后还是回归到生成 SQL 的方案上,基本流程如下 从 JSON 生成 CSV 使用 sqlite3 导入 CSV 到本地 .sqlite 文件 使用 sqlite3 dump 数据到 .sql 文件 使用 wrangler cli 执行 .sql 文件导入数据 1 2 3 4 5 6 7 8 9 10 11 12 sqlite3 db.sqlite < migrations/0001_init.sql # 初始化一个 sqlite 数据库 vite-node 0002_import.ts # 生成需要的 csv 文件 sqlite3 db.sqlite <<EOF .mode csv .import users.csv Tweet EOF # 导入 csv 到 sqlite 中 sqlite3 db.sqlite .dump > db.sql # 导出数据到 sql 文件 # ⚠️ 注意,导出数据后需要进行一些必要的编辑,删除创建 table 和 index 的语句,仅保留正确的 insert into 语句 npx wrangler d1 execute mass-block-twitter --local --file=migrations/db.sql # 执行 sql 文件导入数据,修改 --local 为 --remote 在远端执行操作 0002_import.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 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 import _data from './assets/mass-db_exported_data.json' import { writeFile } from 'node:fs/promises' import path from 'node:path' import { Parser } from '@json2csv/plainjs' import { tweetSchema, userSchema } from '../lib/request' import { Tweet, User } from '@prisma/client' const data = _data as { tweets: (typeof tweetSchema._type & { user_id: string updated_at: string })[] users: (typeof userSchema._type & { updated_at: string })[] } const list: User[] = data.users.map( (it) => ({ id: it.id, screenName: it.screen_name, name: it.name, description: it.description ?? null, profileImageUrl: it.profile_image_url ?? null, accountCreatedAt: it.created_at ? new Date(it.created_at) : null, spamReportCount: 0, createdAt: new Date(it.updated_at!), updatedAt: new Date(it.updated_at!), } satisfies User), )const parser = new Parser({ fields: [ 'id', 'screenName', 'name', 'description', 'profileImageUrl', 'accountCreatedAt', 'spamReportCount', 'createdAt', 'updatedAt', ], header: false, }) const csv = parser.parse(list) await writeFile(path.resolve(__dirname, 'users.csv'), csv) 总结 Cloudflare D1 是一个不错的数据库,基本在 Web 场景中是完全可用的,而且与 Worker 一起使用时也无需关注 Worker 执行的区域与数据库所在的区域可能不一致的问题。而关于这次的数据导入的麻烦,如果已经熟悉 D1 或数据库的话,可能 10 分钟就搞定了,还是太久不碰数据库和后端生疏了。

2025/1/30
articleCard.readMore

在 Chrome 插件中拦截网络请求

动机 在实现 Chrome 插件 Mass Block Twitter 时,需要批量屏蔽 twitter spam 用户,而 twitter 的请求 header 包含的 auth 信息似乎是通过 js 动态生成的,所以考虑到与其检查 twitter 的 auth 信息是如何生成的,还不如拦截现有的网络请求,记录使用的所有 header,然后在调用 /i/api/1.1/blocks/create.json 接口时直接使用现成的 headers。因而出现了拦截 xhr 的需求,之前也遇到过需要拦截 fetch 请求的情况,而目前现有的库并不能满足需要。 已经调查的库包括 mswjs: 一个 mock 库,可以拦截 xhr/fetch 请求,但是需要使用 service worker,而这对于 Chrome 插件的 Content Script 来说是不可能的。 xhook: 一个拦截库,可以拦截 xhr 请求,但无法拦截 fetch 请求,而且最后一个版本是两年前,似乎不再有人维护了 因此,吾辈打算自己实现一个。 设计 首先,吾辈思考了自己的需求 拦截 fetch/xhr 请求 支持修改 request url 以实现代理请求 支持调用原始请求并修改 response 支持 response sse 流式响应 确定了需求之后吾辈开始尝试设计 API,由于之前使用过非常优秀的 Web 框架 hono,所以吾辈希望 API 能够尽可能的简单,就像 hono 的 Middleware 那样。 下面借用 hono 官方的 Middleware 的洋葱图 [1] 例如下面使用了两个 Middleware 1 2 3 4 5 6 7 8 9 10 11 app .use(async (c, next) => { console.log('middleware 1 before') await next() console.log('middleware 1 after') }) .use(async (c, next) => { console.log('middleware 2 before') await next() console.log('middleware 2 after') }) 实际运行结果将会如下,因为最早注册的 Middleware 将在“洋葱”的最外层,在请求处理开始时最先执行,在请求完成后最后执行。 1 2 3 4 5 middleware 1 before middleware 2 before // 实际处理请求... middleware 2 after middleware 1 after 实现 接下来就涉及到具体实现 fetch/xhr 的请求拦截了,这里不会给出完整的实现代码,而是更多给出实现思路,最后将会链接到实际的 GitHub 仓库。 fetch 首先说 fetch,它的拦截还是比较简单的,因为 fetch 本身只涉及到一个函数,而且输入与输出比较简单。 先看一个简单的 fetch 使用示例 1 2 3 fetch('https://api.github.com/users/rxliuli') .then((res) => res.json()) .then((data) => console.log(data)) 基本思路就是重写 globalThis.fetch,然后使用自定的实现运行 middlewares,并在合适的时机调用原始的 fetch。 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 function interceptFetch(...middlewares: Middleware[]) { const pureFetch = globalThis.fetch globalThis.fetch = async (input, init) => { // 构造一个 Context,包含 request 和 response const c: Context = { req: new Request(input, init), res: new Response(), type: 'fetch', } // 运行 middlewares,由于处理原始请求需要在“洋葱”最内层运行,所以处理原始请求实现为一个 middleware await handleRequest(c, [ ...middlewares, async (context) => { context.res = await pureFetch(c.req) }, ]) // 返回处理后的 response return c.res } } // 以洋葱模型运行所有的 middlewares async function handleRequest(context: Context, middlewares: Middleware[]) { const compose = (i: number): Promise<void> => { if (i >= middlewares.length) { return Promise.resolve() } return middlewares[i](context, () => compose(i + 1)) as Promise<void> } await compose(0) } 现在,可以简单的拦截所有的 fetch 请求了。 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 interceptFetch( async (context, next) => { console.log('fetch interceptor 1') await next() console.log('fetch interceptor 1 after') }, async (context, next) => { console.log('fetch interceptor 2') await next() console.log('fetch interceptor 2 after') }, )fetch('https://api.github.com/users/rxliuli') .then((res) => res.json()) .then((data) => console.log(data)) // 输出 // fetch interceptor 1 // fetch interceptor 2 // fetch interceptor 1 after // fetch interceptor 2 after // { // "login": "rxliuli", // "id": 24560368, // "node_id": "MDQ6VXNlcjI0NTYwMzY4", // "avatar_url": "https://avatars.githubusercontent.com/u/24560368?v=4", // ... // } 有人可能会有疑问,这与 hono 的 Middleware API 也不一样啊?别着急,API 在最外层包一下就好了,先实现关键的请求拦截部分。 xhr 接下来是 xhr,它与 fetch 非常不同,先看一个简单的 xhr 使用示例 1 2 3 4 5 6 const xhr = new XMLHttpRequest() xhr.open('GET', 'https://api.github.com/users/rxliuli') xhr.onload = () => { console.log(xhr.responseText) }xhr.send() 可以看出,xhr 涉及到多个方法,例如 open/onload/send 等等,所以需要重写多个方法。而且由于 middlewares 只应该运行一次,而 xhr 的 method/url 与 body 是分步传递的,所以在实际调用 send 之前,都不能调用原始 xhr 的方法。 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 function interceptXhr(...middlewares: Middleware[]) { const PureXhr = XMLHttpRequest XMLHttpRequest = class extends PureXhr { #method: string = '' #url: string | URL = '' #body?: Document | XMLHttpRequestBodyInit | null // 重写 open 方法,在调用原始 open 方法之后,仅记录参数 open(method: string, url: string) { this.#method = method this.#url = url } // 保存所有事件监听器 #listeners: [ string, (this: XMLHttpRequest, ev: ProgressEvent) => any, boolean, ][] = [] set onload(callback: (this: XMLHttpRequest, ev: ProgressEvent) => any) { this.#listeners.push(['load', callback, false]) } // 重写 send 方法,在调用原始 send 方法之前,运行 middlewares async send(body?: Document | XMLHttpRequestBodyInit | null) { this.#body = body const c: Context = { req: new Request(this.#url, { method: this.#method, body: this.#body as any, }), res: new Response(), type: 'xhr', } // 绑定注册的事件监听器 this.#listeners.forEach(([type, listener, once]) => { super.addEventListener.apply(this, [type, listener as any, once]) }) // 运行 middlewares await handleRequest(c, [ ...middlewares, async (c) => { super.addEventListener('load', () => { // 设置响应 c.res = new Response(this.responseText, { status: this.status }) }) super.send.apply(this, [c.req.body as any]) }, ]) } } } 现在实现了一个非常基本的 xhr 拦截器,可以记录和修改 request 的 method/url/body,还能记录 response 的 status/body。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 interceptXhr(async (c, next) => { console.log('method', c.req.method, 'url', c.req.url) await next() console.log('json', await c.res.clone().json()) }) const xhr = new XMLHttpRequest() xhr.open('GET', 'https://api.github.com/users/rxliuli') xhr.onload = () => { console.log(xhr.responseText) }xhr.send() // 输出 // method GET url https://api.github.com/users/rxliuli // json { // "login": "rxliuli", // "id": 24560368, // "node_id": "MDQ6VXNlcjI0NTYwMzY4", // "avatar_url": "https://avatars.githubusercontent.com/u/24560368?v=4", // ... // } 当然,目前 xhr 的实现还非常简陋,没有记录所有 onload/onerror/onreadystatechange 事件,也没有记录所有 header,更不能修改 response。但作为一个演示,整体的实现思路已经出来了。 更多 目前已经实现了完整的 fetch/xhr 拦截器,发布至 npm 上的 @rxliuli/vista 包中,欢迎使用。 https://hono.dev/docs/concepts/middleware ↩

2025/1/2
articleCard.readMore

2024,不上班的第一年

自从 2023 年底前往日本之后,吾辈就没有再上过一天班。在日本的时候,基本没有考虑过打零工。而回国之后,则开始了间隔性的旅行和躺平。回国的主要目标之一成为独立开发者仍未实现,即使尝试过开发一些东西,但都没有找到正确的途径。而且严重缺乏输入,现实几乎没有人可以长期交流,这是一个问题,可能一直都是一个问题。 时间线回顾 1~3 月: 在日本留学,在关西地区旅行,也前往东京进行过短期旅行 4~6 月: 完成了全国旅行,虽然途径磕磕绊绊,但最终吾辈还是做到了 7 月: 在阳江租房长住,但实际上几乎没有出门玩过,月底回到老家两周 8 月: 在阳江躺平,月底终于前往了当地的海陵岛,(服务)体验一般 9 月: 月初终于继续旅行,历经衡山长沙南京镇江之后,遇到台风匆匆赶回 10 月: 前往新一家暂住一周多,之后前往云南昆明大理等地,10 月下旬返回 11~12 月: 完全在阳江躺平,并陷入了严重失调的日常生活 自问自答 你今年最大的成就是什么 – 第一次尝试并完成了全国旅行 你今年最大的失败是什么 – 没有找到独立开发的正确途径 你有没有遵守年初时和自己许下的约定 – 几乎没有,作息规律和独立开发都是 今年的哪个或哪些日子会铭刻在你的记忆中,为什么 – 在新疆抱团旅行的时光,和其他人一起旅行的感觉很好 明年你想要获得哪些你今年没有的东西 – 在苏州长住,独立开发/或者其他什么获得收入(无论多少)、作息规律、开始晨跑 今年你学到了什么宝贵的人生经验 – 在选择有限的情况下不要匆匆决定,它会限制视野,而且很可能不是正确的 有什么是你想要且得到了的 – 没有约束的生活 是什么让你保持理智 – Twitter/Ao3,有时候开发一些插件或应用在 Twitter 发布,以及入坑 Worm 系列,这是自 PMMM 之后花了最多时间的小说系列 在你新认识的人之中,谁是最好的 – 新一,很厉害的一位开发者,不仅尝试创业,还逐渐步入正轨,几乎就是吾辈期望达到的一种状态。而且人也特别好,允许吾辈在他那里借住 你想念哪些人 – 曾经的老师。尽管去过他家里很多次,一起吃过饭出去玩,但现在逐渐渐行渐远,平时也不再能说得上话了 总结 独立开发 2024 年,主要还是开发了各种各样的 Chrome 插件,目前还在维护的大概有十几个,但大多数都反响平平,而且始终没有考虑过盈利问题,也许是因为毕竟没有盈利的压力。其中花费了大量时间开发的 PhotoShare 和 NovaChat,则没有真正完成和推广过。也许随着旅程的继续,吾辈将寻找到真正通往胜利的道路。 下面是 2024 维护过的东西 Mass Block Twitter: 年尾 Twitter 又开始妖魔横行,所以当即开发了一个插件来批量屏蔽各种币圈 Spam 账号 Bilibili Markdown: 发布同人小说到 b 站专栏时粘贴 Markdown 到 Quill 富文本编辑器 Redirector: 根据规则重定向网页,避免外链跳转或者其他需求 ZenFS Viewer: 开发者工具,ZenFS 的可视化集成 TypeScript Console: 开发者工具,在 Devtools 中使用 TypeScript Google Search Console - Bulk Index Cleaner: Google Search Console 的索引批量清理工具 Clean Twitter: 还原干净的 Twitter 体验,清理 UI 上的各种元素 NovaChat: 一个支持插件、本地优先的 ChatUI。时隔两年再次尝试写 ChatUI,想要的插件系统只在 Poe 中见过,但它不是本地优先的 《Worm》 Worm 是一部网络小说,也是自魔法少女小圆之后又一部真正热爱的作品。同样是从同人小说开始入坑,看完可能上百本同人小说,仅浏览记录就有 4257 个,而相同时间内 Twitter 浏览记录仅有 205 个,这确实说明了吾辈花费了多少时间。小说中的泰勒面对了各种困境和挑战,不得不因正确的理由做错事,从一个备受欺凌、想成为超级英雄的女高中生,到成为接管城市的超级反派,最终拯救了世界却被人害怕而招致死亡。至少可以说,“升级女王”是一个让人尊敬的女孩,上一个让吾辈有这种感觉的还是一之濑琴美,以及更早的樱小路露娜,她们都曾经历了痛苦但都成功地克服了它们,或许这正是吾辈喜欢她们的理由。 下面是作品简介: 主角是一个名叫泰勒・赫伯特的女高中生,是个失去母亲的黑发褐肤、性格阴郁的女孩。她在学校遭受霸凌,唯一支撑她活下去的希望是:不久前她经历了 “触发事件”,获得了控制昆虫的超能力。如果她能顺利参加“监护者计划”,就能成为一个真正的超级英雄,为她那悲惨的人生增添一些意义… 推荐从 TV Tropes 寻找喜欢的 Worm 同人小说,就英文圈子而言,它大概有 15k 本同人小说,是 Madoka 英文同人圈的 3~4 倍。 2025 2025 年吾辈仍然不会考虑长期工作,目前仍然有时间和成本去尝试做一些不一样的事情,如果八爷独立开发之路步入正轨花了 3 年,那么吾辈也可以花费同样多的时间。下面立一些 2025 的 Flag,将每个月都 check 一下 通过独立开发获得收入,不管它是多么少,必须开始接入 Paddle 等支付系统 作息规律,这是自工作之后一直想做而未成之事 开始晨跑,从 Worm 中得到的想法,泰勒经历了更糟糕的事情,她通过晨跑来改善心情 寻找新的兴趣,目前动画已经几乎不再维持,沉迷同人小说是个糟糕的兴趣,户外徒步和摄影还不错但还未长期维持过

2025/1/2
articleCard.readMore

在 Chrome 插件中将 ArrayBuffer 从网页传递到 Devtools Panel

背景 最近使用了 ZenFS 在浏览器中模拟文件系统,以在浏览器中像使用 node fs api 一样存储一些文件。但想要可视化的检查当前存储的文件时,却没有一个可以直观的工具来完成。所以就创建了一个 Chrome Devtools Extension ZenFS Viewer,以实现这个目标。在此过程中就遇到了如何传递 ArrayBuffer 从网页到 devtools panel 线程的问题,一些尝试如下。 browser.devtools.inspectedWindow.eval 首先尝试了最简单的方法,browser.devtools.inspectedWindow.eval 可以在网页执行任意代码并得到结果,例如 1 browser.devtools.inspectedWindow.eval(`__zenfs__.readdir('/')`) 然而 inspectedWindow.eval 并不支持 Promise 返回值,例如下面的表达式无法得到 Promise 结果 1 browser.devtools.inspectedWindow.eval(`await __zenfs__.promises.readdir('/')`) 同样的,也无法支持 ArrayBuffer。所以这个显而易见的 API 被放弃了。 1 2 3 browser.devtools.inspectedWindow.eval( `await __zenfs__.promises.readFile('/test.png')`, ) chrome.runtime.sendMessage 接下来就是思想体操的时候了,一开始考虑的方案就是通过 devtools panel => background script => content-script(isolation) => content-script(main) 进行中转通信,以此在 devtools panel 中调用网页的全局变量并传递和获取 ArrayBuffer 的响应。大概示意图如下 然而在使用 chrome.runtime.sendMessage 时也遇到了和 inspectedWindow.eval 类似的问题,无法传递 ArrayBuffer,仅支持 JSON Value。当然可以序列化 ArrayBuffer 为 JSON,但在传输视频之类的大型数据时并不现实。 解决 之后经过一些搜索和思考,找到了一种方法可以绕道 chrome.runtime.message,因为注入的 iframe 和 devtools panel 同源,所以可以使用 BroadcastChannel 通信,而 iframe 和注入的 content-script(main world) 之间尽管不同源,但仍然可以通过 postMessage/onMessage 来通信,并且两者都支持传递 ArrayBuffer,这样便可绕道成功。 Iframe">Content-Script <=> Iframe 网页与注入的 iframe 之间,通信可以使用基本的 postMessage/onMessage 实现,为了减少冗余代码,这里使用 comlink 来实现。 先看看注入的 content-script,它主要是负责对 iframe 暴露一些 API 的。 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 // entrypoints/main-content.ts import { expose, windowEndpoint } from 'comlink' export default defineUnlistedScript(() => { const map = new Map<string, ArrayBuffer>() interface IFS { readFile: (path: string) => Promise<ArrayBuffer> writeFile: (path: string, data: ArrayBuffer) => Promise<void> readdir: (path: string) => Promise<string[]> } expose( { readFile: async (path: string) => { return map.get(path) || new Uint8Array([1, 2, 3]).buffer }, writeFile: async (path: string, data: ArrayBuffer) => { map.set(path, data) }, readdir: async (path: string) => { return Array.from(map.keys()).filter((p) => p.startsWith(path)) }, } as IFS, windowEndpoint( (document.querySelector('#inject-iframe')! as HTMLIFrameElement) .contentWindow!, ), ) console.log('main-content') }) 而在 iframe 中,则需要转发所有来自 BroadcastChannel 的请求通过 postMessage 传递到上层注入的 content-script 中,其中在每次传递 ArrayBuffer 时都需要使用 transfer 来转移对象到不同线程。 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 // entrypoints/iframe/main.ts import { expose, transfer, windowEndpoint, wrap } from 'comlink' interface IFS { readFile: (path: string) => Promise<ArrayBuffer> writeFile: (path: string, data: ArrayBuffer) => Promise<void> readdir: (path: string) => Promise<string[]> } async function main() { const tabId = (await browser.tabs.getCurrent())!.id if (!tabId) { return } const ipc = wrap<IFS>(windowEndpoint(globalThis.parent)) const bc = new BroadcastChannel( `${browser.runtime.getManifest().name}-iframe-${tabId}`, ) expose( { readFile: async (path: string) => { const r = await ipc.readFile(path) // 将 ArrayBuffer 通过 transfer 传递回 devtools-panel 中 return transfer(r, [r]) }, writeFile: async (path: string, data: ArrayBuffer) => { // 将 ArrayBuffer 通过 transfer 传递到 content-script 中 await ipc.writeFile(path, transfer(data, [data])) }, readdir: async (path: string) => { console.log('readdir', path) return await ipc.readdir(path) }, } as IFS, bc, ) console.log('iframe main') } main() Devtools">Iframe <=> Devtools 而在 Devtools 中,要做的事情有一点点多 🤏。首先需要注入两个 content-script,而其中 isolation-content.js 是用来创建 iframe 的 content-script。 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 // entrypoints/devtools-panel/main.ts import { PublicPath } from 'wxt/browser' async function injectScript() { const includeIframe = await new Promise((resolve) => { browser.devtools.inspectedWindow.eval( `!!document.querySelector('#inject-iframe')`, (result) => { resolve(result) }, ) }) if (includeIframe) { return } const tabId = browser.devtools.inspectedWindow.tabId if (!tabId) { return } await browser.scripting.executeScript({ target: { tabId }, files: ['/isolation-content.js' as PublicPath], world: 'ISOLATED', }) await browser.scripting.executeScript({ target: { tabId }, files: ['/main-content.js' as PublicPath], world: 'MAIN', }) } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // entrypoints/isolation-content.ts function createIframeUi() { const wrapper = document.createElement('div') wrapper.style.height = '0' wrapper.style.width = '0' const ifr = document.createElement('iframe') wrapper.appendChild(ifr) ifr.src = browser.runtime.getURL('/iframe.html') ifr.style.width = '0' ifr.style.height = '0' ifr.style.zIndex = '-9999' ifr.style.border = 'none' ifr.id = 'inject-iframe' document.body.appendChild(wrapper) return ifr } export default defineUnlistedScript(() => { console.log('isolation-content', createIframeUi()) }) 接下来就可以在 devtools-panel 中获取数据了,由于 iframe 的注入完成的时机并不能确定,所以需要加个简单的通知机制。 1 2 3 4 5 6 7 8 9 10 11 12 // entrypoints/iframe/main.ts import { wrap } from 'comlink' async function main() { // Other code... console.log('iframe main') await wrap<{ onReady: () => void }>(bc).onReady() } main() 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 // entrypoints/devtools-panel/main.ts async function main() { await injectScript() interface IFS { readFile: (path: string) => Promise<ArrayBuffer> writeFile: (path: string, data: ArrayBuffer) => Promise<void> readdir: (path: string) => Promise<string[]> } const tabId = browser.devtools.inspectedWindow.tabId if (!tabId) { return } const bc = new BroadcastChannel( `${browser.runtime.getManifest().name}-iframe-${tabId}`, ) await new Promise<void>((resolve) => expose({ onReady: resolve }, bc)) console.log('onReady') // Test code... const ipc = wrap<IFS>(bc) const r = await ipc.readdir('/') console.log(r) const data = new Uint8Array([1, 2, 3]).buffer await ipc.writeFile('/test.txt', transfer(data, [data])) const r2 = await ipc.readFile('/test.txt') console.log(r2) } main() 完整代码参考: https://github.com/rxliuli/devtools-webpage-message-demo 总结 至今为止,仍然没有简单的方法来支持 Devtools Extension 与 Webpage 之间的通信来替代 inspectedWindow.eval,确实是有点神奇。

2024/11/13
articleCard.readMore

Mac Tips 分享:创建一个 Shortcut 快速调整窗口尺寸

场景 之前发布 Chrome 扩展到 Chrome WebStore 时,WebStore 要求提供几张截图,而且必须是 1280x800 或者 640x400,而如果想要手动调整窗口大小为特定尺寸的话,会非常痛苦。所以一直想找到一种方法可以快速调整窗口尺寸到指定的大小。之前尝试过 AppleScript,甚至想过开发一个 Mac 原生应用来解决,但都遇到了一些问题(主要是权限问题),直到昨天看到一篇文章启发了吾辈。之前从未使用过 Shortcuts,没想到 Mac 自带的自动化工具还不错,完全解决了吾辈的问题。 尝试 AppleScript 在早前,吾辈曾经就该问题询问过 AI,得到的答案是创建一个 AppleScript 来自动化这个操作,看起来脚本很简单。 1 2 3 4 5 6 7 tell application "System Events" set frontApp to name of first application process whose frontmost is true tell process frontApp set position of window 1 to {0, 0} set size of window 1 to {1280, 800} end tell end tell 事实上,如果在 Automactor 中直接执行,也确实符合预期,可以修改窗口大小。但在吾辈将之保存为 App 后,再次运行却出现了权限错误。 1 2 Can’t get window 1 of «class prcs» "Resize1280x800" of application "System Events". Invalid index. System Events got an error: Can’t get window 1 of process "Resize1280x800". Invalid index. (-1719) 而 System Event 也确实是给了的,不知道发生了什么。🤷 Mac App 开发 在使用简单的脚本实现受挫之后,吾辈考虑快速开发一个 Mac App 来解决这个问题,但实际上仍然遇到了一些问题。主要是对 Swift 没有任何先验知识,XCode 相比之前使用 IDE(Jetbrains/VSCode)非常难用,再加上 AI 对 Swift 代码生成和修改支持并不好,所以开发起来很痛苦,而且最终仍然遇到了与上面 AppleScript 类似的权限问题。吾辈猜测这是个愚蠢的问题,熟悉 Mac App 开发的人或许十分钟就能解决,但确实卡住了吾辈。 Shortcuts 终于,Shortcuts 可以以低代码的方式创建一个应用。基本思路是,获取所有窗口 => 找到置顶的窗口 => 修改窗口尺寸。 拖拽一个 Find Windows Action 修改 Find Windows 配置 再拖拽一个 Resize Window Action 修改 Resize Window 的配置 尝试运行一下,确保没有问题 现在,可以使用 Spotlight Search 输入 Resize Window 来快速运行这个 Shortcut 啦 另外吾辈已经把这个 Shortcut 导出放到 GitHub 上了,可以自行下载使用:https://github.com/rxliuli/mac-resize-window 参考 The Easiest Way to Resize All Windows on Your Mac Simultaneously to the Same Dimensions

2024/10/16
articleCard.readMore

旅行 2024-09

自从九月初从广州出发继续北上旅行,刚好躲过了一次台风摩羯。虽然旅行仍然能带来一些新鲜感,但或多或少已经有些不足了,所以在江浙连续碰上两次台风之后,九月下旬便匆匆赶回来了。 衡山 第一站去往了衡山,之前前往山东时没能爬上泰山,这次顺路去爬下衡山。刚到衡山就被出租车上了一课,滴滴叫了车但司机实收很低线下想让吾辈额外付费,之前在广西阳朔、后面在江苏镇江都遇到过类似的糟心事,也许越是小地方越是不讲规则? 刚到就去了附近的万寿广场,35 度的天气下对吾辈和相机都是考验。 之后前往旁边的南岳大庙,本身吾辈并不信佛道,但拍拍风景也是好的。 晚上出来拍点夜景,偶然还看到了五岳特种兵的宣传牌,之前完全不知道这个活动。 次日一早开始爬衡山,说是爬山,也是要坐很久的区间车,在最后的山顶附近才开始爬。由于雾气很大,没能拍出来什么照片。相比于爬山爱好者,来此求神拜佛者更是络绎不绝。 影集 万寿广场 南岳大庙 衡山县 衡山 长沙 第二站选在了长沙,之前去了湖南的张家界,却没去省会城市,这次补上。不过前往之前刷到了一些关于臭豆腐的坏消息,所以臭豆腐一点也没尝。虽然耳边还记得那句“来长沙不吃长沙臭豆腐等于白来”的广告语。 先去城市里随便逛逛扫个街,有一说一,第一次看到把辣条作为宣传的。 大楼的阴凉处,好多正在直播的人,也许附近有“直播小区”? 途经坡子街,到处都是小吃和茶饮,尤其是茶颜悦色,非常显眼。 之后,来到湘江之畔,走在翠幕之下的河岸边,可以眺望对面的橘子洲。树荫之下,人们三三两两的坐着,或是聊天,或是休息。 途经太平老街,中午简单吃了个饭,随即前往河对岸,只是天色渐晚,遂未登岳麓山。 走了一天也有些累了,之后便回到青旅暂作休息。 次日早晨,前往湖南省博物馆,其中付费的两个场地人数不多,值得一看,尤其是数字科技展览部分。 不幸的是,下午刚出博物馆就遇上了暴雨,无奈之下只能打车回去,暂时避雨。直到傍晚,才出来去杜甫江阁附近观赏晚霞与夜景。 可能唯一留下不好印象的就是小巷之中贴的到处都是的标语了。众所周知,通常缺什么才写什么。。。 影集 湖南省博物馆 湘江之畔 长沙 南京 第三站是南京,可以说是整个行程中最满意的一个城市,是这次旅行中照片最多的城市。 当晚就去了知名的秦淮河畔,表面看上去光鲜亮丽无比。 实则水质较差,隐有臭味,把相机吓得都变形了。 第二天前往中山陵和明孝陵,后者超级超级超级大,逛了好几个小时还没走完,直到下午 4 点钟才离开。 在离开中山陵,进入明孝陵之后,首先碰到了钟山文学馆,江南园林看起来永远都很舒服。 之后是颜真卿碑林,第一看到时突然想起来黑猴里面第四章见到二姐时的屋顶,可能最近看到黑猴的视频太多了 emmmm。 临近中午到达了紫霞湖,许多大爷大妈们在这儿野餐和游泳。 到达中午,终于抵达了明孝陵。 之后更是收集到了红粉黄橙白五种颜色的彼岸花。 里面还有非常非常多的小园子,不再一一赘述。之后打算前往明长城看看,结果下午 5 点就关闭了。 次日去了鸡鸣寺和城墙,吾辈是从电视剧《明王朝》的视频中看到过这个,实际去了之后也没什么。 在城墙上散步时,看到一个很漂亮的小园子,只是忘记记下具体的位置了。 影集 秦淮河畔 白鹭洲公园 中山陵 钟山文学馆 颜真卿碑林 紫霞湖 明孝陵 鸡鸣寺 南京城墙 南京 镇江 第四站是镇江,到的时候还不能办理入住,所以先去了金山寺转转。 第二天遇到了台风,天气非常非常糟糕,所以完全没有出门。第三天倒是去了附近的南山风景区,台风刚过,天气异常的好。 下山之后,附近的风景也非常棒,真的很喜欢这种蓝天白云的干净。旁边还有一处隐秘的日月潭,还有人在那里游泳。 晚上去了当地的历史文化街区,无甚新意,直接略过。 次日前往焦山,相比于自然风景似乎其历史文化意义更为丰富,不太感冒。 影集 金山寺 南山风景区 西津渡历史文化街区 镇江焦山 镇江 无锡 好了,终于到达了最后一站无锡,刚经历过一场台风,下一场台风接踵而至,气的吾辈提前回广东了,可以说这是最糟糕的一站。 抵达当晚便前往惠山古镇,因为预报明天就开始有雨,在旅行中下雨是最烦人的天气了。 夜景还算不错? 探索阴暗的小巷子。 由于台风的影响,旁边的公园也没有开放。 嗯?这就结束了? 是的,由于下雨呆了一天,在下一场台风来临之前便坐上火车回去了。 影集 惠山古镇 总结 可以说,出去旅行排解心情的效果愈发糟糕,还是需要寻找到主线任务。

2024/9/27
articleCard.readMore