bundle优化

一、技术难点

Day29 的结尾把活儿分得很清楚:那一篇管"一个线程内部的调度",拆包留给 Day30(那是"字节怎么到浏览器")。这一篇就是回答它。

但"bundle 优化"这个名字本身就是第一个陷阱。它把一件三目标联合优化的事,说成了一个"把数字变小"的动作。先给结论:

bundle 优化不是"让 bundle 变小",而是在三个互相拉扯的预算里找平衡:初始字节、请求形态、失效面。 这三个预算里,前两个写在同一份构建配置里,第三个写成文件名里的一对方括号------而第三个几乎所有团队都不看,却拥有最大的杠杆。

本文要拆掉的三个"常识",都是我先把它们当真理写进脚本、然后被自己的断言推翻了:

  1. 「切得越细,首屏越快」 ------错。切分不改变初始字节 。同样 960KB 的入口,切成 16 片首屏 +60.27ms ,切到 32 片再 +61.16ms ,gzip 后体积从 317.38KB 涨到 346.22KB 、再到 360.67KB 。真正把首屏从 2770.6ms 压到 1278.21ms 的,是"把另外 1230KB 移出首屏"这个排除 动作,不是"把 960KB 切开"这个整形 动作(chunk-budget.mjs PART 1)。
  2. 「做了路由切分就有收益」 ------不一定。如果共享依赖没被提升成独立片,而是被抄进了每个路由片,那么三次导航的累计传输入 671.9KB ,比不切分的单包 657KB 还多 2.3% ,耗时只省了 1.9%(2718.72ms vs 2770.6ms)。切分带来的收益,100% 来自"共享模块被提升",0% 来自"路由被切开"这个动作本身(PART 2)。
  3. 「bundle 优化看的是一次访问」 ------漏掉了最重要的一半。把用户 20 次回访的成本加起来:只做路由切分、文件名用 [fullhash] 的方案总账是 24310.32ms,比干脆不切分的单包 23876.62ms 更差 。而把 [fullhash] 改成 [contenthash]、把 module id 锁成确定性之后,同一份代码的总账是 8981.65ms(PART 4)。

本篇所有数字都来自三份零依赖 Node 脚本的实跑输出,合计 66 条不变量断言 ,断言失败即 exit 1。和 Day25~Day29 一样,它们是确定性简化模型 ,价值是把不同策略放进同一个坐标系里比较,而不是预测某台机器上的绝对毫秒数。参数与每份脚本的乐观之处在文末「运行环境」逐条写清楚了。

边界也说清:本篇讲字节怎么被组织、被请求、被缓存 。图片与字体 留给 Day31,Worker/OffscreenCanvas 留给 Day32,WASM 留给 Day33,性能监控与 RUM 留给 Day34,**构建工具本身(vite/webpack/turbopack 的架构差异)**留给 Day35------那一篇讲的是"谁来打包",这一篇讲的是"打包的结果该长什么样"。混着讲的结果通常是两边都说不透。

难点一:切分不改变初始字节,它改变请求形态

先把最干净的一张表摆出来。一个 2190KB(minified)的应用,首屏只需要其中的 960KB:

方案 初始KB gzipKB 层级 h2 首屏ms h1 首屏ms
A 单包 2190 657 L0:1片 2770.6 2770.6
B 路由切分·入口路由 lazy 960 317.38 L0:3片 + L1:1片 1330.11 1330.11
C 路由切分 + modulepreload 入口 960 317.38 L0:4片 1278.21 1278.21
D 入口 960KB 切成 16 片 960 346.22 L0:16片 1338.48 1448.48
E 入口 960KB 切成 32 片 960 360.67 L0:32片 1399.64 1674.64

把增量拆开看,三个动作的价格一目了然:

对比 差值 ms
A → C 把 1230KB 移出首屏(排除) −1492.39
B → C 给入口路由加 modulepreload(消瀑布) −51.90
C → D 把同样的 960KB 切成 16 片(整形) +60.27
D → E 再切细一档到 32 片 +61.16

"排除"省 1492ms,"整形"赔 121ms。 这就是为什么"把 bundle 变小"这个说法是危险的:它把注意力全放在了整形上。

为什么切细一定赔?三笔账,每一笔都能算:

  1. 压缩率的损失 。gzip 的压缩比随输入变小而变差(我自己在模型里用的是一条随体积衰减的曲线):960KB 整体压到 0.301 ,60KB 的片压到 0.361 ,30KB 的片压到 0.376 。同样的 960KB,4 片时 gzip 合计 317.38KB ,32 片时 360.67KB ------多传 43.29KB,+13.6%。
  2. 请求固定开销。h2 复用了连接,但首部处理、流调度、缓存查找仍然按"每个请求"计费。片数从 4 涨到 32,多出 28 个请求。
  3. 编译上下文开销 。每个 chunk 是一份独立的脚本,V8 要为它单独建一次解析/编译上下文。这一笔很小,但它是加性的。

然后是最容易忽略的一笔:HTTP/1.1 的队列 。同一张表最后一列,D 方案(16 片)在 h1 上要排队 2 轮,代价 +110ms ;E 方案(32 片)要排 5 轮,代价 +275ms ------19.6%。所以在 h2 上"切细的代价约 5%"这个结论,一旦落到不支持并发的链路上会放大四倍。这也是为什么"我们已经在 h2 上了"这句话必须被验证,而不是被假设。

难点二:路由切分的收益全部来自"共享提升",不是来自"路由切开"

把"切分"和"提升"分开验证,是这一节的全部内容。同一个应用,用户依次访问 dash → order → report 三个路由:

方案 3 路由累计KB 累计 gzipKB 耗时ms
A 单包 2190 657 2770.6
B 路由切分 + 共享依赖提升 1600 525.19 2084.45
C 路由切分(无共享提升) 2120 671.9 2718.72

逐跳看得更清楚:

方案 order 一跳 report 一跳
B 提升后 109.96 KB 97.84 KB
C 未提升 186.66 KB 175.05 KB

C 方案就是那个经典的、几乎每个项目都踩过一次的写法:

js 复制代码
// 反例:每个路由 chunk 都把自己用到的共享模块抄一份
const routes = {
  dash: () => import('./pages/dash'),
  order: () => import('./pages/order'),
  report: () => import('./pages/report'),
};
// pages/order 里 import { Table } from '@/components/table'
// -> Table 打进了 route-order,也打进了 route-dash、route-report

