MoreRSS

site iconrxliuli | 琉璃修改

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

Inoreader Feedly Follow Feedbin Local Reader

rxliuli | 琉璃的 RSS 预览

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

将数据导入到 Cloudflare D1

2025-01-30 09:21:28

背景

最近在实现 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 的方案上,基本流程如下

  1. 从 JSON 生成 CSV
  2. 使用 sqlite3 导入 CSV 到本地 .sqlite 文件
  3. 使用 sqlite3 dump 数据到 .sql 文件
  4. 使用 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 分钟就搞定了,还是太久不碰数据库和后端生疏了。

在 Chrome 插件中拦截网络请求

2025-01-02 22:58:59

动机

在实现 Chrome 插件 Mass Block Twitter 时,需要批量屏蔽 twitter spam 用户,而 twitter 的请求 header 包含的 auth 信息似乎是通过 js 动态生成的,所以考虑到与其检查 twitter 的 auth 信息是如何生成的,还不如拦截现有的网络请求,记录使用的所有 header,然后在调用 /i/api/1.1/blocks/create.json 接口时直接使用现成的 headers。因而出现了拦截 xhr 的需求,之前也遇到过需要拦截 fetch 请求的情况,而目前现有的库并不能满足需要。

1736254864238.jpg

已经调查的库包括

  • mswjs: 一个 mock 库,可以拦截 xhr/fetch 请求,但是需要使用 service worker,而这对于 Chrome 插件的 Content Script 来说是不可能的。
  • xhook: 一个拦截库,可以拦截 xhr 请求,但无法拦截 fetch 请求,而且最后一个版本是两年前,似乎不再有人维护了

因此,吾辈打算自己实现一个。

设计

首先,吾辈思考了自己的需求

  1. 拦截 fetch/xhr 请求
  2. 支持修改 request url 以实现代理请求
  3. 支持调用原始请求并修改 response
  4. 支持 response sse 流式响应

确定了需求之后吾辈开始尝试设计 API,由于之前使用过非常优秀的 Web 框架 hono,所以吾辈希望 API 能够尽可能的简单,就像 hono 的 Middleware 那样。

下面借用 hono 官方的 Middleware 的洋葱图 [1]

1736247259291.jpg

例如下面使用了两个 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 包中,欢迎使用。

2024,不上班的第一年

2025-01-02 22:54:49

自从 2023 年底前往日本之后,吾辈就没有再上过一天班。在日本的时候,基本没有考虑过打零工。而回国之后,则开始了间隔性的旅行和躺平。回国的主要目标之一成为独立开发者仍未实现,即使尝试过开发一些东西,但都没有找到正确的途径。而且严重缺乏输入,现实几乎没有人可以长期交流,这是一个问题,可能一直都是一个问题。

时间线回顾

1~3 月: 在日本留学,在关西地区旅行,也前往东京进行过短期旅行
4~6 月: 完成了全国旅行,虽然途径磕磕绊绊,但最终吾辈还是做到了
7 月: 在阳江租房长住,但实际上几乎没有出门玩过,月底回到老家两周
8 月: 在阳江躺平,月底终于前往了当地的海陵岛,(服务)体验一般
9 月: 月初终于继续旅行,历经衡山长沙南京镇江之后,遇到台风匆匆赶回
10 月: 前往新一家暂住一周多,之后前往云南昆明大理等地,10 月下旬返回
11~12 月: 完全在阳江躺平,并陷入了严重失调的日常生活

1736650224627.jpg

自问自答

  1. 你今年最大的成就是什么 – 第一次尝试并完成了全国旅行
  2. 你今年最大的失败是什么 – 没有找到独立开发的正确途径
  3. 你有没有遵守年初时和自己许下的约定 – 几乎没有,作息规律和独立开发都是
  4. 今年的哪个或哪些日子会铭刻在你的记忆中,为什么 – 在新疆抱团旅行的时光,和其他人一起旅行的感觉很好
  5. 明年你想要获得哪些你今年没有的东西 – 在苏州长住,独立开发/或者其他什么获得收入(无论多少)、作息规律、开始晨跑
  6. 今年你学到了什么宝贵的人生经验 – 在选择有限的情况下不要匆匆决定,它会限制视野,而且很可能不是正确的
  7. 有什么是你想要且得到了的 – 没有约束的生活
  8. 是什么让你保持理智 – Twitter/Ao3,有时候开发一些插件或应用在 Twitter 发布,以及入坑 Worm 系列,这是自 PMMM 之后花了最多时间的小说系列
  9. 在你新认识的人之中,谁是最好的 – 新一,很厉害的一位开发者,不仅尝试创业,还逐渐步入正轨,几乎就是吾辈期望达到的一种状态。而且人也特别好,允许吾辈在他那里借住
  10. 你想念哪些人 – 曾经的老师。尽管去过他家里很多次,一起吃过饭出去玩,但现在逐渐渐行渐远,平时也不再能说得上话了

总结

独立开发

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 中得到的想法,泰勒经历了更糟糕的事情,她通过晨跑来改善心情
  • 寻找新的兴趣,目前动画已经几乎不再维持,沉迷同人小说是个糟糕的兴趣,户外徒步和摄影还不错但还未长期维持过