突发奇想想给自己的文章加个字数统计,试图用纯前端的方式实现,在ai的帮助下我这个前端菜鸡总算是勉勉强强实现了orz。
<script>
class CharCounter {
constructor(article) {
this.article = article;
this.timers = new Set();
this.animationFrames = new Set();
this.isAnimating = false;
this.mainInterval = null;
this.isDestroyed = false; // 添加销毁标记
this.init();
}
init() {
const lastP = this.article.getElementsByTagName('p')[this.article.getElementsByTagName('p').length-1];
this.finalCount = (this.article.innerText.match(/[\u4e00-\u9fff]/g)||[]).length;
lastP.insertAdjacentHTML('afterend',
`<div id="charCounter" style="
text-align: center;
font-size: 10px;
margin: 32px auto;
color: currentColor;
opacity: 0.7;
font-family: system-ui;
position: relative;
">本文共计 <span id="charCountWrapper" style="
display: inline-flex;
font-weight: 700;
font-size: 1.3em;
filter: contrast(1.7) brightness(1.3);
text-shadow: 0 0 0.5px rgba(0,0,0,0.15);
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum';
letter-spacing: 1px;
"></span> 字</div>`
);
this.wrapper = document.getElementById('charCountWrapper');
this.digits = String(this.finalCount).split('');
this.slots = [];
this.digits.forEach(() => {
const slot = this.createDigitSlot();
this.slots.push(slot);
this.wrapper.appendChild(slot);
});
// 绑定清理方法到页面卸载事件
window.addEventListener('unload', () => this.destroy());
}
createDigitSlot() {
const slot = document.createElement('span');
slot.style.cssText = `
display: inline-block;
width: 0.6em;
height: 1.2em;
overflow: hidden;
position: relative;
`;
return slot;
}
destroy() {
this.isDestroyed = true;
this.cleanup();
}
cleanup() {
// 清理所有定时器
this.timers.forEach(timer => {
clearTimeout(timer);
clearInterval(timer);
});
this.timers.clear();
// 清理所有动画帧
this.animationFrames.forEach(frame => cancelAnimationFrame(frame));
this.animationFrames.clear();
// 清理主循环定时器
if (this.mainInterval) {
clearInterval(this.mainInterval);
this.mainInterval = null;
}
this.isAnimating = false;
}
wait(ms) {
return new Promise(resolve => {
if (this.isDestroyed) {
resolve();
return;
}
const timer = setTimeout(() => {
this.timers.delete(timer);
resolve();
}, ms);
this.timers.add(timer);
});
}
getRandomDigit(lastNumber) {
let newNumber;
do {
newNumber = Math.floor(Math.random() * 10);
} while (newNumber === lastNumber);
return newNumber;
}
startRandomizing(startIndex) {
if (this.isDestroyed) return null;
const interval = setInterval(() => {
if (!this.isAnimating || this.isDestroyed) {
clearInterval(interval);
this.timers.delete(interval);
return;
}
for (let i = startIndex; i < this.slots.length; i++) {
this.slots[i].textContent = this.getRandomDigit(
parseInt(this.slots[i].textContent) || 0
);
}
}, 50);
this.timers.add(interval);
return interval;
}
animateDigit(slot, finalDigit) {
return new Promise((resolve) => {
if (this.isDestroyed) {
resolve();
return;
}
let startTime;
let lastUpdateTime = 0;
let lastNumber = null;
const duration = 2000;
const updateDigit = (timestamp) => {
if (!this.isAnimating || this.isDestroyed) {
resolve();
return;
}
if (!startTime) startTime = timestamp;
const elapsed = timestamp - startTime;
const progress = Math.min(elapsed / duration, 1);
const minUpdateInterval = 50 + (progress * 100);
if (timestamp - lastUpdateTime < minUpdateInterval) {
const frame = requestAnimationFrame(updateDigit);
this.animationFrames.add(frame);
return;
}
lastUpdateTime = timestamp;
if (progress < 1) {
const easeOut = t => Math.sin((t * Math.PI) / 2);
const slowdown = easeOut(progress);
if (Math.random() > slowdown) {
let newNumber;
const range = Math.max(1, Math.floor((1 - progress) * 10));
const finalDigitNum = parseInt(finalDigit);
do {
const offset = Math.floor(Math.random() * range) - Math.floor(range / 2);
newNumber = (finalDigitNum + offset + 10) % 10;
} while (newNumber === lastNumber);
lastNumber = newNumber;
slot.textContent = newNumber;
} else {
lastNumber = parseInt(finalDigit);
slot.textContent = finalDigit;
}
const frame = requestAnimationFrame(updateDigit);
this.animationFrames.add(frame);
} else {
slot.textContent = finalDigit;
resolve();
}
};
const frame = requestAnimationFrame(updateDigit);
this.animationFrames.add(frame);
});
}
async startAnimation() {
if (this.isAnimating || this.isDestroyed) return;
this.cleanup();
this.isAnimating = true;
const counter = document.getElementById('charCounter');
if (!counter || this.isDestroyed) return;
try {
counter.style.opacity = '0.6';
let randomizeInterval = this.startRandomizing(0);
await this.wait(1000);
for (let i = 0; i < this.digits.length; i++) {
if (!this.isAnimating || this.isDestroyed) break;
if (randomizeInterval) {
clearInterval(randomizeInterval);
this.timers.delete(randomizeInterval);
}
randomizeInterval = this.startRandomizing(i + 1);
await this.animateDigit(this.slots[i], this.digits[i]);
if (i < this.digits.length - 1) {
await this.wait(1000);
}
}
if (randomizeInterval) {
clearInterval(randomizeInterval);
this.timers.delete(randomizeInterval);
}
counter.style.opacity = '0.7';
} finally {
this.isAnimating = false;
// 移除循环逻辑,动画只执行一次
}
}
start() {
if (!this.isDestroyed) {
this.startAnimation();
}
}
}
// 全局实例存储
let counterInstance = null;
function addCounter() {
const article = document.querySelector('article');
if (!article?.getElementsByTagName('p').length || document.querySelector('#charCounter')) {
return;
}
// 如果已有实例,先销毁
if (counterInstance) {
counterInstance.destroy();
}
counterInstance = new CharCounter(article);
counterInstance.start();
}
// 只在页面加载完成后执行一次
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', addCounter);
} else {
addCounter();
}
</script>
其实后面都是花里胡哨的动画·····
评论区