Skip to content

限制及如何解决

字符长度限制

TL;DR: 解决方案

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

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

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

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

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

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

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

如何解除字符长度限制

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

  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

099000000077011099099022055044077044000099099011


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

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

const adapters = [
  { label: "BuildInNumberAdapter", adapter: BuildInNumberAdapter() },
  // { label: "BuildInBigintAdapter", adapter: BuildInBigintAdapter() },
  { label: "DecimalJsAdapter", adapter: DecimalJsAdapter() },
];
const adapterIndex = ref(0);

const realRenderNumber = ref();
watch([number, adapterIndex], () => (realRenderNumber.value = number.value), {
  immediate: true,
});
function handleAnimationEnd({ direction, data }) {
  realRenderNumber.value = data
    .map(({ digits }) =>
      digits.map(({ data }) => data[direction === "up" ? data.length - 1 : 0])
    )
    .flat()
    .join("");
}

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

<template>
  <div class="text-center">
    <vue-to-counter-number
      :value="number"
      :number-adapter="adapters[adapterIndex].adapter"
      @roll-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>
vue
<script setup>
import StackblitzLogo from "../assets/stackblitz-logo.svg";
import { ref, toRefs } from "vue";
import { getCodeStackblitzParams } from "./generate-stackblitz-params";
import sdk from "@stackblitz/sdk";
import packageInfo from "../../vue-to-counter/package.json";

const props = defineProps({
  title: {
    type: String,
    required: true,
  },
});
const { title } = toRefs(props);

const containerRef = ref();

function handleStackblitz() {
  if (!containerRef.value) return;

  const tabs = containerRef.value.querySelectorAll(".tabs > label");
  const blocks = containerRef.value.querySelectorAll(".blocks > div code");
  if (tabs.length !== blocks.length) {
    window.alert("The number of tabs and code blocks should be the same.");
    return;
  }

  const files = Array.from(tabs).map((tab, index) => ({
    filename: tab.textContent.trim(),
    content: blocks[index].textContent,
  }));

  const params = getCodeStackblitzParams(files, {
    title: `${title.value} - vue-to-counter@${packageInfo.version}`,
  });
  sdk.openProject(params, {
    openFile: "src/demo.vue",
  });
}
</script>

<template>
  <div ref="containerRef" class="demo-container">
    <div class="flex relative">
      <span class="flex-auto" />
      <span
        title="Open In Stackblitz"
        class="inline-block p-1 cursor-pointer hover:outline hover:outline-[#1389FD] outline-1"
        @click="handleStackblitz"
      >
        <img
          class="h-4 w-4 pointer-events-none"
          :src="StackblitzLogo"
          alt="CodeSandbox Logo"
        />
      </span>
    </div>
    <hr />
    <slot />
  </div>
</template>

<style lang="scss">
.demo-container {
  @apply flex flex-col justify-center border p-4 rounded-lg mt-4 text-sm;

  .vue-to-counter {
    @apply font-mono text-4xl;
  }

  .custom-block {
    @apply m-0;
  }
}
</style>

支持 emoji 分词

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

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

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

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

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

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

0ee0mm0oo0jj0ii0  0🎉🎉0  0🧑🧑00💻💻


点击查看代码
vue
<script setup>
import { ref, watch } from "vue";
import {
  BuildInStringAdapter,
  BuildInIntlSegmenterAdapter,
  GraphemeSplitterAdapter,
} from "vue-to-counter";

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

const adapters = [
  { label: "BuildInStringAdapter", adapter: BuildInStringAdapter() },
  {
    label: "BuildInIntlSegmenterAdapter",
    adapter: BuildInIntlSegmenterAdapter(),
  },
  { label: "GraphemeSplitterAdapter", adapter: GraphemeSplitterAdapter() },
];
const adapterIndex = ref(0);

const realRenderString = ref();
watch([string, adapterIndex], () => (realRenderString.value = string.value), {
  immediate: true,
});
function handleAnimationEnd({ direction, data }) {
  realRenderString.value = data
    .map(({ digits }) =>
      digits.map(({ data }) => data[direction === "up" ? data.length - 1 : 0])
    )
    .flat()
    .join("");
}
</script>

<template>
  <div class="text-center">
    <vue-to-counter-string
      :value="string"
      :string-adapter="adapters[adapterIndex].adapter"
      @roll-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>
vue
<script setup>
import StackblitzLogo from "../assets/stackblitz-logo.svg";
import { ref, toRefs } from "vue";
import { getCodeStackblitzParams } from "./generate-stackblitz-params";
import sdk from "@stackblitz/sdk";
import packageInfo from "../../vue-to-counter/package.json";

const props = defineProps({
  title: {
    type: String,
    required: true,
  },
});
const { title } = toRefs(props);

const containerRef = ref();

function handleStackblitz() {
  if (!containerRef.value) return;

  const tabs = containerRef.value.querySelectorAll(".tabs > label");
  const blocks = containerRef.value.querySelectorAll(".blocks > div code");
  if (tabs.length !== blocks.length) {
    window.alert("The number of tabs and code blocks should be the same.");
    return;
  }

  const files = Array.from(tabs).map((tab, index) => ({
    filename: tab.textContent.trim(),
    content: blocks[index].textContent,
  }));

  const params = getCodeStackblitzParams(files, {
    title: `${title.value} - vue-to-counter@${packageInfo.version}`,
  });
  sdk.openProject(params, {
    openFile: "src/demo.vue",
  });
}
</script>

<template>
  <div ref="containerRef" class="demo-container">
    <div class="flex relative">
      <span class="flex-auto" />
      <span
        title="Open In Stackblitz"
        class="inline-block p-1 cursor-pointer hover:outline hover:outline-[#1389FD] outline-1"
        @click="handleStackblitz"
      >
        <img
          class="h-4 w-4 pointer-events-none"
          :src="StackblitzLogo"
          alt="CodeSandbox Logo"
        />
      </span>
    </div>
    <hr />
    <slot />
  </div>
</template>

<style lang="scss">
.demo-container {
  @apply flex flex-col justify-center border p-4 rounded-lg mt-4 text-sm;

  .vue-to-counter {
    @apply font-mono text-4xl;
  }

  .custom-block {
    @apply m-0;
  }
}
</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 ↩︎