7131、6293、4762、1579:同一份稿子在四个编辑器里的四个长度

0 阅读10分钟

7131、6293、4762、1579:同一份稿子在四个编辑器里的四个长度

昨晚我发一篇稿子,脚本的日志里跳出来四个数字。

源文件          7131
博客园          7131
简书            6293(DOM)/ 8779(服务端)
知乎            4762
掘金            1579

同一份稿子,同样的读写方式,四个平台读出四个长度。没有一个等于源文件的字符数——除了一个。

这串数字摊在日志里特别扎眼,因为它们本来是用来校验的。我写这些脚本的初衷是:灌完正文之后读一下编辑器里有多少字,跟源文件对不上就报警。结果四个平台里三个对不上,而且差得很离谱——掘金那个差了 4.5 倍。

如果我把校验条件写成"编辑器长度必须等于源文件长度",那三个平台会永远报警。如果我放宽到"大于 500 就算成功",那真的失败了我又抓不到。

这篇文章就是把这四个数字拆开,看清楚它们各自是怎么算出来的,以及在每种编辑器里,到底该拿什么当"灌进去了没有"的判据。


一、先说结论:长度根本不是一个可移植的度量

四个数字之所以不同,不是哪个平台算错了,是因为它们测的本来就不是同一个东西。

平台数字它实际在测什么
博客园7131原生 textarea 的 value.length
简书(DOM)6293contenteditable 的 innerText.length
简书(服务端)8779存进数据库的 HTML 字符串长度
知乎4762渲染后文章正文 innerText 的长度
掘金1579CodeMirror 当前已渲染到 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 侧的读数不可信–服务端侧的读数才可信。 因为最终存进数据库、最终会被读者看到的是服务端那一份。我在这个平台的校验逻辑现在只看两件事:

  1. PUT /author/notes/<id> 的响应码是 200;
  2. 响应体里 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博客园
CodeMirrorinnerText.length(虚拟滚动)cm.getValue().length掘金
Draft.js编辑器里的 DOM发布后另开标签读成品页知乎
contenteditable + ReactDOM + execCommand 灌入服务端响应体 TA 的 content 长度简书
Cherry Markdowntextarea 的 setValue分块逐行键入后读行数腾讯云

以及一条铁律:

能用服务端回声,就别用本地测量。 服务端回的那个数是它真的存下来的东西;本地测量的数是浏览器此刻展示的东西。这两者之间隔着一整套框架状态同步,随时可能不同步。


(本文所有数字均来自 2026-10-15 一次真实的四平台发文日志,源文件 7131 字符。)