• Yjs + Quill 实现文档多人协同编辑器开发(基础+实战)


    致谢

            感谢大家对文章的关注哈,大家提出的无法在不同浏览器协同的问题,经过两天多的学习研究,终于是解决了。目前版本已经正常提到 git 上了,运行脚本:npm run startServer,是通过WebRTC 的形式实现协同(该方案仅支持内网系统,因为webRTC在外网使用需要stun 服务支持,目前还没实现),还有的就是 node/ws/yjsServer.js 也支持以 websocket的形式实现协同。推荐大家使用 websocket 的形式实现,具体的协同方案已经在 vue/components/Edit/yjs.js 文件中列举,供大家选择。如果大家还有问题,欢迎留言沟通交流,大家的肯定是我前进的动力,大家多多点赞支持呀~

    前言

            多人协同开发确实是比较难的知识点,在技术实现上有一定挑战,但随着各种技术库的发展,目前已经有了比较成熟的解决方案。今介绍 Yjs 基于CRDT算法,用于构建自动同步的协作应用程序,与Quill富文本编辑器,快速构建多人协同编辑器。

            前几章是介绍Quill+Yjs的基础,看项目示例的直接前往  整体样例实现 章节。实现的整体效果如下:

    协同编辑数据模型

            想要实现协同开发,就要对数据模型进行约束,目前比较有代表性的协同数据模型为:

    Delta 数据模型:

             Deltas数据模型的实现是Quill.js富文本编辑器,Deltas是一种简单而富有表现力的格式,可以用来描述Quill的内容和改变。这种格式本质上是JSON,是人类可读的,也很容易被机器解析。Deltas可以描述任何Quill文档,包括所有的文本和格式信息,其中没有HTML的歧义和复杂性。

    1. {
    2. ops: [
    3. { insert: 'Gandalf', attributes: { bold: true } },
    4. { insert: ' the ' },
    5. { insert: 'Grey', attributes: { color: '#cccccc' } }
    6. ]
    7. }

    如上Deltas数据,我们解析下:

    { insert: 'Gandalf', attributes: { bold: true } }【插入 Gandalf,并加粗】,   

    { insert: ' the ' },【插入 the 】,

     { insert: 'Grey', attributes: { color: '#cccccc' } }【插入 Grey,并设置颜色 #ccc】,

    因此实现的效果如下(在线版没有颜色我就不加了),他是对每一项要操作的字符串进行属性描述:

     Slate 数据模型:

            而Slate数据模型的实现是Slate.js,Slate.js 是一款支持完全自定义的富文本编辑器,它在可扩展性、可定制性、丰富的 API 和 React 集成方面有着出色的表现。

    1. {
    2. type: 'insert_text',
    3. path: [0, 0],
    4. offset: 15,
    5. text: 'A new string of text to be inserted.',
    6. }

             我就不解析上面的Slate数据模型了,也比较简单。Quill与Slate.js在底层实现上还是有很大差别的,如下,仅是一个简单的文本,两者渲染的DOM结构完全不同:

            Slate.js嵌套的DOM太多了,可能这样才能实现 支持完全自定义,更多定制化功能。但是我更倾向于Quill,因此本文采用Quill来实现。

    协同编辑的问题所在

            协同编辑最大的问题就是如何保持数据一致性?

     这便是协同编辑需要解决的问题。

    数据一致性算法

            OT算法与CRDT 算法应该算是目前比较好的协同算法了,具体的算法实现我也没有深入了解,如果大家有需要,后续会出文章讲解算法部分。

            大家可以看看这篇文章:文档多人协同编辑底层算法是如何实现的?我的开发也受到该作者的启发,写的很好,包括文档编辑锁等协同思想,大家可以去看看。

    Yjs

             在官网的介绍中,Yjs是一个高性能CRDT,用于构建自动同步的协作应用程序。它将其内部CRDT模型公开为可以并发操作的共享数据类型。共享类型类似于常见的数据类型,如Map和Array。它们可以被操纵,在发生更改时触发事件,并在没有合并冲突的情况下自动合并。

            Yjs支持以下的富文本编辑器,可以看出其生态还是非常完善的。

             到此,还是希望大家明确概念哈,Yjs仅是处理协同数据一致性算法的具体实现,我们很容易与Quill的功能相混淆,认为是Yjs提供了所有的技术支持,并不是。Quill才是文本编辑、协同数据的生产者,而Yjs仅是保证了多人的Delta数据一致性!这个很重要的,要分清楚你的操作对象。

            我们还是先搭建Quill + Yjs 协同编辑吧,然后再跟大家介绍API。

    搭建Quill+Yjs协同编辑器

    下载 Quill、Yjs 依赖

    1. // 下载 Quill
    2. npm install quill@1.3.4
    3. // 下载Yjs
    4. npm install yjs

    初始化Quill编辑器 

    1. <script setup>
    2. import Quill from "quill";
    3. import "quill/dist/quill.snow.css"; // 使用了 snow 主题色
    4. import { onMounted } from "vue";
    5. onMounted(() => {
    6. // 获取dom需要在mounted后
    7. new Quill("#edit", {
    8. theme: "snow",
    9. });
    10. });
    11. script>
    12. <style lang="less" scoped>style>

     到这里,Quill编辑器已经配置好了。

    初始化Yjs

            Yjs提供了三种连接形式,websocket rtc dat,我们稍后会介绍websocket形式,rtc是官网的样例,我们先直接用 。

    npm i y-webrtc # or
    npm i y-websocket # or
    npm i y-dat

    下载yjs与quill的连接器:

    npm i y-quill

    1. // 初始化YJS
    2. // A Yjs document holds the shared data
    3. const ydoc = new Y.Doc();
    4. // Define a shared text type on the document(这个是拿到需要协同的 Delta 数据)
    5. const ytext = ydoc.getText("quill");
    6. // 绑定 quill与YJS
    7. const binding = new QuillBinding(ytext, quill);
    8. // 使用webrtc实现连接
    9. const provider = new WebrtcProvider("quill-demo-room", ydoc);

            ytext对象是用于表示文本的共享数据结构。它还支持格式化属性(即粗体和斜体)。Yjs会自动解决共享数据上的并发更改,因此我们不再需要担心冲突的解决。然后我们将ytext与quill编辑器同步,并使用QuillBinding使它们保持同步,几乎所有的编辑器绑定都是这样工作的。

            创建绑定后,直接利用rtc实现数据共享,就能实现协同编辑了:

    封装类

            因为后续的操作都需要使用到quill及yjs对象,考虑封装为类实现:

    1. // 导出Quill实体类
    2. import Quill from "quill";
    3. import "quill/dist/quill.snow.css"; // 使用了 snow 主题色
    4. export class myQuill {
    5. constructor(selector) {
    6. // 初始化 quill 文档操作对象
    7. this.quill = new Quill(selector, {
    8. theme: "snow",
    9. placeholder: "请输入内容...",
    10. });
    11. }
    12. }
    13. // 导出 Yjs 实体类
    14. import * as Y from "yjs";
    15. import { WebrtcProvider } from "y-webrtc";
    16. import { QuillBinding } from "y-quill";
    17. export class myYjs {
    18. // 需要传入绑定对象
    19. constructor(quill) {
    20. // A Yjs document holds the shared data
    21. this.ydoc = new Y.Doc();
    22. // Define a shared text type on the document(这个是拿到需要协同的 Delta 数据)
    23. const ytext = this.ydoc.getText("quill");
    24. // 绑定 quill与YJS
    25. const binding = new QuillBinding(ytext, quill.quill);
    26. // 使用webrtc实现连接
    27. const provider = new WebrtcProvider("quill-demo-room", this.ydoc);
    28. }
    29. }

     直接初始化即可,后续在拿的是对象进行操作:

    1. onMounted(() => {
    2. // 获取dom需要在mounted后
    3. const quill = new myQuill("#edit");
    4. // 初始化YJS
    5. const yjs = new myYjs(quill);
    6. });

    添加用户光标

     我们需要添加用户光标,区分编辑用户:

    npm i quill-cursors

     绑定光标信息:

     

    这样在协同开发时,就能出现用户光标了 ,同时,还支持修改光标用户信息:

    1. // 完善代码 创建自己的光标信息
    2. createAwareness(name) {
    3. let { awareness } = this.provider;
    4. // 定义随机颜色
    5. let color = "#" + Math.random().toString(16).split(".")[1].slice(0, 6);
    6. awareness.setLocalStateField("user", { name, color });
    7. return awareness;
    8. }

    Yjs Shared Types

            Yjs也有自己的数据类型,允许我们通过API进行操作,但是我还是上面所说,这不是Yjs的事情,文档的编辑、删除、更新,都应该是Quill富文本编辑器的事,因此我不会介绍Yjs的API,下面章节会介绍Quill的API。

    yarray.insert(0, ['some content']) // => delta: [{ insert: ['some content'] }]

    Quill Apis

            我们已经搭建了最简单最基础的协同开发编辑器,用到的Yjs仅是做数据绑定,冲突处理是Yjs内部自己实现的,我们不需要过多关注。下面需要介绍Quill的相关API,因为我们编辑的是Quill富文本编辑器,因此,熟悉Quill API是非常重要的。

            Quill支持多种方式格式化,包括UI控件和API调用,UI控件就是顶部的菜单栏,我们重点看API调用的方式:

    Quill菜单栏配置

     Quill支持我们自定义菜单栏,传入什么就显示什么,支持下列属性:

     属性后面的简写,才是tabbar配置项:

     toolbar: [['background']], // 添加背景颜色

      

     有些图标已经不显示了,因此,我们可以使用 iconfont图标,自定义菜单栏,通过调用API实现相同功能。

    向编辑器中插入文本 insertText

    1. quill.insertText(0, 'Hello', 'bold', true);
    2. quill.insertText(5, 'Quill', {
    3. 'color': '#ffff00',
    4. 'italic': true
    5. });

    如何向末尾追加文本呢?

    获取文本编辑器长度 getLength

             检索返回编辑器的内容长度。注意:即使Quill是空的,编辑器仍然有一个‘\n’表示的空行,所以getLength将返回1。

    var length = quill.getLength();
    1. var length = quill.getLength();
    2. // 向末尾追加
    3. quill.insertText(length, "quill.getLength()");

    效果换行了,考虑下原因:即使Quill是空的,编辑器仍然有一个‘\n’表示的空行,因此,

    1. quill.insertText(0, "Hello", "bold", true);
    2. var length = quill.getLength();
    3. // 向末尾追加
    4. quill.insertText(length, "quill.getLength()");

    就被解析为:【'\n'】+'hello' => 【‘hello’,'\n'】 => length=2 (向2 的位置添加文本),【‘hello’,'\n',‘getLength’】。就跟数组的索引跟下标的关系类似,因此,正确的做法是 length -1

     不再换行。

    insertText实际使用中的问题

    1. 仅支持插入字符串:

    源码中,text是需要进行正则匹配去除特殊符号的,因此,不支持传入其他

    2.  getLength 使用需谨慎:

    1. // 测试变量
    2. [1, 2, 3, 4, 5].forEach((i) => {
    3. console.log(i);
    4. quill.insertText(length, i.toString());
    5. });

    上述代码,理论上,应该插入 12345,但是,实际的效果是

    原因是length是实时变化的,因此,动态获取长度能避免很多问题:

    1. [1, 2, 3, 4, 5].forEach((i) => {
    2. console.log(i);
    3. quill.insertText(quill.getLength(), i.toString());
    4. });

     formatting 格式化API

    1. quill.formatText(0, 5, 'bold', true); // 加粗 'hello'
    2. quill.formatText(0, 5, { // 取消加粗 'hello' 并且设置颜色为blue
    3. 'bold': false,
    4. 'color': 'rgb(0, 0, 255)'
    5. });

    api比较简单

    用户选择

            quill.getSelection(focus = false);这个是比较重要的API,可以实现外部API的格式化操作,对用户选中的内容进行单独格式化,可以进行参数传递,控制是否聚焦输入框,不然点击输入框外,就不能选中了

    撤销与重做

    quill.history.undo();

    quill.history.redo();

    整体样例实现

            我们利用上面的知识,做一个完整的案例,来体验一下多人协同编辑吧。

    登录页实现

            我们协同是基于用户体系的,同时协同用户光标也有用户,因此需要登录,才能加入编辑。

    首页实现

     协同编辑页实现

    接口开发

     

             需要初始化 express、ws、socket的服务(ws的服务我们用在Yjs的y-websocket服务上,后面细说),这次使用数据库实现持久化数据存储,webAPI采用SSM的三层分离架构,controller、serviceImpl、xmlMapper分离,在node中,还多了路由模块,因此,数据流向是 :

    axios => node_router =>node_conrtoller => node_service => node_mapper => axios.then()

            有过SSM开发经验的一看就懂了,不懂的,可以琢磨一下,不然看不懂这个,看代码也比较难。详细的接口设计开发部分,我就不展开说了,这是后端的知识,如果大家感兴趣,可以单独出一篇文章,说说前后端的开发,让大家都能成为全栈开发!

    初始化WS服务

            Yjs提供了三种连接模式嘛,ws是可以自己实现服务器,使用也更稳定,因此,使用node创建ws服务,供Yjs调用,实现双向即时通信: 

    1. module.exports = () => {
    2. console.log("等待初始化 WS 服务...");
    3. // 搭建ws服务器
    4. const { WebSocketServer } = require("ws");
    5. const wss = new WebSocketServer({
    6. port: 9000,
    7. });
    8. console.log(" WS 服务初始化成功,连接地址:ws://localhost:9000");
    9. wss.on("connection", (ws, req) => {
    10. console.log("Yjs 客户端连接 ws 服务");
    11. // ws.send("我是服务端"); // 向当前客户端发送消息
    12. });
    13. };

    Yjs客户端调用:

    1. import * as Y from 'yjs'
    2. import { WebsocketProvider } from 'y-websocket'
    3. const doc = new Y.Doc()
    4. const wsProvider = new WebsocketProvider('ws://localhost:1234', 'my-roomname', doc)
    5. wsProvider.on('status', event => {
    6. console.log(event.status) // logs "connected" or "disconnected"
    7. })

    在这里使用监听的目的是根据用户连接状态,决定是否启用本地连接,实现更加稳定的协同编辑,到此,已经完成了所有的静态开发,接口也差不多了,我们来实现关键的协同编辑:

    协同编辑

     我们不使用Quill 原生的tabbar,自定义了icon,通过调用API实现富文本编辑。

     撤销与重做:

    我们实现的思想还是封装的公共类哈:

    1. // MyQuill 类
    2. // 撤销
    3. undo() {
    4. this.quill.history.undo();
    5. }
    6. // 重做
    7. redo() {
    8. this.quill.history.redo();
    9. }

    调用:

    1. // 撤销
    2. case "icon-chexiao":
    3. quill.undo();
    4. break;
    5. // 重做
    6. case "icon-zhongzuo":
    7. quill.redo();
    8. break;

    格式化

    1. // 格式化
    2. format(opt, color) {
    3. // 将加粗\斜体\删除线\下划线\颜色等操作 封装一个函数,因此,就需要先获取样式,才能判断是否已经有样式
    4. // 还需要获取用户的选择,可能是给某些字符添加样式
    5. // 获取用户选择 ** 这里需要传递参数,不然会导致焦点移出编辑器,选中失效,这个 true 非常关键
    6. var range = this.quill.getSelection(true);
    7. if (!range) return console.warn("User cursor is not in editor");
    8. let { index, length } = range; // index 是当前光标的索引,length 表示当前选择的长度
    9. // 获取样式 检索给定范围内文本的所用格式(加粗 斜体都是块作用域,是需要指定长度的,因此,用户没有选择,则默认不作用,不像标题等,是行作用域)
    10. let { bold, italic, strike, underline } = this.quill.getFormat(
    11. index,
    12. length
    13. );
    14. // "icon-cuti": bold,
    15. // "icon-italic": italic,
    16. // "icon-strikethrough": strike,
    17. // "icon-zitixiahuaxian": underline,
    18. // "icon-zitiyanse": color,
    19. // 拿到用户操作的映射,判断有没有当前属性,没有则添加,有,则删除
    20. if (opt === "icon-cuti")
    21. this.quill.formatText(index, length, "bold", !bold);
    22. if (opt === "icon-italic")
    23. this.quill.formatText(index, length, "italic", !italic);
    24. if (opt === "icon-strikethrough")
    25. this.quill.formatText(index, length, "strike", !strike);
    26. if (opt === "icon-zitixiahuaxian")
    27. this.quill.formatText(index, length, "underline", !underline);
    28. if (opt === "color") this.quill.formatText(index, length, "color", color);
    29. }

    实现图片上传

    insertEmbed 向编辑器中插入嵌入式内容,返回一个改变后的Delta对象:

    quill.insertEmbed(10, 'image', 'http://quilljs.com/images/cloud.png');

    因此,我们需要一个图片的服务器地址,才能实现插入图片,下面来说说文件上传:

     前端文件上传无非是两种方式,一个base64 一个FormData(是二进制文件的载体),两种方式都可以在node中解析并保存文件:

    1. // 文件上传
    2. const upload = async (e) => {
    3. // 创建的本地浏览文件,无法实现 quill 中的url请求,需要借助服务器
    4. // let url = window.URL.createObjectURL(files[0]);
    5. // quill.insertEmbed(0, "image", url);
    6. let baseURL = "http://localhost:5000";
    7. let { files } = e.target;
    8. let form = new FormData();
    9. form.append("file", files[0]);
    10. let res = await editUploadFile_API(form);
    11. // 上传成功后,直接拿到地址,添加到编辑器中
    12. if (res.code !== 200) return ElMessage.error(res.msg);
    13. quill.insertEmbed(null, "image", baseURL + res.data);
    14. };

    使用 express-fileupload 中间件,中间件作用在该上传文件之前哈,可以快速解析文件,放在 req.files上,大家也可以使用Multer:

    1. // 上传文件
    2. exports.uploadFile = async (req, res, next) => {
    3. console.log(req.files);
    4. if (req.files === null)
    5. return res.status(400).json({ code: 400, msg: "no file uploaded" });
    6. // 不然转存数据
    7. let { file } = req.files;
    8. let newfilename = file.md5 + "." + file.name.split(".")[1];
    9. let newpath = path.join(process.cwd(), "/public/images/") + newfilename;
    10. // 移动文件到第一参数指定位置 若有错误 返回500
    11. file.mv(newpath, (err) => {
    12. if (err) return res.status(500).json({ msg: "文件上传失败" });
    13. return httpCode(res, 200, "文件上传成功", `/static/images/${newfilename}`);
    14. });
    15. };

     实现效果:

    实现文件共享

    通过分享链接,实现接口数据传递,绑定文件进而实现文件共享:

     跳到页面后,是没有登录的状态,因此进行登录后,返回invited页面进行确认。 考虑 router的特性,将当前路由信息转存到login页面,才能在login页面直接跳转到确认邀请页面:

    1. // 考虑是否登录
    2. const user = JSON.parse(sessionStorage.getItem("user"));
    3. if (to.path !== "/login") {
    4. if (!user) {
    5. ElMessage.error("请先登录");
    6. // 进行数据转存
    7. if (to.matched[0].path === "/invited/:fileid") {
    8. // 向 login 添加信息
    9. let { fileid } = to.params;
    10. return next({ path: "/login", query: { fileid, ...to.query } });
    11. }
    12. return next({ path: "/login" });
    13. }
    14. }

    登录按钮:

    1. if (router.currentRoute.value.query.fileid) {
    2. let { fileid, filename, username } = router.currentRoute.value.query;
    3. return router.push({
    4. path: `/invited/${fileid}`,
    5. query: { filename, username },
    6. });
    7. }
    8. router.push("/home");

    页面开发:

     效果如下:

     

     实现粘贴板:

    1. const execContent = (text) => {
    2. if (navigator.clipboard) {
    3. // clipboard api 复制
    4. navigator.clipboard.writeText(text);
    5. } else {
    6. var textarea = document.createElement("textarea");
    7. document.body.appendChild(textarea);
    8. // 隐藏此输入框
    9. textarea.style.position = "fixed";
    10. textarea.style.clip = "rect(0 0 0 0)";
    11. textarea.style.top = "10px";
    12. // 赋值
    13. textarea.value = text;
    14. // 选中
    15. textarea.select();
    16. // 复制
    17. document.execCommand("copy", true);
    18. // 移除输入框
    19. document.body.removeChild(textarea);
    20. }
    21. };

    文件版本控制

            这里有一个注意事项:

    1. /**
    2. * 版本控制说明
    3. * 1. 客户端一定是永远调用一个接口,因从需要处理是否处于创建状态,
    4. * 2. 根据files 表的 currenthead 当前指针 是否为空 判断是否是第一次创建
    5. * createVersion 中可以直接 next 跳过创建过程
    6. * 3. 更新版本还需要控制时间
    7. * 4. 更新版本的同时,还需要更新文件表信息 currenthead 字段
    8. */
    9. // 更新版本(有一定的时间周期,不然一个文件会有很多版本)
    10. router.post("/updateVersion", versionCtrl.createVersion, fileCtrl.updateFiles);

             创建文件的时候,是没有初始化版本currenthead 字段的,因此,当我们保存的时候,需要先判断当前是否有版本,没有则正常创建;如果已经有了版本,则需要判断当前版本是否超过时限,不然保存一次创建一个版本是不合理的。

            客户端初始化quill的时候,需要延时判断当前编辑器是否有内容 ,不能直接覆盖,因为可能别的编辑者正在编辑,会导致内容覆盖,还涉及到Delta的数据转换:

    1. // 初始化文本编辑器
    2. init(data) {
    3. // 处理数据(最大程度还原数据)
    4. let _T = data
    5. .replace(/[\r]/g, "#r#")
    6. .replace(/[\n]/g, "#n#")
    7. .replace(/[\t]/g, "#t#");
    8. let delta = JSON.parse(_T);
    9. /**
    10. * 需要先处理特殊字符,不然转不了JSON
    11. * 然后再根据特性,转回来,不然该换行的地方没有换行
    12. */
    13. delta.forEach((i, index) => {
    14. i.insert = i.insert
    15. .toString()
    16. .replace(/#n#/g, "\n")
    17. .replace(/#r#/g, "\r")
    18. .replace(/#t#/g, "\t");
    19. });
    20. this.quill.setContents(delta);
    21. }

    这里有一个小问题哈:Emoji表情是不可以直接存再 UTF8的数据库中,需要做转换,不然报错

    1. // 表情转码
    2. export const utf16toEntities = (str) => {
    3. const patt = /[\ud800-\udbff][\udc00-\udfff]/g; // 检测utf16字符正则
    4. str = str.replace(patt, (char) => {
    5. let H;
    6. let L;
    7. let code;
    8. let s;
    9. if (char.length === 2) {
    10. H = char.charCodeAt(0); // 取出高位
    11. L = char.charCodeAt(1); // 取出低位
    12. code = (H - 0xd800) * 0x400 + 0x10000 + L - 0xdc00; // 转换算法
    13. s = `&#${code};`;
    14. } else {
    15. s = char;
    16. }
    17. return s;
    18. });
    19. return str;
    20. };
    21. // 表情解码
    22. export const entitiestoUtf16 = (strObj) => {
    23. const patt = /&#\d+;/g;
    24. const arr = strObj.match(patt) || [];
    25. let H;
    26. let L;
    27. let code;
    28. for (let i = 0; i < arr.length; i += 1) {
    29. code = arr[i];
    30. code = code.replace("&#", "").replace(";", "");
    31. // 高位
    32. H = Math.floor((code - 0x10000) / 0x400) + 0xd800;
    33. // 低位
    34. L = ((code - 0x10000) % 0x400) + 0xdc00;
    35. code = `&#${code};`;
    36. const s = String.fromCharCode(H, L);
    37. strObj = strObj.replace(code, s);
    38. }
    39. return strObj;
    40. };

     初始化 socket 服务

            socket服务这块我已经讲了很多次了,就不细说了,不过这次使用的是 room ,更贴合房间概念,只有同一个编辑文件中才能交流。可以细看代码。

    1. io.on("connection", (socket) => {
    2. socket.join("room 237");
    3. console.log(socket.rooms); // Set { , "room 237" }
    4. socket.join(["room 237", "room 238"]);
    5. io.to("room 237").emit("a new user has joined the room"); // broadcast to everyone in the room
    6. });

    实现效果如下:

    整体效果

    可优化点 

    文件导入、删除、回收站、文档搜索等,项目基本上已经是完整的项目了,vue+node+mysql,也有数据存储,大家可以继续创作。

    总结

            从Yjs的应用到Quill编辑器的API介绍,算是比较完整的讲述了协同编辑的思想与实现方案,同时,拓展了MySQL的应用,这个项目还是比较不错的,大家可以 fork 继续创作,最后,大家多多支持呀,点赞收藏哦

  • 相关阅读:
    【高等数学】常用函数的n阶导数
    美团二面:如何保证Redis与Mysql双写一致性?连续两个面试问到了!
    [vite] 带看文档配置postcss-pxtorem
    [C++随笔录] vector使用
    集成多元算法,打造高效字面文本相似度计算与匹配搜索解决方案,助力文本匹配冷启动[BM25、词向量、SimHash、Tfidf、SequenceMatcher]
    Qt之QProcess(一)运行cmd命令
    FlinkCDC基础篇章1-安装使用
    Spring Boot 实现字段唯一校验
    单元测试界的高富帅Pytest框架,手把手教学,从入门到精通
    【基本算法题-2022.7.31】12. 激光炸弹
  • 原文地址:https://blog.csdn.net/weixin_47746452/article/details/132402713