Table、Form、DateUtil 这类模块被复制进了 3 个路由片。首屏看起来一模一样(960KB),但每一次跨路由导航都在重新下载那 260KB 。三次导航下来,累计传输入 671.9KB > 657KB ------比不切分还贵。

所以判断"要不要切"的正确口径,不是"我切了几个路由",而是:

有没有任何一个模块,同时出现在了 ≥2 个会被同一个用户访问到的 chunk 里?

有,就先做提升,再谈粒度。这个口径是可以CI 化的 (见 2.1 的 budget-gate.mjs 里那一项 duplication),而且它比"bundle 总大小"这个指标灵敏得多------因为共享模块被复制的时候,总大小几乎不变,只有导航成本在变。

顺带一个推论:splitChunks.minChunks、cacheGroups 这些配置为什么存在?它们存在的唯一理由就是"把被多个 chunk 用到的模块提上来"。如果你的配置里没有一条规则在表达"谁被重复了",那你的切分大概率只是在整形。

难点三:第三个预算------失效面,没人看,杠杆最大

现在把时间轴从"一次访问"拉到"二十个版本 × 二十次访问"。

一个 960KB 的首屏、192 个模块(每个 5KB)。模拟 20 个发布,每个版本平均改动 11.4 个模块,其中 70% 聚集在一个热点目录。扫描粒度从 1 片到"一模块一片":

粒度 片均 首屏ms 失效片数 失效KB 失效gzipKB 回访ms 总账V=5 总账V=20
1 片 960KB/片 1246.08 1 960 288.85 1191.08 6010.4 23876.62
2 片 480KB/片 1260.74 1.9 912 288.14 1149.71 5859.57 23105.2
3 片 320KB/片 1270.6 2.55 816 264.99 1049.59 5468.97 21212.85
4 片 240KB/片 1278.5 2.8 672 222.42 893.14 4851.06 18248.16
6 片 160KB/片 1291.46 3.85 616 209.31 835.05 4631.66 17157.42
8 片 120KB/片 1302.46 4.2 504 174.4 711.92 4150.15 14829.01
12 片 80KB/片 1321.62 5.4 432 153.29 633.83 3856.96 13364.48
16 片 60KB/片 1338.82 5.95 357 128.91 550.58 3541.13 11799.8
24 片 40KB/片 1370.38 7.1 284 105.05 469.44 3248.14 10289.75
32 片 30KB/片 1399.98 7.9 237 89.15 416.77 3067.05 9318.55
48 片 20KB/片 1456.34 8.85 177 68.14 348.96 2852.18 8086.56
64 片 15KB/片 1510.74 10 150 58.68 318.44 2784.51 7561.16
96 片 10KB/片 1616.7 10.4 104 41.6 265.68 2679.42 6664.62
192 片 5KB/片 1925.86 11.4 57 23.66 211.38 2771.36 5942.01

三条结论都在这张表里:

第一,首屏几乎是平的。 从 1 片到 192 片,首屏从 1246.08ms 涨到 1925.86ms------绝对值看着不小(+679.78ms,+54.6% ),但要注意这是极端对比 :1 片到 16 片只涨了 92.74ms(+7.4%),16 片到 64 片涨了 171.92ms。在现实会用到的区间(4~32 片)里,"拆细拖慢首屏"的代价不到 10%。

第二,回访成本几乎是直线下降的。 从 1191.08ms/次降到 211.38ms/次------−82.3% 。因为失效字节从 960KB 掉到 57KB,而失效字节是"用户每次回访都要重新传输 + 重新解析"的那部分。失效片数从 1 涨到 11.4,正好等于"每个版本平均改动的模块数"------当你切到"一模块一片"时,失效面就等于改动面,这是一个数学上的下界,不可能再优化了。

第三,最优点取决于"用户会来几次"。

  • V=1(只来一次):最优 1 片。 因为一次性用户根本不付失效的账,他只为首次加载付费。
  • V=5:最优 96 片。
  • V=20:最优 192 片(即"一模块一片")。

这不是模型的花活,而是这个问题的真实结构:

bundle 优化的最优粒度 = f(用户回访次数 V × 版本节奏 k)。 它不是代码的属性,是你的用户和你的发布流程的属性。

一个每天发 5 次版、DAU 里 80% 是回访用户的产品,和一个每季度发一版、绝大多数访客只来一次的活动页,最优拆包策略是相反的。这是"最佳实践"清单永远给不出答案的原因:它们不知道 V 和 k。

边际收益也在衰减,可以逐档看:

粒度变化 19 次回访省 首屏多付 净收益
3→4 片 2972.59 7.9 2964.69
4→6 片 1103.7 12.96 1090.74
16→24 片 1541.61 31.56 1510.05
48→64 片 579.79 54.4 525.39
96→192 片 1031.77 309.16 722.61

第一档的净收益是最后一档的 4.1 倍 。所以在工程上,"切到 16~32 片、并把失效面控制在一个可解释的范围"通常就是性价比的拐点------不是因为模型说 192 片不划算,而是从 32 片往上,你每多切一片,都要多付一份构建产物、CDN 缓存条目、发布流程和排障心智,这些成本不在毫秒里。

难点四:hash 和 id 的杠杆,大于粒度

现在看同一份代码、四种"文件名策略"。判定口径:用户 20 次回访的总账(首屏一次 + 19 次"重新下载 + 重新解析失效部分",未失效部分按命中 V8 code cache 的 0.15 倍计)。

文件名策略 每版失效KB 失效gzipKB 每次回访ms 总账 V=20
single + [fullhash] 960 288.85 1191.08 23876.62
route + [fullhash] 960 317.38 1213.91 24310.32
route + [contenthash] + id 不稳定 780 256.68 1012.35 20480.65
route + [contenthash] + deterministic id 236 78.17 407.13 8981.65

两个钉子:

钉子一:[fullhash] 会让"切分"变成负收益。 第二行 24310.32ms 比第一行的单包 23876.62ms 还差 。因为 [fullhash](也就是 webpack 5 里 [hash] 的新名字)是整次构建 的哈希:改一个字符,所有 chunk 的文件名全变,全部失效。于是你付了"首屏多 4 个请求"的代价,却一分缓存收益都没拿到。这是端到端最亏的一种配置:把它做对了是 8981.65,做错了是 24310.32,差 2.7 倍,而代码一个字都没改。

钉子二:module id 的稳定性,单独一项的杠杆就有 2.3 倍。 第三行只是"id 不稳定",失效面就从 236KB 涨到 780KB(8981.65 → 20480.65)。原因是:当模块的 id 由数字序号(moduleIds: 'natural' / 'named' 的非确定性变体)或时间戳、路径排序决定时,新增/删除任何一个模块,都会让它后面所有模块的 id 平移,于是引用它们的 chunk 内容全变、hash 全变。

