编程导航技术文档话题讨论

技术文档

3 参与
分享

快来分享你的内容吧~

点击登录,快来和大家讨论吧~
表情
图片
话题
打卡
综合
交流
文章
问答

刚买的 Redis 没多久就满了?罪魁祸首竟是这个

## 背景 大家好,我是小顾,负责开发一款面试刷题工具 —— [面试鸭](https://mianshiya.com/?shareCode=9ou2an) ~ 在每周例行开完需求评审会后,我按照需求文档进行开发需求时,写着写着代码,面试鸭告警群里突然报了好多异常,我一看是 Redis 报内存溢出异常,赶紧去阿里云监控平台看。 <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/fOdx0V4FO2WuLhXk.webp" alt="" width="100%" /> 去了阿里云监控平台查看了下,`/api/question/get/vo` 这个接口 5 分钟内报了好几个 OOM 异常,汗流浃背了。 <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/IVpRxPIaXEWqbm31.webp" alt="" width="121" /> 这个接口是获取题目详情和推荐答案的,并且作了限流和防爬警告处理,当用户获取题目频率过高时会在企微告警,防止有用户恶意爬接口。但是如果频率过高时,一调接口就会告警,群里通知会很频繁,体验不是很好,所以我会把一段时间内的告警信息进行汇总起来一起发送出去,会使用用户 id 为 key 存储在 Redis 里,一段时间后过期。因为这里有 Redis 的写入操作,导致用户刷题时一直报这个错误。 我赶紧打开腾讯云 Redis 云监控,看了下,好家伙,内存使用率都干到 100% 了! <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/0LgxKjN1oAKoZEJ0.webp" alt="" width="100%" /> 通过 Redis 客户端工具可以看到,11 库是面试鸭的 Redis 库,一共有 290 多万条数据,这么多明显不正常,面试鸭根本没有这么多的用户,居然产生了这么多 key,而另外一个项目一共才 40 多万的 key,并且用户数和面试鸭差不多,所以面试鸭的 Redis 明显不正常 ~ <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/VMTbHP7YRCgPmmWl.webp" alt="" width="242" /> ## 分析 在 Redis 里,有 94% 是 spring 的 key,明显这是占用最多的键,这个存的是面试鸭的登录态,不到 10 万的用户登录态有两百多万,肯定是有问题的。这个 Redis 数据是 Spring Session 框架生成的。我们是把用户登录态使用这个框架存在Redis里来解决分布式场景下的登录问题,平常单机应用都是存储在 Spring 的 Tomcat 里,但我们的面试鸭是分布式的应用,一个 web 应用可能部署在不同的 docker 容器中,通过 CPU 或者内存占用情况进行自动扩容,通过负载均衡算法将请求分配到不同的 web 容器中,这样就会导致用户的登录态不一致,比如用户登录时的请求发送到服务器 A ,获取登录信息的请求发送到服务器 B,这样就获取不到用户的登录态了,所以我们需要把登录态存在 Redis 里。 <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/csw9kJwL8y3zgJRa.webp" alt="" width="100%" /> 继续看这个 sping 的 key 里面有什么信息,里面存放了三种类型的数据: 第一种,`"spring:session:sessions" + sessionId`这个是存储的是,`creationTime`(创建时间),`lastAccessedTime`(最后访问时间),`maxInactiveInterval`(session 失效的间隔时长),以及存放在 session 中的用户数据会存放于此,比如在代码中: ```java session.setAttribute(key, user) ``` 这里的用户数据就会存放在这个类型的缓存里。 ```json { "lastAccessedTime": 1523933008926 "creationTime": 1523933008926, "maxInactiveInterval": 1800, "sessionAttr:user_login": {...} } ``` 第二种,`"spring:session:expirations" + 时间戳`,这是存储的是这一分钟所有要失效的 `sessionId`。当会话被创建或更新时,Spring Session 会计算该会话的过期时间。将当前的时间加上 session 失效的间隔时长(maxInactiveInterval),得出会话的过期时间点。Spring Session 通常会将计算出的 expirationTime 向下取整到最近的分钟。如果这个分钟时间点有多个 sessionId,会添加到Set类型的值里。这种类型的键便作为了一个“桶”,存放着这一分钟应当过期的 `sessionId`。 第三种,`"spring:session:sessions:expires" + sessionId`,不存储任何有用数据,它仅仅是 `sessionId` 在 redis 中的一个引用,只是表示 Session 的过期而设置。这个值在 Redis 中的过期时间即为 Session的过期时间。 我正在研究这些数据分别存储什么数据时,同事 S 给我发了一张图,说这都是啥数据,占用这么多? <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/TgPdcbWikZ5SPgW2.webp" alt="" width="100%" /> 我打开一看,占用前 100 个 都是上面第二种类型的数据,最多的将近有 1M 了。这种缓存数据是每当登录时,session 过期时间会被重新从当前时间计算,会得到一个新的值并存进去。如果这一分钟有很多用户同时登录时,相同键里的“桶”会存非常多的`sessionId`,导致这条数据非常大。那么 Spring 为什么会引入这个类型的键呢? <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/n6JohAL3m9Hb26IO.webp" alt="" width="140" /> 我们知道 Session 是有过期机制的,Redis 也是有过期机制的,那么 Spring 是否可以直接沿用 Redis 的自动过期呢,显然是没有,因为 Spring 设计了上面三种类型的键,那么为什么会这么设计?这岂不是比直接使用一条数据来过期多出了两倍的内存空间? Redis 对于过期数据,一般有三种删除策略: 1. 定时删除:当设置键的过期时间的同时会创建一个定时器,当键到达过期时间的同时,定时器会删除这个键。 2. 惰性删除:在获取键的时候,会判断有没有过期,如果过期了,直接返回空并且删除,否则返回这个键 3. 定期删除:每隔一段时间,Redis 会取出一定数量的键,然后过滤出所有的过期的键并删除。 Redis 里默认一般采用惰性删除和定期删除两个策略组合来使用。也就是说 Redis 的键一旦过期了并不会立即删除,而是等某个事件触发时才会删除,这就会有不及时删除的情况。所以单纯依赖 Redis 是不可靠,于是就有了上面三个键的设计。通过源码可以发现,为了保证 Session 过期的及时性,Spring 采用了定时任务去删除过期的 Key,定时任务的频率是 1 分钟,它会检查所有一分钟前应该过期的 `sessionId`的值,即`"spring:session:expirations" + 时间戳`,然后根据“桶”里面的 `sessionId`,立即删除 `"spring:session:sessions:expires" + sessionId`。 <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/cG6vouZz8tecvf4b.webp" alt="" width="100%" /> <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/dUJlkjups4VeqZvX.webp" alt="" width="100%" /> 通过 Redis 客户端工具可以看到,`"spring:session:sessions:" + sessionId` 的 TTL是2591772, `"spring:session:expirations" + 时间戳`的 TTL 是 2591472,第一种类型的键比第二种类型的键多了整整 5 分钟,目的就是为了当 Session 过期时,其他的监听事件还能获取到 Session 的原始数据。第三中类型的缓存的 TTL 其实就是为了标记的真实的过期时间,作用也是为了过期用的,所以值是空的。 所以综上所述,第一种类型的键存放的就是 Session 的真实数据,第二种类型的键是为了能及时删除过期的 Session,第三种类型的键就是真正标记过期用的。 知道了 Session 原理就简单了,用户如果打开网站或者小程序,请求需要登录的接口后,比如 `/api/user/get/login`获取登录态的接口,这个接口因为涉及到 Session 的获取操作,如果在 Redis 里没有找到这个 SessionId,用户无论有没有登录,都会在 Redis 里添加一条 Session 记录。如果请求登录接口后,会在第一种类型的键里加一条用户数据,然后在第二种类型里重新计算用户过期的时间,它的键为当前登录用户 Session 要过期的时间戳,如果用户频繁请求接口接口,这个类型的键会一直增加,因为他是根据当前登录时间动态计算的过期时间。在 web 端如果用户登录之后,刷新页面会自动获取登录态的,不用重复登录,所以排除了 web 端的问题,那么就是小程序了,因为小程序的自动登录机制,用户打开小程序会自动进行登录,通过日志可以看到,小程序关闭之后再次打开,还是会调用 `/api/user/login/miniapp`接口,`"spring:session:expirations" + 时间戳` 的键会增加一条记录,所以这个类型的数据会越来越多。 ## 解决 终于知道问题出在哪里了,那就要开始解决了 ~ <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/I6lw3PVdGHLug5xD.jpg" alt="undefined" width="109" /> 小程序登录之后是可以获取到登录态的,所以不需要每次打开都调用登录接口,可以通过`/api/user/get/login`获取登录信息,如果获取不到,才调用登录的接口,这样就会在 Redis 里节省了很多的空间呢 ~ ## 总结 随着用户数越来越多,Redis 里的登录态也越来越多,内存增加也越来越快,用户只要打开网站就会有 Session 记录,所以要合理利用 Redis 空间,后续可以再看看还有没有可以优化的地方 ~

