# 保哥笔记 — JS教程 > 本分片含 9 篇文章,按发布日期倒序。全部分片索引见 https://zhangwenbao.com/llms-full.md **站点**:https://zhangwenbao.com/ **分类**:JS教程 **生成**:2026-09-12 13:06:31 CST --- ## JS在线运行工具点自带的异步示例,面板上只会出现第一行,剩下6条去了别处 - URL:https://zhangwenbao.com/js-runner-async-console-linenum-blocking-guide.html - 分类:JS教程 - 发布:2026-07-26 | 更新:2026-07-26 - 摘要:从自带的异步示例只输出第一行这个现象入手,量出控制台还原时机、行号样式缺失、无超时保护、序列化失真四处边界,并给出这款工具适合与不适合的场景清单。 - 关键词:JavaScript,前端调试,开发者工具 > **TLDR**:摘要:这个工具最关键的一条限制,用它自己的示例按钮就能验出来:点开自带的Async/Await示例,代码里有7处输出,面板上只会出现1条。不是请求失败,是它在同步代码跑完的那一瞬间就把控制台还回去了,后面所有回调的输出全落到了浏览器自己的控制台里。下面把这条以及行号错位、主线程无保护、对象显示失真几处逐一量出来,最后给一份它适合干什么、不适合干什么的清单。 > 摘要:这个工具最关键的一条限制,用它自己的示例按钮就能验出来:点开自带的Async/Await示例,代码里有7处输出,面板上只会出现1条。不是请求失败,是它在同步代码跑完的那一瞬间就把控制台还回去了,后面所有回调的输出全落到了浏览器自己的控制台里。 下面把这条以及行号错位、主线程无保护、对象显示失真几处逐一量出来,最后给一份它适合干什么、不适合干什么的清单。 ## 先说最要命的那条:异步输出根本没进面板 在线跑一段JavaScript,这类工具满大街都是。真正拉开差距的不是能不能跑,是跑完之后你看到的东西全不全。 JS在线运行工具 (https://zhangwenbao.com/tools/js-runner.php)把代码编辑器、控制台面板、7个示例按钮摆在一屏里,粘贴即跑,不用装环境。这个形态本身很实用,尤其适合手边没有开发环境、只想验一个小东西的时候。但它有一条限制严重到会让人误判自己的代码——而且这条限制在页面上一个字都没提。 ## 4行代码就能验出来 把下面这段贴进去,点运行: console.log('① 同步:这行在函数体里直接跑'); setTimeout(function(){ console.log('② 异步:0毫秒的定时器回调'); }, 0); Promise.resolve().then(function(){ console.log('③ 微任务:Promise 的 then'); }); console.log('④ 同步:最后一行同步代码'); 4条输出,面板上只会出现2条:①和④。②和③永远不会出现,等多久都不会。运行返回的瞬间面板是2条,等上120毫秒再看,还是那2条。 那两条不是慢,是丢了。它们确实执行了,输出去了浏览器自带的开发者工具控制台,只是没有进这个面板。你按F12打开控制台,会发现它们安安静静躺在那儿。 ## 为什么会这样 这类工具捕获输出的通用做法是:运行前把console.log换成自己的函数,运行后再换回来。中间这段时间里的所有输出都会被截住,显示到面板上。这个套路本身没毛病,几乎所有在线运行工具都这么干。 问题出在还回去的时机。它是在用户代码同步执行完毕、函数返回的那一行立刻还原的。而定时器的回调、Promise的后续处理、请求的响应处理,全都排在同步代码之后才执行。等它们真正跑起来时,console.log早已经变回原生的了。 实测确认了这一点:运行返回后立即检查,console.log的源码里已经是原生标记,说明还原动作已经完成。而此时连微任务队列都还没开始跑——微任务的优先级已经是所有异步里最高的了,依然赶不上。 换个角度说,这个捕获窗口的宽度,正好等于同步代码的执行时长。你的代码同步部分跑得越快,这个窗口关得越早。写一段几毫秒就跑完的同步代码,窗口开合之间几乎没有给异步任务留下任何余地。 ## 拿工具自带的示例做对照,一点就露馅 上面那段是我构造的,多少有点刻意。更有说服力的是拿它自己的示例按钮试——那是作者亲手放上去、默认认为能正常演示的代码。 ## 三个示例的实测结果 示例按钮 | 代码里的输出语句数 | 面板实际收到 | 面板上唯一那条的内容 | Async/Await | 7 | 1 | 开始请求... | Fetch API | 4 | 1 | 正在请求API... | 算法示例(纯同步) | 4 | 5 | 全部正常显示 | 前两行和第三行构成了一组干净的对照。纯同步的算法示例,4条输出加1条计时结果,5条全都在;两个异步示例,各自只剩开头那一句。 注意第三行那个5比4大。算法示例里用了计时功能,计时结束时会额外打印一条耗时记录,所以面板收到的比源码里的输出语句还多一条。这一条恰恰说明捕获机制本身是好使的,只要你在同步窗口内打印,一条都不会漏。 ## 同一段代码在浏览器控制台里跑是什么样 把那4行贴进浏览器自己的开发者工具控制台,回车。你会看到①和④先出现,紧接着③出现,最后②出现。四条一条不少,而且顺序清楚地演示了微任务优先于宏任务这件事。 这个对比挺能说明问题:同样的代码、同样的浏览器引擎,差别只在于谁来接管输出。工具的面板不是跑得不对,是听得不全。 顺序这件事本身也值得留意。很多人以为设成0毫秒的定时器会立刻执行,实际它排在所有微任务之后。要理解异步代码的执行次序,这4行比大段文字管用,可惜在这个面板里正好看不到。 ## 为什么这个体验特别容易被误读 你点了Fetch API示例,看到面板上写着正在请求,然后就没动静了。第一反应多半是网络不通、接口挂了、或者跨域被拦。这三个猜测都很合理,也都很难当场排除——尤其那个示例请求的是境外的公开测试接口,在国内网络环境下确实经常连不上。 于是你可能会花十几分钟去查网络、换接口、看跨域策略,最后发现请求其实早就成功了,数据也拿到了,只是打印结果的那几行没人接。一个把成功伪装成失败的界面,比一个明确报错的界面更耗时间。 ## 7个示例里有几个受影响 数了一遍工具自带的示例:基础输出、数组操作、算法演示、正则匹配、JSON处理这5个是纯同步的,能正常显示;请求接口和异步等待这2个是异步的,都只能看到开头。操作页面元素那个介于两者之间,取决于代码里有没有用到延时。 比例上看不算严重,7个里坏2个。但从使用频率看,恰恰是异步那两个最有演示价值——同步代码大家心里都有数,异步的执行顺序才是真正需要跑一跑才明白的部分。 ## 这条限制影响多大 取决于你拿它干什么。测一个正则、算一段字符串处理、验证数组方法的返回值,这些都是同步的,完全不受影响。但凡涉及定时器、接口请求、Promise链、await,面板就只能给你看个开头。 现代JavaScript里异步的比重有多高不用多说,一段业务代码里不含任何异步反而是少数情况。所以更准确的说法是:它是一个同步表达式验证器,不是一个JavaScript运行环境。知道这个定位,用起来就不会踩坑,也不会对它有不切实际的期待。 ## 怎么绕过去 如果非要在这个面板里看到异步结果,有个笨办法:把异步逻辑改写成先把结果攒起来、最后同步打印。但仔细想就会发现这条路是死的——攒到什么时候才算完?判断完成的那个时刻本身就在异步回调里,你在那里打印,照样打印不出来。 所以真正的答案是:别绕,直接按F12打开浏览器自己的控制台看。那些输出都在那儿,一条不少,格式还更好。这个工具的面板只是个方便的展示层,浏览器的控制台才是真正的输出终点。 ## 统计条也跟着一起骗人 面板下方有一行统计:运行次数、耗时、输出行数。前面那段4条输出的代码跑完,它显示的是耗时3.2毫秒、输出行数2。 两个数字都对不上实际情况,而且错的方向一致——都在系统性地低估。 ## 耗时只算同步部分 计时是在同步执行前后各取一次时间戳,差值就是耗时。异步任务还排在队列里没跑,计时就已经结束了。 拿Fetch API示例来说,面板会告诉你耗时几毫秒,而那个网络请求真正完成可能是几百毫秒之后的事。这个数字衡量的是发起的成本,不是完成的成本。拿它比较两段代码的性能,只要涉及异步就完全没有意义。 更微妙的是,同步代码的耗时本身也不太适合用来做性能判断。现代JavaScript引擎会做即时编译和各种优化,同一段代码跑第一次和跑第十次的耗时能差好几倍。要认真测性能,得跑很多次取分布,而不是看单次的一个读数。 ## 输出行数只数进了面板的 同理,那2条是面板收到的条数,不是代码实际打印的条数。异步示例里它会显示输出行数1,而代码写了7条。 这两个数字本身没有说谎,它们如实反映了面板知道的事。麻烦在于用户会把它们当成代码的运行结果来读。一个显示得理直气壮的错误数字,比没有数字更容易误导人。 假如面板在这里加一句提示,比如异步输出不会被统计,问题就小得多。现在的做法是把一个局部的观测值当成全局的结论展示出来,而观测口径从来没被说明过。这类问题在数据类工具里特别常见——不是算错了,是没告诉你算的是什么。 ## 左边那列行号,从第5行起就对不上了 这是个纯视觉问题,但它每天都在发生,而且从工具上线到现在一直是这样。 ## 先看现象 打开工具,编辑器里有段19行的默认代码。左边的行号列,前几行是这样排的:第一行显示的是1和2挤在一起,第二行是3和4,第三行是5和6,一直到9和10,然后从11开始才变成一行一个。 于是代码第4行对应的行号显示成了7和8。你想告诉同事第12行有问题,对着行号列数过去,指到的是别的地方。两个人隔着屏幕对不上号,最后只能靠念代码内容确认。 ## 量一下 用范围对象量出行号列文本实际占据的视觉行数: 指标 | 读数 | 代码真实行数 | 19 | 行号文本里的换行符数量 | 19 | 行号列渲染出的视觉行数 | 14 | 前4个视觉行的宽度 | 各21.5像素 | 第6个起的视觉行宽度 | 各14.3像素 | 换行符一个不少地写进去了,渲染出来却少了5行。宽度这两个数把真相说透了:21.5像素正好是一个数字、一个空格、再一个数字;14.3像素正好是两个数字。也就是说个位数的行号两个挤一行,两位数的一行一个。 算一下也对得上:1到9这9个个位数,两两一组占了5行(最后一行是9和10配对),10到19这10个两位数各占一行,5加9正好14。 ## 根因是一行样式声明 行号是把1、2、3这些数字用换行符拼成一长串,整体塞进一个元素里。要让换行符真的换行,这个元素必须声明保留空白。 实测两个元素的计算样式,代码文本框那边是保留模式,行号列这边是折叠模式,而两者的行高完全一致,都是22.1像素。折叠模式下换行符被当成普通空格,整串行号变成一段连续文本,在44像素宽的窄列里自动换行重排。 这是一对一错的完美对照:同一个样式表里,文本框那边写对了,行号列这边漏了。改法就是补上那一行声明,一个属性的工作量。 > 顺带一提,滚动同步那个功能也跟着废了。代码往下滚时行号列会同步滚动,但两边的内容高度对不上,滚得越远偏差越大。 ## 怎么快速自查 不用量像素也能确认:随便往编辑器里贴一段超过10行的代码,看行号列最后一个数字是不是等于你的代码行数。对不上就是这个毛病。 或者更省事,在代码末尾敲几个空行,看行号有没有跟着往下长。折叠模式下新增的换行符照样会被吃掉,行号列的最后一个数字会变,但位置对不上。 ## 为什么这种问题能存活这么久 因为它不影响功能。代码照样能编辑,运行照样出结果,只有行号是错的。而行号这东西大多数时候没人会特意去核对,除非报错信息给了行号让你去找——偏偏这个工具的报错又不给行号,于是连暴露的机会都没有。 这算是个小小的黑色幽默:两个缺陷凑在一起,反而互相掩护了。 ## 没有沙箱,没有超时,一个死循环就能锁死整页 这一条是使用风险,不是显示问题。 ## 代码在哪里跑 用户代码被包成一个函数直接在页面主线程里执行。没有独立线程隔离,没有内嵌框架沙箱,也没有任何超时中断机制。 做了个安全的实验:写一段忙等400毫秒的代码(不是死循环,会自己结束),同时用动画帧回调数这期间浏览器渲染了多少帧。 观测项 | 读数 | 同步占用主线程 | 400.3毫秒 | 期间渲染的动画帧数 | 0 | 是否有独立线程隔离 | 无 | 是否有超时保护 | 无 | 400毫秒里一帧都没渲染出来。按每秒60帧算,正常应该有24帧。整个页面在这段时间是冻住的,按钮点不动,滚动也不响应,光标闪烁都停了。 ## 换成死循环会怎样 把条件改成永远为真,页面就永久冻结了。没有任何机制能把它停下来——停止按钮不存在,就算存在也点不动,因为处理点击事件的也是同一个主线程。 浏览器过一会儿可能会弹出页面无响应的提示,问你要不要等待或者结束。选结束就是关掉这个标签页。唯一的出路就是这个,没保存的代码跟着一起没。 ## 代码和工具页面共享同一个文档 还有个连带的后果:既然代码在页面主线程里跑,它操作的文档对象就是工具页面本身。工具自带的操作页面元素示例,改的其实是这个页面的元素。 示例代码本身很克制,只是创建了临时元素做演示,不会破坏什么。但如果你贴一段真实业务里的页面操作代码,比如按类名批量删除节点,删掉的就是工具页面上的东西。刷新能恢复,只是当场会有点懵。 ## 实际使用中怎么防 - 写循环时先把终止条件写完整再点运行,别指望能中途叫停 - 递归函数先想清楚出口,栈溢出反而是好事(会抛错停下来),真正麻烦的是那种一直增长但不溢出的情况 - 重要的代码别只存在这个编辑器里,它没有自动保存,页面一关就没了 - 要跑不确定会不会失控的代码,用浏览器自己的开发者工具,至少那里能刷新页面止损 - 先用小数据量跑通,再改成正式的数据量,别一上来就喂十万条 这不是苛责。纯前端的在线运行工具要做真正的隔离,得把代码丢进独立线程或者沙箱框架,通信、输出捕获、超时中断都要重写一遍,工作量翻好几倍。而且独立线程里没有文档对象模型,工具自带的那个操作页面元素的示例就跑不了了。取舍是明摆着的,知道它没有这层保护,自己绕开就是了。 ## 面板上的对象,和你在真控制台里看到的不是一回事 输出的格式化用的是JSON序列化。这条路对普通对象和数组够用,碰到几类特殊值就会失真。 ## 实测一组常见值 你打印的 | 面板上显示的 | 问题 | new Map带两组键值 | {} | 键值全丢 | new Set带三个元素 | {} | 元素全丢 | 正则表达式 | {} | 整个变成空对象 | [1, undefined, 3] | [1, null, 3] | undefined变成了null | 含循环引用的对象 | [object Object] | 整个塌成一个字符串 | new Date(0) | 带引号的标准时间串 | 不是本地时间格式 | 负零 | 0 | 负号丢失 | 前三行是同一个原因:这几种内置类型没有可枚举的自有属性,序列化看过去就是个空壳。这是序列化本身的既定行为,不是工具写错了,但显示成一对空花括号确实会让人以为自己的集合是空的。 正则那一行尤其容易咬人,因为这个工具自带一个正则匹配的示例按钮。在里面打印一个正则对象,得到的是空对象——而你点进这个示例,多半就是想看看正则长什么样。 ## 数组里的undefined那条最阴 数组元素是undefined时会被转成null。这两个值在JavaScript里含义完全不同:一个是没赋值,一个是明确的空值。调试数据处理逻辑时,你可能正在找的就是哪个位置漏了赋值,而面板把它显示成了null,线索直接断了。 更麻烦的是这个转换很难被察觉,因为null看上去是个合法的、说得通的值。你不会怀疑它,只会顺着这个错误的信息往下找。 ## 时间对象那条也有点绕 打印一个时间对象,面板显示的是带引号的标准时间串,那是国际标准格式、以零时区表示的。而浏览器控制台显示的是本地时区的可读格式,还会标出星期几。 差别不只是好不好看。调试时区相关的逻辑时,面板给你的永远是零时区的读数,你得自己在脑子里加8小时。本来想验证的就是时区转换对不对,工具却把时区信息统一抹平了,这一验等于白验。 ## 循环引用那条要单独说 对象自己引用自己时,序列化会直接抛异常。工具捕获了这个异常,退回到普通的字符串转换,结果就是那个经典的[object Object]。 真实浏览器控制台在这里会给你一个可展开的树,还会用特殊标记指出循环的位置。这个差距在调试嵌套数据结构时相当明显——树形数据、双向链表、带父指针的节点,这些结构天然带循环引用。 ## 还有个排版上的小别扭 序列化用了两个空格的缩进,所以数组会被竖着展开。工具自带的算法示例里打印一个8个元素的数字数组,面板上占了整整10行。真实控制台会把它压成一行,前面还标着元素个数。 内容没错,就是把面板撑得很长。排序算法这类要打印中间步骤的场景,每打印一次数组就是十几行,跑完一遍得滚好几屏。要对比排序前后的差异,来回滚动挺累的。 ## 输入1加1什么都不显示,这不是缺陷 新手最容易困惑的一处。在浏览器自带的控制台里敲1 + 1,回车就显示2。在这里输入同样的内容点运行,面板一片空白,什么都没有。 ## 两种写法差在哪 输入1 + 1,面板完全空白,没有任何输出,连一条提示都没有。改成return 1 + 1,面板立刻出现一条标着RETURN的记录,值是2。 差别在于这个工具不是读取求值输出循环。浏览器控制台是那种模式:你输入什么它就求值什么,然后自动把结果显示出来。而这个工具是把你的代码整段包进一个函数体里执行。函数体里写一个孤零零的表达式,语法上合法,但值被丢掉了,除非你显式写return。 工具确实处理了返回值——有返回值时会显示一条RETURN标记的记录。只是它的说明文档从头到尾没提过要写return这件事,7个示例代码里也全都是打印语句,没有一个用返回值的。于是这个能力等于藏起来了。 说明与实现对不上,在这类工具页面里算是通病。同一批里那款生成网站图标的,常见问题里写着生成的代码已经包含矢量图标声明,实测把整段代码搜一遍是零命中,细节在Favicon生成器给的10个图标文件里真正上场的只有一张 (https://zhangwenbao.com/favicon-generator-transparency-crop-ico-html-coverage-guide.html)那篇里。文档是人写的,实现是另一个时间点的人写的,两边不同步太常见了。 ## 顺带说个容易混的点 在函数体的顶层写return是合法的,这一点很多人不确定。它和在模块顶层写return不一样,后者会报语法错误。因为这里的代码本来就被包在函数里,return有明确的归属。 所以规矩是:想看什么,要么打印它,要么return它。直接写表达式等于什么都没写。 ## 报错不给行号,50行代码里自己找 故意在第3行写个语法错误,前两行完全正常。运行之后面板给出的是一行错误:类型是语法错误,消息是遇到了意外的分号。 就这一行。没有行号,没有列号,没有调用栈。 错误处理只取了异常的名称和消息两个字段,堆栈信息整个丢掉了。而语法错误在代码解析阶段就抛出来了,连第1行都还没执行——这也是为什么前两行明明正常,你却看不到它们的输出。 代码只有几行时无所谓,扫一遍就行。写到五六十行,一个位置不明的语法错误能耗掉相当长的时间,尤其在行号列还对不上的情况下。运行时错误也一样,比如某个变量拼错了,只告诉你这个名字未定义,不告诉你在哪一行。 对照一下,浏览器自己的控制台在这里会给出精确到行列的位置,还能点击跳转到出错的那一行。这也是前面那个建议的另一个理由:真要认真调试,还是回到开发者工具里去。 粘贴代码之前先过一道格式化,能提前暴露一部分结构问题,比如括号没配对。工具自己带了格式化按钮,多语言格式化工具的边界在代码格式化工具支持十几种语言,是每种都真懂吗? (https://zhangwenbao.com/code-formatter-multi-language-beautify-honest-guide.html)那篇里有过一次实测。 ## 变量不跨次保留,但有一种写法会留下来 这一节讲的是运行之间的隔离程度,关系到你会不会被上一次的残留坑到。 ## 实测三种声明方式 第一次运行声明三个变量,第二次运行检查它们还在不在。结果是:用var声明的和用let声明的都已经消失,而显式挂到全局对象上的那个仍然存在。 前两种的行为是好事:每次运行互不干扰,不会出现上次的变量污染这次的情况。代码被包在函数体里,函数一返回作用域就销毁了,连带里面的所有声明。 这一点比某些在线工具强。有些实现直接用全局求值,跑两次同一段带const声明的代码就会报重复声明的错,得刷新页面才能继续。这个工具没有这个毛病。 ## 那个口子有多大 显式写到全局对象上的东西会一直留着,直到刷新页面。实测还确认了函数体里的this指向全局对象,这意味着用户代码和工具页面自身的脚本共享同一个全局环境。 理论上你可以覆盖掉工具自己的函数,比如把它的运行函数改成别的东西,工具就不工作了。刷新一下就恢复,没什么破坏性,但足以说明这里确实没有隔离。 实际使用中要注意的只有一点:如果你的代码往全局挂了东西,多跑几次可能会互相影响。调试到一半发现行为诡异,第一反应应该是刷新页面重来,而不是怀疑自己的逻辑。 ## 那么这个工具适合拿来做什么 把上面的限制折过来看,它的适用边界其实相当清晰。 ## 适合的场景 - 验证一个正则表达式在特定字符串上的匹配结果 - 试数组和字符串方法的返回值,比如某个方法到底改不改原数组 - 算一段纯计算逻辑,排序、去重、格式转换这类 - 把一段从别处抄来的同步代码跑一遍看看输出 - 手边没有开发环境时,快速验证一个语法细节 - 给别人演示一段短代码的效果,一个链接就能共享环境 ## 不适合的场景 - 任何带定时器、请求、Promise的代码——面板只给你看开头 - 性能对比——耗时数字不含异步部分,单次读数也不可靠 - 调试语法错误——不给行号,行号列本身还对不上 - 跑不确定会不会失控的循环——没有中断机制 - 需要看清集合类对象内部结构的场景——显示成空对象 - 需要保存或者持续迭代的代码——没有自动保存 ## 一个提高效率的小习惯 既然它没有自动保存,验完一段有价值的代码顺手点一下下载按钮,会存成本地文件。比起下次从头再写一遍,这个动作只花一秒。 另一个习惯是把要验的东西尽量缩到最小。不要把整个函数搬过来,只留下你怀疑的那两三行加上必要的输入。范围越小,同步执行越快,前面提到的那些限制就越碰不到。 ## 一条通用建议 把这类在线运行工具当成草稿纸,不当成工作台。草稿纸的价值是随手可得、不用准备,验个小东西比打开编辑器快得多。但凡事情复杂到需要看调用栈、需要看异步时序、需要反复迭代,就该换地方。 判断标准可以很简单:如果你打算在这里待超过五分钟,那多半选错工具了。 > 保哥自己用这类工具最多的场景是核对一个方法的边界行为,比如某个参数传负数会怎样、空数组调用某方法返回什么。这种问题查文档要翻半天,跑一次两秒钟出结果。至于调试真实业务代码,那还是老老实实开编辑器。 顺带一提,同一批工具里那款在线改CSS的,也有类似的定位问题——它输出的不是完整样式表而是一份差异清单,细节在CSS在线编辑器生成的不是完整样式,而是一份相对默认值的差异清单 (https://zhangwenbao.com/css-editor-83-controls-default-diff-unit-trap-guide.html)里。工具好不好用,往往取决于你有没有搞清楚它到底在给你什么。 ⚡ 动手试试:JS在线运行工具 粘贴代码按Ctrl加回车就跑,控制台输出、错误捕获、代码格式化、7个现成示例都在同一屏里,不用装任何环境。验个正则、试个数组方法,比打开编辑器快得多。 保哥自研免费在线工具,浏览器打开就能用。 → 打开JS在线运行工具 (https://zhangwenbao.com/tools/js-runner.php) ## 常见问题解答 ## 为什么点了Fetch API示例只显示一行就没了? 因为异步回调里的输出没有被面板捕获。工具在同步代码执行完毕的瞬间就把控制台方法还原成原生的,而请求的响应处理排在之后才跑,那时已经没人接管输出了。实测该示例代码里有4处输出语句,面板只收到1条。请求本身多半是成功的,按F12打开浏览器控制台能看到完整结果。 ## 面板下方显示的耗时可以用来比较性能吗? 只有纯同步代码可以,而且单次读数也不太可靠。计时是在同步执行的前后各取一次时间戳,异步任务还在队列里没跑,计时就结束了。带请求或定时器的代码会显示一个很小的数字,那是发起的成本不是完成的成本。要认真测性能得跑很多次看分布。 ## 左边的行号为什么和代码对不上? 行号列缺少保留空白的样式声明,换行符被当成普通空格处理,整串行号在44像素宽的列里自动换行重排。实测19行代码只渲染出14个视觉行,个位数行号两个挤一行、两位数一行一个。同一个样式表里代码文本框那边是写对的,只有行号列漏了这一行。 ## 写了死循环怎么停下来? 停不下来,只能关掉标签页。代码在页面主线程里直接执行,没有独立线程隔离也没有超时中断。实测一段400毫秒的忙等期间浏览器渲染了0帧,页面完全冻结,按钮点不动。未保存的代码会一起丢失,所以重要内容别只放在这个编辑器里。 ## 为什么打印Map和Set显示的是空对象? 输出格式化走的是JSON序列化,而这几类内置对象没有可枚举的自有属性,序列化出来就是一对空花括号。这是序列化的既定行为,不是工具的实现错误。要看内容得先手动转换,比如把Map展开成数组再打印。正则对象也是同样的情况。 ## 输入1加1为什么没有任何输出? 代码被整段包进一个函数体里执行,函数体里的孤立表达式语法合法但值会被丢弃。写成return加上表达式就能看到一条RETURN标记的结果。想看什么就打印它或者return它,直接写表达式等于没写。工具的说明文档没提过这一点,示例代码里也没有用到返回值的。 ## 上一次运行声明的变量还在吗? 用var和let声明的都不在,函数返回时作用域就销毁了,每次运行互不干扰。但显式挂到全局对象上的属性会保留到页面刷新为止。实测函数体里的this指向全局对象,用户代码和工具页面共享同一个全局环境,调试出现诡异行为时刷新页面最省事。 ## 权威参考资料 ## 脚本下载完了,一行都没执行,而拦住它的是三个月前你自己抄下来的那串哈希 - URL:https://zhangwenbao.com/subresource-integrity-third-party-script-update-breakage.html - 分类:JS教程 - 发布:2025-12-31 | 更新:2026-07-29 - 摘要:实测子资源完整性:第三方发新版后哈希对不上,脚本被完整下载再拒绝执行,功能消失且没有任何上报通道。含32组双引擎判决矩阵、CDN地址形态对照与92站现网体检。 - 关键词:第三方脚本,故障排查,浏览器行为 > **TLDR**:摘要:integrity属性记录的是你复制那段引入代码当天、那个地址上的那一份字节。第三方发一个小版本、CDN把范围版本解析到新的补丁号、构建流水线重新压缩一次,字节就换了,而这些动作都不经过你。换掉之后浏览器会把资源完整下载下来再拒绝执行,页面上少一块功能,服务端没有日志,前端监控收到一个空的error事件,SRI自己没有任何上报通道。本文用一个真实HTTPS实验台跑了32组判决矩阵,两个引擎结论完全一致,再对92个能抓到的站做了整体普查,把这套机制的边界、判据和可落地的配法写清楚。 > 摘要:integrity属性记录的是你复制那段引入代码当天、那个地址上的那一份字节。第三方发一个小版本、CDN把范围版本解析到新的补丁号、构建流水线重新压缩一次,字节就换了,而这些动作都不经过你。换掉之后浏览器会把资源完整下载下来再拒绝执行,页面上少一块功能,服务端没有日志,前端监控收到一个空的error事件,SRI自己没有任何上报通道。本文用一个真实HTTPS实验台跑了32组判决矩阵,两个引擎结论完全一致,再对92个能抓到的站做了整体普查,把这套机制的边界、判据和可落地的配法写清楚。 先说结论里最反直觉的那一条:哈希对不上的时候,那个脚本文件不是没下载下来,是下载完整了才被拒绝执行的。 在实验台上,一个被拦下的脚本,它在Resource Timing里的 encodedBodySize 是85,transferSize 是385,跟正常执行那一组的数字口径完全一样,只是最后没跑。带宽花了,往返等了,功能没有。 这件事之所以值得单独写一篇,是因为它跟另一类故障长得太像了。上一次我们拆过白名单里明明写着那个域名却还是被拦 (https://zhangwenbao.com/csp-script-src-allowlist-drift-third-party-silent-block.html)的情形,那一次是域名变了;这一次是域名一个字没动、路径一个字没动,变的是那个地址背后的字节。两种故障在浏览器里的表现几乎是同一副面孔,但排查的入口完全不同。 这一类“服务端一切正常、用户那一侧少了一块东西”的故障,这几年在独立站上出现得越来越密。服务器全程返回200、订单也进了库,用户眼前那块区域却始终是空白 (https://zhangwenbao.com/csp-frame-ancestors-blocked-render-not-request.html)是一种,照着加固清单加一行响应头把嵌进来的地图和支付按钮一起变成摆设 (https://zhangwenbao.com/permissions-policy-iframe-third-party-widget-silent-denial.html)是另一种。它们的共同点是:拦截发生在浏览器里,而你所有的日志都在浏览器外面。 ## 为什么脚本整个下载完了,却一行都没执行? 子资源完整性(Subresource Integrity,业内一般直接叫SRI)的工作方式很朴素:你在标签上写一个哈希,浏览器把资源下载下来,算一遍哈希,对得上就执行,对不上就当作加载失败。 MDN把这个过程写得很直白:浏览器会用指定的函数计算资源内容的哈希,然后跟你写的所有值比对,任何一个对上就加载,否则就拒绝加载这个资源并返回一个网络错误 (https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity)。 注意最后半句:返回的是一个网络错误。这就是所有排查困难的源头——一次哈希不匹配,被翻译成了一个跟断网、跟DNS解析失败、跟对方服务器502一模一样的信号交给你的代码。 ## 下载是真的发生了 为了确认字节到底有没有过来,实验台在每一组里都读一次Resource Timing。下面是同一个地址、同一个标签写法,只改服务端返回哪一个构建产物的对照: 组别 | 钉的哈希 | 服务端实际给的 | encodedBodySize | 脚本执行了吗 | 基线 | 构建1 | 构建1 | 76 | 是 | 漂移 | 构建1 | 构建2 | 85 | 否 | 没写integrity | 无 | 构建2 | 85 | 是 | 服务端不发CORS头 | 构建1 | 构建1 | 0 | 否 | 第二行和第四行的差别,是这张表里最值钱的一格。哈希不匹配那一组,字节数是完整的85;跨源许可缺失那一组,字节数是0。同样是脚本没跑起来、控制台都在飘红,Resource Timing里的字节数能直接告诉你是哪一道关卡拦的:数字是完整的,说明内容已经交付、卡在了校验;数字是0,说明请求在跨源许可那一层就没能把内容交给页面。 这个判据在真实排查里很好用,因为它不依赖你能不能看到控制台。线上环境里用户不会给你截图控制台,但性能监控里的资源条目是采得到的。 ## 失败姿势和网络故障是同一副面孔 把一次哈希不匹配能产生的所有信号都收集一遍,得到的是这么一张清单: - 脚本标签的 onerror 触发了,但事件对象上只有 isTrusted 一个自有属性,message 是空字符串。 - window.onerror 完全不触发,因为它只管脚本执行期间抛出的异常,不管脚本压根没执行这件事。 - 用 fetch() 带 integrity 选项去拉,抛的是 TypeError: Failed to fetch,跟对方服务器宕机时抛的是同一个错误名。 - securitypolicyviolation 事件一次都没有触发。SRI不走内容安全策略那套上报,它自己也没有一套。 最后一条得再强调一遍。内容安全策略至少还有 report-uri 这条老通路能把违规报文送出去,SRI连这个都没有。整个规范里没有任何一个字段是用来告诉服务端有一次校验失败的,所以线上到底有多少用户在多少次访问里少加载了那个脚本,这个数字不存在,也没有办法存在。 这一点对习惯了从服务端找答案的人特别不友好。访问日志能还原出爬虫的抓取轨迹、能识别伪造的抓取来源 (https://zhangwenbao.com/server-log-file-analysis-seo-crawl-budget-bot-verification.html),但它对这次故障一无所知——从服务端看,那个脚本是被完整送出去的,状态码200,字节数正常,一切都很成功。 ## 唯一说人话的地方是控制台 好消息只有一个:控制台不但告诉你被拦了,还把当前那份内容的哈希直接算好打给你。两个引擎的措辞不同,信息量一样: > Failed to find a valid digest in the 'integrity' attribute for resource '……' with computed SHA-384 integrity 'Ll9K58dtW4n1Ej7kISIyIr…'. The resource has been blocked. > None of the “sha384” hashes in the integrity attribute match the content of the subresource at “……”. The computed hash is “Ll9K58dtW4n1Ej7kISIyIr…”. 那串computed后面的值,就是这个地址现在返回的内容的哈希。换句话说,浏览器在报错的同时,已经把修复这次故障需要的那个新哈希写在屏幕上了,复制粘贴就能用。这是整套机制里唯一一处对人友好的设计,可惜它只出现在控制台里,只有主动去看的人才看得见。 ## integrity钉住的到底是什么? 很多人第一次配SRI的时候,心里的模型是把某个库锁在某个版本上。这个模型不对,而且不对得很关键。 integrity钉住的既不是版本号,也不是地址,而是那个地址在你复制它的那一刻返回的那一串字节。字节多一个空格、少一行注释、压缩器换了个换行策略,哈希就是彻底不同的另一个值——哈希函数没有近似这一说,改一个字符和改一整个文件,在结果上是同等的陌生。 ## 版本号只是文件名的一部分 这里有个很容易糊过去的地方:URL里那段版本号,对浏览器来说没有任何特殊含义,它就是路径里的几个字符。是CDN在按照自己的规则决定这段字符对应哪一份文件,而不同的写法对应的是完全不同的承诺。 拿最常见的几种引入写法实测一下,同一个库、同一个文件名,只改URL里版本那一段: URL里的版本写法 | 实际给的版本 | 字节数 | Cache-Control | bootstrap@5.3.3 | 5.3.3 | 80721 | max-age=31536000, immutable | bootstrap@5.3 | 5.3.8 | 80496 | max-age=604800, s-maxage=43200 | bootstrap@5 | 5.3.8 | 80496 | max-age=604800, s-maxage=43200 | 不写版本 | 5.3.8 | 80496 | max-age=604800, s-maxage=43200 | 把最后一列读一遍,答案已经写在响应头里了。钉死到补丁号的那个地址,CDN给的缓存策略是一年加 immutable,意思是这份内容承诺永不改变;只写到主版本或者次版本的那些地址,缓存策略掉到7天、边缘12小时,意思是这份内容随时会变。 这就是判据本身。你不需要去猜某个第三方会不会发新版,也不需要去读它的发布节奏:CDN已经用缓存时长给每一个地址标注了会不会变,而钉哈希的人从来不看那一栏。一年加immutable的地址可以钉,7天的地址钉上去就是在等它挂。 缓存时长这一栏平时是拿来调回源率和边缘命中的 (https://zhangwenbao.com/cdn-cache-configuration-seo-impact-edge-routing-complete-guide.html),很少有人把它当成一份内容变更承诺书来读。但它确实是——响应头本来就是服务端对这份内容作出的一系列声明 (https://zhangwenbao.com/http-response-headers-seo-x-robots-cache-vary-canonical-mechanism.html),缓存策略只是其中说得最直白的一条。 ## 差14个字节和差14千字节是一回事 再看一组更容易骗过直觉的数据。同一个前端框架的生产包,钉死到补丁号的地址返回10737字节,只写到主版本的地址返回10751字节。差14个字节,肉眼看diff大概就是几处版本字符串和一两行边界处理。 但这两份内容的sha384是两个毫不相干的值。在哈希这里没有差不多这个概念,14个字节的差别和18千字节的差别(另一个框架的两种写法之间正好差这么多)产生的后果完全一样:脚本不执行。 顺带一提,同一批测试里有一个地址直接返回了404——那家CDN对省略版本号的写法有自己的一套路径解析规则,跟另一家不一样。所以别指望不同CDN之间的写法可以照抄。 ## 那些看起来最安全的地址,恰好是最没用的 把上面两件事合起来,会得到一个有点尴尬的推论。 只有内容不会变的地址才适合钉哈希,而内容不会变的地址包括两类:钉死到补丁号的开源库文件,以及文件名里带内容指纹的自家打包产物。这两类文件本来就不会在你不知情的时候变化,钉上哈希之后,真正被防住的只剩一种情况——有人入侵了那个CDN,把那个具体文件的内容换掉了。 这个威胁是真实存在的,SRI在这里确实有用。但它同时意味着:凡是你需要跟着第三方一起更新的脚本,也就是分析、客服、支付、标签管理这一整类,SRI都用不上,因为它们的正常形态就是同一个地址随时换内容。这个矛盾不是配置技巧能绕过去的,它写在机制里。 ## 第三方凭什么在同一个地址上换掉字节? 站在自己这一侧看,第三方在同一个地址上偷偷换内容像是一种不负责任。站在对方那一侧看,这恰恰是他们卖的东西。 ## 动态拼装的服务,结构上就没法钉 最典型的例子是按浏览器特性动态拼装补丁包的那类服务。Mozilla的技术博客当年介绍这种做法时说得很清楚,服务端会分析浏览器的user-agent头以及请求的特性列表,然后构建出这个浏览器所需要的补丁清单 (https://hacks.mozilla.org/2014/11/an-easier-way-of-using-polyfills/)。 这句话翻译成SRI的语言就是:同一个地址,一百个访客拿到的是一百份不同的字节。你在自己电脑上算出来的那个哈希,只对你这个浏览器的这个版本成立,换一个用户就不成立。这类服务不是不想支持SRI,是它跟SRI的前提直接冲突。 而这类服务后来出了什么事,做外贸独立站的同行大概都还有印象。安全团队Sansec在2024年6月的报告里写道,这个域名被一家公司收购之后开始向移动设备注入恶意代码,而有十万以上的站点在用它 (https://sansec.io/research/polyfill-supply-chain-attack)。 报告里还有一段更值得读的描述:那段代码有专门的反调试保护,只在特定的移动设备上、在特定的时段激活,检测到管理员身份就不激活,发现页面上装了网页分析服务就延迟执行。换句话说,它被设计成让站点自己的人最不容易碰到。 把这两件事并排放:SRI存在的全部理由就是防这种事,而这次事件发生的那个地址,结构上根本没法配SRI。这大概是整套机制最难堪的一个巧合。 ## 不动版本号也会换字节的四种日常动作 抛开极端案例,第三方在你不知情的情况下换掉字节,靠的是一些完全正常的工程动作: - 发一个补丁版本,而你引的是范围版本地址,CDN自动解析到新的补丁号。 - 换一次压缩工具链或者升级一次构建器,代码逻辑一行没改,产物字节全变。 - 把服务迁到新的边缘节点或者新的对象存储,中间加一层自动优化,比如把注释再删一遍。 - 做灰度,同一个地址在不同区域返回不同版本,你在办公室算的哈希和用户拿到的内容不是一份。 这四件事有一个共同点:它们全都不会触发第三方给你发通知,因为在对方的流程里,这些根本不算变更。版本号没动,接口没动,文档没动,只是重新构建了一次而已。 做过一段时间独立站的人对这种沉默应该不陌生。第三方统计脚本悄悄换掉自己那个图标的样式 (https://zhangwenbao.com/hidden-third-party-website-statistics-icons.html)、图标库把某个字形的SVG路径重画一遍,都属于同一类:对方按自己的节奏迭代,你这边只有等到用户反馈才知道。区别只在于,以前这类变化最多让页面丑一点,现在会让整个脚本消失。 ## 同样是写错一个字符,为什么后果能差这么远? 把integrity的各种写错方式挨个试一遍,会发现结果分成两极,而且分界线在一个谁都想不到的地方。 ## 三档后果,报警级别和实际危害成反比 写法 | 控制台 | 资源 | 实际后果 | 算法名写成md5- 或sha1- | 报错或警告 | 照常执行 | 防护静默取消 | 算法名大写成SHA384- | 报错 | 照常执行 | 防护静默取消 | 哈希值不是合法base64 | 报错 | 照常执行 | 防护静默取消 | integrity写成空字符串 | 没有输出 | 照常执行 | 防护静默取消 | 把哈希写成了十六进制 | 报错 | 被拦 | 脚本当场死掉 | 正确的哈希前后多了空格 | 没有输出 | 照常执行 | 正常 | 多个哈希用换行分隔 | 没有输出 | 照常执行 | 正常 | 前四行是同一类:浏览器解析不出任何一条有效的哈希元数据,于是整个integrity属性被当作不存在,资源照常执行。规范里这句话写得非常干脆:如果解析出来的元数据是空集合,就直接返回真。 W3C的规范文本里,跳过不认识的算法用的也是同一个逻辑:如果算法不是一个有效的SRI哈希算法标记,就继续处理下一条 (https://www.w3.org/TR/SRI/)。所有条目都被跳过之后,剩下的就是空集合,然后返回真。 两个引擎在这里的报警级别还不一样,一个把它记成错误,另一个记成警告,措辞是“integrity属性里没有包含任何有效的元数据”。但无论哪个级别,行为都是放行——一条你以为已经生效的防护,实际上从写下那天起就没生效过,而唯一提醒你的地方是控制台里一行不会导致任何后果的红字。 ## 分界线是base64的字符集 第五行是另一极。把sha384的摘要写成十六进制而不是base64,这个错误的性质跟前面几行完全一样——都是手滑,都是格式不对——但后果是脚本当场被拦,页面上少一块功能。 差别在哪?十六进制字符串正好落在base64的合法字符集里,于是它被当成一条格式有效、只是对不上的哈希;而带感叹号的乱码不在字符集里,就被当成无效元数据丢掉了。同样是打错字,字符恰好合法就打死站点,字符不合法反而静默放行。 这条判据听起来像脑筋急转弯,但在真实的手工维护场景里会碰到:有人从命令行工具里复制摘要,有的工具默认输出十六进制,有的默认输出base64。编辑器往文件头上偷偷塞几个字节就能让整页白屏 (https://zhangwenbao.com/notepad-edit-saved-code-generate-bom-resulting-web-page-error-white-screen-solution.html)是同一类问题的老版本,这次换成了摘要格式。 顺带说一句,图标库是这类手工维护的重灾区。图标库跨大版本时语法和引入方式都会变 (https://zhangwenbao.com/how-to-use-font-awesome-font-icons.html),官方文档里那段带哈希的引入代码也跟着换,而站上那段代码往往是三年前某个人复制进去之后再没人碰过的。 ## 多个哈希写在一起时,只有最强的那个算数 SRI允许在一个属性里写多个哈希,但它们之间的关系不是你想的那样。MDN写得很明确:不同的哈希函数强度不同,从弱到强是sha256、sha384、sha512,浏览器会先挑出用最强那个函数生成的一组,只用这一组来比对,其他的全部忽略 (https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity)。 实验台上跑的两组单变量把这句话的后果摆出来了: - 写了一条sha256(跟服务端给的内容对得上)加一条sha384(对不上):被拦。因为只有sha384那条算数,而它不匹配。 - 写了一条sha256(对不上)加一条sha384(对得上):放行。弱算法那条写错了完全没有影响。 - 写了两条sha256,其中一条对得上:放行。同强度之间是任一匹配即可。 把这三条并起来看:给一个原本工作正常的sha256配置补一条更强的sha384,如果那条sha384是从另一个版本算出来的,你不是加强了防护,而是把原来那条直接作废掉了。升级动作的意图是变严,实际效果是把判定权整个交给了新写的那一条。 保哥见过一次类似的现场,起因是安全评审提了一句“建议升级到更强的哈希算法”,执行的人从新版本的文档里复制了sha384,又保留了老的sha256,上线之后那个组件就消失了。加一条比删一条更容易通过评审,但在这里加一条的破坏力比删一条更大。 ## 跨源脚本漏了crossorigin会怎样? 这是SRI配置里最常踩的一脚,而且它踩下去的时候哈希是完全正确的。 ## 校验需要许可,而许可要单独申请 浏览器要算一个跨源资源的哈希,前提是它有权读到这个资源的完整内容。默认情况下它没有这个权限——跨源脚本可以执行,但内容对页面是不透明的。 W3C规范把这层关系说得很不客气:子资源完整性需要CORS,不带CORS就想用它是一个逻辑错误 (https://www.w3.org/TR/SRI/)。MDN那边给的是操作层面的说法:用了SRI的跨源请求必须走跨源资源共享协议,服务器要明确用 Access-Control-Allow-Origin 响应头表示允许,同时你必须在标签上加 crossorigin 属性。 漏了会怎样?实验台上,哈希完全正确、服务端也发了许可头,只是标签上少写了 crossorigin: > Subresource Integrity: The resource '……' has an integrity attribute, but the resource requires the request to be CORS enabled to check the integrity, and it is not. The resource has been blocked because the integrity cannot be enforced. 另一个引擎说得更短:这个地址不符合完整性校验的条件,因为它既不是跨源许可的,也不是同源的。 结果是资源被拦——不是跳过校验放行,而是因为没法校验所以拒绝。这个选择在安全上是对的,在体验上很致命:你写了一个正确的哈希,忘了写一个属性,得到的结果和写了一个错误的哈希完全一样。 ## 同源不需要,模块脚本也不需要 同一组实验换成同源资源,不写 crossorigin 照样通过。这符合直觉——同源本来就能读到内容。 但下面这组单变量就不那么符合直觉了。同一个跨源地址、同一个正确的哈希、同样不写 crossorigin,唯一的差别是标签上多了一个 type="module": 标签写法 | crossorigin | 哈希 | 结果 | 普通脚本 | 没写 | 正确 | 被拦 | 模块脚本 | 没写 | 正确 | 正常执行 | 模块脚本 | 没写 | 过期 | 被拦 | 第三行是必须跑的对照组,它证明模块脚本那一组不是跳过了校验,而是真的校验通过了。 原因在MDN的脚本元素页上:跟传统脚本不同,模块脚本在跨源获取时必须使用CORS协议 (https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script)。既然模块脚本本来就走跨源许可,crossorigin 属性对它就是多余的。 于是同一个属性在两种脚本上的必要性完全相反:普通脚本漏写就整个消失,模块脚本写不写都行。而这两种脚本在源码里的差别只有 type 那一个词,代码评审时几乎不会有人注意到属性的必要性跟着变了。 这也是前端和运营之间那几个协作动作点 (https://zhangwenbao.com/frontend-engineer-seo-collaboration-7-actions-semantic-cwv-render.html)里最容易漏掉的一类:改动本身完全合理,评审也挑不出毛病,只是有一条隐含约束跟着变了,而这条约束不在任何一份检查清单上。 这不是纸上推演。普查里有一个技术媒体站现网就是这么写的:一个模块脚本,带integrity,不带 crossorigin,控制台干干净净。照着它的写法去配一个普通脚本,那个脚本会立刻消失。 ## use-credentials是另一个陷阱 还有一种写法值得单独拎出来:把 crossorigin 写成 use-credentials。听上去比 anonymous 更完整、更“带上身份”,实际上它跟绝大多数公共CDN是天生不兼容的。 实验台上这一组的报错来自跨源规则本身,跟SRI无关:当请求的凭据模式是include时,响应里的 Access-Control-Allow-Origin 不能是通配符。而公共CDN为了服务所有人,发的恰恰就是通配符。 结果是哈希对得上、属性也写了,脚本照样被拦,而报错信息里一个字都不会提到integrity。排查的人盯着哈希看半天,问题在旁边那个属性的取值上。 ## 哪些地方写了integrity等于没写? SRI的适用范围比大多数人以为的窄。写在范围之外的地方,浏览器不报错、不警告、不拦截,就是当作没看见。 ## 规范只给了两个元素 W3C规范的原话是:integrity属性被加进了link元素和script元素的内容属性列表 (https://www.w3.org/TR/SRI/)。MDN说得更细一点,link元素还要求 rel 是stylesheet、preload或者modulepreload这三种之一。 其余全部无效。实验台上验证了两种最容易写错的: - 在图片标签上写integrity,服务端返回一份跟哈希对不上的图片:图片正常显示。而且用脚本去读这个元素,会发现 integrity 根本不在它的属性接口里,说明这个属性对它连存在都算不上。 - 在内联脚本上写integrity,哈希是从另一个文件算出来的:脚本照常执行。MDN的脚本元素页写得很直接,这个属性在没有 src 的时候不允许出现——规范说不允许,浏览器的处理是当没看见。 普查里正好抓到了一个现网标本:一家支付服务商的首页上,有一个内联的样式元素带着integrity属性。那多半是构建工具统一加的,从上线第一天起就没有起过任何作用,也没有任何机制会告诉他们这一行是白写的。 ## 会被校验的比你想的多 反过来,在支持范围内的地方,校验是一视同仁的。实验台把能加载脚本的写法挨个试了一遍,async、defer、动态创建插入、模块脚本、样式表、预加载,全部照常校验、照常拦截。 预加载那一组尤其值得注意:页面上先用 rel="preload" 预取一份,再用普通脚本标签引同一个地址,只在预加载那一处写了integrity。结果是两处一起失败——预取那一份没通过校验,后面的脚本也就没有可用的内容。预加载不是一个可以偷偷跳过校验的旁路,它是同一条管线上的前半段。 ## 重定向之后校验的是终点 还有一种情况在第三方迁移期间很常见:老地址302到新地址。这时候浏览器校验的是重定向终点返回的字节,跟中间跳了几次无关。 控制台在这里比内容安全策略那次 (https://zhangwenbao.com/csp-script-src-allowlist-drift-third-party-silent-block.html)厚道一些:它会分别打出发起地址和最终地址两条记录,你能看出来是跳转之后的内容对不上。而在那次里,违规报告给出的地址是跳转前的那一个,看报告的人根本不知道中间还跳过一次。 ## 被拦下的那份字节去哪了? 到这里为止,故事还算符合直觉:内容不对,浏览器拒绝执行。但下面这组实验的结果,会让“拒绝”这两个字的含义变得可疑。 ## 它进了缓存,而且立刻能被别人取出来用 实验台上摆了这么一个页面:同一个地址先后引两次,第一个标签带一个过期的哈希,第二个标签什么都不带,服务端给这个地址配了正常的缓存头。 结果是:第一个标签被拦,控制台照常报错;第二个标签正常执行;而服务端的请求计数是1。 也就是说,那份没通过校验的内容不但下载完了,还完完整整地进了HTTP缓存,紧接着被同一个页面上一个不设防的引用取出来跑了起来。两个引擎都是这个结果,一个是网络层只发一次请求,另一个是浏览器发了三次但服务端只收到一次,剩下两次全走缓存。 保哥第一次看到这个计数的时候以为是实验台的计数器写错了,把服务端日志翻出来数了一遍才认下来:请求确实只有一次,被拦的那一次和执行成功的那一次,用的是同一份下载。 SRI拦的是执行这一步,不是下载,不是缓存。换句话说,它是一道贴在门口的告示,不是一把锁——内容已经进屋了,只是这个门牌上写着别执行。 ## 这件事在什么场景下会咬人 听上去像个只在实验室成立的边角料,但它对应的是一个很常见的现实:同一个库被页面上多个地方引用,而只有一部分引用写了integrity。 比如主模板里的引用是前端同学配好SRI的,某个营销落地页的组件是另一个人加的,图省事没写。那么在这个落地页上,SRI的防护等级就是“没有”——不是打了折,是完全没有,因为那份内容只要被拉下来一次,不设防的那个引用就能用。 顺便还测了另一种写法:同一个地址在页面上出现两次,两次写了不同的哈希。这时候浏览器不会只下载一次然后比对两遍,网络层看到的是两次以上的请求。不同的integrity值会把同一个地址切成互不复用的两份缓存条目,这对性能是笔额外开销,不过跟功能相比算小事。 ## 那些真在用SRI的站,是怎么用的? 机制讲完了,来看现网。这一轮扫了164个域名的首页,只发GET、不登录、不做任何交互,能正常拿到页面内容的有92个,其余的要么被反爬挡住,要么直接超时。 ## 用的人比想象中少得多 92个能抓到的站里,首页上出现integrity属性的只有14个,占15%。作为对照,同一批站里发内容安全策略的有三分之一以上。 这14个站一共钉了44个标签。把它们逐个下载回来重算哈希,结果是42个完全匹配,2个对不上——而那2个来自同一个站,那个站在真实浏览器里会先弹反爬挑战页,压根加载不到正文,所以那2个对不上没法在浏览器里复核,按严口径不计入结论。 严格地说,可复核的部分全部匹配。这个结果和写这篇之前的预期是反的:本来以为会挖出一堆过期的哈希,实际上现网在用的那些基本都是对的。 这里必须把口径交代清楚,否则这个数字没有意义。任何一份外部抓取来的数据,先要问的都是这个样本到底代表了什么 (https://zhangwenbao.com/third-party-seo-tool-data-accuracy-estimation-methodology.html):这一轮只看首页、只看服务端直接吐出来的HTML、不执行脚本、不登录、不下单。所以结账流程里那些动态插进去的第三方脚本,本轮一个都没采到——如果有人在那些地方配了SRI,本文的数字看不见它。 ## 不出事的原因不是管得好 把这44个地址的形态摊开看,答案就在里面了: 地址形态 | 典型例子 | 内容会变吗 | 钉死到补丁号的开源库 | bootstrap@5.3.3、jquery-3.7.1、jquery-validate/1.19.1 | 不会 | 文件名带内容指纹的自家产物 | app-22346079ca679d71bdf1.js | 不会,一变就换文件名 | 版本串塞进路径的第三方探针 | beacon.min.js/v4513226cdae… | 不会,换版本就换地址 | 建站平台自动产出的资源 | 各家托管平台的构建产物 | 不会,平台管着 | 四类全是内容不会变的地址。现网SRI之所以没出事,不是因为大家在勤快地更新哈希,而是因为凡是需要更新哈希的地方,大家压根就没配。 这个推论有一个很硬的支撑:44个被钉的标签里,没有任何一个是滚动更新的分析、客服、标签管理或者广告脚本。唯一沾边的那个是某家CDN的性能探针,而它把版本串写进了路径——换句话说,那家CDN为了让自己的脚本能被钉哈希,专门把地址设计成了每个版本一个新地址。 ## 三个值得记住的现网标本 普查里还捡到三个标本,每一个都对应前面讲过的一条机制: - 某支付服务商首页上有一个内联样式元素带着integrity属性。按规范这个属性在这里不该出现,浏览器的处理是忽略,所以它从上线起就是一行装饰。 - 某技术媒体站用模块脚本引第三方,带integrity、不带 crossorigin,一切正常——这正是模块脚本天生走跨源许可的现网例证。 - 同一个支付服务商的六个自家脚本,文件名本身就是内容指纹,再叠一层integrity。这是整份普查里最健康的一种配法:内容一变文件名就变,哈希永远不可能对不上,SRI在这里只承担防篡改,不承担版本管理。 ## 怎么让钉哈希和第三方更新不打架? 把前面所有实验的结论收成一套可以照着做的东西。核心思路只有一句:先按地址会不会变把资源分成两堆,两堆用两套办法,别指望一个属性通吃。 保哥给客户做前端加固评审时,这一步是放在最前面的:先把页面上所有外部资源列出来,逐个判断它的地址会不会变,判断完再谈配不配SRI。跳过这一步直接给所有第三方脚本加哈希,上线当天可能没事,一个月后开始零零散散地挂,而那时候没人会把它跟一个月前的那次加固联系起来。 ## 第一步:用响应头分堆,别靠猜 上线前对每一个准备钉哈希的地址发一次请求,只看两个东西: - Cache-Control 里有没有 immutable,max-age 是不是以年计。是,说明对方承诺这份内容不变,可以钉。 - 如果用的是公共CDN,看它有没有回一个标注当前解析版本的响应头。有些CDN会直接告诉你这个地址现在指向哪个具体版本,这比你去读它的文档快得多。 max-age 只有几天的地址,不要钉。这不是保守,是尊重对方在响应头里已经写明的承诺——人家说了这份内容会变,你偏要按不变来配,挂了不能怪对方。 如果站点前面还挂了自己的一层CDN,这一步要在回源那一端做,别在边缘缓存过的副本上判断。边缘节点会按自己的规则改写缓存头 (https://zhangwenbao.com/cloudflare-cache-real-world-optimization-decision-tree.html),你在浏览器里看到的那个 max-age 未必是第三方原本的那个。 ## 第二步:能自托管的就自托管 对于那些确实需要固定版本的库文件,比自己维护哈希更省心的做法是把文件拉到自己域下,走构建流水线,让文件名带上内容指纹。 这么做之后有三个附带好处:同源资源不需要 crossorigin;内容一变文件名就变,哈希永远不会过期;顺带还省掉一次跨域连接,对核心网页指标 (https://zhangwenbao.com/page-speed-seo.html)有实打实的帮助。给静态资源加内容指纹而不是加查询串版本号 (https://zhangwenbao.com/remove-the-version-number-after-wordpress-loaded-js-and-css-links.html)本来就是缓存策略里的常规动作,SRI只是又给了一个理由。 ## 第三步:需要跟着更新的,用同强度多哈希做灰度 如果某个第三方确实会更新、你又确实想钉,可以利用同强度多哈希任一匹配这个特性:在对方发版前把新旧两个版本的sha384都写进去,等新版全量之后再把旧的删掉。MDN明确提到这个属性支持多个值就是为了让开发者提供资源的替代版本,同时仍然校验它们的完整性 (https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity)。 三条纪律: - 两个哈希必须同强度。混不同强度只有最强的那一组算数,弱的那条形同虚设。 - 切换窗口要覆盖对方的灰度周期,不是发版当天就能收。 - 这套流程要求你能提前拿到新版本的字节,也就是说只对那些愿意提前告知的第三方成立。做不到,就回到第二步自托管。 ## 第四步:给它配一套本来没有的监控 SRI自己没有上报通道,所以监控得自己搭。三个可行的口子: - 在页面上给关键的第三方脚本挂 onerror,进错误上报。事件对象里没有原因,但至少知道哪个地址在什么时候失败了,配合既有的告警体系 (https://zhangwenbao.com/seo-monitoring-alerting-regression-detection-system.html)能把静默故障变成有声故障。 - 加一个探活脚本,定期下载所有被钉的地址、重算哈希、跟仓库里的值比对。这件事的成本很低——本文的普查脚本核心逻辑就二十来行——但它是唯一能在用户之前发现问题的办法。 - 在业务侧盯那个脚本负责的指标。客服组件的会话发起数、支付按钮的点击率、埋点的上报量,这些数字掉下去的时候,往往比任何技术告警都早。 ## 第五步:上线前的六项自查 最后是一份可以贴在评审清单里的东西: 检查项 | 怎么查 | 写错的后果 | 跨源资源写了crossorigin没有 | 看标签属性,模块脚本除外 | 资源直接被拦 | 服务端发了CORS许可头没有 | 看响应头 | 资源直接被拦 | 算法名是不是三个合法值之一 | 看前缀,注意大小写 | 防护静默失效 | 摘要是base64不是十六进制 | 看长度和字符 | 资源直接被拦 | 多个哈希是不是同强度 | 看前缀是否一致 | 只有最强那组算数 | 写在script或link上没有 | 看元素名和rel取值 | 整个属性被忽略 | 这六项里有四项在控制台会有输出,两项完全没有。而恰恰是没有输出的那两项——算法名写错和写错元素——对应的是防护静默失效,也就是你以为有、其实没有的那种状态。 ## 常见问题解答 ## SRI校验失败会影响搜索引擎抓取和排名吗? 直接的排名影响没有,搜索引擎不会因为一个脚本没执行就降权。但间接影响是存在的:如果被拦的是负责渲染内容的脚本,抓取端拿到的就是一个缺内容的页面,这跟页面靠JS渲染而爬虫没跑起来 (https://zhangwenbao.com/javascript-rendering-seo-csr-ssr-debugging.html)是同一类后果,对那些根本不执行脚本的AI爬虫 (https://zhangwenbao.com/js-rendering-ai-crawler-citation-rate-csr-ssr-isr-divergence.html)影响更直接。另外脚本被拦会把完整的字节下载一遍再丢掉,这笔带宽和等待在性能账上是实打实的支出 (https://zhangwenbao.com/woocommerce-performance-6-layer-lcp-core-web-vitals-real-path.html)。判断方法很简单:用抓取工具渲染一次,看渲染后的HTML里该有的内容在不在。 ## 哈希不匹配和跨源许可缺失,在监控里怎么区分? 看Resource Timing里那条资源的 encodedBodySize。哈希不匹配时内容已经完整交付,这个数字是正常的文件大小;跨源许可缺失时内容根本没交给页面,这个数字是0。两种情况下 onerror 事件长得一模一样,字节数是目前最省事的分辨办法。控制台的措辞也不同,一个说找不到有效的摘要,一个说被跨源策略拦截。 ## 能不能在onerror里去掉integrity重新加载一次当作降级? 技术上可以,但这等于宣布这道防护随时可以被绕过——而且绕过它的正是你自己写的代码。真要做,也只应该用在功能确实不能缺、且已经确认过风险的场景,同时必须把这次降级上报出去,别让它变成一个永远不会有人发现的静默兜底。更稳妥的顺序是先修哈希,降级只作为限时的应急开关。 ## 第三方文档里给的integrity值,可以直接抄吗? 可以抄,但要抄完之后自己验一遍:把文档里那个地址下载下来,重算一遍摘要,跟文档给的值比对。文档更新滞后于发版是常态,尤其是那些把版本号写成范围形式的引入示例——文档里的哈希对应的是写文档那天的补丁版本,而那个地址今天给的可能已经是另一个。 ## 给同一个第三方脚本同时配CSP白名单和SRI,是不是更保险? 两者管的不是一件事,可以叠加,但要清楚各自的边界。内容安全策略的白名单管的是这个脚本能不能从这个域名加载 (https://zhangwenbao.com/csp-script-src-allowlist-drift-third-party-silent-block.html),SRI管的是加载下来的内容是不是你认识的那一份。前者挡不住已经在白名单里的域名换了坏内容,后者挡不住页面从一个全新的域名加载脚本。真正需要注意的是两者叠加之后的排查成本:一个资源没加载,现在有两个可能的原因,而它们在代码层面的表现完全相同。 ## 老站上历史遗留的integrity属性,要不要清理? 先跑一遍巡检,把所有带integrity的地址重算一遍哈希,然后分三类处理:对得上且地址不会变的,留着;对不上的,说明这个资源现在就是被拦着的,先确认业务有没有受影响再决定是更新哈希还是删属性;算法名写错、写在图片或内联元素上的,这些从来没生效过,删掉即可——留着只会让下一个人误以为这里有防护。 ## 权威参考资料 ## 那张对照表就写在页面最上面,一个字也没错,浏览器却说这些名字它一个都不认识 - URL:https://zhangwenbao.com/import-map-bare-specifier-module-resolution-breakage.html - 分类:JS教程 - 发布:2025-12-30 | 更新:2026-07-29 - 摘要:实测import map:一行模块预载就能让整张映射表在某个引擎上作废,内联表还会被禁内联的策略拦掉。含50组双引擎判决矩阵、失效半径三档判据与94站现网普查。 - 关键词:CDN,故障排查,浏览器行为 > **TLDR**:摘要:页面里那张把lodash这类名字翻译成真实地址的表,写对了也可能一个名字都翻不出来。保哥搭了一台真实HTTPS双主机名实验台,把50种写法在两个浏览器引擎上各跑一遍,又扫了94个能抓到首页的线上站点,得到三个反直觉的结论:这张表的失效半径分三档,分界线是错误落在JSON的哪一层;它的有效窗口会被上面一行模块预载标签关掉,而且只在其中一个引擎上关;它必须内联写在HTML里,于是和禁止内联脚本的安全策略天然打架。三种作废方式在页面上长得一模一样,报错文案还都在建议你去改相对路径。 > 摘要:页面里那张把lodash这类名字翻译成真实地址的表,写对了也可能一个名字都翻不出来。保哥搭了一台真实HTTPS双主机名实验台,把50种写法在两个浏览器引擎上各跑一遍,又扫了94个能抓到首页的线上站点,得到三个反直觉的结论:这张表的失效半径分三档,分界线是错误落在JSON的哪一层;它的有效窗口会被上面一行模块预载标签关掉,而且只在其中一个引擎上关;它必须内联写在HTML里,于是和禁止内联脚本的安全策略天然打架。三种作废方式在页面上长得一模一样,报错文案还都在建议你去改相对路径。 先说一个现象。你在页面里写了这么一行: 浏览器打开,控制台报错,功能没了。你检查网络面板,那个文件明明躺在CDN上,手动敲地址能打开。你把地址复制到import后面,一切正常。 于是你得出结论:这个名字不能用,改成完整地址就好了。 这个结论对了一半,也正是麻烦的开始。因为让那个名字能用的东西,是页面上另一个你可能压根没看的标签,它叫import map。而它不生效的原因,有至少五种,其中三种在页面上长得完全一样。 ## 浏览器为什么不认识lodash这样的名字? 这件事得从模块的地址说起。 import后面那个字符串,规范里叫模块说明符。浏览器拿到它以后要做的第一件事,是把它变成一个能发请求的地址。而浏览器认的路只有三条:完整的绝对地址、以斜杠开头的根路径、以点开头的相对路径。 只有这三条。别的一律不认。 lodash-es这种既不带协议也不带斜杠的写法,规范里叫裸说明符。它在服务端的运行时里能用,因为那边有一套约定俗成的目录查找规则,可以顺着依赖目录一层层往上翻。 浏览器没有这套目录,也没打算有。MDN的模块指南把这层意思说得很直白:To use bare names on a browser you need an import map,而且明确指出,如果一个说明符解析不到具体位置,JavaScript会直接抛出TypeError。 顺带划一下边界:搜索引擎那边对模块脚本的处理是另一条线,JS渲染的页面抓不到时该查什么 (https://zhangwenbao.com/javascript-rendering-seo-csr-ssr-debugging.html)讲的是渲染阶段,本文讲的是渲染之前那一步——连地址都还没算出来的时候。 ## 没有表的时候,两个引擎说的话完全不一样 这一点在实验台上很值得看一眼。同样是没有import map、同样import "lib",两个引擎给的报错文案是这样的: 引擎 | 控制台原文 | 引擎A | Failed to resolve module specifier "lib". Relative references must start with either "/", "./", or "../". | 引擎B | The specifier "lib" was a bare specifier, but was not remapped to anything. | 看出区别了吗。引擎B直接告诉你:这是个裸名字,没有被重映射。它把你往import map这个方向推。 引擎A那句话里,一个字都没提import map。它只讲了相对路径必须怎么开头。一个正在排查的人读完这句,最自然的反应就是去把lodash-es改成./node_modules/lodash-es/lodash.js,然后发现路径不对,再翻构建配置,再翻发布流程。 而真正的问题可能只是那张表被上面一行标签挡住了,或者被安全策略拦掉了,或者根本就没通过JSON解析。 > 同一个故障,两个引擎给的第一句提示,一个指向根因,一个指向岔路。而大多数人排查前端问题时手边开的是哪一个,基本是习惯决定的。 ## 这张表长什么样 它就是一段内联的JSON,写在 结构简单得不像会出事的东西。左边是你在代码里写的名字,右边是浏览器实际去请求的地址。带斜杠结尾的键是前缀映射,utils/format.js会被拼成后面那个目录加上format.js。 问题不在语法。问题在于这张表被塞进了一个它并不适合待的位置:它是配置,却只能写成HTML;它服务于整个应用,却受制于页面上其他标签的先后顺序;它需要多个团队协作维护,规范上却长期只允许一份。 接下来这几节,全部是围着这三件事转的实测。 ## 一份表写坏了,是整站瘫痪还是少一个功能? 这是我最想搞清楚的一件事。因为它直接决定排查时该往哪个方向看。 实验台里我准备了三种写坏的方式,都很像真人会犯的错,然后看剩下的名字还能不能用。 ## 第一档:JSON语法错,整张表作废 少一个右花括号,就这么简单。两个引擎的反应完全一致,都在控制台抛出一个SyntaxError: Failed to parse import map: invalid JSON 紧接着是第二条报错,说lib解析不出来。而这张表里原本还有另外几个名字,此刻也全都解析不出来了。 这一档的好处是响亮。它是error级别,会触发window.onerror,前端监控只要挂了全局错误采集就能收到。坏处是范围最大:一个逗号,所有裸名字同时阵亡。 ## 第二档:结构类型错,同样整张表作废 把imports写成数组而不是对象,JSON本身是合法的,但结构不对。两个引擎依然是整张表丢掉: 引擎 | 控制台原文 | 引擎A | Failed to parse import map: "imports" top-level key must be a JSON object. | 引擎B | the imports top-level key needs to be a JSON object | 这一档依然是error,依然能被监控抓到。到这里为止,事情都还算讲道理。 ## 第三档:单条值不是合法地址,只作废那一条,而且只吐一个warning 这一档是真正会咬人的。 表里有两个名字,其中一个的值写成了不能解析成地址的东西,另一个完全正确。实测结果:正确的那个照常工作,页面表面上一切正常,控制台只留下一条warning。 引擎 | 控制台原文 | 级别 | 引擎A | Ignored an import map value of "broken": Bare specifier: not a url at all | warning | 引擎B | Address "not a url at all" was invalid. | warning | warning不触发window.onerror,不进任何默认的前端错误采集。它只是静静地待在控制台里,等一个愿意主动去看的人。 而那条坏掉的映射并没有消失,它变成了一个null。等哪天真有代码去import那个名字,报错是这样的: 引擎 | 控制台原文 | 引擎A | "broken" matches with "broken" but is blocked by a null value | 引擎B | Resolution of specifier "broken" was blocked by a null entry. | 请注意这句报错的措辞。它说的是被一个null条目挡住了,一个字都没提你那行地址当初写错了。上线那天的warning早就随着标签页关掉了,此刻站在控制台前的人,看到的是一个来路不明的null。 ## 把三档并排放,规律就出来了 写错的位置 | 失效范围 | 控制台级别 | 监控能不能收到 | JSON语法 | 整张表 | error | 能 | 顶层键的类型 | 整张表 | error | 能 | 单条映射的值 | 只有那一条 | warning | 不能 | MDN在这张表的文档里给了一句总结:Browsers generate console warnings for other cases where the import map JSON does not conform to the import map schema。翻译过来就是,只要JSON本身能解析,剩下不合规范的部分一律降级成warning。 这条规律有一个不太舒服的推论:错得越离谱,你越早知道;错得越像样,你越晚知道。把地址写成一坨乱码,那是语法层面的事故,当场炸开;把地址写成一个少了协议头的域名,那是值层面的瑕疵,安安静静躺进表里,等一个季度以后某次灰度发布把那个名字用上,才第一次露面。 ## 顺手把值的几种边界也测了 - 值写成相对路径,比如./assets/lib.js:可用,按写这张表的那个文档的地址来解析。 - 值写成另一个裸名字,指望它再翻一次:不可用。映射不递归,一次就到底。 - 值显式写成null:等价于第三档那种作废,报的也是blocked by a null entry。 - 同一个键在JSON里写了两遍:后写的赢。这是JSON解析本身的行为,两个引擎一致。请记住这个方向,下面还会用到它,而且会翻转。 ## 那张表到底得写在哪一行之前? MDN那句话说得很清楚:这张表must be declared and processed before any "; ?> 直觉判断:这段会让浏览器弹出两行的提示。实际跑起来弹窗里只有一行 "第一行第二行"——两行被挤到一起、中间那个换行符没了。 ## "查看源代码"看到的真相 打开浏览器 → 右键 → 查看页面源代码,看 PHP 输出到 HTML 里的脚本变成了: 注意——\n 在 PHP 双引号字符串里被解析成了一个真正的换行字符(ASCII 0x0A),它直接"物理换行"到了输出里。JavaScript 引擎在解析这段脚本时,遇到一行字符串中间有个回车符,根据 ES5 规范字符串字面量不能跨行,结果有的引擎吞掉换行变成空白、有的引擎抛 SyntaxError。两种行为都不是开发者想要的。 ## 问题的本质:两次解析的转义层级 这段代码经过两次解析: - PHP 解析(服务器端):PHP 看到 "...第一行\n第二行...",把 \n 解码成 0x0A,输出到 HTTP 响应里的就是物理换行字符。 - JavaScript 解析(浏览器端):浏览器拿到 alert('第一行(换行)第二行'),按 JS 字符串字面量规则处理,遇到中间的换行就报错或吞掉。 正确的目标应该是:让 PHP 输出的 HTML 里保留 \n 这两个字面字符,浏览器再用 JS 引擎把它解释成换行。 ## 双引号字符串的两次转义解剖 ## PHP 双引号字符串的转义规则 PHP 双引号字符串支持的转义序列: 转义 | 含义 | \n | 换行符 LF(0x0A) | \r | 回车符 CR(0x0D) | \t | 水平制表符(0x09) | \v | 垂直制表符(0x0B) | \e | 转义符(0x1B) | \f | 换页符(0x0C) | \\ | 一个反斜杠 | \$ | 美元符号字面量 | \" | 双引号字面量 | \xNN | 十六进制字符 | \NNN | 八进制字符 | \u{NNNN} | Unicode 代码点(PHP 7+) | 这一步发生在 PHP 解析源代码时,输出到 HTTP 之前就完成了所有转义。 ## JavaScript 字符串的转义规则 JavaScript 字符串字面量也支持类似的转义序列。但 JS 引擎拿到的字符串内容必须是源代码里有 \ 加 n 这两个字面字符,它才会执行 JS 自己的那次转义。如果它接收到的字符串字面量已经包含真正的换行字节,行为不可预期。 ## 正确的写法 要在 PHP 双引号字符串里输出"反斜杠加 n"两个字符,就必须写 \\n——第一个 \\ 经过 PHP 转义变成一个反斜杠,加上后面的 n,最终输出的就是 \n 这两个字符: alert('第一行\\n第二行');"; ?> 查看源代码看到的输出: JavaScript 引擎读到 \n 把它解释成换行符,alert 弹出两行。 ## 用一个表格说清"目标输出 vs 在 PHP 里要写什么" 想让 JS 收到的字面字符 | PHP 双引号写法 | PHP 单引号写法 | \n(换行转义) | "\\n" | '\n' | \\(一个反斜杠) | "\\\\" | '\\\\' | \"(一个双引号) | "\\\"" | '\"' | \t(制表符转义) | "\\t" | '\t' | 双引号比单引号多一层转义负担,因为 PHP 会先解释 \。所以单引号写"PHP-JS 混合字符串"通常更省心。 ## 单引号字符串:少一层麻烦 ## 单引号字符串的转义规则简单 PHP 单引号字符串只识别两个转义序列:\\(反斜杠)和 \'(单引号),其他反斜杠都按字面量保留。所以下面这段直接成立: alert("第一行\n第二行");'; ?> 这里的 \n 在 PHP 看来就是普通的两个字符 \ 和 n,原样输出给浏览器,JS 再把它解释成换行。我个人在写短小的弹窗脚本时倾向用单引号包裹外层 PHP 字符串、双引号包裹内层 JavaScript 字符串,避免重复反斜杠看花眼。 ## 单引号字符串的局限:变量插值不支持 PHP 单引号字符串里 $variable 不会被解析为变量值,会原样输出 $variable。所以一旦内容来自变量,必须改用双引号或 . 拼接: alert("' . $msg . '\n请刷新页面");'; ?> 这种写法看起来 OK,但有一个隐藏的安全坑——如果 $msg 来自数据库或用户输入,包含一个英文单引号或双引号就会把整段脚本打穿。比如用户名是 O'Brien,输出就变成: JavaScript 字符串字面量被双引号包裹时单引号 ' 不会引发问题。但如果 $msg 包含双引号 ",整段就崩了。这就引出下一节最稳的方案。 ## 最稳的做法:用 json_encode 一劳永逸 ## json_encode 是 PHP-JS 边界的官方序列化器 所有从 PHP 往 JavaScript 传值的场景,能用 json_encode 就用 json_encode,少手写引号转义。json_encode 输出的就是合法的 JavaScript 字面量,换行、引号、Unicode 字符全部自动处理: alert(" . json_encode($message, JSON_UNESCAPED_UNICODE) . ");"; ?> 这段代码里: - $message 是一个 PHP 字符串,里面的 \n 是真正的换行符(PHP 双引号已转义)。 - json_encode 拿到字符串,输出的 JSON 字面量自动把换行符编码成 \n,并用双引号包裹。 - 最终浏览器接收到 alert("第一行\n第二行\n第三行");,JS 解释执行后弹三行提示。 ## json_encode 标志位详解 常量 | 作用 | 建议 | JSON_UNESCAPED_UNICODE | 中文不被编码成 \uXXXX | 建议加,调试时直观 | JSON_UNESCAPED_SLASHES | 正斜杠 / 不被转义为 \/ | URL 字段建议加 | JSON_HEX_TAG | < 和 > 编码为 < > | 嵌入 HTML 时强烈建议加 | JSON_HEX_APOS | 单引号编码为 ' | 嵌入单引号属性建议加 | JSON_HEX_QUOT | 双引号编码为 " | 嵌入双引号属性建议加 | JSON_HEX_AMP | & 编码为 & | 嵌入 HTML 建议加 | JSON_PARTIAL_OUTPUT_ON_ERROR | 编码失败时输出可解析的占位 | 调试别加,生产可加 | 嵌入 HTML 的 inline 脚本时建议组合用 JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE。这样输出的 JSON 字符串里不会有 < > & 等会被 HTML 解析的字符,避免 XSS 风险。 ## 封装成可复用函数 alert({$payload});"; } js_alert("操作成功\n3 秒后将自动跳转"); ?> 把它放进项目的公共函数库,每次需要弹窗调一行。 ## 动态拼接 JS 的安全风险 ## XSS 攻击向量 动态拼接 JS 字符串如果不做严格转义,几乎都是 XSS 漏洞的温床。考虑这段代码: alert('欢迎,{$username}');"; ?> 攻击者构造 URL ?name=');alert(document.cookie);//,输出就变成: JavaScript 引擎执行后第一个 alert 显示空字符串,第二个 alert 弹出当前页面的所有 cookie——一个标准的 XSS。所有动态拼接 JS 字符串的地方都有这种风险。 ## 正确防御:还是用 json_encode alert('欢迎,' + {$encoded});"; ?> 无论 $username 包含什么字符(单引号、双引号、反斜杠、HTML 标签、JS 代码),json_encode 都会安全转义,攻击者无法逃逸字符串边界。 ## 更彻底:用 data 属性 + JS 读取 最现代的做法是把数据放在 HTML data-* 属性里,再用 JS 读取:
这样 PHP 只负责输出 HTML(用 htmlspecialchars 防 XSS),JS 完全静态——没有动态拼接 JS 字符串的步骤,从架构上消除了这类漏洞。 ## 几个常见衍生坑 ## 把 \n 误写成 br 标签{{ message }} 用 pre 标签保留空白。
- React:style={{whiteSpace: 'pre-line'}} 配合 div。
- 通用:把消息按 \n split 成数组,逐项渲染成 。 ## 原生 alert 的 UX 问题 2020 年后 Chrome 和 Firefox 限制了 iframe 内的 alert/confirm/prompt——跨源 iframe 默认禁用,同源 iframe 也提示用户"此网站正在尝试显示对话框"。生产环境建议彻底用 SweetAlert2 / Toast / Notification 等现代 UI 替代原生 alert。 ## 现代化路线:把动态 JS 拼接彻底淘汰 ## CSP 严格模式禁止 inline script 2024 年的 Web 安全最佳实践要求站点开启 Content Security Policy(CSP)的严格模式,禁用 inline script: Content-Security-Policy: script-src 'self' 'nonce-RANDOM_VALUE' 开启后所有 内联脚本都会被浏览器拒绝执行。这就强迫所有动态数据通过 data 属性 + 外部 JS 文件读取——架构上消除了 inline 脚本拼接的需求。 ## SPA 框架接管 用 Vue / React / Svelte 写前端,PHP 只暴露 RESTful API 返回 JSON。前端拿到 JSON 后由框架的双向绑定渲染,完全没有"PHP 拼接 JS 字符串"这一步。这是 2020 年后所有新项目的默认架构。 ## Server-Side Rendering 框架的 JSON 注入 Next.js / Nuxt / Astro 等 SSR 框架的标准做法:把数据放在 里,前端 JS 读取这个 script 标签的 textContent 然后 JSON.parse。这种方式既保留了 SSR 的 SEO 优势,又避免了 inline JS 的所有问题。 ## 常见问题解答 ## 为什么有些教程里写两个反斜杠就够了不用四个 那是因为他们用的是 PHP 单引号字符串,单引号里 \ 不会被 PHP 二次转义所以写 \n 就是字面上的反斜杠加 n 两个字符。换成双引号字符串就必须写 \\n,因为 PHP 会先把 \\ 折叠成一个反斜杠。 ## alert 弹窗里能显示 HTML 标签吗 不能。浏览器原生 alert、confirm、prompt 三个弹窗都不解析 HTML,只识别纯文本和 \n 换行。如果需要更丰富的样式请改用 SweetAlert、Layer、Bootstrap Modal 这类基于 DOM 的弹层组件。 ## 能不能直接在 PHP 里写真实的换行 字符串里的物理换行会让 JavaScript 字符串字面量提前结束,浏览器抛 SyntaxError 错误。如果一定要在源代码里多行书写,需要用 ES6 模板字符串(反引号包裹),但模板字符串只在浏览器端 ES6+ 引擎里能用。一般情况下还是把换行写成 \n 转义最稳。 ## 用 htmlspecialchars 处理后再丢进 alert 行得通吗 不行反而更乱。htmlspecialchars 会把双引号转成 "、单引号转成 ',这些 HTML 实体在 JavaScript 字符串里完全不被识别,弹窗里会原样出现 " 这种乱码。处理给 JavaScript 用的字符串请用 json_encode 不要用 htmlspecialchars。htmlspecialchars 是给 HTML 文本节点和属性用的,不是给 JS 字符串用的——这两个上下文的转义规则完全不同。 ## json_encode 输出的 JSON 在 inline script 里安全吗 不完全安全,需要加额外标志位。默认 json_encode 不会转义 < > 等字符,攻击者可以构造包含 的输入来逃逸 script 标签。必须加 JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT 才安全。或者更稳妥的做法是用 把数据包裹起来,前端用 JS 读取再 JSON.parse。 ## UTF-8 多字节字符在 alert 里显示乱码怎么办 三个排查方向:(1) PHP 文件本身必须是 UTF-8 编码(无 BOM (https://zhangwenbao.com/notepad-edit-saved-code-generate-bom-resulting-web-page-error-white-screen-solution.html)),用 VS Code 打开看右下角;(2) HTTP 响应头必须有 Content-Type: text/html; charset=UTF-8,可以用 header() 设置;(3) 数据库连接的字符集必须是 utf8mb4,PDO 在 DSN 里指定 charset=utf8mb4。三个层级任一不对都会乱码。 ## alert 弹窗能不能自动关闭 原生 alert 不能。它是阻塞的同步调用,必须用户点击"确定"才返回。要做"3 秒后自动消失"的提示请用 SweetAlert2 的 timer 选项或者自己写一个 div 加 setTimeout。原生 alert 的 UX 在移动端非常差,建议生产项目彻底替换掉。 ## 异步代码里弹窗换行还要这么处理吗 异步代码里数据通常通过 fetch 拿到的 JSON 解析得到,已经是 JS 字符串,\n 由 JSON 反序列化时自动解析。所以异步场景下完全不需要手动处理换行: fetch('/api/message') .then(r => r.json()) .then(data => alert(data.message)); // data.message 里的 \n 已是真换行 异步路径更现代、更安全、更省心,建议尽量把同步 alert 拼接改成异步。 ## Laravel/ThinkPHP 框架里有专用 helper 吗 Laravel 没有内建的 alert helper,但社区有 laravel-flash 之类的包。ThinkPHP 5/6 的 view() 模板引擎支持 $msg|json 修饰符自动 json_encode。Yii、CodeIgniter 也都有类似机制。框架内的最佳实践都是 flash session + 视图层渲染,避免在 controller 里直接 echo JS 拼接字符串。 ## 总结:把这个小问题升级到架构问题 "PHP alert 换行不显示"这个看起来一行代码的小坑,背后是字符串经过两次解析的转义边界、HTML 与 JS 上下文不同的转义规则、动态拼接 JS 的 XSS 风险、现代 Web 安全 CSP 严格模式的趋势。完整的解题路径应该是: - 临时调试:双引号配 \\n、单引号配 \n。 - 项目代码:用 json_encode 加完整 HEX 标志位。 - 架构升级:用 data 属性 + 外部 JS 替代 inline script,开 CSP 严格模式。 - 新项目:直接 SPA + RESTful API,从根本不再有"PHP 拼接 JS"这件事。 从一个换行符的小问题看下去,能看到整个 PHP-JS 通信的演进史。下次再遇到类似看起来很小的转义问题,不妨从架构层面问一句"这件事是不是本来就不该用动态拼接做"——很多时候答案是"是"。 ## 实战参考:把这套规则写进 code review 检查项 给团队做 code review 时围绕"PHP 输出 JS 字符串"这个话题我会扫这几条: - 有没有 echo "" 这种直接插值的拼接?有就要求改成 json_encode。 - 有没有 inline script 在使用了 CSP 的项目里?有就要求改成外部 JS + nonce。 - 有没有 alert / confirm / prompt 直接出现在生产代码里?有就要求评估是否改用现代 UI 组件。 - 有没有 htmlspecialchars 用在 JS 字符串上?这是常见误用,要求改 json_encode。 - 有没有用 document.write 输出动态 HTML?这是 2010 年代写法,现代浏览器警告且性能差,要求改 DOM API。 - 用户输入的字符串送到 JS 之前是否经过 json_encode + 完整 HEX 标志位?没有就是潜在 XSS。 这六条加进 PR 模板的"前端安全"板块,几个月下来团队里基本不会再写出这类问题。文章开头那个 \n 不显示的小坑,本质上是这六条没有形成团队共识时反复出现的症状。 ## JavaScript实战相关阅读 同主题集群覆盖JS移动端跳转、按钮刷新、原生轮播等实战代码: - JS移动端判断跳转m子域 (https://zhangwenbao.com/js-judges-mobile-automatically-jumps-to-mobile.html)——Client Hints+SEO最佳实践 - button按钮刷新页面8种JS写法 (https://zhangwenbao.com/button-refresh-page.html)——不同场景对比实战 - JS幻灯片轮播自适应屏宽+触屏滑动 (https://zhangwenbao.com/js-slide-screen-width-support-adaptive-sliding-touch-screen-mobile.html)——120行原生现代实现 ## button按钮刷新页面的8种JavaScript写法实战 - URL:https://zhangwenbao.com/button-refresh-page.html - 分类:JS教程 - 发布:2018-11-20 | 更新:2026-06-02 - 摘要:button按钮刷新页面的八种JavaScript写法对比:location.reload、location.assign、location.replace、window.open到IE专属execWB写法的适用场景、缓存策略差异、表单提交弹窗规避、SPA路由兼容、移动端踩坑全解析,附React与Vue生产代码模板。 - 关键词:JavaScript,CDN,缓存 > **TLDR**:摘要:用button刷新页面,JavaScript有八种写法。本文概览全部写法,重点对比location.reload的强刷与软刷、assign与replace的关键差异、window.open在SPA里的特殊用途,点明已过时的IE专属写法不要再用,再讲用button刷新时常被忽视的安全细节、性能与缓存对比,附保哥的推荐选型和React与Vue的生产代码模板。 > 摘要:用button刷新页面,JavaScript有八种写法。本文概览全部写法,重点对比location.reload的强刷与软刷、assign与replace的关键差异、window.open在SPA里的特殊用途,点明已过时的IE专属写法不要再用,再讲用button刷新时常被忽视的安全细节、性能与缓存对比,附保哥的推荐选型和React与Vue的生产代码模板。 保哥写前端这些年,被问得最多的一个看似简单却暗藏玄机的问题,就是怎么用一个 button 按钮触发页面刷新。表面上看 location.reload() 一行代码就能搞定,但真正写进项目里,你会遇到强刷与软刷、GET表单与POST表单刷新、跨浏览器兼容、防止表单重复提交等一连串细节。 这一篇保哥把自己常用的八种刷新写法整理出来,并附上每种写法的适用场景、坑点、性能对比和个人推荐顺序,最后给出一套生产级别的代码模板和常见踩坑案例。 ## 八种主流的 button 刷新页面写法概览 先把所有写法一次性铺开,方便你对照查阅。下面这段代码包含了八种常见做法,对应的场景在后文会逐一展开: 保哥强调一句:八种写法里只有第一、第三、第六、第七这四种是现代浏览器(Chrome、Edge、Firefox、Safari)都支持的,其余四种(写法二、四、五、八)要么依赖已经退场的IE,要么在严格模式下有隐患,生产代码请优先使用前四种。 ## 写法一 location.reload 的强刷与软刷 location.reload() 是最常见也最语义化的刷新写法,但它有一个鲜为人知的参数: 传入 true 表示无视本地缓存,从服务器重新拉取所有资源,相当于按下 Ctrl+F5。注意 reload(true) 这个布尔参数虽然在 MDN 文档里被标记为非标准,但 Chrome、Firefox、Safari 直到目前都仍然支持,保哥在生产中用了七八年没遇到兼容问题。如果你需要严格遵循标准,可以改用下面的写法绕一圈: function hardReload() { const url = new URL(location.href); url.searchParams.set('_t', Date.now()); location.replace(url.toString()); } 通过给URL拼一个时间戳参数,让浏览器认为这是一个全新的资源,从而绕过缓存。这种做法在严格按照W3C规范的项目里更安全。如果你的页面已经使用了 query string 做业务逻辑,注意时间戳参数名要避免冲突——保哥习惯用 _t、_r 这种带下划线前缀的临时参数名。 ## 写法三和写法六 assign 与 replace 的关键差异 这两个方法都能把当前地址重新加载一次,但对浏览器历史栈的影响完全不同。 // assign 会在历史栈里压一条新记录 location.assign(location.href); // replace 会把当前历史记录覆盖掉 location.replace(location.href); 保哥的判断标准是这样的:如果用户点刷新之后还希望按浏览器后退按钮回到当前页之前的状态,用 assign;如果你做的是表单提交成功页或支付结果页,不希望用户后退回来再次触发提交,用 replace。后者还有一个隐藏好处——它在某些浏览器里不会触发是否重新提交表单的弹窗。 性能层面两者基本没有差异,都会触发完整的网络请求。差别仅在历史栈和某些边缘行为(比如 referrer 头、前进后退按钮可用性)。保哥踩过一次坑:在支付回执页用了 assign,用户支付成功后点刷新,再按后退键又回到了支付页,重复支付了一次。从此之后所有"提交成功页"统一改用 replace。 ## 写法七 window.open 在 SPA 中的特殊用途 window.open(location.href, '_self') 看上去像是开一个新窗口然后变成自己,听着绕,但它在 SPA(单页应用)里有一个非常实用的场景:彻底卸载 React、Vue 的运行时状态。 function fullReset() { // 清掉本地状态,再用 _self 重新装载页面 sessionStorage.clear(); window.open(location.href, '_self'); } location.reload() 在 SPA 里也能刷,但有些前端路由库会拦截 reload 事件,导致刷新行为被劫持。保哥见过 vue-router 加 history mode 的项目里,location.reload 被 service worker (https://zhangwenbao.com/service-worker-cache-api-offline-pwa-strategies.html) 拦截后只刷新了部分组件状态,造成"刷新了但又没完全刷新"的诡异 bug。window.open(..., '_self') 是浏览器原生导航,绕过了所有 JS 路由钩子,保哥在排查刷新无效的诡异 bug 时经常拿它当兜底。 另一个少有人知的用途是在 iframe 里触发父窗口刷新: // 在 iframe 内部调用,刷新父窗口 window.parent.location.reload(); // 跨域 iframe 无法直接访问父级 location,要用 postMessage window.parent.postMessage({ type: 'reload' }, '*'); // 父窗口监听 window.addEventListener('message', (e) => { if (e.data && e.data.type === 'reload') location.reload(); }); ## 已经过时的 IE 专属写法不要再用 写法二、写法四、写法五、写法八都和 IE 浏览器有不解之缘。 location = location 利用了 JS 引擎对 location 属性赋值会重新加载的特性。它在 Chrome 里也能跑,但严格模式下会被 lint 工具标红,并且代码可读性极差,新人 review 完全看不懂意图。保哥的代码规范里直接把这种写法列入 ESLint 黑名单。 document.execCommand('Refresh') 来自 IE 的 contentEditable 时代,现在 execCommand 整体都被 W3C 标记为 deprecated,连富文本编辑都不推荐用它了,更别说刷新页面。Chrome 在 119 版本之后逐步停止支持大部分 execCommand 命令,依赖它的代码会逐渐失效。 window.navigate 是 IE 的私有 API,从 Edge 切换到 Chromium 内核之后已经彻底消失。如果你的代码里还有这一行,等于在新 Edge 里直接报 ReferenceError 中断后续脚本执行。 document.all.WebBrowser.ExecWB(22,1) 这一句更夸张,它依赖嵌入在页面里的 ActiveX WebBrowser 控件,只有 IE 才支持,并且需要降低安全设置才能跑。保哥的建议非常直接:如果你的代码库里还有这四种写法,请尽快迁移到 location.reload()。批量替换一行 sed 就能搞定: find . -name "*.html" -o -name "*.js" | xargs grep -l "ExecWB\|window.navigate\|execCommand('Refresh')\|location = location" \ | xargs sed -i.bak \ -e "s/document.all.WebBrowser.ExecWB(22,1)/location.reload()/g" \ -e "s/window.navigate(\([^)]*\))/location.assign(\1)/g" \ -e "s/document.execCommand('Refresh')/location.reload()/g" \ -e "s/location = location/location.reload()/g" 跑完之后逐个 diff 确认,没问题再删 .bak 文件。保哥用这个方法在一个 IE 时代的老项目里一次性清理掉了 200 多处过时写法。 ## 用 button 刷新时常被忽视的安全细节 看上去人畜无害的刷新按钮,在以下几种场景里会埋雷。 第一个雷是 POST 表单的"是否重新提交"弹窗。如果当前页面是通过 POST 请求渲染的,调用 location.reload() 时浏览器会弹出确认框。解决办法是后端在 POST 处理完成后用 303 重定向到一个 GET URL(即 PRG 模式:Post-Redirect-Get),前端再刷这个 GET URL 就不会弹框了。这是后端 + 前端配合的事,纯前端无法绕开浏览器的安全机制。 第二个雷是