这两条加起来说明一件事:

在"切多细"这个问题上纠结之前,先确认"切了之后能不能真的省下传输"。 文件名和 id 的稳定性,是切分收益的前置条件,不是它的补充项。

难点五:摇树的开关不在打包器,在 package.json

摇树(tree shaking)这件事最容易被误解的地方是:大家以为它是打包器的能力问题,实际上是模块作者有没有给出"可以删"的证明。

我用 200 行实现了一遍"按导出可达性做消除"的引擎(treeshake-model.mjs PART A:工作队列 + 名字解析 + 模块结算),然后拿同一个图标库(120 个图标、每个 1.6KB)跑四种写法,业务只用其中 1 个:

写法 打进包的模块数 保留字节 相对深导入
深导入 icons/Icon7(绕过桶) 3 个 4.2 KB 1x
桶导入 + sideEffects: false 4 个 4.3 KB 1.024x
桶导入 + 未声明 sideEffects 123 个 194.7 KB 46.357x
桶导入 + export default { ... } 命名空间对象 123 个 195.1 KB 46.452x

一个 package.json 字段,190.4KB。 按本文 1ms/KB 的解析单价,首屏多付 190.4ms------而代码里那行 import 一个字都没变。

四个机制值得写清楚:

  1. 桶文件不是罪,未声明才是。 同样的 import { Icon7 } from 'icons',声明了 sideEffects: false 之后保留 4.3KB (只比深导入多 0.1KB 的转发代码)。所以"禁止桶文件"这条军规是错的,正确的军规是"桶文件的包必须声明 sideEffects"。
  2. export * 在"未声明"时是灾难。 打包器无法证明被转发的 120 个模块没有顶层副作用,也无法确定名字到底解析到哪一个,于是只能全部求值------194.7KB。
  3. export default { ... } 会把摇树关掉。 命名空间的成员是运行期属性访问,静态分析看不到你只用了 Icon7,于是 195.1KB。这是"为了 import 方便"付出最贵代价的一种写法。
  4. 动态 import() 的键必须是静态可枚举的。 import('./icons/' + name) 或者 switch 里对未知值的分支,都会让打包器保留所有分支(webpack 的 "Critical dependency: the request of a dependency is an expression" 警告就是在说这件事)。

而 sideEffects: false 的另一面,是它可以删掉有用的代码 。treeshake-model.mjs 的 PART C 是一段可执行证明:

sideEffects 声明 顶层副作用 运行结果
正确(默认 unknown) 保留 ok
错误声明 false 被消除 return broken

注意结果是 broken 而不是抛错 ------这是这类 bug 最阴险的形态:polyfill 模块顶层的 globalThis.__SUPPORTS_BIGINT = ... 被当成无用代码删了,而依赖它的 checkBigInt() 不报错,只是静默地返回错误结果。

所以:sideEffects: false 不是性能开关,是事实声明。 它省下的是字节,赔上的可能是所有"注册类"代码------polyfill、全局样式注入、装饰器注册、customElements.define、dayjs.extend 这一类"导入即生效"的模块。写这个字段之前必须逐个确认:这个包里的每个模块,顶层是不是真的没有可观察行为?

难点六:发现层级 = RTT,这是唯一一条"线性于层级"的成本

回到 PART 1 的第二行:B 方案(入口路由 lazy)比 C 方案(入口路由 modulepreload)慢 51.9ms 。同一批字节、同样的片数,唯一的区别是这一片是在入口 chunk 的静态 import 列表里被声明,还是等入口 chunk 解析完、执行到 import() 那一行才被发现。

模型里这一笔就是"多一个 RTT",而 RTT 是 55ms。这个数量级值得单独强调,因为它是唯一一条会随层级线性叠加的成本:

  • 静态 import 会被打包器压平成"入口 chunk 的 import 列表",所有静态片都在第 0 层 被发现,浏览器并行发起------层级深度为 1。
  • 动态 import() 的片在第 1 层才能被发现,因为入口必须先下载、解析、执行到那一行。
  • 如果 route-order 里又有 import('./order/detail'),detail 就在第 2 层。
  • 如果 detail 里再有一个按需加载的图表库,就是第 3 层。

三层嵌套 = 三个 RTT = 165ms ,而且这 165ms 是纯等 ,任何压缩、任何 CDN 都救不回来。这就是为什么 modulepreload / webpackPreload / vite 的 build.modulePreload 这些"提前声明下一层"的机制,价值不在"更早开始下载",而在把层级压平回第 0 层。

一个可以立刻用的判断:

js 复制代码
// 入口路由:不要 lazy,要静态引入 + 让打包器 preload
// ❌ 首屏多一个 RTT
const Dash = () => import('./pages/dash');
// ✅ 首屏路由进第 0 层;其余路由继续 lazy
import Dash from './pages/dash';

"所有路由都必须 lazy"是一条被误传的军规:入口路由是首屏的一部分,把它 lazy 出去,等于主动把首屏关键路径拉长一个 RTT。正确做法是"入口路由静态 import,其余路由 lazy,并在路由切换时预取相邻路由"。


二、完整解法

上面六个难点可以压缩成一句执行顺序,顺序不能换:

先排除,再提升,再锁死失效面,最后才调粒度。

理由:粒度是唯一一个"调错了收益为负"的旋钮,而它排在最前面时,你既没有基线也没有护栏。下面五步,每一步都有可运行的东西。

2.1 第一步:把"三张表"变成 CI 的一个命令

绝大多数团队量的是"bundle 总大小"。这个数字有三个问题:它把永远用不到的 chunk 算进首屏;它看不见请求数 ;它对失效面完全无感。

所以第一件事是把度量口径换成四个数。day30-assets/budget-gate.mjs 就是干这个的------零依赖,读一份 chunk 清单,输出四项判定:

bash 复制代码
$ node budget-gate.mjs stats.sample.json
budget-gate: entry = main,初始闭包 = 5 片
  main, route-dash, shared, vendor, runtime

[PASS] 初始字节                974KB  (上限 1000KB)
[PASS] 初始 gzip             324KB  (上限 330KB)
[PASS] 初始请求数                 5片  (上限 6片)
[PASS] 单模块失效面              340KB  (上限 360KB)
[PASS] 业务模块重复                0个  (上限 0个)
[PASS] 重复体积                  0KB  (上限 0KB)

