MoreRSS

site iconChengPeiquan | 程沛权修改

前端,飞牛私有云,广州,花臂,玩贝斯,养了三只猫。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

ChengPeiquan | 程沛权的 RSS 预览

Obsidian x 飞牛 NAS:打造免费的跨平台笔记同步与备份方案

2025-10-08 23:42:03

从 2019.08.01 重新开始写日记,到今天居然坚持了 6 年了,一开始是记录在一款云笔记 App 上,直到四个月前陆陆续续把数据迁移到自己家里的 NAS ,把数据爬回来才发现居然接近 8 GB …… 一直觉得好像都是文字为主,没想到也配了不少图片,重新看的时候生活还挺丰富多彩的哈哈哈!

这里的日记就很纯粹的记录生活,不是什么读书笔记、技术笔记等等,那一类的记录对我来说都属于生活之外的事情,冷冰冰没有个人感情,生活日记记录了我每一段生活的喜怒哀乐,还有各种胡思乱想,闲着无聊的时候翻一翻,能够回顾自己过往的生活和成长,别有一番乐趣。

哈哈哈很多都是跟猫生活的美好瞬间

早期的方案

2019 年重新开始写日记那会,我当时的需求主要是晚上睡觉前用手机记录当天的日记,并且有网页版或者桌面客户端可以平时在电脑上看一看稍微管理一下就足够,但那个时候还没什么特别流行的跨平台笔记方案,而且大部分流行的笔记 App 都是广告很多。

加上当时还没有开始接触 NAS ,也没有选择自建存储的想法,后面选择了一款界面比较清爽、自我感觉用户体量比较小的 App(猜的…… ),由于那家公司本职不是做这个笔记 App 产品,所以对 App 利益相关的运营干涉不是太多,所以 “清爽的体验” 一用就这么用了五年多。

但随着长时间深入使用,这也变成了我不想用它的原因,由于 App 不够被重视,以至于多年未维护更新,产生的 BUG 也没有修复,一度让我自己想抽空做一个客户端自己用(根据它的 BUG 表现推测是比较老版本的 React Native 做的)。

这款 App 的用户体验让我越来越难以忍受

新的迁移方案

不过由于工作太忙, “做一个情怀客户端自己用” 这个事情也就一直放着,可以看到我是 23 年开的 Project ,直到现在都没动哈哈哈。

不过拖延症有拖延症的好处,因为这几年陆陆续续听到 Obsidian 、 Logseq 、 Joplin 、 Notion ,以及国产的为知笔记、思源笔记,以及语雀、飞书文档这一类不太纯笔记但也提供了很优秀的笔记功能的产品,有这么多现成的,干嘛还自己搞呢?

而且在开 Project 的那段时间,也开始玩起了 NAS (详见 我的第一台 NAS 一文),玩熟悉之后,用 NAS 来存储这些相对敏感的数据,应该说是最好的选择了。

迁移之前的考虑

有了数据迁移的想法,先梳理看看自己都需要些啥功能。

明确自己的需求

在现阶段,用 NAS 作为数据存储是已经确定的事情,剩下的不确定因素主要还是客户端方案的选择。

需求点 说明
私有化部署 考虑到自己当前的需求主要是个人日记,偏隐私,我选择将数据存放到我的 NAS 上
多平台客户端 我自己主要设备是需要有 iOS App 和 macOS App (或者 Web )
客户端响应快 不论是启动速度,还是搜索速度,因为写了 6 年,几千篇笔记,速度方面还是有要求的
交互体验好 想换掉之前的方案就是因为体验太差了,至少不能再有我上面提到的那些问题
界面颜值高 我当时选那个 App 的原因就是清爽,虽然没有暗模式,但亮模式的 UI 很像 Shadcn UI
多端同步 最重要的功能,可以直接与我的 NAS 进行数据传输

客户端之间的选择

支持私有部署的 “单机笔记” 方面,主流的就是 Obsidian 、 Logseq 、 Joplin 、为知笔记、思源笔记 这几款。

基于社区评价、私有化部署的内存占用( NAS 比较重视)、客户端颜值等维度对比后,就剩下 Obsidian 和 Logseq 两者, Logseq 是比 Obsidian 要晚一点推出的产品,所以它具备了 OB 的一些优点,同时还具有双向链接、块引用等更现代化的功能。

不过社区普遍认为 Logseq 在处理大量笔记时性能稍弱一些,而它基于大纲的组织方式,对我目前以 “日记” 为主的使用场景来说略显复杂。

未来如果要记录其他类型的内容,我可能会选择 Logseq 来做区分,但目前还是选了更为经典的 Obsidian 。

目前的方案

接下来讲讲我目前确定下来的具体架构和配置。

多端同步架构

经过前面的方案对比之后,我的笔记方案整体架构非常简单。

  • NAS 作为数据存储中心,并通过自带的 WebDAV 服务对外同步
  • 多端则使用 Obsidian 官方提供的桌面和手机客户端,配好对应的同步功能即可和 NAS 进行数据对接

以 NAS 为中心的架构图

在同步方案的选择上,基于 NAS ,就毫无悬念选 WebDAV 了:

  • ✅ 数据完全私有,不依赖第三方服务
  • ✅ 局域网内同步速度极快,也可以配合 DDNS 或内网穿透实现外网访问
  • ✅ 理论上无限容量,无需额外的订阅成本(硬盘成本算到 NAS 的购买成本里了)
  • ✅ Obsidian 有对应的插件支持 WebDAV

其他方案对比

不过 Obsidian 也有其他的同步方案,这里也顺便记录下查过的方案对比,供参考:

方案 优点 缺点
Obsidian Sync 官方方案,稳定可靠 需要订阅,最低 $4 / 月,并且限制 1 GB 上限,单个文件不能超过 5 MB
iCloud Drive 苹果生态无缝集成,有基础的免费容量 真正用起来的话需要订阅,最低 ¥6 / 月可以达到 50 GB ,否则只有 5 GB 可用,另外注意这些方便服务仅限苹果设备
网盘 主流云盘,稳定,有基础的免费容量 免费版速度慢,容量小,也是需要订阅提升体验,但依然有容量限制、文件大小限制
Git 同步 免费,版本控制强大,文本 Diff 对比速度快 配置复杂,不适合非技术用户;仅对纯文本友好,不适合托管图片、视频多的笔记内容
WebDAV (NAS) 服务免费,内置服务开箱即用,局域网速度超快 需要 NAS 设备(一次性硬件投入)

飞牛同步客户端

在这里还要提及一个特别的同步方案,那就是飞牛同步,支持 Windows 和 macOS 平台。

飞牛同步的优势在于:

  • ✅ 飞牛官方团队维护,与飞牛 NAS 深度集成
  • ✅ 配置相对简单,对非技术用户更友好
  • ✅ 针对飞牛 NAS 优化,同步性能可能更好

可以在官网下载 飞牛同步客户端

飞牛同步客户端登录界面

不过目前飞牛同步还只有 PC 版本,还没有移动端版本支持。对于我这种需要在 iPhone 上记录日记的场景,WebDAV 方案配合 Obsidian App 是更合适的选择。

但如果你的主要使用场景是桌面端同步文件(不限于 Obsidian ),或者希望有更简单的配置流程,飞牛同步也是一个值得考虑的选择。

使用体验

先说说目前方案的使用体验吧,如果你觉得这套方式也适合自己,再继续往下看配置部分。

旧数据迁移

我之前用的那个笔记 App 不支持导出数据,所以我是通过 DevTools 查看它的 API 请求过程,写了个爬虫把笔记内容爬了回来。

对方的 API 返回的是 HTML ,因此本地又编写了一个 HTML 转 Markdown 的工具进行格式转换(推荐 Remark 系列工具包)。

笔记中的图片原本也是远程 URL ,我在爬取时一并下载到本地,并按日期文件夹归档,再将笔记里的引用路径改成相对路径指向本地图片,当然这些工作也是用脚本处理的。

具体细节这里就不展开了,前端同学对这种流程应该不陌生,而且爬虫这东西也不太方便公开细说。

使用感受

写这篇博客时,我已经迁移到 Obsidian + 飞牛 NAS 四个月了,总体体验可以说是非常满意:

