/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
1. 引言:为什么我们不再需要「下载新版本」
回想十几年前,安装一个软件意味着去官网下载一个几十兆甚至几百兆的安装包,双击、下一步、下一步、重启。而今天,我们打开浏览器就能用上最新版的在线文档,手机上的 App 在后台悄悄完成升级,桌面客户端重启一次就已经是新版本。
这种「无需下载」的体验背后,并不是真的没有数据传输,而是把「一次性大包下载」拆解成了「按需、增量、后台、可回滚」的一系列机制。本文从原理层面拆解这些机制,帮助你理解现代网站与软件是如何做到「永远是最新版」的。
2. 核心概念:静态资源、版本与缓存
在讨论动态更新之前,先明确三个基础概念:
- 静态资源:HTML、CSS、JavaScript、图片、字体、WASM 等,内容在构建时确定,运行时不变。
- 版本标识:用内容哈希(如 app.3f9a2c.js)或语义版本号标识一份资源,内容变则标识变。
- 缓存:浏览器、CDN、Service Worker 都会缓存资源,缓存是性能的来源,也是更新的最大障碍。
动态更新的本质,就是在保证缓存命中率的同时,让客户端能准确知道「有新版本」并拿到「差异部分」。
3. 网站侧:从刷新页面到热更新
3.1 内容哈希与长缓存
现代前端构建工具(Webpack、Vite、Rollup 等)会给产物文件名加上内容哈希:
dist/
├── index.html
├── assets/
│ ├── index.a1b2c3.js
│ ├── vendor.d4e5f6.js
│ └── style.7g8h9i.css
配合 CDN 的强缓存策略:
Cache-Control: public, max-age=31536000, immutable
文件名带哈希的资源可以缓存一年,因为内容一变文件名就变,永远不会「缓存到旧内容」。而 index.html 使用短缓存或不缓存:
Cache-Control: no-cache
这样用户每次访问都会拿到最新的 HTML,HTML 里引用的又是最新的哈希资源,从而实现「无感更新」。
3.2 增量发布与灰度
大型站点不会一次性替换所有资源,而是:
这就是为什么你有时会看到「新功能只对部分用户开放」——本质是资源与配置的分流。
3.3 Service Worker 与离线更新
PWA 站点通过 Service Worker 拦截网络请求,实现离线访问与后台更新:
// sw.js
const CACHE_NAME = 'app-v2';
const ASSETS = ['/', '/index.html', '/assets/index.a1b2c3.js'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
)
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => cached || fetch(event.request))
);
});
关键点在于:新版本 Service Worker 安装后处于 waiting 状态,页面通过 registration.update() 检测更新,提示用户刷新后由 skipWaiting + clients.claim 接管,实现「后台下载、下次生效」。
3.4 模块热替换(HMR)
开发阶段的 HMR 更进一步:只替换发生变化的模块,保留应用状态。其原理是:
- 构建工具监听文件变化,重新编译单个模块。
- 通过 WebSocket 把新模块推送给浏览器。
- 运行时用新模块替换旧模块的引用,并触发 module.hot.accept 回调。
生产环境通常不用 HMR,但「按模块增量更新」的思想被广泛借鉴。
4. 软件侧:从整包升级到差分与热补丁
4.1 差分更新(Delta Update)
差分更新的核心是:客户端上报当前版本,服务端计算「旧版本 → 新版本」的二进制差异包,客户端下载差异包后在本地合并。
常见算法:
- bsdiff / bspatch:基于后缀排序的二进制差分,压缩率高,适合大文件。
- Courgette(Chrome 使用):先反汇编再差分,对可执行文件效果更好。
- HDiffPatch:支持大文件、内存受限场景。
伪代码示意:
client: 当前版本 v1.0,请求更新
server: 计算 diff(v1.0, v1.1) -> patch.bin
client: 下载 patch.bin(通常只有整包的 5%~20%)
client: bspatch(v1.0, patch.bin) -> v1.1
client: 校验签名与哈希,替换旧文件
差分更新把「下载几百兆」变成「下载几十兆」,是移动端与桌面端的主流方案。
4.2 热更新与动态下发
对于脚本化程度高的应用(如 React Native、Flutter、小程序、游戏 Lua 脚本),可以只下发业务代码包:
- 客户端内置一个 JS/Lua 运行时。
- 业务代码打包成 bundle,通过 CDN 下发。
- 启动时检查版本号,拉取新 bundle 并校验签名。
- 下次启动或立即生效(取决于运行时能力)。
这类方案的关键约束是平台审核政策:iOS 对动态下发可执行代码有严格限制,通常只允许下发资源与配置,不允许改变核心逻辑。
4.3 应用商店的增量与静默更新
手机应用商店的「自动更新」本质是:
整个过程对用户透明,这就是「无需下载」的直观体验来源。
5. 通用机制:版本协商与一致性保障
无论网站还是软件,动态更新都绕不开三个问题:
| 如何知道有新版本 | 版本号轮询、WebSocket 推送、ETag/Last-Modified 协商 |
| 如何保证拿到的是完整正确的版本 | 内容哈希校验、数字签名、灰度回滚 |
| 如何避免更新中断影响使用 | 双缓冲、原子替换、失败回滚 |
一个典型的版本协商流程:
#mermaid-svg-NtFiD1NkuLxzWBUg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-NtFiD1NkuLxzWBUg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-NtFiD1NkuLxzWBUg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-NtFiD1NkuLxzWBUg .error-icon{fill:#552222;}#mermaid-svg-NtFiD1NkuLxzWBUg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-NtFiD1NkuLxzWBUg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-NtFiD1NkuLxzWBUg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-NtFiD1NkuLxzWBUg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-NtFiD1NkuLxzWBUg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-NtFiD1NkuLxzWBUg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-NtFiD1NkuLxzWBUg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-NtFiD1NkuLxzWBUg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-NtFiD1NkuLxzWBUg .marker.cross{stroke:#333333;}#mermaid-svg-NtFiD1NkuLxzWBUg svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-NtFiD1NkuLxzWBUg p{margin:0;}#mermaid-svg-NtFiD1NkuLxzWBUg .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-NtFiD1NkuLxzWBUg .cluster-label text{fill:#333;}#mermaid-svg-NtFiD1NkuLxzWBUg .cluster-label span{color:#333;}#mermaid-svg-NtFiD1NkuLxzWBUg .cluster-label span p{background-color:transparent;}#mermaid-svg-NtFiD1NkuLxzWBUg .label text,#mermaid-svg-NtFiD1NkuLxzWBUg span{fill:#333;color:#333;}#mermaid-svg-NtFiD1NkuLxzWBUg .node rect,#mermaid-svg-NtFiD1NkuLxzWBUg .node circle,#mermaid-svg-NtFiD1NkuLxzWBUg .node ellipse,#mermaid-svg-NtFiD1NkuLxzWBUg .node polygon,#mermaid-svg-NtFiD1NkuLxzWBUg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-NtFiD1NkuLxzWBUg .rough-node .label text,#mermaid-svg-NtFiD1NkuLxzWBUg .node .label text,#mermaid-svg-NtFiD1NkuLxzWBUg .image-shape .label,#mermaid-svg-NtFiD1NkuLxzWBUg .icon-shape .label{text-anchor:middle;}#mermaid-svg-NtFiD1NkuLxzWBUg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-NtFiD1NkuLxzWBUg .rough-node .label,#mermaid-svg-NtFiD1NkuLxzWBUg .node .label,#mermaid-svg-NtFiD1NkuLxzWBUg .image-shape .label,#mermaid-svg-NtFiD1NkuLxzWBUg .icon-shape .label{text-align:center;}#mermaid-svg-NtFiD1NkuLxzWBUg .node.clickable{cursor:pointer;}#mermaid-svg-NtFiD1NkuLxzWBUg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-NtFiD1NkuLxzWBUg .arrowheadPath{fill:#333333;}#mermaid-svg-NtFiD1NkuLxzWBUg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-NtFiD1NkuLxzWBUg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-NtFiD1NkuLxzWBUg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NtFiD1NkuLxzWBUg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-NtFiD1NkuLxzWBUg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NtFiD1NkuLxzWBUg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-NtFiD1NkuLxzWBUg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-NtFiD1NkuLxzWBUg .cluster text{fill:#333;}#mermaid-svg-NtFiD1NkuLxzWBUg .cluster span{color:#333;}#mermaid-svg-NtFiD1NkuLxzWBUg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-NtFiD1NkuLxzWBUg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-NtFiD1NkuLxzWBUg rect.text{fill:none;stroke-width:0;}#mermaid-svg-NtFiD1NkuLxzWBUg .icon-shape,#mermaid-svg-NtFiD1NkuLxzWBUg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NtFiD1NkuLxzWBUg .icon-shape p,#mermaid-svg-NtFiD1NkuLxzWBUg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-NtFiD1NkuLxzWBUg .icon-shape .label rect,#mermaid-svg-NtFiD1NkuLxzWBUg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NtFiD1NkuLxzWBUg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-NtFiD1NkuLxzWBUg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-NtFiD1NkuLxzWBUg :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
否
是
否
是
客户端启动
请求版本清单 manifest.json
版本号是否变化
使用本地缓存
下载增量资源/差分包
校验哈希与签名
校验通过
回滚到旧版本并上报
原子替换并生效
6. 实践建议
- 静态资源一律带内容哈希,HTML 短缓存,避免「更新不生效」。
- 保留旧版本资源一段时间,防止已打开页面加载失败。
- 差分更新务必做签名校验,防止中间人篡改。
- 更新失败要有回滚路径,尤其是桌面端与移动端。
- 遵守平台政策,动态下发能力以官方文档为准。
7. 总结
「无需下载」并不是没有下载,而是把下载拆成了更小、更聪明、更隐蔽的片段:内容哈希让缓存可以长期有效,差分算法让传输量大幅下降,后台更新让用户无感,签名与回滚让更新安全可控。理解这些机制,才能在构建自己的产品时,既享受动态更新的便利,又守住稳定与安全的底线。