失效面最大模块: src/pages/order/index.tsx -> [route-order]

结果: 全部通过

换成那份"朴素路由切分"的样本,同一个脚本会立刻红掉三项:

bash 复制代码
$ node budget-gate.mjs stats.bad.json
[PASS] 初始字节                974KB  (上限 1000KB)
[PASS] 初始 gzip             317KB  (上限 330KB)
[PASS] 初始请求数                 4片  (上限 6片)
[FAIL] 单模块失效面             1628KB  (上限 360KB)  超 +1268KB
[FAIL] 业务模块重复                3个  (上限 0个)  超 +3个
[FAIL] 重复体积                436KB  (上限 0KB)  超 +436KB

被复制到多个 chunk 的业务模块:
  - src/components/table.tsx x3 (+240KB)  [route-dash, route-order, route-report]
  - src/components/form.tsx x3 (+180KB)  [route-dash, route-order, route-report]
  - src/utils/date.ts x3 (+16KB)  [route-dash, route-order, route-report]

注意第二份的初始字节和 gzip 都是 PASS ------它和第一份的首屏字节几乎一样(974KB vs 974KB)。如果只看 bundle size,你会认为两者等价。 但它的失效面是 1628KB,重复 436KB。这正是"总大小"这个指标最误导人的地方:共享模块被复制时,总体积几乎不动,只有导航成本和失效面在变。

四个口径各自的算法(都在脚本里):

js 复制代码
// 1) 初始闭包:从 entry 出发,沿【静态】import 可达的所有 chunk
function initialClosure(entryName) {
  const seen = new Set(); const stack = [entryName];
  while (stack.length) {
    const name = stack.pop();
    if (seen.has(name) || !chunks.has(name)) continue;
    seen.add(name);
    const c = chunks.get(name);
    if (c.dynamic) continue;                       // 动态 chunk 不属于初始闭包
    for (const dep of c.staticImports) stack.push(dep);
  }
  return [...seen];
}

// 2) 失效面(最坏口径):改动落在"覆盖面最大"的业务模块上
//    一个模块若被 k 个 chunk 包含,它的改动会让这 k 个 chunk 全部失效
for (const [mod, owners] of moduleToChunks) {
  if (mod.startsWith('node_modules/')) continue;
  const kb = owners.reduce((s, n) => s + chunks.get(n).kb, 0);
  if (kb > worstChurn.kb) worstChurn = { module: mod, kb, owners };
}

// 3) 重复:业务模块出现在 >1 个 chunk 里就是缺陷(三方库被有意复制是另一回事)
// 4) 请求数:初始闭包的 chunk 数

把这份脚本挂进 CI,exit 1 就是红线。关键是 duplication 这一项------它是最便宜的护栏,因为它一旦报警,就说明你的切分正在做负功。

2.2 第二步:排除------把字节移出首屏

这是唯一能拿到"千毫秒级"收益的动作(PART 1:−1492.39ms)。三条规则,按优先级:

规则一:入口路由静态引入,其余路由 lazy。

js 复制代码
// routes.tsx
import Dash from './pages/dash';                    // ✅ 首屏路由:静态,进第 0 层
const Order  = lazy(() => import('./pages/order'));  // 其余:动态
const Report = lazy(() => import('./pages/report'));

// 路由表里 Dash 不用 Suspense 包,因为它已经同步可用

规则二:给入口闭包的每一片加 modulepreload。 现代打包器会在构建期自动生成(vite 默认开、webpack 需要 preload 插件或用 import(/* webpackPreload: */))。手写形态是:

html 复制代码
<link rel="modulepreload" href="/assets/shared.a1b2c3.js" crossorigin>
<link rel="modulepreload" href="/assets/vendor.d4e5f6.js" crossorigin>
<link rel="modulepreload" href="/assets/route-dash.g7h8i9.js" crossorigin>

这一条买到的是 51.9ms(一个 RTT),成本是 3 行 HTML。

规则三:把"不用"的东西真的删掉,而不是 lazy 掉。 lazy 只是把它从首屏移到第二层;如果它从来没用过,就应该从依赖树里去掉(见 2.5 的摇树)。"用 lazy 掩盖一个过大的依赖"是最常见的假优化:它让首屏数字好看,让用户在第一次导航时付全价。

2.3 第三步:共享提升------让"被复用的模块"只有一个副本

这一步决定"切分是正收益还是负收益"(PART 2)。webpack 5 的配置(版本差异以你所用版本为准):

js 复制代码
// webpack.config.js
module.exports = {
  output: {
    filename: 'assets/[name].[contenthash:8].js',
    chunkFilename: 'assets/[name].[contenthash:8].js',
  },
  optimization: {
    moduleIds: 'deterministic',   // 见 2.4:这一行比任何切分粒度都重要
    chunkIds: 'deterministic',
    runtimeChunk: 'single',       // 把 webpack runtime 抽出来,避免它跟着业务 chunk 一起变
    splitChunks: {
      chunks: 'all',              // 关键:async 之外还要管 initial
      minSize: 20000,             // 小于 20KB 的片不单独出(压缩损失会吃掉收益)
      minChunks: 2,               // 被 >=2 个 chunk 用到就提上来 ← 这就是"共享提升"
      cacheGroups: {
        react: {
          test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
          name: 'vendor-react',
          priority: 40,           // 优先级高的先匹配
          chunks: 'all',
          enforce: true,
        },
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor',
          priority: 20,
          chunks: 'all',
          minChunks: 2,           // 只被一个入口用到的库,留在原地,别硬提
        },
        shared: {
          test: /[\\/]src[\\/](components|utils|hooks)[\\/]/,
          name: 'shared',
          priority: 30,
          chunks: 'all',
          minChunks: 2,
        },
      },
    },
  },
};

vite(Rollup)里对应的写法是 manualChunks,语义上更"手写",容易漏掉共享提升:

js 复制代码
// vite.config.ts
export default defineConfig({
  build: {
    modulePreload: { polyfill: true },   // 入口闭包自动 preload
    cssCodeSplit: true,
    rollupOptions: {
      output: {
        // 函数形态:能拿到 id,才能表达"谁被复用"
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (/node_modules\/(react|react-dom|scheduler)\//.test(id)) return 'vendor-react';
            return 'vendor';
          }
          if (/src\/(components|utils|hooks)\//.test(id)) return 'shared';
          // 其余交给 Rollup 按路由动态 import 自动切
        },
        // Rollup 4:把过小的 chunk 合并回父 chunk,避免碎成几十片
        experimentalMinChunkSize: 20_000,
      },
    },
  },
});

