
1. 项目概述当k6脚本遇上ES6集合的“水土不服”如果你正在用k6做性能压测并且习惯性地在脚本里写new Set()或new Map()来去重、缓存数据那么大概率会在执行时遇到一个让人困惑的报错ReferenceError: Set is not defined。这不是你的代码写错了而是k6运行时环境的一个核心限制k6的JavaScript引擎并不完整支持ES6标准其中就缺失了Set和Map这两个常用的集合类型。我第一次踩到这个坑时正在为一个API设计压测场景需要统计一批不重复的用户ID。本能地用了Set结果脚本直接跑不起来控制台一片红。这感觉就像你准备开车上路却发现方向盘被锁死了——工具本身没问题但你习惯的操作方式行不通。k6作为一个专注于高性能负载测试的工具为了极致轻量和保证跨平台一致性尤其是Go语言环境它没有使用完整的V8或Node.js环境而是基于自家的goja引擎。这个引擎在ES5.1兼容性上做得很好但对部分ES6的高级特性支持有限Set/Map就是典型的“牺牲品”。那么在k6脚本里我们还能高效地处理集合数据吗当然可以。这篇文章就来彻底解决这个问题。我会带你深入理解k6的限制根源然后提供一套从基础到进阶的、经过实战检验的替代方案。无论你是想简单模拟Set的去重功能还是需要Map的键值对存储甚至是实现更复杂的集合操作这里都有现成的“轮子”可以直接用。我们的目标很明确在不支持原生Set/Map的k6环境里写出同样高效、清晰的集合操作代码让压测脚本的开发不再被这个限制绊住脚。2. 核心限制解析为什么k6说“不”要找到好的替代方案首先得明白为什么k6不支持Set和Map。这不是开发者偷懒而是一个经过权衡的架构决策。2.1 k6的JavaScript引擎goja的定位与取舍k6的核心是用Go语言写的为了能在Go程序中执行JavaScript脚本它采用了goja这个纯Go实现的ECMAScript 5.1解释器。选择goja而非嵌入Node.js或V8主要出于以下几点考量无依赖与轻量化goja是纯Go库编译后就是一个独立的二进制文件。这意味着k6可以轻松分发到任何平台Linux, Windows, macOS甚至容器内无需安装Node.js运行环境或处理复杂的C依赖。对于压测工具来说这种简洁性和可移植性至关重要。性能与可控性在Go的协程模型中goja引擎与k6的压测调度器可以更紧密地集成避免跨语言调用的开销。同时Go团队对内存和整个运行时拥有完全的控制权便于优化高并发场景下的资源使用。安全性与沙箱作为压测工具k6需要安全地执行用户提供的、可能来源不明的脚本。goja提供了一个相对封闭的沙箱环境更容易限制脚本对系统资源的访问如文件系统、网络这比功能完整的V8引擎更易管控。然而鱼与熊掌不可兼得。goja项目的主要目标是实现ES5.1规范的完整兼容。ES6ES2015及之后版本引入的许多新特性如Set,Map,Proxy,Reflect等并不在其核心支持范围内。虽然社区通过插件或polyfill可以部分实现但k6为了保持核心的稳定和精简默认并未引入这些扩展。注意你可能会在k6文档或社区讨论中看到Babel或webpack的用法。这些工具确实能通过转译transpile将ES6语法转换为ES5但这主要解决的是语法问题如箭头函数、let/const。Set和Map是运行时APIruntime API即使语法被转译了如果运行时环境本身不提供这些全局对象代码执行时依然会报ReferenceError。转译工具无法无中生有地创建出Set构造函数。2.2 Set/Map不可用的直接影响缺少这两个类型最直接的冲击就是我们无法使用它们提供的优雅API和性能优势Set的缺失失去了一个专门为存储“唯一值”设计的高效数据结构。我们不能再简单地用mySet.add(value)、mySet.has(value)或[...mySet]来操作集合。Map的缺失失去了一个可以用任何值包括对象作为键的键值对集合。普通的JavaScript对象Object作为键值对容器时键只能是不重复的字符串或Symbol且其属性遍历顺序在ES6之前没有保证。Map的缺失让我们在处理非字符串键或需要有序遍历时变得棘手。在实际的k6压测脚本中这两个数据结构的用途非常广泛用Set来维护已访问的URL列表避免重复请求。用Set来过滤从CSV或JSON中读取的重复测试数据。用Map来缓存接口响应以请求参数为键响应结果为值实现简单缓存。用Map来存储虚拟用户VU的会话状态键可以是用户ID或某个唯一标识。理解了“为什么不能”接下来我们就聚焦于“怎么才能”。我们将从零开始构建一套在k6中完全可用的集合工具库。3. 替代方案实战从零构建k6可用的集合工具既然没有原生的我们就自己造。下面提供的方案都是纯ES5语法完全兼容k6环境你可以直接复制到你的脚本中使用。3.1 方案一使用Object模拟Set基础去重对于大多数简单的去重场景用Object来模拟Set是最快、最直接的方法。其核心思想是利用对象的键名唯一性。// 模拟一个简单的Set function createSet(initialArray) { var obj Object.create(null); // 创建一个没有原型的干净对象避免属性冲突 if (initialArray) { for (var i 0; i initialArray.length; i) { obj[initialArray[i]] true; } } return { add: function(value) { obj[value] true; return this; }, has: function(value) { return obj[value] true; }, delete: function(value) { var hasValue this.has(value); if (hasValue) { delete obj[value]; } return hasValue; }, clear: function() { obj Object.create(null); }, get size() { return Object.keys(obj).length; }, values: function() { return Object.keys(obj); }, // 为了方便提供一个转换为数组的方法 toArray: function() { return this.values(); } }; } // 使用示例 export default function() { var mySet createSet([1, 2, 2, 3]); console.log(初始集合: ${mySet.values()}); // 初始集合: 1,2,3 console.log(是否包含2: ${mySet.has(2)}); // 是否包含2: true mySet.add(4); mySet.add(2); // 重复添加无效果 console.log(添加后集合: ${mySet.values()}); // 添加后集合: 1,2,3,4 console.log(集合大小: ${mySet.size}); // 集合大小: 4 mySet.delete(3); console.log(删除3后: ${mySet.values()}); // 删除3后: 1,2,4 }注意事项与局限性键类型限制对象的键会被自动转换为字符串。这意味着mySet.add(123)和mySet.add(123)会被视为同一个键。如果你的集合需要同时包含数字1和字符串1这个方案就不适用。对象键的问题如果你尝试mySet.add({id: 1})对象会被转换成类似[object Object]的字符串导致所有普通对象都被视为同一个键从而失去去重意义。性能考量对于非常大的集合例如数万以上元素频繁的Object.keys()调用用于获取size和values可能会成为性能瓶颈因为它会创建一个新的数组。实操心得对于处理从CSV文件读取的用户名、邮箱或ID这类本身就是字符串或可以安全转换为字符串的数据这个Object模拟方案是首选。它代码简单在元素数量不多几千以内时性能完全足够。在k6脚本中我们通常处理的是测试数据这个规模完全在可接受范围内。3.2 方案二使用Array与自定义逻辑模拟Set通用去重当你的数据包含无法安全转换为字符串唯一标识的对象或者你需要严格区分不同类型如数字1和字符串‘1’时就需要一个更通用的方案。我们可以用Array作为底层存储通过遍历和自定义比较逻辑来实现。// 模拟一个更通用的Set支持自定义相等性比较函数 function createGenericSet(compareFn) { var items []; // 默认使用严格相等进行比较 var isEqual compareFn || function(a, b) { return a b; }; function findIndex(value) { for (var i 0; i items.length; i) { if (isEqual(items[i], value)) { return i; } } return -1; } return { add: function(value) { if (findIndex(value) -1) { items.push(value); } return this; }, has: function(value) { return findIndex(value) ! -1; }, delete: function(value) { var idx findIndex(value); if (idx -1) { items.splice(idx, 1); return true; } return false; }, clear: function() { items []; }, get size() { return items.length; }, values: function() { // 返回副本避免外部修改内部数组 return items.slice(); }, forEach: function(callback) { for (var i 0; i items.length; i) { callback(items[i], items[i], this); } } }; } // 使用示例处理对象数组的去重 export default function() { // 假设我们有一组用户对象根据id去重 var users [ { id: 1, name: Alice }, { id: 2, name: Bob }, { id: 1, name: Alice } // 重复ID ]; var userSet createGenericSet(function(a, b) { return a.id b.id; // 自定义比较规则id相同即视为相同 }); users.forEach(function(user) { userSet.add(user); }); console.log(去重后的用户数: ${userSet.size}); // 去重后的用户数: 2 var uniqueUsers userSet.values(); console.log(JSON.stringify(uniqueUsers)); // [{id:1,name:Alice},{id:2,name:Bob}] }性能优化提示 上述通用方案的findIndex函数在每次add、has、delete时都需要遍历数组时间复杂度为O(n)。当集合元素很多时比如超过1000个性能下降会很明显。对于k6压测脚本如果你预期集合会很大可以考虑以下优化策略使用“混合键”对于对象在添加时计算一个唯一字符串键如obj.id | obj.type同时用Object存储这个键到对象的映射用Array保持顺序。这样has操作可以降到O(1)。仅在必要时去重如果数据源本身重复不多或者去重操作不是性能关键路径简单的数组方案也可以接受。在k6中VU脚本的执行时间会被计入响应时间对于超大规模集合的操作需要谨慎。3.3 方案三使用Object模拟Map键值对存储模拟Map的思路与模拟Set类似但我们需要存储键值对。同样我们先从最简单的字符串键场景开始。// 模拟一个简单的Map键为字符串 function createMap() { var store Object.create(null); var size 0; return { set: function(key, value) { if (!this.has(key)) { size; } store[key] value; return this; }, get: function(key) { return store[key]; }, has: function(key) { return store[key] ! undefined; }, delete: function(key) { var hasKey this.has(key); if (hasKey) { delete store[key]; size--; } return hasKey; }, clear: function() { store Object.create(null); size 0; }, get size() { return size; }, keys: function() { return Object.keys(store); }, values: function() { return Object.keys(store).map(function(k) { return store[k]; }); }, entries: function() { var keys Object.keys(store); var result []; for (var i 0; i keys.length; i) { result.push({ key: keys[i], value: store[keys[i]] }); } return result; }, forEach: function(callback) { var keys Object.keys(store); for (var i 0; i keys.length; i) { callback(store[keys[i]], keys[i], this); } } }; } // 使用示例缓存API响应 export default function() { var responseCache createMap(); // 模拟一个昂贵的API调用 function fetchData(userId) { console.log(Fetching data for user: ${userId}); // 这里应该是实际的http.get调用 return Data for ${userId}; } function getCachedData(userId) { if (responseCache.has(userId)) { console.log(Cache hit for: ${userId}); return responseCache.get(userId); } console.log(Cache miss for: ${userId}); var data fetchData(userId); responseCache.set(userId, data); return data; } console.log(getCachedData(user_001)); // Cache miss, 然后 Fetching... console.log(getCachedData(user_001)); // Cache hit! console.log(getCachedData(user_002)); // Cache miss, 然后 Fetching... console.log(缓存大小: ${responseCache.size}); // 缓存大小: 2 }3.4 方案四支持复杂键的通用Map实现如果需要用对象、数组等作为键我们需要一个机制将任意键转换为一个唯一的字符串标识符。这通常通过序列化如JSON.stringify来实现但要注意序列化后的字符串是否真正唯一。// 模拟一个支持复杂键的通用Map function createGenericMap() { var keyStore []; // 存储原始键用于解决冲突和遍历 var valueStore Object.create(null); // 存储值键是序列化后的字符串 var size 0; function serializeKey(key) { // 简单序列化对于函数、undefined等类型会有问题 // 更健壮的实现需要处理各种类型这里为简洁起见使用JSON.stringify // 注意对象的属性顺序不同会导致序列化结果不同 try { return typeof key : JSON.stringify(key); } catch (e) { // 如果无法序列化如包含循环引用退回使用String() return typeof key : String(key); } } return { set: function(key, value) { var serializedKey serializeKey(key); if (!valueStore.hasOwnProperty(serializedKey)) { keyStore.push(key); size; } valueStore[serializedKey] value; return this; }, get: function(key) { var serializedKey serializeKey(key); return valueStore[serializedKey]; }, has: function(key) { var serializedKey serializeKey(key); return valueStore.hasOwnProperty(serializedKey); }, delete: function(key) { var serializedKey serializeKey(key); if (valueStore.hasOwnProperty(serializedKey)) { delete valueStore[serializedKey]; // 也需要从keyStore中移除 for (var i 0; i keyStore.length; i) { if (serializeKey(keyStore[i]) serializedKey) { keyStore.splice(i, 1); break; } } size--; return true; } return false; }, clear: function() { keyStore []; valueStore Object.create(null); size 0; }, get size() { return size; }, keys: function() { return keyStore.slice(); // 返回副本 }, values: function() { var vals []; for (var i 0; i keyStore.length; i) { vals.push(this.get(keyStore[i])); } return vals; }, entries: function() { var ents []; for (var i 0; i keyStore.length; i) { ents.push({ key: keyStore[i], value: this.get(keyStore[i]) }); } return ents; }, forEach: function(callback) { for (var i 0; i keyStore.length; i) { callback(this.get(keyStore[i]), keyStore[i], this); } } }; } // 使用示例 export default function() { var complexMap createGenericMap(); var key1 { id: 1, type: user }; var key2 [1, 2, 3]; complexMap.set(key1, Value for user object); complexMap.set(key2, Value for array key); complexMap.set(simpleString, A string value); console.log(complexMap.get(key1)); // Value for user object console.log(complexMap.get([1, 2, 3])); // undefined! 因为新数组的序列化结果与key2不同 console.log(complexMap.get(key2)); // Value for array key console.log(Map大小: ${complexMap.size}); // Map大小: 3 console.log(所有键:, JSON.stringify(complexMap.keys())); }重要警告使用JSON.stringify进行序列化作为键存在固有缺陷。首先它无法处理包含循环引用的对象。其次对象的属性顺序会影响序列化结果。JSON.stringify({a:1, b:2})和JSON.stringify({b:2, a:1})会产生不同的字符串导致在Map中被视为不同的键尽管它们代表相同的对象。因此这种通用Map实现更适合键是稳定、可预测的数据结构如按固定顺序生成的对象。如果键是动态的、属性顺序不固定的对象你需要一个更稳定的哈希函数或考虑其他方案。4. 性能对比与选型建议在k6脚本中性能至关重要因为脚本执行效率直接影响压测的并发能力和资源消耗。下面我们来分析一下各种替代方案的性能特点并给出选型建议。4.1 各方案性能特点分析Object模拟Set/Map字符串键优势has、get、set操作接近O(1)时间复杂度性能最高。实现简单代码量小。劣势键必须是字符串或可安全转换为唯一字符串的值。无法区分1和1。无法使用对象作为键。适用场景键是简单的ID、用户名、URL等字符串或可以无损转换为字符串的数字。这是k6脚本中最推荐、最常用的方案。Array模拟通用Set遍历查找优势可以存储任意类型的值包括对象并且可以自定义相等性比较逻辑。实现直观。劣势has、add、delete操作需要遍历时间复杂度为O(n)。当集合元素数量大1000时性能下降明显。适用场景集合规模较小几十到几百个元素或者元素是对象且需要基于特定属性去重。不适用于高频操作的大集合。通用Map序列化键优势理论上支持任何类型的键。劣势JSON.stringify有性能开销特别是对于复杂对象。序列化结果可能不稳定属性顺序问题。内存占用相对较高因为需要同时存储原始键和序列化键。适用场景键是复杂的、但结构稳定的对象或数组且操作频率不高。需要谨慎评估性能影响。4.2 实战选型决策树面对一个具体的k6脚本需求你可以按照以下流程来选择最合适的方案需要存储键值对 ├── 是 → 键是字符串或数字吗 │ ├── 是 → 使用【方案三Object模拟Map】 │ └── 否 → 键是结构稳定的对象/数组吗 │ ├── 是 → 对性能敏感吗 │ │ ├── 是 → 考虑将对象转换为唯一字符串ID作为键如obj.id │ │ └── 否 → 使用【方案四通用Map】注意属性顺序问题 │ └── 否 → 重新设计尽量避免使用复杂对象作为键 │ └── 否 → 只需要存储唯一值去重 ├── 是 → 值是字符串或数字吗 │ ├── 是 → 使用【方案一Object模拟Set】 │ └── 否 → 集合规模大吗500 │ ├── 是 → 尝试提取值的唯一标识符如obj.key用Object模拟 │ └── 否 → 使用【方案二Array模拟通用Set】 └── 否 → 你可能不需要集合类型普通数组即可。4.3 一个综合工具函数示例在实际项目中你可以将上述方案封装成模块方便复用。下面是一个简单的工具模块示例// 文件k6-collections.js // 为k6环境提供的集合工具库 export function createStringSet(initialArray) { var obj Object.create(null); if (initialArray) { for (var i 0; i initialArray.length; i) { obj[initialArray[i]] true; } } return { add: function(v) { obj[v] true; return this; }, has: function(v) { return obj[v] true; }, delete: function(v) { var has this.has(v); if (has) delete obj[v]; return has; }, clear: function() { obj Object.create(null); }, get size() { return Object.keys(obj).length; }, values: function() { return Object.keys(obj); } }; } export function createStringMap() { var store Object.create(null); var size 0; return { set: function(k, v) { if (!this.has(k)) size; store[k] v; return this; }, get: function(k) { return store[k]; }, has: function(k) { return store[k] ! undefined; }, delete: function(k) { var has this.has(k); if (has) { delete store[k]; size--; } return has; }, clear: function() { store Object.create(null); size 0; }, get size() { return size; }, keys: function() { return Object.keys(store); }, values: function() { return Object.keys(store).map(function(k) { return store[k]; }); } }; } // 在k6脚本中使用 import { createStringSet, createStringMap } from ./k6-collections.js; export default function() { var userIds createStringSet(); var userCache createStringMap(); // ... 你的压测逻辑 }5. 常见问题与排查技巧实录在实际使用自建集合工具的过程中你可能会遇到一些典型问题。这里记录了我踩过的坑和解决方法。5.1 问题一去重结果不符合预期场景你用Object模拟的Set对一组数字[1, 2, 2, 3]进行去重期望得到[1, 2, 2, 3]但实际得到了[1, 2, 3]。原因与排查检查键类型Object的键是字符串。数字2会被转换成字符串2与字符串2冲突。这是Object模拟方案的固有局限。验证数据源打印或检查传入数组的每个元素的类型。确认你是否真的需要区分数字2和字符串2。解决方案如果不需要区分类型Object方案是正确的它帮你统一了格式。如果需要严格区分必须使用基于Array的通用方案方案二并提供一个自定义的比较函数该函数同时比较值和类型。var strictSet createGenericSet(function(a, b) { // 严格比较值和类型都必须相同 return a b; // 操作符会区分类型 });5.2 问题二使用对象作为Map的键时“失效”场景你用通用Map存储数据键是一个对象{userId: 123}。第一次map.set(key, value)成功但后续用看起来相同的新对象{userId: 123}调用map.get()却返回undefined。原因与排查理解引用与值在JavaScript中对象是引用类型。即使两个对象内容相同它们也是不同的引用。{userId: 123} {userId: 123}的结果是false。检查序列化函数回顾方案四中的serializeKey函数。它使用JSON.stringify。如果两个对象属性顺序相同序列化字符串会一致。但如果属性顺序不同在某些JavaScript引擎中可能发生序列化结果就会不同。打印调试在set和get时打印出serializeKey(key)的结果看它们是否一致。解决方案确保使用同一个对象引用如果你需要多次以同一个对象为键进行操作请保存该对象的引用并始终使用这个引用。var myKey { userId: 123 }; map.set(myKey, data); // ... 后续操作 map.get(myKey); // 正确使用同一个引用使用稳定标识符作为键这是更可靠的做法。从对象中提取一个保证唯一的稳定字符串作为键如obj.id。var userId someObj.userId; // 例如 user_123 map.set(userId, data); // 使用字符串键 map.get(userId);实现更健壮的序列化可以写一个函数无论属性顺序如何都生成相同的字符串例如先将属性按字母排序再JSON.stringify。但这会增加计算开销。5.3 问题三大规模集合操作导致脚本执行缓慢场景在init阶段或每个VU迭代中你需要处理一个包含上万个元素的数组去重脚本执行时间明显变长影响了压测的RPS每秒请求数。原因与排查性能分析使用console.time和console.timeEnd来测量你的集合操作耗时。console.time(deduplicate); var largeSet createStringSet(largeArray); // 假设largeArray有10000个元素 console.timeEnd(deduplicate);定位瓶颈如果使用的是Array遍历方案方案二add每个元素都是O(n)操作处理n个元素就是O(n²)对于万级数据就是灾难。即使是Object方案如果initialArray很大初始化时的循环也可能耗时。解决方案优先使用Object方案对于字符串/数字数据Object方案的add操作是O(1)初始化万级数据也很快。分批处理或离线处理如果数据是静态的如从CSV文件读取考虑在脚本外部如用Python/Node.js脚本预先处理好去重数据k6直接读取处理后的结果。评估必要性真的需要完全去重吗有时采样测试或部分去重也能达到测试目的。或者是否可以减少单次操作的数据量使用k6的shared-array对于只读的、巨大的静态数据集考虑使用k6的SharedArray。它可以在VU之间共享内存但注意它是只读的不适合动态集合操作。5.4 一个实用的调试技巧为你的集合添加toString()方法为了方便调试为你自建的集合对象添加一个toString或toJSON方法会很有帮助能让你在console.log时清晰地看到内容。function createStringSet(initialArray) { var obj Object.create(null); // ... 其他方法 ... return { // ... 其他方法 ... toString: function() { return Set { Object.keys(obj).join(, ) }; }, toJSON: function() { // 方便JSON.stringify输出 return this.values(); } }; } var mySet createStringSet([1, 2, 3]); console.log(String(mySet)); // 输出: Set {1, 2, 3} console.log(JSON.stringify(mySet)); // 输出: [1,2,3]绕开k6对ES6 Set/Map的限制本质上是一次对JavaScript基础数据结构的再认识和实践。它迫使我们去理解这些抽象API背后的原理从而写出更底层、有时也更高效的代码。对于k6压测脚本而言这种可控性和透明性未必是坏事。当你亲手实现了这些集合操作你对其性能特征和内存消耗会有更精准的把握这在设计高并发、低延迟的压测场景时反而成了一种优势。下次当你的k6脚本因为一个ReferenceError而中断时希望你能自信地打开工具箱选择合适的替代方案而不是感到困扰。