7131、6293、4762、1579:同一份稿子在四个编辑器里的四个长度
昨晚我发一篇稿子,脚本的日志里跳出来四个数字。
源文件 7131
博客园 7131
简书 6293(DOM)/ 8779(服务端)
知乎 4762
掘金 1579
同一份稿子,同样的读写方式,四个平台读出四个长度。没有一个等于源文件的字符数——除了一个。
这串数字摊在日志里特别扎眼,因为它们本来是用来校验的。我写这些脚本的初衷是:灌完正文之后读一下编辑器里有多少字,跟源文件对不上就报警。结果四个平台里三个对不上,而且差得很离谱——掘金那个差了 4.5 倍。
如果我把校验条件写成"编辑器长度必须等于源文件长度",那三个平台会永远报警。如果我放宽到"大于 500 就算成功",那真的失败了我又抓不到。
这篇文章就是把这四个数字拆开,看清楚它们各自是怎么算出来的,以及在每种编辑器里,到底该拿什么当"灌进去了没有"的判据。
一、先说结论:长度根本不是一个可移植的度量
四个数字之所以不同,不是哪个平台算错了,是因为它们测的本来就不是同一个东西。
| 平台 | 数字 | 它实际在测什么 |
|---|---|---|
| 博客园 | 7131 | 原生 textarea 的 value.length |
| 简书(DOM) | 6293 | contenteditable 的 innerText.length |
| 简书(服务端) | 8779 | 存进数据库的 HTML 字符串长度 |
| 知乎 | 4762 | 渲染后文章正文 innerText 的长度 |
| 掘金 | 1579 | CodeMirror 当前已渲染到 DOM 里的那部分文本 |
五个数字,五种语义。 拿它们互相比较是没有意义的,就像拿体重秤的读数和体温计的读数比大小。
更麻烦的是,同一份内容在"纯文本"和"带标签"之间本来就没有固定换算比例。markdown 源码里那些语法符号——井号、反引号、表格的竖线——渲染之后会消失;而 <p>、<strong>、<code> 这些 HTML 标签又会往回加。所以:
- 服务端存 HTML → 比 markdown 源码长(8779 > 7131)
- 渲染后的纯文本 → 比 markdown 源码短(4762 < 7131)
这两个方向是反的,而且偏移量取决于你这篇文章里代码块多不多、标题多不多、有没有表格。它是文章结构的函数,不是常数。
二、博客园:唯一那个对得上的
博客园的编辑器几乎是这类事情里最舒服的情况:一个货真价实的 <textarea id="md-editor">,里面装的就是原始 markdown。
var el = document.querySelector('#md-editor');
var raw = el.value; // 原生 textarea 的全部内容
console.log(raw.length); // 7131
原生表单控件,.value 就是它的全部内容,不打折,不渲染,不虚拟化。7131 == 7131,一个字符都不差。
为什么它能这么老实? 因为 markdown 编辑器不需要所见即所得——它存的就是源码,预览是另一块面板的事。所见即所得才需要把 DOM 树当数据源。
所以第一条经验:遇到原生 textarea,直接用 .value。 它是唯一不需要任何解释的数字。
顺带说一个我踩过的坑:博客园这个 textarea 必须先真实点击聚焦再灌内容。我第一版脚本没聚焦,Input.insertText 直接把整篇稿子灌进了上一个聚焦元素里——那次是标题框,于是标题变成了整篇正文。这个坑跟长度无关,但它是同一个根因:你以为你在跟编辑器对话,其实你在跟浏览器的焦点管理器对话。
三、简书:同一个平台,两个数字都对
简书有意思的地方在于,它给了我两个数字,而两个都是真的。
粘贴之后我立刻读 DOM:
var area = document.querySelector('.kalamu-area');
var flat = area.innerText; // 先把 DOM 拍平成文本
console.log(flat.length); // 6293
然后脚本触发自动保存,服务端回过来一个 JSON:
{"id":144908349, "content_updated_at":1791997831, "content_size_status":"fine"}
抓包取到服务端实际存的内容长度:8779。
6293 和 8779,差了 2486 个字符。这不是某一方错了,是它们在数不同的东西:
- 6293 是浏览器把 DOM 拍平成纯文本的结果。此时 markdown 已经被引擎解析,标题的
#、代码块的 ```、加粗的**都不再作为字符存在。 - 8779 是带 HTML 标签的字符串。每个段落外面裹着
<p>,代码块裹着<pre><code>,标签本身算字符。
如果 DOM 里的纯文本是 6293,加上标签到 8779,是完全合理的。
这里有一个重要的推论:DOM 侧的读数不可信–服务端侧的读数才可信。 因为最终存进数据库、最终会被读者看到的是服务端那一份。我在这个平台的校验逻辑现在只看两件事:
PUT /author/notes/<id>的响应码是 200;- 响应体里
content_size_status是fine。
数字本身不再参与判断,最多打进日志供人看。
(写到这里插一个跟长度无关但很痛的坑:这个 PUT 接口要求 body 里的 id 是字符串,并且必须带 autosave_control 字段。传数字 id,或者漏了那个字段,服务端一律回 400 缺少文章内容参数 —— 明明 content 好好地带在请求里。这种报错文案和真实原因完全不搭界的情形,在自动化里比长度问题难缠十倍。)
四、知乎:4762 才是"文章本身"的长度
知乎的 4762 是我换了一个读法得到的。这个平台我是这么做的:先在写文章页用 Draft.js 的 resetWithValue() 把 HTML 灌进去,然后点发布,最后另开一个标签打开文章页读:
var el = document.querySelector('.RichText');
var shown = el.innerText;
console.log(shown.length); // 4762
注意这一步的位置:它不是在编辑器里读的,是在发布之后的成品页上读的。
这两个位置差别巨大。在编辑器里读,你读到的是编辑器此刻的内部状态;在成品页读,你读到的是服务端存量 + 前端渲染结果的合取。前者可能发生"前端看着有了、服务端没存"的问题(简书这类 contenteditable 编辑器就经常如此——用 document.execCommand('insertText') 灌进去的文本,React 不认,编辑器的自动保存会拿空状态覆盖掉你在 DOM 里看到的内容)。
所以 4762 这个数字,虽然跟源文件差了近 2400,却是我最愿意相信的一个——它是终点,不是中途站。
这也是我在上一篇文章里讲过的判据分级:
- 弱判据 = 页面上写着什么
- 中判据 = DOM 状态 / 接口响应
- 强判据 = 结果的对外可见状态
另开一个标签,用读者的身份去看这篇文章在不在、多少字——这是最贵的一种校验(多一次导航、多一次等待),也是唯一不会被自欺的一种。
五、掘金:1579 是最有意思的那个
最后说掘金,因为它最反直觉。
CM_LEN 1625
CM_LEN 1577
CM_LEN 1579
CM_LEN 1625
这是我隔几分钟跑了几次读到的值。同一篇稿子,同一个页面,四次读数都不一样。
原因:掘金的编辑器是 CodeMirror,而 CodeMirror 用的是虚拟滚动。一篇 7131 字符、300 多行的文章,它只把当前视口里那十几二十行真正渲染成 DOM 节点,其余的在滚动条出现时才生成。
所以 innerText 读到的,永远只是"此刻屏幕上可见的那一小截"。往下滚一点,读数变大;往上滚,读数变小。我那四次读数的差异,就是滚动位置不同造成的。
这意味着什么?
如果你在一个 CodeMirror 编辑器上用 innerText.length 做校验,你得到的不是一个数,是一个跟滚动位置相关的随机变量。它有下界(至少要能填满一屏),没有稳定的上界。
我当时写的判据是 cmLen < 500 就算灌入失败。这个阈值侥幸生效了——1579 > 500,脚本判断"灌进去了"。但如果那天我发的文章短一些呢?或者某次它正好滚到顶部只渲染了 400 字呢?这个判据会在毫无征兆的情况下误报,而我不会知道。
正确做法有两种:
// 一:直接问编辑器实例,绕过 DOM
var cm = document.querySelector('.CodeMirror').CodeMirror;
console.log(cm.getValue().length);
// 二:退而求其次,读行数
console.log(document.querySelectorAll('.CodeMirror-line').length);
第一种最好——CodeMirror 实例挂了 CodeMirror 属性在容器上,getValue() 拿的是完整文档对象,跟渲染无关。
(顺带:这个属性名跟库同名,第一次看到容易以为是个 bug。)
六、跳出长度:什么才是可移植的判据
四个平台走完,你会发现"长度"这个度量从一开始就选错了。它便宜、直观、写代码的时候顺手,但它测的东西在不同平台上语义不一致,而且会受到渲染模型、虚拟滚动、标记语言的共同扰动。
我现在用的替代品是一个笨办法:校验特征串,而不是校验数量。
// 源文件最后一行的前 24 个字符
var MARK = '判断一件事成没成,永远去看它对外部世界造成了什么改变';
// 灌完之后,在目标元素里找这个串
var seen = targetEl.innerText;
var ok = seen.includes(MARK);
它好在几个地方:
第一,它对"渲染掉了什么"免疫。 markdown 符号被解析没了?没关系,我要找的是正文本身的一段话,它在任何渲染模型里都会保留。
第二,它对"虚拟滚动"免疫。 CodeMirror 只渲染了视口内的行?那我就滚到底部,或者干脆用 getValue()。只要那一句话在,就说明整篇稿子灌进去了——因为它是最后一段。
第三,它能发现"灌了一半"。 这是长度校验做不到的。假设粘贴在中途断了,前 3000 字进去了,后 4000 字丢了——长度读数是 3000,如果你的阈值是 500,你会判定成功。而特征串选在结尾,找不到就是没灌完,一个字都不会漏。
第四,它跟平台的存盘格式无关。 不管对方存 markdown、存 HTML 还是存 Draft.js 的 block 树,那段中文的形式都一样。
代价只有一个:它不能告诉你"灌了多少"。它能回答的是一个是非题,不是一道算术题。
但说实话,在发布流水线里,我需要回答的本来就是是非题。 "这篇稿子完整地进去了吗?"——是或否。至于进去之后是 6293 还是 8779,那是排版的事,不是我该在这一步关心的事。
七、一份对照表
最后留个速查表,下次写新平台的脚本可以照着填。
| 编辑器类型 | 别这么读 | 该这么读 | 代表平台 |
|---|---|---|---|
原生 textarea | — | 取 value 再取 length | 博客园 |
| CodeMirror | innerText.length(虚拟滚动) | cm.getValue().length | 掘金 |
| Draft.js | 编辑器里的 DOM | 发布后另开标签读成品页 | 知乎 |
contenteditable + React | DOM + execCommand 灌入 | 服务端响应体 TA 的 content 长度 | 简书 |
| Cherry Markdown | textarea 的 setValue | 分块逐行键入后读行数 | 腾讯云 |
以及一条铁律:
能用服务端回声,就别用本地测量。 服务端回的那个数是它真的存下来的东西;本地测量的数是浏览器此刻展示的东西。这两者之间隔着一整套框架状态同步,随时可能不同步。
(本文所有数字均来自 2026-10-15 一次真实的四平台发文日志,源文件 7131 字符。)