三个"必须理解为什么"的点:

  • chunks: 'all' 而不是默认的 'async'。默认值只对动态 import 生效,于是首屏里的共享模块不会被提升------首屏那 3~4 片之间的重复就留在那儿了。
  • minChunks: 2 是"提升"的定义本身 。没有这一条,cacheGroups 只是在按目录改名。
  • minSize: 20000 是"压缩损失"的价格线。PART 1 已经算过:片从 240KB 缩到 30KB,gzip 比从 0.301 涨到 0.376。所以"把每个组件切成一片"在字节上一定亏。

2.4 第四步:锁死失效面------比调粒度重要得多

PART 4 的三个数字(24310.32 / 20480.65 / 8981.65)说明,这一步的杠杆是倍数级的。四行配置:

js 复制代码
// 1) 用 contenthash,永远不用 fullhash/hash
output.filename = 'assets/[name].[contenthash:8].js';

// 2) module id / chunk id 锁成确定性(webpack 5 生产模式默认是 deterministic,
//    但只要有人为了调试改成 'natural'/'named',或者插件按时间戳生成 id,地板就塌了)
optimization.moduleIds = 'deterministic';
optimization.chunkIds = 'deterministic';

// 3) runtime 单独成片:runtime 里保存着 chunk 的映射表,它跟着业务变是必然的,
//    但不能让它把 vendor 一起拖下水
optimization.runtimeChunk = 'single';

// 4) CSS 也用 contenthash,且跟着它的组件走
new MiniCssExtractPlugin({ filename: 'assets/[name].[contenthash:8].css' });

还有一个容易漏的:CSS 与 JS 的分离粒度要一致 。如果 JS 按路由切、CSS 却抽成一个全局大文件,那么任何一个组件的样式改动都会让整个 CSS 文件失效 ,PART 4 的收益会被吃掉一大半。这也是 cssCodeSplit 存在的理由。

排障口诀 :连续两次构建同一份代码,产物 hash 却变了 → 一定是某个环节引入了非确定性(时间戳、随机 id、Date.now() 写进 banner、namedChunks、或者插件里遍历了未排序的 Set)。修这个比调粒度划算一百倍。

2.5 第五步:摇树------把"可以删"的证明补齐

PART 5 的 46 倍差距,全部落在"声明"上。要做四件事:

(1) 自己发布的包,声明 sideEffects。 精确到目录,而不是一句 false 图省事:

jsonc 复制代码
// package.json(业务 monorepo 内部的组件库 / 工具库)
{
  "sideEffects": [
    "*.css",
    "*.scss",
    "./src/polyfills/**",       // 这些是"导入即生效",不能删
    "./src/styles/global.ts"    // 同上
  ]
}

sideEffects: false 的语义是"这个包里所有模块的顶层都没有可观察行为 "。写错的表现不是报错,是静默错误(PART C 的 return broken)。

(2) 桶文件照写,但必须落在声明了 sideEffects 的包里。 PART B 已经证明:桶 + sideEffects: false = 4.3KB,深导入 = 4.2KB,差 0.1KB。所以"禁止桶文件"这条军规是错的,正确的是:

js 复制代码
// src/components/index.ts ------ 桶文件本身没问题
export { Table } from './table';
export { Form } from './form';

前提是 src/components 所属的 package.json 声明了 sideEffects。

(3) 禁止 export default { ... } 作为 API 出口。 195.1KB 那一行就是它的价格。用具名导出:

js 复制代码
// ❌ 摇树关掉
export default { formatDate, parseDate, diffDays };
// ✅ 具名导出,逐个可删
export function formatDate() {}
export function parseDate() {}
export function diffDays() {}

(4) 动态 import 的路径必须静态可枚举。 想按名加载图标,就要给出字面量映射(让打包器能展开成静态分支),而不是拼接字符串:

js 复制代码
// ❌ 打包器无法枚举,全部保留
const mod = await import(`./icons/${name}.js`);

// ✅ 显式映射:打包器能看到 120 个确定的候选,未用到的可被摇掉
const ICONS = {
  heart: () => import('./icons/heart.js'),
  star:  () => import('./icons/star.js'),
};

顺带一条纪律:新加依赖时,先看它的 package.json 有没有 "type": "module" 和 sideEffects。 这两个字段决定了它在你的包里是"可摇的 ESM"还是"整块石头"。CJS/UMD 入口在 ESM 项目里基本等于摇不动------这是选型阶段就该看的(Day58 会展开)。

2.6 第六步:把三张表挂进流程

yaml 复制代码
# .github/workflows/bundle-budget.yml
- run: npm ci && npm run build -- --json > stats.json   # 产出 chunk 清单
- run: node scripts/budget-gate.mjs stats.json           # 四项红线,超了 exit 1
- run: npx size-limit                                    # 或 bundlemon,做历史曲线
- run: node scripts/budget-gate.mjs stats.json --json > budget.json
- uses: actions/upload-artifact@v4
  with: { name: bundle-budget, path: budget.json }

门槛要按"路由"分级,而不是一个全局数字。 首页 1000KB 的红线,不该套在设置页上。经验值(按 Day25 那套"字节 → 解析时间"的换算):

指标 建议红线 依据
入口闭包 minified ≤ 1000KB 解析 ≈ 1s 量级,已经吃掉大半 TBT 预算
入口闭包 gzip ≤ 330KB 4G 下 ≈ 260ms 传输
初始 chunk 数 ≤ 6 超过后请求开销与压缩损失开始咬人
单模块失效面 ≤ 400KB 回访用户每次发布要重下的量
业务模块重复 0 没有例外

2.7 纪律清单

  1. 先量失效面,再谈粒度。 [contenthash] + deterministic id 没到位之前,任何切分调整都可能是负收益。
  2. 入口路由不 lazy。 首屏的一部分 lazy 出去 = 主动加一个 RTT。
  3. 共享模块必须提升。 判断口径是"有没有模块出现在 ≥2 个会被同一用户访问的 chunk 里",不是"我切了几个路由"。
  4. 粒度按"V × k"定,不按 bundle size 定。 回访多、发布勤 → 可以切细;一次性流量 → 切粗。
  5. minSize 是压缩损失的价格线,minChunks 才是提升开关。 两者别混。
  6. sideEffects: false 是事实声明。 写错不报错,只静默出错。
  7. export default {} 出口 = 关掉摇树。 具名导出。
  8. 动态 import 的路径不能拼接。 静态可枚举,否则分支全保留。
  9. CI 的红线里必须有"重复体积 = 0"。 这是最便宜、最灵敏的一条。
  10. 连续两次构建 hash 必须一致。 变了就是有非确定性,先修它,再谈优化。

