使用方法
- 把要倒序的文字粘进上面的文本框,或直接在里面输入;上限 10 万个字符(按 UTF-16 码元算)。页面打开时框里已经预置了一段 3 行示例:柳泉工具文本倒序😀、Hello world 你好、café 组合字符(这里的
é故意用的是分解写法)。 - 在「倒序模式」里选一种。整串倒序把全部字符反着排一遍,换行符也参与排队,所以原来的最后一行会跑到第一行;按行倒序只把行与行换位置,每行内部的字符顺序不动;按词倒序先切行,再把每行按空白切成词、反转词的顺序,行序保持不变。
- 点「开始倒序」,或者在文本框里按 Ctrl + Enter(macOS 用 Cmd + Enter)立即重算一次。单按 Enter 留给换行——这是文本框类工具和单行输入框最容易混淆的一点。
- 看结果上方的 8 张统计卡:输入码点数、输入码元数、代理对数、字素数、行数、词数、输出码点数、输出码元数。示例文本是 36 个码元、35 个码点、34 个字素,三个数各不相同,正好演示了本页要讲清的三级计数。
- 点「复制结果」把倒序后的文本带走,点「载入示例」还原样例,点「清空文本」从零开始。下方 16 行的字符对照表可以「加载更多」看全、按 TSV「复制对照表」,或「导出 CSV」存成带 BOM 的文件。
这个文本倒序工具能解决什么问题
一是把 emoji 完好地倒过来。JavaScript 里最常见的写法 x.split('').reverse().join('') 走的是 UTF-16 码元,把 😀 倒序会得到一个低代理在前、高代理在后的非法序列,屏幕上只剩两个「�」。本工具用 Array.from() 取码点,😀 进去还是 😀 出来,两个连排的表情也只是交换前后,不会碎成四块。
二是定位「是哪一步把文字弄坏了」。文本倒序工具把码元数、码点数、字素数三个计数并排给出:三者相等说明这段文字全是基本平面字符;码元数大于码点数,说明有补充平面字符(emoji、CJK 扩展 B 生僻字、数学字母);码点数大于字素数,说明有组合附加符号、ZWJ 或区域指示符。有了这三个数,你就能判断某个平台显示异常到底是编码问题还是字体问题。
三是按行倒序做清单重排。日志、聊天记录、名单、待办、勘误表,把最新的一条挪到最前面是最常见的需求。这和「按字母排序」是两件不同的事:按行倒序不动行内内容,只换位置,编号和时间戳原样保留。
四是按词倒序做措辞实验。把一句话里的词序整个翻过来,看它还能不能读通,是改写标题、给文案找新说法时很省力的一步;因为词内字符不动,英文单词和中文词组都不会被切碎。
本页不做这些事:不做 Unicode 双向算法(阿拉伯文、希伯来文那种从右往左的排版属于 bidi 的职责,不是倒序)、不做字素簇倒序(那需要 Intl.Segmenter)、不把字母替换成镜像字符去「假装倒序」(那是页脚「倒序首符文本生成器」在做的事)、也不做去重与排序。
倒序原理:码元、码点、字素簇的三级之差
这个文本倒序工具的核心只有一行:Array.from(text).reverse().join('')。Array.from() 对字符串走的是 ECMAScript 规定的字符串迭代器(String.prototype[Symbol.iterator]),每次吐出一个码点;而 text.split('') 走的是按 UTF-16 码元切分。两者只在遇到补充平面字符时才有区别——而这就是全部区别所在。
UTF-16 是变长编码:基本平面 U+0000–U+FFFF 用一个码元,补充平面 U+10000–U+10FFFF 用一对代理码元表示,高代理占 U+D800–U+DBFF(共 1024 个)、低代理占 U+DC00–U+DFFF(1024 个),两者组合能表示 1048576 个码点。所以 😀(U+1F600)是 1 个码点、2 个码元。按码元倒序时两个代理会被拆开并交换位置,「低代理在前、高代理在后」不是合法序列,渲染引擎只能各给一个替换字形。
更麻烦的是它还会凑出新字符。对 😀😁(4 个码元)按码元倒序,结果是「低代理、高代理、低代理、高代理」,中间那对「高代理 + 低代理」会重新拼成一个合法代理对,于是屏幕上出现「一个乱码 + 一个表情 + 一个乱码」。顺带说明一个反直觉的事实:孤立代理码元不会让程序报错,x.length 照算,Array.from(x) 也会老老实实把它们各算一个码点。你没法靠异常发现它,只能看屏幕或数这三个计数。
第三个单位是字素簇(grapheme cluster,Unicode 附件 UAX #29 定义),也就是「人眼看成一个字」的最小单位。以示例里的 café 为例:它有预组合的 U+00E9(1 码点)和 e 加组合尖音符 U+0301(2 码点)两种写法,屏幕上完全一样;按码点倒序时只有分解写法会露出马脚——组合符被推到字母前面,不再附着在 e 上,看起来像多出一个小符号。示例全文 35 个码点、34 个字素,差的这 1 个就是它。
电子国旗是另一个典型:🇨🇳 由两个区域指示符组成,U+1F1E8(C)和 U+1F1F3(N),2 个码点 1 个字素。按码点倒序后得到 U+1F1F3 + U+1F1E8,也就是 🇳🇨——一个完全合法的序列,只是它代表的是新喀里多尼亚。码点倒序对这种序列「不产生乱码,但会改变语义」,这是它和字素倒序最本质的差别。
那为什么不直接按字素簇倒序?因为字素边界表很大,浏览器里只有 Intl.Segmenter 提供这个能力,而它并非所有环境都可用,用一次还要额外构造迭代器。码点倒序是「不依赖运行时环境、结果完全可复现」的中间口径:对 emoji 和生僻字安全,对组合符、国旗、ZWJ 序列只保证不产生非法序列。所以页面上额外给出字素数,让你一眼看到这两种口径差了几位;不支持 Intl.Segmenter 的旧浏览器里,字素数会退化成码点数。
三种模式的实现差别也一并写清:整串倒序就是上面那一行;按行倒序先用 split(/\r\n|\r|\n/) 切行(CRLF、CR、LF 都认),reverse() 之后统一用 LF 连接;按词倒序先切行,每行用 split(/\s+/) 切词、丢掉空串、反转后用单个空格连接。三步都是线性扫描,10 万码点量级下 Array.from() 产生一个约 10 万元的临时数组,在现代浏览器里是一次很轻的分配,不会卡住界面。
完整示例(数值均为实测)
示例就是页面打开时文本框里那段文字,3 行、36 个码元、35 个码点、34 个字素。逐行拆开看:第 1 行柳泉工具文本倒序😀含 1 个 emoji(2 码元 / 1 码点);第 2 行Hello world 你好含 2 个英文单词与 2 个汉字;第 3 行café 组合字符里的 é 是分解写法,占用 e 与 U+0301 两个码点。
同一段示例在三种模式下的输出如下,可以直接和页面结果区对照:
| 模式 | 输出 | 要点 |
|---|---|---|
| 整串倒序 | 符字合组 ́efac 好你 dlrow olleH 😀序倒本文具工泉柳 | 原第 3 行跑到第 1 行;组合尖音符被推到 e 前面,单独占位 |
| 按行倒序 | café 组合字符 Hello world 你好 柳泉工具文本倒序😀 | 三行顺序颠倒,每行内容一字未动,emoji 仍在原位 |
| 按词倒序 | 柳泉工具文本倒序😀 你好 world Hello 组合字符 café | 行序不变;café 作为整词挪到行尾,组合符仍贴着 e |
数字对得上:码元 36 − 码点 35 = 1,正是那个 emoji;码点 35 − 字素 34 = 1,正是那个组合尖音符。另外注意按词倒序那一行——它输出的 café 依然是完整的,因为按词倒序只搬动词块,不拆词。
输入范围与对照表
同一段文字用三种口径去数,结果不同是常态。下表是示例文本的逐项核对:
| 口径 | 结果 | 怎么来的 |
|---|---|---|
| UTF-16 码元数 | 36 | 等价于 x.length,emoji 算 2 |
| Unicode 码点数 | 35 | Array.from(x).length,emoji 算 1 |
| 字素簇数 | 34 | Intl.Segmenter 按 UAX #29 切分,e + U+0301 算 1 个 |
| 行数 / 词数 | 3 / 6 | 按 \r\n|\r|\n 切行,按 \S+ 数词 |
| 汉字数 | 14 | 落在 U+4E00–U+9FFF 的字符 |
输入范围与限制:
- 文本上限 10 万字符(输入框的
maxlength,按 UTF-16 码元计)。纯汉字能放满 10 万,纯表情只能放约 5 万个——每个补充平面字符占 2 个码元。示例文本 36 个码元,只占了上限的 0.036%。 - 三种模式都接受多行文本,行数不限,只要总长度不超上限。空行会被保留并参与倒序。
- 模式只有三个值,由下拉框给出,不存在非法取值。下拉框上按 Enter 等同于点「开始倒序」。
- 不支持自定义分隔符。按词倒序固定以「一个及以上空白字符」为界,不会按标点切词——所以
你好,世界是一个词而不是两个。 - 不做繁简、全半角、大小写的任何归一,输入是什么码点,输出就是这些码点的逆序。
结果边界与精度说明
- 按行倒序时,末尾的空行会跑到开头。如果输入以换行结束,切行后会多出一个空字符串元素,它被一起倒序后就变成开头的一行。这是「整行参与倒序」的必然结果,不是缺陷;把末尾那个换行删掉即可。
- 输出统一使用 LF(\n)换行。输入里的 CRLF 会被
split(/\r\n|\r|\n/)归一,因为如果把\r一起倒序,它会跑到行首变成看不见的控制字符,反而更难排查。 - 按词倒序会压掉多余空白:行首缩进、连续多个空格、制表符在切词时都被丢弃,词与词之间统一用一个空格连接,行首行尾不再留空白。需要保留原始排版时请用按行倒序。
- 码点倒序不是字素倒序。组合附加符、肤色修饰符、区域指示符对、ZWJ 序列在码点倒序后可能不再附着,或被重新解释成另一个合法序列(国旗
🇨🇳会变成🇳🇨)。要逐字素处理请改用支持Intl.Segmenter的环境自行处理。 - 输入本身含孤立代理码元时,工具原样搬运,既不修复也不报错,因为 JavaScript 字符串允许这种状态,且无法判断它的真实意图。
- 空文本时八张统计卡全部显示 0,包括行数与词数——不会出现「1 行」这种看着就不对的默认值;结果区同样清空。
- 只有空白时会给出提示而不是静默输出,因为「把空格倒过来」没有任何意义。
- 不做四舍五入、不产生浮点数。本页只做字符串与数组操作,所有计数都是整数,结果区不存在精度损失;唯一的「丢失」来自上面提到的空白压缩与换行归一,两者都会在提示里说明。
常见问题
为什么我倒序之后 emoji 变成了两个方块?
因为你用的那一步是按 UTF-16 码元拆的,也就是 split('') 或某些语言的逐字节遍历。一个 emoji 在 UTF-16 里是两个码元,拆开后两个代理会交换位置,形成非法序列,渲染引擎只能各给一个替换字形。本页用 Array.from() 按码点拆,emoji 始终是一个整体。
「整串倒序」和「按行倒序」到底差在哪?
整串倒序把换行符也当成普通字符参与排队,所以行与行会换位,同时每行内部的字符也会从后往前翻。按行倒序只交换行的位置,每行内容一字不动。示例里整串倒序第一行是 符字合组 ́efac,按行倒序第一行是 café 组合字符——前者字符级反转,后者只是换了个顺序。
按行倒序后开头多了一个空行,是我哪里操作错了吗?
这是末尾换行被一起倒序的结果。输入以换行结尾时,切行会多出一个空字符串元素,倒序后它排到最前面,看起来就是顶部多了一行。把末尾那个换行删掉,或者先清空再重新粘贴即可。
组合字符倒序后位置怪怪的,能修好吗?
按码点倒序修不好,因为组合符和它附着的字母是两个独立码点,倒序后组合符会跑到字母前面、去附着前一个字符。示例第 3 行的 café 用的是分解写法,倒序后就出现了这个现象。想做字素级倒序需要 Intl.Segmenter 按 UAX #29 切分,本页不提供;实际业务里更稳妥的做法是先把文本规范化为组合形式(NFC),让 é 变成单个码点 U+00E9。
国旗倒序后变成了另一个国家的旗子,这算 bug 吗?
不算 bug,但确实是个坑。国旗由两个区域指示符码点组成,码点倒序后仍是合法序列,只是表示另一个国家:🇨🇳 倒过来是 🇳🇨。本页在原理一节把这个边界写在明处,就是希望你别拿它处理旗帜、肤色修饰符和 ZWJ 表情组合。
倒序会改变文本的存储大小吗?
码元数和 UTF-8 字节数都不会变——倒序只改变顺序,不改变字符本身。但按词倒序因为会压掉多余空格,字符数通常会变少;按行倒序在把 CRLF 归一成 LF 时,每个换行会少 1 个码元(2 个码元变 1 个)。
超过 10 万字符会怎样?
输入框到达上限后不再接收新内容,已经在里面的文字不会被截断也不会丢失。要处理更长的文本,建议先按行拆成几段分别倒序,再手动拼接——因为本页是纯浏览器端运算,10 万码点以上虽然仍能跑,但粘贴和渲染本身会开始变慢。
我输入的文本会被上传或者保存吗?
不会。这个文本倒序工具的三种模式全部由页面里的字符串与数组运算完成,没有任何网络请求,也不写浏览器存储,刷新页面后输入框会回到那段预置示例。想自己确认,打开开发者工具的 Network 面板,改几次输入观察有没有请求发出即可。