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

在 Web 中实现一个 TypeScript Editor

前言 最近为 Chrome 开发了可以直接在浏览器运行 TypeScript 的插件 TypeScript Console,需要将代码编辑器集成到 Chrome Devtools 面板。其实要在 Web 中引入代码编辑器也是类似的,下面分享一下如何实现。 实现 首先来看看有什么问题 代码编辑器选择什么? 如何在浏览器编译和运行代码? 如何使用 npm 包呢? 使用 npm 包怎么有类型定义提示? 了解 Monaco 首先,考虑到要编写的是 TypeScript 编辑器,所以选择 Monaco Editor。它是 VSCode 的底层编辑器,所以对 TypeScript 的支持度是毋庸置疑的。来看看如何使用它 安装依赖 1 pnpm i monaco-editor 引入它,注意 MonacoEnvironment 部分,使用 TypeScript LSP 服务需要使用 WebWorker 引入对应的语言服务。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // src/index.ts import './style.css' import * as monaco from 'monaco-editor' import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker' self.MonacoEnvironment = { getWorker: (_: any, label: string) => { if (label === 'typescript' || label === 'javascript') { return new tsWorker() } return new editorWorker() }, } const value = `const add = (a: number, b: number) => a + b console.log(add(1, 2))` const editor = monaco.editor.create(document.getElementById('app')!, { value, language: 'typescript', automaticLayout: true, }) style.css 1 2 3 4 #app { width: 100vw; height: 100vh; } 现在就有了一个基本的 TypeScript 编辑器了。 编译和运行 接下来如何编译和运行呢?编译 TypeScript 为 JavaScript 代码有多种多样的选择,包括 TypeScript、Babel、ESBuild、SWC 等等,这里考虑到性能和尺寸,选择 ESBuild,它提供 WASM 版本以在浏览器中使用。 安装依赖 1 pnpm i esbuild-wasm 基本使用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import { initialize, transform } from 'esbuild-wasm' import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm?url' let isInit = false async function compileCode(code: string): Promise<string> { if (!isInit) { await initialize({ wasmURL: esbuildWasmUrl, }) isInit = true } const result = await transform(code, { loader: 'ts', format: 'iife', }) return result.code } console.log( await compileCode(`const add = (a: number, b: number) => a + b console.log(add(1, 2))`), ) 编译结果 接下来,如何运行编译好的代码呢?最简单的方式是直接使用 eval 执行,或者根据需要使用 WebWorker/Iframe 来运行不安全的代码。 1 2 3 4 eval(`(() => { const add = (a, b) => a + b; console.log(add(1, 2)); })();`) 或者也可以使用 WebWorker。 1 2 3 4 5 6 7 const code = `(() => { const add = (a, b) => a + b; console.log(add(1, 2)); })();` new Worker( URL.createObjectURL(new Blob([code], { type: 'application/javascript' })), ) 现在,结合一下上面的代码,在按下 Ctrl/Cmd+S 时触发编译执行代码。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // other code... let worker: Worker function executeCode(code: string) { if (worker) { worker.terminate() } const blobUrl = URL.createObjectURL( new Blob([code], { type: 'application/javascript' }), ) worker = new Worker(blobUrl) } window.addEventListener('keydown', async (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault() const compiledCode = await compileCode(editor.getValue()) executeCode(compiledCode) } }) 完整代码 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 import './style.css' import * as monaco from 'monaco-editor' import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker' self.MonacoEnvironment = { getWorker: (_: any, label: string) => { if (label === 'typescript' || label === 'javascript') { return new tsWorker() } return new editorWorker() }, } const value = `const add = (a: number, b: number) => a + b console.log(add(1, 2))` const editor = monaco.editor.create(document.getElementById('app')!, { value, language: 'typescript', automaticLayout: true, }) import { initialize, transform } from 'esbuild-wasm' import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm?url' let isInit = false async function compileCode(code: string): Promise<string> { if (!isInit) { await initialize({ wasmURL: esbuildWasmUrl, }) isInit = true } const result = await transform(code, { loader: 'ts', format: 'iife', }) return result.code } let worker: Worker function executeCode(code: string) { if (worker) { worker.terminate() } const blobUrl = URL.createObjectURL( new Blob([code], { type: 'application/javascript' }), ) worker = new Worker(blobUrl) } window.addEventListener('keydown', async (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault() const compiledCode = await compileCode(editor.getValue()) executeCode(compiledCode) } }) 引用 npm 包 接下来,应该看看如何支持引用 npm 包了。不使用构建工具时一般是怎么引用 npm 包呢?先看看来自 Preact 的 官方示例 吧 1 2 3 4 5 6 7 8 <script type="module"> import { h, render } from 'https://esm.sh/preact' // Create your app const app = h('h1', null, 'Hello World!') render(app, document.body) </script> 可以看到,这里借助浏览器支持 ESModule 的特性,结合上 esm.sh 这个服务,便可以引用任意 npm 包。 而关键在于这里使用了 esm 格式,而上面可以看到在构建时使用了 iife 格式,简单的解决方法是将运行时的代码修改为 esm 格式,复杂的方式是将 esm 格式转换为 iife 格式。 使用 esm 格式 先说简单的方法,修改之前的代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @@ -10,7 +10,7 @@ async function compileCode(code: string): Promise<string> { const result = await transform(code, { loader: 'ts', - format: 'iife', + format: 'esm', }) return result.code } @@ -23,5 +23,5 @@ function executeCode(code: string) { const blobUrl = URL.createObjectURL( new Blob([code], { type: 'application/javascript' }), ) - worker = new Worker(blobUrl) + worker = new Worker(blobUrl, { type: 'module' }) } 现在,可以使用 esm.sh 上的 npm 包了。 1 2 3 import { sum } from 'https://esm.sh/lodash-es' console.log(sum([1, 2, 3, 4])) 但实际代码中通常希望使用 import { sum } from 'lodash-es' 而非 import { sum } from 'https://esm.sh/lodash-es',所以还是需要转换 import。这涉及到操作代码语法树,此处选择使用 babel,首先安装依赖。 1 2 pnpm i @babel/standalone lodash-es pnpm i -D @babel/types @types/babel__core @types/babel__generator @types/babel__standalone @types/babel__traverse @babel/parser @types/lodash-es 还需要给 @babel/standalone 打上类型定义的补丁(已提 PR)。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // src/vite-env.d.ts declare module '@babel/standalone' { import parser from '@babel/parser' import * as types from '@babel/types' import type * as t from '@babel/types' import generator from '@babel/generator' const packages = { parser, types, generator: { default: generator, }, } export { packages, t } } 然后获取所有的 import 并转换。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 function transformImports(code: string) { const { parser, types, generator } = packages const ast = parser.parse(code, { sourceType: 'module', plugins: ['typescript'], sourceFilename: 'example.ts', }) const imports = ast.program.body.filter((it) => types.isImportDeclaration(it)) if (imports.length === 0) { return code } imports.forEach((it) => { it.source.value = `https://esm.sh/${it.source.value}` }) const newCode = generator.default(ast).code return newCode } 然后在编译代码之前先处理一下 imports 就好了。 1 2 3 4 5 6 7 8 9 @@ -8,7 +8,7 @@ async function compileCode(code: string): Promise<string> { isInit = true } - const result = await transform(code, { + const result = await transform(transformImports(code), { loader: 'ts', format: 'esm', }) 现在,编译时会自动处理 npm 模块了。 完整代码 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 import './style.css' import * as monaco from 'monaco-editor' import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker' import { groupBy } from 'lodash-es' self.MonacoEnvironment = { getWorker: (_: any, label: string) => { if (label === 'typescript' || label === 'javascript') { return new tsWorker() } return new editorWorker() }, } let value = `const add = (a: number, b: number) => a + b console.log(add(1, 2))` const editor = monaco.editor.create(document.getElementById('app')!, { value, language: 'typescript', automaticLayout: true, }) import { initialize, transform } from 'esbuild-wasm' import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm?url' let isInit = false async function compileCode(code: string): Promise<string> { if (!isInit) { await initialize({ wasmURL: esbuildWasmUrl, }) isInit = true } const result = await transform(transformImports(code), { loader: 'ts', format: 'esm', }) return result.code } let worker: Worker function executeCode(code: string) { if (worker) { worker.terminate() } const blobUrl = URL.createObjectURL( new Blob([code], { type: 'application/javascript' }), ) worker = new Worker(blobUrl, { type: 'module' }) } import { packages } from '@babel/standalone' function transformImports(code: string) { const { parser, types, generator } = packages const ast = parser.parse(code, { sourceType: 'module', plugins: ['typescript'], sourceFilename: 'example.ts', }) const imports = ast.program.body.filter((it) => types.isImportDeclaration(it)) if (imports.length === 0) { return code } imports.forEach((it) => { it.source.value = `https://esm.sh/${it.source.value}` }) const newCode = generator.default(ast).code return newCode } window.addEventListener('keydown', async (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault() const compiledCode = await compileCode(editor.getValue()) executeCode(compiledCode) } }) 使用 iife 格式 esm 是新的标准格式,但旧的 iife 仍然有一些优势。例如不挑环境、可以直接粘贴运行等,下面将演示如何将 esm 转换为 iife。 下面两段代码是等价的,但前者无法在 Devtools Console 中运行,也无法使用 eval 执行,而后者则可以。 1 2 3 4 5 6 7 // before import { sum } from 'https://esm.sh/lodash-es' console.log(sum([1, 2, 3, 4])) // after const { sum } = await import('https://esm.sh/lodash-es') console.log(sum([1, 2, 3, 4])) 需要将下面包含 import 的代码转换为动态 import 的,参考 amd 格式可以得到 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // before import { sum } from 'lodash-es' console.log(sum([1, 2, 3, 4])) // after async function define(deps: string[], fn: (...args: any[]) => any) { const args = await Promise.all( deps.map(async (dep) => { const mod = await import('https://esm.sh/' + dep) return 'default' in mod ? mod.default : mod }), ) return fn(...args) }define(['lodash-es'], ({ sum }) => { console.log(sum([1, 2, 3, 4])) }) 接下来使用 babel 提取所有 imports 并生成一个 define 函数调用,清理所有 exports,并将自定义的 define 函数追加到顶部。 首先解析每个 import,它可能在 define 中生成多个参数,例如 1 import _, { sum } from 'lodash-es' 会得到 1 define(['lodash-es', 'lodash-es'], (_, { sum }) => {}) 所以先实现解析 import 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 type ImportType = { source: string } & ( | { type: 'namespace' name: string } | { type: 'default' name: string } | { type: 'named' imports: Record<string, string> } ) function parseImport(imp: ImportDeclaration): ImportType[] { const { types } = packages const specifiers = imp.specifiers const source = imp.source.value const isNamespace = specifiers.length === 1 && types.isImportNamespaceSpecifier(specifiers[0]) const includeDefault = specifiers.some((it) => types.isImportDefaultSpecifier(it), ) if (isNamespace) { return [ { type: 'namespace', source, name: specifiers[0].local.name, }, ] } const namedImport = specifiers.filter( (it) => !types.isImportDefaultSpecifier(it), ) const result: ImportType[] = [] if (namedImport.length > 0) { result.push({ type: 'named', source, imports: namedImport.reduce((acc, it) => { acc[((it as ImportSpecifier).imported as Identifier).name] = it.local.name return acc }, {} as Record<string, string>), } as ImportType) } if (includeDefault) { result.push({ type: 'default', source, name: specifiers[0].local.name, } as ImportType) } return result } 然后修改 transformImports 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 @@ -7,14 +7,69 @@ function transformImports(code: string) { sourceFilename: 'example.ts', }) - const imports = ast.program.body.filter((it) => types.isImportDeclaration(it)) - if (imports.length === 0) { - return code - } - imports.forEach((it) => { - it.source.value = `https://esm.sh/${it.source.value}` - }) + const defineAst = parser.parse( + `export async function define(deps: string[], fn: (...args: any[]) => any) { + const args = await Promise.all( + deps.map(async (dep) => { + const mod = await import('https://esm.sh/' + dep) + return 'default' in mod ? mod.default : mod + }), + ) + return fn(...args) +} +`, + { + sourceType: 'module', + plugins: ['typescript'], + sourceFilename: 'define.ts', + }, + ) + + const grouped = groupBy(ast.program.body, (it) => { + if (types.isImportDeclaration(it)) { + return 'import' + } + if (types.isExportDeclaration(it)) { + return 'export' + } + return 'other' + }) + const imports = (grouped.import || []) as ImportDeclaration[] + const other = (grouped.other || []) as Statement[] + const parsedImports = imports.flatMap(parseImport) + const params = parsedImports.map((imp) => + imp.type === 'named' + ? types.objectPattern( + Object.entries(imp.imports).map((spec) => + types.objectProperty( + types.identifier(spec[0]), + types.identifier(spec[1]), + ), + ), + ) + : types.identifier(imp.name), + ) + const newAst = types.program([ + defineAst.program.body[0], + types.expressionStatement( + types.callExpression(types.identifier('define'), [ + types.arrayExpression( + parsedImports.map((it) => types.stringLiteral(it.source)), + ), + types.arrowFunctionExpression(params, types.blockStatement(other)), + ]), + ), + ]) + + ast.program = newAst + const newCode = generator.default(ast).code return newCode } 完整代码 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 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 import './style.css' import * as monaco from 'monaco-editor' import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker' import { groupBy } from 'lodash-es' self.MonacoEnvironment = { getWorker: (_: any, label: string) => { if (label === 'typescript' || label === 'javascript') { return new tsWorker() } return new editorWorker() }, } let value = `const add = (a: number, b: number) => a + b console.log(add(1, 2))` const editor = monaco.editor.create(document.getElementById('app')!, { value, language: 'typescript', automaticLayout: true, }) import { initialize, transform } from 'esbuild-wasm' import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm?url' let isInit = false async function compileCode(code: string): Promise<string> { if (!isInit) { await initialize({ wasmURL: esbuildWasmUrl, }) isInit = true } const result = await transform(transformImports(code), { loader: 'ts', format: 'iife', }) return result.code } let worker: Worker function executeCode(code: string) { if (worker) { worker.terminate() } const blobUrl = URL.createObjectURL( new Blob([code], { type: 'application/javascript' }), ) worker = new Worker(blobUrl) } import { packages } from '@babel/standalone' import type { Identifier, ImportDeclaration, ImportSpecifier, Statement, } from '@babel/types' type ImportType = { source: string } & ( | { type: 'namespace' name: string } | { type: 'default' name: string } | { type: 'named' imports: Record<string, string> } ) function parseImport(imp: ImportDeclaration): ImportType[] { const { types } = packages const specifiers = imp.specifiers const source = imp.source.value const isNamespace = specifiers.length === 1 && types.isImportNamespaceSpecifier(specifiers[0]) const includeDefault = specifiers.some((it) => types.isImportDefaultSpecifier(it), ) if (isNamespace) { return [ { type: 'namespace', source, name: specifiers[0].local.name, }, ] } const namedImport = specifiers.filter( (it) => !types.isImportDefaultSpecifier(it), ) const result: ImportType[] = [] if (namedImport.length > 0) { result.push({ type: 'named', source, imports: namedImport.reduce((acc, it) => { acc[((it as ImportSpecifier).imported as Identifier).name] = it.local.name return acc }, {} as Record<string, string>), } as ImportType) } if (includeDefault) { result.push({ type: 'default', source, name: specifiers[0].local.name, } as ImportType) } return result } function transformImports(code: string) { const { parser, types, generator } = packages const ast = parser.parse(code, { sourceType: 'module', plugins: ['typescript'], sourceFilename: 'example.ts', }) const defineAst = parser.parse( `export async function define(deps: string[], fn: (...args: any[]) => any) { const args = await Promise.all( deps.map(async (dep) => { const mod = await import('https://esm.sh/' + dep) return 'default' in mod ? mod.default : mod }), ) return fn(...args) } `, { sourceType: 'module', plugins: ['typescript'], sourceFilename: 'define.ts', }, ) const grouped = groupBy(ast.program.body, (it) => { if (types.isImportDeclaration(it)) { return 'import' } if (types.isExportDeclaration(it)) { return 'export' } return 'other' }) const imports = (grouped.import || []) as ImportDeclaration[] const other = (grouped.other || []) as Statement[] const parsedImports = imports.flatMap(parseImport) const params = parsedImports.map((imp) => imp.type === 'named' ? types.objectPattern( Object.entries(imp.imports).map((spec) => types.objectProperty( types.identifier(spec[0]), types.identifier(spec[1]), ), ), ) : types.identifier(imp.name), ) const newAst = types.program([ defineAst.program.body[0], types.expressionStatement( types.callExpression(types.identifier('define'), [ types.arrayExpression( parsedImports.map((it) => types.stringLiteral(it.source)), ), types.arrowFunctionExpression(params, types.blockStatement(other)), ]), ), ]) ast.program = newAst const newCode = generator.default(ast).code return newCode } window.addEventListener('keydown', async (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault() const compiledCode = await compileCode(editor.getValue()) console.log(compiledCode) executeCode(compiledCode) } }) 处理类型定义 现在,代码可以正常编译和运行了,但在编辑器中引入的 npm 包仍然有类型错误提示,这又应当如何解决呢? 得益于 TypeScript 的生态发展,现在实现这个功能非常简单。首先,安装依赖 1 pnpm i @typescript/ata typescript 然后引入 @typescript/ata 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 { setupTypeAcquisition } from '@typescript/ata' import ts from 'typescript' function initTypeAcquisition( addLibraryToRuntime: (code: string, path: string) => void, ) { return setupTypeAcquisition({ projectName: 'TypeScript Playground', typescript: ts, logger: console, delegate: { receivedFile: (code: string, path: string) => { addLibraryToRuntime(code, path) // console.log('Received file', code, path) }, progress: (dl: number, ttl: number) => { // console.log({ dl, ttl }) }, started: () => { console.log('ATA start') }, finished: (f) => { console.log('ATA done') }, }, }) }const ta = initTypeAcquisition((code: string, path: string) => { const _path = 'file://' + path monaco.languages.typescript.typescriptDefaults.addExtraLib(code, _path) }) editor.onDidChangeModelContent(async () => { // 判断是否有错误 const value = editor.getValue() await ta(value) }) // editor 初始化完成后,执行一次 ta ta(editor.getValue()) 还需要为编辑器设置一个 Model,主要是需要指定一个虚拟文件路径让 Monaco Editor 的 TypeScript 能正确找到虚拟 node_modules 下的类型定义文件。 1 2 3 4 5 6 const model = monaco.editor.createModel( value, 'typescript', monaco.Uri.file('example.ts'), )editor.setModel(model) 完整代码 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 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 import './style.css' import * as monaco from 'monaco-editor' import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker' import { groupBy } from 'lodash-es' self.MonacoEnvironment = { getWorker: (_: any, label: string) => { if (label === 'typescript' || label === 'javascript') { return new tsWorker() } return new editorWorker() }, } let value = `import { sum } from 'lodash-es' console.log(sum([1, 2, 3, 4]))` const editor = monaco.editor.create(document.getElementById('app')!, { value, language: 'typescript', automaticLayout: true, }) import { initialize, transform } from 'esbuild-wasm' import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm?url' let isInit = false async function compileCode(code: string): Promise<string> { if (!isInit) { await initialize({ wasmURL: esbuildWasmUrl, }) isInit = true } const result = await transform(transformImports(code), { loader: 'ts', format: 'iife', }) return result.code } let worker: Worker function executeCode(code: string) { if (worker) { worker.terminate() } const blobUrl = URL.createObjectURL( new Blob([code], { type: 'application/javascript' }), ) worker = new Worker(blobUrl) } import { packages } from '@babel/standalone' import type { Identifier, ImportDeclaration, ImportSpecifier, Statement, } from '@babel/types' type ImportType = { source: string } & ( | { type: 'namespace' name: string } | { type: 'default' name: string } | { type: 'named' imports: Record<string, string> } ) function parseImport(imp: ImportDeclaration): ImportType[] { const { types } = packages const specifiers = imp.specifiers const source = imp.source.value const isNamespace = specifiers.length === 1 && types.isImportNamespaceSpecifier(specifiers[0]) const includeDefault = specifiers.some((it) => types.isImportDefaultSpecifier(it), ) if (isNamespace) { return [ { type: 'namespace', source, name: specifiers[0].local.name, }, ] } const namedImport = specifiers.filter( (it) => !types.isImportDefaultSpecifier(it), ) const result: ImportType[] = [] if (namedImport.length > 0) { result.push({ type: 'named', source, imports: namedImport.reduce((acc, it) => { acc[((it as ImportSpecifier).imported as Identifier).name] = it.local.name return acc }, {} as Record<string, string>), } as ImportType) } if (includeDefault) { result.push({ type: 'default', source, name: specifiers[0].local.name, } as ImportType) } return result } function transformImports(code: string) { const { parser, types, generator } = packages const ast = parser.parse(code, { sourceType: 'module', plugins: ['typescript'], sourceFilename: 'example.ts', }) const defineAst = parser.parse( `export async function define(deps: string[], fn: (...args: any[]) => any) { const args = await Promise.all( deps.map(async (dep) => { const mod = await import('https://esm.sh/' + dep) return 'default' in mod ? mod.default : mod }), ) return fn(...args) } `, { sourceType: 'module', plugins: ['typescript'], sourceFilename: 'define.ts', }, ) const grouped = groupBy(ast.program.body, (it) => { if (types.isImportDeclaration(it)) { return 'import' } if (types.isExportDeclaration(it)) { return 'export' } return 'other' }) const imports = (grouped.import || []) as ImportDeclaration[] const other = (grouped.other || []) as Statement[] const parsedImports = imports.flatMap(parseImport) const params = parsedImports.map((imp) => imp.type === 'named' ? types.objectPattern( Object.entries(imp.imports).map((spec) => types.objectProperty( types.identifier(spec[0]), types.identifier(spec[1]), ), ), ) : types.identifier(imp.name), ) const newAst = types.program([ defineAst.program.body[0], types.expressionStatement( types.callExpression(types.identifier('define'), [ types.arrayExpression( parsedImports.map((it) => types.stringLiteral(it.source)), ), types.arrowFunctionExpression(params, types.blockStatement(other)), ]), ), ]) ast.program = newAst const newCode = generator.default(ast).code return newCode } window.addEventListener('keydown', async (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault() const compiledCode = await compileCode(editor.getValue()) console.log(compiledCode) executeCode(compiledCode) } }) import { setupTypeAcquisition } from '@typescript/ata' import ts from 'typescript' function initTypeAcquisition( addLibraryToRuntime: (code: string, path: string) => void, ) { return setupTypeAcquisition({ projectName: 'TypeScript Playground', typescript: ts, logger: console, delegate: { receivedFile: (code: string, path: string) => { addLibraryToRuntime(code, path) // console.log('Received file', code, path) }, progress: (dl: number, ttl: number) => { // console.log({ dl, ttl }) }, started: () => { console.log('ATA start') }, finished: (f) => { console.log('ATA done') }, }, }) }const ta = initTypeAcquisition((code: string, path: string) => { const _path = 'file://' + path monaco.languages.typescript.typescriptDefaults.addExtraLib(code, _path) console.log('addExtraLib', _path) }) editor.onDidChangeModelContent(async () => { const value = editor.getValue() await ta(value) }) ta(editor.getValue()) const model = monaco.editor.createModel( value, 'typescript', monaco.Uri.file('example.ts'), )editor.setModel(model) 结语 上面的代码还有许多地方没有优化,例如在主线程直接编译代码可能会阻塞主线程、引入了 3 个 TypeScript 解析器导致 bundle 大小膨胀、没有正确处理 sourcemap 等等,但这仍然是一个不错的起点,可以在遇到需要为 Web 应用添加代码编辑器之时尝试用类似的方法完成。

