多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Bookmarklet踩坑实录:%0A换行符如何让洛谷路由崩溃

Bookmarklet踩坑实录:%0A换行符如何让洛谷路由崩溃 如果你写过 Bookmarklet大概率遇到过这种诡异情况脚本在本地调试一切正常做成书签发给朋友后要么点了没反应要么在某个特定网站上报一个八竿子打不着的错。最近我就被“洛谷提交显示 提交失败无法解析路由对象”整了一晚上最后排查下来罪魁祸首竟然是 URL 编码里的%0A——一个肉眼几乎不可见的换行符。这篇文章把完整的排查链路、JavaScript 解析原理、以及一套跨环境兼容的改造方案分享出来适合所有写过或正在写 Bookmarklet、油猴脚本以及需要在多个浏览器、多个平台之间复用同一段前端脚本的人。1. 故障现场一次让我怀疑人生的“无法解析路由对象”报错1.1 书签脚本好好用了半年突然说炸就炸我一直维护一个 OI 刷题效率工具核心功能很简单在本地把代码写完点一下书签按钮自动把代码填充到洛谷的在线编辑器里然后触发提交。这个脚本本质就是一个 Bookmarklet源码压缩成一行以javascript:协议存在收藏夹里用的时候点一下靠浏览器在当前页面上下文执行。前后稳定跑了快半年直到某天用户群里有人发了一条消息提交代码时页面弹出一个红色错误提示内容写的是“提交失败无法解析路由对象”。我第一反应是洛谷改版了提交接口变了或者前端路由升级导致旧脚本调用的某个全局对象被移除。于是赶快自己打开洛谷手动跑了一遍脚本结果在 Chrome 上一切正常代码正常填进编辑器正常提交没有任何报错。但群里陆续又有两三个人说遇到同样的问题。这就奇怪了同一份 Bookmarklet有的人正常有的人报错还有人说直接在地址栏粘贴执行就没事但从收藏夹里点就会挂。这种“和环境强相关”的现象已经不能用“洛谷改版”来解释了。1.2 表面看哪都正常实际上哪都不对我开始做交叉验证。先在自己电脑上用 Firefox 打开洛谷点击书签结果顺利复现了“提交失败无法解析路由对象”。接着用 Safari 试也能复现。用 Edge 呢正常。这个时候我基本确定了不是洛谷的锅是我的 Bookmarklet 在某些浏览器环境里跑挂了。但更让我费解的是脚本挂掉之后页面并没有马上报错而是等我去点提交按钮的瞬间才弹错误说明脚本在注入阶段留下了某个“半初始化”状态真正干活的时候才爆雷。我把收藏夹里的 Bookmarklet URL 完整导出来复制到一个临时文件里用decodeURIComponent做了一次还原。看到还原结果的那一刻我整个人清醒了脚本里一段用双引号写的多行代码模板在源码中直接包含了真实换行经过 URL 编码后变成了%0A再被浏览器解码回真实换行于是双引号字符串被拦腰截断JS 引擎直接抛了一个Unterminated string literal。1.3 洛谷的报错与 %0A 的关联链路这里要解释一下洛谷报错和换行符之间的关联。Bookmarklet 在页面里执行时本质是往当前页面上下文注入一段 JavaScript。如果脚本中途因为语法错误崩掉那么脚本后续要做的初始化动作比如挂载提交按钮的事件代理、准备待提交的数据对象全都会跳过。页面表面上看起来没变化但已经处于一个“残缺”状态。之后用户点击洛谷自己的提交按钮前端路由层尝试把当前状态序列化并跳转到提交结果页结果发现自己需要的数据对象是undefined或者格式非法于是前端路由解析器报出“无法解析路由对象”。简而言之一个换行符让脚本先崩脚本崩了让页面状态坏掉页面状态坏掉让路由解析失败。它是个间接连环崩。这个定位过程让我意识到%0A这个看起来人畜无害的百分号编码是所有 Bookmarklet 开发者迟早会踩到的一个大坑而且它在不同浏览器、不同执行方式下的表现完全不一样。2. 拆解 %0A 的完整解码链路四种命运背后的 JavaScript 语法原因2.1 Bookmarklet 的执行模型先理清 Bookmarklet 的执行模型。你在收藏夹里存的是一个以javascript:开头的 URL比如javascript:alert(hello);当你点击这个书签时浏览器会把javascript:协议后面的字符串取出来先对整段 URL 做百分号解码和decodeURIComponent类似得到一段 JavaScript 源码文本再把这段源码交给 JS 引擎执行。这个流程在主流浏览器里是一致的但具体到解码时机、解码粒度、以及哪些字符会被解码不同浏览器之间是有差异的。问题就出在这个“百分号解码”上。你的源码里如果有换行在 URL 里通常被编码成%0A但如果你是在一个不支持完整 URL 解码的环境里执行比如直接对源码字符串调用eval%0A就只是字面上的三个字符永远不会变成换行。同一个字符串经不经过 URL 解码最终到达 JS 引擎的文本完全不同。这就是%0A跨环境不兼容的根本来源。2.2 命运一换行落在普通字符串字面量里直接 SyntaxError最典型、也最致命的一种情况%0A解码成真实换行后落在了某个普通字符串字面量内部。看这个例子。你开发时写的源码是这样的var template function solve() { const a readline(); console.log(a); };这段代码在你本地编辑器里是合法的吗不是。JS 的双引号字符串里根本不允许出现裸换行一旦出现就是Unterminated string literal。所以能写出这种源码通常是因为你有一个“多行模板生成器”它把多行文本拼到双引号字符串里然后再做 URL 编码。于是真实换行在 URL 层被编码成%0AURL 本身看着是合法的。问题是浏览器在点击书签执行时会把%0A解码回真实换行。于是 JS 引擎接收到的源码变成了var template function solve() {字符串在第二行行首就被判定为未闭合直接抛出一个语法错误。整个脚本不执行。这就是我这次洛谷事故的直接原因。2.3 命运二换行落在模板字符串里虚惊一场如果你用的是反引号模板字符串情况又会不一样var template function solve() { const a readline(); console.log(a); };URL 编码之后里面的真实换行同样变成%0A浏览器解码之后换行又被还原回来。但模板字符串是允许包含真实换行的所以这段代码在解码后依然合法字符串的 value 就包含这些换行。这就是为什么很多开发者会说“我这边明明是好的”——他写的多行文本恰好都放在模板字符串里于是%0A解码后落在合法位置不炸。但这不代表他没问题只是问题还没暴露在别的位置比如正则字面量或者return语句后面。2.4 命运三整个 URL 没有被解码%0A 变成三个字面字符还有一种情况你拿到源码不是通过点击 Bookmarklet而是把代码直接贴到 DevTools 控制台里跑。比如你在控制台执行eval(alert(a%0Ab));控制台上的eval不会做什么 URL 解码%0A就是%、0、A三个字符。于是弹窗里显示的是a%0Ab而不是带换行的字符串。不报错但结果完全不对。这种差异会导致一个很迷惑的现象同一段 Bookmarklet你把它从书签里复制出来贴进控制台调试发现“数据不对”但通过点击书签执行又发现“语法报错”。两边行为不一致但其实都是同一个%0A在作怪只是不同执行环境对它的处理路径不同。2.5 命运四换行与 ASI 相爱相杀不报错但是错换行还有一个更隐蔽的坑自动分号插入规则。JavaScript 解析器在很多情况下会根据换行自动推断语句结束其中最著名的就是return。看这段代码function getValue() { return 42; }你以为是return 42实际因为return后面紧跟换行ASI 规则会在return后面自动补一个分号函数直接返回undefined42成了永远执行不到的死代码。如果把这段代码压进 Bookmarklet你就需要特别小心源码里如果存在一个跨行的returnURL encode 时换行变成了%0A解码后又变回换行于是这个函数的行为就和你的预期完全不一样了。不报错但结果是错的。这种错比语法错误更难排查因为你找不到任何红字只能觉得“函数返回了 undefined”。2.6 正则、行注释、\r 的同伙们除了字符串和 ASI还有几个和换行相关的语法陷阱哪怕没直接出现在你的字符串里也可能在某天咬你一口。正则字面量里的裸换行是直接非法的。如果你的 Bookmarklet 代码里有一段/[\s\S]/压缩后没有换行没问题但如果你为了让正则可读在正则内部手动换行编码解码之后正则字面量也会因为裸换行抛错。行注释和换行的搭配也会产生“吞代码”的效果。书签脚本为了省空间经常会把代码压成一行但如果你这一行里写了//开头的行注释那注释后面的所有代码都会被吞掉因为一个压缩行里根本没有换行来结束这条注释。如果你构建时保留了多行注释依赖那更是灾难。\r也不能无视。Windows 环境下的换行通常是\r\nURL 编码后是%0D%0A。有些构建脚本只处理了\n漏掉了\r结果在部分环境下解码后字符串中间多出一个回车符表现又不一样。后面我会专门讲怎么在构建期拦掉\r。3. 三步定位法不靠猜把 Bookmarklet 里的 %0A 揪出来3.1 第一步把最终 URL 还原成可读代码遇到 Bookmarklet 行为异常第一步不是去改逻辑而是把真正交给浏览器的代码还原出来看。我通常直接在浏览器控制台里执行const url javascript:(function(){var template\...\;})(); // 如果是从收藏夹导出的 URL先去掉 javascript: 前缀再百分号解码 console.log(decodeURIComponent(url.replace(/^javascript:/, )));这样一来你在收藏夹里存的是一串编码后看不出结构的 URL还原之后就能看到 JS 引擎真正要解析的源码。重点观察源码里有没有真实换行被塞进双引号字符串里、正则里、或者return后面。你不需要把所有代码都看懂只需要做一件事盯着字符串字面量和行注释看有没有裸换行。如果一段长字符串被活生生截成两行而且行尾没有反斜杠续行那基本实锤了。3.2 第二步最小化二分定位崩溃点如果你拿到还原后的源码发现结构比较复杂不好判断具体是哪一行炸的可以用“最小化二分”来缩小范围。先把整个 Bookmarklet 缩减成几个测试用例逐一验证// 测试 1没有任何换行的基础脚本 javascript:alert(ok); // 测试 2多行字符串用双引号包裹 javascript:alert(line1 line2); // 测试 3多行字符串用模板字符串包裹 javascript:alert(line1 line2);测试 1 正常说明环境本身没问题。测试 2 报Unterminated string literal说明你的 Bookmarklet 一旦在源码里出现双引号字符串内裸换行必炸。测试 3 正常说明模板字符串可以承载换行。用这种小用例先验证环境行为再用同样的方法去测你脚本里的可疑片段每次只改一小块很快就能定位到具体哪一段代码出了事。3.3 第三步目标站点控制台动态验证如果问题只在特定站点出现比如这次洛谷那么最好在目标页面上挂一个全局错误监听再手动执行 Bookmarklet 源码。在洛谷页面的控制台里先执行window.onerror function (msg, source, line, col, error) { console.log(捕获异常:, msg, at line, line, column, col); console.log(error); };然后再把还原后的 Bookmarklet 源码粘进去执行。如果捕获到的错误是Unterminated string literal基本说明问题就是%0A解码后的裸换行。如果你在控制台执行的源码本来就是正常多行源码没有经过 URL 编码那么可能不会触发这个错误这就需要你同时把“百分号编码后的 URL 版”和“源码文本版”分别执行一遍对比差异。这一步做完定位阶段就结束了。接下来要做的是怎么在架构上彻底避免这个问题而不是每次手工去改。4. 跨环境兼容的核心设计源码多行、交付单行、数据转义4.1 为什么“源码多行、交付单行”是唯一靠谱基线经过上面的分析你会发现一个问题Bookmarklet 的真身是一段 URL只要它经过“URL 解码→JS 解析”这个过程源码里的真实换行就成了一个极度不可控的变量。有的环境解码有的环境不解码有的环境只解码一部分。你没法控制用户的浏览器也没法控制目标站点唯一能控制的是交付给用户的 URL 里不含裸换行即使换行语义存在也换到 JS 的安全语法里去表达。所以我的核心设计原则是开发阶段源码随便多行模板字符串随便写注释随口加以可维护性为主。构建阶段用压缩器把代码压成单行移除所有注释并做一次硬性校验产物中一个\n、一个\r都不允许出现。如果业务上确实需要字符串带换行比如要把代码文本提交给在线评测系统那就在 JS 源码里写\n转义序列或者用其他运行时方案让它在编码前后语义一致。下面这个表能直观看出“裸换行”和“\n转义序列”在不同环境下的行为差异交付产物中的换行表达经过 URL 解码的环境点击书签、地址栏不经过 URL 解码的环境控制台 eval源码内真实换行编码为%0A解码为真实换行双引号字符串直接 SyntaxError%0A保持字面字符结果不对但通常不报错源码内\n转义序列编码为%5Cn解码为\n两个字符JS 解析为合法转义序列源码本身就是\nJS 解析为合法转义序列模板字符串内真实换行编码为%0A解码为真实换行模板字符串允许裸换行%0A保持字面字符模板字符串值错误结论很明确对于字符串中的换行语义统一用\n转义序列而不是真实换行。这是跨环境兼容的第一条铁律。4.2 构建期用脚本防住裸换行手工把代码压成一行很容易出错所以应该把这件事写进构建脚本。我用的是 Node.js Terser。核心逻辑不复杂源码多行可读压缩器负责压成单行随后立刻检测产物里有没有换行有就直接构建失败一个都别想漏过去。下面是一个可以直接用的构建脚本骨架// build-bookmarklet.mjs import { readFileSync, writeFileSync } from node:fs; import { minify } from terser; async function main() { const src readFileSync(src/bookmark.js, utf8); const result await minify(src, { compress: true, mangle: true, format: { beautify: false, comments: false, }, }); let code result.code.trim(); // 硬性校验压缩产物不允许出现任何裸换行 if (/[\r\n]/.test(code)) { throw new Error([build] 压缩产物仍包含裸换行请检查源码字符串); } // 方式一全量 encodeURIComponent简单可靠产物不可读但兼容性最好 const url1 javascript: encodeURIComponent(code); // 方式二只编码必要字符产物可读性好适合手工检查 const url2 javascript: encodeMinimal(code); writeFileSync(dist/bookmarklet-url.txt, url1); writeFileSync(dist/bookmarklet-url-readable.txt, url2); console.log([build] 生成完成长度:, url1.length, /, url2.length); } function encodeMinimal(code) { return code .replace(/%/g, %25) // 必须先替换 % .replace(/#/g, %23) .replace(/ /g, %20) .replace(//g, %22) .replace(//g, %27); } main();这里有几个细节说一下。第一%必须最先替换。如果你在代码里写了encodeURIComponent里面会有很多%字符如果不先把它们编码成%25浏览器解码 URL 时会把%后面的字符当作十六进制数去解析比如%41变成A整个字符串就乱了。我在实际项目里被这个坑咬过不止一次。第二全量encodeURIComponent和“只编码必要字符”的方案我两个都生成。全量版扔给用户最稳可读版留给自己调试。两个 URL 在主流浏览器里都能执行但全量版兼容性更保险。第三Terser 压缩后一般不会产生裸换行但如果你写了模板字符串压缩器也可能把模板字符串保留成多行这时候换行检测就会拦下来。你需要在模板字符串里改用\n转义来处理换行语义或者干脆接受它作为“模板字符串内裸换行”的合法场景。我个人倾向于在构建流水线里对“非模板字符串区域的裸换行”做严格拦截这个策略更精细但实现起来也不复杂。为了演示上面的脚本直接对所有换行一刀切自己用的时候可以根据需要放宽。4.3 运行时兜底如果必须在 JS 里表达换行语义该怎么写构建期拦截住了裸换行业务上还是会有“必须让某个字符串带换行”的场景。如果不写真实换行那要怎么写我整理了几种常用的运行时兜底方案。方案 A\n转义序列。最简单适合确定的中短文本const code function solve() {\n const a readline();\n console.log(a);\n};URL 编码后反斜杠会变成%5Cn保持n。浏览器解码后JS 解析器看到的是\n转义序列这是一个合法的字符转义字符串的值才是真正的换行。两个环境语义完全一致。方案 B用String.fromCharCode(10)拼接。适合动态构造换行完全避开转义序列和裸换行const nl String.fromCharCode(10); const code line1 nl line2;这招在极老的环境里也适用缺点是可读性差适合在构建脚本里用开发源码里不建议写这种。方案 C模板字符串用于承载长文本。如果你确实有一大段多行文本要放进去而且想保留可读性可以用模板字符串。要注意模板字符串内的裸换行在“经过 URL 解码”的环境里是合法的在“控制台 eval 原样执行”的环境里也是合法的唯一不一致的是“把经过 URL 编码的字符串文本再 eval”这种小众场景。实际使用下来模板字符串方案跨环境表现足够好只要你的目标浏览器支持 ES6。我会在后面的实战代码里用这个方案。方案 DBlob URL.createObjectURL注入长脚本。如果你的 Bookmarklet 已经大到没边或者你希望脚本主体保持非常宽松的多行可读格式可以这样Bookmarklet 只负责创建一段script标签把多行脚本代码放进 Blob生成一个 objectURL注入页面执行javascript:(function(){ var code (function(){ // 这里是任意多行脚本随便换行 console.log(hello from blob); })(); ; var s document.createElement(script); s.src URL.createObjectURL(new Blob([code], { type: text/javascript })); document.head.appendChild(s); })();这里code是模板字符串裸换行合法进入 Blob 后又是一段独立脚本不再受 URL 解码影响。注意如果目标站点有 CSP 限制禁止script-src blob:那么这个方案会被拦下来。遇到这种情况回退到方案 A 或 C 更稳妥。4.4 数据提交场景防止换行进入路由/接口最后回到洛谷这个场景。如果你的 Bookmarklet 需要把一段包含换行的代码提交给在线评测系统那么哪怕是脚本内部代码没问题数据层面的换行也可能在提交给后端接口时出问题。后端接口一般有两种接收方式表单提交和 JSON 提交。表单提交时换行会被encodeURIComponent转成%0A放进 query 或 body后端拿到后解码数据是完整的。JSON 提交时换行会被JSON.stringify自动转义为\nJSON 解析后还原为换行数据也是完整的。真正危险的场景是你直接拼了一个字符串当路由参数或者把一个格式非法的对象塞给了页面自己的提交函数。比如// 错误示范直接把换行拼进路由参数 window.location.href /submit?code code;如果code里包含真实换行整个 URL 结构可能直接坏掉。更常见的是脚本尝试调用页面内部某个提交函数比如router.push(...)传进去的对象因为换行被破坏变成了undefined于是在洛谷这边就表现为“无法解析路由对象”。正确的姿势是在把数据交给页面之前先用JSON.stringify或encodeURIComponent做一次包装保证你传入的是一个“合法字符串”而不是一段原始的多行文本const payload JSON.stringify({ code: code }); // 然后再交给页面自己的逻辑或者后端接口这一步处理完换行就只是被转义的普通字符不再有语法层面和数据层面的破坏力。5. 实战改造洛谷自动填充代码书签的健壮化全过程5.1 原版脚本的错误代码长什么样我最初的 Bookmarklet 大概是这样的已脱敏简化核心逻辑javascript:(function(){ var template function solve() { const a readline(); console.log(a); }; var ta document.querySelector(textarea); ta.value template; ta.dispatchEvent(new Event(input, { bubbles: true })); })();诱发事故的写法就是这段var template的双引号字符串里为了排版好看直接写了真实换行。开发时这个源码文件是多行文本我的构建脚本又比较“原始”只是简单做了字符串拼接和整体 URL 编码完全没有意识到这个真实换行会在 URL 解码时变成语法错误。5.2 改造后的完整代码我把这段脚本重写成了下面这个版本。它做的事情没有变往页面里找编辑器、填代码、触发输入事件为手动点击提交做准备。javascript:(function(){ // 使用 \n 转义序列代替真实换行避免 %0A 解码后破坏字符串字面量 var template function solve() {\n const a readline();\n console.log(a);\n}; // 兼容多级编辑器 DOM 结构CodeMirror - textarea - 任意 textarea var ta document.querySelector(.CodeMirror textarea) || document.querySelector(textarea.editor) || document.querySelector(textarea); if (!ta) { alert(未找到代码编辑器); return; } // 如果页面挂载了 CodeMirror 实例优先通过实例 API 赋值 var cmEl document.querySelector(.CodeMirror); if (cmEl cmEl.CodeMirror) { cmEl.CodeMirror.setValue(template); } else { ta.value template; ta.dispatchEvent(new Event(input, { bubbles: true })); } // 如果还要帮用户提交建议用 JSON 序列化包裹代码避免换行破坏路由/接口 var payload JSON.stringify({ code: template }); // 这里不直接调用洛谷内部函数留给用户手动点击提交确保状态完整 })();5.3 四个兼容点逐行说明为什么这么改里面有几个关键取舍值得展开。第一为什么内容用\n而不是真实换行前面已经反复说过真实换行在 URL 解码后是 JS 语法炸弹而\n转义序列是合法字符序列。这段代码经过 Terser 压缩后再做 URL 编码时Terser 不会删除字符串里的\n转义URL 编码后变成%5Cn浏览器解码后又还原成\nJS 引擎解析为换行逻辑全程一致。第二为什么对编辑器做多级回退洛谷的在线编辑器在不同时期换过不同的实现旧版本可能是一个普通textarea新版本可能是 CodeMirror 或者类似组件。如果你只写了document.querySelector(textarea)可能选到页面上某个隐藏的、和提交逻辑无关的 textarea。我这里的做法是优先找 CodeMirror 内部的 textarea新版常见再找带有 editor 标记的 textarea最后才是任意 textarea。实际接入时你还需要根据目标站点当日的 DOM 做一版实测但这个多级回退框架能覆盖大多数情况。第三为什么要 dispatchinput事件现在很多前端框架Vue、React对 textarea 的值变化有监听你直接设置.value框架内部的数据模型不会同步更新。手动派发一个input事件可以让框架感知到值变化后续提交时才不会把旧数据带上去。如果你选中的是 CodeMirror它内部有自己的一套数据模型需要走setValueAPI不能只靠 dispatch 事件。第四为什么最后用JSON.stringify({ code: template })这是为了给页面/接口一个结构化数据。换行在 JSON 字符串中会被自动转义成\n无论经过多少次序列化都不会变成裸换行去破坏路由对象。如果你最终要调用洛谷页面自己的提交函数也应该把参数包成合法对象再传避免传一个因为换行导致格式异常的数据进去。5.4 如果脚本实在太长Blob 注入方案示例如果自动填充的代码模板特别长或者你需要在 Bookmarklet 里维护一个完整的多行工具函数可以考虑用 Blob 方案拯救可读性。上面提到过的模板字符串 Blob在长脚本场景下非常实用javascript:(function(){ var code (function(){ // 这里是完整的多行脚本 function buildCode() { return function solve() {\\n return 1;\\n}; } window.__luoguHelper { buildCode: buildCode }; })(); ; var s document.createElement(script); s.src URL.createObjectURL(new Blob([code], { type: text/javascript })); document.head.appendChild(s); })();注意模板字符串里如果要表达\n给外层脚本使用需要写成\\n因为这里是先经过“模板字符串解析”再进入 Blob 脚本。如果你忘了转义Blob 里的脚本看到的可能是一个真实换行或者一个无效转义需要自己实测调整。CSP 限制下这个方案可能失效所以它只是备选不是替代。6. 测试矩阵与容易反复踩的细节6.1 跨浏览器/执行方式测试矩阵一次靠谱的 Bookmarklet 交付不能只在 Chrome 里点了没问题就算完。至少要把主流浏览器和几种常见执行方式都过一遍。下面是我现在用的测试矩阵环境执行方式含裸换行的旧版产物使用\n转义的新版产物Chrome / Edge点击书签双引号字符串处 SyntaxError正常Firefox点击书签双引号字符串处 SyntaxError正常Safari点击书签双引号字符串处 SyntaxError正常任意浏览器地址栏粘贴javascript:URL取决于是否先解码大概率 SyntaxError正常任意浏览器控制台直接 eval 源码%0A保持字面不报错但逻辑错正常洛谷等 SPA 站点书签注入页面状态残缺提交时路由解析失败正常重点看最后一行这也是本篇文章的初衷。改造后的版本由于不再往页面里注入裸换行脚本不会在注入阶段崩溃页面状态保持完整提交按钮点击后洛谷自身路由自然不会再收到“无法解析路由对象”这类错误。6.2 容易被忽略但一定会在某天咬你一口的三个细节第一个是%的二次转码。构建脚本如果处理顺序不对%会被再编码成%25。比如你脚本里写了一个encodeURIComponent函数里面本来就有大量%字符如果构建器先替换了空格、再替换%那这个函数可能直接失效。正确顺序一定是先把代码里所有的%替换为%25然后再处理其他字符。我建议直接用我在第四节给的encodeMinimal先%后其他。第二个是\r残留。如果你在 Windows 下开发和构建源码可能自带 CRLF 换行。压缩后如果字符串里藏着\rURL 编码后是%0D解码回来后会多一个回车符。回车符在部分语法位置可能不报错但会改变一些解析结果。构建脚本里的/[\r\n]/检测可以同时拦掉这两种不要只检查\n。第三个是书签 URL 长度。虽然现代浏览器对 Bookmarklet 长度一般没有硬性限制但一些 WebView 环境、旧版移动浏览器、以及某些富文本编辑器里粘贴长 URL 时会有截断风险。尽量控制产物长度能用 Terser 压缩就用不要手工拼接大段字符串。如果确实很大考虑 Blob 方案把主逻辑外置让主体 Bookmarklet 保持轻量。6.3 我现在的工程习惯经过这次事故之后我给自己立了几条规矩。第一所有 Bookmarklet 项目都加一个构建步骤产物必须经过裸换行检测一旦检出\n或\r直接构建失败不给自己留任何侥幸空间。第二每一版发布之前都按上面那张矩阵跑一遍至少覆盖 Chrome 点击书签、Firefox 点击书签、控制台 eval 源码三种执行方式。第三构建产物同时生成可读版和全编码版可读版方便排查全编码版给用户两边永远同步。说到底%0A只是一个换行符的百分号编码但它在 Bookmarklet 这条链路上牵涉到 URL 解码、JavaScript 语法、浏览器的执行模型、以及目标站点的页面状态管理。只要理解了这整条链路你就能在踩坑之前把它拦下来。如果你也想把某个书签脚本做得更稳建议现在就回去把你收藏夹里的 Bookmarklet URL 解一遍码看看里面有没有潜伏着没被发现的%0A。
返回列表