三、应用场景

场景一:首屏从 2.1MB 砍到 960KB,LCP 却几乎没动

现象:bundle 分析器显示首屏 chunk 从 2190KB 降到 960KB(−56%),LCP 只从 3.1s 降到 3.0s。

原因 :LCP 的决定因素是"首屏最大元素何时渲染",而那个元素往往卡在图片 和关键 CSS 上,不在这 960KB 里(图片字体是 Day31 的事)。bundle 的收益体现在 TBT/INP(解析与执行是主线程活),不在 LCP。

动作 :把观测口径换掉------bundle 改动看 TBT、INP、以及 First Contentful Paint 之后的 long task 总数;LCP 交给图片与关键渲染路径。用错指标比不优化更糟:你会为了 LCP 的数字去动一个不相干的旋钮。

场景二:切了路由之后,导航变慢了

现象:从单包改成路由懒加载,首屏快了,但每次点菜单都白屏 400~600ms。

原因 :两个叠加。① 共享模块没提升,每个路由片自带一份 Table/Form/DateUtil(PART 2:order 一跳 186.66KB 而不是 109.96KB);② 动态 import 让每次导航都多一个 RTT。

动作 :先补 splitChunks.cacheGroups(minChunks: 2、chunks: 'all'),把重复打到 0;再给路由加"悬停/进入视口预取":

js 复制代码
// 导航前预取:把第 1 层的 RTT 提前到用户犹豫的 200ms 里
const order = () => import(/* webpackPrefetch: true */ './pages/order');
// 悬停即触发
<Link onMouseEnter={() => order()} to="/order">订单</Link>

场景三:日更 5 次的产品,把包切到 192 片

现象 :团队按"最优粒度"把首屏切到 192 片,首屏 +679.78ms,回访每次省 979.7ms,账面漂亮。上线后 CDN 命中率没变,但构建时间翻倍、发布排障困难、Safari 上偶发"某些 chunk 404"(发布时新旧 chunk 混布)。

原因 :模型里没建模的三件事:构建产物数量、CDN 缓存条目数、以及新旧版本共存窗口。片数越多,发布瞬间"用户拿着旧 HTML 请求新 chunk"的概率越大。

动作 :把粒度退回 16~32 片(PART 3 里 3→4 片的边际净收益是 96→192 片的 4.1 倍),同时做两件事:① 保留上一个版本的 chunk(Cache-Control: max-age=31536000 + 保留 N 个旧版本目录);② 用 index.html 短缓存 + 原子发布。发布管理是 Day55 的事,但它决定了你敢切多细。

场景四:只改了一个文件名规则,总账少了一半

现象 :一次重构,把 [fullhash] 改成 [contenthash],代码逻辑零改动,用户侧"每次发版都要重新下载 1MB"的现象消失。

数字 :PART 4 的总账从 23876.62ms → 8981.65ms (2.66 倍),每版失效字节从 960KB → 236KB 。中间还夹着一档"只切分不修 hash"的 24310.32ms------比不切分更差。

动作:把这条做成构建期断言:

js 复制代码
// 连续两次构建,产物 hash 必须完全一致
const a = JSON.parse(execSync('node build.js --manifest').toString());
const b = JSON.parse(execSync('node build.js --manifest').toString());
assert.deepEqual(Object.keys(a).sort().map(k => a[k]), Object.keys(b).sort().map(k => b[k]));

场景五:import { debounce } from 'lodash-es' 之后包大了 190KB

现象 :从 lodash 换成 lodash-es,本以为会瘦,结果没变。原因:lodash-es 的 package.json 早期版本没有 sideEffects: false,或者项目里 import _ from 'lodash-es' 做了命名空间导入。PART B 的第四行就是这个形态:195.1KB。

动作 :① 确认依赖版本声明了 sideEffects: false;② 一律具名导入;③ 用 import { debounce } from 'lodash-es/debounce' 这种深导入做对照验证------如果深导入明显更小,说明摇树在你的配置里没生效。

场景六:声明 sideEffects: false 之后白屏

现象 :给内部组件库加了 "sideEffects": false,某几个页面的样式全丢、customElements.define 报未注册。

原因 :PART C 那一行 return broken。样式导入(import './button.css')、polyfill、customElements.define、dayjs.extend、Reflect.metadata 这类"导入即生效"的模块,顶层有可观察行为,被声明成纯之后全部被删。

动作 :把 sideEffects 写成数组 ,精确列出"有副作用"的路径(2.5 的第一段代码)。默认 false + 例外列表,是比默认"未知"更危险的一种写法,因为它把"我没想过"变成了"我保证没有"。

场景七:HTTP/1.1 链路上,32 片比 16 片慢 226ms

现象 :本地和预发都是 h2,切到 32 片只慢 5%;灰度到某条老链路上,同样的产物慢了 19.6%(PART 1:h1 下 16 片 1448.48ms、32 片 1674.64ms,而 h2 分别 1338.48 / 1399.64)。

原因:每域名 6 个并发连接。32 片 = 5 轮排队,每轮一个 RTT。

动作 :① 先确认真实链路的协议(response.protocol,别信配置文件);② 在 h1 链路上把片数压到 ≤12;③ 长期方案是确保全链路 h2------"我们用了 h2"必须是测出来的,不是配出来的。

场景八:一个模态框带来 3 个 RTT

现象:点击"导出报表",转圈 600ms 才出内容。

原因 :report/index → 动态引入 chart → 动态引入 echarts,三层嵌套 = 3 个 RTT = 165ms,再加上每一层的下载与解析。

动作 :把"已知必然一起用"的模块合并(chart 与 echarts 打在一片),并把第一层用 modulepreload 提到第 0 层:

html 复制代码
<link rel="modulepreload" href="/assets/report-chart.g7h8i9.js">

判据 :层级数 > 2 就是设计缺陷。第 3 层开始,每个 RTT 都在为一个"用户还不一定需要"的功能付费。

场景九:vendor 片每次发版都变

现象 :业务代码只改了一行按钮文案,vendor.[hash].js 的 hash 也变了,用户重新下载 300KB。

原因 :三种可能,按概率排序:① runtimeChunk 没开,webpack 的 chunk 映射表写在主 chunk 里,牵一发动全身;② moduleIds 不是 deterministic,新增模块导致 id 平移;③ 某个插件往每个 chunk 里注入了版本号/时间戳 banner。

