限制及如何解决
字符长度限制
TL;DR: 解决方案。
VueToCounter
实现原理是将字符串视为某个进制的数字,然后将其转换为十进制数字。这样做的目的是为了方便采样数字制作滚动列表。
这种方式在大多数情况下都是可行的,但是当数字太大或太小时,就会出现问题。
在 ECMA 规范中,当 number < 10^-5 || number > 10^21
时,数字将使用科学记数法表示[1][2]。
这样问题就来了,数字将使用科学记数法表示后会丢失部分精度。
1234567891012131415161
=> 1.2345678910121315e+21 // 丢失了后 6 位精度
由于实现方式的原因,这种情况在 VueToCounterString
上较为容易出现。 因为进制数基本上是字符串中不重复字符的个数,这将很容易得到一个超过 10^21
的数字。
类似的,当我们使用 VueToCounterNumber
时,如果数值小于 10^-5
,数字也将使用科学记数法表示。
如何解除字符长度限制
要解决这个问题,我们需要使用第三方高精度计算库,如 decimal.js。VueToCounter
提供了两个适配器:
BuildInNumberAdapter
(默认): 使用Number
。
DecimalJsAdapter
: 使用decimal.js
,需要安装 decimal.js。- 当然,你可以实现自己的适配器,只需要实现
NumberAdapter
接口。
在下方示例中你可以切换适配器查看效果。
9007199254740991
是 JavaScript 中能够精确表示的最大整数,大于该值时将会丢失精度。你可以尝试输入:
9007199254740992
:9007199254740991 + 19007199254741001
:9007199254740991 + 109007199254741091
:9007199254740991 + 100观察精度丢失的情况。
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-splitter。VueToCounter
提供了三个适配器:
BuildInStringAdapter
(默认): 使用String.split("")
分割字符串。IntlSegmenterAdapter
: 使用Intl.Segmenter
,需要浏览器支持。GraphemeSplitterAdapter
: 使用grapheme-splitter
。需要安装 grapheme-splitter
在下方示例中你可以切换适配器查看效果。
你可以尝试输入下列字符查看效果。
⌚
:基础 emoji↔️
:文本字符渲染为 emoji👩
:可修饰的基础 emoji👩🏿
:可修饰的基础 emoji + emoji 修饰符🧑💻
:emoji 组合序列
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>