在做 镜像站的时候,发现有些镜像站是没做打字机的光标效果的,就只是文字输出,是他们不想做吗?反正我想做。于是我仔细研究了一下,实现了打字机效果加光标的效果,现在分享一下我的解决方案以及效果图
共识
首先要明确一点, 返回的文本格式是 的,最基本的渲染方式就是把 文本转换为 HTML 文本,然后 v-html 渲染即可。这里的转换和代码高亮以及防 XSS 攻击用到了下面三个依赖库:
同时我们是可以在 中写 html 元素的,这意味着我们可以直接把光标元素放到最后!
将 转为 html 并处理代码高亮
先贴代码
.vue
<script setup>
import {computed} from 'vue';
import DOMPurify from 'dompurify';
import {marked} from 'marked';
import hljs from '//cdn.staticfile.org/highlight.js/11.7.0/es/highlight.min.js';
import mdInCode from "@/utils/mdInCode"; // 用于判断是否显示光标
const props = defineProps({
// 输入的 markdown 文本
text: {
type: String,
default: ""
},
// 是否需要显示光标?比如在消息流结束后是不需要显示光标的
showCursor: {
type: Boolean,
default: false
}
})
// 配置高亮
marked.setOptions({
highlight: function (code, lang) {
try {
if (lang) {
return hljs.highlight(code, {language: lang}).value
} else {
return hljs.highlightAuto(code).value
}
} catch (error) {
return code
}
},
gfmtrue: true,
breaks: true
})
// 计算最终要显示的 html 文本
const html = computed(() => {
// 将 markdown 转为 html
function trans(text) {
return DOMPurify.sanitize(marked.parse(text));
}
// 光标元素,可以用 css 美化成你想要的样子
const cursor = '';
if (props.showCursor) {
// 判断 AI 正在回的消息是否有未闭合的代码块。
const inCode = mdInCode(props.text)
if (inCode) {
// 有未闭合的代码块,不显示光标
return trans(props.text);
} else {
// 没有未闭合的代码块,将光标元素追加到最后。
return trans(props.text + cursor);
}
} else {
// 父组件明确不显示光标
return trans(props.text);
}
})
</script>
<template>
<div v-html="html" class="markdown leading-7">
</div>
</template>
<style lang="postcss">
/** 设置代码块样式 **/
.markdown pre {
@apply bg-[#282c34] p-4 mt-4 rounded-md text-white w-full overflow-x-auto;
}
.markdown code {
width: 100%;
}
/** 控制段落间的上下边距 **/
.markdown p {
margin: 1.25rem 0;
}
.markdown p:first-child {
margin-top: 0;
}
/** 小代码块样式,对应 markdown 的 `code` **/
.markdown :not(pre) > code {
@apply bg-[#282c34] px-1 py-[2px] text-[#e06c75] rounded-md;
}
/** 列表样式 **/
.markdown ol {
list-style-type: decimal;
padding-left: 40px;
}
.markdown ul {
list-style-type: disc;
padding-left: 40px;
}
/** 光标样式 **/
.markdown .cursor {
display: inline-block;
width: 2px;
height: 20px;
@apply bg-gray-800 dark:bg-gray-100;
animation: blink 1.2s step-end infinite;
margin-left: 2px;
vertical-align: sub;
}
@keyframes blink {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
</style>
可以发现最基本的 显示还是挺简单的,话就不多说了,都在注释里。
我想你也许对判断消息中的代码块是否未闭合更感兴趣,那么就继续看下去吧!
代码块是否未闭合
有两种代码块,一种是 `code` ,另一种是 “` code “`,我叫他小代码块和大代码块。
一开始我是想用正则去判断的,但是奈何有点复杂,我实在想不出应该如何去编写正则,让 写的正则也会判断失败,而且还要考虑到转义符,就算写出了正则,估计也会很复杂和难以维护。
经过短暂的苦思冥想后,我想到了之前在 《Vue.js设计与实现》 中看到的用有限元状态机解析 html 文本的方案。
这里有请 简单介绍一下有限元状态机:
有限元状态机( state )是一种基于状态机的建模技术,它可以将系统的行为描述为一组状态和状态之间的转换。每个状态可以对应系统内的一种状态,而状态之间的转换则表示系统内部的转换过程。有限元状态机还可以用于系统的可靠性分析和测试,同时也可以作为软件工程中的一种建模工具,帮助代码实现、测试和调试等方面。
在实际应用中,有限元状态机通常是通过自动化工具生成,并且可以用于控制系统、网络通信协议、软件接口等方面的设计和测试。由于有限元状态机具有自动化生成和完备性检查等优点,因此它已经成为了一种广泛应用的建模与测试工具。
请站在web前端的视角下进行介绍
从前端开发的角度来看,有限元状态机可以用于构建复杂的交互式Web应用程序。在Web应用程序中,有限元状态机可以描述用户界面的不同状态(例如,登录、注册、未登录等),以及状态之间的转换(例如,从登录状态转换到注册状态)。
通常情况下,前端工程师使用实现有限元状态机,包括定义状态、定义状态之间的转换规则、处理状态转换时的行为等。使用有限元状态机可以帮助前端工程师更好地掌控应用程序的状态,从而减少代码中的复杂条件分支和无法预测的行为。同时,有限元状态机也可以帮助前端团队共同理解应用程序的状态和转换规则,从而更好地协作开发和维护Web应用程序。
总之,有限元状态机是一种非常有用的前端开发技术,可以帮助前端工程师更好地构建和管理Web应用程序的状态和行为,提高应用程序的可靠性和用户体验。
回到正题,我可以一点一点的从头开始去解析 文本。想象这么一个简单的状态转换流程:
不过现实要更复杂一点,我们有小代码块和大代码块。有限元状态机的妙处就在这里,当处在小代码块状态的时候,我们不需要操心大代码块和正常文本的事,他的下一个状态只能是遇到小代码块的闭合标签,进入文本状态。
理解了这些,再来看我的源码,才会发现他的精妙。
const States = {
text: 0, // 文本状态
codeStartSm: 1, // 小代码块状态
codeStartBig: 2, // 大代码块状态
}
/**
* 判断 markdown 文本中是否有未闭合的代码块
* @param text
* @returns {boolean}
*/
function isInCode(text) {
let state = States.text
let source = text
let inStart = true // 是否处于文本开始状态,即还没有消费过文本
while (source) { // 当文本被解析消费完后,就是个空字符串了,就能跳出循环
let char = source.charAt(0) // 取第 0 个字
switch (state) {
case States.text:
if (/^n?```/.test(source)) {
// 以 ``` 或者 n``` 开头。表示大代码块开始。
// 一般情况下,代码块前面都需要换行。但是如果是在文本的开头,就不需要换行。
if (inStart || source.startsWith('n')) {
state = States.codeStartBig
}
source = source.replace(/^n?```/, '')
} else if (char === '\') {
// 遇到转义符,跳过下一个字符
source = source.slice(2)
} else if (char === '`') {
// 以 ` 开头。表示小代码块开始。
state = States.codeStartSm
source = source.slice(1)
} else {
// 其他情况,直接消费当前字符
source = source.slice(1)
}
inStart = false
break
case States.codeStartSm:
if (char === '`') {
// 遇到第二个 `,表示代码块结束
state = States.text
source = source.slice(1)
} else if (char === '\') {
// 遇到转义符,跳过下一个字符
source = source.slice(2)
} else {
// 其他情况,直接消费当前字符
source = source.slice(1)
}
break
case States.codeStartBig:
if (/^n```/.test(source)) {
// 遇到第二个 ```,表示代码块结束
state = States.text
source = source.replace(/^n```/, '')
} else {
// 其他情况,直接消费当前字符
source = source.slice(1)
}
break
}
}
return state !== States.text
}
export default isInCode
到这里,就已经实现了一个 消息渲染了。喜欢的话点个赞吧!谢谢!
2023年8月25日更新
我注意到有人没理解从消息获取到渲染的流程,所以这里我想补充一下。
我使用了 SSE 来获取后端返回的 AI 回答以实现一个字一个字流式传输的效果。比如 AI 回答了一条消息,其中包含了代码。假设全文是:
如何用js打印hello?
```
console.log('hello')
```
以上就是代码
用于本文章转义,忽略即可
那么前端接收的时候,是一小段一小段(大多数情况是一个字)接收的,比如:
如何用js
打印hello?
```
console
.log('hello')
```
以上就是代码
加上渲染后,整个流程就是:
申明一个 raw 变量存储接收到的消息
接收到一小段消息并追加到 raw 变量:如何用js
使用 markdown 解析器解析 raw 变量里的内容后输出 html 代码,渲染 html 代码
接收到一小段消息并追加到 raw 变量:打印hello?
使用 markdown 解析器解析 raw 变量里的内容后输出 html 代码,渲染 html 代码
接收到一小段消息并追加到 raw 变量:```
使用 markdown 解析器解析 raw 变量里的内容后输出 html 代码,渲染 html 代码
(因为有```符号,因此解析器返回的html会渲染一个代码块,即使还没有出现结束符 ```)
接收到一小段消息并追加到 raw 变量:console
使用 markdown 解析器解析 raw 变量里的内容后输出 html 代码,渲染 html 代码
(此时代码块中会有代码)
接收到一小段消息并追加到 raw 变量:.log('hello')
使用 markdown 解析器解析 raw 变量里的内容后输出 html 代码,渲染 html 代码
接收到一小段消息并追加到 raw 变量:```
使用 markdown 解析器解析 raw 变量里的内容后输出 html 代码,渲染 html 代码
(解析器解析到完整的一对 ```,代码块结束)
接收到一小段消息并追加到 raw 变量:以上就是代码
使用 markdown 解析器解析 raw 变量里的内容后输出 html 代码,渲染 html 代码
接收完成,更新状态图标
现在这个业务逻辑流程我认为已经很清晰了。
不要把收到的消息段追加到解析器解析出来的 html 后面!!!