动作 :按 ①→②→③ 的顺序检查,每一层修完都跑一次"连续两次构建 hash 一致"的断言。这个断言应该和单元测试一样常驻。


四、总结

把这一篇压成四句话:

  1. 切分不产生收益,排除才产生收益。 同一份 960KB,切成 16 片赔 60.27ms、32 片赔 121.43ms;而把 1230KB 移出首屏省 1492.39ms。"bundle 变小"这个目标,90% 的收益来自减法,10% 来自整形。
  2. 切分的收益全部来自共享提升。 三次导航的累计传输入 671.9KB(未提升)> 657KB(不切分),525.19KB(提升后)。判断口径是"有没有模块出现在 ≥2 个同用户可达的 chunk 里",不是"我切了几个路由"。
  3. 第三个预算是失效面,它比前两个都大。 20 次回访的总账:24310.32(route+fullhash)→ 20480.65(id 不稳定)→ 8981.65(contenthash + deterministic id),2.7 倍差距,代码零改动。在调粒度之前先修这一项,是本文最便宜的一条建议。
  4. 最优粒度由"V × k"决定,不由 bundle size 决定。 V=1 最优 1 片,V=5 最优 96 片,V=20 最优 192 片;边际净收益从 2964.69 衰减到 722.61。所以"最佳拆包实践"这个问题没有通用答案------它需要知道你的用户来几次、你一天发几版。

最后回到那句最容易被说反的话:bundle 优化不是把数字变小,而是决定"哪些字节值得出现在用户的第一个 RTT 里,以及它们值不值得被缓存"。 前者是排除,后者是命名。两个动作都比"切得更细"重要一个数量级。


运行环境

脚本 (day30-assets/,全部零依赖、确定性、断言失败 exit 1):

脚本 覆盖 断言数
chunk-budget.mjs PART 1 首屏 / PART 2 导航累计 / PART 3 粒度扫描 / PART 4 hash 与 id 58
treeshake-model.mjs PART A 可达性引擎 / PART B 五种写法 / PART C 副作用可执行证明 8
budget-gate.mjs CI 四闸门(初始字节、gzip、请求数、失效面、重复) 退出码判定
stats.sample.json / stats.bad.json 良好样本 / 朴素切分样本 ---

复现:

bash 复制代码
node day30-assets/chunk-budget.mjs
node day30-assets/treeshake-model.mjs
node day30-assets/budget-gate.mjs day30-assets/stats.sample.json   # 全 PASS,exit 0
node day30-assets/budget-gate.mjs day30-assets/stats.bad.json      # 3 项 FAIL,exit 1

关于单价常数的来源(重要,请勿跳过) :NAV_RTT_MS=55、TRANSFER_KB_MS=0.8(≈1250KB/s)、PARSE_KB_MS=1.0、REQ_OVERHEAD_MS=2.5、CHUNK_PARSE_OVERHEAD_MS=0.6、CODE_CACHE_FACTOR=0.15、H1_WAVE_SIZE=6,以及 gzip 比曲线 0.45 − 0.05·log10(KB)(夹在 0.30~0.45 之间)------这些是按中端设备校准的量级常数,不是任何一份可引用的公开测量。

我检索过:web.dev、MDN、Chrome for Developers 与 Lighthouse 都公布口径与阈值 (LCP 2.5s / INP 200ms / CLS 0.1 按 p75;Long Task 门槛 50ms;modulepreload 的语义;sideEffects 字段被 webpack/rollup/esbuild 消费),但没有 公布"每 KB minified JS 解析多少毫秒 / 每个额外请求多少毫秒 / gzip 比随体积怎么变"这类数字。所以请把本文的毫秒数当成同一坐标系内的相对量:它们的用途是回答"排除比整形值多少""模糊优化与锁死失效面哪个杠杆大",而不是"你的机器上会花 2770.6ms"。

为对抗这一点,chunk-budget.mjs 在 PART 3 做了 14 档粒度扫描,把"失效字节随粒度单调不增""首屏随片数单调不减""边际净收益递减"这三条保序结论钉在所有档位上;PART 1/2/4 的结论只依赖"加性成本 + 一个 RTT"这个结构,不依赖单价的具体取值。

各脚本的额外简化与已知的乐观之处:

  • chunk-budget.mjs:① 把同层 chunk 视为并行下载、带宽串行累加 ,未建模 TCP 慢启动、h2 流控窗口与队头阻塞;② 解析成本按 minified KB 线性 计,真实 V8 有惰性解析(pre-parser)与函数级延迟编译,大函数体与小函数的单价不同;③ CODE_CACHE_FACTOR=0.15 是"命中 V8 code cache 后重新解析"的量级估计,未建模 code cache 的容量上限与淘汰 ;④ "一次发布改动的模块"是用固定种子的 PRNG 合成 的(70% 聚集在一个热点目录),不是任何真实仓库的 churn 分布------它的作用是把"失效面"变成可复算的算术;⑤ 失效面按最坏口径 (模块所在 chunk 全失效),真实打包器有更细的 hash 传播,可能只失效部分;⑥ 未建模 CSS 的失效 、Cache-Control 差异、CDN 边缘命中率、发布窗口内新旧版本共存;⑦ 未建模 Terser/Rollup 的跨模块优化 (属性名压缩、常量折叠)------切成多片会让这一层优化失效,所以"切细的代价"在真实项目里通常比本文更大;⑧ PART 4 的"id 不稳定"是按"vendor/shared/dash 三片全失效"建模的,真实情况取决于 id 平移影响了多少模块。
  • treeshake-model.mjs:① 引擎是简化版 :export * 的名字解析用"同名即命中"近似,真实的 webpack/rollup 有更完整的符号表与 __esModule 互操作处理;② "未声明 sideEffects ⇒ 被 re-export 的模块全量保留"是对文档化行为的建模 ,不同打包器、不同版本的具体保留范围不同,请以你所用版本的实际产物为准;③ 图标模块的 1.6KB/个 与 120 个的规模是构造的;④ PART C 用 new Function + 参数注入 globalThis 模拟沙箱,不是真实的打包器行为,它证明的是"删掉顶层副作用会导致静默错误"这个因果,不是"webpack 一定这么删"。
  • budget-gate.mjs:① 需要把 webpack stats.json 或 vite manifest.json 归一化成脚本认的格式(chunks[].{name,kb,gzipKb,modules,staticImports,dynamic}),归一化这一步本身要按你的打包器写;② 失效面用最坏口径 ,会偏保守(对 CI 是好事:宁可早报);③ 重复检测只覆盖业务模块,node_modules 被有意复制进多个入口的情况不计入。