优点:

  • 速度快:局域网同步几乎是秒传,搜索 2000+ 笔记也很流畅
  • 稳定性好:至今未出现同步失败或数据丢失
  • 界面清爽:Obsidian 的界面简洁干净,用起来非常舒适
  • 插件丰富:几乎所有功能都有对应插件支持自定义
  • 数据安全:可以只在局域网内同步,数据不出网更安全
  • 备份方便:在 NAS 端还可以通过 “文件备份” 功能自动备份到其他存储位置

需要注意的点:

  • ⚠️ 初次配置有门槛:NAS 端配置简单,但 Obsidian 的客户端选项较多,需要点时间摸索
  • ⚠️ 外网访问速度:若 NAS 没有公网 IP ,使用 FN Connect 免费版的访问速度可能偏慢(可考虑付费版)
  • ⚠️ 移动端容量:Obsidian 是本地化编辑器,所有文件都会存储在设备上,需确保手机空间充足
  • ⚠️ 预防同步冲突:多设备同时编辑时,仍需留意冲突问题

多端同步配置

接下来讲讲怎么围绕 NAS 这个数据中心实现多端同步,主要以飞牛 NAS 端,以及 Obsidian 桌面客户端,先把流程跑通了,在 Obsidian App 的设置也是一样的。

飞牛 NAS 配置

下面的配置步骤都是以 Web 端的操作为例,在飞牛的 App 操作也是类似,按顺序操作即可,需要注意的是,请使用管理员账号 登录飞牛 NAS ,而不要使用普通账号,很多操作需要管理员才可以设置。

创建笔记存储目录

建议在 “文件管理” 中创建一个专门的文件夹,作为数据的存储根目录,比如 database ,这样其他类似的数据托管都可以存档在该文件夹里。

真正存放数据的地方,可以根据需要再建一层目录,例如我的日记是放在 databasediary 下。

重要数据建议存放在一个有数据保护的存储空间下,预算不高的话可以像我一样,用两块 2TB 的硬盘创建一个 RAID 1 。

创建数据根目录

配置 WebDAV 服务

飞牛 NAS 自带 WebDAV 服务,访问 “系统设置 → 文件共享协议 → WebDAV ” ,启用服务。默认的 HTTP 端口号 5005 / HTTPS 端口号 5006 可以直接使用,也可以自行修改。

再点击 WebDAV 界面上的 “设置可见文件夹范围” ,设置允许通过 WebDAV 访问的目录范围,根据自己的需要去限制范围,记得包含刚才的数据文件夹就行。

在 NAS 的 WebDAV 配置界面上,可以看到自己的访问地址是

  • http://{你的 NAS 内网 IP}:{端口号}/
  • http://{你的 NAS 域名}:{端口号}/

注意,使用 HTTP 或 HTTPS 时,两者的端口号是不一样的

如果需要使用域名访问 WebDAV ,可以在 “系统设置 → 远程访问” 管理 FN Connect 账号,或者在 DDNS 配置域名,具体在这里不过多介绍,飞牛的界面操作还是很清晰的。

系统设置 - 文件共享协议 - WebDAV

Obsidian 桌面客户端配置

在 Obsidian 中,根据最流行的免费同步方案,使用了 Remotely Save ,另外为了统一处理 Markdown 的内嵌文件路径(图片、视频等)的存放位置,我同时使用了 Custom Attachment Location 插件。

仓库与日记设置

启动 Obsidian 后会引导创建一个笔记仓库,其实就是在电脑里选择一个文件夹存档这些笔记,所选的文件夹对于这个仓库来说也是一个根目录的概念。

如果和我一样是用来写日记的,或者是想类似日记一样在同一个文件夹里存档笔记,并且有自己的固定笔记模板,那么可以在 “日记” 设置一些存档规则,例如我选择了将所有日记都归类到 docs 文件夹,而日记模板则归类在 template 文件夹下(模版需要具体到某一个文件的路径)。

在 Obsidian 的日记设置界面,以及 Mac 对应的目录和文件

内部链接设置

Obsidian 对 Markdown 的链接和图片引用默认是它自己的语法,为了以后兼容其他客户端,建议这里也改成 “基于当前笔记的相对路径” 。

将内部链接规则修改为 ”基于当前笔记的相对路径“

安装插件

插件好像都是从 GitHub 安装的,所以需要确保所处的网络环境可以顺利打开 GitHub 。

  1. 打开 Obsidian 设置 → 第三方插件 → 关闭 “安全模式”
  2. 点击「浏览」搜索 Remotely Save (必要)和 Custom Attachment Location(可选)
  3. 安装对应的插件并启用

同步插件配置

同步插件是核心插件,使用的是 Remotely Save ,这里有几项需要设置:

设置项 如何设置
远程服务 选择 WebDAV
服务器地址 从飞牛 NAS 复制 WebDAV 的访问地址,拼接数据库文件夹路径,例如 http://192.168.8.8:5005/database
用户名 飞牛 NAS 里,这个 database 文件夹的归属账号的用户名
密码 飞牛 NAS 里,这个 database 文件夹的归属账号的密码
并行度 由于我只使用局域网同步,所以我开到了最大,目前可以设置 20

其他的就根据实际需要调整,或者保持默认就可以了。

Remotely Save

附件插件配置

辅助插件目前只用了一个附件管理的 Custom Attachment Location ,从前面的仓库与日记设置可以看到我还有一个 assets 文件夹,这是因为我每天的日记除了文字,还带有不少图片,有时候还会贴视频,如果没有合理归档这些附件,混在一起就太难维护了。

设置项 如何设置
Location for new attachments 我配置了 Location for new attachments../assets/${noteFileName} ,这样每一篇日记的附件都会归档到 assets 文件夹下的 “笔记文件名” 文件夹里
Should rename attachment folder 如果笔记对应的 Markdown 文件修改了命名,它会监听并重命名这个附件文件夹,虽然我几乎不改,但一旦修改,这个功能确实挺省事的

其他选项可以根据自己需要修改,例如图片自动重命名的一些功能。

主要设置 Location for new attachments 选项

Obsidian 移动设备配置

我的常用移动设备是 iPhone ,所以直接在 App Store 搜索 Obsidian 即可找到客户端下载,也可以在官网找到其他端的下载。

安装好 App 后,在 iPhone 或其他设备上重复上述步骤进行配置。

总结

日常写笔记时,我的习惯是只在一端更新,写完后同步回 NAS ,下次在另一台设备启动 Obsidian 时,插件会自动比对版本,从 NAS 拉取最新数据,实现双向同步,这种方式在实际使用中没有出现文件冲突,整体体验非常稳定。

从 2019 年开始在第三方 App 上写日记,到现在用 Obsidian + 飞牛 NAS 搭建私有笔记系统,这 6 年的数据迁移总算告一段落。

这套方案在技术上并不复杂,但在内容管理理念上是一次很大的升级:

  • 数据完全自控,还可以在 NAS 上定期备份或迁移
  • 所有数据本地均有存档,离线也能完整访问与编辑
  • Markdown 格式通用、可扩展,不受笔记客户端约束

经过几个月使用,无论是多端同步、搜索速度还是文件安全,都比以往的云笔记方案更可靠,如果你也希望让笔记系统更可控、更长期可维护,这套组合值得一试。

适用于 ESLint V9 的现代化扁平化配置

2025-03-14 00:35:02

ESLint v9.0.0 是 ESLint 的一个主要版本,它有几个重大变化,其中最大的变化是其配置文件和插件生态系统的使用,可以通过官方网站的 迁移到 v9.x 文档了解如何迁移。

这里有一个关于 ESLint V9 的扁平化配置的 npm 包,内置了一些个人常用的 ESLint 配置,这也是我在 GitHub 上发布的一个开源项目。如果它对您有帮助,请 给它一个 Star

一款现代化的扁平 ESLint 配置,适用于 ESLint V9 ,由 @chengpeiquan 精心打造。

⚡ 使用方法

