MoreRSS

site iconrxliuli | 琉璃修改

Web 前端开发,在日本,喜欢自称「吾辈」让我很难受。。博客主要记录一些技术相关的东西
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

rxliuli | 琉璃的 RSS 预览

发布 Safari 扩展到 iOS 应用商店

2025-08-08 07:28:40

背景

今年以来,吾辈开始发布一些 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 按钮。
1755089514895.jpg

其次,模拟器中的 iOS 扩展的封装 App 就会自动打开。
Simulator Screenshot - iPhone 16 Pro - 2025-08-13 at 20.52.34.jpg

再其次,点击模拟器顶部工具栏的 Home 图标,返回桌面,打开 Settings > Apps > Safari > Extensions 中,即可看到刚刚 Build 的扩展。默认情况下它应该是 Disable 的,进入然后 Enable 即可。如果你无法找到刚刚 Build 的扩展,请参考下面的问题,就我而言,在排查问题的过程中 Claude 4.1 Opus 确实给了不错的提示,让吾辈意识到排查错误的方向和关键词是什么。
1755089694981.jpg
1755089768574.jpg

最后,点击 Home 回到桌面,找到 Safari 打开,你应该能在浏览器工具栏看到刚刚 Build 的扩展。
1755090127320.jpg

参考: Apple 官方视频 2022

发布到 AppStore

在测试完成确认没有漏洞之后,就可以发布到 AppStore 了。

首先,在 Xcode 中选择 Product > Archive 进行打包。
1755090217839.jpg

其次,在弹窗中点击 Distribute App 按钮,接着选择 App Store Connect 作为分发渠道,最后点击 Distribute 按钮,你的 App 就会开始上传到 AppStore 了。
1755090347344.jpg
1755090341984.jpg

但请注意,上传完成之后并未发布,只是上传了一个构建包,还需要到 App Store Connect 添加版本信息、App 描述、截图等一系列常规信息,并提交审核才能最终发布。
1755090522509.jpg

问题

如果没有报错但同时也不生效,那可能不是你的错,只能怪 Apple/Safari 的开发体验太糟。

iOS 里面的 Settings > Apps 中看不到任何 App

如图

Snipaste\_2025-08-08\_07-23-20.jpg

根据这个 社区 issue,可以知道是 18.2 的 bug,升级到 18.4 解决。

Snipaste\_2025-08-08\_07-31-46.jpg

验证

Snipaste\_2025-08-08\_07-32-07.jpg

在 Settings > Safari > Extensions 中始终看不到开发的扩展

如图

Snipaste\_2025-08-08\_08-01-05.jpg

升级至最新版的 XCode 及虚拟机解决,就吾辈而言,是 XCode 16.4 及 iOS 18.4 的虚拟机。

Snipaste\_2025-08-08\_08-43-45.jpg

验证方法是通过 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

在 Background Script 中发起的网络请求不会自动带上 cookie

例如下面这段代码在 Chrome/Firefox 中都是正常的,但在 Safari 中就无法工作。

1
fetch('https://mail.google.com/mail/u/0/feed/atom')

似乎和下面几个 Issue 有关,Tampermonkey 扩展也踩过坑,需要调研一下它是如何实现的。

Tampermonkey 似乎也找到了绕过 notifications api 缺失的问题,参考 https://github.com/Tampermonkey/tampermonkey/issues/1258#issuecomment-2488015079

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。

1755090943068.jpg

总结

截止目前为止,吾辈已经成功发布了两个全平台浏览器扩展,分别是

发布 Safari 扩展虽然有趣,却也让人意识到 Safari 扩展的开发体验有多么糟糕,吾辈在开发过程中踩了不少坑,也浪费了不少时间。

再游新疆 -- 自驾

2025-06-28 17:35:11

前言

六月初有个去新疆自驾的机会,于是便和新一开始了二刷新疆之旅。大致路线定的是南疆环线,由于距离霍尔果斯口岸很近,所以也顺便出国去哈萨克斯坦看了看。这次的旅行体验比上次报团要好得多,主要是单个地点好玩的话可以多玩会而不再卡时间了。

cover

赛里木湖

首先第一站前往了赛里木湖,中间由于路途太远,所以在精河县暂住一晚,第二天继续前往赛里木湖。

首先去游客中心买票,确认了自驾是每个人 140,人车合一而非车单独计算。
赛里木湖 1

将这里称作天空之镜似乎很合适。
赛里木湖 2

让吾辈大吃一惊的雪堆,在 6 月中旬这个季节。
赛里木湖 3

远处的湖边有人正在拍“场照”?
赛里木湖 4

从赛里木湖西侧的山坡木栈道上向下俯拍,可以看到赏心悦目的风景。
赛里木湖 5

