还记得上次发布博客已经是24年3月1号了,写的是我在上一段实习结束时最后遗留的议题,然后就再也没有更新过。
其实,我从23年年底开始就对我已有的基于Hexo的博客方案不满已久,包括麻烦的导出、同步过程,时不时还会遇到语法不兼容的问题,Hexo的自定义性也不是很好,于是开始筹划要构建一个自己的新博客平台,同时做一些新功能,如个人主页、AI助手、项目集等。
走过的弯路
初探 Astro
24年初,我正好了解到了一个正在名声鹊起的前端新框架——Astro,其新颖的理念(无JS、岛屿模型)和极致的性能一下子就吸引了我,我开始在这个框架上捣鼓,基于OpenBlog建立了我的第一个Astro博客。
但是很快,我找到了一份比较有意思的兼职工作,时间马上就不够用了,规划中的AI知识库助手、炫酷个人主页等功能很快就搁浅,只留下一个非常粗糙的页面,现在还可以在 refactor_astro 分支上看到当时的代码。
现在回想起来,当时的Astro相比其他方案除了SSG的理念创新,其实在应用形态或者开发体验上并没有所谓绝对优于其他框架的「杀手锏」功能,包括客户端不运行JS的理念也很快被NextJS的RSC抢了过去。
但是,在这趟Astro之旅上,我也学习了很多新东西,包括ViewTransition API、Lighthouse性能评分、岛屿架构等。
这次旅途就像一次绕路,最后我又回到了Hexo的舒适圈内,毕竟他省心又好看,只是同步比较麻烦,这也导致我大半年虽然没有辍笔,但是也没有发布。
发现新大陆
2024年中,我遇见了elog,这是一个Adapter平台,支持各种各样的数据源(如Notion、飞书等),利用这些应用的API导出为md文件,再将这些md用于Hexo这样的博客平台,很好地解决了创作「同步麻烦」的痛点。
这个项目打开了我的思路——Notion既然是我的创作中心,那么我就直接将Notion作为我的CMS(Content Management System)进行文章发布,并进一步将Notion打造为我的知识库中心,将收集、处理、创作和输出的所有信息都统一放在Notion中管理,而Notion强大的数据库功能和开放的API生态又给这一设想提供了可能性。
寻着这个思路,我继续往下找,找到了一个Star数8k+的优秀开源项目——NotionNext,这是一个直接利用Notion API读取Block数据,渲染成Notion页面的项目,比起elog先转换成markdown作为中间层,这种「没有中间商赚差价」的模式可以避免元数据的丢失,更好地保留源格式信息,避免排版错误,理论上可以做到完美复刻Notion的所有Block渲染。
可是我在尝试接入后,发现了新问题:
- 我现在使用的是国内版Notion「wolai」,其并没有那么开放的API和生态,无法接入这样的体系
- NotionNext是纯JS开发的,且大量用到了动态注入,虽然对于非开发者来说部署非常友好,但是对于有二次开发需求的人来说用起来非常生硬
因此,接下来半年的时间我就断断续续地开始了下面的工作,但是因为本职工作繁忙,并没有那么多的时间来做完:
- 使用TS+RSC的技术重构NotionNext
- 从wolai迁移到notion
本来一切都很顺利,我的重构工作也基本实现了基础的博客渲染功能,具体代码都在下面的仓库,有志者可以继承我的衣钵接着开发。
好巧不巧,Astro此时推出了重量级更新,将我一把拽回了Astro的怀抱。
Astro 5.0的革命性更新
正值年底,我的NotionNext重构工作正在如火如荼地进行着,奈何平地一声惊雷,Astro 5.0横空出世,带来了如Server Islands(服务器岛屿)和Content Layer(内容层)API两个重量级功能。
Astro不忘初心,秉承其内容优先应用的框架理念,不断在易用性和性能上推陈出新,这次更是重量级:
- 服务器岛屿:类似于NextJS的异步RSC,允许服务端渲染时将一部分动态组件「抠出来」,先返回其他更快的部分,等这部分比较慢的组件加载完了再通过网络传输到前端,整个过程前端无需运行任何JS或进行长时间的TTFB等待即可快速得到动态的页面响应
- 内容层API:这个版本更新吸引我的重点,这一改动极大拓展了Astro原有的内容集合(Content Collection)的数据来源,使其可以从任何数据源、任何CMS加载数据,并在Astro中以统一的、类型安全的方式调用,这其中也包括Notion,社区也在第一时间提供了Notion的Loader
我一拍屁股,想到这不对了吗,完全契合了我们的使用场景,我就是把Notion当作CMS来用的。因此说干就干,立刻开始尝试着手尝试迁移。
手搓博客框架
技术方案调研
根据我一年多的调研,要使用Notion作为博客CMS,可以按中间层格式、转换工具、渲染层三个维度来划分渲染方案。
中间层格式 | 转换工具 | 渲染层 | 代表方案 |
Markdown | https://github.com/souvikinator/notion-to-md | https://astro.build/ | |
Markdown | https://github.com/LetTTGACO/elog | https://hexo.io/zh-cn/ | |
Notion Blocks | https://github.com/NotionX/react-notion-x | https://nextjs.org/ | https://github.com/tangly1024/NotionNext |
Notion Blocks | 自包含 | https://astro.build/ |
经过一番调研,我发现上面的开源方案虽然都很优秀,但我用起来或多或少都有些不太舒服,有这样那样的限制:
- 大部分都要求必须从一个已有的Notion模板,这对于我这种已经建立知识库的人来说有较高的迁移成本
- 二次开发体验不佳,项目历史包袱重,比如不开放接口、没有类型提示等
Notion Astro Loader
在Content Layer API推出的第一时间,Notion作为主流的数据源,就被社区添加了相关的Loader支持。
解析其实现如下:
- 使用Notion API请求数据库信息,获取所有页面
- 解析每个页面,提取出所有Blocks
- 按DFS顺序处理所有Block,包括递归处理
children
块和处理图像资源 - 使用 https://github.com/Mongkii/notion-rehype 库解析Notion Blocks,转换为unified AST
- 使用rehype生态将AST渲染为HTML,并存储在Content Layer API中
总的来看,是一个非常简单且实用的设计,没有做多余的事情,还提供了一个线上博客例子。
只可惜,这个示例中只有标题含有图片,正文中并没有图片,这埋下了一个巨大的坑,足足占据了我大半个月的时间进行修复,这里先按下不表,等到后文再谈。
启动博客框架开发
在经过了一段时间的技术调研后,我最终确定了我的技术选型,并于2025年初正式启动了开发:
- 基于https://github.com/onwidget/astrowind模板进行开发,它是唯一一个支持Astro 5(需要内容层API)并集成了完整的样式设计系统、博客解析系统等开箱即用的模板,且本身是作为脚手架定位,二次开发体验较好,省去了前期大量初始化开发工作
- 交互组件部分,在SolidJS和React之间最终还是选择了React,一方面是选择熟悉的东西做起来更快,另一方面是React有成熟的组件生态,如shadcn等组件库
- 渲染模式方面选择静态(static)渲染模式,即传统的SSG,在构建时获取Notion数据,生成静态页面,通过Web Hook联动Notion来触发页面部署更新
- 直接使用Notion Astro Loader来加载Notion内容,这样我就只需要做一些上层的组件和页面设计就可以完成一个新的博客框架了
(事实证明还是太年轻了)
于是,我兴致勃勃地创建了我的仓库:
图片资源的大坑
本文只做简单的介绍,具体的技术细节可以参考我的另一篇文章 Astro Content Layer 处理图片的技术细节
我兴致勃勃地创建项目,创建内容集合,做简单的适配接入,一个Demo很快就运行了起来,我怀揣着激动的心,颤抖的手,在心里疯狂对Astro易用的开发体验竖起大拇指。
S3链接过期
然而,好景不长,仅仅调试了一段时间,我就遇到了下面的错误:
我尝试点开一个链接
Vite服务器故障
然而,刚解决了上面的问题,我写完了第一版的代码,准备将初版demo部署上网时,天又塌了,运行构建时刷刷的红色错误接踵而至:
我按照错误指示检查配置文件,却发现我早已将aws域名添加到了白名单里,那这是为何呢?我尝试点到报错的路径里,打印出了完整的Trace信息。
Error: There was an error loading the configured image service. Please see the stack trace for more information.
InvalidImageService: There was an error loading the configured image service. Please see the stack trace for more information.
at eval (/Users/bytedance/Code/personal/notion-astro-rev/node_modules/.pnpm/[email protected]_@[email protected][email protected][email protected][email protected][email protected][email protected][email protected]/node_modules/astro/dist/assets/internal.js:25:21)
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async getConfiguredImageService (/Users/bytedance/Code/personal/notion-astro-rev/node_modules/.pnpm/[email protected]_@[email protected][email protected][email protected][email protected][email protected][email protected][email protected]/node_modules/astro/dist/assets/internal.js:21:34)
at async getImage (/Users/bytedance/Code/personal/notion-astro-rev/node_modules/.pnpm/[email protected]_@[email protected][email protected][email protected][email protected][email protected][email protected][email protected]/node_modules/astro/dist/assets/internal.js:55:19)
at async getImage (astro:assets:32:42)
at async #fetchImage (/Users/bytedance/Code/personal/notion-astro-rev/node_modules/.pnpm/[email protected][email protected]_@[email protected][email protected][email protected]_roll_2ibjhaoyvb5densitsoeosfrn4/node_modules/notion-astro-loader/dist/render.js:187:38)
at async listBlocks (/Users/bytedance/Code/personal/notion-astro-rev/node_modules/.pnpm/[email protected][email protected]_@[email protected][email protected][email protected]_roll_2ibjhaoyvb5densitsoeosfrn4/node_modules/notion-astro-loader/dist/render.js:79:25)
at async awaitAll (/Users/bytedance/Code/personal/notion-astro-rev/node_modules/.pnpm/[email protected][email protected]_@[email protected][email protected][email protected]_roll_2ibjhaoyvb5densitsoeosfrn4/node_modules/notion-astro-loader/dist/render.js:54:22)
at async NotionPageRenderer.render (/Users/bytedance/Code/personal/notion-astro-rev/node_modules/.pnpm/[email protected][email protected]_@[email protected][email protected][email protected]_roll_2ibjhaoyvb5densitsoeosfrn4/node_modules/notion-astro-loader/dist/render.js:163:28)
at async Promise.all (index 19) {
loc: undefined,
title: 'Error while loading image service.',
hint: undefined,
frame: undefined,
type: 'AstroError',
cause: Error: Vite module runner has been closed.
at SSRCompatModuleRunner.getModuleInformation (file:///Users/bytedance/Code/personal/notion-astro-rev/node_modules/.pnpm/[email protected]_@[email protected][email protected][email protected][email protected][email protected]/node_modules/vite/dist/node/module-runner.js:1196:13)
at SSRCompatModuleRunner.cachedModule (file:///Users/bytedance/Code/personal/notion-astro-rev/node_modules/.pnpm/[email protected]_@[email protected][email protected][email protected][email protected][email protected]/node_modules/vite/dist/node/module-runner.js:1186:21)
at request (file:///Users/bytedance/Code/personal/notion-astro-rev/node_modules/.pnpm/[email protected]_@[email protected][email protected][email protected][email protected][email protected]/node_modules/vite/dist/node/module-runner.js:1222:99)
at dynamicRequest (file:///Users/bytedance/Code/personal/notion-astro-rev/node_modules/.pnpm/[email protected]_@[email protected][email protected][email protected][email protected][email protected]/node_modules/vite/dist/node/module-runner.js:1224:124)
at getConfiguredImageService (/Users/bytedance/Code/personal/notion-astro-rev/node_modules/.pnpm/[email protected]_@[email protected][email protected][email protected][email protected][email protected][email protected][email protected]/node_modules/astro/dist/assets/internal.js:21:40)
at getImage (/Users/bytedance/Code/personal/notion-astro-rev/node_modules/.pnpm/[email protected]_@[email protected][email protected][email protected][email protected][email protected][email protected][email protected]/node_modules/astro/dist/assets/internal.js:55:25)
at getImage (astro:assets:32:82)
at fileToImageAsset (/Users/bytedance/Code/personal/notion-astro-rev/node_modules/.pnpm/[email protected][email protected]_@[email protected][email protected][email protected]_roll_2ibjhaoyvb5densitsoeosfrn4/node_modules/notion-astro-loader/dist/format.js:33:46)
at #fetchImage (/Users/bytedance/Code/personal/notion-astro-rev/node_modules/.pnpm/[email protected][email protected]_@[email protected][email protected][email protected]_roll_2ibjhaoyvb5densitsoeosfrn4/node_modules/notion-astro-loader/dist/render.js:187:86)
at listBlocks (/Users/bytedance/Code/personal/notion-astro-rev/node_modules/.pnpm/[email protected][email protected]_@[email protected][email protected][email protected]_roll_2ibjhaoyvb5densitsoeosfrn4/node_modules/notion-astro-loader/dist/render.js:79:31)
}
可以看出,这并非是没有加白,而是没有获取到图像服务导致的,我还查到了一个相关Issue:
我进一步溯源出问题的代码,发现在内容层进行 sync
操作时,Astro会先启动一个Vite服务器用于解析和编译配置代码,然后在内容层执行 loader.load()
进行同步之前,关闭并释放这个Vite服务器;而在开发环境中,Vite服务器一直都保持运行状态,因此没有这个问题。
当时,我想到了一个很愚蠢的解决方案,即把 dispose
删掉,让构建结束后Node来杀掉并释放Vite的内存即可,虽然造成了内存泄漏,但至少也不报错了不是吗?
终极解决方案
在报错问题解决后,我越想越不对劲——这种写法怎么看也不是个事儿啊,就像一颗沙子一样硌着我,让我食不能安,夜不能寐。
于是,我做了一个和祖训完全一致的决定——研究一下内容层API的源码,看看官方到底是怎么写的,是Notion Loader写的不对,还是Content Layer API有问题,反正最后谁有问题都要接我的大板PR。
我从三个入口入手对Astro源码进行研究,核心目的是搞清楚Astro官方是如何在内容层中处理这种需要输出到产物中的图片的,这里不深究技术细节,仅供各位看个乐子,了解下大概思路,具体细节请移步 Astro Content Layer 处理图片的技术细节 。
- 以官方的glob markdown loader为例,研究官方是怎么处理图片资源的
- 从
cli/sync/index.ts
入手,研究内容层的同步机制 - 从
content/runtime.ts:renderEntry()
函数入手,研究内容层的每个Entry是怎么被渲染成HTML产物的
最终,我得到的结论是:Astro官方推荐的对本地缓存并输出到产物的图片的处理方法是在内容层中先「打标签」,在真正构建时再读取这些标签,调用服务进行处理,因此是Notion Loader用错了。
简单来说,glob loader是这样处理图像资源的:
Sync 周期:生成内容层
- 根据glob匹配读取md文件,输入数组
- 调用remark处理md,并收集图片信息
- 使用rehype渲染,调用之前收集的信息,对
<img>
标签作特殊标记 - 生成store文件,存储资源信息、模块信息和类型信息
Build 周期:渲染页面 & 生成产物
- 调用
render()
函数渲染时,会基于先前收集的元数据对<img>
标签上的标记进行替换和处理 - 处理时调用
assets
模块,使用先前存储在store中的信息,将图片转为ESM模块导入,以获取完整的元数据用于图像优化 - 调用配置好的图像处理服务,优化图像并生成产物
- 调用
graph LR
subgraph Sync["Sync Period (Content Layer)"]
A[Read MD Files] --> B[Process with Remark]
B --> C[Render with Rehype]
C --> D[Generate Store File]
B -.->|Collect| E[Image Info]
E -->|Mark| C
D -->|Store| F[Resource Info]
D -->|Store| G[Module Info]
D -->|Store| H[Type Info]
end
subgraph Build["Build Period (Page Rendering)"]
I[Render Function] --> J[Process IMG Tags]
J --> K[ESM Import]
K --> L[Image Optimization]
F -.->|Use| J
G -.->|Use| K
L -->|Output| M[Final Assets]
end
Sync --> Build
结合考虑到Notion链接过期的问题,我最终选择将所有的图片都存在本地的 /src
目录内,这样做有几个好处:
- 相比之前的Hack解决方案,这个方案没有内存泄漏,也不会链接过期,更加优雅
- 相比存放在
/public
目录内,存放在/src
目录内的图片可以转为ESM导入,从而获得资源优化
Talk is cheap, let me show you the code!
我直接从原仓库中fork了一份代码,并仿照glob loader的处理方式实现了类似的图片处理机制,等功能稳定了就考虑PR合并到原项目中。
如果我的项目有帮到你,麻烦帮我点个star吧~
做点开源的计划
我的本意是解决自己的博客需求,让自己能用的爽,在Github上设为Public的原因也是我这点代码没啥好藏着掖着的,公开了便是,根本没有完善README、LICENSE等,也没有发包的打算。
没想到,我的小小贡献也意外帮助了几个人,Issue里有好几个人等我的方案,也因此收获了几个陌生人的star,npm第一天的下载量也有了小几百。
那么,不若就此试试做一个完整的开源项目吧,做成了自然好,做不成也可以自己用。
规范化与技术栈
为了让别人用的舒心,也能方便地参与到开发中来,我需要尽可能地使自己的项目规范化:
- 主应用、其他应用和各依赖库形成完善的monorepo架构
- 代码的Git记录遵循严格的规范,包括Prettier格式化、ESLint规范、CommitLint规范、LSLint规范等
- 使用自动化CI/CD发版:基于changeset和conventional-commits自动生成CHANGELOG,在Github Action编译并发版
- 注重Git分支控制,严格实施主干发布、分支开发的模式,每个版本都确保生成tag,合并请求严格遵循规范(包括描述、CI和Squash)
路线图
目前的项目还处于非常初期的阶段,可以看作是一个仅仅实现了博客功能的MVP,还不能作为完整的个人网站使用。
Milestone 1: 可作为完善的个人网页模板使用(2025.03)
- 增加首页和作品集模块
- 完善开源项目的信息:REAMDE、License、文档等
- 完善项目基建,实现自动化发布
将博客本体发布为模板
- 创建可以用于
npm create
的模板生成器包 - 独立一个github模板仓库,使用git submodule嵌入主体仓库
- 创建可以用于
Milestone 2: 功能完备的Notion博客渲染器(2025.05)
- fork实现notion-rehype包,实现核心依赖闭环:添加更多block的渲染支持,并增加插件系统
- 支持全文搜索功能
- 添加评论系统
- 基于大模型的多语言支持
后话:知识系统与博客
WIP
为什么要写博客
- 输出促进输入
- 提高行业影响力,增加后续就业可能性
- 开源精神:想要在互联网上获得认同,满足一点小小的虚荣心,同时也尽可能地帮助到别人
- 知识的整理和再输出,深化理解
- 个人的小天地,想说啥说啥
- 个人知识库,可以实践AI Agent
- 技术试验田
个人知识系统
- 输入
- 管理与处理:选择一个合适的中心点
- 输出