
列表页渲染一千行,每行都要判断这一行在不在选中集合里,常见写法是 selectedIds.includes(row.id)。一千行就是一千次线性扫描,单次最坏要从头数到尾。另一种写法是先把选中 id 装进 Set,再逐行 has,代价是每次渲染先付一笔建表的钱。这笔账什么时候划算,取决于两件事:列表多长,同一次渲染里一共查几次。
这一组数字怎么来的
脚本用固定种子的伪随机取目标值,命中率维持在五成左右,每种组合跑五次取中位数。列表长度取 100、1000、10000 三档,查询次数从 10 次一路到 10 万次,建表成本单独计时。种子写死,两次运行拿到的是同一批 probe,数字之间可以直接比。
// 选中列表判重:Array.includes 与 Set.has 的对照实验
// 用法: ~/.hermes/node/bin/node tools/verify-lookup-vs-set.mjs | tee tools/verify-lookup-vs-set.log
import { performance } from "node:perf_hooks";
// 固定种子的伪随机,保证每次跑都是同一批目标值
function lcg(seed) {
let s = seed >>> 0;
return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296);
}
function makeIds(n) {
return Array.from({ length: n }, (_, i) => "u_" + i);
}
function bench(fn, reps = 5) {
const runs = [];
for (let r = 0; r < reps; r++) {
const t0 = performance.now();
fn();
runs.push(performance.now() - t0);
}
runs.sort((a, b) => a - b);
return runs[Math.floor(reps / 2)]; // 中位数
}
const LOOKUPS = [10, 100, 1000, 10000, 100000];
console.log("Node", process.version, "| 机器", process.arch, process.platform);
console.log("");
console.log("表头:列表长度 | 查几次 | includes 耗时 | 建 Set 耗时 | Set 查完耗时 | 建表+查询");
console.log("-".repeat(84));
const rows = [];
for (const listLen of [100, 1000, 10000]) {
const ids = makeIds(listLen);
const rand = lcg(20260926);
// 命中率约一半:一半取真实存在的 id,一半取不存在的
const probes = (k) =>
Array.from({ length: k }, () => (rand() < 0.5 ? "u_" + Math.floor(rand() * listLen) : "u_" + (listLen + Math.floor(rand() * 1000))));
for (const k of LOOKUPS) {
const targets = probes(k);
const tInc = bench(() => {
let hit = 0;
for (let i = 0; i < k; i++) if (ids.includes(targets[i])) hit++;
return hit;
});
const tBuild = bench(() => new Set(ids));
const set = new Set(ids);
const tHas = bench(() => {
let hit = 0;
for (let i = 0; i < k; i++) if (set.has(targets[i])) hit++;
return hit;
});
const fmt = (x) => x.toFixed(x < 10 ? 3 : 1).padStart(9);
rows.push({ listLen, k, tInc, tBuild, tHas });
console.log(
String(listLen).padStart(8), "|", String(k).padStart(7), "|",
fmt(tInc), "ms |", fmt(tBuild), "ms |", fmt(tHas), "ms |", fmt(tBuild + tHas), "ms",
);
}
console.log("-".repeat(84));
}
// 拐点:从查几次开始,建表 + 查询比纯扫数组更快
for (const listLen of [100, 1000, 10000]) {
const r = rows.filter((x) => x.listLen === listLen);
const first = r.find((x) => x.tBuild + x.tHas < x.tInc);
const row = (k) => r.find((x) => x.k === k);
console.log("");
console.log(`列表 ${listLen} 条:`);
for (const k of [10, 100, 1000]) {
const x = row(k);
console.log(` 查 ${String(k).padStart(6)} 次 includes ${x.tInc.toFixed(3)} ms | 建表+Set ${(x.tBuild + x.tHas).toFixed(3)} ms | ${x.tInc > x.tBuild + x.tHas ? "Set 更划算" : "直接 includes 更划算"}`);
}
console.log(` 拐点落在 ${first ? first.k + " 次左右" : "本轮测的次数之外"}`);
}
const ids = makeIds(1000);
const tBuild = bench(() => new Set(ids));
const probe = ["u_999"];
const perLookup = bench(() => { for (let i = 0; i < 1000; i++) ids.includes(probe[0]); }) / 1000;
console.log("");
console.log("列表 1000 条:建一次 Set 约", tBuild.toFixed(3), "ms");
console.log("单次 includes(最坏情况,查最后一条)约", perLookup.toFixed(4), "ms");
console.log("建表成本约等于", Math.round(tBuild / perLookup), "次 includes");
原始输出
Node v26.8.1 | 机器 arm64 darwin
表头:列表长度 | 查几次 | includes 耗时 | 建 Set 耗时 | Set 查完耗时 | 建表+查询
------------------------------------------------------------------------------------
100 | 10 | 0.002 ms | 0.004 ms | 0.001 ms | 0.005 ms
100 | 100 | 0.034 ms | 0.003 ms | 0.005 ms | 0.007 ms
100 | 1000 | 0.449 ms | 0.003 ms | 0.029 ms | 0.032 ms
100 | 10000 | 1.158 ms | 0.003 ms | 0.205 ms | 0.208 ms
100 | 100000 | 7.581 ms | 0.001 ms | 1.232 ms | 1.233 ms
------------------------------------------------------------------------------------
1000 | 10 | 0.007 ms | 0.015 ms | 0.000 ms | 0.015 ms
1000 | 100 | 0.184 ms | 0.013 ms | 0.001 ms | 0.014 ms
1000 | 1000 | 2.620 ms | 0.011 ms | 0.007 ms | 0.018 ms
1000 | 10000 | 27.3 ms | 0.013 ms | 0.111 ms | 0.125 ms
1000 | 100000 | 69.3 ms | 0.019 ms | 1.410 ms | 1.428 ms
------------------------------------------------------------------------------------
10000 | 10 | 0.058 ms | 0.231 ms | 0.000 ms | 0.231 ms
10000 | 100 | 1.807 ms | 0.247 ms | 0.001 ms | 0.247 ms
10000 | 1000 | 6.861 ms | 0.217 ms | 0.009 ms | 0.225 ms
10000 | 10000 | 70.2 ms | 0.190 ms | 0.143 ms | 0.333 ms
10000 | 100000 | 699.0 ms | 0.205 ms | 1.639 ms | 1.844 ms
先看最极端的一格:列表 10000 条、查 10 万次,includes 用掉 699.0 ms,建表加 Set 查询一共 1.844 ms。差出三百多倍。再看最不划算的一格:列表 100 条、只查 10 次,includes 是 0.002 ms,建表加查询 0.005 ms,扫数组赢。
拐点在哪一格
列表 100 条:
查 10 次 includes 0.002 ms | 建表+Set 0.005 ms | 直接 includes 更划算
查 100 次 includes 0.034 ms | 建表+Set 0.007 ms | Set 更划算
查 1000 次 includes 0.449 ms | 建表+Set 0.032 ms | Set 更划算
拐点落在 100 次左右
列表 1000 条:
查 10 次 includes 0.007 ms | 建表+Set 0.015 ms | 直接 includes 更划算
查 100 次 includes 0.184 ms | 建表+Set 0.014 ms | Set 更划算
查 1000 次 includes 2.620 ms | 建表+Set 0.018 ms | Set 更划算
拐点落在 100 次左右
列表 10000 条:
查 10 次 includes 0.058 ms | 建表+Set 0.231 ms | 直接 includes 更划算
查 100 次 includes 1.807 ms | 建表+Set 0.247 ms | Set 更划算
查 1000 次 includes 6.861 ms | 建表+Set 0.225 ms | Set 更划算
拐点落在 100 次左右
列表 1000 条:建一次 Set 约 0.022 ms
单次 includes(最坏情况,查最后一条)约 0.0072 ms
建表成本约等于 3 次 includes
三档列表给出同一个答案:查 10 次的时候扫数组更快,查 100 次的时候建表加 Set 已经很划算,拐点落在 10 次与 100 次之间。具体是第几次没有继续细分,这里只取了五个采样点。
还有一处值得记下来。单次 includes 的最坏情况(查列表最后一条)在 1000 条的列表上约 0.0072 ms,建一次 Set 约 0.022 ms,也就是三倍的单次查询。Set 换来的东西是每次查询花费固定,跟列表多长无关,它把随长度增长的代价换成了一次性的建表成本。列表会不会变长,比列表当前多长更影响判断。
顺带看一眼命中率的影响。上面这轮 probe 里一半能命中、一半命中不了,对 includes 来说这个比例几乎不影响总耗时,因为它每次都要扫完才敢说不在;对 Set 来说,只在半路上多做一次哈希。真正的差别体现在列表长度上:列表越长,includes 的每次查询越贵。
落到渲染代码上
一千行、每行查一次,属于 1000 次查询那一档,落在这张表里 Set 明显划算的区域,代价是每渲染一次要付一次建表钱。渲染循环里出现 includes 的写法,问题不在单次慢,而在这个单次被乘了一千遍。
如果同一次渲染里查询次数很少,比如只查当前滚动到的那几行,就没必要提前建表,includes 更省事,也少一次分配。列表长度不到 100、查询次数个位数的情况下,两种写法的差别在噪声里,选好读的那种就行。
// 每渲染一次建一次表:适合查询次数多的场合
const selected = new Set(selectedIds);
const rows = allRows.map((row) => ({ ...row, checked: selected.has(row.id) }));
另外两种写法不在这张表里。用对象字面量当查找表,键是字符串,数字 id 会先转成字符串,1 和 "1" 会撞在一起,__proto__ 这类键还有原型链上的老问题;用 Array.prototype.find 找对象则是每次都要构造比较函数,比 includes 更慢。这两种我都没实测,写在代码里之前值得先量一遍。