做了一个 byd 编辑器插件,用户再也不汪汪叫了。。。

# 引言 大家好,我是程序员 K.N, 一个试图用代码和世界重新打结的前端小白~ 先叠个甲,byd = ByteMD,小小的标题党一下,各位看官老爷轻喷。 前段时间,我们团队做了个面试刷题工具——面试鸭,而我也作为一名前端开发参与了该项目的开发。 在这个工具中,有一个流畅、简洁的 Markdown 编辑器,该编辑器所见即所得,支持代码高亮、代码块复制,标签解析、数学公式、流程图、对齐方式、图片自定义大小(仿语雀)、图片放大预览等功能。 今天,我将手把手带大家揭秘,这个 Markdown 编辑器是如何实现的,助你也能打造同款功能! <img src="https://pic.code-nav.cn/post_picture/1730577019852419074/PWliKRKpWEeHMTbH.webp" alt="" width="100%" /> <img src="https://pic.code-nav.cn/post_picture/1730577019852419074/U8rVudSdMyGDw2yo.webp" alt="" width="100%" /> # 一、ByteMD 是啥? 其实这款编辑器是基于 ByteMD 实现的,它是字节开源的一款轻量编辑器,是使用 Svelte 构建的 Markdown 编辑器组件,它也可以用于其他框架,例如 React、Vue 和 Angular。 ## 具有以下特性: 1)轻量级且与框架无关 2)易于扩展:ByteMD 有一个插件系统来扩展基本的 Markdown 语法, 3)默认安全:ByteMD 正确处理跨站点脚本(XSS) 攻击,例如 `<script>` 和 `<img onerror>`。无需引入额外的 DOM 清理步骤。 4)SSR 兼容:ByteMD 可以在服务器端渲染(SSR) 环境中使用,无需额外配置。 ## 相关链接: ByteMD 开源地址:https://github.com/pd4d10/bytemd demo 示例:[https://bytemd.js.org/playground/](https://bytemd.js.org/playground/) # 二、快速集成 ByteMD ## 1、环境准备: Node.js 16 及以上 ## 2、基本使用 ### 安装 ByteMD 相关依赖 ```powershell npm install bytemd ``` ```powershell npm instal @bytemd/react ``` ### 安装 gfm(表格支持)插件、highlight 代码高亮插件 ```tsx npm install @bytemd/plugin-gfm @bytemd/plugin-highlight ``` ### 引入 ByteMD 汉化包 ```tsx # 引入中文包 import zhHans from 'bytemd/locales/zh_Hans.json ``` ### 用法 ByteMD 有两个组件:`Editor` 和 `Viewer`。Editor 是 Markdown 编辑器; `View` 通常用于显示呈现的 Markdown 结果,无需编辑。在使用组件之前,还要导入CSS文件以确保样式正确: ```tsx import 'bytemd/dist/index.css' ``` ### 封装自定义的 Editor 和 Viewer 组件 接下来需要对官方的 Editor 和 Viewer 进行封装, 以提高组件的通用性。 #### 新建 MdEditor 组件,示例写法如下: ```tsx import type { FC } from "react"; import { Editor } from "@bytemd/react"; import gfm from "@bytemd/plugin-gfm"; import gfmLocale from "@bytemd/plugin-gfm/locales/zh_Hans.json"; import highlight from "@bytemd/plugin-highlight"; import locale from "bytemd/locales/zh_Hans.json"; import "bytemd/dist/index.css"; import "./index.css"; interface Props { value?: string; onChange?: (v: string) => void; placeholder?: string; } const plugins = [ gfm({ locale: gfmLocale, }), highlight(), ]; /** * Markdown 编辑器 */ const MdEditor: FC<Props> = (props) => { const { value = "", onChange, placeholder } = props; return ( <div className="md-editor"> <Editor value={value || ""} placeholder={placeholder} editorConfig={{ // 不显示行数 lineNumbers: false, autofocus: false, }} mode="split" locale={locale} plugins={plugins} onChange={onChange} /> </div> ); }; export default MdEditor; ``` #### 页面中使用 ```tsx import "./App.css"; import MdEditor from "@/components/MdEditor"; import { useState } from "react"; function App() { const [value, setValue] = useState<string>(""); return ( <> <MdEditor value={value} onChange={setValue} /> </> ); } export default App; ``` <img src="https://pic.code-nav.cn/post_picture/1730577019852419074/7A5m202YUppa53Ac.webp" alt="" width="100%" /> 这样,就能得到一个基本的编辑器了,大家可以在光标中输入看看有没有实现所见即所得呢?但是右上角多了一个 GitHub 的图标,咱们把它隐藏起来,主打的就是一个简洁~ ```css /*隐藏 github 图标*/ .bytemd-toolbar-icon.bytemd-tippy.bytemd-tippy-right:last-child { display: none; } ``` ## 3、ByteMD 插件配置 ### 安装插件 官方支持的插件已经有不少,但对于一款体验良好的编辑器来说,我觉得还不够,除了使用以下列表中的插件外,我们还需要拓展其他插件,且听我娓娓道来 ~ 官方插件列表如下: | 插件名 | 插件功能 | | --- | --- | | [@bytemd/plugin-breaks](https://github.com/bytedance/bytemd/tree/main/packages/plugin-breaks) | 默认md渲染时硬换行需要双空格或者双回车, 该插件确保正常回车即可硬换行 | | [@bytemd/plugin-frontmatter](https://github.com/bytedance/bytemd/tree/main/packages/plugin-frontmatter) | 解析元数据 | | [@bytemd/plugin-gemoji](https://github.com/bytedance/bytemd/tree/main/packages/plugin-gemoji) | 解析gemoji表情 | | [@bytemd/plugin-gfm](https://github.com/bytedance/bytemd/tree/main/packages/plugin-gfm) | 支持GFM(自动链接文字、删除、表格、任务列表) | | [@bytemd/plugin-highlight](https://github.com/bytedance/bytemd/tree/main/packages/plugin-highlight) | 代码高亮 | | [@bytemd/plugin-highlight-ssr](https://github.com/bytedance/bytemd/tree/main/packages/plugin-highlight-ssr) | 代码高亮ssr版本 | | [@bytemd/plugin-math](https://github.com/bytedance/bytemd/tree/main/packages/plugin-math) | 支持数学公式 | | [@bytemd/plugin-math-ssr](https://github.com/bytedance/bytemd/tree/main/packages/plugin-math-ssr) | 支持数学公式ssr版本 | | [@bytemd/plugin-medium-zoom](https://github.com/bytedance/bytemd/tree/main/packages/plugin-medium-zoom) | 支持点击图片放大预览 | | [@bytemd/plugin-mermaid](https://github.com/bytedance/bytemd/tree/main/packages/plugin-mermaid) | 支持流程图 | 我们把几个常用的插件都安装上,在 plugins 中导入我们所需的插件: ```tsx import type { FC } from "react"; import { Editor } from "@bytemd/react"; import gfm from "@bytemd/plugin-gfm"; import gfmLocale from "@bytemd/plugin-gfm/locales/zh_Hans.json"; import gemoji from "@bytemd/plugin-gemoji"; import highlight from "@bytemd/plugin-highlight"; import math from "@bytemd/plugin-math"; import mathLocale from "@bytemd/plugin-math/locales/zh_Hans.json"; import mermaid from "@bytemd/plugin-mermaid"; import mermaidLocale from "@bytemd/plugin-mermaid/locales/zh_Hans.json"; import mediumZoom from "@bytemd/plugin-medium-zoom"; import locale from "bytemd/locales/zh_Hans.json"; import "bytemd/dist/index.css"; import "highlight.js/styles/vs.css"; import "github-markdown-css/github-markdown-light.css"; import "./index.css"; const plugins = [ gfm({ locale: gfmLocale, }), gemoji(), highlight(), math({ locale: mathLocale, }), mermaid({ locale: mermaidLocale, }), mediumZoom(), ]; ``` ### 自定义插件 ByteMD 使用 remark 和 rehype 生态系统来处理 Markdown. 完整流程如下: 1. Markdown 文本被解析为AST 2. Markdown AST 可以通过多种注释插件进行操作 3. Markdown AST 转换为 HTML AST 4. 出于安全原因,HTML AST 已被清理 5. HTML AST 可以被多个rehype 插件操纵 6. HTML AST 被字符串化为 HTML 7. HTML 渲染后的一些额外 DOM 操作 这里借用下官方描述的流程图: <img src="https://pic.code-nav.cn/post_picture/1730577019852419074/qhfgWdTH1e6DoKhN.png" alt="file_1727597413028_829.png" width="402px" /> <br> <br> 2、5、7步骤是通过 ByteMD 插件 API 进行用户定制的。官方文档中用了一个 plugin-math 插件作为例子解释了我们该如何编写插件,接下来,我带大家来自定义实现几个实用的插件,包括:居中插件、标签解析插件、代码块复制插件等。 #### 添加对齐方式插件: 给输入的文本、图片、链接等进行对齐方式设置,实现原理就是通过给某个元素包裹上一个 `p` 标签,并通过 `align` 属性设置其在文本框内的位置。 <img src="https://pic.code-nav.cn/post_picture/1730577019852419074/sTP9MLMqZDdjqMST.webp" alt="" width="100%" /> ##### 代码如下: 1)给这三个对齐方式设置对应的 icon ,这里可以直接套用我的 svg。 ```typescript export const ALIGN_CENTER = ` <svg t="1719248469954" class="icon-symbol" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4377"> <path d="M96 128h832v96H96zM96 576h832v96H96zM224 352h576v96H224zM224 800h576v96H224z" p-id="4378"></path> </svg>`; export const ALIGN_LEFT = ` <svg width="24" height="24" t="1719248152373" class="icon-symbol" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4230"> <path d="M96 128h832v96H96zM96 576h832v96H96zM96 352h576v96H96zM96 800h576v96H96z" p-id="4231"></path> </svg>`; export const ALIGN_RIGHT = ` <svg t="1719248528798" class="icon-symbol" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4524"> <path d="M96 128h832v96H96zM96 576h832v96H96zM352 352h576v96H352zM352 800h576v96H352z" p-id="4525"></path> </svg>`; ``` 2)编写插件代码 ```typescript import type { BytemdPlugin } from 'bytemd'; import zh_Hans from './localels/zh_Hans.json'; import { ALIGN_LEFT, ALIGN_CENTER, ALIGN_RIGHT } from './icon'; export interface AlignPluginOptions { locale?: Record<string, string>; } /** * 对齐方式插件 */ export default function alignPlugin(options?: AlignPluginOptions): BytemdPlugin { const locale = { ...zh_Hans, ...options?.locale } as typeof zh_Hans; return { actions: [ { title: locale.alignType, icon: ALIGN_CENTER, handler: { type: 'dropdown', actions: [ { title: locale.alignTypeLeft, icon: ALIGN_LEFT, handler: { type: 'action', click: (ctx) => { ctx.wrapText('<p align="left">', '</p>'); ctx.editor.focus(); }, }, }, { title: locale.alignTypeCenter, icon: ALIGN_CENTER, handler: { type: 'action', click: (ctx) => { ctx.wrapText('<p align="center">', '</p>'); ctx.editor.focus(); }, }, }, { title: locale.alignTypeRight, icon: ALIGN_RIGHT, handler: { type: 'action', click: (ctx) => { ctx.wrapText('<p align="right">', '</p>'); ctx.editor.focus(); }, }, }, ], }, }, ], }; } ``` 通过 ByteMD 的接口定义返回一个 actions 数组,即可定义这个工具栏下具有的操作。 #### 添加标签解析插件 在输入 HTML 标签语法的时候,往往都会给 HTML 标签添加标签语法,如:`<div>` ,如果不添加,则会直接渲染,来看看在掘金的效果: <img src="https://pic.code-nav.cn/post_picture/1730577019852419074/CTMoJrChE23zXEgv.webp" alt="" width="100%" /> 那有没有一种办法,在我复制大量代码,或是标签时,能够将该标签直接转为文本呢?又不写特定的标签语法 还真有,该插件就是可以允许一些标签直接编译成文本的,目的是转义 HTML 标签,防止某些未经允许的 HTML 内容被直接渲染。可以帮助防止某些不安全或不需要的 HTML 标签在渲染时生效,从而增强内容的安全性。 效果如下: <img src="https://pic.code-nav.cn/post_picture/1730577019852419074/XjKPHyRRJIsxSmwb.webp" alt="" width="100%" /> ##### 代码如下: ```typescript import type { BytemdPlugin } from "bytemd"; import { visit } from "unist-util-visit"; export default function escapeHtmlTags(): BytemdPlugin { return { remark: (processor) => // @ts-ignore processor.use(() => (treeNode) => { visit(treeNode, "html", (node) => { // 排除的标签列表 const excludeTags = ["img", "br", "p", "text"]; // 解析HTML标签,检查是否包含src属性且src属性有值 const parser = new DOMParser(); const doc = parser.parseFromString(node.value, "text/html"); const allElements = doc.body.getElementsByTagName("*"); let shouldEscape = true; for (const el of allElements as any) { if ( excludeTags.includes(el.tagName.toLowerCase()) && (el.tagName.toLowerCase() !== "img" || el.getAttribute("src")) ) { shouldEscape = false; break; } } if (shouldEscape) { node.value = node.value.replace(/</g, "&lt;").replace(/>/g, "&gt;"); } }); }), }; } ``` 上述代码中,我们通过遍历抽象语法树中的 `HTML` 类型节点,解析其中的内容,并根据标签类型决定是否需要转义。特定的标签如 `img`、`br`、`p` 和 `text` 等不会被转义,如果 `img` 标签包含有效的 `src` 属性,也会被保留原样。对于不在排除列表中的标签,会将 `<` 和 `>` 转义为 `&lt;` 和 `&gt;`。 #### 添加代码块插件(支持复制、折叠): ByteMD 默认渲染的出来的就是最简单的 HTML,代码块是被解析成 pre > code 标签, 因此是不带任何额外功能的,我们希望在代码块的右上角有个复制代码的按钮,在左上角有个折叠的按钮,类似掘金的代码块,效果如下: <img src="https://pic.code-nav.cn/post_picture/1730577019852419074/xWyDfZew86WC7cuW.webp" alt="" width="100%" /> 我们通过 `rehype` 和 `viewerEffect` 来实现以上效果,再通过 css 给这个代码添加一些样式 ##### 代码如下: ```typescript import type { BytemdPlugin } from "bytemd"; import { visit } from "unist-util-visit"; // 复制的方法,直接使用浏览器的 API 即可实现复制 const copyToClipboard = async (text: string) => { if (navigator.clipboard) { try { await navigator.clipboard.writeText(text); console.log("当前代码已复制到剪贴板"); } catch (err) { console.error("复制代码失败,请手动复制"); console.error("复制失败!", err); } } else { const textarea = document.createElement("textarea"); textarea.value = text; document.body.appendChild(textarea); textarea.select(); try { document.execCommand("copy"); document.body.removeChild(textarea); message.success("已复制到剪贴板"); } catch (err) { document.body.removeChild(textarea); message.error("复制代码失败,请手动复制"); console.error("无法复制到剪贴板", err); } } }; // 一些图标 const clipboardCheckIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy-check"><path d="m12 15 2 2 4-4"/><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`; const successTip = `<span style="font-size: 0.90em;">复制成功!</span>`; const foldBtn = `<svg t="1726055300369" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2293" width="1em" height="1em"><path d="M232 392L512 672l280-280z" fill="#707070" p-id="2294"></path></svg>`; const newSvgIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2L2 12h10l0 10l10-10h-10z" /></svg>`; export default function codeCopy(): BytemdPlugin { return { rehype: (processor) => processor.use(() => (tree: any) => { visit(tree, "element", (node) => { if (node.tagName === "pre") { const codeNode = node.children.find((child: any) => child.tagName === "code"); const language = codeNode?.properties?.className ?.find((cls: any) => cls.startsWith("language-")) ?.replace("language-", "") || "text"; if (codeNode) { node.children.unshift({ type: "element", tagName: "div", properties: { className: ["code-block-extension-header"], }, children: [ { type: "element", tagName: "div", properties: { className: ["code-block-extension-headerLeft"], }, children: [ { type: "element", tagName: "div", properties: { className: ["code-block-extension-foldBtn"], }, children: [ { type: "text", value: "▼", }, ], }, { type: "element", tagName: "span", properties: { className: ["code-block-extension-lang"], }, children: [{ type: "text", value: language }], }, ], }, { type: "element", tagName: "div", properties: { className: ["code-block-extension-headerRight"], style: "cursor: pointer;", }, children: [ { type: "element", tagName: "div", properties: { className: ["code-block-extension-copyCodeBtn"], style: "filter: invert(0.5); opacity: 0.6;", }, children: [{ type: "text", value: "复制代码" }], }, ], }, ], }); node.properties = { ...node.properties, }; } } }); }), viewerEffect({ markdownBody }) { const copyButtons = markdownBody.querySelectorAll(".code-block-extension-copyCodeBtn"); const foldButtons = markdownBody.querySelectorAll(".code-block-extension-foldBtn"); copyButtons.forEach((button) => { button.addEventListener("click", () => { const pre = button.closest("pre"); const code = pre?.querySelector("code")?.textContent || ""; copyToClipboard(code); const tmp = button.innerHTML; button.innerHTML = clipboardCheckIcon + successTip; setTimeout(() => { button.innerHTML = tmp; }, 1500); }); }); // 处理折叠按钮的点击事件,实现旋转 foldButtons.forEach((foldButton) => { foldButton.addEventListener("click", () => { foldButton.classList.toggle("code-block-extension-fold"); // 切换折叠类名 // 找到最近的 pre 标签 const pre = foldButton.closest("pre"); if (pre) { if (pre.style.paddingTop === "1em") { pre.style.paddingTop = "3em"; // 恢复原来的 padding } else { pre.style.paddingTop = "1em"; // 设置 padding 为 0 } } // 在 pre 标签下找到 code 标签 const code = pre?.querySelector("code"); // 切换 code 标签的类名 if (code) { code.classList.toggle("code-block-extension-fold"); } // 在 pre 标签下找到 code-block-extension-header const headerElement = pre?.querySelector(".code-block-extension-header"); // 切换 code-block-extension-header 的类名 if (headerElement) { headerElement.classList.toggle("code-block-extension-fold"); } }); }); }, }; } ``` 通过 `rehype` 解析和修改 Markdown 的语法树结构,使用 `unist-util-visit` 遍历 `pre` 标签,并向其子元素中插入额外的复制和折叠功能的 HTML 代码块。同时,在页面加载后为按钮元素添加事件监听,实现交互效果。 渲染代码块时,通过为每个 `pre` 标签一个头部区域,该区域包括显示语言类型的文本、复制按钮和折叠按钮。用户点击复制按钮后,代码会被复制到剪贴板,按钮会显示复制成功的提示。 折叠按钮用于收起或展开代码块,点击后代码块的内容和相关样式会发生相应变化。通过 `classList` 和样式的动态切换实现折叠效果。我们需要把以下 css 代码加入封装好的 MDViewer 组件样式中。 ```css /* 修改复制代码栏处的样式 */ .md-viewer .markdown-body pre { position: relative; overflow: auto; line-height: 1.75; padding-top: 3em; } .code-block-extension-header { display: flex; user-select: none; height: 28px; align-items: center; justify-content: space-between; margin-bottom: 3px; position: absolute; top: 0; left: 0; width: 100%; font-size: 1em; background-color: rgb(248, 248, 248); box-shadow: 0px 4px 5px -6px #888888; padding: 0.5em 1em; } .code-block-extension-header.code-block-extension-fold { box-shadow: none; margin-bottom: 0; } .code-block-extension-headerLeft { display: flex; align-items: center; } .code-block-extension-headerLeft > .code-block-extension-foldBtn { display: flex; align-items: center; justify-content: center; width: 10px; height: 10px; color: #707070; /* 初始颜色 */ margin-right: 8px; } .code-block-extension-headerLeft > .code-block-extension-foldBtn:hover { cursor: pointer; color: #1890ff; /* 悬停时变为蓝色 */ } .code-block-extension-headerLeft > .code-block-extension-foldBtn.code-block-extension-fold { transform: rotate(-90deg); } pre > code.code-block-extension-fold { display: none !important; } ``` #### 插件使用: 最后,我们需要将以上写的三个插件,都补充到 `plugins` 中: ```tsx const plugins = [ // 对齐插件 alignPlugin(), gfm({ locale: gfmLocale, }), gemoji(), highlight(), math({ locale: mathLocale, }), mermaid({ locale: mermaidLocale, }), mediumZoom(), allowHtmlTags(), codeCopy(), ]; ``` 注意:这里的 codeCopy() 插件我们添加至 MDViewer 组件的 plugins 即可,否则会影响用户编辑时的体验,影响编辑器的性能。 至此,我们就拥有了一款轻量、简洁、实用的编辑器了!可以随心所欲的集成到任何 React 项目中,一款支持代码高亮、emoji 解析、数学公式、流程图、图片预览放大,对齐设置、标签解析、代码块复制折叠等功能的插件,集成到面试项目中也是不可多得的加分点! <img src="https://pic.code-nav.cn/post_picture/1730577019852419074/MsW1OL2DZfIi0cB4.webp" alt="" width="100%" /> # 结语 目前 [面试鸭](https://mianshiya.com) 已有几百个企业面试题库,8000 多道面试真题,不仅能够帮助大家利用闲暇时间背八股、面试题提升自身外,面试鸭 web 端也运用了多种新颖的技术与功能,如:沉浸式刷题、海报生成、消息系统、用户编辑器内支持调整图片大小等 <img src="https://pic.code-nav.cn/post_picture/1730577019852419074/LzNx6duG88afV3WS.webp" alt="" width="100%" /> <br> <br> 后面我还会持续给大家分享面试鸭中某些功能的实现方式,感兴趣的欢迎各位来面试鸭:[mianshiya.com](https://www.mianshiya.com) 中体验各种功能鸭!遇到 bug 也欢迎各位砸我身上呢!

#资源# #分享# #掘金小册# #技术文档# 掘金小册,优质资源[呲牙][呲牙] 「 掘金小册」等文件 https://www.aliyundrive.com/s/gVmoStAefyC 点击链接保存,或者复制本段内容,打开「阿里云盘」APP ,无需下载极速在线查看,视频原画倍速播放。

下载 APP