雨过天晴,很幸运的看到了双彩虹。不幸的是,吾辈没有拍好它。
赛里木湖 6

第二天早上,早起前往点将台等待日出。
赛里木湖 7
赛里木湖 8
赛里木湖 9

上午的湖水无比湛蓝。
赛里木湖 10
赛里木湖 11

还在湖边用石头堆起了一个玛尼堆。

赛里木湖 12

霍尔果斯/扎尔肯特

接下来,由于赛里木湖附近就有一个陆上口岸,加之哈萨克斯坦又是免签国家,所以之后前往了口岸城市霍尔果斯,休息一晚后第二天出发前往扎尔肯特。

霍尔果斯当地并没有什么东西,旧口岸附近有一个观光塔。
霍尔果斯 1

闹市之中有一处欧式建筑。
霍尔果斯 2

暂住一晚后第二天前往哈萨克斯坦的扎尔肯特小镇,从新国门出去。
霍尔果斯 3

再经过两三个小时漫长的安检审核流程之后,终于到了扎尔肯特,由于有 3 个小时的时差,所以还能吃上午饭。第一次吃如此巨大的鸡肉卷。
扎尔肯特 1

在路上看到的一个广告牌,有点好奇这是否就是 Local Superman?
扎尔肯特 2

之后前往了旅馆,但由于哈萨克斯坦是旧苏联加盟国,所以西方软件在此地不好使,打车、住宿和支付都使用俄罗斯 Yandex 系列的软件,而不是常用的 Uber/Booking/GooglePay。由于没有自驾而且也无法打车,所以步行前往了最近的清真寺。这个清真寺似乎不太清真,杂糅了中国风?
扎尔肯特 3
扎尔肯特 4

这是小镇上最大的超市,大部分本地人都来这里采购东西,甚至被作为一日游的景点了。
扎尔肯特 5

在路上随处可见的管道中不知道有什么,自来水?或者是天然气?
扎尔肯特 6

伊昭公路

回国之后到了伊宁,六星街没什么好玩的,喀赞其民俗旅游区也因为天气原因没有去看,所以直接略过,开始翻越天山的伊昭公路之旅。

山脉之间。
伊昭公路 1
伊昭公路 2

尽管已经快没有信号了,但海拔三千米仍然有人卖现杀羊肉烧烤。
伊昭公路 3

这是沿山而筑的一条小路,虽然危险,但风景却也是极好的。
伊昭公路 4

下山后快到昭苏时,开始看到牛和羊。
伊昭公路 5

这个弧线看着真的太舒服了,如果没有人就更好了。
伊昭公路 6

山上还牧民的马。
伊昭公路 7

昭苏玉湖

吃完午饭,开始前往了昭苏附近的一个小景点:玉湖。但它却着实带来了不少惊喜,首先,它的门票人和车分开计算,一辆车只会计算一次,所以人均只有 55,与赛里木湖(140)、那拉提(200)相比,实在太划算了。

只看湖水颜色有点像西藏的羊卓雍湖。
昭苏玉湖 1
昭苏玉湖 2
昭苏玉湖 3

但真正带来惊喜的不是湖水,而是景区内部公路的长度,单程至少超过 45km(没能走到终点),而景区区间车只走 25km。在后面能看到雪山、草地和成群的牛羊,风景着实不错。
昭苏玉湖 4
昭苏玉湖 5
昭苏玉湖 6

往回走时看到远方层层叠叠的山有一些透明的感觉。
昭苏玉湖 7

天山花海

在中途经过特克斯八卦城住下之后,前往了非常有名的天山花海,但却大失所望,里面的花海挺壮观,但从地面上非常难拍。

成片的薰衣草花海。
天山花海 1

蜜蜂正在采蜜。
天山花海 2
天山花海 3

似乎已经到末期的绳子草。
天山花海 4

在纪念品购买的地方看到介绍天山花海似乎是个农业庄园,只是兼具旅游的功能。
天山花海 4

那拉提

与赛里木湖一样,那拉提也是二刷。上次抱团来的时候体验极其糟糕,只是乘坐区间车快速走马观花看了空中草原(盛名之下,其实难副)。这次自驾进来,在 48 小时内一共进来了 3 次,空中草原的体验仍然一般般,但河谷草原末端登上天鹰台才真正看到很棒的风景。

不知道什么时候立的一座雕像。
那拉提 1

空中草原,也就是说,在海拔很高的山上的一片草原。
那拉提 2

来的时候还下着淅淅沥沥的小雨,天气比较糟糕。
那拉提 3

曾经的牧民就住在这种小房子中,牛羊在外面吃草。
那拉提 4
那拉提 5

一条小溪从山间流下,也许正是来自雪莲谷。
那拉提 6

