下载、上传与延迟测速
测速功能使用 @cloudflare/speedtest 引擎,在浏览器中组织下载、上传、延迟和抖动测量。实现先执行固定的小规模预览,再切换到使用启动时配置的独立正式测试,并将成功结果发送给应用内事件消费者。
目的与范围
本页覆盖测速会话的构造、预览与正式测试切换、实时读数、进度、暂停与恢复、结果评分和完成事件。核心依据是 speedtest-session.js 与 SpeedTest.vue 的已读取实现。
IP 历史、Globalping、成就和报告仅说明本功能向它们提供数据的边界,不展开其内部实现。当前提供的目录上下文没有这些相邻页面的准确路径,因此不构造未验证的 Wiki 链接。SDK 内部请求调度、测速服务器选择、重试策略,以及未读取的界面默认配置不属于本页已核验内容。
概述
测速过程有两个相互独立的层次:
- 会话层:
createSpeedTestSession接受引擎构造器和测量配置,管理预览、正式测试以及引擎回调生命周期。 - 界面层:
engineMethods、setupTestEngine和speedTestController管理 Vue 状态、图表、成功与失败判定、评分和应用事件。
预览只测两次延迟和一次 100,000 字节下载,不包含上传。正式测试重新创建引擎,按延迟、下载、上传顺序提交用户配置。预览的测量结果不会作为正式引擎的原始样本继续累积;不过界面会保留已经显示的预览读数,直到对应正式指标产生有效样本。
这种分离同时满足两个目的:较早显示可用反馈,以及让正式测试采用独立样本。它也意味着读数暂时可来自不同阶段,不应将测试中的某一帧视为最终报告。
架构与数据边界
Sources: speedtest-session.js、SpeedTest.vue
engineMethods.reset() 使用 markRaw 包装新会话;状态重置则由控制器负责。这使“建立引擎”和“清理旧展示状态”成为两个明确职责,而不是在构造引擎时隐式修改所有界面数据。
连接信息是另一条辅助数据链:getIPFromSpeedTest() 请求 Cloudflare trace,解析 ip、colo、loc,验证 IP,随后动态加载机房信息并转换国家名称。失败会记录日志并返回 null。loadConnection(currentRun) 只在尚无连接 IP 时发起查询,并仅在轮次仍匹配时写入状态和调用 store.updateAllIPs。该入口把测速获得的 IP 提供给其他功能,但不证明其他功能的存储方式。
依据:SpeedTest.vue、SpeedTest.vue。
会话生命周期与核心控制流
1. 启动时保存正式测试配置
会话创建时立即把 packages 的值复制到 measurements。因此在预览期间修改配置对象,不会改变已经构造的正式测量列表。
1 const measurements = [
2 { type: 'latency', numPackets: packages.latency.count },
3 { type: 'download', bytes: packages.download.bytes, count: packages.download.count },
4 { type: 'upload', bytes: packages.upload.bytes, count: packages.upload.count },
5 ];Source: speedtest-session.js
这段代码同时定义了正式阶段的顺序,以及界面配置到 SDK 字段的映射:延迟使用 numPackets,吞吐测试使用 bytes 与 count。封装没有执行输入范围校验,因此不能把它当成已经验证参数的公共服务端 API。
2. 预览与正式引擎隔离
所有引擎均使用 autoStart: false。预览额外设置最小带宽请求时间为零、跳过下载最小时长限制、关闭下载与上传负载延迟测量,并令 logAimApiUrl 为 null。正式引擎只显式获得 autoStart 和 measurements,其他行为交给 SDK 默认值;这些默认值未在已读仓库代码中展开。
Source: speedtest-session.js
切换之前的最后一次 onResultsChange() 很重要:SDK 排队的最后样本先被读取,之后才替换当前结果对象。正常预览结束不会触发界面的最终完成处理;只有正式结束,或者已失败的预览结束,才将结果交给会话的 onFinish。
3. 暂停、恢复与重新测量
speedTestController 根据状态分支处理操作:
| 当前状态 | 已核验行为 |
|---|---|
running | 调用 testEngine.pause(),设置 paused 并返回 |
paused | 调用 testEngine.play() 并返回,不重建会话 |
| 其他状态 | 增加 runId,销毁仍存在的旧会话,重置并销毁图表,重置指标和评分,构造新会话并注册回调 |
新一轮把基本指标置零、负载延迟与评分置为 '-',进度置零、阶段置为 probe。本次读取到控制器的 setupTestEngine() 为止,后续首次启动及组件卸载的具体调用顺序未核验,不作推断。
会话的 play() 在已销毁或已经运行时直接返回;pause() 会暂停当前引擎并通知界面;destroy() 标记销毁、解绑回调并暂停当前引擎,没有向界面再次发送暂停状态。
依据:SpeedTest.vue、speedtest-session.js。
实时读数、进度与最终值
样本门控而非直接读取零值
getSpeedTestSampleCounts(raw) 统计延迟 timings 数量,并累加下载、上传各 bucket 的 timings.length。getSpeedTestLiveValues(results) 只有在存在对应样本时才调用 getter;抖动至少需要两个延迟样本。
1 const metrics = [
2 ['downloadSpeed', 'getDownloadBandwidth', counts.download, 1e6],
3 ['uploadSpeed', 'getUploadBandwidth', counts.upload, 1e6],
4 ['latency', 'getUnloadedLatency', counts.latency, 1],
5 ['jitter', 'getUnloadedJitter', counts.latency >= 2, 1],
6 ];
7 return Object.fromEntries(metrics.flatMap(([key, getter, hasSamples, divisor]) => {
8 if (!hasSamples) return [];
9 const value = results[getter]();
10 return Number.isFinite(value) ? [[key, Number((value / divisor).toFixed(2))]] : [];
11 }));Source: speedtest-session.js
返回对象仅含有效且可更新的字段。界面用 Object.assign 合并该对象,因此没有正式样本的字段不会被 SDK 的空样本零值覆盖。下载和上传除以 1e6,采用十进制 Mb/s;各指标舍入到两位小数后再转换为数值,不保证始终显示两个小数位。
实时更新在 finished 或 error 状态下直接返回;读取与图表更新异常仅输出日志。图表调用同时获得四个显示值和 results.raw,但图表内部采样、绘制和销毁机制未在本页读取范围内。
依据:SpeedTest.vue。
进度是阶段状态,不是流量百分比
正式测试将延迟、下载和上传各分配 100 / 3 的权重:未开始为零、开始但未完成为半阶段、完成为整阶段。预览不参加该计算。界面收到运行通知时会先把进度提升到至少 5%,表示已经启动。
进度使用旧值与新值的最大值,因此不会回退,并将计算值限制在 100% 内。这是阶段式反馈,不是按已发送字节、耗时或样本数量计算的准确完成比例。
依据:SpeedTest.vue、SpeedTest.vue。
最终汇总与实时更新的语义不同
最终结果直接来自 results.getSummary():
| 字段 | 最终转换 | 缺失值处理 |
|---|---|---|
downloadSpeed、uploadSpeed | download、upload 除以 1,000,000,保留至两位小数 | null / undefined 变为 0 |
latency、jitter | 使用同名摘要值,保留至两位小数 | null / undefined 变为 0 |
downLoadedLatency、upLoadedLatency | 保留至两位小数 | 保留 '-' |
因此最终基本指标的 0 可能表示没有样本,而不是确实测得零;负载延迟则保留明确的未测占位符。实时阶段使用 Number.isFinite 筛选,但最终格式化函数只专门处理空值,这两条路径的校验强度并不相同。
依据:SpeedTest.vue。
完成判定、评分与事件输出
完成回调不等于成功
组件明确考虑 SDK 在所有请求失败后仍触发 onFinish 的情况。以下原始代码先读取摘要,再检查下载、上传、延迟是否至少有一个有限正数:
1 const summary = results?.getSummary?.() ?? {};
2 const measured = (v) => Number.isFinite(v) && v > 0;
3 const hasData = [summary.download, summary.upload, summary.latency].some(measured);
4 if (state.speedTest.status === 'error' || !hasData) {
5 state.speedTest.status = 'error';
6 testEngine.onRunningChange = () => {};
7 testEngine.onResultsChange = () => {};
8 testEngine.onError = () => {};
9 testEngine = null;
10 return;
11 }Source: SpeedTest.vue
失败分支不发出成功事件,也不将进度强行设为完成。成功分支则设为 finished、进度设为 100、关闭部分迟到回调并更新最终结果。
这里是“至少一个有效测量”的成功标准,而不是“所有阶段都有有效测量”。例如只有延迟为有限正数、上传下载没有样本,仍可通过 hasData 检查;抖动不独立参与成功判定。
体验评分包含乐观丢包假设
最终评分来自 SDK 的 getScores(),包括 streaming、gaming 和 rtc 的分数与分类名称。调用前会改写结果对象的 getSummary:
const origGetSummary = results.getSummary.bind(results);
results.getSummary = () => ({ ...origGetSummary(), packetLoss: 0 });
const scores = results.getScores();Source: SpeedTest.vue
源码注释说明此流程不执行需要 TURN 配置的丢包测量,并希望避免 SDK 对未测丢包施加评分惩罚。因而这里注入的 packetLoss: 0 是评分假设,不是实测零丢包。解释游戏或实时通信质量时必须保留这一限制。
仅在 scores?.streaming 存在时,组件设置 hasScores 并读取三个体验类别;这一分支也依赖 SDK 同时提供 gaming、rtc 对象。
依据:SpeedTest.vue。
speedtest:finished 事件契约
成功处理在释放 testEngine 引用后设置 hasEverSettled,并发送事件。负载包含:
| 数据 | 内容 |
|---|---|
| 基础测量 | downloadSpeed、uploadSpeed、latency、jitter |
| 负载延迟 | downLoadedLatency、upLoadedLatency,可能为 '-' |
scores | { streaming, gaming, rtc },无评分时为 null |
qualities | 同三个键的分类名称,无评分时为 null |
connection | state.connection 的浅拷贝 |
源码注释将此事件与成就和报告消费者联系起来;本页只保证生产端的字段与触发条件。报告数据库、缓存、历史保存、导出格式和消费者过滤行为未从对应实现核验,不能据此宣称测速结果已经持久化。
依据:SpeedTest.vue。
配置选项
以下区分会话内部固定配置与调用方传入值,避免将预览参数误认为正式测试默认值。
| 配置项 | 类型 | 默认值或本实现赋值 | 作用 |
|---|---|---|---|
packages.latency.count | 预期为 number | 调用方提供;默认值未核验 | 正式测量 numPackets |
packages.download.bytes | 预期为 number | 调用方提供;默认值未核验 | 正式下载单次字节配置 |
packages.download.count | 预期为 number | 调用方提供;默认值未核验 | 正式下载次数配置 |
packages.upload.bytes | 预期为 number | 调用方提供;默认值未核验 | 正式上传单次字节配置 |
packages.upload.count | 预期为 number | 调用方提供;默认值未核验 | 正式上传次数配置 |
autoStart | boolean | 两阶段均显式为 false | 由会话显式控制启动 |
预览 latency.numPackets | number | 2 | 固定的小规模延迟采样 |
预览 download.bytes / count | number | 1e5 / 1 | 固定预览下载 |
预览 bypassMinDuration | boolean | true | 跳过该测量的最小时长限制 |
预览 bandwidthMinRequestDuration | number | 0 | 允许快速预览产生带宽读数 |
预览 measureDownloadLoadedLatency | boolean | false | 关闭预览下载负载延迟 |
预览 measureUploadLoadedLatency | boolean | false | 关闭预览上传负载延迟 |
预览 logAimApiUrl | null | null | 禁用预览的该日志目标 |
配置证据:speedtest-session.js、speedtest-session.js。预览未配置日志目标不等于整个测速过程没有任何外部日志;正式阶段的 SDK 默认行为未读取。
内部 API 参考与用法
这些是 JavaScript 函数和回调,不是 HTTP API。源码没有静态类型声明,下述参数类型说明来自实际字段访问,不构造额外签名。
createSpeedTestSession(Engine, packages)
- 参数:
Engine是可通过new Engine(options)实例化的构造器;packages提供上一节列出的三个阶段配置。 - 返回:具有
results、isProbegetter、三个控制方法及五个可覆盖回调的会话对象。 - 控制方法:
play()、pause()、destroy(),均不显式返回业务值。 - 回调:
onRunningChange(boolean)、onPhaseChange(phase)、onResultsChange()、onFinish(results)、onError(error)。 - 阶段值:预览阶段统一为
'probe';正式阶段转发 SDK 的measurement.type。 - 异常:构造、配置访问和引擎方法调用没有在该封装内统一捕获;不存在可从源码保证的自定义异常类型。
组件中的真实构造用法如下:
reset() {
return markRaw(createSpeedTestSession(SpeedTestEngine, state.config.package));
},Source: SpeedTest.vue
getSpeedTestSampleCounts(raw)
返回包含 latency、download、upload 的样本计数对象。缺失顶层测量结果时按零处理;但 raw 本身必须存在,存在的下载、上传 bucket 也需要具有 timings 数组。它不是任意外部输入的通用验证器。
getSpeedTestLiveValues(results)
接受具有 raw 和对应 getter 的 SDK 结果对象,返回只包含可用指标的局部更新对象。无样本和非有限值被省略;基础延迟与抖动分别调用 getUnloadedLatency() 和 getUnloadedJitter()。
上述两个函数依据:speedtest-session.js。
失败模式、边界情况与并发
过期回调隔离
prepare() 为每一个实际引擎保存 current,每次转发前检查 !disposed && engine === current。因此已销毁会话的回调,或预览引擎在正式引擎接管后的迟到回调,不再转发。
detach() 把所有引擎回调替换为空函数而非 null;组件完成处理也采用类似方式。这样可以兼容 SDK 定时器无条件调用回调的行为。该机制是异步事件隔离,不是线程锁,也不能被解读为已证明 SDK 取消了每一个在途网络请求。
依据:speedtest-session.js、speedtest-session.js、SpeedTest.vue。
两层错误处理的差异
| 情形 | 会话层 | 界面层 |
|---|---|---|
| 任意当前引擎错误 | 将 failed 置为 true 并转发 | 所有错误写入控制台 |
不含 ICE 的字符串错误 | 同上 | 立即设为 error |
含 ICE 的字符串、非字符串错误 | 同上 | 不在 onError 中立即修改状态 |
| 预览失败后触发结束 | 不再启动正式引擎,转发完成结果 | 由已有错误状态与 hasData 决定成功或失败 |
| 没有任何有限正数的下载、上传、延迟 | 转发完成 | 判为失败,不发送成功事件 |
| trace 请求或 IP 校验失败 | 与测速会话无直接耦合 | 连接查询记录日志并返回 null |
一个值得注意的边界是:会话层对任何错误都设置 failed,但界面只对一部分错误立即进入错误状态。因此,被界面忽略的预览错误仍会阻止正式阶段启动;若预览结果含有效数据且界面未处于 error,完成判定可能仍通过。不能把 ICE 过滤描述为“错误对整个流程完全无影响”。
依据:speedtest-session.js、SpeedTest.vue、SpeedTest.vue。
轮次与连接信息
控制器开始新轮次时增加 runId;连接查询只在返回时仍匹配该轮次才落入状态,从而避免旧异步查询覆盖新一轮信息。但 loadConnection 在已有 state.connection.ip 时跳过查询,因此仅依据该方法不能保证每次重新测速都会刷新出口 IP。网络、代理或出口切换后,应核查调用方是否重置了连接状态。
依据:SpeedTest.vue、SpeedTest.vue。
性能、运维与扩展建议
- 流量成本:预览至少配置一次 100,000 字节下载,并额外执行延迟请求;正式流量取决于调用方的
bytes与count。源码未展开 SDK 的额外采样和请求规则,不能仅凭配置推导精确总流量。 - 实时计算:每次结果变更会重新遍历下载、上传的结果 bucket 统计样本,再更新指标和图表。已读的更新路径没有额外节流;实际回调频率由 SDK 决定。
- 排障入口:区分连接查询日志、
updateSpeedInRealTime日志和测速引擎错误日志。连接信息失败与无测速样本是不同故障,不应混为一谈。 - 超时与重试:trace 请求使用
fetchWithTimeout,但默认超时值及内部重试未读取;会话封装没有显式重试循环。不能据此断言 SDK 没有自己的重试。 - 扩展引擎:
Engine作为参数传入,便于替换实现或提供测试替身。替代实现必须匹配构造选项、回调、控制方法和results数据形状;不能只实现play()和pause()。 - 扩展指标:新增测量需要同步修改正式测量列表、样本门控、最终摘要转换、进度权重和事件负载。当前进度硬编码为三个阶段,添加第四阶段而不调整权重会使进度失真。
- 评分说明:若未来增加真实丢包测量,应重新审视
packetLoss: 0的覆盖逻辑,避免实际结果仍被乐观假设替换。
这些建议分别基于已述会话配置、实时更新、三阶段进度和评分路径,不表示仓库已经实现相应扩展。
测试证据边界
本次限额内未读取测速会话专用测试,不能宣称暂停、预览失败、重复启动或过期回调已有自动化覆盖。适合后续核验的场景包括:预览不污染正式原始样本、正式空样本不覆盖预览值、抖动至少两个样本、销毁后的迟到回调被忽略,以及“存在少量有效数据但某阶段失败”的最终判定。这些是由控制流导出的测试建议,而非已确认测试清单。
相关链接
- 会话控制与两阶段切换:修改生命周期、预览行为和配置快照时的入口。
- 实时指标与进度更新:解释显示值与进度时的入口。
- 完成判定、体验评分和事件生产端:对接报告、成就或其他结果消费者时的契约依据。