为了实现 SSR,我重构了 Amateras

这是在做出重构决定五个月后着笔写的文章,其实我一直在找时间将这个框架的变化历程好好写下来。但这几个月总冒出许多事需要我去解决,因此只能拖到现在才能开始下笔。毕竟目前还没有一行我写过的代码是真的为我赚来一毛钱收入的,保障生活的优先级总是安排在这些兴趣前。

故事的开始

大约在2025年4月,我开始着手设计了 Amateras 这一新框架,当然从现在来看,那是第一版的设计了。它承载着我对一个前端框架的所有美好愿景,是一个只属于我的艺术品。运用着过去种种编写前端项目的经验,我写出了一个能让开发者不依赖编译器的纯 JS 框架。

你可以只写 JS 就能完成网页布局的排版、页面路由的配置、CSS 的样式设计等,不需要经过任何编译器编译。

当然,现在没人会这么做,大部分前端开发者一定会使用 TypeScript 作为主要语言进行开发,而我也是。所以这个框架也拥有 TypeScript 优先的代码库,所有接口都提供了完善的类型保护。

一根刺

我一边捣鼓这个新框架,一边使用它来开发1.0版本的 MYViki 网站。整个过程其实相当顺利,甚至都没留下什么印象深刻的挑战,我都快以为这个框架的设计思路是完美无缺的了。但是我心里一直有一根刺,一个让我无论如何都始终无法对它感到满意的刺。

它无法实现 SSR。

严格来说并非真的无法实现,只是在这个框架的底层逻辑上实现 SSR 需要的性能开销太大了。而这一切的问题根源就是以浏览器环境为主的设计思路,几乎封锁了实现高性能 SSR 方案的道路。

其实这个框架的设计思路相当简单,就是为创建的元素套上一个接口层,这个接口层的每一个函数都会返回这个接口层本身,以达到实现函数链的效果。如果你接触过 jQuery 就会发现这个概念很熟悉,因为正是受到 jQuery 启发的。

但这个框架与 jQuery 的区别是,jQuery 是查询元素并操作它们,而 Amateras 则是创建元素、操作属性并且直接布置子元素内容。而这就让 Amateras 能编写出网页布局的代码结构,实现高效率的排版代码编写体验。

并且还有一个与浏览器深度绑定的要素:为了不让接口层的函数接口代码过于臃肿,我让框架在每次初始化时将 Node 实例的所有属性都直接转换成接口层的函数链形式,这能极大缩小我的框架代码体积。

但这些要素让这个框架几乎无法在不依赖第三方代码库的条件下运行在伺服器环境中,因为这些 Runtime 并不包含这些 DOM 相关的类。我也尝试过使用 jsdom 和 happy-dom 这类模拟浏览器环境的第三方库,但它们无一在每次处理渲染请求时大幅增加内存占用,并且内存泄漏问题难以追溯清理。

我试了很多不同办法,绞尽脑汁想了一个月,每个方案最后都让我对这个框架越来越失望透顶。也许就在某个睡不着觉的夜晚,我也记不清了,我做出了一个决定。推翻花费六个月编写的所有代码,甚至不惜重写我手头上正在维护的项目,我都要把这个框架彻彻底底重构一遍。

新思路

下定决心后,对于新版本的设计思路开始源源不绝向脑海里涌入。但那也是我思绪最混乱的时候,我也记不清有哪些被我抛弃的方案了。我只记得我写了一个测试版本,然后拿去开发另一个短期项目,最后在这个测试中发现了框架另一个更大的短板后,狠狠地把这个测试版本从我的 git 仓库中消灭掉。

多亏这个经历,我发现了之前框架一直缺少的一个能力:实时根据状态改变的控制流组件。

控制流组件,可以根据条件决定是否要显示其内容。比如有一个条件型控制流组件,能够根据条件判断是否构建并显示包含在其中的元素。再或者遍历型控制流组件,根据数组内容变化来实时渲染每个对应数组物件的元素,一旦该物件被移除,对应的元素也会移除。

过去我较少开发高交互型的网站,因此一直都没意识到这个功能的重要性。但在参考其他框架的设计思路后,我确定这是能极大提升开发效率的重要组件。

类似这样的领悟还有很多,它们都不可或缺的为新版本的底层设计贡献了方向,渐渐地看到新框架的最终成品。

不是2.0

因为 Amateras 本就还在内测阶段,连1.0都没发布过,因此只能算是内测大改动版本。为了日后方便大家记忆,我把旧版的版本号停留在 0.7 版本,而新版则是直接从 0.10 开始。

这个新版本的底层设计可以说是彻底改头换面,其概念相较于旧版更加复杂和抽象一些……但它能实现的开发便利性却远超以往,更重要的是它彻底解决 SSR 性能开销的痛点,因为这是为运行在伺服器和浏览器的环境而设计的!

我展示一段旧版和新版差异的排版代码,就能看到有多大的不同了:

