MoreRSS

site iconrxliuli | 琉璃修改

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

Inoreader Feedly Follow Feedbin Local Reader

rxliuli | 琉璃的 RSS 预览

在 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 试用并提出反馈。

Cloudflare D1 数据库查询优化之路

2025-04-03 15:56:35

背景

最近在做一些服务端相关的事情,使用了 Cloudflare Workers + D1 数据库,在此过程中,遇到了一些数据库相关的问题,而对于前端而言数据库是一件相当不同的事情,所以在此记录一下。

下图是最近 30 天的请求记录,可以看到数据库查询变化之剧烈。

1743670296635.jpg

发现问题

解决问题的前提是发现问题,有几个方法可以更容易留意到相关问题。

  1. 检查 D1 仪表盘,确定数据库操作是否有异常增长
  2. 检查查询语句及读取/写入行数,特别关注 count/rows read/rows written 排在前列的查询
  3. 使用 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 的暴增。

1743677991276.jpg

这导致了吾辈在实现分页时直接选择了基于 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

吾辈认为这个优化还是值得做的,封装之后它对代码几乎是无侵入的。

总结

服务端的问题与客户端相当不同,在客户端,即便某个功能出现了错误,也只是影响使用者。而服务端的错误可能直接影响月底账单,而且需要一段时间才能看出来,因此需要小心,添加足够的单元测试。解决数据库查询相关的问题时,吾辈认为遵循 发现 => 调查 => 尝试解决 => 跟进 => 再次尝试 => 跟进 => 完成 的步骤会有帮助,第一次解决并不一定能够成功,甚至有可能变的更糟,但持续的跟进将使得及时发现和修复问题变得非常重要。

转换 Chrome Extension 为 Safari 版本

2025-03-13 08:33:23

背景

这两天吾辈开始尝试将一个 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 并运行这个扩展。

1741855496643.jpg
1741855508793.jpg

然而,打开 Safari 之后默认不会看到刚刚 build 的扩展,因为 Safari 默认不允许运行未签名的扩展 [3]
1741855578407.jpg

需要设置 Safari

  1. 选中 Safari > Settings > Advanced > Show features for web developers
    1741855703402.jpg
  2. 选中 Safari > Settings > Developer > Allow unsigned extensions
    1741855678560.jpg

此时,如果你像吾辈一样之前安装然后卸载过这个扩展的话,需要手动使用 --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 中查看并启用临时扩展了。
1741858042811.jpg

启用它,然后就可以在 Safari Toolbar 中看到扩展图标并进行测试了。
1741858161233.jpg

如果你发现 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 或在其他地方发布扩展,无论哪种方式,都需要正确配置签名证书。

  1. 首先修改 Project > Build Settings > Checkout ‘All’ & ‘Combined’ > Search ‘team’ > Development Team
    1741836712437.jpg
  2. Mac/iOS Targets > General > Identity > App Category 选择产品类型
    1741837528537.jpg

在发布之前,还需要手动指定版本,因为它并不跟随 manifest 中指定的版本,而是单独的,建议在转换之后就指定。
1741862590410.jpg

该配置也在 <project name>/<project name>.xcodeproj/project.pbxproj 文件中,可以搜索并替换 MARKETING_VERSION = 1.0;

然后从 Xcode 工具栏选择 Product > Archive,就可以构建一个 bundle 并等待分发了。
1741862602934.jpg

首先点击 Validate App 确保 bundle 没有什么配置错误。这里遇到了一个错误,提示吾辈的扩展名(不是扩展 id)已存在,需要使用一个其他的名字。
1741862725322.jpg

好的,让我们修改 manifest 中的名字并重复以上转换和构建流程,重新验证,没有发现错误。
1741863485137.jpg

接下来就可以分发 App 了,点击 Distribute App,然后选择 App Store Connect 在 App Store 上架或 Direct Distribute 公证插件。
1741864293656.jpg

吾辈发现这个视频很有帮助 https://youtu.be/s0HtHvgf1EQ?si=rbzc88E1Y_6nZY6k

完善发布信息

最后,还需要前往 https://appstoreconnect.apple.com/apps 完善发布信息,包括截图、隐私政策和定价等等。吾辈没有意识到 Apple 使用网页来管理 App 发布,这与 Apple 万物皆可 App(App Developer/TestFlight) 的风格似乎不太相像,因此白白苦等了 2 周。

