1、方式一 :使用插件 typed.js
typed.js 网站地址,点我打开
1.1、核心代码如下:
//TypeWriteEffect/index.tsx 组件
import React, { useEffect, useRef } from 'react';
import Typed from 'typed.js';
import { PropsType } from './index.d';
const TypeWriteEffect: React.FC<PropsType> = ({ text = '', callback, seed = 20 }) => {
const el = useRef(null);
useEffect(() => {
const typed = new Typed(el.current, {
strings: [text],
typeSpeed: seed,
showCursor: true,
onComplete(self) {
callback?.();
self.cursor.style.display = 'none'; // 隐藏光标
},
});
return () => {
typed.destroy();
};
}, []);
return (
<div>
<span ref={el}></span>
</div>
);
};
export default TypeWriteEffect;
// index.d.ts
export type PropsType = {
text: string; //文本内容
seed?: number; //速度
callback?: () => void; //打印结束后的回调函数
};
1.2、使用
/*
* @Description:
* @Author: muge
* @LastEditors: muge
*/
import TypeWriteEffect from '@/components/TypeWriteEffect';
import React from 'react';
const Index = () => {
const richText =
'<code>2112.1</code>这是<span class="typing-text" style="color: red">智能问答小助手--</span>的响应文本----很长很长的的。<div style="color: pink; font-size: 20px">原神*启动!</div>---王者*启动!<img src="https://nimg.ws.126.net/?url=http%3A%2F%2Fdingyue.ws.126.net%2F2022%2F0830%2F74168ba1j00rhf6m5002cd000u000jfp.jpg&thumbnail=660x2147483647&quality=80&type=jpg" style="height: 150px"/>';
return <TypeWriteEffect text={richText} />;
};
export default Index;
1.3、效果如图
2、方式二:自定义实现
2.1、思路
我的思路是将字符串切割成两个数组,一个是 <></>的标签数组,一个是按字符和标签截取的数组,效果如图:
然后遍历chucksList生成新的数组,如下图:
然后遍历这个数组,使用定时器插入dom即可
2.2、核心代码
2.2.1、writeEffect.ts
// utils/writeEffect/index.ts
import type { TypingEffectType } from './index.d';
import initData from './lib/tool';
import { createBlinkSpan } from './lib/createBlinkSpan';
import { textConversionArr } from './lib/textConversionArr';
import { getCursorClassName } from './lib/getCursorClassName';
import { removeCursor } from './lib/removeCursor';
/**
* @description: 光标打印效果
* @param {HTMLElement} dom
* @param {TypingEffectType} parameter
* @author: muge
*/
export const typingEffect = (dom: HTMLElement, parameter: TypingEffectType) => {
const { text, callback, cursorConfig = {}, seed = initData.seed } = parameter;
const {
cursor = false,
dieTime = initData.dieTime,
blinkSeed = initData.blinkSeed,
} = cursorConfig as any;
if (!dom || !text) return;
const textArrs: string[] = textConversionArr(text);
dom.innerHTML = ''; //每次清空内容
let blinkInterval: any = null; //光标定时器
// 添加光标效果
cursor && createBlinkSpan(dom, blinkInterval, blinkSeed);
let startIndex = 0;
const element = document.createElement('span'); //文本存放标签
const start = () => {
startIndex++;
if (startIndex >= textArrs.length) {
cursor && removeCursor(dom, blinkInterval, dieTime);
callback?.();
return;
}
if (cursor) {
element.innerHTML = textArrs[startIndex];
dom.insertBefore(element, getCursorClassName());
} else {
dom.innerHTML = textArrs[startIndex];
}
setTimeout(() => start(), seed);
};
start();
};
//index.d.ts
type cursorConfigType = {
cursor?: boolean; //是否显示光标
seed?: number; //光标默认速度=>默认250ms
dieTime?: number; //打字结束后光标消失时间=>默认200ms
blinkSeed?: number; //光标闪烁速度
};
export type TypingEffectType = {
text: string; //文本
seed?: number; //默认打字速度,默认250ms
callback?: () => void; //打字机结束的回调函数
cursorConfig?: cursorConfigType; //光标配置项
};
2.2.2、createBlinkSpan
import initData from './tool';
export const createBlinkSpan = (
dom: HTMLElement,
intervalName: NodeJS.Timer,
blinkSeed: number,
) => {
const { cursorClassName } = initData;
const blinkName = document.createElement('span');
blinkName.className = cursorClassName;
blinkName.innerHTML = '|';
dom.appendChild(blinkName);
// 设置闪烁间隔,例如每500毫秒切换一次光标状态
intervalName = setInterval(() => {
blinkName.style.display = blinkName.style.display === 'none' ? 'inline' : 'none';
}, blinkSeed);
};
2.2.3、textConversionArr
// 标签切割
const labelCut = (str: string) => {
const arrs = str.match(/<[^>]+>(?!\/>)/g);
if (!arrs) return [];
return arrs.filter((item) => !/<[^>]+\/>$/.test(item));
};
// 通过<></>分隔字符串=》数组
const splitStringToChunks = (str: string): string[] => {
const chunks: string[] = [];
let currentChunk = '';
let insideTag = false;
for (let i = 0; i < str.length; i++) {
const char = str[i];
if (char === '<') {
insideTag = true;
currentChunk += char;
} else if (char === '>') {
insideTag = false;
currentChunk += char;
} else {
currentChunk += char;
}
if (!insideTag || i === str.length - 1) {
chunks.push(currentChunk);
currentChunk = '';
}
}
return chunks;
};
/**
* @description: 文本转换数组
* @param {string} str
* @author: muge
*/
export const textConversionArr = (str: string): string[] => {
const labelCutList = labelCut(str);
const chucksList = splitStringToChunks(str);
let startIndex: number = 0;
const result: string[] = [];
let lastStr = ''; //拼接的字符串
const isCloseTagReg = /<\/[^>]*>/; //是否是闭合标签 </img>=>true <>=>false <div/>=>false
while (startIndex < chucksList?.length) {
let currentIndex = startIndex;
++startIndex;
const currentStr = chucksList[currentIndex];
const index = labelCutList.indexOf(currentStr);
if (index === -1) {
lastStr += currentStr;
result.push(lastStr);
continue;
}
// 起始标签
if (!/<\/[^>]+>/.test(currentStr)) {
// 判断是否为自闭合标签,如 <img> <hr> <br>这种不规范的写法
const nextCloseTag: string | undefined = labelCutList[index + 1];
if (!nextCloseTag || !isCloseTagReg.test(nextCloseTag)) {
lastStr += currentStr;
result.push(lastStr);
continue;
}
// 查找第一个闭合标签的下标
const findArrs = chucksList.slice(currentIndex);
const endTagIndex = findArrs.findIndex((item) => item === nextCloseTag);
let curStr: string = '';
for (let i = 1; i < endTagIndex; i++) {
curStr += findArrs[i];
const res = labelCutList[index] + curStr + nextCloseTag;
result.push(lastStr + res);
if (endTagIndex - 1 === i) {
lastStr += res;
}
}
startIndex = currentIndex + endTagIndex; //重置下标
continue;
}
}
return result;
};
2.2.4、getCursorClassName
import initData from './tool';
/**
* @description: //获取光标dom
* @author: muge
*/
export const getCursorClassName = () => {
return document.querySelector(`.${initData.cursorClassName}`) as HTMLElement;
};
2.2.5、removeCursor
import initData from './tool';
/**
* @description: //移除光标标签
* @param {HTMLElement} dom //光标标签dom
* @param {string} intervalName //定时器名字
* @param {number} cursorAway //光标消失时间
* @author: muge
*/
export const removeCursor = (dom: HTMLElement, intervalName: NodeJS.Timer, cursorAway: number) => {
setTimeout(() => {
clearInterval(intervalName);
dom.removeChild(document.querySelector(`.${initData.cursorClassName}`) as HTMLElement);
}, cursorAway);
};
2.2.6、initData
type initDataType = {
cursorClassName: string;
seed: number;
blinkSeed: number;
dieTime: number;
};
const initData: initDataType = {
cursorClassName: 'blink-class',
seed: 100,
dieTime: 500,
blinkSeed: 350,
};
export default initData;
2.3、使用
import { typingEffect } from '@/utils/writeEffect';
import React, { useEffect, useRef } from 'react';
const Index = () => {
const el = useRef<HTMLElement | any>(null);
const richText =
'原神 · 启动!<img src="https://nimg.ws.126.net/?url=http%3A%2F%2Fdingyue.ws.126.net%2F2022%2F0830%2F74168ba1j00rhf6m5002cd000u000jfp.jpg&thumbnail=660x2147483647&quality=80&type=jpg" style="height: 150px"/><br/><hr><br><div>王者荣耀 · 启动!</div>';
useEffect(() => {
typingEffect(el.current, {
text: richText,
callback: () => {
console.log('打印机结束后执行的回调函数!');
},
cursorConfig: {
cursor: true,
},
});
}, []);
return <div ref={el}></div>;
};
export default Index;
2.4、效果
git项目地址,点我打开