// v0.7
$(document.body).content([
  $('h1')
    .class("title")
    .css({ color: "red" })
    .content("Hello, World!")
])
// v0.13
const $h1 = $("h1", {class: "title"}, $$ => {
  $$.css({ color: "red" })
  $`Hello, World!`
})

$.render($h1, "body")

由于篇幅有限,我不会对新版结构设计多做展开。但如果只从代码编写和阅读体验来说,新版具备一些旧版所没有的优势。

每一层都包裹在函数之中

旧版使用函数链的形式将所有对元素的操作代码都平铺开来,虽然看上去挺简洁,但函数链是无法被折叠的!主流代码编辑器都支持折叠代码的功能,往往只有数组或是区块等具有开头和结尾符号的内容可以被折叠。而函数链并不支持这个功能,这也就意味着你无法折叠这个元素相关的所有代码。

新版直接将所有操作和子元素代码全都囊括在函数区块里,折叠一个元素的相关代码变得非常轻松,也让已经不需要关注的元素代码可以暂时从视野中离开,提升阅读代码的体验。

不再需要逗号

旧版在布置子元素时使用的是数组输入的形式,有时候必须在上一个元素代码结尾加个逗号确实会让人觉得麻烦,甚至有点抓狂……

新版不再使用数组输入,而是更加人性化的运行函数的布置方式,这很好避免了逗号问题,也让代码布局更加美观。

还有更多

我为这个框架开发了诸多核心模块,比如文章中提到的控制流组件也作为其中一个核心模块实现了元素显示管理的能力,这些模块都能进一步提升框架能力并带给开发者流畅的开发体验。我想描述更多设计巧思给读者听,但这些略显枯燥的技术名词还是留到下次分享吧。

SSR

终于,在这个新版框架中迎来了我第一个完整的 SSR 方案,为此我写了另一个处理代码打包以及伺服器渲染的工具库:Tsukimi。它不是一个伺服器框架(至少目前还不是),而是一个根据输入网址并透过页面路由来输出目标页面 HTML 的工具。因此你可以直接使用 Bun 原生的伺服器框架:

// server.ts
import { Tsukimi } from 'tsukimi';
import { App } from './index.ts';

const tsukimi = new Tsukimi({
    entrypoint: './index.html',
    outDir: './dist',
    app: App,
    selector: 'body'
})

Bun.serve({
    port: 3000,
    routes: {
        '/*': async (req) => {
            const html = await tsukimi.render(req);
            return new Response(html, {
                headers: [
                    ['Content-Type', 'text/html'],
                    ['Content-Length', `${html.length}`]
                ]
            })
        }
    }
})

每当创建一个 Tsukimi 实例时,它会自动打包好所有的代码(透过 Vite),这些代码都是即将发送到客户端运行的。而同时,你的伺服器进程也在同时运行着那些客户端代码,每次请求都会构建一套完整的网页结构并在最后转换成 HTML 格式发送到客户端。

当然这一切并非一帆风顺,这始终是一个我尚未完全理解的领域,我依旧排除万难将这个功能实现出来了。目前也还有各种未知挑战等着我发现,但至少对我手头上的项目来说,这个框架和工具已经足够应付了。

回顾

望向过去几个月所面临的挑战,我其实学到了不少:

  • 利用抽象层节点实现控制流。
  • 在伺服器和浏览器环境中维护同一套代码。
  • 响应式嵌套数据的实现。
  • 根据渲染的元素生成对应所需的 CSS 并插入 HTML 中。
  • 在 SSR 中实现预取数据,避免渲染时访问数据库后又在客户端访问多一次。
  • 使用 APIs 控制 Vite 的打包行为,自动化构建客户端代码,并将文件路径插入到 HTML 中。
  • 将来自用户请求的 Cookie 贯穿整个渲染过程中的所有同域名请求,以实现渲染过程中的新请求不会丢失用户信息。
  • 将 OpenGraph 编写与页面组件布局函数结合,无需再独立写一套应对不同页面请求的生成 OpenGraph 标签代码。
  • 还有很多……

仔细想想,我学前端与后端已经步入第六个年头,从一开始使用 document.createElement('h1') 的土炮方式来构建网页,一步步理解浏览器和伺服器环境 JS 的差异,到现在终于完成了两者并行的框架代码,并将其用在我的所有项目之中。这一路走来我写过的代码变化是如此之大,那些曾经写过的乱七八糟的框架仿佛还历历在目(fluentx.ts,widget.ts,elexis),尽管重新造过这么多次的轮子,我依然还怀揣着那个手搓前端框架的梦,实在是对自己感到不可思议。

绝大部分前端工作者都在项目中选择使用主流框架,毕竟这才是稳妥的做法。而我也只是恰好觉得前端代码不应该变得如此臃肿,根目录里不应该存放一堆工具配置文件,我们总能把这些工具都以另一种更熟悉的方式融合成一个代码文件。这样的念头一旦产生,就踏上了这趟手搓框架的旅程。

也许保持干净简洁的依赖和根目录,就是一种对代码艺术的坚持吧。