《Bun上的可重复利用岛屿:陶器框架的设计启示》
以服务器优先的框架如何将 HTML 设为默认值,将 JavaScript 限制在可恢复的独立单元中,并处理静态导出、错误、CSP 以及部署问题。
网页上的大多数内容只是简单添加了少量交互控件,而主流的开发模式则是将整个页面作为 JavaScript 应用程序发送给浏览器。Stoneware 是一个基于 Bun 构建的新兴开源 TypeScript 框架,它采用了完全相反的思路:浏览器默认接收的是 HTML,组件必须主动选择后才会发送相应的 JavaScript 代码。了解其设计原理有助于理解“岛屿架构”、可恢复性、静态导出,以及框架工程中那些不太引人注目的方面,比如错误信息处理、安全性排序和部署打包等。读完之后,你应该能够判断何时适合采用以服务器为中心、基于“岛屿架构”的设计方式,以及在使用或构建此类架构时需要注意哪些潜在问题。
为何内容页面不应变成应用程序
想象一个典型的产品页面。它有标题、图片、描述、规格列表、价格、相关产品、用户评价以及网站导航。在所有这些元素中,真正能回应用户的或许只有两样:移动端菜单和“加入购物车”按钮。其余的都是服务器早已知道如何生成的静态内容。
无论是客户端渲染还是完全水合的方式,都仍需让浏览器下载、解析并执行代表整个页面的代码,只为让这两个控件能够正常使用。而服务器优先的思路则很简单:如果浏览器只接收所需部分的代码会怎样?
思维模型:HTML加上独立模块
该架构将页面划分为两种区域。大部分内容为服务器端渲染的HTML,而交互部分则形成独立的“岛屿”,即自带JavaScript的小型独立区域。这两者最终都会出现在浏览器的同一份文档中。
Web page
│
┌───────────┴───────────┐
│ │
HTML Islands
│ │
Server rendered JavaScript
│ │
└───────────┬───────────┘
│
Browser
其关键意义在于,交互性不再要求你必须部署整个应用程序。要让某个组件具备交互功能,只需提供该组件的代码即可,无需包含整个页面的代码。
基于Bun开发而非组装工具链
选择Bun并不意味着Node.js已经过时。Node拥有庞大的生态系统,支撑着大量生产环境中的JavaScript应用,它依然是一款非常出色的运行时环境。有趣的是,当框架围绕一个已内置大多数项目所需工具的运行时设计时,会发生哪些变化。
Bun 提供了 JavaScript 运行时,同时还包含包管理器、打包工具和测试运行器。对于框架开发者而言,这大大减少了额外的整合工作。无需在 Node 之上再叠加包管理器、独立的打包工具以及单独的测试运行器,该设计将所有这些组件视为一个统一的整体。
Bun
│
┌────────────┼────────────┐
│ │ │
Runtime Tooling Testing
│ │ │
└────────────┼────────────┘
↓
Stoneware
需要明确各组件的角色:Bun 是底层平台,而 Stoneware 是构建在其之上的框架。如需对各类运行时进行更全面的比较,可参阅Node.js、Deno 与 Bun 的对比。
重视 HTML 首要性
服务器端渲染已经存在很长时间了,因此“在服务器上渲染HTML”听起来并无特别之处。Stoneware背后的核心理念是:在浏览器需要了解应用程序的任何信息之前,服务器就应该生成真正有用的HTML。
以一个显示产品标题和价格的组件为例。
<ProductCard
title="MacBook Pro"
price={1999}
/>
这个组件无需浏览器存储其对应的JavaScript模型。服务器可以直接将其转换为纯标记语言:
<div class="product-card">
<h2>MacBook Pro</h2>
<span>$1999</span>
</div>
这样的输出已经完整。人们可以阅读它,搜索引擎爬虫可以对其建立索引,浏览器也可以立即渲染它。在内容传递过程中无需任何脚本。
明确选择使用JavaScript
现在再添加一个购物车按钮。与产品卡片不同,它需要响应点击操作、更新状态,还可能需要与API进行通信。
<AddToCart product={product} />
该组件用于定义客户端行为,因此它便成为一个独立的模块。页面的其余部分仍为HTML格式,最终呈现的页面效果如下:
Product page
│
├── Product title HTML
├── Product description HTML
├── Product image HTML
├── Product specifications HTML
│
└── Add to cart JavaScript island
该页面并非“无JavaScript”的。它只是有选择地使用了脚本,且这种选择是针对各个组件而非整个页面来进行的。这一区别很重要,因为它能让默认实现保持简单,同时让每一段实际使用的代码都成为明确、经过深思熟虑的选择。
独立模块的边界位置
一个岛屿象征着在服务器端渲染的内容与在客户端运行的功能之间的分界线。文档网站清晰地展示了这种结构:文章正文、标题、代码示例、图片、链接、导航和页脚都属于内容部分。仅有少数功能是真正具有交互性的:搜索功能、主题切换器、代码块上的复制到剪贴板按钮,以及可能的可展开导航树。
Documentation page
│
├── Article ─────────────── SSR
├── Code blocks ─────────── SSR
├── Images ──────────────── SSR
├── Navigation ──────────── SSR
│
├── Search ──────────────── Island
├── Theme switcher ──────── Island
└── Copy button ─────────── Island
这种以内容为主、仅包含少量交互区域的页面,正是该框架所设计的用途。
数据注入与可恢复性
一个合理的反对意见是,这一切不就是服务器端渲染吗?确实有这部分:服务器负责生成HTML。区别在于HTML到达客户端后会发生什么。
在传统的数据注入模式下,流程大致如下:
- 服务器发送HTML;
- 浏览器下载应用程序的JavaScript代码;
浏览器最终会重新构建一个它已经接收过输出的应用程序。从用户的角度来看,这项工作纯粹是额外的开销:像素早已显示在屏幕上了。
Stoneware则采用可恢复的独立区域作为设计基础。服务器会发送HTML以及这些交互式区域所需的全部状态,浏览器则从服务器停止的地方继续处理这些区域,而无需重新执行整个页面来获取那些状态。这样做的目的是避免小的交互区域迫使整个页面被完全重建。
不同的优化目标
可恢复性重新定义了性能问题。与其思考如何加快所有内容的初始化速度,不如考虑能完全避免多少浏览器端的处理工作。数据负载从“HTML加上应用程序的所有JavaScript代码再加上一次初始化过程”转变为“HTML加上仅用于交互的代码,从服务器渲染的状态直接恢复”。对于内容繁重的网站而言,这往往比任何初始化优化都能带来更大的优势。
如果你使用React,服务器组件背后也存在同样的考量;零包体渲染的架构原理介绍了这种实现方式,并进行了有用的对比分析。
以服务器优先并不等同于仅依赖服务器
以上内容并非针对客户端应用程序的反对意见。协作编辑器、设计工具、浏览器游戏以及复杂的交互式数据应用有着截然不同的需求,而且它们的大部分组件本质上都是交互式的。这种架构针对的是另一类应用:那些大部分内容都是在服务器端直接渲染、交互行为属于例外情况的页面。
静态导出基于同样的理念
既然HTML是默认格式,那么下一个自然的问题就是:对于那些每次请求内容都不会变化的页面,为何还要运行服务器?Stoneware可以将应用程序导出为静态文件,并将其交给CDN处理。
Stoneware application
│
▼
export
│
▼
dist/
│
▼
CDN
文档、营销页面以及产品目录通常可以提前制作好,直接从边缘节点提供。那些确实需要按请求处理逻辑的路由则仍可保持服务器端渲染方式。这样做的好处是,无需为整个应用更换编程模型即可实现路由在静态渲染与服务端渲染之间的切换。
动态路由需要明确的路径列表
一旦路由包含参数,静态导出就会变得困难。像 /products/[sku] 这样的路由原则上可以匹配无限多的URL,因此导出工具无法判断应生成哪些页面。于是框架会要求该路由列出所有可能的路径:
export function staticPaths() {
return products.map(product => ({
sku: product.sku
}));
}
利用该列表,导出工具会为每个已知的SKU生成一个真实的HTML文件,例如dist/products/laptop-1/index.html。这正是框架设计超越JSX渲染能力的体现:框架必须理解路由、数据、构建步骤以及部署目标之间的关联。需要提前考虑的一个实际边界情况是,当某个动态路由完全没有staticPaths()时该如何处理;框架要么明确报错,要么保持对该路由的服务器端渲染,而默默跳过它则是最糟糕的选择。
错误信息也是渲染器的一部分
从根本上说,渲染器的作用是将组件转换为HTML。难点在于它需要处理极其多样的子元素和属性:文本与数值、数组与null、普通元素与嵌套组件、信号与属性、抛出的错误、异步操作,以及那些根本无法被渲染的值。
一个常见的错误是本想写<span>{product.name}</span>,却误写了<span>{product}</span>。那种通用的“无法渲染对象类型的值”提示几乎无法指引你找到问题所在。而Stoneware则会明确指出有问题的具体值:
Cannot render a plain object with keys: id, title, price.
随后还会显示导致该问题的组件路径:
in <span>
in <Price>
in <ProductCard>
in <Home>
列出对象的键可以提示你可能想要的属性,而组件路径则能指出需要打开的具体文件。评判一个框架不仅要看它在正常流程下的表现,还要看它能在出现异常时多快帮你解决问题。
当微基准测试掩盖了真实成本
最初收集组件路径意味着要将许多渲染操作包裹在try/catch结构中。某个孤立的微基准测试显示这种额外开销可以忽略不计。然而在真实的页面渲染场景中,对每个元素都进行这样的处理会使渲染成本增加约38%。
对于任何进行过JavaScript基准测试的人来说,这个现象都很常见:在规模极小且重复性高的测试中,引擎的优化编译器可以消除或提前处理掉你试图测量的大部分操作,因此得到的测试结果反映的是经过优化后的代码版本。
当运行时优化机制消除了正在测量的内容时,微基准测试可能会产生误导。
解决方案是从架构层面进行的。错误跟踪的开销被限制在组件边界之内,各个元素则使用成本更低的保存与恢复操作来维持状态。通过对实际渲染结果的A/B测试,发现两者之间并无显著差异。更重要的启示是:渲染器的性能不仅关乎原始速度,还在于在添加对开发者友好的功能时避免性能下降,而且任何关于性能的宣称都应在具有代表性的工作负载上进行验证。
安全默认设置与处理流程顺序
一个基础应用不应要求开发者在上线前记住所有的安全机制。Stoneware默认提供了CSRF防护和内容安全策略支持等功能。
请求处理流程的顺序是经过精心设计的:
- 首先执行CSRF防护;
- 接着运行应用中间件;
- 随后进行路由匹配和页面渲染;
- 所有操作最终通过单一响应出口输出。
如果应用中间件在CSRF检查之前运行,用户代码就有可能通过提前返回或重写请求等方式,绕过框架级的安全限制。将这种顺序内置在框架中,而非仅靠文档说明并期望所有人遵守,正是框架应当制定的规则。单一的响应出口还意味着诸如CSP之类的头部信息能够一致地应用于每个响应中。
在不禁用严格CSP的情况下对其进行扩展
限制性的策略是不错的默认选择,但实际网站还需要加载分析工具、支付组件、API、网页字体和地图。常见的问题是开发者遇到被屏蔽的脚本后便完全关闭CSP功能。更好的设计应允许在保持默认策略其他部分不变的情况下单独扩展某些指令:
csp: {
scriptSrc: ["https://www.googletagmanager.com"],
connectSrc: ["https://www.google-analytics.com"],
imgSrc: ["https://www.google-analytics.com"],
}
其原则是采用安全的默认设置,并为第三方内容提供明确的、针对每项指令的允许列表。在设计此类API时,需明确指定所提供的内容是补充默认指令还是替代它,并将决策记录下来,因为这两种行为都有可能发生,而其差异会带来安全影响。
最棘手的错误往往出现在渲染器之后
框架的范畴并不仅限于渲染器。代码必须经历整个流程:源代码、编译器、渲染器、构建过程、资源处理、部署、CDN,最终到达浏览器。一些最棘手的问题往往出现在这一流程的末端,远离人们通常认为的“框架”所在部分。
为 Vercel 打包岛屿资源
0.1.8 版本的发布改变了 Stoneware 向 Vercel 部署的方式。针对该目标,生成的客户端代码块现在以 base64 数据的形式嵌入到服务器包中,从而成为平台部署时的追踪包的一部分,并通过 /_stoneware/* 路径提供。
Stoneware build
↓
server bundle
├── server code
├── CSS assets
└── island assets
↓
Vercel
↓
/_stoneware/*
此功能为可选配置,且仅适用于 Vercel 平台。容器部署时文件已存储在磁盘上,没有必要在服务器包中再保存每个文件的副本。总体而言,不同托管平台在部署时包含的内容各不相同,因此处理资源通常需要针对特定平台的策略,而非通用解决方案。
验证修复的测试
该项目拥有超过 500 个自动化测试,涵盖渲染器、路由器、islands 和 signals 功能、样式表与资源服务、静态导出、CSRF 和 CSP 安全机制、部署目标、错误报告,以及路径遍历尝试、二进制文件和格式错误的资源路径等恶意或异常输入情况。
测试数量本身并非关键,更有用的标准是:
回归测试应当能够证明在修复之前该功能本来会失败。
在代码变更前后都能通过测试的方案虽然能够记录功能行为,却无法防止错误再次出现。通过检查新测试在旧代码下是否失败,这种简单的做法能让测试套件更加可靠。
使用编码助手而不外包设计
Stoneware的大部分功能都是通过让Claude充当编码助手来实现的。它在处理日益庞大的代码库、编写重复性的基础设施代码、生成测试用例、分析故障、重构代码、记录架构以及排查边缘情况方面表现得尤为出色。
但它无法独自决定应采用何种框架,那些关键问题都属于架构层面:
- 对于每条路由,SSR还是静态导出才是合适的方式?
staticPaths() 的动态路由,其导出行为应如何?开发人员可以研究这些问题并选择相应的解决方案,但仍需有人对设计进行质疑并验证结果。
合理并不等同于正确
编码工具能够非常快速地生成看似合理的实现方案,这种速度固然宝贵,但也意味着它可能很快出错。Vercel的资产问题正是这种循环的体现:第一次修复将生成的资产复制到public/目录中,虽然看起来合理且通过了本地测试,但实际部署后才发现这一假设是错误的。第二次尝试则改变了部署模式本身,而边缘情况测试又暴露了该版本中的另一个漏洞。
这种循环属于常规的软件工程现象,并非人工智能的故障。变化的是每次迭代的速度,这反而使得在真实目标环境中进行端到端验证变得更加重要,而非不那么重要。
为何运行时选择的重要性不仅限于速度
将这个故事简化为“Bun比Node更快”会忽略其核心意义,而且纯粹的速度并非框架的设计理念。更有趣的实验是在从一开始就拥有现代运行时和集成工具链的前提下来设计框架。Bun将运行时、包管理、打包和测试功能结合在一起,为这类架构探索提供了便捷的基础。
该架构的适用场景
当页面大部分内容为静态内容,仅有一部分是交互式内容时,这种方案尤为出色:
- 文档:文章和代码示例在服务器端渲染;搜索功能、主题切换器和复制按钮则为独立模块。
- 电子商务:产品详情、图片和SEO相关内容在服务器端渲染;购物车和筛选功能则为独立模块。
哪些场景不适合使用该工具
如果你的产品实际上是在浏览器中运行的桌面应用,比如协作编辑器、图形工具、游戏、高度交互式的控制面板,或是几乎所有组件都在客户端运行的应用,那么能够以HTML形式呈现的页面内容占比很小。在这种情况下,独立模块模式只会增加不必要的限制而无法带来实际好处,以客户端为中心的架构可能更为合适。框架可以明确表达自己的优势,但不应假装适用于所有类型的工作负载。
尝试使用
在撰写本文时,Stoneware仍处于0.1.x版本系列,因此功能可能还不够完善,建议查看仓库以了解最新状态。未来的改进计划包括更多集成功能、更完善的诊断与开发警告机制、更多示例和部署目标、更详尽的文档以及更多实际应用场景。要搭建项目并启动开发服务器,请执行以下操作:
bun create stoneware my-app
cd my-app
bun dev
一个不错的初步尝试是创建内容丰富但规模较小的项目:博客、文档网站、产品目录、作品集或企业官网。在开发过程中,要不断思考页面中究竟有多少部分真的需要JavaScript。
关键要点
- 将HTML视为默认输出格式,仅让客户端JavaScript成为各组件的可选功能;这样页面只会部分使用脚本,而不会变成一个完整的应用程序。