Astro 基准测试中的 @benchmark/timer 适配器:用毫秒计时器替代页面输出,精确度量服务端渲染耗时
@benchmark/timer 是 Astro 官方仓库中 benchmark/ 基准测试套件的一部分,它实现了一个"像 @astrojs/node 但不返回页面 HTML、而是返回页面渲染耗时(毫秒)"的专用适配器,仅供内部性能基准测试使用。本文以该包的 README 说明为骨架,结合其完整源码与基准测试驱动脚本,讲清楚这个计时适配器的注册机制、计时实现、在 astro build/astro preview 流程中的位置,以及统计口径(每页 100 次采样的均值、标准差与最大值),帮助读者理解 Astro 是如何量化"每次请求的服务端渲染时间"的,并借鉴其测量方法。
它是什么:一个只输出耗时的适配器
benchmark/packages/timer/README.md 对 @benchmark/timer 的全部官方定义只有三行:
Like
@astrojs/node, but returns the rendered time in milliseconds for the page instead of the page content itself. This is used for internal benchmarks only.
即:行为上类似 @astrojs/node 适配器,但对每个页面的响应体不再是渲染后的 HTML,而是一个纯文本数字——这次渲染所花费的毫秒数。该包被明确标注为"仅用于内部基准测试"(used for internal benchmarks only),这也与其 package.json 中的 "private": true、"version": "0.0.0" 和 keywords: ["astro-adapter"] 一致:它不会发布到 npm,只在 Astro monorepo 内通过 workspace:* 协议被 benchmark/package.json 引用。
整个 benchmark/ 目录是 Astro 的主基准测试套件,benchmark/README.md 指出它暴露了 astro-benchmark CLI 命令(astro-benchmark --help 可查看全部命令)。在该体系中,@benchmark/timer 承担的角色是"计时探针":先用它构建并预览一个标准测试项目,再通过 HTTP 请求逐页采集渲染耗时,最后做统计分析。
包结构:两个入口文件与一个适配器
package.json 的 exports 字段声明了四个出口:
{
"exports": {
".": "./dist/index.js",
"./server.js": "./dist/server.js",
"./preview.js": "./dist/preview.js",
"./package.json": "./package.json"
}
}
.(即dist/index.js,来源 src/index.ts):在用户的astro.config.*中作为 integration 引入,负责注册适配器;./server.js(来源 src/server.ts):SSR 服务入口,真正的"计时 handler";./preview.js(来源 src/preview.ts):astro preview使用的预览服务器入口。
构建脚本为 "build": "astro-scripts build \"src/**/*.ts\" && tsc",依赖上只有 server-destroy(预览服务器关闭时用于销毁 socket),并通过 peerDependencies 与 devDependencies 声明了对 astro: workspace:* 的依赖——再次印证它只服务于 monorepo 内部的 Astro 版本,这也是使用它的前提:必须能解析到工作区内的 astro 包,外部项目无法直接安装复用。
集成与适配器注册:src/index.ts 的实现细节
src/index.ts 对外导出两个东西:一个 getAdapter() 工厂和一个默认的 createIntegration() 工厂。
import type { AstroAdapter, AstroIntegration } from 'astro';
export function getAdapter(): AstroAdapter {
return {
name: '@benchmark/timer',
serverEntrypoint: '@benchmark/timer/server.js',
previewEntrypoint: '@benchmark/timer/preview.js',
exports: ['handler'],
supportedAstroFeatures: {
serverOutput: 'stable',
},
};
}
export default function createIntegration(): AstroIntegration {
return {
name: '@benchmark/timer',
hooks: {
'astro:config:setup': ({ updateConfig }) => {
updateConfig({
vite: {
ssr: {
noExternal: ['@benchmark/timer'],
},
},
});
},
'astro:config:done': ({ setAdapter, config }) => {
setAdapter(getAdapter());
if (config.output === 'static') {
console.warn(`[@benchmark/timer] \`output: "server"\` is required to use this adapter.`);
}
},
},
};
}
可以从源码中读出三个关键设计点:
- 适配器元信息。
AstroAdapter声明了serverEntrypoint: '@benchmark/timer/server.js'与previewEntrypoint: '@benchmark/timer/preview.js',与上面exports字段一一对应;exports: ['handler']表明该 server 入口只向 Astro 运行时暴露一个handler函数;supportedAstroFeatures.serverOutput: 'stable'声明它支持服务端输出能力。 astro:config:setup钩子。通过updateConfig把@benchmark/timer加入 Vite 的ssr.noExternal,让该包在 SSR 构建时走打包而非外部 require。从源码结构看,这是因为它依赖工作区内的astro产物,需要与 Astro 核心一起被打进 bundle,避免在 SSR 运行期按 Node 模块解析失败。astro:config:done钩子与前置条件。在配置完成后调用setAdapter(getAdapter())完成注册;同时检查config.output === 'static',并打印警告`output: "server"` is required to use this adapter。这正是 README 中"像@astrojs/node"的具体体现:计时逻辑运行在服务端渲染路径上,静态输出模式没有请求级渲染过程,自然无从计时。
计时核心:src/server.ts 如何用 performance.now() 包络一次渲染
真正的测量逻辑只有十几行,位于 src/server.ts:
import type { IncomingMessage, ServerResponse } from 'node:http';
import { createApp } from 'astro/app/entrypoint';
import { createRequest } from 'astro/app/node';
const app = createApp();
export async function handler(req: IncomingMessage, res: ServerResponse): Promise<void> {
const start = performance.now();
await app.render(
createRequest(req, {
allowedDomains: app.manifest.allowedDomains,
}),
);
const end = performance.now();
res.write(end - start + '');
res.end();
}
调用链是:astro/app/entrypoint 的 createApp() 创建应用实例 → 收到 Node IncomingMessage 后用 astro/app/node 的 createRequest() 转换为 Astro 内部 Request(并透传 app.manifest.allowedDomains 作为 fetch 允许的域名)→ await app.render(...) 完成一次完整的服务端渲染 → 用 performance.now() 差值得到毫秒数 → res.write(end - start + '') 把这个数字作为纯文本响应体写出。
由此可以明确它的测量口径:计时范围是 app.render() 这一次请求在服务器进程内的执行时间,不包含客户端网络往返、浏览器解析与前端框架水合;由于基准脚本从本机发起请求(见下文),网络开销也基本被排除在统计之外。
配套的 src/preview.ts 则实现了 astro preview 所需的 CreatePreviewServer:动态 import(serverEntrypoint) 拿到 handler,用 node:http 的 createServer(ssrHandler) 创建服务器并 listen(port, host),再通过 server-destroy 的 enableDestroy(server) 支持 stop() 时销毁服务器;closed() 返回的 Promise 在服务器 close 时 resolve、error 时 reject。这与 @astrojs/node 等正式适配器的预览服务形态一致,所以 README 才会说它"like @astrojs/node"。
基准套件如何驱动它:生成项目、百次采样、统计分析
生成带 timer 的测试项目
benchmark/make-project/render-default.js 负责"造项目":它写入一套标准页面/组件文件后,动态生成一份 astro.config.js:
import { defineConfig } from 'astro/config';
import timer from '@benchmark/timer';
import mdx from '@astrojs/mdx';
export default defineConfig({
integrations: [mdx()],
output: 'server',
adapter: timer(),
});
注意这里 adapter: timer() 调用的是 integration 的默认导出——即上一节 createIntegration() 返回的对象,由它内部的 astro:config:done 钩子再调用 setAdapter(getAdapter())。同时 output: 'server' 也正好避开了前面提到的静态模式警告。
构建、预览与采集
benchmark/bench/render.js 的 run() 编排了完整流程:
- 在项目目录执行
astro build(astroBin由 benchmark/bench/_util.js 解析为工作区内astro包的bin/astro.mjs,保证测的是当前源码而非发布版本); - 执行
astro preview --port 4322并waitUntilBusy(port)等待服务就绪; - 调用
benchmarkRenderTime()采集数据; - 以
SIGTERM终止预览进程,把结果写入 JSON 文件并打印 Markdown 表格。
benchmarkRenderTime() 的统计口径是:对每个页面连续发起 100 次请求,每次通过 fetchRenderTime() 读取响应体(把 timer 返回的纯文本毫秒值用 +data 转成数字)并压入数组;随后交给 calculateStat() 计算三项指标:
const avg = numbers.reduce((a, b) => a + b, 0) / numbers.length;
const stdev = Math.sqrt(
numbers.map((x) => Math.pow(x - avg, 2)).reduce((a, b) => a + b, 0) / numbers.length,
);
const max = Math.max(...numbers);
return { avg, stdev, max };
最终通过 markdownTable 输出 Page / Avg (ms) / Stdev (ms) / Max (ms) 四列表格,均值与标准差保留两位小数。也就是说,@benchmark/timer 提供的每次"单请求耗时"读数,最终被汇总成可跨版本对比的统计量。
小结:可借鉴的计时探针模式
把 README 的一句话结论展开后,@benchmark/timer 的完整图景是:一个私有 Astro 适配器,用 performance.now() 在 app.render() 两侧打点、以纯文本返回耗时;通过 astro:config:done 钩子注册、以 output: 'server' 为硬性前提;由 benchmark/ 套件的"生成项目 → build → preview → 每页 100 次采样 → avg/stdev/max 统计"流水线驱动,产出可重复对比的渲染耗时数据。其限制也很明确:private: true 且依赖 workspace:* 的 astro,只能在 Astro 仓库内部使用;它度量的是服务端单次请求渲染耗时,不包含网络、浏览器端行为。如果你需要在自己的项目中做类似的 SSR 耗时测量,"在渲染调用两侧用 performance.now() 打点 + 多次采样求均值与极值"这一模式可以直接参照实现。
补充一点仓库内的观察:同级的 benchmark/packages/adapter 包 README 标题也写作 # @benchmark/timer(可视为文档笔误),但其 integration 内部声明的名称是 @benchmark/adapter、server 入口为 @benchmark/adapter/server.js,与本文介绍的 @benchmark/timer 是两个不同的包,引用时请注意区分。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00