使用此 ESLint 配置仅需三步:

  1. 安装依赖(参考:🚀 安装
  2. 添加 ESLint 配置文件(参考:📂 配置文件
  3. 在 VS Code 的 settings.json 启用自动 Lint(参考:🛠 VS Code 配置

这个快速指南可以作为入门辅助,避免遗漏关键步骤 🚀 。

🚀 安装

使用常用的包管理器安装该包:

npm install -D eslint @bassist/eslint-config

注意: 需要 ESLint 版本 >= 9.0.0 ,以及 TypeScript 版本 >= 5.0.0

如果使用的是 pnpm,建议在项目根目录添加 .npmrc 文件,并包含以下配置,以更顺利地处理 peer 依赖:

shamefully-hoist=true
auto-install-peers=true

如果仍在使用 ESLint v8,请参考旧版(已不再维护)包:@bassist/eslint

📂 配置文件

在项目根目录创建 eslint.config.js 文件:

// eslint.config.js
import { imports, typescript } from '@bassist/eslint-config'

// 导出一个包含多个配置对象的数组
export default [...imports, ...typescript]

然后在 package.json 中添加 "type": "module" :

{
  "type": "module",
  "scripts": {
    "lint": "eslint src",
    "lint:inspector": "npx @eslint/config-inspector"
  }
}

运行 npm run lint 以检查代码,或运行 npm run lint:inspectorhttp://localhost:7777 查看可视化的 ESLint 配置。

对于 TypeScript 配置文件(例如 eslint.config.ts ),需要 额外的设置

# 为 Node.js 提供运行时 TypeScript 和 ESM 支持
# 才可以使用 `eslint.config.ts` 作为配置文件
npm install -D jiti

✅ 类型安全的配置

为了增强类型安全性,可以使用 defineFlatConfig:

// @ts-check
import { defineFlatConfig, imports, vue } from '@bassist/eslint-config'

export default defineFlatConfig([
  ...imports,
  ...vue,
  // 添加更多自定义配置
  {
    // 为每个配置提供名称,以便在运行 `npm run lint:inspector` 时,
    // 可以在可视化工具中清晰展示
    name: 'my-custom-rule/vue',
    rules: {
      // 例如:默认情况下,该规则是 `off`
      'vue/component-tags-order': 'error',
    },
    ignores: ['examples'],
  },
])

🛠 VS Code 配置

在 VS Code 工作区的 settings.json 添加以下配置,以启用自动 Lint 修复:

{
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "always",
    "source.fixAll.prettier": "always"
  },
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "eslint.useFlatConfig": true,
  "eslint.format.enable": true,
  "eslint.validate": [
    "javascript",
    "javascriptreact",
    "typescript",
    "typescriptreact"
  ],
  "prettier.configPath": "./.prettierrc.js"
}

关于 prettier.configPath 请查看 格式化工具 部分。

📘 API 参考

defineFlatConfig

定义 ESLint 配置,可选支持 Prettier 和 Tailwind CSS。

API 类型声明:

/**
 * 定义 ESLint 配置,可选支持 Prettier 集成。
 *
 * @param configs 基础 ESLint 配置数组。
 * @param options - 配置选项。
 * @returns 最终的 ESLint 配置数组。
 */
declare const defineFlatConfig: (
  configs: FlatESLintConfig[],
  options?: DefineFlatConfigOptions,
) => FlatESLintConfig[]

选项类型声明:

interface DefineFlatConfigOptions {
  /**
   * 指定用于加载 `.prettierrc` 配置的工作目录。
   *
   * 配置文件应为 JSON 格式。
   *
   * @default process.cwd()
   */
  cwd?: string

  /**
   * 如果 `prettierEnabled` 设为 `false`,则所有与 Prettier 相关的规则和配置都将被忽略, 即使提供了
   * `prettierRules` 也不会生效。
   *
   * @default true
   */
  prettierEnabled?: boolean

  /**
   * 默认情况下,会从当前工作目录读取 `.prettierrc`,并且 `.prettierrc` 文件必须是 JSON 格式。
   *
   * 如果配置文件不是 JSON 格式,或者使用了不同的文件名,可以将其转换为 JSON 规则后传入。
   *
   * 读取自定义配置后,会与默认的 ESLint 规则合并。
   *
   * @see https://prettier.io/docs/configuration.html
   */
  prettierRules?: PartialPrettierExtendedOptions

  /**
   * Tailwind CSS 规则默认启用。如果它们影响了项目,可以通过该选项禁用。
   *
   * @default true
   */
  tailwindcssEnabled?: boolean

  /**
   * 如果需要覆盖 Tailwind CSS 配置,可以传入相应的选项。
   *
   * 如果想要合并配置,可以导入 `defaultTailwindcssSettings`,手动合并后再传入。
   *
   * 如果传入空对象 `{}`,则会使用默认设置。
   *
   * @see https://github.com/francoismassart/eslint-plugin-tailwindcss/tree/v3.18.2
   */
  tailwindcssSettings?: TailwindcssSettings
}

createGetConfigNameFactory

createGetConfigNameFactory 是一个灵活的工具函数,用于生成 ESLint 配置命名工具。它可以快速拼接配置名称,确保命名空间一致,并便于组织和管理复杂的规则集。

API 类型声明:

/**
 * 一个灵活的工具函数,用于生成 ESLint 配置命名工具。 它可以快速拼接配置名称,确保命名空间一致,并便于组织和管理复杂的规则集。
 *
 * @param prefix - 表示配置名称前缀的字符串。
 * @returns 一个函数,该函数会将提供的名称片段与指定的前缀拼接在一起。
 */
declare const createGetConfigNameFactory: (
  prefix: string,
) => (...names: string[]) => string

使用示例:

import {
  createGetConfigNameFactory,
  defineFlatConfig,
} from '@bassist/eslint-config'

const getConfigName = createGetConfigNameFactory('my-prefix')

export default defineFlatConfig([
  {
    name: getConfigName('ignore'), // --> `my-prefix/ignore`
    ignores: ['**/dist/**', '**/.build/**', '**/CHANGELOG.md'],
  },
])

为什么要使用它?

  • 一致性:强制执行清晰统一的配置命名模式。
  • 灵活性:允许为不同项目或范围自定义前缀。
  • 简化管理:便于组织和浏览大型 ESLint 配置。

这个工具在构建可复用的 ESLint 配置或维护复杂项目的规则集时尤其有用。

📦 导出的配置

这些是一些常用的配置,如果有额外需求,欢迎提交 PR!

语言支持

框架支持

格式化工具

格式化规则默认启用,不会单独导出。如需自定义配置,请通过 defineFlatConfig APIoptions 传入。

  • Prettier :
    • 默认会读取 .prettierrc.prettierignore 的内容,并添加到 ESLint 规则中。
    • 如果预期的配置文件不存在,则会使用 内置的 Prettier 规则作为兜底规则。
    • 非以上配置文件并且不喜欢默认规则时,可以通过 defineFlatConfigoptions.prettierRules 将完整配置传递进来优先作为 ESLint 规则使用
  • Tailwind CSS :
    • 默认会将 tailwind.config.js 作为 Tailwind CSS 配置文件传入。
    • 非默认文件或者需要更改规则,可通过 options.tailwindcssSettings 传递

其它

📚 迁移指南

  • 扁平化配置(Flat Configs)不支持 ESLint 8.x 以下的版本。
  • --ext CLI 选项已被移除 (#16991) 。

📝 发布日志

详细更新内容请参考 CHANGELOG

解决 better-sqlite3 连接 SQLite 时报错 Could not locate the bindings file

2025-02-16 00:42:33

五年前买的阿里云 ECS 这个月底到期,前天准备续费的时候发现买个新的更划算,不仅价格差不多,还多了 1GB 内存,那还续个屌…… 服务器上要迁移的东西不多,影响不大,所以就直接买个新的了。

简单的迁移工作

由于老的服务器上部署的大多是前端项目(数据是连 Serverless 的 API 操作的,不在这台机器上),并且基本都是用 Docker 部署的,所以迁移工作都比较简单,在源码仓库上修改 Workflow 的目标机器 IP 和 SSH Key ,重新运行一次 CI 打包,就可以把新的镜像推送到新的服务器上了。

其他的像 SSL 证书, Nginx 配置,都是拷贝过去后重启一下 Nginx 就搞定,等服务都起来了,去 DNS 解析那里把域名指向新机器的 IP 就迁移完了,都问题不大。

除了有一个 Nest 服务,因为连了 SQLite ,迁移后出现了一点问题。

部署后运行报错

问题倒不是出在 SQLite 上,用 Docker 连接这种嵌入式数据库,都是通过 Volumes 挂载到宿主机器上的,所以访问的数据库文件路径是宿主机器上的路径,知道了这一点,把 SQLite 数据迁移到新服务器上也很简单,并且在新机器上直接用 SQLite 查询数据,也都没问题。

至于在 Docker 里使用 SQLite 本身,只需要在 Dockerfile 里,在安装 libc6-compat 的时候记得一起安装 sqlite 就可以。

# Dockerfile

# Use the official Node.js image as the base image
FROM node:18-alpine AS base

# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat sqlite

# Set the working directory inside the container
WORKDIR /app

# ...

但是 Docker 容器运行后,访问接口却挂了,通过 docker logs 查询容器的日志,发现启动后这里有个报错:

[Nest] 1  - 02/15/2025, 1:23:01 PM     LOG [InstanceLoader] ScheduleModule dependencies initialized +1ms
[Nest] 1  - 02/15/2025, 1:23:01 PM   ERROR [TypeOrmModule] Unable to connect to the database. Retrying (1)...
Error: Could not locate the bindings file. Tried:
 → /app/node_modules/.pnpm/[email protected]/node_modules/better-sqlite3/build/better_sqlite3.node
 → /app/node_modules/.pnpm/[email protected]/node_modules/better-sqlite3/build/Debug/better_sqlite3.node
 → /app/node_modules/.pnpm/[email protected]/node_modules/better-sqlite3/build/Release/better_sqlite3.node
 → /app/node_modules/.pnpm/[email protected]/node_modules/better-sqlite3/out/Debug/better_sqlite3.node
 → /app/node_modules/.pnpm/[email protected]/node_modules/better-sqlite3/Debug/better_sqlite3.node
 → /app/node_modules/.pnpm/[email protected]/node_modules/better-sqlite3/out/Release/better_sqlite3.node
 → /app/node_modules/.pnpm/[email protected]/node_modules/better-sqlite3/Release/better_sqlite3.node
 → /app/node_modules/.pnpm/[email protected]/node_modules/better-sqlite3/build/default/better_sqlite3.node
 → /app/node_modules/.pnpm/[email protected]/node_modules/better-sqlite3/compiled/18.20.6/linux/x64/better_sqlite3.node
 → /app/node_modules/.pnpm/[email protected]/node_modules/better-sqlite3/addon-build/release/install-root/better_sqlite3.node
 → /app/node_modules/.pnpm/[email protected]/node_modules/better-sqlite3/addon-build/debug/install-root/better_sqlite3.node
 → /app/node_modules/.pnpm/[email protected]/node_modules/better-sqlite3/addon-build/default/install-root/better_sqlite3.node
 → /app/node_modules/.pnpm/[email protected]/node_modules/better-sqlite3/lib/binding/node-v108-linux-x64/better_sqlite3.node
    at bindings (/app/node_modules/.pnpm/[email protected]/node_modules/bindings/bindings.js:126:9)
    at new Database (/app/node_modules/.pnpm/[email protected]/node_modules/better-sqlite3/lib/database.js:48:64)
    at BetterSqlite3Driver.Database [as sqlite] (/app/node_modules/.pnpm/[email protected]/node_modules/better-sqlite3/lib/database.js:11:10)
    at BetterSqlite3Driver.createDatabaseConnection (/app/node_modules/.pnpm/[email protected][email protected][email protected][email protected]_@[email protected][email protected]_/node_modules/typeorm/driver/better-sqlite3/BetterSqlite3Driver.js:88:41)
    at async BetterSqlite3Driver.connect (/app/node_modules/.pnpm/[email protected][email protected][email protected][email protected]_@[email protected][email protected]_/node_modules/typeorm/driver/sqlite-abstract/AbstractSqliteDriver.js:171:35)
    at async DataSource.initialize (/app/node_modules/.pnpm/[email protected][email protected][email protected][email protected]_@[email protected][email protected]_/node_modules/typeorm/data-source/DataSource.js:136:9)
[Nest] 1  - 02/15/2025, 1:23:04 PM   ERROR [TypeOrmModule] Unable to connect to the database. Retrying (2)...
Error: Could not locate the bindings file. Tried:

错误日志分析

在 Node 服务端程序连接 SQLite 是用了 better-sqlite3 这个库,它是 Node.js 中速度最快、最简单的 SQLite 库,在 Nestjs 里也是支持用 TypeORM 来基于这个库操作 SQLite 。

better-sqlite3 的产物

和普通的依赖包直接引入 dist 产物开箱即用不一样,它还需要编译一次原生绑定文件,默认情况下,它会尝试在安装时自动构建原生模块,对比本地在 node_modules 里的目录文件,和 npmjs 上的发布文件列表,会发现线上的发布版少了 better_sqlite3.node 这个文件,提示的报错信息也是少了这个文件。

本地在 node_modules 里的 better-sqlite3 目录文件

npmjs 上的 better-sqlite3 发布文件列表

构建脚本解析

在 better-sqlite3 的 package.json 里,可以看到 install 脚本就是执行这个安装后编译 Node.js 模块的操作。

{
  "scripts": {
    "install": "prebuild-install || node-gyp rebuild --release",
    "build-release": "node-gyp rebuild --release",
    "build-debug": "node-gyp rebuild --debug",
    "rebuild-release": "npm run lzz && npm run build-release",
    "rebuild-debug": "npm run lzz && npm run build-debug",
    "test": "mocha --exit --slow=75 --timeout=5000",
    "benchmark": "node benchmark",
    "download": "bash ./deps/download.sh",
    "lzz": "lzz -hx hpp -sx cpp -k BETTER_SQLITE3 -d -hl -sl -e ./src/better_sqlite3.lzz"
  }
}

这个 npm install 脚本,是 npm 的 生命周期 之一,当执行 npm install 时触发(其它包管理器如 pnpm install 也会触发)。如果 npm 包的根目录下有一个名为 binding.gyp 的文件,当没有自定义 install 或 preinstall 脚本时, npm 将默认使用 node-gyp rebuild 命令对 binding.gyp 进行编译。

node-gyp 是 Node.js 官方提供的跨平台命令行工具,用于编译 Node.js 的原生插件模块。

可以看到 better-sqlite3 的根目录下,也是存在一个 binding.gyp 文件,所以在 install 依赖的时候,better-sqlite3 会尝试使用 prebuild-install 来下载已编译好的二进制文件,如果没有找到匹配的文件,则会退回使用 node-gyp rebuild --release 来手动编译源代码。

better-sqlite3 这行 install 命令的意思是:

  1. prebuild-install:

    • 这个命令会首先检查是否有已经构建好的二进制文件(预编译的二进制文件)。 它是由 prebuild-install 这个工具提供的,用来自动下载已编译好的二进制文件,避免在安装时重新编译原生模块,这个步骤是为了加速安装过程,并且能够在大多数情况下避免编译原生代码,尤其是针对不同平台的预编译文件。
    • prebuild-install 会检查与当前 Node.js 版本和操作系统匹配的预编译包,如果找到了,则直接使用。
  2. node-gyp rebuild --release:

    • 如果没有找到匹配的预编译文件(比如第一次安装时或没有适用的二进制文件),则会执行 node-gyp rebuild,这会尝试从源代码编译原生模块。
    • node-gyp 是一个用于编译 Node.js 本地模块的工具,它会根据模块中的 C++ 源代码生成并编译 .node 文件。
    • --release 参数表示以发布模式编译(优化后的版本),而不是调试模式。

但以上虽然是理论上的预期方案和兜底方案,但不知道为什么在 CI 机器上居然都没有执行成功,导致最后缺少了这个编译好的二进制文件。

解决思路

既然确认问题就是因为少了这个编译的 Node.js 模块,那么可以尝试手动 build 一下,主动生成 better_sqlite3.node 文件。

因此在 Dockerfile 里,通过 pnpm i 之类安装项目依赖这一步的后面,添加如下代码:

# Dockerfile

# ...

RUN apk add --update --no-cache python3 build-base gcc && ln -sf /usr/bin/python3 /usr/bin/python
RUN cd node_modules/better-sqlite3 && pnpm build-release

# ...

手动安装编译需要的依赖,并手动执行 better-sqlite3 的构建脚本,主动生成运行程序需要的 better_sqlite3.node 文件,然后就一切恢复正常了!

感慨一下

之前 CI 构建没问题的时候是基于 Ubuntu 22.04.5 ,现在构建失败的时候是基于 Ubuntu 24.04.1 ,只能说 CI 机器的新系统环境少了一些预装的依赖,导致 node-gyp build 没有执行成功。

这次自己的主服务器迁移是从旧机器的 CentOS 7 迁移到新机器的 Debian 12 ,在选择 Debian 之前,还一度先装了 Ubuntu 24 ,然后看到一些建议说作为主服务器还是稳定优先,建议用 Debian ,所以就重新装了系统。

来自大佬的踩坑经验建议

没想到话音刚落就在 CI 机器踩了 Ubuntu 的坑,害,对 Linux 这些系统版本不太熟,还是得多多学习呀。

本色十年

2025-01-28 02:45:58

本来这篇文章的标题按惯例应该是《年终总结:2024 年的一些回顾和 2025 年的一些小规划》 ,但 2024 年刚好也是我开始独立博客的第十年,想顺便回顾一下十年时间自己经历和变化,所以换了这个不起眼的标题哈哈哈。

想了两个月的标题

最初也想过用《独立博客的十年》或者是一些其它类似的就事论事的标题,但总觉得不够好,后来想到这十年时间刚好覆盖了我从一个产品运营到前端开发工程师的转变、从大厂光鲜到创业公司更好玩的从容、从广州去深圳又回到广州的生活见识,尽管工作和生活经历了很多变化,但对我个人的内心深处来说,似乎没有受到过多的影响,始终知道自己喜欢的是什么,不会因为外界的干扰而改变。

所以最后脑海里总是停留在《英雄本色》这部电影里,不论是它的中文名,还是英文名《A Better Tomorrow》,或者是它的主题曲《当年情》,都感觉很符合我想要的那种感觉,所以最终才决定用《本色十年》这个标题。

我所热爱的事情

随着年龄的增长,我越来越觉得所谓的本色,就是一个人内心深处最真实的样子,不会因为外界的干扰而改变,也不会因为时间的流逝而改变。

十年前,也就是 2014 年,我的好友吴庸吴老师在 他的博客 上给我加了一个友情链接,他给我配的文案是:

诗人、贝斯手、出色的厨子、编辑、切得一手好图的前端、曾经放荡过的旅行家

吴老师博客上的友情链接

十年后,2024 年,这个文案还在他博客上挂着,挂了整整十年。

再看看我在 2023 年刚入职现在这家公司的时候(飞牛 fnOS),在公司同学录里的自我介绍:

纹了一条花臂,钟爱 Blackwork Tattoo 风格,第一个文身是我的琴;

养了三只猫,从 2016 年到现在,超粘人,喜欢抱着我的花臂睡觉;

自己跟自己玩的贝斯手,常用五弦的 MusicMan Neck-Through Bass;

从 2018 年留长发至今,已过肩快及腰,喜欢听摇滚乐 / 新金属 / 核;

家庭主厨,小红书的潮汕美食博主 @底迪 ,擅长粤菜和潮汕菜。

在公司同学录里的照片

虽然有点变化,但不多,什么都可以变,但热爱的东西不变。

稳定了 1/5 个世纪的性格

情绪和性格方面,这十年过得还算乐观,依然是个内向的人,依然独来独往,至于优点和缺点,好像也是维持了至少十年前的状态没啥变化。

优点嘛,想了想,比如:会做饭、喜欢做家务、能坚持文字阅读、情绪还算稳定、做事还算细心…… 都是一些独处的能力?好像也都是一些只要是个人都可以学会的东西……

缺点倒是挺多的,比如:不会抽烟、不会喝酒、不会打牌、不会打麻将、不会桌游、不会唱歌、不会打篮球、不会踢足球、不会炒股、不爱八卦、不会开摩托、不会骑电动车、不会开车…… 相对于新时代对一个普通人的要求,我好像什么都不会,可以说社交方面还真的就蛮需要这些技能的。

要学的话貌似也不难,但主要的阻力是自己不愿意,因为做自己不喜欢的事情很痛苦,就拿开车来说,因为从小家里很穷,出行只有自行车,后来有机会坐车的时候都是从潮州开到广州的大巴车,每次都几乎坐到吐,很害怕车的味道,打车有时候也会遇到那种味道,说不上来是什么味,很难受,心理阴影面积很大,所以一直到现在,我都不喜欢坐车,每次坐进车里还没开就会有一种心理排斥。

洪金宝在《奇谋妙计五福星》里的这段台词,简直就是为我量身定做,笑死。

《奇谋妙计五福星》

做自己喜欢的工作

很多人转码农都是基于 “混口饭吃” ,说直白点就是趁年轻多赚点,仅此而已,但我是在考虑很久尝试很久确定自己是真的喜欢才转行的。

在做产品运营的时候,最早是为了在拿不到排期的时候能解决自己的需求上线而尝试自己实现,写着写着感觉做前端挺有意思的,又从前端慢慢接触到其它更多的领域,读了很多计算机大佬的故事,并且也看着很多前辈都是五六十岁还在写代码,感受到如果真的喜欢,这就是一个能玩一辈子的事情,计算机的世界太广阔了,想怎么玩都行。

最重要的是:这一行很适合我这种独来独往的内向人士,不像以前要出差、要去接触各种玩家、媒体,反正只要自己乐意,可以从起床直接写代码写到睡觉,不用跟什么人打交道!

我在知乎上回答过两个关于职业咨询的问题,有几段话虽然是对提问者说的话,但实际上也是在人生十字路口的时候会对自己说的话。

一个是关于是否要转岗的:

在 “大转岗” 这个事情上面,单纯的喜欢是远远不够的,如果想在某个岗位走的更深更远,靠着一份 “喜欢” ,是支撑不了你很多年的,你至少需要上升到 “热爱” 这个层次。

我这里的 “大转岗” 是指 “产品转运营” / “运营转技术” 这种直接脱离原来核心能力的转岗; “小转岗” 一般是 “社区运营转直播运营” / “内容运营转新媒体运营” 这种原来的经验还可以大幅度复用的转岗。

“小转岗” 很正常,但是 “大转岗” ,大部分人其实都不会有很多次大转岗的机会,因为:虽然说种一棵树最好时间是十年前,其次是现在,但是这棵树要从树苗长成大树,它是需要时间的,如果没有足够的热爱去支撑你不断提升自己,那么很可能两三年就觉得又想换个岗位做一下,等到你毕业 10 年了,人家在那个岗位上已经是个 10 年经验的大佬,而你在当前的岗位,可能依然是一个只有 2、3 年经验的中级专员或工程师。

就像我喜欢某类型的电影,我可能就是那段时间觉得很喜欢而已(曾经漫威电影必看,到现在压根不看了);但是我热爱的事情,比如摇滚乐、下厨、养猫,这些事情能够让我从十几岁到现在,还是十年如一日的保持着高度的热情。

—— 领导问我(目前任职前端)愿不愿意转产品?

另一个是如何选择适合自己的公司:

这种工作内容拖久了,实际上对你下一份工作所需要的经验沉淀、业绩沉淀,起不到什么帮助,工作越久,需要的工作经验是深耕,而不是广而不精。

目前你还有一个优势是,已经回到了家乡,哪怕今年疫情影响工作比较难找,但是家在身边,总归比其他人能撑得住,我认真建议你先别着急接一些奇奇怪怪的 Offer ,好好考虑一下自己的兴趣和未来的发展方向。

做自己喜欢的事情是最好,哪怕有时加班到半夜,也会是一种目标接近完成的兴奋感,而不是说好烦啊怎么还没搞完我不想上班了的丧。

—— 网易外包岗和小公司的正式员工该选哪个?

特别是那句 “哪怕有时加班到半夜,也会是一种目标接近完成的兴奋感” ,最近一年在狂赶 fnOS 的需求时,总会在开发完的时候冒出来和我击掌。

影响过我的人

这十年来影响过我帮助过我的人很多,展示一个 Acknowledgement 在这里肯定放不下哈哈哈哈,我主要单独提一下对我在 “入门、成长、坚持” 这三个阶段影响比较大的人,没有提及的大佬们请不要介意,我一样心存感激!

入门阶段

“入门” 阶段的影响力应该属于初代淫贼三人组…… (后面不同时期有不同的淫贼 N 人组… )

插个词语释义: “淫贼” 是我对好友们的昵称,代表这个人人品端正、性格随和、乐观有趣、落落大方、有自己的独立人格、开得起玩笑、不会过于严肃、在一定程度上聊得来,是一个非常褒义的词语哈哈哈!

三人组分别是:产品大佬吴庸吴老师、技术大佬振权(网名 phpbug )和家辉(是的,真的姓张!)。

2022 年离开深圳前和吴老师的合照

2014 年那会因为一些项目合作,和他们仨对接很频繁,也因为他们当时都有自己的独立博客,在他们的影响下我也尝试自己搞了起来。

和吴老师提起准备十周年

在此之前完全没搞过自己的网站哈哈哈哈,也是第一次购买了自己的域名,学着很多技术大佬那样,实名制走江湖(像:阮一峰 ruanyifeng.com 、张鑫旭 zhangxinxu.com ),所以也用了自己的姓名拼音注册了域名,幸亏当时用了自己的名字,不然这些年各种中二的网名改来改去都不知道该叫什么了……

十年前注册的两个域名

还记得最早是用新浪的 SAE 托管的,后来越来越慢,而且免费用户极度不友好,就逐步迁移到阿里云用到现在(在 2018 年迁移后的第一篇博客 《 世界,您好! 》 有说过这个事情,刚看了一下,当时竟然还是用 Windows 做的服务…… 不堪回首),说来这次迁移可以说是绝对正确的选择,现在工作的服务也都是阿里云的,契合度 100% 。

成长阶段

在成长阶段里,前端大佬丰神对我的影响很大,除了请教过他不少问题外,他在我刚起步的时候对我说过一句话印象特别深刻,那就是 “不要只学会实现功能,还要了解实现原理” 。

那个时候我刚好处于 ”想实现 A 功能,就去搜包含 A 功能的 demo ,改改代码放到自己的网页上跑起来“ 的阶段,功能实现是实现了,但不知道为什么就实现了,所谓的代码能跑就行。

这句话在自学的过程中对我影响很大,当了解了实现原理之后,就会懂得如何举一反三去做更多的东西!哪怕没有亲自写过的也能知道个大概,以后遇到类似功能也有印象应该往哪个方向去查资料。

另外还有小毅 @chawyehsu ,当我还在用 jQuery 写 HTML 页面的时候,跟我分享了 Vue.js ,也就从那个时候开始慢慢知道了还有 Node.js 、 Webpack 等前端工程化的一些东西,以及来自 React / Vue 在当年完全没接触过的全新开发模式,还有不知道从哪年开始被他影响了开始在 GitHub 上活跃,在开源上真的学到了很多在公司里学不到的东西!(Btw: 他在 GitHub 也很活跃,熟悉很多开发语言,目前休息 ing ,年后有公司 OR 猎头挖人的话可以联系聊聊!)。

坚持阶段

这一点我要感谢从小影响我长大的黄家驹先生和 Beyond 乐队,他们的歌给人努力、乐观、坚强的感染力,并且人生真的没有污点、一直言行一致地传达着积极向上的精神。

就像《Beyond 日记之莫欺少年穷》的这个片段(右下角可以先暂停 BGM 再看)。

还有之前某天在凌晨三点多的时候,想起小时候看过的一个香港广告《生有限 活无限》,凭着记忆里的画面关键词搜了出来,竟然是 2000 年拍的,可以说是最喜欢的一个广告片,整整 24 年都没有忘记里面的画面和文案。

开源社区

回来讲讲我的 2024 年,虽然这一年很忙,但还是偶尔在 GitHub 上提交一些有的没的,毕竟之前开源的项目也有一些用户反馈,时不时跟进一下,另外主要就是对博客做了一次改版,这一波也是贡献了不少活跃度在里面(截图生成自 GitHub Contributions )。

这两年在 GitHub 的活跃情况

前段时间还在博客上线了一个 开源项目 的栏目,记录了一些由我创建或维护的项目,虽然没有大型项目,但有一些教程或者工具的受欢迎程度还可以,如果觉得不错,欢迎点个 Star 支持一下!

我的开源项目

主力项目

其他的事情今年没什么时间搞,主力还是在开发公司的 飞牛 fnOS 的 Web 生态,可能很多朋友在公测期间就已经用上了,我家里的 NAS 也是装着我们的系统,用自己开发的作品影响着自己的生活!

如果不了解 NAS ,也可以看我之前写的《 千元预算组装入门 NAS 设备 分享 NAS 的硬件基础知识 》 一文。

飞牛 NAS 界面

飞牛影视

刚好放假前用 git-fame 跑了一下代码贡献度,发现我竟然是贡献度最高的,有点惊喜啊哈哈!不过作为 Core Team 的第一批成员,确实参与到了很多需求里,也学到了很多东西,感谢团队!

核心仓库的代码贡献

这个产品 2025 年会正式上线,到时候欢迎大家来体验!

关于 2024 年和独立博客的十周年回顾,就写到这里吧,祝大家新年快乐!

记录一次 ERR_INCOMPLETE_CHUNKED_ENCODING 的问题排查

2024-11-25 23:58:02

最近博客改版也顺便改了部署方式,页面访问也检查了重定向配置等等,看起来似乎没什么问题,但还是收到了一个反馈 RSS 订阅源报错的情况( issue 见 #370 ,订阅源见 feed.xml ,感谢 @AsanZhang 的反馈 )。

反馈在 RSS 聚合软件里提示订阅报错了,我自己也尝试了确实不行,奇了怪了!

在 RSS 聚合软件里提示订阅报错了

报错截图和信息

在浏览器直接访问 XML ,发现 Network 里 Failed 了,控制台还报了个错误信息:

net::ERR_INCOMPLETE_CHUNKED_ENCODING 200 (OK)

截图如下:

请求状态

控制台的错误信息

这个报错有点眼熟啊!想起前段时间给博客加搜索功能的时候,一开始想做全文搜索,结果部署后也遇到类似的报错,本地 build 完预览没事,线上就跪了。

之前做搜索的时候也遇到过类似错误

但那次因为是把所有文章都处理到一个 JSON 文件里,但因为文章里有很多代码块等内容,引起问题的原因比较多,例如可能破坏了数据结构、文件本身也很大,所以做搜索的时候最后决定去掉全文,改成了只搜标题,解决了当时遇到的问题,但没想到在 RSS 这里还是遇到类似的情况了。

那会还在本地 Docker 部署对比了,但本地也正常,愣是没怀疑到线上多了一层 Nginx 可能是个坑。

解决思路

由于对 Nginx 并没有过多的深入使用,常年处于基础的转发配置阶段,所以直接请教 GPT 帮我解决。

ChatGPT 给我的解决思路

调整 Nginx 的配置

原来的配置是这样子,比较早期的默认配置:

server_names_hash_bucket_size 128;
client_header_buffer_size 32k;
large_client_header_buffers 4 32k;
client_max_body_size 50m;

sendfile   on;
tcp_nopush on;

keepalive_timeout 60;

tcp_nodelay on;

fastcgi_connect_timeout 300;
fastcgi_send_timeout 300;
fastcgi_read_timeout 300;
fastcgi_buffer_size 64k;
fastcgi_buffers 4 64k;
fastcgi_busy_buffers_size 128k;
fastcgi_temp_file_write_size 256k;

按照 GPT 的描述, proxy_buffer_sizeproxy_buffersproxy_busy_buffers_size 这些参数用于调整代理缓冲区的大小, fastcgi_buffer_sizefastcgi_buffersfastcgi_busy_buffers_size 这些参数用于调整 FastCGI 缓冲区的大小,另外还建议我新增 Proxy 缓冲区相关配置。

打开 nginx.conf 文件,修改配置如下:

fastcgi_connect_timeout 300;
fastcgi_send_timeout 300;
fastcgi_read_timeout 300;
-fastcgi_buffer_size 64k;
+fastcgi_buffer_size 128k;
-fastcgi_buffers 4 64k;
+fastcgi_buffers 4 128k;
-fastcgi_busy_buffers_size 128k;
+fastcgi_busy_buffers_size 256k;
-fastcgi_temp_file_write_size 256k;
+fastcgi_temp_file_write_size 512k;

+proxy_buffer_size 128k;
+proxy_buffers 8 128k;
+proxy_busy_buffers_size 256k;
+proxy_temp_file_write_size 512k;

调整完这些配置后,重启 Nginx 服务以应用更改。

sudo nginx -s reload

现在确实解决了,成功订阅!

成功订阅!

使用 remark-directive 为 Unifiedjs 提供 Markdown 视频语法的解析

2024-11-10 01:34:05

最近对博客进行了一次技术栈迁移,其中对 Markdown 的解析渲染支持也从 Markdown-it 系列迁移至 Unifiedjs 系列,在 Unified 的工作流程里,又包含了处理 Markdown 的 Remarkjs 系列以及处理 HTML 的 Rehypejs 系列。

在博客里, Markdown Parser 的整个工作流程都是自己管理的,包括不同结果的输出,例如:提供给 RSS 订阅用的 HTML ,提供给列表和搜索用的 Metadata ,以及提供给详情页作为 React 组件渲染内容用的 JSX ,这些过程并不算复杂,事实上进展确实是很顺利,但是在我以为即将大功告成之际,突然发现渲染出来的内容少了一个东西:我的视频呢?

改版前的表现

改版之前是使用 Markdown-it 作为技术栈, Markdown 代码与 HTML 代码的相处非常和谐,对于没有 Markdown 原生语法支持的 HTML 标签,都可以直接编写 HTML 代码进行渲染,内嵌视频最初就是这样子实现的。

像这样,在 Markdown 里直接编写 HTML 代码,即可直接输出 HTML 。

<video 
  src="https://cdn.chengpeiquan.com/video/my-cats-in-mountain-view-room.mp4" 
  poster="https://cdn.chengpeiquan.com/img/2022/12/20221231235941.jpg?x-oss-process=image/interlace,1"
  title="山景房里的三只猫"
  controls
  preload="auto"
/>

但改版后,原本应该渲染为视频的地方,都只剩下一个 <p></p> 标签,很明显是在 Markdown 代码转换过程中被过滤了。

理解 Unified 的工作流程

像我这种 Parser 流程比较长,中间处理环节还是动态变化的情况,很怕这些奇怪的问题,但还好 Unified 的设计非常清晰,先了解一下实现原理,更方便找到问题的原因。上面提到了 Unified 包含了 Remark 和 Rehype 两个系列的工作流,因为在 Unified 生态的工作过程中,都是基于 AST 语法树工作,可以简单地理解为:

  1. 先由 Remark 负责把 Markdown 文件的内容转为 MDAST ,在这个过程中所有代码都是围绕 Markdown 工作
  2. 再通过中间插件 remark-rehype ,把 MDAST 转为 HAST
  3. 最终由 Rehype 处理 HAST ,自此阶段开始,所有工作都是围绕 HTML 展开,最终输出什么样的结果也是在这个阶段处理

以上流程可以反过来,也就是先处理 HTML 再还原为 Markdown ,如果是这种流程,中间插件需要更换为 rehype-remark

名词解释:

MDAST —— Markdown Abstract Syntax Tree , Markdown 抽象语法树

HAST —— Hypertext Abstract Syntax Tree ,超文本抽象语法树

问题的排查

了解了工作流程,就可以分三个阶段排查问题了,要么就是在 Remark 环节把 HTML 代码屏蔽了,要么就是 Rehype 环节有问题,要么就是中间的 AST 转换抛弃了这部分代码。

此时 Parser 里的处理器插件是这么启用的:

const processor = unified()
  .use(remarkPlugins) // Markdown to MDAST
  .use(remarkRehypePlugins) // MDAST to HAST
  .use(rehypePlugins) // HAST to HTML
  .use(reactPlugins) // HTML to JSX

const file = await processor.process(markdown)

这里的每一个 Plugins 变量都是一个数组,会根据我的构建场景动态启用插件(相关源码见:core/parser ),例如:

import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkStringify from 'remark-stringify'
import { type PluggableList } from 'unified'

const remarkPlugins: PluggableList = [
  [remarkParse], // e.g. [plugin, pluginOptions]
  [remarkGfm],
  [remarkStringify],
]

因此先仅启用 remarkPlugins ,发现一切正常,继续启用 remarkRehypePlugins ,就出问题了, Markdown 里的 <video /> 标签被过滤了。

所以我在 remark-rehype 的文档里找到了关于 HTML 标签过滤的说明:

因为在 markdown 中支持 HTML 是一项繁重的任务(性能和包大小) ,而且并不总是需要的,要同时使用两者,您还必须配置 allowDangerousHtml: true 选项。 —— 详见 When should I use this?

解决方案

原因被定位到了就很好解决,目前是找到了这些解决方案,可以根据需要处理。

开启 allowDangerousHtml

根据 remark-rehype 的文档,仅需开启该选项即可支持将 Markdown 里的 HTML 代码作为半标准节点嵌入 HAST 中 raw

import remarkRehype from 'remark-rehype'
import { type PluggableList } from 'unified'

const remarkRehypePlugins: PluggableList = [
  [remarkRehype, { allowDangerousHtml: true }],
]

注意:除了该插件需要开启该选项之外,像我的博客还使用了 rehype-stringify 插件,它也需要一起开启该选项。

由于我还使用了 rehype-sanitize 用于对 HTML 内容的清理,因此仅开启该选项在我的博客里并不能直接达到目的,还要在 Sanitize 进行放行,并且平时写 React 组件的习惯上,我对 dangerouslySetInnerHTML 的使用非常克制,有一些代码洁癖让我不喜欢这个方案,因此我放弃了它。

将 Raw HTML 转为 HAST

remark-rehype 的文档里,描述 allowDangerousHtml 部分提及到了另外一个插件: rehype-raw

这个插件很适合希望支持渲染嵌入在 Markdown 里的 HTML(需要传递 allowDangerousHtml: true 给 remark-rehype ),它可以获取 Markdown 里的 HTML 字符串并将它们作为实际节点包含到 HAST 中。

在开启 allowDangerousHtml 选项时, Markdown 里的 HTML 代码仅作为半标准节点嵌入 HAST 中 raw 属性,但配合这个插件,可以将原始的 HTML 字符串解析为标准的 HAST 节点。

处理过程需要依赖一个完整的 HTML 解析器(详见 parse5 ),它将完全按照浏览器解析的方式重新创建抽象语法树,同时保持原始数据和位置信息完好无损。

注意在使用过程中的插件顺序:

import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
import rehypeRaw from 'rehype-raw'
import { type PluggableList } from 'unified'

const remarkRehypePlugins: PluggableList = [
  [remarkRehype, { allowDangerousHtml: true }],
]

const rehypePlugins: PluggableList = [
  [rehypeStringify, { allowDangerousHtml: true }],
  [rehypeRaw],
]

这个方案处理过程比较繁重,但这是支持不受信任内容的唯一方法,除非类似那种内容完全由用户提交的场景,否则在内容可控的场景下,都不推荐使用这个方案。

使用 Markdown 图片语法

这是一个最轻巧的解决方案,几乎没有多余的处理成本。

因为我的博客文章详情页最终是通过 JSX 进行渲染(可参考 markup/renderer ),因此完整的处理过程是:Markdown > MDAST > HAST > HTML > JSX ,在最后一个环节使用 rehype-react 的时候,可以将 HTML 代码转换为 React 组件需要的 JSX 代码。

import rehypeReact, { type Options as RehypeReactOptions } from 'rehype-react'
import { a, img } from './components'
import { Fragment, jsx as _jsx, jsxs as _jsxs } from 'react/jsx-runtime'
import { type PluggableList } from 'unified'

const components = {
  a, // e.g. `<a />` --> Next.js `<Link />`
  img, // e.g. `<img />` --> Next.js `<Image />`
} as unknown as RehypeReactOptions['components']

const rehypeReactOptions = {
  Fragment,
  components, // e.g. Record<tagName, componentName>
  ignoreInvalidStyle: true,
  jsx,
  jsxs,
  passKeys: true,
  passNode: true,
  development: false,
} satisfies RehypeReactOptions

const reactPlugins: PluggableList = [[rehypeReact, rehypeReactOptions]]

所以我想到了一个方案,使用 Markdown 内置的图片语法,将视频链接放在原本需要放图片链接的位置,然后在转 JSX 的过程中,判断 URL 结尾的扩展名将视频 URL 分配给 Video 组件。

![山景房里的三只猫](https://cdn.chengpeiquan.com/video/my-cats-in-mountain-view-room.mp4)
// components.tsx

const video = async (props: React.VideoHTMLAttributes<HTMLVideoElement>) => {
  // 组件里的其它逻辑
  // ...

  return <video {...props} />
}

export const img = async (props: React.ImgHTMLAttributes<HTMLImageElement>) => {
  // 判断常用的视频文件扩展名,将其转发给视频组件渲染
  if (props?.src?.endsWith('.mp4')) {
    return <video {...props} />
  }

  // 组件里的其它逻辑
  // ...

  return <img {...props} />
}

事实上在文章详情里实现很完美,但我考虑到了 RSS 订阅源里的 HTML 代码并没有得到解决,并且这种方式无法配置视频的 poster 属性,所以这个方案也被我放弃了。

使用 Markdown 自定义指令

这个方案是在 GitHub Remarkjs Discussions 里搜索时找到了几个讨论,提供了很棒的灵感!

Remark 提供了这方面的插件支持,仅需安装 remark-directive 插件,这是对 Markdown 指令语法提案 的实现(这个提案很有意思,值得阅读!),可以使用和 Markdown 十分接近的语法实现一些自定义功能。

看到这里的读者应该不会陌生,很多知名的静态生成器项目都支持自定义指令,例如:

最终实现方案

简单说一下实现方案,最终是通过编写一个 Remark 插件实现自定义指令,以 :::video 的语法,在 Markdown 内容里配置视频的 srcpostertitle 属性。

源码在 plugins/remark-video ,这里贴的代码在未来可能会有变化。

所需的依赖

先安装依赖,由于不需要在运行时使用,所以统一安装到 devDependencies 里。

pnpm add -D remark-directive unist-util-visit @types/mdast

这些插件的作用:

插件 作用 写本文时使用的版本号
remark-directive 添加对通用指令的支持 ^3.0.0
unist-util-visit 遍历 AST 语法树节点,导出了一个 visit 方法 ^5.0.0
@types/mdast 为 TypeScript 提供插件主要参数的类型 ^4.0.4

设计时的想法

考虑到需要配置的参数如 srcposter 的 URL 都比较长,用 leafDirective 语法会比较难维护,因此选择了 containerDirective 语法,按照约定,从上往下分为三行内容,分别是 srcposter 以及 title

:::video
https://example.com/video-src.mp4
https://example.com/video-poster.jpg
A video title
:::

其他的视频播放器属性,由指令插件统一管理,因此不需要在 Markdown 里自定义配置。

编写指令插件

按照 README 的例子,很快就能编写一个自定义插件了,这里就不赘述具体的过程,看代码和注释即可。

import { type Root } from 'mdast'
import { visit } from 'unist-util-visit'
import { isArray, isObject, isString } from '@bassist/utils'

// For the `src` and `poster` attributes
interface LinkNode {
  type: 'link'
  url: string
  children?: unknown[]
  [key: string]: unknown
}

// For the `title` attribute
interface TextNode {
  type: 'text'
  value: string
  [key: string]: unknown
}

interface VideoDirectiveNodeChildren {
  children: (TextNode | LinkNode | unknown)[]
}

interface VideoDirectiveNode {
  type: 'containerDirective'
  name: 'video'
  children: VideoDirectiveNodeChildren[]
  [key: string]: unknown
}

interface HyperScriptData {
  hName?: string
  hProperties?: {
    [key: string]: unknown
  }
  [key: string]: unknown
}

/**
 * With container directive
 *
 * @example
 *   ;```md
 *   :::video
 *   src
 *   poster
 *   title
 *   :::
 *   ```
 */
const isVideoNode = (i: unknown): i is VideoDirectiveNode => {
  if (!isObject(i)) return false
  const children = i?.children?.[0]?.children
  return (
    i.type === 'containerDirective' &&
    i.name === 'video' &&
    isArray(children) &&
    children.length > 0
  )
}

const isLinkNode = (i: unknown): i is LinkNode => {
  return isObject(i) && i.type === 'link' && isString(i.url) && !!i.url
}

const isTextNode = (i: unknown): i is TextNode => {
  return isObject(i) && i.type === 'text' && isString(i.value) && !!i.value
}

const isValidChildNode = (i: unknown) => isLinkNode(i) || isTextNode(i)

/**
 * I have customized a compilation process in Markdown Parser, so not all HTML
 * codes are allowed to be rendered.
 *
 * When Markdown is being converted to AST, many HTML tags will be discarded,
 * and the same is true for Video.
 *
 * In order to uniformly implement custom rendering content, this plugin
 * implements the ability of `video` directive.
 *
 * One more important thing, since rehype-sanitize is enabled, remember to
 * configure the options to allow rendering of video tags and attributes.
 *
 * @example
 *   Enter the following into the markdown file:
 *
 *   ```md
 *   :::video
 *   https://example.com/video-src.mp4
 *   https://example.com/video-poster.jpg
 *   A video title
 *   :::
 *   ```
 *
 *   Compile and output a Video tag:
 *
 *   ```html
 *   <video
 *   src="https://example.com/video-src.mp4"
 *   poster="https://example.com/video-poster.jpg"
 *   title="Hello World"
 *   />
 *   ```
 *
 * @returns Transformer
 */
const remarkVideo = () => {
  return (tree: Root) => {
    // Prevents the following judgment from being inferred as never
    visit(tree, (node: unknown) => {
      if (!isVideoNode(node)) return

      const [srcNode, posterNode, titleNode] = node.children[0].children
        .map((i) => {
          if (isLinkNode(i)) return i
          if (isTextNode(i)) {
            i.value = i.value.replace(/\n/g, '').trim()
            if (i.value) return i
          }
          return undefined
        })
        .filter(isValidChildNode)

      const src = srcNode.url
      const poster = posterNode.url
      const title = titleNode.value

      const data = (node.data || (node.data = {})) as HyperScriptData
      data.hName = 'video'
      data.hProperties = {
        src,
        poster,
        title,
        controls: true,
        preload: 'auto',
        className: 'w-full aspect-video rounded-lg',
      }
    })
  }
}

export default remarkVideo

启用插件

在我的博客项目里,是在 core/parser 里启用插件(也就是最终提供给 unified().use() 使用 ),在使用的过程中,如果启用了另外一个 rehype-sanitize 插件,还需要在该插件的选项里配置 tagNamesattributes 的白名单列表。

import remarkDirective from 'remark-directive'
import remarkVideo from './plugins/remark-video'
import { type PluggableList } from 'unified'

const remarkPlugins: PluggableList = [
  [remarkParse],
  [remarkDirective],
  [remarkVideo],
]

const rehypePlugins: PluggableList = [
  // ...
  [
    rehypeSanitize,
    {
      // No need `user-content-` prefix
      clobberPrefix: '',
      // https://github.com/syntax-tree/hast-util-sanitize#tagnames
      tagNames: [...toArray(defaultSchema.tagNames), 'video'],
      // https://github.com/syntax-tree/hast-util-sanitize#attributes
      attributes: {
        ...(defaultSchema.attributes || {}),
        video: ['src', 'poster', 'controls', 'preload', 'className'],
      },
    },
  ],
  // ...
]

// ...

最终结果

这就是这段 Markdown 指令渲染出来的效果(当然,不包括下面的标题展示,那是我另外包裹了一层 figure 标签,详见 parser/components ,在转换为 JSX 的时候处理的)。

:::video
https://cdn.chengpeiquan.com/video/my-cats-in-mountain-view-room.mp4
https://cdn.chengpeiquan.com/img/2022/12/20221231235941.jpg?x-oss-process=image/interlace,1
山景房里的三只猫
:::