JSON、转义与 Markdown 预览:四个格式页面的能力边界,和它们彼此不一致的地方
四个页面都归在「格式」下面,但没有两台机器是一样的。JSON 格式化是 JSON.parse 接 JSON.stringify 的一层壳;JSON 转代码是一台命名规则机;HTML 转义只处理五个字符;Markdown 预览则是四个里唯一真的把文本重排成 HTML 的那个——也因此是唯一一个「渲染即执行」的。
知道每台机器是什么,比背按钮位置有用:一个数被改写了,你只需要问这台机器会不会改它;一条报错没出现,你只需要问这台机器的失败路径长什么样。
下面每一条都是从这四个页面自己的脚本里读出来、再在页面上实际跑一遍确认过的。
格式化页就是 JSON.stringify,不多也不少
JSON 在线格式化的两个主按钮是「格式化」和「压缩」。页面上没有缩进下拉、没有键排序、没有保留键序的开关——因为它根本没实现这些。所谓格式化是 JSON.stringify(obj, null, 2),压缩是同一句去掉第二个参数。所以 stringify 的毛病它全都有,而且都是对的:
| 你粘进去 | 页面上出现 | 为什么 |
|---|---|---|
{"b":1,"a":2,"b":3} |
"b": 3 在前,"a": 2 在后 |
键序按解析结果,重复键保第一个的位置、取最后的值 |
{"a":1e309} |
"a": null,并提示格式化成功 |
JSON.parse 给 Infinity,JSON.stringify 没有对应写法,只写 null |
{"id":12345678901234567890} |
"id": 12345678901234567000 |
数字走 IEEE 754 双精度,尾数存不下 20 位整数 |
{"a":"\\u00e9"} |
"a": "é" |
反斜杠 u 转义在解析时被解成字符,输出里不再保留转义写法 |
// 注 加对象 |
红条报错 | JSON 不允许注释 |
{'a':1} |
红条报错 | 字符串必须双引号 |
0 / false |
0 / false |
标量也是合法 JSON,能正常输出 |
尾逗号、单引号、注释,以及你从别的语言里带出来的非 JSON 字面量(NaN、undefined)——这四类占了被拒原因的绝大部分。
「字符数」那一行数字的是 JavaScript 的字符串长度,不是字节。实测 {"a":"中文中文中文"}:输入 14 个字符、UTF-8 编码 26 字节,格式化后输出 19 个字符(缩进和换行加了 7 个字符,三个中文每字在线上是 3 字节、在这里算 1 个字符)。所以这个页面的数字和你 HTTP 客户端里的数字不一样,别用它估载荷上限。
失败路径是这个页面最该记的一段
「格式化 / 压缩 / 清空」三个按钮开头都会先把两条横幅收起来,然后:
- 输入是空的或只有空格:红条 请输入 JSON 文本。输出框不动。
- JSON 非法:红条 JSON 解析错误: 加引擎原话。输出框保留着上一次的结果,「字符数」也还是上一次的数字。实测:先格式化
{"a":1}得到 12 个字符的输出,再格式化{"a":,},红条挂上来,输出框里仍是那个{\n "a": 1\n}、字符数仍写着 12。绿条此时是被显式隐藏的,不会和你对着显示。 - 输入恰好是
null:这个页面完全不动。JSON.parse('null')成功返回null,而null正是页面用来表示「解析失败」的哨兵值,于是判断if (obj === null) return;把它当失败处理,又因为失败提示已经在validateAndGet里给过了(这次没给),结果是:没有红条、没有绿条、输出不变、字符数不变。实测过:上一轮输出还在,横幅两条都收着。而字符串null(写成"null")是另一种东西,它正常输出"null"、正常报成功。 - 你随后在输入框里改了一个字:这个页面没有给输入框挂监听,所以红条会一直挂在屏幕上,直到你再点一次任意按钮(那时它先被收起来)。换句话说:屏幕上没有消息不等于成功。想知道刚才那一下到底成没成,看输出框和字符数有没有变。
这条和英文页的行为不一样——英文页解析失败会把输出清空、并在你输入时收起横幅。这两个页面上,同一个动作留下的线索不同,跨语言用的时候别照搬经验。
关于红条里那句引擎原话:措辞是浏览器给的,会跟着你浏览器的语言和版本变(我这台英文环境的 Edge 给的是英文整句),但里面的位置数字是可靠的定位线索。同一台机器上跑出来的几条:
| 你粘进去 | 红条全文 |
|---|---|
{'a':1} |
JSON 解析错误:Expected property name or '}' in JSON at position 1 (line 1 column 2) |
{"a":,} |
JSON 解析错误:Unexpected token ',', "{"a":,}" is not valid JSON |
// 注 加对象 |
JSON 解析错误:Unexpected token '/', "// …" is not valid JSON |
{"a":NaN} |
JSON 解析错误:Unexpected token 'N', "{"a":NaN}" is not valid JSON |
第一句里的 position 1 (line 1 column 2) 指向的就是那个单引号。
转代码页:两种语言,一条命名规则
JSON 转代码的下拉里正好两个选项:TypeScript interface 和 Python dict。没有根名输入框、没有分号开关、没有「给每个对象都生成类型」。
TypeScript 模式的规则(以下都是实测输出):
- 顶层对象永远叫
interface Root { … },改不了。 - 嵌套对象按键名转 PascalCase:
"user_profile"→interface UserProfile,"http_response_code"→interface HttpResponseCode。 - 对象数组在类型名后加
Item:{"list":[{"x":1}]}→list: ListItem[]外加interface ListItem { x: number; }。 - 键名本身原样抄进产物:
{"a-b":1}→a-b: number;;带空格的键也照抄。这不是合法 TypeScript,页面不会替你把它们变成aB或加引号。 - 只有大小写不同的键会撞名:
{"a":{"x":1},"A":{"x":"s"}}生成两个都叫interface A的定义,内容还不一样。my_key和myKey、my key同理(都变MyKey)。 - 数组类型只看第 0 个元素,后面的根本不检查:
[1,"a"]→number[];[[1,2],[3]]→number[][];空数组[]和第 0 个是null的数组 →any[]。 - 顶层就是数组时,下标被当成键:
[{"a":1},{"a":2}]→0: _0; 1: _1;加两个 interface。 - 顶层是标量时你会拿到一个空壳:输入
42→interface Root {\n\n},合法但不含任何信息;这不报错,也没有提示。
Python 模式产出的是 data = { … },四空格缩进、键用双引号。它不是 Python 字面量:实测 {"ok":true,"miss":null,"big":1e309,"empty":{}} 得到
data = {
"ok": true,
"miss": None,
"big": Infinity,
"empty": {}
}
null 被换成了 None,但 true、false、Infinity 都按 JSON 的写法留着——Python 里应是 True、False、float("inf")。这段代码贴进 .py 文件会在第一行就 NameError。它是给人眼看的,不是给人跑的。
失败路径:空输入给 请输入 JSON 文本,解析失败给 JSON 解析错误: 加引擎原话,输出框保留上一次的代码(实测:先生成 interface Root { a: number; },再喂 {oops},那块里仍是上一轮的代码)。按钮是「生成代码 / 清空 / 复制代码」。
转义页只动五个字符,层数由「交换」控制
HTML 转义的转义是一条五步链:&→&、<→<、>→>、"→"、'→';反转义按同样的顺序把五个实体换回去。就这些。
实测样本:
| 输入 | 转义后 | 反转义后 |
|---|---|---|
<a href="x">&</a> |
<a href="x">&amp;</a> |
<a href="x">&</a> |
©é |
&nbsp;&copy;&eacute; |
©é 原样 |
'& |
&#x27;&amp; |
'& |
© ü |
© ü |
© ü |
©、é、' 这类命名/数字实体两个方向都不认识。它不是实体解码器,是一个「五个字符的标签安全过滤器」。适合把代码片段贴进博客正文或评论区,不适合把一整个被反复转义过的文档还原。
要点:按两次不等于转两层。 两个按钮都是从「输入框」取原文、往「输出框」写结果,所以连点两次「转义」得到的仍是同一份一层结果——实测 <b>&</b> 连点两次还是 <b>&amp;</b>。想再加一层,要在两次之间点「交换」:交换会把两个框的内容对调,于是下一轮「转义」读到的就是刚才的结果,产出 &lt;b&gt;&amp;amp;&lt;/b&gt;。
顺带一句:刚点完交换的那一刻,输出框里放的是你原来的文本,不是任何结果——它只是把两个框调了个个儿,得再按一次按钮才有对应关系。反方向同理:&lt;p&gt; 点一次「反转义」得 <p>,再点还是 <p>,点交换后再点一次才得到 <p>。层数是靠交换推进的,不是靠多点。
「字符数」跟着输出框走,静态写的和 JS 写的是同一个标签 字符数:,不会出现按完按钮标签换了个说法的情况。而且已经转义过的文本再转一次是常见事故: 会变成 &nbsp;,贴到你的页面上就显示成字面的 而不是一个空格。从 CMS 里复制出来的内容往往已经转过了,这里再转一遍是 bug,不是修复。
预览页:本站语法覆盖最全的一个,但代码块和引号靠不住
Markdown 在线预览左侧打字右侧立刻渲染(输入框有监听),滚动会跟着同步。它支持什么、不支持什么,页面 FAQ 写得大体准确,实测之后可以给出更硬的边界。
支持的(括号里是实测得到的 DOM):
- 标题 h1 到 h6:
###### 六级→<h6>六级</h6> - 分割线:独占一行的
---→<hr> - 引用:
>开头的行 →<blockquote>,连续多行会被并成同一个引用块、中间插<br> - 无序列表
-→<ul><li>;有序列表1.→<ol><li> - 围栏代码块 →
<pre><code>;行内`x`→<code> - 粗体
**x**、斜体*x*、粗斜***x*** - 链接
[文字](URL)→<a href="URL" target="_blank" rel="noopener"> - 其余每个非空行包成
<p>
不支持的:表格、任务列表、脚注(FAQ 自己承认)。实测表格的每一行都变成独立段落、竖线原样留着:|a|b| → <p>|a|b|</p>;图片也不支持, 渲染成 !<a href="…">alt</a>——链接生成了,前面还多掉一个感叹号。
围栏语言标注被丢弃。 ```python 和 ```js 都只产出 <pre><code>,没有 class、没有任何高亮钩子——正则捕获了语言名但没用它。指望这个页面出带高亮的 HTML 是不可能的。
更要紧的是替换顺序:标题、分割线、引用、列表先跑,围栏后跑。 于是代码块里的内容在被识别成代码之前,已经先被当作 Markdown 处理过了。实测这段:
```
# 这不是标题
- 这不是列表
**这不是粗体**
[这不是链接](http://e.com)
```
得到的 DOM 是 <pre><code><h1>这不是标题</h1><ul><li>这不是列表</li></ul><p><strong>这不是粗体</strong></p></code><p><code><a href="…" …>这不是链接</a></code></p></pre><p></p> —— 井号变成了 <h1>、- 变成了列表、** 变粗,而且结构整个塌了:<pre> 里面冒出 <p>,末尾还多出一个空的 <p>。所以别把带 # 注释或 YAML 片段的代码直接贴进这个预览,它不会保持原样。
角度括号转义了,引号没有。 页面先做 escapeHtml,而它只替换 &、<、> 三个字符——所以 <script>alert(1)</script> 会变成字面文本(实测得到 <script>…),但链接规则是把 URL 直接塞进 href="$2",URL 里出现一个 " 就能开出新属性。实测:
[点我](x" onclick="window.__pwn=1)
渲染成 <a href="x" onclick="window.__pwn=1" target="_blank" rel="noopener">点我</a>,我程序化地点击它之后,window.__pwn 真的变成了 1。同理 javascript: 开头的 href 会原样保留,跟着链接一按就执行。
这不是「Markdown 写错」的问题,而是这个页面的定位问题:它把渲染当成执行。自己写的笔记随便用;任何别人给你的 Markdown,别在这个页面里渲染,尤其是会点它的场景。
两个字数框永远显示同一个数字。 渲染完之后,页面把 md.length 同时写给「编辑区」和「预览」两个计数标签——实测输入 # hi there 两边都是 10 字。所以右边那个不是渲染后的文字数,它只是源码长度又显示了一遍,删掉多少 Markdown 符号都不会变。而这里的「字」实际是 JavaScript 的字符串长度(UTF-16 单元):输入三个 😀 会显示 6 字,因为一个 emoji 占两个单元。
哪个页面干哪件事
| 你有什么 | 用 | 要当心 |
|---|---|---|
| 一坨压缩 JSON 要看 | 格式化 | 大整数被折、Infinity 变 null 还报成功 |
| 要贴回线上的 JSON | 格式化的「压缩」 | 键序按解析结果,不是你文件里的顺序 |
| 一份响应体要照着写类型 | 转代码的 TypeScript | 带空格/连字符的键原样抄、大小写撞名、数组只看第 0 个 |
| notebook 里眼看一下结构 | 转代码的 Python | true/Infinity 不是 Python 字面量 |
| 要贴进 HTML 的代码片段 | 转义 | 只对五个字符生效,已转义的文本别再转一遍 |
| 把实体还原成字符 | 「反转义」 | 只有五个实体,一层要一次交换 |
| 自己的 Markdown 笔记 | 预览 | 代码块里的井号会变标题 |
| 别人给的 Markdown | 谁都别用 | 一个引号就能挂上 onclick |
常见问题
我的 1e309 怎么变成 null 了?
JSON.parse 给你 Infinity,JSON.stringify 没有对应写法,于是写 null——而页面照样报「格式化成功」。在期望数字的地方扫一眼有没有 null。
为什么输出框里还是上一次的结果,横幅却是红的? 这是这个页面的失败路径:解析失败时它只挂红条,不清空输出、不改「字符数」。所以「输出没变 + 红条」= 刚才那一次没生效。
我输入 null 点格式化,页面一点反应都没有?
因为 JSON.parse('null') 的结果正好是页面表示「解析失败」的哨兵值,于是被当成失败静默返回,而失败提示这次又没机会出现。结果就是没有红条也没有绿条、输出和字符数都不动。把 null 包一层({"a":null})就能正常看到输出。
我改了一个字再点格式化,之前那条错误还在屏幕上,是不是没清掉? 不是。输入框没有监听,横幅只在按钮被按下时收起。红条残留说明你还没重新点过。
按了两次「反转义」,第二次怎么没变化? 按钮每次都从输入框取原文,所以两次结果相同。要去第二层:点「交换」把输出搬到输入,再按一次「反转义」。
代码块里的一行 # comment 为什么变成大标题了?
围栏是在标题、列表、引用之后才被替换的,所以内容已经先被 Markdown 化过一次。这个预览不适合带 #、-、1. 起头的代码。
```python 为什么不高亮?
语言名被正则吃掉但没有使用,产出的只是 <pre><code>,没有 class。要高亮请用真正的渲染器。
 为什么前面多个感叹号?
这个页面没有图片语法:[文字](URL) 的规则照样命中它,于是变成「一个感叹号 + 一个链接」。
左右两个「字数」为什么一模一样? 因为它们是同一个数:渲染后页面把源码长度同时写给两个标签。右边不是渲染出来的文字数。
这个预览安全吗?我拿它看别人发的 Markdown。
不。escapeHtml 只处理 & < >,不处理引号,所以链接 URL 里的一个 " 就能注入 onclick;实测注入的处理器确实被执行了。自己的笔记无所谓,陌生内容请别在这里渲染。
「字符数」能当字节数用吗? 不能。它是 UTF-16 单元数:中文在线上是 3 字节、这里算 1 个字符,一个 emoji 算 2 个字符。要算线上大小请另找工具。