Skip to content

限制及如何解决

字符长度限制

TL;DR: 解决方案

TimeredCounter 实现原理是将字符串视为某个进制的数字,然后将其转换为十进制数字。这样做的目的是为了方便采样数字制作滚动列表。

这种方式在大多数情况下都是可行的,但是当数字太大或太小时,就会出现问题。

在 ECMA 规范中,当 number < 10^-5 || number > 10^21 时,数字将使用科学记数法表示[1][2]

这样问题就来了,数字将使用科学记数法表示后会丢失部分精度。

    1234567891012131415161
=>  1.2345678910121315e+21 // 丢失了后 6 位精度

由于实现方式的原因,这种情况在 timered-counter-string 上较为容易出现。 因为进制数基本上是字符串中不重复字符的个数,这将很容易得到一个超过 10^21 的数字。

类似的,当我们使用 timered-counter-number 时,如果数值小于 10^-5,数字也将使用科学记数法表示。

如何解除字符长度限制

要解决这个问题,我们需要使用第三方高精度计算库,如 decimal.jsTimeredCounter 提供了两个适配器:

  1. BuildInNumberAdapter(默认): 使用 Number
  1. DecimalJsAdapter: 使用 decimal.js,需要安装 decimal.js
  2. 当然,你可以实现自己的适配器,只需要实现 NumberAdapter 接口。

在下方示例中你可以切换适配器查看效果。

9007199254740991 是 JavaScript 中能够精确表示的最大整数,大于该值时将会丢失精度。你可以尝试输入:

  1. 9007199254740992:9007199254740991 + 1
  2. 9007199254741001:9007199254740991 + 10
  3. 9007199254741091:9007199254740991 + 100

观察精度丢失的情况。

数字适配器CodeSandbox Logo



点击查看代码
vue
<script setup>
import { ref, watch } from "vue";
import { setNumberAdapter } from "timered-counter";

const number = ref(Number.MAX_SAFE_INTEGER.toString(10));

const adapters = [
  { label: "BuildInNumberAdapter", value: "number" },
  // { label: "BuildInBigintAdapter", adapter: BuildInBigintAdapter() },
  { label: "DecimalJsAdapter", value: "decimal.js" },
];
const adapterIndex = ref(0);
watch(adapterIndex, (index) => setNumberAdapter(adapters[index].value));

const realRenderNumber = ref();
watch([number, adapterIndex], () => (realRenderNumber.value = number.value), {
  immediate: true,
});
const counterRef = ref();
function handleAnimationEnd() {
  realRenderNumber.value = counterRef.value?.getAttribute("aria-label");
}

function handleInput(e) {
  number.value = e.target.value
    .split("")
    .filter((c) => (c >= "0" && c <= "9") || c === "-" || c === ".")
    .join("");
}
</script>

<template>
  <div class="text-center">
    <timered-counter-number
      ref="counterRef"
      :key="adapters[adapterIndex].value"
      :value="number"
      @timered-counter-animation-end="handleAnimationEnd"
    />
  </div>
  <div
    v-if="realRenderNumber !== number"
    class="bg-[var(--vp-c-danger-soft)] p-2 rounded mt-4"
  >
    Mismatch: should be
    <span class="text-[var(--vp-c-danger-1)]">{{ number }}</span> but rendered
    as <span class="text-[var(--vp-c-danger-1)]">{{ realRenderNumber }}</span>
  </div>
  <hr />
  <div class="flex gap-4">
    <textarea
      class="flex-auto border border-solid p-1"
      @input="handleInput"
      :value="number"
    />
    <select
      v-model="adapterIndex"
      class="self-start border border-solid p-1 appearance-auto"
    >
      <option v-for="(adapter, index) in adapters" :key="index" :value="index">
        {{ adapter.label }}
      </option>
    </select>
  </div>
</template>

<style scoped></style>

支持 emoji 分词

对于一般的 emoji,我们可以直接使用默认的配置,但是对于一些特殊emoji,如 👨‍👩‍👧‍👦。他们被称为 Emoji ZWJ Sequence,是由多个 emoji 组合而成的。

这种情况下,难以将包含该 emoji 的字符串正确的识别。我们可以使用 Intl.Segmenter 或第三方库。 如 grapheme-splitterTimeredCounter 提供了三个适配器:

  1. BuildInStringAdapter(默认): 使用 String.split("") 分割字符串。
  2. IntlSegmenterAdapter: 使用 Intl.Segmenter,需要浏览器支持。
  3. GraphemeSplitterAdapter: 使用 grapheme-splitter。需要安装 grapheme-splitter

在下方示例中你可以切换适配器查看效果。

你可以尝试输入下列字符查看效果。

  • :基础 emoji
  • ↔️:文本字符渲染为 emoji
  • 👩:可修饰的基础 emoji
  • 👩🏿:可修饰的基础 emoji + emoji 修饰符
  • 🧑‍💻:emoji 组合序列
字符串适配器CodeSandbox Logo



点击查看代码
vue
<script setup>
import { onMounted, ref, watch } from "vue";
import { setStringAdapter } from "timered-counter";

const string = ref("emoji 🎉 🧑‍💻");

const adapters = [
  { label: "BuildInStringAdapter", value: "string" },
  {
    label: "BuildInIntlSegmenterAdapter",
    value: "intl-segmenter",
  },
  { label: "GraphemeSplitterAdapter", value: "grapheme-splitter" },
];
const adapterIndex = ref(0);
watch(adapterIndex, (index) => setStringAdapter(adapters[index].value));

const realRenderString = ref();
watch([string, adapterIndex], () => (realRenderString.value = string.value), {
  immediate: true,
});
const counterRef = ref();
function handleAnimationEnd() {
  realRenderString.value = counterRef.value?.getAttribute("aria-label");
}
onMounted(() => setTimeout(() => handleAnimationEnd(), 1000));
</script>

<template>
  <div class="text-center">
    <timered-counter-string
      ref="counterRef"
      :value="string"
      :key="adapters[adapterIndex].value"
      @timered-counter-animation-end="handleAnimationEnd"
    />
  </div>
  <div
    v-if="realRenderString !== string"
    class="bg-[var(--vp-c-danger-soft)] p-2 rounded mt-4"
  >
    Mismatch: should be
    <span class="text-[var(--vp-c-danger-1)]">{{ string }}</span> but rendered
    as <span class="text-[var(--vp-c-danger-1)]">{{ realRenderString }}</span>
  </div>
  <hr />
  <div class="flex gap-4">
    <textarea class="flex-auto border border-solid p-1" v-model="string" />
    <select
      v-model="adapterIndex"
      class="self-start border border-solid p-1 appearance-auto"
    >
      <option v-for="(adapter, index) in adapters" :key="index" :value="index">
        {{ adapter.label }}
      </option>
    </select>
  </div>
</template>

<style scoped></style>

  1. https://tc39.es/ecma262/multipage/ecmascript-data-types-and-values.html#sec-numeric-types-number-tostring ↩︎

  2. https://medium.com/@anna7/large-numbers-in-js-4feb6269d29b ↩︎