终于,在经过上午去空中草原的失望之后。在河谷草原末端,登上天鹰台,便可以远眺整个那拉提。
那拉提 7
那拉提 8

山顶的小路两侧正卧着几只牛。
那拉提 9

山上还放牧着一群马。
那拉提 10

还看到一朵不知名的花。
那拉提 11

独库公路

之后就是独库公路,由于之前已经走过伊昭公路,也同样是翻山越岭,加之在那拉提镇不小心磕伤需要提前回去,所以并没能带来预期的体验。

独库公路 1

路边随手一拍。
独库公路 2

雪山草甸。
独库公路 3
独库公路 4

雪山之顶。
独库公路 5

奇形怪石。
独库公路 6

高山深壑。
独库公路 7

途径山泉。
独库公路 8

丹霞地貌。
独库公路 9
独库公路 10

天山峡谷。
独库公路 11

总结

这次旅行起于偶然,终于偶然。几乎盲目的进入新疆,完全没有计划。所有的住宿都是临时决定,所有的门票都是现场购买。这种旅行体验之前从未尝试过,但自驾旅行的话,似乎确实不需要完整的旅行计划。实在不行,睡在车上凑活一晚也不是不行。

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

2025-06-07 20:27:37

场景

最近写了几个前后端都包含的应用,从最初的 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
1753979669566.jpg

Hono starter
1753981327766.jpg

实现

谁在前面?

现在,同时使用 Hono 和 Web 元框架,例如 SvelteKit,来开发一个应用。问题来了,谁在前面?也就是说,Hono 在前面并转发路由,还是 SvelteKit 在前面并转发路由?由于下面几个特征,Hono 在前面会更好

  1. Hono 的代码更少,启动更快
  2. 元框架可能会有一些意外的行为,例如自动序列化所有 Response [8]
  3. 如果没有 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 通常增加的复杂性可能是没有必要的。

Web 流式写入文件

2025-05-28 22:08:02

背景

由于吾辈之前使用的一个域名即将到期,需要将 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 所需要的数据格式

基本流程

  1. 创建 TransformStream
  2. 使用 ReadableStream 结合 TextEncoderStream 创建 Response
  3. 立刻获取 blob,触发 ReadableStream 的拉取
  4. 使用 WritableStream 开始写入
  5. 关闭 TransformStream
  6. 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

由于 blob.stream() 返回的 chunk 可能存在截断或不完整,例如假使预期的 chunk 是按照换行分割点文本 line1\nline2\nblob.stream() 可能会返回 line1 甚至截断的 line1\nli,所以必须使用自定义的 TransformStream 来将默认的流转换为预期的按行分割的流。

  1. 用户选择文件
  2. 得到 File(Blob 子类)
  3. file.stream() 流式读取
  4. 使用 TextDecodeStream 解码 Uint8Array 为文本
  5. 自定义 LineBreakStream 根据 line 分割 chunk
  6. 流式遍历
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 尺寸的文件。

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

2025-05-06 14:23:55

背景

最近重构了个人主站,添加了作品集和博客部分,它们都使用 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 组件

实现思路

flow.excalidraw.svg

代码实现

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,吾辈只是意外一个看似常见的需求居然没有很好的现成解决方案,即便已经有人做过的事情,只要有所改进仍然可以再次创建。

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

2025-04-24 20:14:09

背景

最初是在 reddit 上看到有人在寻找可以解压 zip 文件的 Firefox 插件 [1],好奇为什么还有这种需求,发现作者使用的是环境受限的电脑,无法自由的安装本地程序。于是吾辈便去检查了现有的在线解压工具,结果却发现排名前 5 的解压工具都没有完全支持下面两点

  1. 解压大型 ZIP 文件,例如数十 G 的 ZIP
  2. 解压目录时保持目录结构

下面的视频展示了当前一些在线工具的表现

实际上,只有 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)

1745601286178.jpg

如果消费 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()
}

1745601480457.jpg

而在 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 已经可以胜任普通的文件操作,但它仍然有一些局限性,包括

  1. 用户可以选择的目录是有限制的,例如,无法直接选择 ~/Desktop 或 ~/Downlaod 目录,因为这被认为是有风险的 [10]
  2. 无法保存一些特定后缀名的文件,例如 *.cfg 或者以 ~ 结尾的文件,同样被认为有风险 [11]

总结

这是一个很早之前就有人做过的事情,但直到现在仍然可以发现一些有趣的东西。尤其是 Blob 的部分,之前从未真正去了解过它的存储方式。

基于本文探讨的技术,吾辈最终实现了一个名为 MyUnzip 的在线解压工具,欢迎访问 https://myunzip.com 试用并提出反馈。