MoreRSS

site iconrxliuli | 琉璃修改

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

Inoreader Feedly Follow Feedbin Local Reader

rxliuli | 琉璃的 RSS 预览

在构建时而非运行时编译 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 试用并提出反馈。

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 了。