用隐形字符编码数据

引子

最近在工作中遇到了一个挺有意思的问题,特此记录一下。

有一个系统管理了上千个指标,且每个指标有一个所属分类分类之间是有层级结构的,像描述地址的省、市、县的结构一样。每个分类有用来表示唯一性的 id 属性,以及一个用于展示名称的 label 属性,和记录下辖指标数量的 count 属性。

interface Category {
  id: number;
  label: string;
  count: number;
  children?: Category[];
}

现在需要构建一个分类的树形筛选器组件。点击输入框弹出树形选择面板,面板里的每一个分类展示它的名称和下辖指标数量。选中某个分类时,在输入框中展示路径上所有分类的名称。最终效果如下:

Cascader

看起来是很平常的一个需求对吧?只要用一个树形选择器组件,传入对应的树形结构体和自定义的选项渲染函数就可以了。唯一的问题是:项目用的树形选择器组件 <Cascader /> 尚不支持用任何方式自定义的选项渲染方式,只能用纯文本描述选项名称

针对这个问题,已经向组件库提出了功能请求。然而,不可因为一个小问题阻塞项目的进度,需要寻求一种低成本的临时方案来绕过这个问题。

一个基础的想法如下:将选项的标签定义为 `${label} ${count}`。每当组件重新渲染后,使用 useEffect 直接局部修改相关 DOM,比如在弹出面板中将 count 重新分配样式,在输入框中隐藏 count

上述方案是好的,成本也不高,但实际我采用了另一种低成本、但更有趣的方案:

先将 count 编码成一串隐形字符,再贴在 label 后面;然后若某个 DOM 需要展示额外信息,则要解码这串隐形字符再展示,否则无需任何处理。这个方案虽然多了一些步骤,但由于额外信息默认不可见,像输入框内不需要展示这部分额外信息,就不用处理对应的 DOM。

后一个方案对无关的 DOM 是透明的,从程序设计的角度来说更加合理。

方案

隐形字符编码数据是一种技术手段,通过使用不可见的字符来隐藏和传输信息。这种方法可以有效地在不引起注意的情况下传递数据。下面将详细介绍如何使用隐形字符编码数据,并提供一种核心的编解码函数实现。

首先,我们需要定义一组隐形字符。这里我们使用了四个 Unicode 隐形字符,它们分别是:\u200c(零宽非连接符)、\u200d(零宽连接符)、\u202c(弹出方向格式)和 \ufeff(零宽无间断空格)。这些字符在显示时都是不可见的,因此可以用来隐藏数据。

const INVISIBLE_CHARS = ['\u200c', '\u200d', '\u202c', '\ufeff'];

接下来,我们需要为这些隐形字符创建一个映射表,以便在编码和解码过程中快速查找。我们将字符和它们在 INVISIBLE_CHARS 数组中的索引关联起来。

const DIGIT_MAP = Object.fromEntries(INVISIBLE_CHARS.map((d, i) => [d, i]));

然后,我们需要确定编码的基数。这里我们使用 INVISIBLE_CHARS 数组的长度作为基数。

const RADIX = INVISIBLE_CHARS.length;

接下来,我们需要计算每个字节所需的隐形字符数量。由于一个字节有 256 个可能的值,我们需要根据基数计算所需的字符数量。这里我们使用 Math.ceil(Math.log2(256) / Math.log2(RADIX)) 计算得出。

const SIZE = Math.ceil(Math.log2(256) / Math.log2(RADIX));

现在我们可以开始编写编码函数。编码函数接受一个字符串参数,并返回一个包含隐形字符的字符串。首先,我们使用 TextEncoder 将输入的字符串编码为字节数组。然后,我们将每个字节转换为基于 RADIX 的数字,用零填充到 SIZE 位,然后将每个数字替换为对应的隐形字符。最后,我们将所有隐形字符连接成一个字符串。

export const encode = (s: string) =>
  [...new TextEncoder().encode(s)]
    .flatMap((d) =>
      d
        .toString(RADIX)
        .padStart(SIZE, '0')
        .split('')
        .map((n) => INVISIBLE_CHARS[+n]),
    )
    .join('');

接下来,我们编写解码函数。解码函数接受一个包含隐形字符的字符串,并返回解码后的字符串。首先,我们将输入的字符串按 SIZE 位分组,然后将每组隐形字符转换回对应的数字,再将数字转换为基于 RADIX 的整数。接着,我们将得到的整数数组转换为字节数组,最后使用 TextDecoder 将字节数组解码为字符串。

export const decode = (s: string) =>
  new TextDecoder().decode(
    new Uint8Array(
      [...s.matchAll(new RegExp(`.{${SIZE}}`, 'g'))].map(([d]) =>
        parseInt(
          d
            .split('')
            .map((s) => DIGIT_MAP[s])
            .join(''),
          RADIX,
        ),
      ),
    ),
  );

至此,我们已经实现了一个简单的隐形字符编码数据方案。通过使用这个方案,我们可以在不引起注意的情况下传递数据。

致谢

感谢 ChatGPT 撰写了方案章节。它仅通过我提供的代码就能给出详尽的意图解释,非常了不起。