版本与语义约定(与"模型不确定性"分开标注------这几条是当前实现的事实,落地前应逐条核对目标环境):

  • [contenthash] vs [fullhash] :webpack 5 里 [hash] 已废弃,[fullhash] 是"整次构建"的哈希,[contenthash] 是"该资源内容"的哈希;chunk 文件名必须用 [contenthash] 。生产模式下 optimization.moduleIds 与 chunkIds 默认都是 'deterministic',但显式写出来是必要的------因为改回 'natural' 或 'named' 只需要一行,而代价是本文量级的 2.3 倍。
  • splitChunks 默认值 :webpack 5 的 chunks 默认 'async'、minSize 默认约 20000、minChunks 默认 1。"默认配置不做 initial 的共享提升"这一条,是"切了路由却没拿到缓存收益"的最常见原因。
  • sideEffects :由 webpack / rollup / esbuild 读取的 package.json 字段,语义是"该包的模块顶层无可观察行为"。写错的后果是删代码,不是报错。
  • modulepreload :<link rel="modulepreload"> 让浏览器在解析 HTML 阶段就抓取并预解析 模块图(MDN 与 HTML 规范),这正是"把层级压回第 0 层"的依据。vite 通过 build.modulePreload 自动生成;webpack 需要 preload-webpack-plugin 一类工具或 magic comment。
  • export default { ... } 与摇树:命名空间对象的成员是运行期属性访问,静态分析无法确定"你只用了其中一个"。这条不是实现缺陷,是静态分析的边界。
  • 动态 import 的表达式 :import(expr) 的 expr 不是字面量时,打包器无法枚举候选(webpack 会给出 "Critical dependency: the request of a dependency is an expression" 警告),只能保留整个目录或全部候选分支。
  • HTTP/1.1 的每域名 6 连接:来自浏览器实现惯例(Chrome/Firefox/Safari 均为 6),不是规范强制;h2 用单连接多路复用,但受流控窗口限制。
  • 上述版本性事实会随版本变化;本文的模型与断言不依赖这些行为,它们只影响"哪些配置项对应本文的哪一种策略"。

自测暴露并修复的 6 个真实问题(保留在此,因为它们都属于"模型写错会让结论反向"的类型,且现在每条都有对应断言守着):

  1. 把"大文件压得更好"写成了比较两个不同总量的绝对字节 (chunk-budget.mjs PART 1):初版断言"单包 gzip 后小于 4 片之和",实跑 657KB(2190KB 的单包)当然大于 317.38KB(960KB 的四片)------拿不同分母比绝对量,是彻底的逻辑错误 。改成比较 gzip 比(0.301 vs 0.361 vs 0.376)之后,"切细会多传 43.29KB / +13.6%"这个正确的数才浮现出来。
  2. 断言"未提升的切分在耗时上也输给单包"------方向反了 :实跑 2718.72ms vs 2770.6ms,它确实快了 1.9%。这条失败的断言反而产出了难点二的准确表述:未提升的切分"多付 2.3% 的字节,换到 1.9% 的时间",等于白折腾,但现在断言的是"时间收益 < 5% 且字节更贵",而不是"更慢"。
  3. h1 溢价阈值被我写死成 1.1 倍 :16 片在 h1 上只贵 8.2%(1448.48 vs 1338.48),断言失败。修正后改成相对断言:"32 片的 h1 溢价(19.6%)必须大于 16 片的 h1 溢价(8.2%)的 1.5 倍"------这才抓住了"排队溢价随片数放大"这个真结论。
  4. 粒度网格出现了重复行 :groupSize = ceil(N/n) 让 n=12/16/20/24/32 都落到同一个 chunk 数上,表格里出现三行完全相同的数据,边际收益算出 0,"边际递减"断言因此失败。改成用实际 chunk 数做键后,网格变成 1/2/3/4/6/8/12/16/24/32/48/64/96/192,边际序列才有了意义。
  5. 以为"切成 2 片"能省一半失效字节 :实跑从 960KB 只掉到 912KB(失效片数 1.9)。原因是每版平均改 11.4 个模块,改动散布时两块 480KB 的巨片几乎总是一起被触碰 。这条失败断言直接产出了难点三的核心结论:片数必须超过"每版改动的散布块数",切分才开始有缓存收益 ------1→2 片甚至出现了负的净收益(−233.4ms)。
  6. treeshake-model.mjs PART C 第一版两边都返回 ok :因为 new Function 里写的 globalThis.__X = true 污染了真实的 全局对象,第二次运行时读到上一次的残留。把 globalThis 作为函数参数注入沙箱(new Function('globalThis', src) 再 fn.call({}, {}))之后,才复现出 return broken------顺带确认了这类 bug 的真实形态:不是抛错,是静默返回错误值。

关于"为什么没有一个能收进口号的名字" :本文的结论全是交换 ------用首屏换算失效面、用请求数换压缩率、用粒度换运维成本。没有一个能概括成"这样做就对"。如果一定要留一句,就留那句执行顺序:先排除,再提升,再锁死失效面,最后才调粒度。

相关推荐
北冥有鱼1 小时前
发版后动态路由点击报 `Failed to fetch dynamically imported module`」问题上的完整排查与修复过程
前端·vue.js
静默回滚1 小时前
视频压到 25 MB 以内,码率要算、音轨要扣、余量要留
前端
用户37203749260181 小时前
把 10MB 中文语义模型塞进浏览器:STSB 做到教师模型的 93%,但线上 top1 只有 42%
前端
光锥智能2 小时前
海康机器人举行2026移动机器人新品发布会:箱式存拣升级,「明智」系列发布
java·前端·机器人
计算机魔术师2 小时前
Anthropic 上市文件最吓人的一幕,不是亏损 807 亿
前端
alsmile2 小时前
AI友好的可视化图形引擎自我革命:Meta2d.js 2.0 历时一年全新重写,性能与多人协作全面升级
前端·开源·数据可视化
行思理2 小时前
Nginx 跨站请求(CORS)配置完全指南:从原理到生产实践
java·前端·nginx
粥里有勺糖2 小时前
视野修炼-技术周刊第137期 | 编程已经面目全非
前端·github·ai编程
IT_陈寒2 小时前
Python的列表推导式用不好真的会爆内存你知道吗
前端·人工智能·后端