需求:
在开发一个基于大模型的QA系统时,由于在回答编程相关问题时,模型会输出使用markdown形式表示的代码代码,如下图所示,不利于查看,故借助Marked.js将markdown形式的字符串转换为html形式,由于是需要实时渲染故选择marked.js,可以达到极致的轻量化,相对于mavon-editor
(包太大),vue-marked
(功能少)来说更加适合本项目。
marked.js
一个功能齐全的markdown解析器和编译器,用JavaScript编写。 专为速度而设计。
- 快速构建
- 用于解析markdown的低级编译器,无需长时间缓存或阻塞
- 非常轻量,同时实现支持的falses和规格的所有降价功能
- 支持浏览器,服务器或命令行界面(CLI)
但是轻量化的同时也带来一些问题,就是没有其他的一些附加功能,如代码行数限制,代码复制等等没有提供,官网中也有一些扩展,可以满足大部分的需求,如代码高亮(marked-highlight
),在使用了现在有一些扩展后,效果如下,个人还需要语言显示、代码复制,故就基于marked扩展了功能,开发所使用的环境是Vue3+Typescript,其他环境应该可以基于我们代码进行修改实现同样的功能。
实现
思路:
这里将具体的实现分为两步:静态绘制与动态注册。
首先是静态绘制,利用在markdown转html的时候的返回结果,将其中的pre标签进行注入一个自定义标签用于占位,以方便后续的操作。
第二步是动态注册,在已有元素的基础上进行动态值与函数的设置与注册。
具体实现
在原有marked的基础上进行“增强”,具体如下:
enhanceCodeBlock(marked(chatItem.content))
enhanceCodeBlock函数:
//增强代码块
const enhanceCodeBlock = (content: string) => {
// console.log(content)
//在pre块中增加一个元素用于显示
let enhance = content.replace(/<pre><code/g, '<pre><div class="enhance"><div class="lang">CODE</div><div class="copyCode">Copy<i class="el-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path fill="currentColor" d="M128 320v576h576V320H128zm-32-64h640a32 32 0 0 1 32 32v640a32 32 0 0 1-32 32H96a32 32 0 0 1-32-32V288a32 32 0 0 1 32-32zM960 96v704a32 32 0 0 1-32 32h-96v-64h64V128H384v64h-64V96a32 32 0 0 1 32-32h576a32 32 0 0 1 32 32zM256 672h320v64H256v-64zm0-192h320v64H256v-64z"></path></svg></i></div></div><code')
// console.log(enhance)
return enhance
}
这里利用拦截器的思想对其进行增强,在得到结果后使用正则匹配“<pre><code”,在这其中添加自己需要的元素节点,我这里添加了一个enhance类的div标签,其中还包括有一个语言显示块和一个预留的复制按钮,这里根据自己实际的需要进行增加,注意我这里的/g不省略的原因是我的一段文本中可能有多个代码块,在具体的实际场景中可能做法不同。
再加下一定的css样式最终效果如下:
pre .enhance {
display: flex;
color: #fff ;
padding: 0px 10px ;
border-radius: 5px 5px 0 0 ;
font-size: 14px ;
// font-weight: 600 ;
background: #343541de;
justify-content:space-between;
.copyCode{
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.5s ease-in-out;
&:hover{
color: #bae9a4d7;
}
i{
font-size: 16px;
margin-left: 5px;
}
}
}
到这里基本上第一步就完成了,接下来是第二步,动态注册,思路也比较清晰,就是在元素生成完成后,查找前面自定义的标签元素,进行值的设置与点击事件的监听。这里的重点就是找到合适的时机进行渲染,如果是结果固定,在获取结果进行渲染即可(但我试了在多个地方渲染均不生效,后面再来解决这个问题),但由于我这里在与后端交互时使用的是流式输出,所以需要实时渲染,故而采用Vue的自定义指令,由于其特性当其绑定的元素被插入到 DOM 中时,会立即执行一些行为非常适合这个场景,自定义指令允许我们在渲染的 DOM 元素上应用自定义的行为。这里可以选择全局注册app.directive(name, options)
,也可以局部注册,我这里只需要在这一个页面上使用,故使用的具体注册。具体请看官网。
下面直接上代码:
//自定义指令增强代码块功能
const vEnhanceCode = (el: HTMLElement) => {
let codeBlocks = el.querySelectorAll('pre');
// console.log(codeBlocks)
codeBlocks.forEach((codeBlock, i) => {
// console.log(codeBlock)
// 获取代码块中的语言标识
const code = codeBlock.querySelector("code") as any
let lang = code["result"].language as string
//首字母大写
if (lang) {
lang = lang.charAt(0).toUpperCase() + lang.slice(1)
}
// 获取增强入口
let enhance = codeBlock.querySelector(".enhance")
if (enhance) {
// 判断是否已经注册
const origin = enhance.querySelector(".lang")!.innerHTML
if (origin != "CODE") {
return
}
// 替换预定义的Code标识
enhance.querySelector(".lang")!.innerHTML = lang
// 注册复制按钮事件
let copyCode = enhance.querySelector(".copyCode")
// 判断是否有注册事件
if (copyCode) {
copyCode.removeEventListener("click", () => { })
}
copyCode!.addEventListener("click", () => {
copy(code.innerText)
})
}
})
}
这里代码都比较清晰,需要注意的点就是在注册监听事件的时候,最好判断一下当前是否注册,如果是结果固定的没什么影响,但是像我这种实时渲染的不加判断会导致事件一直注册,最后导致在点击复制代码时,n个函数被调用,导致浏览器直接无响应(这是一个悲伤的故事)。
最后在元素上加上自定义的指定就大功告成啦!
最终的结构如下:
<div class="chatContent" v-highlight v-enhance-code v-html="enhanceCodeBlock(marked(chatItem.content))"></div>
下面是演示:
QQ录屏20230830200346