距离我上线的第一个网站已经过去了八年之久;这八年之间,我用过了各类的部署方式,CMS (Content Management System) 产品和 Web framework。
短命的 Jekyll#
Jekyll 在那个时候确实是一个优秀的 SSG (Static Site Generator)。我还依旧记得 GitHub Pages + Jekyll 的官方方案为所有开发者提供了一个极为便捷且快速的将自己内容上线的方式。我也是“获利者”其中之一。
只需要一些简单的配置文件和一堆的 Markdown 文件,并且能上传到 GitHub,GitHub Pages 就能实现自动构建与部署,点击链接就能看到已经上线的网站。对于一个当时并不是非常了解 Jekyll 内部所用技术的我来说,简直是太神奇了。
但是 Jekyll 和 GitHub Pages 这套组合的限制也是无比的明显。
Jekyll 对于非 Ruby 开发者来说的环境配置并不是那么的省心,加上 Jekyll 在 GitHub Pages 上被限制了大量的插件能力,Jekyll 主题安装方式不统一,我作为普通用户维护它的热情正在逐渐消失...
小巧的 Typecho#
我曾经用过一段时间的 WordPress,但对于简单的个人内容管理来说,WordPress 携带的大量功能反而成了累赘。而 Typecho 对于当时熟悉使用 WordPress 的我就较为友好,它就是为了简单的内容网站而生的,也带有相当不错的拓展生态,无论是主题还是插件。
所以后来有相当一段时间,我的内容站点是一直基于 Typecho 的,并且对于当时的我来说是真的相当的够用且正正好不多不少。至少,在当时有精力处理各种服务器,数据库,和那时候人人都在用但很明显老掉牙的 LAMP (Linux + Apache + MySQL + PHP) 架构的我来说是这样的。
我那段时间一直是以一种想要彻底省心的方式来运营,无论是从平常的运营维护还是从架构的角度。既要又要的想法逐渐开始迫使着我审视现有的架构,自带后台的 Typecho 在管理和编辑文章确实是比编辑 Markdown 后推送到 GitHub 方便许多。可是现有架构天生的无论是从安全,备份还是迁移的角度来说都不算方便。当然你可以配置各类的自动备份,看到好的服务器价格再想办法整体迁移过去。对于一个只想省心维护,不想花过多金钱成本,又想要天生带有安全和性能优势的我来说,这种架构满足不了我的胃口。
更多的 SSG#
回顾了一圈,想要低成本,安全和速度的最佳平衡选择依旧是 SSG。
SSG 可以生成出最终生产版本所需的所有静态资源,从而规避了很多针对动态服务的攻击,例如
- 注入
- 远程代码执行
- 请求伪造
- 后台扫描 / 爆破
- API 抓包 (除非存在部分逻辑需要前端主动 Fetch)
...
而产出的构建物则可以部署在几乎任何地方,因为通常构建物就是普通的 HTML,CSS 和 JavaScript 文件,因此可选项相比于必须要运行时的架构就无比的多。且无需动态处理请求和渲染,需要的配置与性能也大幅度下降。
性能上限也会更高,如果不去严谨的估算各种静态文件的大小,网络的带宽上限等,大致上可以理解为从需要动态渲染的 变为 。其中 为请求数, 为读取构建出来的静态文件的成本,相比于动态渲染,可忽略。
VuePress?#
我是在 VuePress 项目创立之初无意间接触到它的,那个时候版本号甚至还是 0.x。即使功能稀缺,只能用来做最基础的本职工作——文档生成,我也还是用作主力框架 1 年左右。
VuePress 的 0.x 版本确实是一个简陋但能用的框架,没有插件,大部分功能都得手写。到最后被迫从 VuePress 迁移走的原因,是当时我的开发环境跟 VuePress 出现了一些很匪夷所思的,难以从错误输出调查的致命 BUG。
我当时的开发环境还是 Windows,我用一个 Ubuntu 的 CI 尝试构建了一次,发现在 CI 中还是能正常构建的。
为了不要让解决问题麻烦化,考虑到当时写的大部分东西已经不好升级到 1.x,正好又出现了一个这样的问题,借此机会就换到了另一个框架,Hexo。
Hexo?#
Hexo 依旧是本地文件为主的 SSG,并且它最初的定位就是针对个人的内容站点开发的框架,而生态可以说是非常的好,也让我一度认为这就是终点。
事实上,在我用 VuePress 作为我主站的 Framework 的时候,我也尝试用 Hexo 建立了一个文章更新更加频繁的副站点,本来当时的定位是随时用来更新一些心得,琐碎想法... 因此我用 Hexo 的时间到现在依旧是最长的,一共有 4 年。
Hexo 确实相当的好用,很多前人为你开发了大量的功能,你也只需要写写配置文件,然后直接写 Markdown 文件即可,/source目录下的 Markdown 文件都会自动被转换为 HTML 文件,然后就可以被访问到了。毫无疑问,它是一个相当简单,优秀的 SSG 框架。
不过...到现在为止,安全、性能和成本的三角都兼顾到了,是不是还缺了点什么?
是的,这些 SSG 都有一个共同的痛点,没有一个能随时随地编辑的后台。
虽然你可以通过一些其他的方案,例如我知道 Hexo 有一些可以让你远程编辑的插件,或是直接从 GitHub 的在线页面编辑...
不过这都有点像是强扭的瓜了,它们的哲学依旧是本地文件优先的模式。
变道#
既然 SSG 从架构优势上已经帮我们铺平了安全,性能和成本三角,现在的最后一步就是如何研究出易于运营维护的 SSG 方案了。
业内最成熟的方案就是 Headless CMS,这类产品通常会提供一个后台用于管理一切,但不提供前端,只提供一个 API。所以称之为 Headless,也就是不管前端。
Headless CMS 或者 API 与前端彻底解耦的好处是,后端和前端都可以各自随时替换,更新。
在后面的时间里,我尝试了不同的 Headless CMS 和自由度更高的 SSG 组合,但一直没能让我下定决心真正替换掉在用的 Hexo。
一直到我搜罗到 Strapi 和 Astro 这个组合,让我考虑从 0 设计一套可用和上线的新架构组合。
新组合#
厨房#
Strapi 是开源的,我用的时候是 v4。它是一个挺好的 Headless CMS 产品,它可以直接通过 JSON 文件来构建数据模型,也支持通过后台和可视化的方式构建你的数据模型。
Strapi 提供了从简单到复杂的数据模型的抽象能力,事实上,我目前设计出用于给自己的内容管理的领域模型中,有一部分还受到了当时用 Strapi 时 Dynamic Zones 的启发,这个后面会提到。
我当时给后端架构设计的方案是 Strapi + SQLite,我不想再去维护一个独立的数据库实例,只需要一个简单的 data.db 就可以跑起来,并且为其启用了 Git 作为版本管理。这样一来,配合一个较为便宜的服务器和脚本,就能实现比较简单的部署和备份方案。
但这个方案有两个大问题。
- Strapi 的破坏性改动太多,哪怕是小数点升级都会直接成火葬场
- 需要服务器,这正是我本来想避免的之一
Strapi 的破坏性改动和更新是一个很令人难受的问题,而且这个问题甚至不是出现在大版本号更新上,连小数点版本更新都有可能会更新炸。Strapi 更新后炸掉的表现甚至不一致。有些时候炸掉是直接表现为依赖出现问题,更新失败。有些时候更新成功,但是 Development 环境彻底跑不起来。有些时候更新成功,Development 环境也能启动起来,但是当你访问后台的时候,发现浏览器里面的 Console 已经一片红了,然后你就被迫对着一个彻底白屏的界面发呆,这...
虽然此时 Strapi + Astro 的架构已经被推到前面,变成了主站运行,但是这套方案依旧是实验性质的。
得益于静态网站的架构,Strapi 被我下线之后,内容依旧可以访问。
最后选择 Sanity,相当于是把我的这两个大烦恼解决掉了。
首先 Sanity 成熟到足以让人放心,并且有免费的定价计划,数据是完全托管在他们的服务器上。
其次,Sanity 也能够接受不同复杂度的数据模型抽象,并且它的数据核心是与后台 (Sanity Studio) 解耦的。我不用担心升级 Sanity Studio 的项目后整个 Sanity 项目会炸掉。
Sanity 成熟,免费,功能够用,与后台解耦,成为了我用到目前的后端方案。
前厅#
后端讲完了,接下来聊聊前端框架。最早选择 Astro 并没有一个很确切的原因,真的就是单纯看上了。不过现在回头看,似乎也是一个蛮正确的选择。
Astro 宣称自己是一个 Content-Driven Websites 的框架,正好贴合我的用途,而且当时我觉得蛮有趣的东西在于 Astro 宣称自己默认不会产出 JavaScript,也就是说它默认情况下产出的最终产出物,只会有小巧的 HTML 和一些 CSS。当你需要写一些比较 fancy 的功能和效果时,它才会输出 JavaScript。
也就是说,Astro 实际上可以帮助你实现“最小产出原则”,当你用不着的时候,根本不产出额外消耗网络和性能的东西。
Astro 的模板语法也很有趣。
它的模板语法大概可以分为两个部分,Frontmatter 和 HTML 描述。
Frontmatter 可以处理大量复杂的 TypeScript 逻辑,数据交互,类型声明。而 HTML 描述部分几乎就是干净的内容模板。当然,你还是可以在 HTML 的描述部分插入 Astro 的表达式插值,来做到动态渲染,表达式求值,循环渲染等等...
它的语法很像是 JSX / TSX,但没有 JSX / TSX 自己的那套 className,htmlFor 等负担,近似于纯粹的 HTML。
最后是 Astro 的 Content Layer 设计
早年我用过 GatsbyJS,它的哲学是一切的内容都往一个地方集中装,开发者再通过 GraphQL 语法查询出来。当然我并不是一个 GatsbyJS 的资深开发者,我仅仅用过 GatsbyJS 很短一段时间,仅仅在实验各个不同 Headless CMS 和前端框架组合的时候试过一段时间,我并不知道或者没有资格讨论 GatsbyJS 能否通过其他方式来获取到内容,而绕过 GraphQL。
但这点上,Astro 确实做的比较好。
Astro 的 Content Layer 并不是一个强制选项,但是当你为内容来源做了 Astro Content Layer 后,之后会是一个很舒心的排雷。
你可以为你的 content 声明一个 loader 和一个 schema
- loader: 如何加载你的数据
- schema: 你的数据是什么结构
当定义完之后,通过 Astro 自带的 API,像是
const posts = await getCollection("posts");就可以加载数据。
Astro 的 Content Layer 设计还不止于此。但是只要已经做到了这一步,就成功在 Astro 中把数据来源,结构定义和前端怎么用彻底解耦。
未来数据来源的改变,可以独立改 loader 里的获取逻辑;如果是结构发生改变,就可以单独改 schema。Astro 会借助 Zod 来校验获取到的数据结构是否与你定义的 schema 匹配,若不匹配就会报错并且结束,只要有效定义就可以形成约束,防止出现前端莫名其妙的各种 undefined 和魔法。
而这些,让 Astro 在我的架构选型中得以存在。
蓝图#
在我打算放弃 Strapi 的时候,我打算设计一套实现无关的内容领域模型。这也是现在我 Sanity 项目的核心蓝图与地基。
它的完整 TypeScript 定义比较长,因此我把它共享在了 GitHub Gist 上。
还记得上面我说过的有一部分的模型灵感来源于 Strapi 的 Dynamic Zones 吗?
Strapi 的 Dynamic Zones 的功能可以让你决定添加你提前定义的哪种模块,以实现前端的动态功能渲染。
对的,这其实就是 ADT (Algebraic Data Type) 中的 Sum Type,又或者称之为 Tagged Union 的工程实现。
所以我目前的定义是这样的
type ContentBlocks = (
| Components.PostContent.ContentBlock // Represents any common rich text content block, such as plain text with or without formatting, headings, lists, images, blockquotes, code blocks, as well as Markdown and other markup formats.
| Components.PostContent.DownloadableBlock
| Components.PostContent.MathBlock
| Components.CodeBlocks.CodeBlocks
| Components.PostContent.PostReferenceBlock
| Components.PostContent.PageReferenceBlock
| Components.PostContent.WebReferenceBlock
| Components.PostContent.AlertBlock
| Components.PostContent.RenderableBlock)[]; // <-- 注意这里是个数组而每篇文章中的内容,就是
// ...
interface Post {
// ...
content: ContentBlocks
//...
}
//...这样前端只需要根据 Sanity 响应数据中不同的 Block type,就可以选取不同的前端模块进行渲染。
而且之所以抽象出这个蓝图,也仅仅是为了后面或许又要从 Sanity 跳到其他地方留的一个后手。
结语#
回看这八年来的架构设计,每一种架构都有它自己最独特的一面。
但目前我最开心和满足的,是每更新一次架构,距离我最初的愿望越来越近。而到现在,我终于可以既要又要了:
Sanity 的内容更新或者 GitHub repository 更新会触发 Cloudflare Pages 更新构建,从而构建出带有最新内容 / 功能的前端。
Cloudflare Pages 可以部署到 Cloudflare 的全球计算的边缘节点,以及 Cloudflare 带有的一系列保护服务,让这个优势三角的能力简直提升到狂乱级别。
我的文章是保存在 iCloud 同步的文件夹上的,通常是为了备份和留后手,确实怕之后不用 Sanity 了导出数据会很麻烦。这意味着我可以在 Mac / iPhone / iPad 任意设备访问和更新文章,然后复制到 Sanity 的后台中进行发布。
也是昨天发现的事情,Sanity 给 ChatGPT 写了官方插件,这意味着我之后写完文章,哪怕在手机上,我也只需要丢给 ChatGPT,它就可以自动建立 Sanity 的草稿,只需要一次人为的审查,没有什么大问题,就可以 publish。
我的既要又要架构实在是,深得我心。