2025-06-28 17:35:11
六月初有个去新疆自驾的机会,于是便和新一开始了二刷新疆之旅。大致路线定的是南疆环线,由于距离霍尔果斯口岸很近,所以也顺便出国去哈萨克斯坦看了看。这次的旅行体验比上次报团要好得多,主要是单个地点好玩的话可以多玩会而不再卡时间了。
首先第一站前往了赛里木湖,中间由于路途太远,所以在精河县暂住一晚,第二天继续前往赛里木湖。
首先去游客中心买票,确认了自驾是每个人 140,人车合一而非车单独计算。
将这里称作天空之镜似乎很合适。
让吾辈大吃一惊的雪堆,在 6 月中旬这个季节。
远处的湖边有人正在拍“场照”?
从赛里木湖西侧的山坡木栈道上向下俯拍,可以看到赏心悦目的风景。
雨过天晴,很幸运的看到了双彩虹。不幸的是,吾辈没有拍好它。
第二天早上,早起前往点将台等待日出。
上午的湖水无比湛蓝。
还在湖边用石头堆起了一个玛尼堆。
接下来,由于赛里木湖附近就有一个陆上口岸,加之哈萨克斯坦又是免签国家,所以之后前往了口岸城市霍尔果斯,休息一晚后第二天出发前往扎尔肯特。
霍尔果斯当地并没有什么东西,旧口岸附近有一个观光塔。
闹市之中有一处欧式建筑。
暂住一晚后第二天前往哈萨克斯坦的扎尔肯特小镇,从新国门出去。
再经过两三个小时漫长的安检审核流程之后,终于到了扎尔肯特,由于有 3 个小时的时差,所以还能吃上午饭。第一次吃如此巨大的鸡肉卷。
在路上看到的一个广告牌,有点好奇这是否就是 Local Superman?
之后前往了旅馆,但由于哈萨克斯坦是旧苏联加盟国,所以西方软件在此地不好使,打车、住宿和支付都使用俄罗斯 Yandex 系列的软件,而不是常用的 Uber/Booking/GooglePay。由于没有自驾而且也无法打车,所以步行前往了最近的清真寺。这个清真寺似乎不太清真,杂糅了中国风?
这是小镇上最大的超市,大部分本地人都来这里采购东西,甚至被作为一日游的景点了。
在路上随处可见的管道中不知道有什么,自来水?或者是天然气?
回国之后到了伊宁,六星街没什么好玩的,喀赞其民俗旅游区也因为天气原因没有去看,所以直接略过,开始翻越天山的伊昭公路之旅。
山脉之间。
尽管已经快没有信号了,但海拔三千米仍然有人卖现杀羊肉烧烤。
这是沿山而筑的一条小路,虽然危险,但风景却也是极好的。
下山后快到昭苏时,开始看到牛和羊。
这个弧线看着真的太舒服了,如果没有人就更好了。
山上还牧民的马。
吃完午饭,开始前往了昭苏附近的一个小景点:玉湖。但它却着实带来了不少惊喜,首先,它的门票人和车分开计算,一辆车只会计算一次,所以人均只有 55,与赛里木湖(140)、那拉提(200)相比,实在太划算了。
只看湖水颜色有点像西藏的羊卓雍湖。
但真正带来惊喜的不是湖水,而是景区内部公路的长度,单程至少超过 45km(没能走到终点),而景区区间车只走 25km。在后面能看到雪山、草地和成群的牛羊,风景着实不错。
往回走时看到远方层层叠叠的山有一些透明的感觉。
在中途经过特克斯八卦城住下之后,前往了非常有名的天山花海,但却大失所望,里面的花海挺壮观,但从地面上非常难拍。
成片的薰衣草花海。
蜜蜂正在采蜜。
似乎已经到末期的绳子草。
在纪念品购买的地方看到介绍天山花海似乎是个农业庄园,只是兼具旅游的功能。
与赛里木湖一样,那拉提也是二刷。上次抱团来的时候体验极其糟糕,只是乘坐区间车快速走马观花看了空中草原(盛名之下,其实难副)。这次自驾进来,在 48 小时内一共进来了 3 次,空中草原的体验仍然一般般,但河谷草原末端登上天鹰台才真正看到很棒的风景。
不知道什么时候立的一座雕像。
空中草原,也就是说,在海拔很高的山上的一片草原。
来的时候还下着淅淅沥沥的小雨,天气比较糟糕。
曾经的牧民就住在这种小房子中,牛羊在外面吃草。
一条小溪从山间流下,也许正是来自雪莲谷。
终于,在经过上午去空中草原的失望之后。在河谷草原末端,登上天鹰台,便可以远眺整个那拉提。
山顶的小路两侧正卧着几只牛。
山上还放牧着一群马。
还看到一朵不知名的花。
之后就是独库公路,由于之前已经走过伊昭公路,也同样是翻山越岭,加之在那拉提镇不小心磕伤需要提前回去,所以并没能带来预期的体验。
路边随手一拍。
雪山草甸。
雪山之顶。
奇形怪石。
高山深壑。
途径山泉。
丹霞地貌。
天山峡谷。
这次旅行起于偶然,终于偶然。几乎盲目的进入新疆,完全没有计划。所有的住宿都是临时决定,所有的门票都是现场购买。这种旅行体验之前从未尝试过,但自驾旅行的话,似乎确实不需要完整的旅行计划。实在不行,睡在车上凑活一晚也不是不行。
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
基本流程
10 行代码即可验证
1 |
|
相比之下,流式读取使用的 API 要更少,只需要使用 blob.stream()
即可流式读取一个 Blob(或者一个一个 File)。几个关键的 API
由于 blob.stream()
返回的 chunk 可能存在截断或不完整,例如假使预期的 chunk 是按照换行分割点文本 line1\nline2\n
,blob.stream()
可能会返回 line1
甚至截断的 line1\nli
,所以必须使用自定义的 TransformStream 来将默认的流转换为预期的按行分割的流。
1 |
|
然后来验证它是否有效,下面写入了 3 个不规则的 chunk,但最终得到的结果仍然是 [ "line1", "line2" ]
,也就是说,LineBreakStream 生效了。
1 |
|
现在,来使用它读取 Blob 就很简单了。
1 |
|
在浏览器中创建和读取大型文本文件似乎是个小众的需求,但如果确实需要,现代浏览器确实可以处理。考虑到之前做过的在线压缩工具,确认甚至可以处理数十 GB 尺寸的文件。
2025-05-06 14:23:55
最近重构了个人主站,添加了作品集和博客部分,它们都使用 markdown 来编写内容。而直接引入 react-markdown [1] 组件在运行时编译 markdown 不仅成本较高,要添加一系列的 unified 依赖来进行编译 markdown,还要引入相当大的 shikijs [2] 来实现代码高亮。经过一些快速测试,打算尝试使用预编译 markdown 为 html 的方式来解决这个问题。
首先,吾辈尝试了几个现有的工具。
而且由于吾辈还需要在编译时就获取 markdown 的一些元数据,例如 frontmatter/toc 等等,所以最终考虑基于 unified.js 自行封装 vite 插件来处理。
基本上,吾辈决定遵循 vite 的惯例 [3],即通过 import query 来支持不同的导入,例如
1 |
|
实现思路
1 |
|
要在 TypeScript 中使用,还需要在 vite-env.d.ts 中添加一些额外的类型定义,让 TypeScript 能正确识别特定文件名及后缀。[4]
1 |
|
这里碰到了一个问题,如何将转换 markdown 为编译后的 jsx。例如
1 |
|
希望得到的是
1 |
|
是的,吾辈尝试先将 markdown 转换为 html,然后使用 esbuild 编译 jsx。不幸的是,html 与 jsx 不完全兼容。即便解决了 html/jsx 兼容问题,再将 jsx 编译为 js 时仍然可能存在问题,例如 react-element-to-jsx-string [5] 是一个常见的包,但它也存在一些问题,例如处理 code block 中的 ‘\n’ 时会自动忽略,导致编译后的代码不正确。
最终,吾辈决定直接转换 react element 为 js 字符串,本质上它也只是一个字符串拼接罢了,远没有想象中那么复杂。
1 |
|
目前,完整功能在 unplugin-markdown [6] 实现并发布至 npm,吾辈只是意外一个看似常见的需求居然没有很好的现成解决方案,即便已经有人做过的事情,只要有所改进仍然可以再次创建。
2025-04-24 20:14:09
最初是在 reddit 上看到有人在寻找可以解压 zip 文件的 Firefox 插件 [1],好奇为什么还有这种需求,发现作者使用的是环境受限的电脑,无法自由的安装本地程序。于是吾辈便去检查了现有的在线解压工具,结果却发现排名前 5 的解压工具都没有完全支持下面两点
下面的视频展示了当前一些在线工具的表现
实际上,只有 ezyZip 有点接近,但它也不支持解压 ZIP 中的特定目录。
在简单思考之后,吾辈考虑尝试使用时下流行的 Vibe Coding 来创建一个 Web 工具来满足这个需求。首先检查 zip 相关的 npm 包,吾辈之前经常使用的是 jszip,但这次检查时发现它的不能处理大型 ZIP 文件 [2]。所以找到了更快的 fflate,但遗憾的是,它完全不支持加密解密功能,但作者在 issue 中推荐了 zip.js [3]。
官网给出的例子非常简单,也非常简洁明了。如果是解压文件并触发下载,只需要结合使用 BlobWriter/file-saver 即可。
1 |
|
这段代码出现了一个有趣之处:BlobWriter
,它是如何保存解压后的超大型文件的?毕竟数据总要在某个地方,blob 似乎都在内存中,而且也只允许流式读取而不能流式写入。检查一下 GitHub 上的源代码 [4]。
1 |
|
是的,这里的关键在于 Response
,它允许接受某种 ReadableStream [5] 类型的参数,而 ReadableStream 并不保存数据到内存,它只是一个可以不断拉取数据的流。
例如下面手动创建了一个 ReadableStream,它生成一个从零开始自增的无限流,但如果没有消费,它只会产生第一条数据。
1 |
|
如果消费 100 次,它就会生成 100 个值。
1 |
|
而在 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 |
|
尽管 File System API 已经可以胜任普通的文件操作,但它仍然有一些局限性,包括
*.cfg
或者以 ~
结尾的文件,同样被认为有风险 [11]
这是一个很早之前就有人做过的事情,但直到现在仍然可以发现一些有趣的东西。尤其是 Blob 的部分,之前从未真正去了解过它的存储方式。
基于本文探讨的技术,吾辈最终实现了一个名为 MyUnzip 的在线解压工具,欢迎访问 https://myunzip.com 试用并提出反馈。
2025-04-03 15:56:35
最近在做一些服务端相关的事情,使用了 Cloudflare Workers + D1 数据库,在此过程中,遇到了一些数据库相关的问题,而对于前端而言数据库是一件相当不同的事情,所以在此记录一下。
下图是最近 30 天的请求记录,可以看到数据库查询变化之剧烈。
解决问题的前提是发现问题,有几个方法可以更容易留意到相关问题。
c.env.DB.prepare('<sql>').run()).meta
并检查返回的 meta,它包含这个 sql 实际读取/写入的行数首先明确一点,Workers 和 D1 虽然同为 Cloudflare 的服务,但同时使用它们并不会让 D1 变得更快。拿下面这个简单的查询举例,它的平均响应时间(在 Workers 上发起查询到在 Workers 上得到结果)超过了 200ms。
1 |
|
所以在一个接口中包含大量的数据库操作时,应该尽量使用 d1 batch 来批量完成,尤其是对于写入操作,由于没有只读副本,它只会比查询更慢。例如
1 |
|
应该更换为
1 |
|
这样只会向 d1 发出一次 rest 请求即可完成多个数据库写入操作。
ps1: prisma 不支持 d1 batch,吾辈因此换到了 drizzle 中,参考 记录一次从 Prisma 到 Drizzle 的迁移。
ps2: 使用 batch 进行批量查询时需要小心,尤其是多表有同名的列时,参考 https://github.com/drizzle-team/drizzle-orm/issues/555
在 update 时应该排除 id(即使实际上没有修改)。例如下面的代码,将外部传入的 user 传入并更新,看起来没问题?
1 |
|
实际执行的 SQL 语句
1 |
|
然而,一旦这个 id 被其他表通过外键引用了。它就会导致大量的 rows read 操作。例如另一张名为 tweet 的表有一个 userId 引用了这个字段,并且有 1000 行数据。
1 |
|
然后进行一次 update 操作并检查实际操作影响的行数
1 |
|
可以看到 rows read 突然增高到了 2005,而预期应该是 1,考虑一下关联的表可能有数百万行数据,这是一场噩梦。而如果确实排除了 id 字段,则可以看到 rows read/rows written 确实是预期的 1,无论它关联了多少数据。
1 |
|
可以说这是个典型的愚蠢错误,但前端确实对数据库问题不够敏锐。
吾辈在 D1 仪表盘中看到了下面这个 SQL 语句在 rows read 中名列前矛。像是下面这样
1 |
|
可能会在仪表盘看到 rows read 的暴增。
这导致了吾辈在实现分页时直接选择了基于 cursor 而非 offset,而且永远不会给出总数,因为即便 id 有索引,统计数量也会扫描所有行。这也是一个已知问题:https://community.cloudflare.com/t/full-scan-for-simple-count-query/682625
起因是吾辈注意到下面这条 sql 导致了数十万的 rows read。
1 |
|
下面是对应的 ts 代码
1 |
|
可以看到这里连接了 4 张表查询,这种愚蠢的操作吾辈不知道当时是怎么写出来的,也许是 LLM 告诉吾辈的 😂。而吾辈并未意识到这种操作可能会导致所谓的“笛卡尔积爆炸”[1],必须进行一些拆分。
“笛卡尔积爆炸”是什么?在这个场景下就吾辈的理解而言,如果使用 leftJoin 外连多张表,并且外联的字段相同,那么就是多张表查询到的数据之和。例如下面这条查询,如果 modListUser/modListRule 都有 100 条数据,那么查询的结果则有 100 * 100 条结果,这并不符合预期。
1 |
|
而如果正确的拆分查询并将数据分组和转换放到逻辑层,数据库的操作就会大大减少。
1 |
|
如果 rows written 数量不多,或者没有批处理的需求,那这可能只是过早优化。
这是在优化写入性能时尝试的一个小技巧,可以提升批量写入的性能。考虑下面这个批量插入的代码
1 |
|
嗯,这只是个愚蠢的例子,当然要使用 batch 操作,就像上面说的那样。
1 |
|
但是否忘记了什么?是的,数据库允许在一行中写入多条数据,例如:
1 |
|
不幸的是,sqlite 允许绑定的参数数量有限,D1 进一步限制了它 [2],每次参数绑定最多只有 100 个。也就是说,如果我们有 10 列,我们最多在一条 SQL 中插入 10 行,如果批处理数量很多,仍然需要放在 batch 中处理。
幸运的是,实现一个通用的自动分页器并不麻烦,参考 https://github.com/drizzle-team/drizzle-orm/issues/2479#issuecomment-2746057769
1 |
|
那么,我们实际获得性能收益是多少?
就上面举的 3 个例子进行了测试,每个例子分别插入 5000 条数据,它们在数据库执行花费的时间是
78ms => 37ms => 14ms
吾辈认为这个优化还是值得做的,封装之后它对代码几乎是无侵入的。
服务端的问题与客户端相当不同,在客户端,即便某个功能出现了错误,也只是影响使用者。而服务端的错误可能直接影响月底账单,而且需要一段时间才能看出来,因此需要小心,添加足够的单元测试。解决数据库查询相关的问题时,吾辈认为遵循 发现 => 调查 => 尝试解决 => 跟进 => 再次尝试 => 跟进 => 完成 的步骤会有帮助,第一次解决并不一定能够成功,甚至有可能变的更糟,但持续的跟进将使得及时发现和修复问题变得非常重要。