项目标题里这个“大型数据集”四个字,我最早听到时以为是把接口返回的几千条列表渲染快一点,真上手才发现完全不是一个量级的事。当时接到的活是浏览器端要摊开三十多万行埋点数据做多维筛选和聚合,接口一次吐过来 180 多 MB 的 JSON,页面转圈转到浏览器弹“页面无响应”,控制台里那行红色堆内存报错我记得特别清楚。后来一路折腾,从解析、内存布局、计算线程、渲染策略四个环节逐一拆开重做,最终把首屏时间从二十多秒压到两秒出头,滚动筛选也做到了跟手。这篇就把这套思路完整摊开讲,包括我踩过的坑、量级怎么分级、参数怎么算、代码怎么写。适合已经会用 JavaScript 但被几十万行数据折磨过的前端,也适合刚接触大数据量处理、想搞明白“卡到底卡在哪”的朋友。全文讲的都是从实践中来的取舍逻辑,不是教科书上的标准答案。
1. 先弄明白“大”这个字到底卡在哪里
1.1 数据量级分级与真正的瓶颈定位
很多人一上来就问“用什么库能处理大数据”,这问题本身就是错的。处理大数据量在浏览器里从来不是一个“换个库”的问题,而是要先定位瓶颈到底在哪个阶段:是网络传输慢、是 JSON 解析卡、是内存爆了触发频繁垃圾回收、是计算循环太重、还是 DOM 节点太多导致布局重排。每个阶段的解法完全不同,用错方向就是白折腾一周。
我习惯按量级把场景分成四档,先对号入座再谈方案。
| 数据量级 | 典型表现 | 主要瓶颈 | 推荐主攻方向 |
|---|---|---|---|
| 1 千行以内 | 基本流畅 | 无明显瓶颈 | 正常渲染,注意不必要的重复计算 |
| 1 万至 5 万行 | 首次渲染卡 1 至 3 秒 | DOM 节点数过多、布局耗时 | 虚拟滚动、分页 |
| 5 万至 50 万行 | 页面卡死、内存告警 | JSON 解析、主线程计算、内存 | Web Worker、TypedArray、分块 |
| 50 万行以上 | 直接崩溃或白屏 | 堆内存上限、GC 抖动 | 流式解析、降采样、服务端预处理 |
这张表是我自己踩出来的经验值,不同浏览器、不同机器会有浮动,但量级判断基本可用。分档之后你会发现,绝大多数人喊着“处理不了”的情况,其实卡在最朴素的一步:一次性把几十万条数据塞进 DOM 里。浏览器渲染几十万个节点,光是样式计算和布局就能吃掉十几秒,跟你的 JavaScript 算法一点关系都没有。
所以定位瓶颈的第一步永远是打开 Performance 面板录一段,看火焰图里时间到底花在哪。是黄色块(Scripting)占大头,还是紫色块(Rendering)或绿色块(Painting)占大头?前者是代码问题,后者是渲染问题。这一步不做,后面所有优化都是蒙。
1.2 内存与垃圾回收:那些看不见的卡顿来源
比“慢”更阴间的是“忽快忽慢”。同一份数据,第一次渲染要三秒,第二次只要一秒,第三次又变成五秒。这种抖动十有八九是垃圾回收在作怪。JavaScript 的对象分配都发生在堆上,当堆里存活的对象越来越多,主线程会被 V8 的标记清扫阶段打断,而这个过程你无法精确控制,表现出来就是偶发的长任务卡顿。
想看到这个现象,得用 Chrome DevTools 的 Memory 面板录一段 Allocation 时间线。如果你看到内存曲线像锯齿一样上下猛拉,基本上就是在疯狂创建临时对象。最常见的有两个来源:一是在循环里用map、filter、slice链式调用生成大量中间数组,二是每行数据都用完整的对象字面量表示,属性名重复存了几十万遍。
粗算一笔账你就明白问题有多大。假设一条数据有 10 个字段,字段名平均 8 个字符(在 V8 里是内部化的,只存一份,不重复),但每个字段值如果是字符串,每个字符串对象大约有 20 到 40 字节的固定开销,再加上对象本身的隐藏类指针和属性槽位,一条记录保守估计在 250 到 400 字节之间。三十万条就是 75 到 120 MB。如果中间某一步用了JSON.parse加一次map转换,峰值可能翻到两倍以上。而浏览器单标签页的堆内存上限通常在你的机器内存的四分之一左右浮动,超过就触发 OOM。
提示:判断是内存问题还是计算问题,最快的办法是在 Console 里连按几次手动触发 GC(Memory 面板里那个垃圾桶图标),如果按完之后页面明显变流畅,那基本就是临时对象太多了。
我后来做的第一件事就是把“每行一个对象”改成“每列一个 TypedArray”。这个变化带来的不只是内存下降,更关键的是 GC 压力几乎消失,因为 ArrayBuffer 本身在堆外,不参与对象扫描。后面第三部分会详细讲怎么改。
1.3 用 Performance 与计时打点建立基线
优化最忌讳没有基线就动手。我现在接手这类需求,第一步固定是写一版“什么都不做”的原始实现,然后用performance.mark和performance.measure在关键节点打点:网络返回、解析完成、计算完成、首帧渲染完成。这些数据会变成后面每一次改动的对照尺。
performance.mark('fetch-start'); const raw = await fetch('/api/records').then(r => r.text()); performance.mark('fetch-end'); performance.mark('parse-start'); const parsed = JSON.parse(raw); performance.mark('parse-end'); performance.mark('compute-start'); const view = buildViewModel(parsed); performance.mark('compute-end'); performance.measure('网络', 'fetch-start', 'fetch-end'); performance.measure('解析', 'parse-start', 'parse-end'); performance.measure('计算', 'compute-start', 'compute-end'); console.table(performance.getEntriesByType('measure').map(m => ({ 阶段: m.name, 耗时ms: Math.round(m.duration) })));这段代码看着简单,但它是整个优化过程的锚点。我那个项目第一次测出来:解析 8.2 秒、计算 6.5 秒、渲染 9.1 秒,网络只有 1.3 秒。数据一目了然,网络根本不是问题,重头戏在解析和渲染。如果当时凭感觉先去搞 HTTP 缓存或者压缩传输,那就是典型的力气用错地方。
打点还有个隐性好处:它能防止你在优化过程中自欺欺人。人很容易觉得“我改了个算法应该快了吧”,实测下来可能是负优化。有基线摆在那,改完重跑一遍,数字说话。
2. 数据进场:解析方式与内存布局的取舍
2.1 JSON.parse 的能力边界与流式解析的引入
JSON.parse是 V8 里高度优化的原生实现,对几十 MB 以内的数据来说,它比任何 JavaScript 手写的解析器都快,这点必须先说清楚,不要盲目去找第三方解析库。它的真正问题不是速度,而是峰值内存和不可中断。
当我们手上那份 JSON 字符串有 180 MB 时,内存里同时存在:原始字符串 180 MB、解析出来的对象树可能 600 MB 以上、V8 内部的临时结构。三样加在一起超过 1 GB,很容易直接顶到上限。而且JSON.parse是同步阻塞的,一旦开始就不能停,主线程卡死期间用户点什么都没反应,连个 loading 动画都转不动。
注意:如果你观察到解析过程中页面完全冻结,不要试图用
setTimeout把JSON.parse包起来,没用的。同步函数一旦执行,中间不会让出主线程,包多少层定时器都改变不了这一点。
真要处理几百 MB 级别的数据,得换成流式解析,思路是把大字符串切成小块边读边吐。浏览器里可以用fetch拿response.body的ReadableStream,配合TextDecoderStream逐块读取,再交给增量 JSON 解析逻辑。这类做法在 Node 环境更成熟,浏览器端需要自己写一个能处理括号配对的增量解析器,工作量不小。所以在动手前先问自己:这份数据是不是真的必须完整加载到前端?我后来的做法是把重数据的聚合放在服务端做,前端只取聚合后的结果,一下子把问题消掉了大半。能用架构解决的问题,不要硬扛。
如果数据必须全量进前端,还有一个中间的折中方案:把 JSON 换成 NDJSON(每行一个独立 JSON),这样可以用split('\n')之后分块解析,每解析一万行就让出一次主线程,用户的感知会好很多。
2.2 TypedArray 与对象数组的内存账
前面提了一句列式存储,这里展开算算账。假设我们要存三十万条记录,每条有timestamp(数字)、value(浮点)、category(0 到 15 的枚举)、userId(整数)四个字段。
用对象数组表示:
// 每条记录约 300 字节,30 万条约 90 MB const records = raw.map(r => ({ timestamp: r.ts, value: r.v, category: r.c, userId: r.u }));用列式 TypedArray 表示:
const n = raw.length; const timestamps = new Float64Array(n); // 8 字节 × 30 万 = 2.4 MB const values = new Float64Array(n); // 2.4 MB const categories = new Uint8Array(n); // 1 字节 × 30 万 = 0.3 MB const userIds = new Uint32Array(n); // 4 字节 × 30 万 = 1.2 MB // 合计约 6.3 MB,并且不在 JS 堆里参与 GC 扫描 for (let i = 0; i < n; i++) { timestamps[i] = raw[i].ts; values[i] = raw[i].v; categories[i] = raw[i].c; userIds[i] = raw[i].u; }同样的数据,内存从 90 MB 降到 6.3 MB,差了十四倍。而且因为数据是连续存放的,CPU 缓存命中率高,遍历性能也会好一截。我实测过同样的求和计算,TypedArray 版本比对象数组版本快大约 2 到 3 倍,数据量越大差距越明显。
代价是什么?代价是代码写起来不直观。你不能records[i].value了,得写values[i]。而且删改数据的逻辑变复杂了,TypedArray 长度固定,不能push。所以我的经验是:只读的、需要大量计算的、字段类型固定的数据适合列式存储;需要频繁结构化增删改的数据还是老老实实用对象。别为了性能把代码写成天书,维护成本也是成本。
还有一个容易忽略的点:Uint8Array的取值上限是 255,Uint32Array是 42 亿左右,选类型的时候要按实际取值范围来。我见过有人拿Uint8Array存毫秒时间戳,存进去全变成 0 到 255 的截断值,排查了半天。选之前先确认数据的最大最小值。
2.3 字段裁剪与结构复用的实操细节
除了换存储结构,还有两个立竿见影的减负动作。
第一个是字段裁剪。服务端返回的数据往往包含前端根本用不到的字段,比如各种冗余的描述文本、嵌套的子对象。在解析之前就应该跟后端商量,用查询参数控制返回字段。实在改不动后端,那就解析完立刻只保留需要的字段,把原对象引用置空,让 GC 能回收掉。这一步在那份 180 MB 数据上帮我省掉了将近四成内存。
第二个是结构复用。如果业务上确实需要对象数组,尽量让所有对象拥有相同的属性顺序和相同的隐藏类,避免属性增删。V8 对结构一致的对象会生成共享的隐藏类,属性访问走的是快速路径;一旦你对某个对象delete一个属性或者后加一个属性,隐藏类就分裂了,访问方式退化为字典模式,性能掉得很厉害。所以:
// 反例:初始化时没有字段,后面动态补 const item = {}; item.a = 1; item.b = 2; // 正例:构造时就定型,字段顺序保持一致 const item = { a: 1, b: 2, c: null, d: null };看着是小事,三十万个对象上的差别就是肉眼可见的。
提示:如果你不确定某个改动有没有让隐藏类分裂,用 DevTools 的 Memory 面板拍两份堆快照做对比,看某类对象的数量有没有莫名其妙翻倍,这是个很实用的排查手段。
3. 计算阶段:把主线程从繁重劳动中解放出来
3.1 Web Worker 的任务切分策略
主线程既要跑 JavaScript,又要做样式计算、布局、绘制,还要响应输入事件。当一段计算任务超过 50 毫秒,用户就能感觉到输入延迟;超过 200 毫秒,就是明显的“卡”。所以凡是能被切出去的重计算,都不应该留在主线程。
Web Worker 的基本用法不难,难的是切分粒度。我见过两种极端:一种是把所有东西都塞进一个 Worker,结果 Worker 内部又变成串行阻塞,只是把卡顿从主线程挪到了另一个地方;另一种是每个小任务开一个 Worker,结果线程创建和销毁的开销把收益全吃光了。
我的经验值是:单个 Worker 任务连续执行不要超过 200 毫秒,超过就考虑拆分或分块。Worker 数量控制在navigator.hardwareConcurrency的一半左右比较稳妥,因为你的机器同时还要跑浏览器渲染、系统进程。
// 主线程 const worker = new Worker('./aggregate.worker.js', { type: 'module' }); worker.postMessage({ action: 'aggregate', buffer: timestamps.buffer }, [timestamps.buffer]); worker.onmessage = (e) => { const { result } = e.data; render(result); };注意postMessage的第二个参数,那是转移列表。timestamps.buffer传进去之后,主线程这边的timestamps会变成长度为零的失效视图,数据所有权转移给了 Worker,中间不发生拷贝。这个细节后面会专门讲。
顺带说一个容易被忽略的点:Worker 文件本身也是要加载的,如果你在首屏就new Worker,会多一次网络请求。我的做法是首屏只做基础渲染,Worker 延后到用户第一次触发筛选时再懒加载,用不到的场合一分钱不花。
3.2 分块处理与时间切片的具体写法
有些计算没法丢进 Worker,比如必须依赖 DOM 测量结果的逻辑,或者需要频繁跟用户交互反馈的渐进式处理。这种情况用时间切片:把大循环拆成小批次,每批执行完检查一次耗时,超过预算就主动让出主线程。
function processInChunks(items, chunkSize, onChunk) { let index = 0; function run() { const deadline = performance.now() + 8; // 每帧留 8ms 给计算 while (index < items.length && performance.now() < deadline) { const end = Math.min(index + chunkSize, items.length); for (let i = index; i < end; i++) { // 实际处理逻辑 } index = end; } if (index < items.length) { requestAnimationFrame(run); // 让出一帧,等下一帧继续 } else { onChunk('done'); } } run(); }这里的8这个数字不是拍的。一帧的总预算是 16.7 毫秒(60 帧),浏览器要做样式计算和绘制,通常占掉 5 到 8 毫秒,留给脚本的安全余量就在 8 毫秒左右。给多了会掉帧,给少了处理时间拉得太长让人等得难受。
chunkSize的设置要看单条数据的处理成本。如果单条处理是微秒级的,chunkSize设大一点没问题,反正都会被 deadline 截断;如果单条处理本身就要几毫秒(比如涉及正则匹配或复杂字符串操作),那chunkSize最好设成 10 到 100,避免一批就超时。我一般会先写个微基准测单条耗时,再倒推批次大小,比拍脑袋靠谱。
用requestAnimationFrame还是setTimeout(fn, 0)?前者跟渲染节奏对齐,适合有视觉反馈的场景;后者适合后台静默处理,更快一些但可能挤占渲染时间。我一般首选requestIdleCallback,实在兼容性有顾虑就退回setTimeout。不过requestIdleCallback在低负载时才会给你时间,如果你的计算是用户等了很久必须马上出结果的,别用它,会等得莫名其妙。
3.3 结构化克隆的开销与零拷贝转移
很多人在 Worker 里算完,把整个结果数组postMessage回主线程,然后发现“用了 Worker 反而更慢了”。原因就在结构化克隆算法:postMessage传普通对象和数组时,会做一次深拷贝,几十万条记录拷一遍,光拷贝就得几百毫秒,而且在主线程上执行,照样卡。
解决方案有两种。第一种是把结果也转成 TypedArray,用转移列表传回来:
// Worker 内部 self.onmessage = (e) => { const view = new Uint32Array(e.data.buffer); const agg = new Uint32Array(16); // 假设按 16 个分类聚合 for (let i = 0; i < view.length; i++) { agg[view[i]]++; } self.postMessage({ agg: agg.buffer }, [agg.buffer]); };第二种是用SharedArrayBuffer,主线程和 Worker 共享同一块内存,两边用 Atomics 做同步。这个方案性能最好但坑也最多,需要页面开启跨源隔离(两个响应头都要配),而且并发写同一块内存很容易出数据竞争,非必要不建议上。我那个项目最后用的是转移列表方案,够用且简单。
还有一个实测细节:postMessage传字符串和传 ArrayBuffer 的成本差得很远。如果你的 Worker 需要返回一大段文本,先转成 UTF-8 的Uint8Array再转移,比直接传字符串快不少。
4. 渲染阶段:只画用户真正看得见的部分
4.1 虚拟列表的核心计算与高度估算
DOM 节点数是渲染性能的第一杀手。三十万行数据意味着三十万个<tr>,每个节点都有样式计算、布局盒模型、绘制指令,加起来轻松破 GB 的渲染内存。虚拟滚动的思路很朴素:容器高度按总行数撑开,实际只渲染可视区域加少量缓冲的行,滚动时动态替换内容。
核心计算就三行:
const totalHeight = rowHeight * totalRows; const startIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - BUFFER); const endIndex = Math.min(totalRows, Math.ceil((scrollTop + viewportHeight) / rowHeight) + BUFFER); const offsetY = startIndex * rowHeight;BUFFER我一般取 5 到 10 行,作用是快速滚动时提前铺好内容,减少白屏闪烁。取太大反而会增加每帧的渲染量,得不偿失。
固定行高是最省事的,直接除法定位。麻烦的是行高不固定的场景,比如内容可能有换行。这时候要用前缀和加二分查找:预先算出每行累积高度数组,查找时用二分定位scrollTop落在哪两行之间,复杂度是 O(log n),三十万行也就查 19 次。前缀和数组可以用Float64Array存,三十万条才 2.4 MB,完全负担得起。
注意:滚动事件默认触发频率非常高,一定要用
passive: true监听,避免浏览器等待你的处理结果而阻塞滚动。同时用requestAnimationFrame做节流,把同一帧内的多次滚动合并成一次计算。
节流我推荐这种写法,比固定时间间隔的 debounce 更跟手:
let ticking = false; container.addEventListener('scroll', () => { if (ticking) return; ticking = true; requestAnimationFrame(() => { updateViewport(container.scrollTop); ticking = false; }); }, { passive: true });4.2 索引构建与 Map、Set 的降复杂度改造
虚拟滚动解决了渲染问题,但筛选和查找的复杂度问题还在。这是我那个项目里最坑的一环。原来的逻辑是:用户选了一个分类,代码遍历全部三十万行挑出匹配的;用户再选一个用户 ID,又遍历一遍。界面上一共有八个筛选条件,用户稍微调整几下,就是三十万乘以几十次的比较,轻松上亿次。
改造思路是一次建索引,多次查。把需要频繁筛选的字段预先建成Map,值是下标数组:
// O(n) 建一次索引 const byCategory = new Map(); for (let i = 0, n = categories.length; i < n; i++) { const key = categories[i]; let bucket = byCategory.get(key); if (!bucket) { bucket = []; byCategory.set(key, bucket); } bucket.push(i); } // 之后每次筛选都是 O(1) 拿桶,O(k) 取数据 const matched = byCategory.get(selectedCategory) ?? [];几个筛选条件做交集时,用Set求交比嵌套循环快得多,而且Set的has是哈希查找:
function intersect(a, b) { const small = a.length < b.length ? a : b; const large = new Set(a.length < b.length ? b : a); return small.filter(x => large.has(x)); }这里有个容易忽略的细节:用小的那个做遍历。如果 A 有 20 万条、B 只有 500 条,用 A 遍历去查 B 的 Set 要查 20 万次,反过来只要查 500 次。这个顺序一换,性能差好几百倍,而代码只改一个判断。
索引也有代价:每个 Map 都占内存,字段多了内存会涨。我的策略是只给用户实际会用到的筛选项建索引,并且索引结果不存值只存下标,下标用Uint32Array存而不是普通数组,能省下不少。三十万条的全量索引大概占几 MB,相比每次重算的 CPU 开销,这笔买卖划算。
4.3 派生数据的缓存与惰性计算
还有一种常见的浪费是重复计算派生数据。比如界面上要显示“总条数”“平均值”“前十大类别”,这些数据如果每次渲染都重算一遍,三十万行累加一次是很快,但一秒渲染几十次就受不了了。
做法很直接:用原始数据的版本号做缓存键,数据不变就不重算。
const cache = new Map(); function getAggregate(dataVersion) { if (cache.has(dataVersion)) return cache.get(dataVersion); let sum = 0; for (let i = 0, n = values.length; i < n; i++) sum += values[i]; const result = { sum, avg: sum / values.length }; cache.set(dataVersion, result); return result; }缓存别无限涨,超过一定数量就清掉最旧的。用Map的插入顺序特性很容易实现,cache.keys().next().value就是最老的那个。
惰性计算则用在“算一次可能用不上”的地方。比如用户可能永远不点“导出”按钮,那导出的格式化工作在点击之前完全没必要做。原则很简单:能拖到用户真正需要时再算的,就别提前算。首屏时间是最宝贵的资源,别浪费在用户不一定会看的数字上。
5. 常见问题与排查实录
5.1 卡顿场景速查与对应手法
这些年被问到的问题高度重复,我整理成一张表,遇到对应症状可以先在这里查一下。
| 症状 | 高概率原因 | 优先尝试的手法 |
|---|---|---|
| 首屏卡死后恢复正常 | JSON 解析阻塞主线程 | 换 NDJSON 分块解析或 Worker 内解析 |
| 页面偶发卡顿,GC 曲线锯齿 | 循环内创建大量临时对象 | 改列式 TypedArray、复用对象 |
| 滚动时掉帧但数据不多 | 渲染节点太多、滚动未节流 | 虚拟滚动 + rAF 节流 + passive |
| 筛选点击后要等好几秒 | 每次重新遍历全量数据 | 预建 Map 索引、Set 求交 |
| 用了 Worker 反而更慢 | postMessage 深拷贝开销 | 转移 ArrayBuffer 所有权 |
| 内存占用持续上涨不回落 | 闭包持有大数组、事件未解绑 | 打堆快照对比、主动置空引用 |
| 表格滚动时内容错位 | 行高与实际不一致 | 用实际测量高度重算前缀和 |
| 数据量不大但操作很慢 | 隐藏类分裂、字典模式对象 | 统一对象结构、避免动态增删属性 |
这张表不是万能药,但能省掉大量试错时间。我的判断顺序固定是:先看 Performance 火焰图定方向,再看 Memory 快照定是不是内存问题,最后才动代码。跳过前两步直接改代码,十有八九白干。
5.2 几个踩过的坑与实战教训
说几个具体踩过的坑,都是文档里不写、但实际会遇到的。
第一个坑是分块处理时闭包捕获了大数组。当时我在分块函数外层持有原始数据,本来想的是处理完再释放,结果每次requestAnimationFrame都会重新创建一个闭包,V8 不敢回收那个数组,内存一直顶在天花板。解决方法是处理完主动raw = null,并且把内层函数用到的数据缩到最小范围。这个坑我排查了整整两天,因为从火焰图上看不出来,只有堆快照对比才露出马脚。
第二个坑是**performance.now()在 While 循环里的调用开销**。我最初在每处理一条数据就调一次performance.now()判断是否超时,结果这个调用本身成了瓶颈。改成每处理 500 条判断一次,性能立刻回来了。计时函数本身不贵,但在千万次循环里就不一样了。
第三个坑是虚拟滚动配合动态行高时的抖动。行高如果依赖内容动态撑开,滚动时新旧行的测量会互相影响,导致滚动条不断跳动。解决办法是先按估算高度撑开,行渲染完成后用ResizeObserver量真实高度,如果和估算差太多就更新前缀和数组,并且用一个阈值(比如差 2 像素以内不更新)抑制频繁重算。
第四个坑是在 Worker 里用了主线程的 DOM 相关 API。Worker 环境没有document,也没有window,任何依赖这些的代码搬过去直接报错。我建议把计算逻辑写成纯函数,输入输出都是数据,主线程和 Worker 都能跑,这样调试的时候还能在主线程里断点调试,测通了再挪过去。
提示:写 Worker 代码时,养成先在主线程里跑通、确认结果正确,再整体搬进 Worker 的习惯。Worker 里的断点调试体验比主线程差很多,先在熟悉的环境里保证逻辑正确能省下大量时间。
最后一个心得是关于测量本身。很多性能问题在开发机上完全复现不出来,因为开发机内存大、CPU 快。一定要在接近真实用户的低配设备或者 DevTools 的 4 倍 CPU 降速模式下验证一遍。我那个项目在本机跑得好好的,一放到测试机(内存只有一半)上,瞬间暴露了三个内存问题。降速模式不只能测 CPU,还能帮你提前发现那些“在本机被高性能掩盖”的隐患。
如果这块内容要继续往下挖,还有两个方向值得折腾:一是把聚合计算改成增量式的,数据只追加不重算,适合实时流式场景;二是把渲染层从 DOM 换成 Canvas,行数上万之后 Canvas 的绘制优势会非常明显,代价是丧失了 DOM 的可访问性和原生事件能力,得自己实现一套命中检测。这两个方向我自己还在试,等踩完坑再说。
数据量这件事,说到底不是技术炫技,是在资源有限的前提下做合理的取舍。算得少、存得小、画得少,三件事各做对一半,性能自然就上来了。真正难的从来不是某个 API 怎么写,而是知道该在哪个环节停下来,把剩下的交给架构去解决。