1743476029592.jpg

吾辈还发现 App 描述中禁止使用 emoji 字符,否则会提示 The message contains invalid characters.

总结

Mac/iOS 开发是非常封闭的平台,开发工具与体验与 Web 大有不同,但考虑到 Safari 是 Mac 上默认的浏览器,而在 iOS 上更是无法修改的事实上的标准,可能还是值得投入一些精力去支持它,尽管它甚至比 Firefox 更加糟糕。

svelte5:一个更糟糕的 vue3

2025-03-04 22:05:04

背景

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

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--
},
})

印象中这几种写法在 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 却特别严重,包括

社区反应

每当有人抱怨 svelte5 变得更复杂时,社区总有人说你是用 svelte5 编写 hello world 的新手、或者说你可以继续锚定到 svelte4。首先,第一点,像吾辈这样曾经使用过 react/vue 的人而言,svelte4 看起来很简单,吾辈已经用 svelte4 构建了一些程序,它们并不是 hello world,事实上,可能有 10k+ 行的纯代码。其次,锚定到旧版本对于个人是不可能的,当你开始一个新项目的时候,几乎没有办法锚定到旧版本,因为生态系统中的一切都在追求新版本,旧版本的资源很难找到。


就在吾辈发布完这篇博客之后,立刻有人以 “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 太久了,还是想要找点新的东西看看。

记录一次从 Prisma 到 Drizzle 的迁移

2025-02-23 11:40:21

背景

最近使用 Cloudflare D1 作为服务端的数据库,ORM 选择了很多人推荐的 Prisma,但使用过程中遇到了一些问题,主要包括

  1. 不支持 D1 的 batch 批处理,完全没有事务 https://www.prisma.io/docs/orm/overview/databases/cloudflare-d1#transactions-not-supported
  2. 不支持复杂查询,例如多表 Join SQL 语法 https://github.com/prisma/prisma/discussions/12715
  3. 单次查询很慢,通常在 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%,这听起来并不那么不可思议了。

image (26).png

有人做了一些测试,似乎批量插入 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(),
})

这里的自动转换有几个问题

  1. sql`uuid()` 在 prisma 中由应用抽象层填充,但 schema.ts 里使用 sql`uuid()`,这里应该同样由应用抽象层填充
  2. updatedAt 有相同的问题,schema.ts 里使用了 sql`CURRENT_TIMESTAMP`
  3. 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 两张表

id screenName name
user-1 user-screen-name user-name
id name userId
modlist-1 modlist-name user-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 了。

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

2025-02-16 20:31:07

背景

最近开发一个跨浏览器的扩展时,由于需要在 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 错误。

1739867422408.jpg

如果你将相同的代码放在扩展的 Content Script 中,然后在 Chrome 中测试扩展,一切正常。

1739869145008.jpg

而在 Firefox 中,嗯,你仍然会得到一个 CSP 错误。

1739869071702.jpg

如果你使用 Manifest V2,Firefox 则会正常放过,并且不会显示在 Network 中。吾辈甚至不想知道它做了什么。

1739869022575.jpg

经过一番调查,吾辈成功找到了相关的 issue,而它们均创建于 9 年前,最新的讨论甚至在 4 天前。检查下面两个 issue。

思路

那么,问题就在这儿,看起来也无法在短期内解决,如果想要让自己的扩展支持 Firefox,现在应该怎么办?
好吧,基本思路有 2 个:

  1. 绕过去,如果 Content Script 无法不能请求,为什么不放在 Background Script 中然后加一层转发呢?
  2. 如果网站的 CSP 有麻烦,为什么不使用 declarativeNetRequest/webRequestBlocking API 来修改或删除它们呢?

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 都不是完全可以序列化的,这会有点麻烦。

1739871065758.jpg

使用 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 已经不复存在,可以看到浏览器也不会拦截你的请求了。但是,这种方法的主要问题是网站安全性受损,就这点而言,这不是一个好方法。

1739874302081.jpg
1739874464160.jpg

参考

一般而言,推荐使用 Background Script 转发请求,尽管它要编写更多的样板代码,吾辈也就此在 WXT 上问过框架作者,他似乎一般也会这样做。

参考: https://github.com/wxt-dev/wxt/discussions/1442#discussioncomment-12219769