代码改了却不生效:先别急着清缓存,按四层把改动追到运行现场
数据截至 2026-07。文中命令默认 Linux/macOS 环境,各构建工具的配置项名称、默认行为与缓存目录位置随版本变化,以你所用版本的官方文档为准。
改了代码没反应,先别清缓存——绝大多数这类问题的真相是:你改的那份文件,压根不在正在运行的那条链路上。 缓存确实会骗人,但它排在很后面。在我处理过的这类现象里,排前几位的成因是改动没保存、改的是同名的另一个文件、AI 工具把补丁写进了别的目录、老的开发服务器进程还占着端口没退、或者构建产物根本没重新生成。这些都不需要清缓存,清了也没用,反而把现场毁了——你一 rm -rf 完再全量重建,五分钟过去了,问题恰好被顺带修好,你却永远不知道刚才卡在哪一层,下次照样卡。
这篇讲的是同一台机器、同一套环境下,改动没有传导到运行现场。它和另外两个题目要分开:如果你的本地一切正常、一上线就报错,那是环境之间的输入差异,看 本地测试全绿一上线就炸;如果同一份代码在你和同事的机器上表现不同,那是运行环境本身的差别,看 运行环境不一致。本篇假定环境只有一套、代码你确信改对了,只解决「为什么改了等于没改」。相应地,三件事不在范围内:代码逻辑本身写错了(那要靠断点和用例,跟缓存没关系)、性能层面的表现变化、以及部署流水线上的权限与凭证问题。
一、先回答一个问题:你改的那份代码,在不在跑
这一步花不到两分钟,能砍掉一半的可能性。方法是打标:在你改动的函数入口塞一个绝对不会撞的字符串,比如:
console.log('MARK-A7F3-20260728');
然后按三个位置依次追这个标记:
位置一,源码目录里有没有。 保存了吗?编辑器是不是开着另一个路径的同名文件?用命令看,别用眼睛看:
git status --porcelain
git diff -- path/to/your/file
grep -rn "MARK-A7F3" src/
git status 空空如也而你以为改了东西,那就是没保存、改在别的目录、或者改动被 .gitignore 挡住了。AI 编程工具批量改文件时这一步尤其值得做——它可能在你没注意的时候创建了新文件而不是改旧的,也可能改到了 node_modules 里某个包的源码。monorepo 下更容易错位,同名模块在多个包里都有一份,具体的辨认套路在 monorepo 里 AI 越改越迷糊 里展开过。
位置二,构建产物里有没有。
grep -rl "MARK-A7F3" dist/ 2>/dev/null
ls -l --time-style=full-iso dist/ | head # --time-style 是 GNU coreutils 的选项
macOS 自带的 ls 没有 --time-style,把最后一行换成 ls -lT dist/ | head 效果一样;Windows 下在 PowerShell 里用 Get-ChildItem dist | Select-Object Name, LastWriteTime。看时间戳只是为了回答一个问题:这堆文件是刚才那次构建产出的,还是上午留下来的。
源码有、产物没有,问题就锁定在构建这一层,往第三节走。产物的修改时间还停在几小时前,说明构建根本没跑,或者跑的是另一个输出目录。
位置三,运行现场有没有。 浏览器里打开开发者工具,在 Sources 或 Network 里找到实际加载的那个文件,搜标记;服务端就直接看日志有没有打出来。产物里有、运行现场没有,问题在分发和缓存这一层,看第三节后两段(「产物没送出去」和「送到了但没执行」)。
打标法的价值在于它把「我以为」变成「我看见」。很多人排查半天的真实原因是:他改的是 src/utils/format.ts,而运行时加载的是 packages/shared/dist/format.js——一个编译好的产物,改源码当然不生效。
二、判别表:从现象直接对号
现象是有指纹的。先在表里找到最像的一行,再去做对应的验证,比通读日志快。
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
| 改任何文件都毫无反应,控制台连重新编译的提示都没有 | 监听器没工作,或你连的是另一个进程 | 检查端口上的进程数量;改一个必定报错的语法看会不会红 | 杀掉所有残留进程后重启,端口排查命令见第三节的清理顺序,具体坑见第六节坑三 |
| 控制台显示重新编译成功,页面还是旧的 | 浏览器缓存、Service Worker、或页面加载的是另一个入口 | 开发者工具勾选禁用缓存后硬刷新;在 Network 里核对实际请求的 URL | 注销 Service Worker,确认入口路径 |
| 部分文件改了生效,另一部分永远不生效 | 不生效的那部分来自已编译的依赖包或软链 | 在产物里搜打标字符串,看命中的是源码路径还是 dist 路径 | 先构建被依赖的包,或改用源码直连 |
| 全量重建就好,增量就不行 | 增量构建的失效判断没覆盖你改的文件 | 保存一次,看构建器日志里有没有列出这个文件被重新编译;没列出就是没被判定为「已变更」 | 清该项目的构建缓存,并检查这个文件是否在构建输入范围内 |
| 本机好了,容器里还是旧的 | 镜像里是旧代码,或挂载路径不对 | 进容器里直接搜打标字符串 | 检查忽略规则与挂载配置,重建镜像 |
| 改了却报旧代码的错,行号对不上 | 加载的是旧产物,或 source map 过期 | 看报错堆栈指向的文件路径和时间戳 | 清产物目录后重建 |
| 改 Python 文件不生效,重启才生效 | 进程内已导入的模块不会自动重载 | 确认启动时有没有开自动重载模式 | 重启进程,或按框架文档开启重载 |
| 静态资源拿到的是上一版内容 | 文件名没带内容哈希,代理或 CDN 按原名继续给旧副本 | curl -I 看响应头里的缓存与校验字段,再比对本地产物内容 | 让产物带内容哈希,入口 HTML 设不缓存 |
| 静态资源 404,文件名带着一串哈希 | 入口 HTML 与资源版本错配:旧 HTML 仍在请求已被清掉的旧文件,或新 HTML 请求的新文件还没同步过去 | 拿浏览器请求的完整文件名去产物目录和服务器目录各找一遍,看哪一侧缺 | 重新生成入口 HTML 并与资源一起发布,发布期保留上一版资源 |
表只是入口。对上号之后,还是要按下面的顺序把它验实,别跳过验证直接执行处置——很多人清了缓存看到问题消失,其实是重启顺带修好了别的东西。
三、四层依次收敛:源码、构建、分发、运行时
把链路想成四段:你写的文件 → 构建器产出的东西 → 被送到运行侧的东西 → 进程里真正执行的东西。故障一定卡在某两段之间,逐段验证就是了。
第一段到第二段(构建没吃到你的改动)。 常见的三种:
其一,文件不在构建器的输入范围里。新建的目录被排除规则挡住、导入路径写的是没被解析到的别名、或者文件扩展名不在处理列表内。验证方法是看构建器输出的模块列表或依赖图里有没有这个文件。
其二,增量缓存误命中。多数构建器判断「要不要重编」靠的是修改时间加内容摘要,一旦你用了会保留原时间戳的方式覆盖文件——cp -p、rsync -a、从压缩包直接解出、从备份恢复——新文件带着一个比缓存记录还旧的修改时间落地,缓存就可能判定它没变过。顺带纠正一个流传很广的说法:切分支并不会让时间戳倒流,git checkout 写出的文件用的是当下的时间,它引起的构建异常通常是另一回事(缓存键里没算上分支相关的输入,或者两个分支产物混在同一个输出目录里)。判断很简单:删掉缓存目录再构建一次,如果好了,就是它;如果没好,说明问题压根不在增量判断上,别再往这个方向清了。
其三,改的是依赖包的源码而不是产物。工作区软链场景下,你的应用引用的是包的入口字段指向的编译结果,改源码必须先把那个包构建一遍。这条在多包仓库里发生频率极高。
第二段到第三段(产物没送出去)。 典型是构建输出目录和服务读取目录不是同一个,或者服务读的是上次构建残留的旧目录。还有一种阴险情况:新旧两份产物同时存在,带哈希的新文件生成了,但 HTML 里引用的还是旧文件名,因为模板没有重新生成。核对办法是拿浏览器实际请求的资源名,去产物目录里找有没有这个文件,再看它的时间戳。
第三段到第四段(送到了但没执行)。 进程里跑的是旧模块。服务端语言普遍不会在文件变化时自动替换已经加载进内存的模块,除非你显式开了重载。前端则是浏览器层面的缓存。
一个稳妥的清理顺序是从后往前、每步验证一次,而不是一口气全清:
# 1) 只硬刷新浏览器(禁用缓存),看是否恢复
# 2) 停掉服务,确认端口上没有残留进程
lsof -i :3000 # macOS / Linux
# Linux 上没装 lsof 时:ss -lptn 'sport = :3000'
# Windows PowerShell:Get-NetTCPConnection -LocalPort 3000
# 3) 删产物目录(先确认它不是源码目录!)
rm -rf dist
# 4) 删构建缓存目录,再全量构建
# 5) 仍不行才动依赖:删依赖目录并重装
顺序反过来做的人最吃亏——先重装依赖要等几分钟,而问题往往在第一步就能解决。
四、热更新失效:单独一类,成因很物理
热更新不生效经常被算进「缓存问题」,其实它的成因很少和缓存有关,多半是文件变更事件根本没送到构建器。几种可查的原因:
监听器额度耗尽。 Linux 上文件监听有数量上限,大仓库很容易触顶,触顶后新增监听静默失败,表现就是部分目录改了没反应。当前值可以直接读:
cat /proc/sys/fs/inotify/max_user_watches
处理方式是调大这个内核参数,或者把不需要监听的大目录排除掉。
编辑器用原子替换写文件。 不少编辑器保存时是「写临时文件再改名覆盖」,这会换掉 inode,绑在旧 inode 上的监听就失效了。现象是第一次改生效,之后再改就不动了。多数构建器有对应的开关来兼容这种写入方式。
跨文件系统边界。 虚拟机共享目录、容器挂载卷、网络盘,这些场景下宿主机的文件事件常常不会透传到容器内的监听器。判断方法是在容器内部手动 touch 一下文件,如果这时热更新触发了,说明监听本身没坏,坏的是事件透传。解法是切到轮询模式——代价是持续占 CPU,只在开发期开。
路径被忽略规则排除。 构建器默认会跳过依赖目录,而你软链进来的本地包正好在那个目录下,于是它的改动永远不被监听。
大小写不敏感的文件系统。 你的导入路径写成 ./Utils,磁盘上是 utils,本机能跑,但监听匹配和构建缓存的键可能不一致,表现为改了偶尔生效偶尔不生效。这类问题在 Linux 上会直接变成模块找不到,反而更早暴露。
还有一类要区分:热更新触发了、模块也替换了,但页面上的状态没跟着更新。这是模块热替换的固有限制——已经创建的实例、注册过的副作用、绑定过的事件不会自动重来。这种时候你看到的「不生效」其实是「生效了但被旧状态盖住了」,刷新一次就对了,不用去排缓存。注意这里的刷新和后面坑四说的不是一回事:这里刷新是为了让页面从零重建状态,新代码本来就已经到位;坑四那种是新文件根本没到浏览器手上,普通刷新解决不了。分辨方法还是打标——如果标记字符串在 Sources 里搜得到,就属于前一种。
五、什么情况下别再折腾
排查是有成本的,工程上更该管的是止损点。我的判断线是这样几条:
十五分钟没定位到层,就直接全量重建。 停服务、删产物、删构建缓存、重装依赖、重启,一条龙走完。这不是投降,是用五分钟买回确定性。前提是你已经做过打标验证,确认了改动在源码里——否则重建也救不了你。
同一现象重建两次仍在,停止清理动作。 两次全量重建都没解决,就说明不是缓存和产物的问题,继续清是浪费时间。此时应该换方向:去核对导入路径、核对入口配置、核对是不是有两份服务同时在跑。
改动来自 AI 且改了多个文件时,优先回滚而不是继续排。 一次生成动了七八个文件,你很难判断是哪一处让链路断了。把改动整体退回到干净状态,确认基线是好的,再分批放回去,比一直往前追要快。具体做法可以参考 AI 改坏代码怎么回滚 的分段落回思路。
明显是依赖树混乱时,别在应用层继续修。 症状是同一个库在产物里出现了两个版本、或者类型报错指向依赖包内部。这时清缓存治不了根,得回到依赖版本层面处理,见 依赖版本冲突。
换条路的判断依据: 如果这次改动本身是可选的、或者只是为了验证一个想法,而你已经在环境上耗了半小时,那就换个最小复现——新建一个空白工程把那段逻辑跑通,确认逻辑没问题之后再回来处理工程链路。把「验证想法」和「修工程」这两件事搅在一起,是这类排查最容易失控的原因。
回滚点也要提前留。开始清理之前先确保工作区是干净的、或者改动已经提交或暂存,不然你一顿 rm -rf 之后可能把没提交的东西一起清掉。
六、避坑清单
坑一:先重装依赖。 会踩是因为重装是唯一「不用动脑」的动作,焦虑的时候人会本能选它。代价是几分钟等待外加把现场毁掉。避法是把重装放在清理链的最后一步,前面每一步都留验证。
坑二:rm -rf 打错目录。 会踩是因为很多项目的产物目录和源码目录长得像(dist、build、lib、out 都可能是手写代码)。避法是删之前先 git status,能被 git 追踪的目录多半是源码,产物目录通常在忽略规则里。
坑三:忘了杀残留进程。 会踩是因为开发服务器崩溃后不一定释放端口,你重启的新进程可能起在别的端口上,而浏览器还连着老的。避法是重启前先看端口占用,别只靠终端里那个窗口关掉了就以为进程没了。
坑四:把浏览器的「刷新」当成清缓存。 会踩是因为普通刷新对带强缓存头的资源不生效,Service Worker 更是会直接拦截请求返回旧内容。避法是开发期在开发者工具里常开禁用缓存,并且在应用列表里手动注销 Service Worker。
坑五:改了环境变量没重启。 会踩是因为环境变量在进程启动时读入,之后再改 .env 文件对已经跑着的进程没用;有些构建器还会把变量在构建期内联进产物,那就连重启都不够,得重新构建。这里有个容易搞错的细节:.env 文件通常是应用自己在启动时读进来的,不会写进 shell 的环境,所以你在终端里敲 env | grep 前缀 大概率什么都搜不到,那不代表变量没生效。可靠的确认办法是在应用启动的最早处把它真正读到的值打印一行出来,前端则直接去产物里搜这个值,看构建期内联进去的是新的还是旧的:
grep -rn "你要确认的变量值" dist/
坑六:在容器里改宿主机的文件。 会踩是因为挂载方向和缓存策略容易记反,尤其是那种「构建时拷贝一份、运行时又挂载一份」的配置,你改的和它读的可能是两份。避法是进容器里直接搜打标字符串,一次就能定论。
坑七:Python 里改了模块以为会自动重载。 会踩是因为交互式环境和长驻进程里已导入的模块会一直留在内存,改源文件不会影响已经在 sys.modules 里的那份。避法是调试期显式重启进程,或者用框架自带的重载开关。顺带澄清一个常见误判:很多人第一反应是去删 __pycache__,但正常情况下解释器会按源文件的时间戳和大小自动判断缓存是否过期,旧字节码极少是真凶。真会中招的是那几种时间戳不可信的场景——从压缩包解出、跨机器拷贝、只读挂载、或者部署包里干脆只有编译产物没有源文件。只有落在这几种情况里,才值得把对应的 __pycache__ 目录删掉再跑一次做对照。这里有个很多人记反的点:PYTHONDONTWRITEBYTECODE=1 只管「不再写新的字节码文件」,已经躺在磁盘上的旧文件解释器照用不误,拿它当「跳过缓存」的开关是排除不了旧字节码的。想干净地做一次对照,就得先物理删掉那些目录:
find . -type d -name __pycache__ -prune -exec rm -rf {} +
删之前确认你在项目目录里,别在家目录根上执行。
坑八:静态资源没带内容哈希。 会踩是因为文件名不变时,代理和 CDN 完全有理由继续给旧内容。判断用响应头:
curl -sI https://example.com/assets/app.js | grep -iE 'etag|last-modified|cache-control|age'
避法是让构建产物带内容哈希,并且入口 HTML 设成不缓存。
坑九:把「生效了但看不出来」当成没生效。 会踩是因为有些改动本来就没有可见效果——分支条件没进去、样式被更高优先级的规则盖住、日志级别没开到位。避法就是本文开头那个打标动作,它同时也是「有没有生效」的唯一可靠判据。
收束:一份自检清单
这类问题真正难的从来不是修,而是不要在错误的层上使劲。养成一个习惯:动手清任何东西之前,先花两分钟打标,把「改动在哪一层断掉」这件事变成看得见的事实。断点找到了,处置几乎都是一行命令。
改完没反应时,按顺序问自己这七个问题:
- 保存了吗?
git status里能看到这次改动吗? - 我改的文件,是运行时真正加载的那一份吗?
- 构建有没有真的跑过?产物的时间戳是新的吗?
- 打标字符串在产物里搜得到吗?
- 服务读的目录,和构建输出的目录是同一个吗?
- 端口上是不是还有老进程?
- 浏览器请求的 URL 和响应头,指向的是新文件还是旧缓存?
七个问题走完还没结论,就直接执行全量重建,别再逐个试了——那时候继续猜的期望收益已经低于重建的固定成本。