2024/9/12
articleCard.readMore

周报 2024-08-31 -- 海陵岛之旅

漫长的暑假终于结束,可以继续出门旅行了。这周先去了附近的海陵岛,虽然一直素有坑人的水鱼岛之称,不过吾辈还是来玩了三天。 路线: 大角湾 => 大角湾夜滩 => 北洛秘境(沙滩)=> 马尾岛 => 滨海栈道 => 观海楼 => 十里银滩 大角湾 刚到这里住在了大角湾,据说是岛上最方便的地方。来的时候已经是下午,便在附近走了走,把一块海滩围起来收费并且规定只允许在那里泡水实在太蠢了。 有很多人在远方冲浪。 远远的还能看到左边的滨海栈道,不过今天没往那边走。 大角湾夜滩 不过下午去旁边的大角湾夜滩,沙滩确实维护的还不错,海水看起来也非常清。 街景 在步行的路上也拍了一些街景。 路边还拍到了一些野花? 北洛秘境 在前往马尾岛的路上,偶然发现北洛秘境沙滩免费开放了,于是也便前往看了看。 这张照片的棕榈树真的有夏日海滩的感觉。 远方的海角伫立着一座灯塔,只是不知是否还在工作。 远方海面上依稀可见停靠的风力发电平台。 从灯塔下面向海滩望去。 遗憾的是,就在海滩的左侧,吾辈看到了污水排放口。 神奇的是,污水排放口旁边似乎有个很漂亮的拍照地点,但位于岩石之上。 马尾岛 之后前往马尾岛,看到了海面上停放着百舸千帆。 到达马尾岛入口,山脊的形状很奇怪。 此时已近黄昏,偏逢乌云汇聚。 待至七点,终于看到了美丽的余晖。 夜景 晚上吃了点东西便继续出门了,小摊夜市都陆陆续续出摊了,但大多数都还停留在十年前的骗人玩意上,套圈、打气球之类的。 海边可以看到涨潮已经把原本位在海边的杆子淹掉了。 有人在放烟花,但并没有动画中那么美好。 滨海栈道 第二天刚起床便有雷雨,等到下午雨终于转小时,带着相机就出了门。昨天往右走到了马尾岛,所以今天便向左出发。 沿着海滩一直向左走,便看到了城市的排污入海口,各种垃圾沿着下水道流入海中,旁边大角湾的海水水质可想而知。 不消片刻,便抵达了附近的南海放生台(放生到污水入海口也活不了吧?) 旁边的山上可以看到隐匿于山林之中的居所。 之后就开始沿着滨海栈道出发了,总长 2.5 公里,由于栈道破损缺乏维修,花了一个半小时才走完,而且并不安全。 在一个海角可以看到部分栈道,栈道左上方便是公路。 看起来很危险,实际上一点也不安全。 山林之下。 偶遇一只鸟儿,有人知道这是什么吗? 山上便有一座妈祖庙。 山下则有各种大小的鹅卵石。 又偶遇到一只世界之王羊。 微距啊微距,好想入坑微距。 观海楼 走了很久之后终于到达山的另一侧,接近十里银滩的位置有一个楼梯,上面通往已经废弃的观海楼,随处可见废弃后的破败。 行至高处,海阔天空。 十里银滩 终于抵达山的另一侧,可以看到十里银滩了。 海边的礁石迎接着永无止境的海浪。 海天一色,有点难。 一只渔船,不知在捕捞什么东西。 下到山脚下,看到一片野沙滩,几个小孩子正在海里玩水,于是也下去想沿着沙滩走走,却不想下来时把手机忘在了台阶上。 次日中午终于前往海里泡水,并未去往收费的海滩,而是出门即达的酒店旁边的海滩。 要说海里与水上乐园泡水的不同,应该是大海有永不停歇的浪潮在推动,让人无法在一个地方安心泡水吧。 总结 关于海陵岛是否坑人,吾辈会说确实如此。虽然风景还算不错,但恐怕吾辈不会再去第二次。之后将会前往长沙,之后转向南京,随后便在江浙附近待到 9 月底了。

2024/9/3
articleCard.readMore